Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- add `shell.env` to inject environment variables into spawned shells
- add `--maximized` and `--fullscreen` flags to start the window in that mode
- persist and restore window size, maximized, and fullscreen state per session
- REP (`CSI Ps b`): repeat the last printed character `Ps` times
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ cursor_blink_ms = 500

[shell]
# program = "/bin/zsh" # defaults to $SHELL
# env = [["EDITOR", "nvim"]] # injected into every spawned shell; TOML-only, not editable in the in-app config panel

[terminal]
scrollback_lines = 10000 # minimum 100
Expand Down
1 change: 1 addition & 0 deletions assets/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ detect_urls = true

[shell]
# program = "/bin/zsh"
# env = [["EDITOR", "nvim"], ["LANG", "en_US.UTF-8"]]

[terminal]
scrollback_lines = 10000
Expand Down
4 changes: 4 additions & 0 deletions doc/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ Screenshot capture is a two-step flow: region selection followed by a name promp
- Sections: `[font]`, `[window]`, `[shell]`, `[terminal]`, `[logging]`,
`[status_bar]`, `[colors]`, `[theme]`.
- In-process TUI config panel: `Ctrl+,` (editable fields, saved on Enter).
- `shell.env` injects environment variables into every spawned shell, in the
form `env = [["VAR", "value"], ["VAR2", "value2"]]`. It is TOML-only and is
not editable in the in-app config panel.

| Section | Key | Type | Default |
|---|---|---|---|
Expand All @@ -223,6 +226,7 @@ Screenshot capture is a two-step flow: region selection followed by a name promp
| window | detect_urls | bool | `true` |
| terminal | scrollback_lines | uint | `10000` (min 100) |
| shell | program | string? | `$SHELL` |
| shell | env | array | `[]` |
| logging | auto_log | bool | `false` |
| logging | log_dir | string | `""` (→ `~/.mmterm`) |
| status_bar | right | string | `""` |
Expand Down
2 changes: 2 additions & 0 deletions src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,7 @@ impl AppState {
pty_tx,
"/bin/true",
None,
&[],
Box::new(|| {}),
)
.expect("PTY spawn failed");
Expand Down Expand Up @@ -1102,6 +1103,7 @@ impl AppState {
pty_tx,
"/bin/true",
None,
&[],
Box::new(|| {}),
)
.expect("PTY spawn failed");
Expand Down
30 changes: 30 additions & 0 deletions src/config/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,33 @@ fn general_update_defaults() {
assert!(cfg.general.shell_integration); // OSC 133 prompt/exit badges on by default
assert!(cfg.general.desktop_notifications); // OSC 777 desktop notifications on by default
}

#[test]
fn shell_env_defaults_empty() {
let cfg = ShellConfig::default();
assert!(cfg.env.is_empty());
}

#[test]
fn shell_env_deserializes_pairs() {
let toml = r#"env = [["EDITOR", "nvim"], ["LANG", "en_US.UTF-8"]]"#;
let cfg: ShellConfig = toml::from_str(toml).unwrap();
assert_eq!(
cfg.env,
vec![
("EDITOR".to_string(), "nvim".to_string()),
("LANG".to_string(), "en_US.UTF-8".to_string()),
]
);
}

