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
27 changes: 26 additions & 1 deletion rust/limux-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ fn parse_global_args() -> Result<GlobalOptions> {

fn print_help() {
println!(
"limux CLI\n\nUsage: limux [--socket <path>] [--json] [--id-format refs|both|uuids] <command> [args...]\n limux\n\nRunning `limux` with no arguments launches the GTK app.\n\nCommon commands:\n identify [--workspace <id|ref>] [--surface <id|ref>]\n list-panels [--workspace <id|ref>]\n list-panes [--workspace <id|ref>]\n list-workspaces\n surface-health [--workspace <id|ref>]\n send [--workspace <id|ref>] [--surface <id|ref>] <text>\n send-key [--workspace <id|ref>] [--surface <id|ref>] <key>\n new-workspace [--cwd <path>] [--command <text>]\n close-workspace --workspace <id|ref>\n sidebar-state --workspace <id|ref>\n new-surface [--workspace <id|ref>]\n new-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--direction <left|right|up|down>] [--type <terminal|browser>] [--command <text>] [--url <url>]\n Live GTK self-spawn currently supports terminal panes only; browser panes remain deferred.\n rename-workspace [--workspace <id|ref>] <title>\n rename-window [--workspace <id|ref>] <title>\n rename-tab [--workspace <id|ref>] [--tab <id|ref>] <title>\n read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]\n capture-pane (alias of read-screen)\n tab-action --action <name> [--workspace <id|ref>] [--tab <id|ref>] [--title <text>] [--url <url>]\n browser [--surface <id|ref>|<surface>] <subcommand> ...\n\nAgent integrations:\n notify [--workspace <id|ref>] [--subtitle <text>] [--body <text>] <title>\n hooks setup [agent] | hooks uninstall [agent] | hooks <agent> <event>\n claude-hook | opencode-hook | gemini-hook --event <name> [--subtitle <text>] [--body <text>] [--title <text>]\n agent-team [--agents codex,claude[,opencode,gemini]] [--cwd <path>] [--no-launch] [--dry-run]\n Splits the active workspace into one pane per agent (caller's pane stays\n as the orchestrator on the left, peers stack down the right), launches\n each CLI in its pane, and writes AGENTS.md describing the <agent-msg>\n XML protocol so peers can talk via\n `limux send --surface <peer-surface-id> <envelope>`.\n"
"limux CLI\n\nUsage: limux [--socket <path>] [--json] [--id-format refs|both|uuids] <command> [args...]\n limux\n\nRunning `limux` with no arguments launches the GTK app.\n\nCommon commands:\n identify [--workspace <id|ref>] [--surface <id|ref>]\n list-panels [--workspace <id|ref>]\n list-panes [--workspace <id|ref>]\n list-workspaces\n surface-health [--workspace <id|ref>]\n send [--workspace <id|ref>] [--surface <id|ref>] <text>\n send-key [--workspace <id|ref>] [--surface <id|ref>] <key>\n new-workspace [--cwd <path>] [--command <text>]\n select-workspace --workspace <id|ref>\n close-workspace --workspace <id|ref>\n sidebar-state --workspace <id|ref>\n new-surface [--workspace <id|ref>]\n new-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--direction <left|right|up|down>] [--type <terminal|browser>] [--command <text>] [--url <url>]\n Live GTK self-spawn currently supports terminal panes only; browser panes remain deferred.\n rename-workspace [--workspace <id|ref>] <title>\n rename-window [--workspace <id|ref>] <title>\n rename-tab [--workspace <id|ref>] [--tab <id|ref>] <title>\n read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]\n capture-pane (alias of read-screen)\n tab-action --action <name> [--workspace <id|ref>] [--tab <id|ref>] [--title <text>] [--url <url>]\n browser [--surface <id|ref>|<surface>] <subcommand> ...\n\nAgent integrations:\n notify [--workspace <id|ref>] [--subtitle <text>] [--body <text>] <title>\n hooks setup [agent] | hooks uninstall [agent] | hooks <agent> <event>\n claude-hook | opencode-hook | gemini-hook --event <name> [--subtitle <text>] [--body <text>] [--title <text>]\n agent-team [--agents codex,claude[,opencode,gemini]] [--cwd <path>] [--no-launch] [--dry-run]\n Splits the active workspace into one pane per agent (caller's pane stays\n as the orchestrator on the left, peers stack down the right), launches\n each CLI in its pane, and writes AGENTS.md describing the <agent-msg>\n XML protocol so peers can talk via\n `limux send --surface <peer-surface-id> <envelope>`.\n"
);
}

Expand Down Expand Up @@ -2330,6 +2330,23 @@ async fn run_close_workspace(client: &mut Client, args: &[String]) -> Result<Val
.await
}

/// `limux select-workspace --workspace <id|ref>` — bring a workspace to the front.
///
/// The host has always implemented `workspace.select`; it just wasn't reachable
/// from the CLI. It needs to be, because a pane only realizes its ghostty surface
/// once its workspace is displayed. Splits made in a background workspace stay
/// unrealized — `surface-health` reports `healthy=false` and `send-key` fails —
/// until something selects it. Without this verb a purely CLI-driven fleet cannot
/// bring its own workspace forward, so it can never start.
async fn run_select_workspace(client: &mut Client, args: &[String]) -> Result<Value> {
let workspace = parse_opt(args, "--workspace")
.or_else(|| env::var("LIMUX_WORKSPACE_ID").ok())
.ok_or_else(|| anyhow!("select-workspace requires --workspace <id|ref>"))?;
client
.call("workspace.select", json!({ "workspace_id": workspace }))
.await
}

