diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..beae0be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- add `shell.startup_command` to run a command in each new pane - 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 diff --git a/README.md b/README.md index c55a473..09575d7 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ cursor_blink_ms = 500 [shell] # program = "/bin/zsh" # defaults to $SHELL +# startup_command = "ssh myserver" # run in each new pane (not on session restore) [terminal] scrollback_lines = 10000 # minimum 100 diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..6ca70c6 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -14,6 +14,7 @@ detect_urls = true [shell] # program = "/bin/zsh" +# startup_command = "ssh myserver" [terminal] scrollback_lines = 10000 diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..a1006f8 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -223,11 +223,16 @@ 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 | startup_command | string? | `none` | | logging | auto_log | bool | `false` | | logging | log_dir | string | `""` (→ `~/.mmterm`) | | status_bar | right | string | `""` | | theme | name | string | `"default"` | +`shell.startup_command`, when set, is written to every newly spawned interactive +pane (new tab or split) right after the shell starts. It is **not** re-run for +panes restored from a saved session. + ### Themes Themes define all terminal and UI colors in a single `.toml` file. diff --git a/src/config/config_test.rs b/src/config/config_test.rs index 4bd6877..ba3a8ad 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -283,3 +283,39 @@ 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 startup_command_defaults_to_none_when_missing() { + let toml = r###" +[font] +family = "Mono" +size = 14.0 +[window] +width = 800 +height = 600 +title = "t" +cursor_blink_ms = 500 +[shell] +program = "/bin/zsh" +[colors] +background = "#000000" +foreground = "#ffffff" +cursor = "#ffffff" +selection = "#333333" +palette = [] +"###; + let cfg: Config = toml::from_str(toml).expect("parse failed"); + assert!(cfg.shell.startup_command.is_none()); +} + +#[test] +fn startup_command_round_trips() { + let mut cfg = Config::default(); + cfg.shell.startup_command = Some("ssh myserver".to_string()); + let serialized = toml::to_string_pretty(&cfg).expect("serialize failed"); + let parsed: Config = toml::from_str(&serialized).expect("parse failed"); + assert_eq!( + parsed.shell.startup_command, + Some("ssh myserver".to_string()) + ); +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 571c12a..c6f4073 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -163,6 +163,8 @@ pub struct WindowConfig { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ShellConfig { pub program: Option, + #[serde(default)] + pub startup_command: Option, } fn default_auto_log() -> bool { diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..69866c7 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -33,6 +33,7 @@ const F_AUTO_UPDATE_CHECK: usize = 37; const F_AUTO_UPDATE_INSTALL: usize = 38; const F_SHELL_INTEGRATION: usize = 39; const F_DESKTOP_NOTIFICATIONS: usize = 40; +const F_STARTUP_COMMAND: usize = 41; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -301,6 +302,13 @@ impl ConfigPanel { kind: FieldKind::Bool, section: None, }); + fields.push(Field { + label: "Startup Command", + hint: "command run in each new pane (empty = none)", + value: cfg.shell.startup_command.clone().unwrap_or_default(), + kind: FieldKind::OptText, + section: None, + }); let mut collapsed = HashSet::new(); collapsed.insert("Palette"); @@ -628,6 +636,10 @@ impl ConfigPanel { let s = get(F_SHELL); if s.is_empty() { None } else { Some(s) } }; + let startup_command = { + let s = get(F_STARTUP_COMMAND); + if s.is_empty() { None } else { Some(s) } + }; let scrollback_lines = get(F_SCROLLBACK) .parse::() @@ -688,7 +700,10 @@ impl ConfigPanel { inactive_dim, detect_urls, }, - shell: ShellConfig { program: shell }, + shell: ShellConfig { + program: shell, + startup_command, + }, terminal: TerminalConfig { scrollback_lines }, logging: LogConfig { auto_log, log_dir }, colors: ColorsConfig { diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index 5f8381b..7086ecf 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -10,8 +10,8 @@ fn make_panel() -> ConfigPanel { #[test] fn from_config_has_correct_field_count() { let panel = make_panel(); - // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify = 41 - assert_eq!(panel.fields.len(), 41); + // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify + 1 startup_command = 42 + assert_eq!(panel.fields.len(), 42); } #[test] @@ -308,6 +308,7 @@ fn distinct_config() -> Config { }, shell: ShellConfig { program: Some("/bin/xyzsh".into()), + startup_command: None, }, terminal: TerminalConfig { scrollback_lines: 4097, @@ -375,6 +376,7 @@ fn field_index_sanity() { F_AUTO_UPDATE_INSTALL, F_SHELL_INTEGRATION, F_DESKTOP_NOTIFICATIONS, + F_STARTUP_COMMAND, ]; occupied.extend((0..16).map(|i| F_PALETTE + i)); occupied.sort_unstable(); @@ -455,6 +457,28 @@ fn build_config_shell_nonempty_becomes_some() { } } +#[test] +fn build_config_startup_command_empty_becomes_none() { + let mut panel = make_panel(); + panel.fields[F_STARTUP_COMMAND].value = String::new(); + if let ConfigAction::Save(cfg) = panel.save() { + assert!(cfg.shell.startup_command.is_none()); + } else { + panic!("expected Save action"); + } +} + +#[test] +fn build_config_startup_command_nonempty_becomes_some() { + let mut panel = make_panel(); + panel.fields[F_STARTUP_COMMAND].value = "ssh myserver".to_string(); + if let ConfigAction::Save(cfg) = panel.save() { + assert_eq!(cfg.shell.startup_command, Some("ssh myserver".to_string())); + } else { + panic!("expected Save action"); + } +} + // ── Validation helpers ──────────────────────────────────────────────────────── #[test] @@ -680,8 +704,8 @@ fn palette_collapsed_by_default() { #[test] fn visible_indices_hides_palette_body() { let panel = make_panel(); - // 41 total - 15 palette body fields = 26 visible - assert_eq!(panel.visible_indices().len(), 26); + // 42 total - 15 palette body fields = 27 visible + assert_eq!(panel.visible_indices().len(), 27); } #[test] @@ -690,7 +714,7 @@ fn toggle_on_palette_header_expands() { panel.selected = F_PALETTE; panel.toggle_collapse(); assert!(!panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 41); + assert_eq!(panel.visible_indices().len(), 42); } #[test] @@ -700,7 +724,7 @@ fn toggle_twice_restores_collapsed() { panel.toggle_collapse(); panel.toggle_collapse(); assert!(panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 26); + assert_eq!(panel.visible_indices().len(), 27); } #[test] @@ -755,10 +779,10 @@ fn move_up_skips_collapsed_palette() { #[test] fn move_down_at_last_visible_clamps() { let mut panel = make_panel(); - // F_DESKTOP_NOTIFICATIONS is the last field and is always visible - panel.selected = F_DESKTOP_NOTIFICATIONS; + // F_STARTUP_COMMAND is the last field and is always visible + panel.selected = F_STARTUP_COMMAND; panel.handle_down(); - assert_eq!(panel.selected, F_DESKTOP_NOTIFICATIONS); + assert_eq!(panel.selected, F_STARTUP_COMMAND); } #[test] diff --git a/src/pane_ops.rs b/src/pane_ops.rs index 7f57df5..949d603 100644 --- a/src/pane_ops.rs +++ b/src/pane_ops.rs @@ -24,6 +24,7 @@ impl App { tab_idx: usize, rect: [u32; 4], cwd: Option, + run_startup: bool, ) -> Option { let id = self.state.next_pane_id; self.state.next_pane_id += 1; @@ -109,6 +110,17 @@ impl App { metrics, }, ); + // Run the configured startup command only for interactive new + // panes (new tab / split), never for panes restored from a + // saved session — restore callers pass `run_startup = false`. + if run_startup + && let Some(cmd) = self.state.config.shell.startup_command.as_deref() + && !cmd.is_empty() + && let Some(entry) = self.state.tabs[tab_idx].panes.get_mut(&id) + && let Err(e) = entry.pty.write_input(format!("{cmd}\n").as_bytes()) + { + log::warn!("Failed to write startup command to pane {id}: {e}"); + } Some(id) } Err(e) => { @@ -125,7 +137,7 @@ impl App { .get(self.state.active_tab) .and_then(|t| t.panes.get(&t.active)) .and_then(|e| e.pty.cwd()); - self.spawn_pane_into(self.state.active_tab, rect, cwd) + self.spawn_pane_into(self.state.active_tab, rect, cwd, true) } pub(crate) fn new_tab(&mut self, win_w: u32, win_h: u32) { @@ -157,7 +169,7 @@ impl App { passthrough: false, mode: InputMode::Insert, }); - match self.spawn_pane_into(tab_idx, initial_rect, cwd) { + match self.spawn_pane_into(tab_idx, initial_rect, cwd, true) { Some(id) => { self.state.tabs[tab_idx].layout = Layout::new(id, win_w, win_h); self.state.tabs[tab_idx].active = id; diff --git a/src/restore.rs b/src/restore.rs index b8c4132..9fad928 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -102,7 +102,7 @@ impl App { } else { Some(home.clone()) }; - self.spawn_pane_into(tab_idx, rect, cwd_opt) + self.spawn_pane_into(tab_idx, rect, cwd_opt, false) }) .collect(); if !self.restore_tab_layout(tab_idx, tab_sess, &slot_to_id, rect, win_w, win_h) { @@ -176,7 +176,7 @@ impl App { // No saved pane spawned (empty tab, or every spawn failed): try one fresh // fallback pane; if even that fails, drop the empty tab so we never leave a // ghost tab with no panes. - match self.spawn_pane_into(tab_idx, rect, None) { + match self.spawn_pane_into(tab_idx, rect, None, false) { Some(id) => { self.state.tabs[tab_idx].layout = Layout::new(id, win_w, win_h); self.state.tabs[tab_idx].active = id;