#[test]
fn shell_env_round_trips() {
let cfg = ShellConfig {
program: Some("/bin/zsh".to_string()),
env: vec![("FOO".to_string(), "bar".to_string())],
};
let serialized = toml::to_string(&cfg).unwrap();
let back: ShellConfig = toml::from_str(&serialized).unwrap();
assert_eq!(cfg, back);
}
4 changes: 4 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ pub struct WindowConfig {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ShellConfig {
pub program: Option<String>,
/// Environment variables injected into every spawned shell.
/// TOML format: `env = [["VAR", "value"], ["VAR2", "value2"]]`.
#[serde(default)]
pub env: Vec<(String, String)>,
}

fn default_auto_log() -> bool {
Expand Down
9 changes: 8 additions & 1 deletion src/config/tui_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ pub struct ConfigPanel {
/// Section names that are currently collapsed (body fields hidden).
pub collapsed: HashSet<&'static str>,
pub version: &'static str,
/// `shell.env` is TOML-only (not an editable field); carry it through the
/// panel round-trip so saving from the TUI does not wipe it.
pub shell_env: Vec<(String, String)>,
}

impl ConfigPanel {
Expand Down Expand Up @@ -313,6 +316,7 @@ impl ConfigPanel {
status: None,
collapsed,
version: env!("MMTERM_VERSION"),
shell_env: cfg.shell.env.clone(),
}
}

Expand Down Expand Up @@ -688,7 +692,10 @@ impl ConfigPanel {
inactive_dim,
detect_urls,
},
shell: ShellConfig { program: shell },
shell: ShellConfig {
program: shell,
env: self.shell_env.clone(),
},
terminal: TerminalConfig { scrollback_lines },
logging: LogConfig { auto_log, log_dir },
colors: ColorsConfig {
Expand Down
1 change: 1 addition & 0 deletions src/config/tui_config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ fn distinct_config() -> Config {
},
shell: ShellConfig {
program: Some("/bin/xyzsh".into()),
env: Vec::new(),
},
terminal: TerminalConfig {
scrollback_lines: 4097,
Expand Down
1 change: 1 addition & 0 deletions src/drain_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ fn make_pane_entry() -> (PaneEntry, crossbeam_channel::Sender<Vec<u8>>) {
pty_tx,
"/bin/true",
None,
&[],
Box::new(|| {}),
)
.expect("PTY spawn failed");
Expand Down
2 changes: 2 additions & 0 deletions src/pane_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ impl App {
// Wakeup fires from the parser thread after each parsed batch.
// Uses the app-level wakeup_pending flag (same Arc the main thread reads)
// so the parser can cooperatively yield when the render thread is behind.
let env = self.state.config.shell.env.clone();
let proxy = self.proxy.clone();
let app_wakeup_pending = Arc::clone(&self.wakeup_pending);
let wakeup = Box::new(move || {
Expand All @@ -75,6 +76,7 @@ impl App {
pty_tx,
&shell,
cwd.as_ref(),
&env,
// PTY reader thread no longer calls wakeup; parser thread handles it.
Box::new(|| {}),
) {
Expand Down
6 changes: 5 additions & 1 deletion src/pty/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ impl PtySession {
#[allow(dead_code)]
pub fn spawn(cols: u16, rows: u16, output_tx: Sender<Vec<u8>>) -> anyhow::Result<Self> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
Self::spawn_with_shell(cols, rows, output_tx, &shell, None, Box::new(|| {}))
Self::spawn_with_shell(cols, rows, output_tx, &shell, None, &[], Box::new(|| {}))
}

pub fn spawn_with_shell(
Expand All @@ -67,6 +67,7 @@ impl PtySession {
output_tx: Sender<Vec<u8>>,
shell: &str,
cwd: Option<&PathBuf>,
env: &[(String, String)],
wakeup: Box<dyn Fn() + Send + 'static>,
) -> anyhow::Result<Self> {
let pty_system = NativePtySystem::default();
Expand All @@ -81,6 +82,9 @@ impl PtySession {

let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
for (k, v) in env {
cmd.env(k, v);
}
if let Some(dir) = cwd {
cmd.cwd(dir);
}
Expand Down
60 changes: 52 additions & 8 deletions src/pty/session_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ fn libc_eio() -> i32 {
#[test]
fn spawn_with_shell_succeeds_with_bin_true() {
let (tx, _rx) = unbounded();
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, Box::new(|| {}));
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, &[], Box::new(|| {}));
assert!(
session.is_ok(),
"spawn_with_shell failed: {:?}",
Expand All @@ -44,16 +44,17 @@ fn spawn_with_shell_succeeds_with_bin_true() {
#[test]
fn write_bytes_after_spawn_does_not_panic() {
let (tx, _rx) = unbounded();
let mut session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, Box::new(|| {}))
.expect("spawn failed");
let mut session =
PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, &[], Box::new(|| {}))
.expect("spawn failed");
// Writing to a live shell; ignore errors (shell may exit before write).
let _ = session.write_input(b"exit\n");
}

#[test]
fn resize_after_spawn_does_not_panic() {
let (tx, _rx) = unbounded();
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, Box::new(|| {}))
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, &[], Box::new(|| {}))
.expect("spawn failed");
let result = session.resize(120, 40);
assert!(result.is_ok(), "resize failed: {:?}", result.err());
Expand All @@ -70,14 +71,15 @@ fn spawn_default_uses_env_shell() {
fn spawn_with_cwd_sets_working_directory() {
let (tx, _rx) = unbounded();
let cwd = std::path::PathBuf::from("/tmp");
let result = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", Some(&cwd), Box::new(|| {}));
let result =
PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", Some(&cwd), &[], Box::new(|| {}));
assert!(result.is_ok(), "spawn with cwd failed: {:?}", result.err());
}

#[test]
fn cwd_returns_path_or_none_after_spawn() {
let (tx, _rx) = unbounded();
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, Box::new(|| {}))
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, &[], Box::new(|| {}))
.expect("spawn failed");
// May return None on non-Linux; just assert no panic.
let _ = session.cwd();
Expand All @@ -92,7 +94,7 @@ fn cwd_returns_path_or_none_after_spawn() {
#[test]
fn reader_thread_exits_on_pty_eof() {
let (tx, rx) = unbounded::<Vec<u8>>();
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, Box::new(|| {}))
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, &[], Box::new(|| {}))
.expect("spawn failed");
// Keep the session (and thus the master) alive until the child has exited.
let deadline = Instant::now() + Duration::from_secs(5);
Expand All @@ -111,6 +113,48 @@ fn reader_thread_exits_on_pty_eof() {
drop(session);
}

/// An environment variable passed via `env` must be visible to the spawned
/// shell. We inject `MMTERM_TEST_ENV=hello`, then ask the shell to print it and
/// exit; the value must appear in the PTY output within the deadline.
#[test]
fn env_vars_are_injected_into_shell() {
let (tx, rx) = unbounded::<Vec<u8>>();
let env = vec![("MMTERM_TEST_ENV".to_string(), "hello".to_string())];
let mut session =
PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, &env, Box::new(|| {}))
.expect("spawn failed");
session
.write_input(b"printf '%s' \"$MMTERM_TEST_ENV\"; exit\n")
.expect("write failed");

let deadline = Instant::now() + Duration::from_secs(5);
let mut output = Vec::new();
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(bytes) => {
output.extend_from_slice(&bytes);
if String::from_utf8_lossy(&output).contains("hello") {
break;
}
}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
}
assert!(
Instant::now() < deadline,
"env var not observed in PTY output within 5s; got: {:?}",
String::from_utf8_lossy(&output)
);
}

assert!(
String::from_utf8_lossy(&output).contains("hello"),
"expected injected env var value 'hello' in output, got: {:?}",
String::from_utf8_lossy(&output)
);
drop(session);
}

/// Verify that a child process does not become a zombie after it exits.
///
/// Without the reaper thread in `spawn_with_shell`, every shell spawned for a
Expand All @@ -126,7 +170,7 @@ fn reader_thread_exits_on_pty_eof() {
#[cfg(target_os = "linux")]
fn no_zombie_after_child_exits() {
let (tx, _rx) = unbounded();
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, Box::new(|| {}))
let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, &[], Box::new(|| {}))
.expect("spawn failed");

let pid = match session.pid() {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/overlays_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fn make_panel(value: &str, kind: FieldKind) -> ConfigPanel {
status: None,
collapsed: HashSet::new(),
version: "",
shell_env: Vec::new(),
}
}

Expand Down Expand Up @@ -331,6 +332,7 @@ fn make_section_panel(collapsed: bool) -> ConfigPanel {
status: None,
collapsed: c,
version: "",
shell_env: Vec::new(),
}
}

Expand Down