gardesk/garterm / ccbfec2

Browse files

add Lua scripting: sessions, function keybinds, OSC 133 startup commands

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ccbfec2aff71eab9915a1338cd2dde674a21b276
Parents
be0f637
Tree
c85b375

10 changed files

StatusFile+-
M garterm-ipc/src/lib.rs 18 2
M garterm/src/app.rs 224 10
M garterm/src/config/keybinds.rs 8 0
M garterm/src/config/lua.rs 5 0
M garterm/src/config/mod.rs 2 0
A garterm/src/config/runtime.rs 691 0
M garterm/src/terminal/mod.rs 41 0
M garterm/src/ui/manager.rs 36 3
M garterm/src/ui/pane.rs 68 0
M garterm/src/ui/tab.rs 33 2
garterm-ipc/src/lib.rsmodified
@@ -10,7 +10,14 @@ pub enum Command {
1010
         exec: Option<String>,
1111
     },
1212
     /// Create new tab in focused window
13
-    NewTab { cwd: Option<String> },
13
+    NewTab {
14
+        cwd: Option<String>,
15
+        /// Startup command to run after shell prompt
16
+        #[serde(default, rename = "exec")]
17
+        startup_cmd: Option<String>,
18
+        #[serde(default)]
19
+        title: Option<String>,
20
+    },
1421
     /// Close current tab
1522
     CloseTab,
1623
     /// Switch to next tab