async fn run_sidebar_state(client: &mut Client, args: &[String]) -> Result<Value> {
let workspace = parse_opt(args, "--workspace")
.or_else(|| env::var("LIMUX_WORKSPACE_ID").ok())
Expand Down Expand Up @@ -3449,6 +3466,14 @@ async fn execute_command(client: &mut Client, opts: &GlobalOptions) -> Result<Co
CommandOutput::Text(format!("OK {}", handle))
}
}
"select-workspace" => {
let payload = run_select_workspace(client, args).await?;
if opts.json_output {
CommandOutput::Json(payload)
} else {
CommandOutput::Text("OK".to_string())
}
}
"close-workspace" => {
let payload = run_close_workspace(client, args).await?;
if opts.json_output {
Expand Down
97 changes: 94 additions & 3 deletions rust/limux-host-linux/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,27 +180,56 @@ impl TerminalHandle {
return false;
};

let press = translate_key_event(
// A key needs BOTH halves, and supplying only one drops it silently.
//
// `keycode` — ghostty resolves the *physical* key from the hardware keycode
// and does not fall back to the keyval. With `keycode: 0` it sees an
// unidentified key and drops the event, so Enter, arrows, F-keys and
// ctrl-chords never reach the PTY.
//
// `text` — ghostty writes ordinary printable input from the text field, not
// from the keycode (see the comment on the GTK key controller below: "Ghostty
// uses the text field for actual character input and the keycode for
// bindings"). With `text` null, `send-key a` is encoded as a keypress that
// produces no character, so it too vanishes.
//
// The GTK path gets `text` from the input method; a socket-injected key has
// no IM behind it, so derive it here the same way GTK's fallback does.
// `key_event_text` returns None for control characters, which is what we want:
// Enter and ctrl-chords must be *encoded* by ghostty from the key + mods, not
// written as literal bytes.
let keycode = keycode_for_keyval(self.gl_area.upcast_ref(), keyval);
let text = key_event_text(keyval);

let mut press = translate_key_event(
GHOSTTY_ACTION_PRESS,
Some(self.gl_area.upcast_ref()),
None,
keyval,
0,
keycode,
modifier,
);
// The CString must outlive the ghostty call below; `text` owns it until the
// end of this function.
if let Some(text) = text.as_ref() {
press.text = text.as_ptr();
}

// Release carries no text, matching the GTK controller.
let release = translate_key_event(
GHOSTTY_ACTION_RELEASE,
Some(self.gl_area.upcast_ref()),
None,
keyval,
0,
keycode,
modifier,
);

unsafe {
ghostty_surface_key(surface, press);
ghostty_surface_key(surface, release);
}
drop(text);
true
}

Expand Down Expand Up @@ -2073,6 +2102,21 @@ fn translate_key_event(
}
}

/// Map a keyval back to a hardware keycode via the display's keymap.
///
/// Real key events arrive from GTK with the keycode already filled in. Keys
/// injected through the control socket only have a keyval (parsed from a string
/// like `enter`), so we have to ask the keymap for the physical key that
/// produces it. Returns 0 when the keyval isn't on the current layout, which
/// preserves the previous behaviour rather than inventing a wrong key.
fn keycode_for_keyval(widget: &gtk::Widget, keyval: gtk::gdk::Key) -> u32 {
widget
.display()
.map_keyval(keyval)
.and_then(|keys| keys.first().map(|key| key.keycode()))
.unwrap_or(0)
}

fn key_event_text(keyval: gtk::gdk::Key) -> Option<CString> {
let ch = keyval.to_unicode()?;
if ch.is_control() {
Expand Down Expand Up @@ -2369,6 +2413,53 @@ mod tests {
assert!(key_event_text(gtk::gdk::Key::BackSpace).is_none());
}

/// The contract `send-key` depends on, in one place.
///
/// A socket-injected key needs BOTH the hardware keycode and, for printable
/// input, the text field. Supplying only one drops the key silently:
///
/// * keycode only -> `send-key a` returns OK and writes nothing.
/// * text only -> Enter/arrows/ctrl-chords cannot be encoded at all.
///
/// `key_event_text` is what decides which keys carry text, so pin its behaviour
/// for each of the three classes.
#[test]
fn send_key_text_is_supplied_for_printables_and_withheld_from_control_keys() {
// Printable: ghostty writes these from the text field.
for (key, expected) in [
(gtk::gdk::Key::a, "a"),
(gtk::gdk::Key::A, "A"),
(gtk::gdk::Key::_1, "1"),
(gtk::gdk::Key::space, " "),
] {
let text = key_event_text(key).and_then(|s| s.into_string().ok());
assert_eq!(
text.as_deref(),
Some(expected),
"printable key must carry text or it is dropped"
);
}

// Control keys: text must be WITHHELD so ghostty encodes them from the
// key + mods. Writing "\r" as literal text instead of letting ghostty encode
// Return is how you break the kitty keyboard protocol.
for key in [
gtk::gdk::Key::Return,
gtk::gdk::Key::Tab,
gtk::gdk::Key::Escape,
gtk::gdk::Key::BackSpace,
] {
assert!(
key_event_text(key).is_none(),
"{key:?} must be encoded by ghostty, not written as literal text"
);
}

// Non-textual keys have no unicode at all.
assert!(key_event_text(gtk::gdk::Key::Up).is_none());
assert!(key_event_text(gtk::gdk::Key::F1).is_none());
}

#[test]
fn shell_escape_preserves_simple_paths() {
assert_eq!(
Expand Down
Loading