@@ -20,7 +27,14 @@ pub enum Command {
2027
     /// Switch to specific tab
2128
     SwitchTab { index: usize },
2229
     /// Split focused pane
23
-    Split { direction: String },
30
+    Split {
31
+        direction: String,
32
+        #[serde(default)]
33
+        cwd: Option<String>,
34
+        /// Startup command to run after shell prompt
35
+        #[serde(default, rename = "exec")]
36
+        startup_cmd: Option<String>,
37
+    },
2438
     /// Close focused pane
2539
     ClosePane,
2640
     /// Focus pane in direction
@@ -29,6 +43,8 @@ pub enum Command {
2943
     ResizePane { direction: String, amount: i32 },
3044
     /// Send text to focused terminal
3145
     SendText { text: String },
46
+    /// Load a named session from config
47
+    LoadSession { name: String },
3248
     /// Get terminal info
3349
     GetInfo,
3450
     /// Reload configuration
garterm/src/app.rsmodified
@@ -1,4 +1,4 @@
1
-use crate::config::{Action, Config, KeybindSet, Modifiers as ConfigModifiers};
1
+use crate::config::{Action, Config, KeybindSet, LuaRuntime, Modifiers as ConfigModifiers, TerminalCommand};
22
 use crate::input::{Clipboard, KeyboardHandler, MouseButton, MouseEvent, MouseHandler, Selection, SelectionMode};
33
 use crate::ipc::IpcServer;
44
 use crate::pty::{PtySize, ReceivedSignal, SignalHandler};
@@ -38,6 +38,8 @@ pub struct App {
3838
     shell: String,
3939
     /// Working directory for new panes
4040
     cwd: Option<std::path::PathBuf>,
41
+    /// Lua runtime for scripting (callbacks, sessions)
42
+    lua_runtime: Option<LuaRuntime>,
4143
 }
4244
 
4345
 impl App {
@@ -129,8 +131,8 @@ impl App {
129131
         let clipboard = Clipboard::new(&conn, window.id())?;
130132
 
131133
         // Load keybindings from config
132
-        let keybinds = config.keybindings();
133
-        info!("Loaded {} keybindings", keybinds.iter().count());
134
+        let mut keybinds = config.keybindings();
135
+        info!("Loaded {} keybindings from config", keybinds.iter().count());
134136
 
135137
         // Start IPC server
136138
         let ipc = match IpcServer::new() {
@@ -141,6 +143,22 @@ impl App {
141143
             }
142144
         };
143145
 
146
+        // Initialize Lua runtime for scripting
147
+        let lua_runtime = match LuaRuntime::new() {
148
+            Ok(runtime) => {
149
+                if let Err(e) = runtime.load() {
150
+                    tracing::warn!("Lua config error: {}", e);
151
+                }
152
+                // Merge Lua keybinds (function callbacks, sessions) into keybind set
153
+                runtime.merge_keybinds(&mut keybinds);
154
+                Some(runtime)
155
+            }
156
+            Err(e) => {
157
+                tracing::warn!("Failed to create Lua runtime: {}", e);
158
+                None
159
+            }
160
+        };
161
+
144162
         Ok(Self {
145163
             window,
146164
             renderer,
@@ -159,6 +177,7 @@ impl App {
159177
             height: actual_height,
160178
             shell: config.general.shell.clone(),
161179
             cwd: config.general.working_directory.clone(),
180
+            lua_runtime,
162181
         })
163182
     }
164183
 
@@ -223,6 +242,16 @@ impl App {
223242
                     if pane.terminal.take_bell() {
224243
                         tracing::debug!("Bell!");
225244
                     }
245
+
246
+                    // Handle startup command: check for OSC 133 prompt ready
247
+                    if pane.terminal.take_prompt_ready() {
248
+                        pane.on_prompt_ready();
249
+                    }
250
+
251
+                    // Check startup command deadline (fallback for shells without OSC 133)
252
+                    if pane.has_pending_startup_cmd() {
253
+                        pane.check_startup_deadline();
254
+                    }
226255
                 }
227256
             }
228257
 
@@ -444,10 +473,162 @@ impl App {
444473
                 Ok(false)
445474
             }
446475
 
476
+            // Lua scripting
477
+            Action::LuaCallback(index) => {
478
+                if let Some(ref runtime) = self.lua_runtime {
479
+                    if let Err(e) = runtime.execute_callback(*index) {
480
+                        tracing::error!("Lua callback error: {}", e);
481
+                    }
482
+                    // Process any pending terminal commands from the callback
483
+                    self.process_lua_commands()?;
484
+                }
485
+                Ok(true)
486
+            }
487
+            Action::LoadSession(name) => {
488
+                self.load_session(name)?;
489
+                Ok(true)
490
+            }
491
+
447492
             Action::None => Ok(false),
448493
         }
449494
     }
450495
 
496
+    /// Process pending Lua commands from callbacks
497
+    fn process_lua_commands(&mut self) -> Result<()> {
498
+        let Some(ref runtime) = self.lua_runtime else { return Ok(()) };
499
+
500
+        let commands = runtime.take_pending_commands();
501
+        for cmd in commands {
502
+            match cmd {
503
+                TerminalCommand::NewTab { cwd, cmd, title } => {
504
+                    let cwd_path = cwd.map(std::path::PathBuf::from);
505
+                    self.tabs.new_tab_with_command(
506
+                        self.width,
507
+                        self.height,
508
+                        cwd_path.as_deref(),
509
+                        cmd.as_deref(),
510
+                    )?;
511
+                    // TODO: Set title if provided (currently set via OSC title sequence from shell)
512
+                    if let Some(_title) = title {
513
+                        // Will be set via OSC title sequence from shell
514
+                    }
515
+                    self.tabs.relayout(self.width, self.height)?;
516
+                    self.tabs.mark_all_dirty();
517
+                }
518
+                TerminalCommand::Split { direction, cwd, cmd } => {
519
+                    let cwd_path = cwd.map(std::path::PathBuf::from);
520
+                    match direction.to_lowercase().as_str() {
521
+                        "horizontal" | "h" => {
522
+                            self.tabs.split_horizontal_with_command(cwd_path.as_deref(), cmd.as_deref())?
523
+                        }
524
+                        _ => {
525
+                            self.tabs.split_vertical_with_command(cwd_path.as_deref(), cmd.as_deref())?
526
+                        }
527
+                    };
528
+                    self.tabs.relayout(self.width, self.height)?;
529
+                    self.tabs.mark_all_dirty();
530
+                }
531
+                TerminalCommand::SendText { pane_id: _, text } => {
532
+                    // TODO: Support pane_id targeting
533
+                    if let Some(pane) = self.tabs.focused_pane_mut() {
534
+                        pane.write_pty(text.as_bytes())?;
535
+                    }
536
+                }
537
+                TerminalCommand::CloseTab { tab_id: _ } => {
538
+                    self.tabs.close_tab();
539
+                    self.tabs.relayout(self.width, self.height)?;
540
+                    self.tabs.mark_all_dirty();
541
+                }
542
+                TerminalCommand::ClosePane { pane_id: _ } => {
543
+                    if self.tabs.close_pane() {
544
+                        self.tabs.relayout(self.width, self.height)?;
545
+                        self.tabs.mark_all_dirty();
546
+                    }
547
+                }
548
+                TerminalCommand::FocusTab { index } => {
549
+                    self.tabs.switch_to_tab(index);
550
+                    self.tabs.mark_all_dirty();
551
+                }
552
+                TerminalCommand::FocusPane { pane_id: _ } => {
553
+                    // TODO: Support direct pane focusing by ID
554
+                }
555
+                TerminalCommand::FocusDirection { direction } => {
556
+                    let dir = match direction.to_lowercase().as_str() {
557
+                        "up" => crate::ui::Direction::Up,
558
+                        "down" => crate::ui::Direction::Down,
559
+                        "left" => crate::ui::Direction::Left,
560
+                        _ => crate::ui::Direction::Right,
561
+                    };
562
+                    self.tabs.focus_direction(dir, self.width, self.height);
563
+                    self.tabs.mark_all_dirty();
564
+                }
565
+                TerminalCommand::NextTab => {
566
+                    self.tabs.next_tab();
567
+                    self.tabs.mark_all_dirty();
568
+                }
569
+                TerminalCommand::PrevTab => {
570
+                    self.tabs.prev_tab();
571
+                    self.tabs.mark_all_dirty();
572
+                }
573
+                TerminalCommand::LoadSession { name } => {
574
+                    self.load_session(&name)?;
575
+                }
576
+            }
577
+        }
578
+        Ok(())
579
+    }
580
+
581
+    /// Load a named session from Lua config
582
+    fn load_session(&mut self, name: &str) -> Result<()> {
583
+        let Some(ref runtime) = self.lua_runtime else {
584
+            tracing::warn!("No Lua runtime, cannot load session");
585
+            return Ok(());
586
+        };
587
+
588
+        let Some(session) = runtime.get_session(name) else {
589
+            tracing::warn!("Session '{}' not found", name);
590
+            return Ok(());
591
+        };
592
+
593
+        info!("Loading session '{}' with {} tabs", name, session.tabs.len());
594
+
595
+        for tab_def in &session.tabs {
596
+            // Create the tab with startup command (waits for OSC 133 prompt)
597
+            self.tabs.new_tab_with_command(
598
+                self.width,
599
+                self.height,
600
+                tab_def.cwd.as_deref(),
601
+                tab_def.cmd.as_deref(),
602
+            )?;
603
+
604
+            // Create splits within the tab
605
+            for split_def in &tab_def.splits {
606
+                match split_def.direction.to_lowercase().as_str() {
607
+                    "horizontal" | "h" => {
608
+                        self.tabs.split_horizontal_with_command(
609
+                            split_def.cwd.as_deref(),
610
+                            split_def.cmd.as_deref(),
611
+                        )?
612
+                    }
613
+                    _ => {
614
+                        self.tabs.split_vertical_with_command(
615
+                            split_def.cwd.as_deref(),
616
+                            split_def.cmd.as_deref(),
617
+                        )?
618
+                    }
619
+                };
620
+            }
621
+        }
622
+
623
+        self.tabs.relayout(self.width, self.height)?;
624
+        self.tabs.mark_all_dirty();
625
+
626
+        // Focus first tab
627
+        self.tabs.switch_to_tab(1);
628
+
629
+        Ok(())
630
+    }
631
+
451632
     /// Convert gartk Modifiers to config Modifiers
452633
     fn modifiers_to_config(mods: &gartk_core::Modifiers) -> ConfigModifiers {
453634
         ConfigModifiers {
@@ -524,9 +705,24 @@ impl App {
524705
         // Reload colors
525706
         self.renderer.set_colors(config.color_palette());
526707
 
527
-        // Reload keybindings
708
+        // Reload keybindings from TOML config
528709
         self.keybinds = config.keybindings();
529
-        info!("Reloaded {} keybindings", self.keybinds.iter().count());
710
+        info!("Reloaded {} keybindings from config", self.keybinds.iter().count());
711
+
712
+        // Reload Lua runtime and merge keybinds
713
+        match LuaRuntime::new() {
714
+            Ok(runtime) => {
715
+                if let Err(e) = runtime.load() {
716
+                    tracing::warn!("Lua config error on reload: {}", e);
717
+                }
718
+                // Merge Lua keybinds into the keybind set
719
+                runtime.merge_keybinds(&mut self.keybinds);
720
+                self.lua_runtime = Some(runtime);
721
+            }
722
+            Err(e) => {
723
+                tracing::warn!("Failed to create Lua runtime on reload: {}", e);
724
+            }
725
+        }
530726
 
531727
         // Update shell for new panes
532728
         self.shell = config.general.shell.clone();
@@ -558,9 +754,14 @@ impl App {
558754
                     Err(e) => Response::error(format!("Failed to reload: {}", e)),
559755
                 }
560756
             }
561
-            Command::NewTab { cwd } => {
757
+            Command::NewTab { cwd, startup_cmd, title: _ } => {
562758
                 let cwd_path = cwd.as_ref().map(|s| std::path::PathBuf::from(s));
563
-                match self.tabs.new_tab(self.width, self.height, cwd_path.as_deref()) {
759
+                match self.tabs.new_tab_with_command(
760
+                    self.width,
761
+                    self.height,
762
+                    cwd_path.as_deref(),
763
+                    startup_cmd.as_deref(),
764
+                ) {
564765
                     Ok(_) => {
565766
                         let _ = self.tabs.relayout(self.width, self.height);
566767
                         self.tabs.mark_all_dirty();
@@ -590,10 +791,17 @@ impl App {
590791
                 self.tabs.mark_all_dirty();
591792
                 Response::ok()
592793
             }
593
-            Command::Split { direction } => {
794
+            Command::Split { direction, cwd, startup_cmd } => {
795
+                let cwd_path = cwd.as_ref()
796
+                    .map(|s| std::path::PathBuf::from(s))
797
+                    .or_else(|| self.cwd.clone());
594798
                 let result = match direction.to_lowercase().as_str() {
595
-                    "horizontal" | "h" => self.tabs.split_horizontal(self.cwd.as_deref()),
596
-                    "vertical" | "v" => self.tabs.split_vertical(self.cwd.as_deref()),
799
+                    "horizontal" | "h" => {
800
+                        self.tabs.split_horizontal_with_command(cwd_path.as_deref(), startup_cmd.as_deref())
801
+                    }
802
+                    "vertical" | "v" => {
803
+                        self.tabs.split_vertical_with_command(cwd_path.as_deref(), startup_cmd.as_deref())
804
+                    }
597805
                     _ => return Response::error(format!("Invalid direction: {}", direction)),
598806
                 };
599807
                 match result {
@@ -605,6 +813,12 @@ impl App {
605813
                     Err(e) => Response::error(format!("Failed to split: {}", e)),
606814
                 }
607815
             }
816
+            Command::LoadSession { name } => {
817
+                match self.load_session(&name) {
818
+                    Ok(()) => Response::ok(),
819
+                    Err(e) => Response::error(format!("Failed to load session: {}", e)),
820
+                }
821
+            }
608822
             Command::ClosePane => {
609823
                 if self.tabs.close_pane() {
610824
                     let _ = self.tabs.relayout(self.width, self.height);
garterm/src/config/keybinds.rsmodified
@@ -108,6 +108,10 @@ pub enum Action {
108108
     SendBytes(Vec<u8>),
109109
     SendText(String),
110110
 
111
+    // Lua scripting
112
+    LuaCallback(usize),      // Index into LuaRuntime callbacks
113
+    LoadSession(String),     // Load a named session
114
+
111115
     // No action (for documenting disabled defaults)
112116
     None,
113117
 }
@@ -125,6 +129,10 @@ impl Action {
125129
         if let Some(n) = s.strip_prefix("scroll_down_") {
126130
             return n.parse().ok().map(Action::ScrollDown);
127131
         }
132
+        // load_session:session_name
133
+        if let Some(name) = s.strip_prefix("load_session:") {
134
+            return Some(Action::LoadSession(name.to_string()));
135
+        }
128136
 
129137
         match s.to_lowercase().replace(['-', '_'], "").as_str() {
130138
             "copy" => Some(Action::Copy),
garterm/src/config/lua.rsmodified
@@ -98,6 +98,11 @@ fn create_gar_stubs(lua: &mlua::Lua) -> mlua::Result<()> {
9898
 }
9999
 
100100
 /// Parse gar.terminal Lua table into Config
101
+/// This is also called by runtime.rs for the persistent Lua runtime
102
+pub fn parse_terminal_table_internal(table: &mlua::Table) -> Config {
103
+    parse_terminal_table(table)
104
+}
105
+
101106
 fn parse_terminal_table(table: &mlua::Table) -> Config {
102107
     let mut config = Config::default();
103108
 
garterm/src/config/mod.rsmodified
@@ -30,9 +30,11 @@
3030
 pub mod colors;
3131
 pub mod keybinds;
3232
 pub mod lua;
33
+pub mod runtime;
3334
 
3435
 pub use colors::{Color, ColorPalette};
3536
 pub use keybinds::{Action, Keybind, KeybindSet, Modifiers};
37
+pub use runtime::{LuaRuntime, LuaState, LuaKeybind, TerminalCommand, Session, SessionTab, SessionSplit};
3638
 
3739
 use serde::{Deserialize, Serialize};
3840
 use std::path::PathBuf;
garterm/src/config/runtime.rsadded
@@ -0,0 +1,691 @@
1
+//! Persistent Lua runtime for garterm scripting
2
+//!
3
+//! Provides a long-lived Lua environment that supports:
4
+//! - Function keybinds (callbacks executed on keypress)
5
+//! - Terminal API (gar.terminal.* functions)
6
+//! - Session definitions
7
+//!
8
+//! Unlike the static config loader, this runtime is kept alive
9
+//! for the entire application lifetime to support callbacks.
10
+
11
+use super::keybinds::{Action, Keybind, KeybindSet, Modifiers};
12
+use super::{Config, ConfigLoader};
13
+use mlua::{Function, Lua, RegistryKey, Result as LuaResult, Table, Value};
14
+use std::collections::HashMap;
15
+use std::path::{Path, PathBuf};
16
+use std::sync::{Arc, Mutex};
17
+use tracing::{debug, error, info, warn};
18
+
19
+/// Terminal commands queued by Lua callbacks
20
+#[derive(Debug, Clone)]
21
+pub enum TerminalCommand {
22
+    NewTab {
23
+        cwd: Option<String>,
24
+        cmd: Option<String>,
25
+        title: Option<String>,
26
+    },
27
+    Split {
28
+        direction: String,
29
+        cwd: Option<String>,
30
+        cmd: Option<String>,
31
+    },
32
+    SendText {
33
+        pane_id: Option<u32>,
34
+        text: String,
35
+    },
36
+    CloseTab {
37
+        tab_id: Option<u32>,
38
+    },
39
+    ClosePane {
40
+        pane_id: Option<u32>,
41
+    },
42
+    FocusTab {
43
+        index: usize,
44
+    },
45
+    FocusPane {
46
+        pane_id: u32,
47
+    },
48
+    FocusDirection {
49
+        direction: String,
50
+    },
51
+    NextTab,
52
+    PrevTab,
53
+    LoadSession {
54
+        name: String,
55
+    },
56
+}
57
+
58
+/// Session definition parsed from Lua
59
+#[derive(Debug, Clone)]
60
+pub struct Session {
61
+    pub name: String,
62
+    pub tabs: Vec<SessionTab>,
63
+}
64
+
65
+/// Tab within a session
66
+#[derive(Debug, Clone)]
67
+pub struct SessionTab {
68
+    pub title: Option<String>,
69
+    pub cwd: Option<PathBuf>,
70
+    pub cmd: Option<String>,
71
+    pub splits: Vec<SessionSplit>,
72
+}
73
+
74
+/// Split within a session tab
75
+#[derive(Debug, Clone)]
76
+pub struct SessionSplit {
77
+    pub direction: String,
78
+    pub cwd: Option<PathBuf>,
79
+    pub cmd: Option<String>,
80
+}
81
+
82
+/// Shared state between Rust and Lua
83
+pub struct LuaState {
84
+    /// Registered Lua function callbacks
85
+    pub callbacks: Vec<RegistryKey>,
86
+    /// Parsed session definitions
87
+    pub sessions: HashMap<String, Session>,
88
+    /// Pending terminal commands from Lua callbacks
89
+    pub pending_commands: Vec<TerminalCommand>,
90
+    /// Keybinds parsed from Lua (separate from TOML keybinds)
91
+    pub lua_keybinds: Vec<(String, LuaKeybind)>,
92
+    /// Last assigned IDs for return values
93
+    pub last_tab_id: u32,
94
+    pub last_pane_id: u32,
95
+}
96
+
97
+impl Default for LuaState {
98
+    fn default() -> Self {
99
+        Self {
100
+            callbacks: Vec::new(),
101
+            sessions: HashMap::new(),
102
+            pending_commands: Vec::new(),
103
+            lua_keybinds: Vec::new(),
104
+            last_tab_id: 0,
105
+            last_pane_id: 0,
106
+        }
107
+    }
108
+}
109
+
110
+/// Type of keybind action from Lua
111
+#[derive(Debug, Clone)]
112
+pub enum LuaKeybind {
113
+    /// String action name: "new_tab", "copy", etc.
114
+    Action(String),
115
+    /// Index into callbacks vector
116
+    Callback(usize),
117
+    /// Load a named session
118
+    SessionLoad(String),
119
+}
120
+
121
+/// Persistent Lua runtime
122
+pub struct LuaRuntime {
123
+    lua: Lua,
124
+    state: Arc<Mutex<LuaState>>,
125
+    lua_path: PathBuf,
126
+}
127
+
128
+impl LuaRuntime {
129
+    /// Create a new Lua runtime
130
+    pub fn new() -> LuaResult<Self> {
131
+        let lua = Lua::new();
132
+        let state = Arc::new(Mutex::new(LuaState::default()));
133
+
134
+        let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"));
135
+        let lua_path = config_dir.join("gar/init.lua");
136
+
137
+        Ok(Self {
138
+            lua,
139
+            state,
140
+            lua_path,
141
+        })
142
+    }
143
+
144
+    /// Load and execute the Lua config file
145
+    pub fn load(&self) -> LuaResult<Option<Config>> {
146
+        // Set up gar.* stubs for WM compatibility
147
+        self.setup_gar_stubs()?;
148
+
149
+        // Register gar.terminal.* API
150
+        self.register_terminal_api()?;
151
+
152
+        // Load the config file if it exists
153
+        if !self.lua_path.exists() {
154
+            debug!("No Lua config at {}", self.lua_path.display());
155
+            return Ok(None);
156
+        }
157
+
158
+        let content = match std::fs::read_to_string(&self.lua_path) {
159
+            Ok(c) => c,
160
+            Err(e) => {
161
+                warn!("Failed to read {}: {}", self.lua_path.display(), e);
162
+                return Ok(None);
163
+            }
164
+        };
165
+
166
+        // Execute the Lua file
167
+        if let Err(e) = self.lua.load(&content).exec() {
168
+            error!("Lua config error: {}", e);
169
+            return Ok(None);
170
+        }
171
+
172
+        info!("Loaded Lua config from {}", self.lua_path.display());
173
+
174
+        // Parse gar.terminal table into Config
175
+        let config = self.parse_terminal_config()?;
176
+
177
+        // Parse sessions
178
+        self.parse_sessions()?;
179
+
180
+        // Parse keybinds (including function callbacks)
181
+        self.parse_keybinds()?;
182
+
183
+        Ok(config)
184
+    }
185
+
186
+    /// Set up gar.* stub functions for WM compatibility
187
+    fn setup_gar_stubs(&self) -> LuaResult<()> {
188
+        let globals = self.lua.globals();
189
+        let gar = self.lua.create_table()?;
190
+
191
+        // No-op functions that accept any arguments
192
+        let noop = self.lua.create_function(|_, _: mlua::MultiValue| Ok(()))?;
193
+
194
+        gar.set("set", noop.clone())?;
195
+        gar.set("bind", noop.clone())?;
196
+        gar.set("exec", noop.clone())?;
197
+        gar.set("exec_once", noop.clone())?;
198
+        gar.set("rule", noop.clone())?;
199
+        gar.set("picom_rule", noop.clone())?;
200
+
201
+        // Action functions that return nil
202
+        let nil_fn = self.lua.create_function(|_, _: mlua::MultiValue| Ok(Value::Nil))?;
203
+
204
+        gar.set("focus", nil_fn.clone())?;
205
+        gar.set("swap", nil_fn.clone())?;
206
+        gar.set("resize", nil_fn.clone())?;
207
+        gar.set("workspace", nil_fn.clone())?;
208
+        gar.set("workspace_next", nil_fn.clone())?;
209
+        gar.set("workspace_prev", nil_fn.clone())?;
210
+        gar.set("move_to_workspace", nil_fn.clone())?;
211
+        gar.set("focus_monitor", nil_fn.clone())?;
212
+        gar.set("move_to_monitor", nil_fn.clone())?;
213
+        gar.set("close_window", nil_fn.clone())?;
214
+        gar.set("force_close_window", nil_fn.clone())?;
215
+        gar.set("exit", nil_fn.clone())?;
216
+        gar.set("reload", nil_fn.clone())?;
217
+        gar.set("equalize", nil_fn.clone())?;
218
+        gar.set("toggle_floating", nil_fn.clone())?;
219
+        gar.set("toggle_fullscreen", nil_fn.clone())?;
220
+
221
+        globals.set("gar", gar)?;
222
+        Ok(())
223
+    }
224
+
225
+    /// Register gar.terminal.* API functions
226
+    fn register_terminal_api(&self) -> LuaResult<()> {
227
+        let globals = self.lua.globals();
228
+        let gar: Table = globals.get("gar")?;
229
+        let terminal = self.lua.create_table()?;
230
+
231
+        // gar.terminal.new_tab({ cwd = "...", cmd = "...", title = "..." })
232
+        let state = Arc::clone(&self.state);
233
+        let new_tab = self.lua.create_function(move |_, opts: Option<Table>| {
234
+            let (cwd, cmd, title) = if let Some(t) = opts {
235
+                (
236
+                    t.get::<Option<String>>("cwd").ok().flatten(),
237
+                    t.get::<Option<String>>("cmd").ok().flatten(),
238
+                    t.get::<Option<String>>("title").ok().flatten(),
239
+                )
240
+            } else {
241
+                (None, None, None)
242
+            };
243
+
244
+            let mut state = state.lock().unwrap();
245
+            state.last_tab_id += 1;
246
+            let tab_id = state.last_tab_id;
247
+            state.pending_commands.push(TerminalCommand::NewTab { cwd, cmd, title });
248
+            Ok(tab_id)
249
+        })?;
250
+        terminal.set("new_tab", new_tab)?;
251
+
252
+        // gar.terminal.split({ direction = "horizontal", cwd = "...", cmd = "..." })
253
+        let state = Arc::clone(&self.state);
254
+        let split = self.lua.create_function(move |_, opts: Option<Table>| {
255
+            let (direction, cwd, cmd) = if let Some(t) = opts {
256
+                (
257
+                    t.get::<String>("direction").unwrap_or_else(|_| "vertical".into()),
258
+                    t.get::<Option<String>>("cwd").ok().flatten(),
259
+                    t.get::<Option<String>>("cmd").ok().flatten(),
260
+                )
261
+            } else {
262
+                ("vertical".into(), None, None)
263
+            };
264
+
265
+            let mut state = state.lock().unwrap();
266
+            state.last_pane_id += 1;
267
+            let pane_id = state.last_pane_id;
268
+            state.pending_commands.push(TerminalCommand::Split { direction, cwd, cmd });
269
+            Ok(pane_id)
270
+        })?;
271
+        terminal.set("split", split)?;
272
+
273
+        // gar.terminal.send_text(pane_id, text) or gar.terminal.send_text(text)
274
+        let state = Arc::clone(&self.state);
275
+        let send_text = self.lua.create_function(move |_, args: mlua::MultiValue| {
276
+            let args: Vec<Value> = args.into_iter().collect();
277
+            let (pane_id, text) = match args.len() {
278
+                1 => {
279
+                    // send_text("text") - send to focused pane
280
+                    let text = match &args[0] {
281
+                        Value::String(s) => s.to_str()?.to_string(),
282
+                        _ => return Err(mlua::Error::runtime("expected string")),
283
+                    };
284
+                    (None, text)
285
+                }
286
+                2 => {
287
+                    // send_text(pane_id, "text")
288
+                    let pane_id = match &args[0] {
289
+                        Value::Integer(n) => Some(*n as u32),
290
+                        Value::Nil => None,
291
+                        _ => return Err(mlua::Error::runtime("expected pane_id or nil")),
292
+                    };
293
+                    let text = match &args[1] {
294
+                        Value::String(s) => s.to_str()?.to_string(),
295
+                        _ => return Err(mlua::Error::runtime("expected string")),
296
+                    };
297
+                    (pane_id, text)
298
+                }
299
+                _ => return Err(mlua::Error::runtime("expected 1 or 2 arguments")),
300
+            };
301
+
302
+            let mut state = state.lock().unwrap();
303
+            state.pending_commands.push(TerminalCommand::SendText { pane_id, text });
304
+            Ok(())
305
+        })?;
306
+        terminal.set("send_text", send_text)?;
307
+
308
+        // gar.terminal.close_tab(tab_id?)
309
+        let state = Arc::clone(&self.state);
310
+        let close_tab = self.lua.create_function(move |_, tab_id: Option<u32>| {
311
+            let mut state = state.lock().unwrap();
312
+            state.pending_commands.push(TerminalCommand::CloseTab { tab_id });
313
+            Ok(())
314
+        })?;
315
+        terminal.set("close_tab", close_tab)?;
316
+
317
+        // gar.terminal.close_pane(pane_id?)
318
+        let state = Arc::clone(&self.state);
319
+        let close_pane = self.lua.create_function(move |_, pane_id: Option<u32>| {
320
+            let mut state = state.lock().unwrap();
321
+            state.pending_commands.push(TerminalCommand::ClosePane { pane_id });
322
+            Ok(())
323
+        })?;
324
+        terminal.set("close_pane", close_pane)?;
325
+
326
+        // gar.terminal.focus_tab(n)
327
+        let state = Arc::clone(&self.state);
328
+        let focus_tab = self.lua.create_function(move |_, index: usize| {
329
+            let mut state = state.lock().unwrap();
330
+            state.pending_commands.push(TerminalCommand::FocusTab { index });
331
+            Ok(())
332
+        })?;
333
+        terminal.set("focus_tab", focus_tab)?;
334
+
335
+        // gar.terminal.focus_pane(pane_id)
336
+        let state = Arc::clone(&self.state);
337
+        let focus_pane = self.lua.create_function(move |_, pane_id: u32| {
338
+            let mut state = state.lock().unwrap();
339
+            state.pending_commands.push(TerminalCommand::FocusPane { pane_id });
340
+            Ok(())
341
+        })?;
342
+        terminal.set("focus_pane", focus_pane)?;
343
+
344
+        // gar.terminal.focus_direction(dir)
345
+        let state = Arc::clone(&self.state);
346
+        let focus_direction = self.lua.create_function(move |_, direction: String| {
347
+            let mut state = state.lock().unwrap();
348
+            state.pending_commands.push(TerminalCommand::FocusDirection { direction });
349
+            Ok(())
350
+        })?;
351
+        terminal.set("focus_direction", focus_direction)?;
352
+
353
+        // gar.terminal.next_tab()
354
+        let state = Arc::clone(&self.state);
355
+        let next_tab = self.lua.create_function(move |_, ()| {
356
+            let mut state = state.lock().unwrap();
357
+            state.pending_commands.push(TerminalCommand::NextTab);
358
+            Ok(())
359
+        })?;
360
+        terminal.set("next_tab", next_tab)?;
361
+
362
+        // gar.terminal.prev_tab()
363
+        let state = Arc::clone(&self.state);
364
+        let prev_tab = self.lua.create_function(move |_, ()| {
365
+            let mut state = state.lock().unwrap();
366
+            state.pending_commands.push(TerminalCommand::PrevTab);
367
+            Ok(())
368
+        })?;
369
+        terminal.set("prev_tab", prev_tab)?;
370
+
371
+        // gar.terminal.load_session(name)
372
+        let state = Arc::clone(&self.state);
373
+        let load_session = self.lua.create_function(move |_, name: String| {
374
+            let mut state = state.lock().unwrap();
375
+            state.pending_commands.push(TerminalCommand::LoadSession { name });
376
+            Ok(())
377
+        })?;
378
+        terminal.set("load_session", load_session)?;
379
+
380
+        gar.set("terminal", terminal)?;
381
+        Ok(())
382
+    }
383
+
384
+    /// Parse gar.terminal table into Config (static settings only)
385
+    fn parse_terminal_config(&self) -> LuaResult<Option<Config>> {
386
+        let globals = self.lua.globals();
387
+        let gar: Table = match globals.get("gar") {
388
+            Ok(t) => t,
389
+            Err(_) => return Ok(None),
390
+        };
391
+
392
+        let terminal: Table = match gar.get("terminal") {
393
+            Ok(t) => t,
394
+            Err(_) => return Ok(None),
395
+        };
396
+
397
+        // Delegate to existing lua.rs parsing logic
398
+        // This reuses the static config parsing
399
+        let config = super::lua::parse_terminal_table_internal(&terminal);
400
+        Ok(Some(config))
401
+    }
402
+
403
+    /// Parse session definitions from gar.terminal.sessions
404
+    fn parse_sessions(&self) -> LuaResult<()> {
405
+        let globals = self.lua.globals();
406
+        let gar: Table = globals.get("gar")?;
407
+        let terminal: Table = match gar.get("terminal") {
408
+            Ok(t) => t,
409
+            Err(_) => return Ok(()),
410
+        };
411
+
412
+        let sessions: Table = match terminal.get("sessions") {
413
+            Ok(t) => t,
414
+            Err(_) => return Ok(()),
415
+        };
416
+
417
+        let mut state = self.state.lock().unwrap();
418
+
419
+        for pair in sessions.pairs::<String, Table>() {
420
+            let (name, session_table) = pair?;
421
+            if let Ok(session) = self.parse_session(&name, &session_table) {
422
+                debug!("Parsed session: {}", name);
423
+                state.sessions.insert(name, session);
424
+            }
425
+        }
426
+
427
+        info!("Loaded {} sessions", state.sessions.len());
428
+        Ok(())
429
+    }
430
+
431
+    /// Parse a single session definition
432
+    fn parse_session(&self, name: &str, table: &Table) -> LuaResult<Session> {
433
+        let mut tabs = Vec::new();
434
+
435
+        if let Ok(tabs_table) = table.get::<Table>("tabs") {
436
+            for i in 1..=tabs_table.len()? {
437
+                if let Ok(tab_table) = tabs_table.get::<Table>(i) {
438
+                    tabs.push(self.parse_session_tab(&tab_table)?);
439
+                }
440
+            }
441
+        }
442
+
443
+        Ok(Session {
444
+            name: name.to_string(),
445
+            tabs,
446
+        })
447
+    }
448
+
449
+    /// Parse a session tab definition
450
+    fn parse_session_tab(&self, table: &Table) -> LuaResult<SessionTab> {
451
+        let title = table.get::<Option<String>>("title").ok().flatten();
452
+        let cwd = table.get::<Option<String>>("cwd").ok().flatten().map(PathBuf::from);
453
+        let cmd = table.get::<Option<String>>("cmd").ok().flatten();
454
+
455
+        let mut splits = Vec::new();
456
+        if let Ok(splits_table) = table.get::<Table>("splits") {
457
+            for i in 1..=splits_table.len()? {
458
+                if let Ok(split_table) = splits_table.get::<Table>(i) {
459
+                    splits.push(self.parse_session_split(&split_table)?);
460
+                }
461
+            }
462
+        }
463
+
464
+        Ok(SessionTab { title, cwd, cmd, splits })
465
+    }
466
+
467
+    /// Parse a session split definition
468
+    fn parse_session_split(&self, table: &Table) -> LuaResult<SessionSplit> {
469
+        let direction = table.get::<String>("direction").unwrap_or_else(|_| "vertical".into());
470
+        let cwd = table.get::<Option<String>>("cwd").ok().flatten().map(PathBuf::from);
471
+        let cmd = table.get::<Option<String>>("cmd").ok().flatten();
472
+
473
+        Ok(SessionSplit { direction, cwd, cmd })
474
+    }
475
+
476
+    /// Parse keybinds from gar.terminal.keybinds (supports functions!)
477
+    fn parse_keybinds(&self) -> LuaResult<()> {
478
+        let globals = self.lua.globals();
479
+        let gar: Table = globals.get("gar")?;
480
+        let terminal: Table = match gar.get("terminal") {
481
+            Ok(t) => t,
482
+            Err(_) => return Ok(()),
483
+        };
484
+
485
+        let keybinds: Table = match terminal.get("keybinds") {
486
+            Ok(t) => t,
487
+            Err(_) => return Ok(()),
488
+        };
489
+
490
+        for pair in keybinds.pairs::<String, Value>() {
491
+            let (key_combo, value) = pair?;
492
+
493
+            let lua_keybind = match value {
494
+                Value::String(s) => {
495
+                    // String action: "new_tab", "copy", etc.
496
+                    LuaKeybind::Action(s.to_str()?.to_string())
497
+                }
498
+                Value::Function(f) => {
499
+                    // Lua function callback - store in registry
500
+                    let key = self.lua.create_registry_value(f)?;
501
+                    let mut state = self.state.lock().unwrap();
502
+                    let index = state.callbacks.len();
503
+                    state.callbacks.push(key);
504
+                    drop(state);
505
+                    debug!("Registered Lua callback {} for {}", index, key_combo);
506
+                    LuaKeybind::Callback(index)
507
+                }
508
+                Value::Table(t) => {
509
+                    // Action table: { action = "load_session", session = "webdev" }
510
+                    if let Ok(session) = t.get::<String>("session") {
511
+                        LuaKeybind::SessionLoad(session)
512
+                    } else if let Ok(action) = t.get::<String>("action") {
513
+                        LuaKeybind::Action(action)
514
+                    } else {
515
+                        continue;
516
+                    }
517
+                }
518
+                _ => continue,
519
+            };
520
+
521
+            let mut state = self.state.lock().unwrap();
522
+            state.lua_keybinds.push((key_combo.clone(), lua_keybind));
523
+        }
524
+
525
+        let state = self.state.lock().unwrap();
526
+        info!("Loaded {} Lua keybinds ({} callbacks)",
527
+              state.lua_keybinds.len(),
528
+              state.callbacks.len());
529
+        Ok(())
530
+    }
531
+
532
+    /// Execute a registered callback by index
533
+    pub fn execute_callback(&self, index: usize) -> LuaResult<()> {
534
+        let state = self.state.lock().unwrap();
535
+        let key = state.callbacks.get(index)
536
+            .ok_or_else(|| mlua::Error::runtime(format!("callback {} not found", index)))?;
537
+
538
+        let func: Function = self.lua.registry_value(key)?;
539
+        drop(state); // Release lock before calling Lua
540
+
541
+        func.call::<()>(())?;
542
+        Ok(())
543
+    }
544
+
545
+    /// Take pending commands (drains the queue)
546
+    pub fn take_pending_commands(&self) -> Vec<TerminalCommand> {
547
+        let mut state = self.state.lock().unwrap();
548
+        std::mem::take(&mut state.pending_commands)
549
+    }
550
+
551
+    /// Get a session by name
552
+    pub fn get_session(&self, name: &str) -> Option<Session> {
553
+        let state = self.state.lock().unwrap();
554
+        state.sessions.get(name).cloned()
555
+    }
556
+
557
+    /// Get Lua keybinds to merge with config keybinds
558
+    pub fn get_lua_keybinds(&self) -> Vec<(String, LuaKeybind)> {
559
+        let state = self.state.lock().unwrap();
560
+        state.lua_keybinds.clone()
561
+    }
562
+
563
+    /// Check if we have any Lua callbacks (for feature detection)
564
+    pub fn has_callbacks(&self) -> bool {
565
+        let state = self.state.lock().unwrap();
566
+        !state.callbacks.is_empty()
567
+    }
568
+
569
+    /// Merge Lua keybinds into an existing KeybindSet
570
+    ///
571
+    /// This converts LuaKeybind variants to Action variants and adds them
572
+    /// to the keybind set, overriding any existing bindings.
573
+    pub fn merge_keybinds(&self, keybinds: &mut KeybindSet) {
574
+        let state = self.state.lock().unwrap();
575
+
576
+        for (key_combo, lua_bind) in &state.lua_keybinds {
577
+            // Parse the key combo
578
+            let Some((modifiers, key)) = Keybind::parse_key_combo(key_combo) else {
579
+                warn!("Invalid key combo from Lua: {}", key_combo);
580
+                continue;
581
+            };
582
+
583
+            // Convert LuaKeybind to Action
584
+            let action = match lua_bind {
585
+                LuaKeybind::Action(s) => {
586
+                    // Parse string action
587
+                    if let Some(a) = Action::from_str_loose(s) {
588
+                        a
589
+                    } else {
590
+                        warn!("Unknown action '{}' for Lua keybind '{}'", s, key_combo);
591
+                        continue;
592
+                    }
593
+                }
594
+                LuaKeybind::Callback(index) => Action::LuaCallback(*index),
595
+                LuaKeybind::SessionLoad(name) => Action::LoadSession(name.clone()),
596
+            };
597
+
598
+            // Add to keybind set (overrides existing)
599
+            if let Some(old) = keybinds.add(Keybind::new(modifiers, key, action)) {
600
+                if old.action != Action::None {
601
+                    debug!("Lua keybind {} overrides {:?}", key_combo, old.action);
602
+                }
603
+            }
604
+        }
605
+
606
+        info!("Merged {} Lua keybinds", state.lua_keybinds.len());
607
+    }
608
+}
609
+
610
+#[cfg(test)]
611
+mod tests {
612
+    use super::*;
613
+
614
+    #[test]
615
+    fn test_runtime_creation() {
616
+        let runtime = LuaRuntime::new().unwrap();
617
+        assert!(!runtime.has_callbacks());
618
+    }
619
+
620
+    #[test]
621
+    fn test_terminal_api_registration() {
622
+        let runtime = LuaRuntime::new().unwrap();
623
+        runtime.setup_gar_stubs().unwrap();
624
+        runtime.register_terminal_api().unwrap();
625
+
626
+        // Execute Lua that calls the API
627
+        runtime.lua.load(r#"
628
+            local tab = gar.terminal.new_tab({ cwd = "/tmp", title = "Test" })
629
+            gar.terminal.send_text(tab, "echo hello\n")
630
+        "#).exec().unwrap();
631
+
632
+        let commands = runtime.take_pending_commands();
633
+        assert_eq!(commands.len(), 2);
634
+    }
635
+
636
+    #[test]
637
+    fn test_session_parsing() {
638
+        let runtime = LuaRuntime::new().unwrap();
639
+        runtime.setup_gar_stubs().unwrap();
640
+        runtime.register_terminal_api().unwrap();
641
+
642
+        runtime.lua.load(r#"
643
+            gar.terminal.sessions = {
644
+                webdev = {
645
+                    tabs = {
646
+                        { title = "Frontend", cwd = "~/app", cmd = "npm run dev" },
647
+                        { title = "Backend", cmd = "python manage.py runserver" },
648
+                    }
649
+                }
650
+            }
651
+        "#).exec().unwrap();
652
+
653
+        runtime.parse_sessions().unwrap();
654
+
655
+        let session = runtime.get_session("webdev").unwrap();
656
+        assert_eq!(session.tabs.len(), 2);
657
+        assert_eq!(session.tabs[0].title, Some("Frontend".into()));
658
+        assert_eq!(session.tabs[0].cmd, Some("npm run dev".into()));
659
+    }
660
+
661
+    #[test]
662
+    fn test_function_keybind() {
663
+        let runtime = LuaRuntime::new().unwrap();
664
+        runtime.setup_gar_stubs().unwrap();
665
+        runtime.register_terminal_api().unwrap();
666
+
667
+        runtime.lua.load(r#"
668
+            gar.terminal.keybinds = {
669
+                ["alt+t"] = function()
670
+                    gar.terminal.new_tab({ title = "From callback" })
671
+                end
672
+            }
673
+        "#).exec().unwrap();
674
+
675
+        runtime.parse_keybinds().unwrap();
676
+
677
+        assert!(runtime.has_callbacks());
678
+
679
+        // Execute the callback
680
+        runtime.execute_callback(0).unwrap();
681
+
682
+        let commands = runtime.take_pending_commands();
683
+        assert_eq!(commands.len(), 1);
684
+        match &commands[0] {
685
+            TerminalCommand::NewTab { title, .. } => {
686
+                assert_eq!(title.as_deref(), Some("From callback"));
687
+            }
688
+            _ => panic!("expected NewTab command"),
689
+        }
690
+    }
691
+}
garterm/src/terminal/mod.rsmodified
@@ -76,6 +76,8 @@ pub struct Terminal {
7676
     current_hyperlink_id: u16,
7777
     /// Clipboard events pending processing
7878
     clipboard_events: VecDeque<ClipboardEvent>,
79
+    /// Prompt ready flag (from OSC 133;A - shell integration)
80
+    prompt_ready: bool,
7981
 }
8082
 
8183
 impl Terminal {
@@ -109,6 +111,7 @@ impl Terminal {
109111
             next_hyperlink_id: 1,
110112
             current_hyperlink_id: 0,
111113
             clipboard_events: VecDeque::new(),
114
+            prompt_ready: false,
112115
         }
113116
     }
114117
 
@@ -166,6 +169,11 @@ impl Terminal {
166169
         std::mem::replace(&mut self.bell_pending, false)
167170
     }
168171
 
172
+    /// Check and clear prompt ready flag (from OSC 133;A)
173
+    pub fn take_prompt_ready(&mut self) -> bool {
174
+        std::mem::replace(&mut self.prompt_ready, false)
175
+    }
176
+
169177
     /// Get current working directory (from OSC 7)
170178
     pub fn cwd(&self) -> Option<&str> {
171179
         self.cwd.as_deref()
@@ -754,6 +762,39 @@ impl vte::Perform for Performer<'_> {
754762
                     }
755763
                 }
756764
             }
765
+            // OSC 133: Shell integration (prompt marking)
766
+            // Format: OSC 133 ; A ST (prompt start)
767
+            //         OSC 133 ; B ST (command start)
768
+            //         OSC 133 ; C ST (command output start)
769
+            //         OSC 133 ; D ; exit_code ST (command finished)
770
+            b"133" => {
771
+                if params.len() >= 2 {
772
+                    match params[1] {
773
+                        b"A" => {
774
+                            // Prompt start - shell is ready for input
775
+                            trace!("OSC 133;A - prompt ready");
776
+                            self.term.prompt_ready = true;
777
+                        }
778
+                        b"B" => {
779
+                            // Command start (user pressed enter)
780
+                            trace!("OSC 133;B - command start");
781
+                        }
782
+                        b"C" => {
783
+                            // Command output start
784
+                            trace!("OSC 133;C - output start");
785
+                        }
786
+                        b"D" => {
787
+                            // Command finished
788
+                            if params.len() >= 3 {
789
+                                if let Ok(code) = std::str::from_utf8(params[2]) {
790
+                                    trace!("OSC 133;D - command finished with code {}", code);
791
+                                }
792
+                            }
793
+                        }
794
+                        _ => {}
795
+                    }
796
+                }
797
+            }
757798
             _ => {
758799
                 trace!("unhandled OSC: {:?}", params);
759800
             }
garterm/src/ui/manager.rsmodified
@@ -89,6 +89,17 @@ impl TabManager {
8989
         width: u32,
9090
         height: u32,
9191
         cwd: Option<&std::path::Path>,
92
+    ) -> Result<TabId> {
93
+        self.new_tab_with_command(width, height, cwd, None)
94
+    }
95
+
96
+    /// Create a new tab with an optional startup command
97
+    pub fn new_tab_with_command(
98
+        &mut self,
99
+        width: u32,
100
+        height: u32,
101
+        cwd: Option<&std::path::Path>,
102
+        startup_cmd: Option<&str>,
92103
     ) -> Result<TabId> {
93104
         // After adding this tab, we'll have tabs.len() + 1 tabs
94105
         let new_tab_count = self.tabs.len() + 1;
@@ -99,7 +110,9 @@ impl TabManager {
99110
         let tab_id = TabId(self.next_tab_id);
100111
         self.next_tab_id += 1;
101112
 
102
-        let tab = Tab::new(tab_id, &self.shell, cols, rows, width, content_height, cwd)?;
113
+        let tab = Tab::new_with_command(
114
+            tab_id, &self.shell, cols, rows, width, content_height, cwd, startup_cmd
115
+        )?;
103116
         self.tabs.insert(tab_id, tab);
104117
         self.tab_order.push(tab_id);
105118
         self.active_tab = tab_id;
@@ -159,14 +172,24 @@ impl TabManager {
159172
     pub fn split_horizontal(
160173
         &mut self,
161174
         cwd: Option<&std::path::Path>,
175
+    ) -> Result<Option<PaneId>> {
176
+        self.split_horizontal_with_command(cwd, None)
177
+    }
178
+
179
+    /// Split the focused pane horizontally with an optional startup command
180
+    pub fn split_horizontal_with_command(
181
+        &mut self,
182
+        cwd: Option<&std::path::Path>,
183
+        startup_cmd: Option<&str>,
162184
     ) -> Result<Option<PaneId>> {
163185
         if let Some(tab) = self.tabs.get_mut(&self.active_tab) {
164
-            let pane_id = tab.split(
186
+            let pane_id = tab.split_with_command(
165187
                 super::split::SplitDirection::Horizontal,
166188
                 &self.shell,
167189
                 self.cell_width,
168190
                 self.cell_height,
169191
                 cwd,
192
+                startup_cmd,
170193
             )?;
171194
             Ok(Some(pane_id))
172195
         } else {
@@ -178,14 +201,24 @@ impl TabManager {
178201
     pub fn split_vertical(
179202
         &mut self,
180203
         cwd: Option<&std::path::Path>,
204
+    ) -> Result<Option<PaneId>> {
205
+        self.split_vertical_with_command(cwd, None)
206
+    }
207
+
208
+    /// Split the focused pane vertically with an optional startup command
209
+    pub fn split_vertical_with_command(
210
+        &mut self,
211
+        cwd: Option<&std::path::Path>,
212
+        startup_cmd: Option<&str>,
181213
     ) -> Result<Option<PaneId>> {
182214
         if let Some(tab) = self.tabs.get_mut(&self.active_tab) {
183
-            let pane_id = tab.split(
215
+            let pane_id = tab.split_with_command(
184216
                 super::split::SplitDirection::Vertical,
185217
                 &self.shell,
186218
                 self.cell_width,
187219
                 self.cell_height,
188220
                 cwd,
221
+                startup_cmd,
189222
             )?;
190223
             Ok(Some(pane_id))
191224
         } else {
garterm/src/ui/pane.rsmodified
@@ -5,6 +5,18 @@
55
 use crate::pty::{Pty, PtySize};
66
 use crate::terminal::Terminal;
77
 use anyhow::Result;
8
+use std::time::{Duration, Instant};
9
+
10
+/// State for pending startup command
11
+#[derive(Debug)]
12
+enum StartupCmdState {
13
+    /// No pending command
14
+    None,
15
+    /// Waiting for shell prompt (OSC 133;A) or deadline
16
+    WaitingForPrompt { cmd: String, deadline: Instant },
17
+    /// Command has been sent
18
+    Sent,
19
+}
820
 
921
 /// Unique identifier for a pane
1022
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -32,6 +44,8 @@ pub struct Pane {
3244
     pub y: u32,
3345
     /// Whether this pane is focused
3446
     pub focused: bool,
47
+    /// Startup command state (for cmd parameter)
48
+    startup_cmd_state: StartupCmdState,
3549
 }
3650
 
3751
 impl Pane {
@@ -64,9 +78,33 @@ impl Pane {
6478
             x: 0,
6579
             y: 0,
6680
             focused: false,
81
+            startup_cmd_state: StartupCmdState::None,
6782
         })
6883
     }
6984
 
85
+    /// Create a new pane with an optional startup command
86
+    pub fn new_with_command(
87
+        id: PaneId,
88
+        shell: &str,
89
+        cols: usize,
90
+        rows: usize,
91
+        width: u32,
92
+        height: u32,
93
+        cwd: Option<&std::path::Path>,
94
+        startup_cmd: Option<&str>,
95
+    ) -> Result<Self> {
96
+        let mut pane = Self::new(id, shell, cols, rows, width, height, cwd)?;
97
+
98
+        if let Some(cmd) = startup_cmd {
99
+            pane.startup_cmd_state = StartupCmdState::WaitingForPrompt {
100
+                cmd: cmd.to_string(),
101
+                deadline: Instant::now() + Duration::from_millis(500),
102
+            };
103
+        }
104
+
105
+        Ok(pane)
106
+    }
107
+
70108
     /// Resize the pane
71109
     pub fn resize(&mut self, cols: usize, rows: usize, width: u32, height: u32) -> Result<()> {
72110
         self.width = width;
@@ -125,4 +163,34 @@ impl Pane {
125163
     pub fn mark_dirty(&mut self) {
126164
         self.terminal.mark_dirty();
127165
     }
166
+
167
+    /// Called when terminal receives OSC 133;A prompt marker
168
+    pub fn on_prompt_ready(&mut self) {
169
+        if let StartupCmdState::WaitingForPrompt { ref cmd, .. } = self.startup_cmd_state {
170
+            let cmd_with_newline = format!("{}\n", cmd);
171
+            if let Err(e) = self.write_pty(cmd_with_newline.as_bytes()) {
172
+                tracing::error!("Failed to send startup command: {}", e);
173
+            }
174
+            self.startup_cmd_state = StartupCmdState::Sent;
175
+        }
176
+    }
177
+
178
+    /// Check startup deadline and send command if timed out
179
+    pub fn check_startup_deadline(&mut self) {
180
+        if let StartupCmdState::WaitingForPrompt { ref cmd, deadline } = self.startup_cmd_state {
181
+            if Instant::now() >= deadline {
182
+                // Fallback: send anyway after timeout
183
+                let cmd_with_newline = format!("{}\n", cmd);
184
+                if let Err(e) = self.write_pty(cmd_with_newline.as_bytes()) {
185
+                    tracing::error!("Failed to send startup command (deadline): {}", e);
186
+                }
187
+                self.startup_cmd_state = StartupCmdState::Sent;
188
+            }
189
+        }
190
+    }
191
+
192
+    /// Check if there's a pending startup command
193
+    pub fn has_pending_startup_cmd(&self) -> bool {
194
+        matches!(self.startup_cmd_state, StartupCmdState::WaitingForPrompt { .. })
195
+    }
128196
 }
garterm/src/ui/tab.rsmodified
@@ -35,9 +35,25 @@ impl Tab {
3535
         width: u32,
3636
         height: u32,
3737
         cwd: Option<&std::path::Path>,
38
+    ) -> Result<Self> {
39
+        Self::new_with_command(id, shell, cols, rows, width, height, cwd, None)
40
+    }
41
+
42
+    /// Create a new tab with an initial pane and optional startup command
43
+    pub fn new_with_command(
44
+        id: TabId,
45
+        shell: &str,
46
+        cols: usize,
47
+        rows: usize,
48
+        width: u32,
49
+        height: u32,
50
+        cwd: Option<&std::path::Path>,
51
+        startup_cmd: Option<&str>,
3852
     ) -> Result<Self> {
3953
         let pane_id = PaneId(0);
40
-        let mut pane = Pane::new(pane_id, shell, cols, rows, width, height, cwd)?;
54
+        let mut pane = Pane::new_with_command(
55
+            pane_id, shell, cols, rows, width, height, cwd, startup_cmd
56
+        )?;
4157
         pane.focused = true;
4258
 
4359
         let mut panes = HashMap::new();
@@ -71,6 +87,19 @@ impl Tab {
7187
         cell_width: f32,
7288
         cell_height: f32,
7389
         cwd: Option<&std::path::Path>,
90
+    ) -> Result<PaneId> {
91
+        self.split_with_command(direction, shell, cell_width, cell_height, cwd, None)
92
+    }
93
+
94
+    /// Split the focused pane with an optional startup command
95
+    pub fn split_with_command(
96
+        &mut self,
97
+        direction: SplitDirection,
98
+        shell: &str,
99
+        cell_width: f32,
100
+        cell_height: f32,
101
+        cwd: Option<&std::path::Path>,
102
+        startup_cmd: Option<&str>,
74103
     ) -> Result<PaneId> {
75104
         let focused_pane = self.panes.get(&self.focused).ok_or_else(|| {
76105
             anyhow::anyhow!("No focused pane")
@@ -89,7 +118,9 @@ impl Tab {
89118
         let new_id = PaneId(self.next_pane_id);
90119
         self.next_pane_id += 1;
91120
 
92
-        let new_pane = Pane::new(new_id, shell, cols, rows, new_width, new_height, cwd)?;
121
+        let new_pane = Pane::new_with_command(
122
+            new_id, shell, cols, rows, new_width, new_height, cwd, startup_cmd
123
+        )?;
93124
         self.panes.insert(new_id, new_pane);
94125
 
95126
         // Update layout tree