From 5bd07f5fca6964fd2f5a193ea7fb9758c0c666e7 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Thu, 12 Mar 2026 22:28:13 +0400 Subject: [PATCH 1/2] Refine skill and help --- skills/claude/attyx/SKILL.md | 15 +++++++++++---- src/config/cli_help.zig | 4 ++-- src/config/cli_ipc_help.zig | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/skills/claude/attyx/SKILL.md b/skills/claude/attyx/SKILL.md index e96b3a15..0751a426 100644 --- a/skills/claude/attyx/SKILL.md +++ b/skills/claude/attyx/SKILL.md @@ -9,10 +9,6 @@ argument-hint: [action] [args...] You are running inside Attyx, a terminal emulator with a full IPC interface. You can control it programmatically. -## Available IPC Commands - -!`attyx --help 2>&1 | sed -n '/^IPC commands/,/^$/p'` - ## Identifying Panes — Stable IPC IDs Every pane has a **stable numeric ID** that never changes once assigned, even when other panes are closed. IDs are monotonically increasing integers (1, 2, 3, ...). @@ -47,6 +43,17 @@ attyx send-keys -p "$id" "print('hello')\r" attyx get-text -p "$id" ``` +### Waiting for Commands (`--wait`) +`tab create` and `split` support `--wait` to block until the spawned command exits (requires `--cmd`): +```bash +attyx tab create --cmd "make test" --wait # blocks, returns exit code + stdout +attyx split v --cmd "cargo build" --wait # same for splits +``` +The response is the process exit code (first byte) followed by captured stdout. Useful for scripting: +```bash +attyx run "make test" --wait && echo "Tests passed" +``` + ### Don't confuse titles with identity Multiple panes can have the same title (e.g. two `bash` panes). **Never rely on title matching** to find a specific pane. Always use IDs from `attyx list` or captured from creation. diff --git a/src/config/cli_help.zig b/src/config/cli_help.zig index 875085a4..682d362c 100644 --- a/src/config/cli_help.zig +++ b/src/config/cli_help.zig @@ -70,7 +70,7 @@ const ipc_header = const ipc_tabs = " " ++ d ++ "Tabs" ++ r ++ "\n" ++ - cmd("tab create [--cmd ] [--wait] ", "Create a new tab (returns index)") ++ + cmd("tab create [--cmd ] [--wait] ", "Create a new tab (returns pane ID)") ++ cmd("tab close [] ", "Close tab N (default: active)") ++ cmd("tab next" ++ r ++ " / " ++ c ++ "tab prev ", "Switch tabs") ++ cmd("tab select <1-9> ", "Switch to tab by number") ++ @@ -113,7 +113,7 @@ const ipc_misc = const ipc_sessions = " " ++ d ++ "Sessions" ++ r ++ "\n" ++ cmd("session list ", "List all daemon sessions") ++ - cmd("session create ", "Create a new empty session") ++ + cmd("session create [cwd] [-b] [name] ", "Create a session (returns ID)") ++ cmd("session switch ", "Switch to a session by ID") ++ cmd("session rename [id] ", "Rename a session") ++ cmd("session kill ", "Kill a session and all its panes") ++ diff --git a/src/config/cli_ipc_help.zig b/src/config/cli_ipc_help.zig index bfd8053d..918f1c75 100644 --- a/src/config/cli_ipc_help.zig +++ b/src/config/cli_ipc_help.zig @@ -19,7 +19,7 @@ pub const top_level = \\ focus Move focus between panes (up, down, left, right) \\ session Manage daemon sessions (list, create, kill, switch, rename) \\ send-keys Send keystrokes to a pane (supports escape sequences) - \\ send-text Send raw text to a pane (no escape processing) + \\ send-text Send text to a pane (same escape support as send-keys) \\ get-text Read visible text from a pane \\ reload Reload configuration from disk \\ theme Switch to a named theme From 71385d37ce1f3b8c4d2ccc415ca4ee84bfa56a71 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Thu, 12 Mar 2026 23:58:20 +0400 Subject: [PATCH 2/2] Proper key handling, autodelay between keys --- skills/claude/attyx/SKILL.md | 125 +++++-- src/cli/main.zig | 33 +- src/config/cli_help.zig | 7 +- src/config/cli_ipc.zig | 32 +- src/config/cli_ipc_help.zig | 91 ++--- src/ipc/client.zig | 283 ++++++++------- src/ipc/handler.zig | 4 +- src/ipc/keys.zig | 644 +++++++++++++++++++++++++++++++++++ src/ipc/protocol.zig | 4 +- 9 files changed, 1009 insertions(+), 214 deletions(-) create mode 100644 src/ipc/keys.zig diff --git a/skills/claude/attyx/SKILL.md b/skills/claude/attyx/SKILL.md index 0751a426..35f5ebf3 100644 --- a/skills/claude/attyx/SKILL.md +++ b/skills/claude/attyx/SKILL.md @@ -39,7 +39,7 @@ id=$(attyx split v --cmd python3) # returns e.g. "7" ``` **Always capture this output** so you can target the pane later without guessing: ```bash -attyx send-keys -p "$id" "print('hello')\r" +attyx send-keys -p "$id" "print('hello'){Enter}" attyx get-text -p "$id" ``` @@ -83,7 +83,7 @@ sid=$(attyx session create ~/Projects/myapp -b) Use `-s`/`--session ` to route **any** command to a specific session: ```bash attyx -s 123 tab create # create tab in session 123 -attyx -s 123 send-text "hello" -p 2 # send to pane 2 in session 123 +attyx -s 123 send-keys "hello" -p 2 # send to pane 2 in session 123 attyx -s 123 get-text -p 5 # read from pane 5 in session 123 attyx -s 123 list # list tabs/panes in session 123 ``` @@ -108,38 +108,113 @@ attyx tab close 2 # close entire tab 2 ``` This closes the specified pane/tab **without changing focus**. Plain `attyx split close` (no target) closes the focused pane — which is YOU. -### Use \r for Enter, Not \n -When sending input via `send-keys`, always use `\r` (carriage return) to submit: +### Named Keys — Use `{Enter}`, Not `\n` +`send-keys` supports `{KeyName}` syntax (case-insensitive) for all special keys: + +```bash +# Press Enter to submit a command +attyx send-keys "ls -la{Enter}" + +# Arrow keys for navigation +attyx send-keys "{Down}{Down}{Enter}" # navigate a menu +attyx send-keys "{Up}{Enter}" # rerun last command + +# Ctrl combos +attyx send-keys "{Ctrl-c}" # interrupt +attyx send-keys "{Ctrl-d}" # EOF +attyx send-keys "{Ctrl-z}" # suspend + +# Tab completion, Escape, function keys +attyx send-keys "{Tab}{Tab}" # show completions +attyx send-keys "{Escape}:wq{Enter}" # vim: save and quit +attyx send-keys "{F1}" # help in many TUIs +``` + +**Full key reference:** +| Key | Name(s) | +|-----|---------| +| Enter | `{Enter}`, `{Return}`, `{CR}` | +| Tab | `{Tab}` | +| Space | `{Space}` | +| Escape | `{Escape}`, `{Esc}` | +| Backspace | `{Backspace}`, `{BS}` | +| Delete | `{Delete}`, `{Del}` | +| Insert | `{Insert}`, `{Ins}` | +| Arrows | `{Up}`, `{Down}`, `{Left}`, `{Right}` | +| Page | `{PgUp}`, `{PgDn}`, `{PageUp}`, `{PageDown}` | +| Home/End | `{Home}`, `{End}` | +| Function | `{F1}` through `{F12}` | +| Ctrl+key | `{Ctrl-a}` through `{Ctrl-z}` | + +**Modifier combos** — prefixes are combinable (Ctrl-, Shift-, Alt-, Super-): +| Combo | Example | Use case | +|-------|---------|----------| +| Ctrl+Arrow | `{Ctrl-Right}` | Word jump in shells | +| Alt+letter | `{Alt-a}` | Alt shortcuts in TUIs | +| Shift+Tab | `{Shift-Tab}` | Reverse tab / backtab | +| Ctrl+Shift | `{Ctrl-Shift-p}` | Command palettes (CSI u) | +| Shift+F-key | `{Shift-F5}` | Modified function keys | +| Ctrl+Delete | `{Ctrl-Delete}` | Delete word forward | + +C-style escapes (`\r`, `\t`, `\xHH`, `\e`) also work but named keys are preferred for clarity. + +### Navigating TUI Applications +When interacting with interactive programs (menus, prompts, fzf, editors, etc.): + +1. **Read the screen first** — use `attyx get-text -p ` to see what's displayed +2. **Navigate with arrow keys** — `{Up}`, `{Down}` to move through lists/menus +3. **Select with Enter** — `{Enter}` to confirm a selection +4. **Type to filter** — many TUIs support typing to search/filter +5. **Use Tab for completion** — `{Tab}` cycles through options in shells and some TUIs +6. **Cancel/back with Escape** — `{Escape}` to dismiss dialogs or go back +7. **Read again after each action** — always `get-text` to verify the result + +**Example: navigating a numbered list and selecting item 3:** +```bash +attyx send-keys -p "$id" "3{Enter}" +``` + +**Example: scrolling down in a TUI and selecting:** +```bash +attyx send-keys -p "$id" "{Down}{Down}{Down}{Enter}" +output=$(attyx get-text -p "$id") +``` + +**Example: searching in fzf-style interface:** ```bash -attyx send-keys "ls -la\r" +attyx send-keys -p "$id" "search query" +sleep 0.5 # let filter update +attyx send-keys -p "$id" "{Enter}" ``` -### Reading Output — Don't Guess Sleep Times -Instead of blind `sleep N && attyx get-text`, poll until output stabilizes: +**Example: vim/editor interaction:** +```bash +attyx send-keys -p "$id" "ihello world{Escape}:wq{Enter}" +``` + +### Reading Output — Use `--wait-stable` +Instead of blind `sleep N && attyx get-text`, use `--wait-stable` to send keys and automatically wait for output to settle: ```bash -# Wait for command output to stabilize (poll every 2s, 3 stable reads = done) -stable=0; prev=""; for i in $(seq 1 15); do - sleep 2 - curr=$(attyx get-text 2>/dev/null) - if [ "$curr" = "$prev" ] && [ -n "$curr" ]; then - stable=$((stable + 1)) - [ $stable -ge 2 ] && break - else - stable=0 - fi - prev="$curr" -done -echo "$curr" -``` - -For quick commands (ls, cat, etc.) a simple `sleep 1` is fine. Use polling for anything interactive or slow (builds, AI responses, installs). +# Send a command and wait for output to stabilize (default: 300ms stable window) +attyx send-keys --wait-stable "ls -la{Enter}" + +# Custom stability window (500ms) for slower commands +attyx send-keys --wait-stable 500 "make build{Enter}" + +# With pane targeting +attyx send-keys -p 3 --wait-stable "cargo test{Enter}" +``` + +`--wait-stable [ms]` sends the keys, then polls `get-text` every 50ms until screen content is unchanged for `ms` milliseconds (default 300). The final screen content is printed to stdout. Hard timeout at 30s. + +For quick commands where you don't need the output, plain `send-keys` without `--wait-stable` is fine. Use `--wait-stable` when you need to read the result. ### Pane Targeting (Preferred) Almost all commands support `--pane` (`-p`) to target any pane by its stable ID: ```bash # IO -attyx send-keys -p 3 "ls -la\r" # send to pane 3 +attyx send-keys -p 3 "ls -la{Enter}" # send to pane 3 attyx get-text -p 3 # read from pane 3 # Split management @@ -163,7 +238,7 @@ Without `--pane`, `send-keys` and `get-text` operate on the focused pane: If the user provides arguments, interpret them as a natural language instruction: - `/attyx open a split with htop` → `attyx split v --cmd htop` -- `/attyx send "hello" to the other pane` → `attyx send-keys -p "hello"` +- `/attyx send "hello" to the other pane` → `attyx send-keys -p "hello{Enter}"` - `/attyx close the other pane` → `attyx split close -p ` - `/attyx what's on screen in the right pane` → `attyx get-text -p ` - `/attyx create a background session for ~/Projects/api` → `attyx session create ~/Projects/api -b` diff --git a/src/cli/main.zig b/src/cli/main.zig index f49f02aa..1aca37c0 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -267,13 +267,25 @@ pub fn doUninstall() void { // ── Skill auto-update ── -const skill_content = @import("skill_data").content; +const skill_content_raw = @import("skill_data").content; +const is_dev = @import("builtin").mode == .Debug; +const skill_name = if (is_dev) "attyx-dev" else "attyx"; +/// In dev builds, rewrite the frontmatter name so the skill registers as /attyx-dev. +const skill_content = if (is_dev) replaceSkillName() else skill_content_raw; + +fn replaceSkillName() []const u8 { + @setEvalBranchQuota(skill_content_raw.len * 2); + const needle = "name: attyx\n"; + const replacement = "name: attyx-dev\n"; + const idx = std.mem.indexOf(u8, skill_content_raw, needle) orelse return skill_content_raw; + return skill_content_raw[0..idx] ++ replacement ++ skill_content_raw[idx + needle.len ..]; +} /// Silently update installed skills if they exist. Called on app launch. pub fn autoUpdateSkills() void { const home = std.posix.getenv("HOME") orelse return; var file_buf: [512]u8 = undefined; - const file_path = std.fmt.bufPrint(&file_buf, "{s}/.claude/skills/attyx/SKILL.md", .{home}) catch return; + const file_path = std.fmt.bufPrint(&file_buf, "{s}/.claude/skills/{s}/SKILL.md", .{ home, skill_name }) catch return; // Only update if already installed — don't create if user never ran `attyx skill install` std.fs.accessAbsolute(file_path, .{}) catch return; @@ -306,15 +318,15 @@ fn doSkillInstall(stdout: std.fs.File) void { return; }; - // Build path: ~/.claude/skills/attyx/SKILL.md + // Build path: ~/.claude/skills/{skill_name}/SKILL.md var dir_buf: [512]u8 = undefined; - const dir_path = std.fmt.bufPrint(&dir_buf, "{s}/.claude/skills/attyx", .{home}) catch { + const dir_path = std.fmt.bufPrint(&dir_buf, "{s}/.claude/skills/{s}", .{ home, skill_name }) catch { stdout.writeAll("error: path too long\n") catch {}; return; }; var file_buf: [512]u8 = undefined; - const file_path = std.fmt.bufPrint(&file_buf, "{s}/.claude/skills/attyx/SKILL.md", .{home}) catch { + const file_path = std.fmt.bufPrint(&file_buf, "{s}/SKILL.md", .{dir_path}) catch { stdout.writeAll("error: path too long\n") catch {}; return; }; @@ -340,8 +352,9 @@ fn doSkillInstall(stdout: std.fs.File) void { return; }; - stdout.writeAll("Installed Claude Code skill to ~/.claude/skills/attyx/\n") catch {}; - stdout.writeAll("Use /attyx in Claude Code to control the terminal.\n") catch {}; + var msg_buf: [256]u8 = undefined; + const install_msg = std.fmt.bufPrint(&msg_buf, "Installed Claude Code skill to ~/.claude/skills/{s}/\nUse /{s} in Claude Code to control the terminal.\n", .{ skill_name, skill_name }) catch return; + stdout.writeAll(install_msg) catch {}; } fn doSkillUninstall(stdout: std.fs.File) void { @@ -351,7 +364,7 @@ fn doSkillUninstall(stdout: std.fs.File) void { }; var dir_buf: [512]u8 = undefined; - const dir_path = std.fmt.bufPrint(&dir_buf, "{s}/.claude/skills/attyx", .{home}) catch { + const dir_path = std.fmt.bufPrint(&dir_buf, "{s}/.claude/skills/{s}", .{ home, skill_name }) catch { stdout.writeAll("error: path too long\n") catch {}; return; }; @@ -367,7 +380,9 @@ fn doSkillUninstall(stdout: std.fs.File) void { return; }; - stdout.writeAll("Removed Claude Code skill from ~/.claude/skills/attyx/\n") catch {}; + var msg_buf: [256]u8 = undefined; + const rm_msg = std.fmt.bufPrint(&msg_buf, "Removed Claude Code skill from ~/.claude/skills/{s}/\n", .{skill_name}) catch return; + stdout.writeAll(rm_msg) catch {}; } const skill_help = diff --git a/src/config/cli_help.zig b/src/config/cli_help.zig index 682d362c..4b6e13bf 100644 --- a/src/config/cli_help.zig +++ b/src/config/cli_help.zig @@ -94,9 +94,8 @@ const ipc_focus = const ipc_io = " " ++ d ++ "Input / Output" ++ r ++ "\n" ++ - cmd("send-keys [-p ] ", "Send keystrokes to a pane") ++ - cont(" " ++ d ++ "Escapes: \\n \\t \\x03 \\x04 \\x1b \\x7f \\x1b[A/B/C/D" ++ r) ++ - cmd("send-text [-p ] ", "Send raw text (same escape support)") ++ + cmd("send-keys [-p ] [--wait-stable] ", "Send keystrokes to a pane") ++ + cont(" " ++ d ++ "Named: {Enter} {Up} {Down} {Tab} {Ctrl-c} ... or \\n \\xHH" ++ r) ++ cmd("get-text [-p ] [--json] ", "Read visible screen text from a pane") ++ "\n"; @@ -123,7 +122,7 @@ const agent_workflow = b ++ "AGENT WORKFLOW" ++ r ++ "\n" ++ ex("id=$(attyx split v --cmd \"tool\") " ++ r ++ d ++ "# open pane, capture ID") ++ ex("attyx get-text -p \"$id\" " ++ r ++ d ++ "# read its output") ++ - ex("attyx send-keys -p \"$id\" \"input\\n\" " ++ r ++ d ++ "# type into it") ++ + ex("attyx send-keys -p \"$id\" \"input{Enter}\" " ++ r ++ d ++ "# type into it") ++ ex("attyx get-text -p \"$id\" " ++ r ++ d ++ "# read the result") ++ ex("attyx split close -p \"$id\" " ++ r ++ d ++ "# clean up by ID") ++ "\n"; diff --git a/src/config/cli_ipc.zig b/src/config/cli_ipc.zig index 1ea9bcf7..9c131d76 100644 --- a/src/config/cli_ipc.zig +++ b/src/config/cli_ipc.zig @@ -32,7 +32,6 @@ pub const IpcCommand = enum { focus_left, focus_right, send_keys, - send_text, get_text, config_reload, theme_set, @@ -60,6 +59,7 @@ pub const IpcRequest = struct { target_pid: ?u32 = null, json_output: bool = false, wait: bool = false, + wait_stable_ms: u32 = 0, background: bool = false, /// Global session targeting. 0 = current/attached session (default). target_session: u32 = 0, @@ -175,13 +175,9 @@ pub fn parse(args: []const [:0]const u8) ?IpcRequest { if (std.mem.eql(u8, sub, "split")) break :blk parseSplit(args, start, target_pid, json_output); if (std.mem.eql(u8, sub, "focus")) break :blk parseFocus(args, start, target_pid, json_output); - if (std.mem.eql(u8, sub, "send-keys")) { + if (std.mem.eql(u8, sub, "send-keys") or std.mem.eql(u8, sub, "send-text")) { if (hasHelp(args, start)) showHelp(help.send_keys); - break :blk parseSendText(args, start, .send_keys, target_pid, json_output); - } - if (std.mem.eql(u8, sub, "send-text")) { - if (hasHelp(args, start)) showHelp(help.send_text); - break :blk parseSendText(args, start, .send_text, target_pid, json_output); + break :blk parseSendText(args, start, target_pid, json_output); } if (std.mem.eql(u8, sub, "get-text")) { if (hasHelp(args, start)) showHelp(help.get_text); @@ -426,9 +422,9 @@ fn parseFocus(args: []const [:0]const u8, start: usize, target_pid: ?u32, json_o // Send text / keys // --------------------------------------------------------------------------- -fn parseSendText(args: []const [:0]const u8, start: usize, cmd: IpcCommand, target_pid: ?u32, json_output: bool) ?IpcRequest { +fn parseSendText(args: []const [:0]const u8, start: usize, target_pid: ?u32, json_output: bool) ?IpcRequest { var result = IpcRequest{ - .command = cmd, + .command = .send_keys, .target_pid = target_pid, .json_output = json_output, }; @@ -440,6 +436,18 @@ fn parseSendText(args: []const [:0]const u8, start: usize, cmd: IpcCommand, targ if (i + 1 >= args.len) fatal("--pane requires a pane ID"); i += 1; parsePaneArg(args[i], &result); + } else if (std.mem.eql(u8, arg, "--wait-stable")) { + // Optional ms argument (default 300ms) + if (i + 1 < args.len) { + if (std.fmt.parseInt(u32, args[i + 1], 10)) |ms| { + result.wait_stable_ms = ms; + i += 1; + } else |_| { + result.wait_stable_ms = 300; + } + } else { + result.wait_stable_ms = 300; + } } else if (result.text_arg.len == 0) { result.text_arg = arg; } @@ -447,11 +455,7 @@ fn parseSendText(args: []const [:0]const u8, start: usize, cmd: IpcCommand, targ } if (result.text_arg.len == 0) { - if (cmd == .send_keys) { - printHelp(help.send_keys); - } else { - printHelp(help.send_text); - } + printHelp(help.send_keys); return null; } return result; diff --git a/src/config/cli_ipc_help.zig b/src/config/cli_ipc_help.zig index 918f1c75..b5625ceb 100644 --- a/src/config/cli_ipc_help.zig +++ b/src/config/cli_ipc_help.zig @@ -19,7 +19,7 @@ pub const top_level = \\ focus Move focus between panes (up, down, left, right) \\ session Manage daemon sessions (list, create, kill, switch, rename) \\ send-keys Send keystrokes to a pane (supports escape sequences) - \\ send-text Send text to a pane (same escape support as send-keys) + \\ Alias: send-text \\ get-text Read visible text from a pane \\ reload Reload configuration from disk \\ theme Switch to a named theme @@ -39,9 +39,9 @@ pub const top_level = \\ attyx tab create --cmd htop Open a tab running htop \\ attyx split vertical --cmd claude Open a vertical split running claude \\ attyx focus right Move focus to the right pane - \\ attyx send-keys "ls -la\n" Type "ls -la" and press Enter - \\ attyx send-keys -p 3 "ls\n" Send to pane 3 (no focus change) - \\ attyx send-text "hello" Write "hello" to PTY (no newline) + \\ attyx send-keys "ls -la{Enter}" Type "ls -la" and press Enter + \\ attyx send-keys -p 3 "ls{Enter}" Send to pane 3 (no focus change) + \\ attyx send-keys --wait-stable "ls{Enter}" Send and wait for output \\ attyx get-text Read what's on screen \\ attyx get-text --pane 5 Read from pane 5 \\ attyx list --json Get structured tab/pane info @@ -57,7 +57,7 @@ pub const top_level = \\Typical agent workflow: \\ 1. id=$(attyx split v --cmd "your-tool") # open pane, capture stable ID \\ 2. attyx get-text -p "$id" # read its output - \\ 3. attyx send-keys -p "$id" "input\n" # send input without focus + \\ 3. attyx send-keys -p "$id" "input{Enter}" # send input without focus \\ 4. attyx get-text -p "$id" # read the result \\ 5. attyx split close -p "$id" # clean up by ID \\ @@ -229,7 +229,7 @@ pub const focus = \\ right Focus the pane to the right \\ \\Focus determines which pane receives keystrokes by default. - \\Use --pane on send-keys/send-text to target any pane without changing focus. + \\Use --pane on send-keys to target any pane without changing focus. \\ \\Examples: \\ attyx focus right @@ -331,58 +331,61 @@ pub const session_rename = pub const send_keys = \\Send keystrokes to a pane. \\ - \\Usage: attyx send-keys [--pane ] + \\Usage: attyx send-keys [--pane ] [--wait-stable [ms]] \\ \\The key string supports C-style escape sequences. This is the primary \\way for agents to type into a terminal pane. \\ - \\Options: - \\ --pane, -p Target a specific pane by its stable ID instead of - \\ the focused one. Pane IDs are shown in 'attyx list' - \\ output and returned by creation commands. - \\ - \\Escape sequences: - \\ \n Enter / newline - \\ \t Tab - \\ \x03 Ctrl-C (interrupt) - \\ \x04 Ctrl-D (EOF) - \\ \x1a Ctrl-Z (suspend) - \\ \x1b Escape - \\ \x1b[A Arrow up - \\ \x1b[B Arrow down - \\ \x1b[C Arrow right - \\ \x1b[D Arrow left - \\ \x7f Backspace - \\ - \\Examples: - \\ attyx send-keys "ls -la\n" Type ls -la and press Enter - \\ attyx send-keys --pane 3 "ls\n" Send to pane 3 (no focus change) - \\ attyx send-keys --pane 5 "\x03" Send Ctrl-C to pane 5 - \\ attyx send-keys "\x1b[A\n" Arrow up then Enter (rerun last cmd) - \\ attyx send-keys "q" Press q (e.g. to quit less/man) - \\ -; - -pub const send_text = - \\Send text to a pane. - \\ - \\Usage: attyx send-text [--pane ] - \\ - \\The text is written to the pane's PTY. Supports the same C-style - \\escape sequences as send-keys (\n, \t, \x03, etc.). + \\Aliases: send-text (identical behavior) \\ \\Options: \\ --pane, -p Target a specific pane by its stable ID instead of \\ the focused one. Pane IDs are shown in 'attyx list' \\ output and returned by creation commands. + \\ --wait-stable [ms] After sending, poll screen content and wait until it + \\ stabilizes, then print the final screen text to stdout. + \\ Default: 300ms. Max timeout: 30s. + \\ Replaces manual sleep + get-text polling loops. + \\ + \\Named keys (case-insensitive, inside braces): + \\ {Enter} Carriage return {Tab} Tab + \\ {Space} Space {Escape} Escape + \\ {Backspace} Backspace (0x7f) {Delete} Delete + \\ {Up} Arrow up {Down} Arrow down + \\ {Left} Arrow left {Right} Arrow right + \\ {Home} Home {End} End + \\ {PgUp} Page Up {PgDn} Page Down + \\ {Insert} Insert + \\ {F1}-{F12} Function keys + \\ {Ctrl-a} Ctrl+A (works for a-z, e.g. {Ctrl-c} = interrupt) + \\ + \\Modifier combos (prefix with Ctrl-, Shift-, Alt-, combinable): + \\ {Ctrl-Up} Ctrl+Arrow (word jump in shells) + \\ {Ctrl-Shift-Up} Ctrl+Shift+Arrow + \\ {Alt-a} Alt+A (ESC prefix) + \\ {Shift-Tab} Backtab (reverse tab) + \\ {Shift-F5} Shift+F5 + \\ {Ctrl-Shift-p} Ctrl+Shift+P (CSI u encoding) + \\ + \\C-style escape sequences (also supported): + \\ \n \t \r \e \xHH \\ \' \" \0 \a \b \\ \\Examples: - \\ attyx send-text "hello" Write "hello" (no newline) - \\ attyx send-text --pane 3 "hello" Write to pane 3 - \\ attyx send-text --pane 5 "echo hi\n" Write to pane 5 + \\ attyx send-keys "ls -la{Enter}" Type ls -la and press Enter + \\ attyx send-keys -p 3 "ls{Enter}" Send to pane 3 (no focus change) + \\ attyx send-keys -p 5 "{Ctrl-c}" Send Ctrl-C to pane 5 + \\ attyx send-keys "{Up}{Enter}" Arrow up then Enter (rerun last) + \\ attyx send-keys "q" Press q (e.g. to quit less/man) + \\ attyx send-keys "{Down}{Down}{Enter}" Navigate a menu: down twice, select + \\ attyx send-keys "{Tab}{Tab}{Enter}" Tab through options, then confirm + \\ attyx send-keys --wait-stable "ls{Enter}" Send, wait for output, print it + \\ attyx send-keys --wait-stable 500 "make{Enter}" 500ms stable window + \\ attyx send-keys "{Escape}:wq{Enter}" Vim: exit with save \\ ; +// send_text removed — send-text is now an alias for send-keys (same help) + pub const get_text = \\Read visible text from a pane. \\ diff --git a/src/ipc/client.zig b/src/ipc/client.zig index 90518b7c..347c21da 100644 --- a/src/ipc/client.zig +++ b/src/ipc/client.zig @@ -7,6 +7,7 @@ const std = @import("std"); const posix = std.posix; const protocol = @import("protocol.zig"); +const keys = @import("keys.zig"); const session_connect = @import("../app/session_connect.zig"); const max_response = 65536; @@ -138,6 +139,19 @@ pub fn run(args: []const [:0]const u8) void { std.process.exit(1); }; + // Discover socket early — needed for both paths + var sock_buf: [256]u8 = undefined; + const socket_path = discoverSocket(&sock_buf, parsed.target_pid) orelse { + writeStderr("error: no running Attyx instance found\n"); + std.process.exit(1); + }; + + // For send_keys: use token-based sending with inter-key delays + if (parsed.command == .send_keys) { + sendKeysTokenized(socket_path, parsed); + return; + } + // Build the request message var req_buf: [protocol.header_size + 4096]u8 = undefined; var request = buildRequest(&req_buf, parsed) catch { @@ -154,13 +168,6 @@ pub fn run(args: []const [:0]const u8) void { }; } - // Discover socket - var sock_buf: [256]u8 = undefined; - const socket_path = discoverSocket(&sock_buf, parsed.target_pid) orelse { - writeStderr("error: no running Attyx instance found\n"); - std.process.exit(1); - }; - // Send and receive var resp_buf: [max_response]u8 = undefined; const resp = sendCommand(socket_path, request, &resp_buf) catch |err| { @@ -219,6 +226,80 @@ pub fn run(args: []const [:0]const u8) void { } } +/// Send keys using the token iterator. Inserts a 30ms pause after each named +/// key token to let the target TUI process the input and redraw. This prevents +/// race conditions like {Down}{Enter} selecting the wrong menu item. +fn sendKeysTokenized(socket_path: []const u8, parsed: @import("../config/cli_ipc.zig").IpcRequest) void { + const inter_key_delay_ns: u64 = 30_000_000; // 30ms + + var iter = keys.KeyTokenIter{ .input = parsed.text_arg }; + var tok_buf: [4096]u8 = undefined; + var sent_any = false; + + while (iter.next(&tok_buf)) |token| { + const payload = tok_buf[0..token.len]; + + // Build and send the IPC message for this token + var req_buf: [protocol.header_size + 4200]u8 = undefined; + const request = buildSendKeysRequest(&req_buf, payload, parsed.pane_id, parsed.target_session) catch continue; + + var resp_buf: [max_response]u8 = undefined; + const resp = sendCommand(socket_path, request, &resp_buf) catch continue; + if (resp.msg_type == .err) { + writeStderr("error: "); + std.fs.File.stderr().writeAll(resp.payload) catch {}; + std.fs.File.stderr().writeAll("\n") catch {}; + std.process.exit(1); + } + + // Pause after named keys to let the TUI process and redraw + if (token.is_named_key and sent_any) { + // We delayed *before* this send via the previous iteration's delay, + // but we also need to delay *after* this named key for the next token. + } + if (token.is_named_key) { + std.posix.nanosleep(0, inter_key_delay_ns); + } + sent_any = true; + } + + // Handle --wait-stable after all tokens sent + if (parsed.wait_stable_ms > 0) { + const stdout = std.fs.File.stdout(); + const stable_text = waitStable( + socket_path, + parsed.wait_stable_ms, + parsed.pane_id, + parsed.target_session, + ); + if (stable_text.len > 0) { + stdout.writeAll(stable_text) catch {}; + if (stable_text[stable_text.len - 1] != '\n') { + stdout.writeAll("\n") catch {}; + } + } + } +} + +/// Build a send_keys (or send_keys_pane) request for a single chunk of processed bytes. +fn buildSendKeysRequest(buf: []u8, payload: []const u8, pane_id: u32, target_session: u32) ![]u8 { + var inner_buf: [protocol.header_size + 4200]u8 = undefined; + const inner = if (pane_id != 0) blk: { + var pane_payload: [4100]u8 = undefined; + std.mem.writeInt(u32, pane_payload[0..4], pane_id, .little); + const plen = @min(payload.len, pane_payload.len - 4); + @memcpy(pane_payload[4 .. 4 + plen], payload[0..plen]); + break :blk try protocol.encodeMessage(&inner_buf, .send_keys_pane, pane_payload[0 .. 4 + plen]); + } else try protocol.encodeMessage(&inner_buf, .send_keys, payload); + + if (target_session != 0) { + return wrapSessionEnvelope(buf, inner, target_session); + } + + @memcpy(buf[0..inner.len], inner); + return buf[0..inner.len]; +} + fn buildRequest(buf: []u8, parsed: @import("../config/cli_ipc.zig").IpcRequest) ![]u8 { return switch (parsed.command) { .tab_create => protocol.encodeMessage(buf, if (parsed.wait) .tab_create_wait else .tab_create, parsed.text_arg), @@ -273,24 +354,21 @@ fn buildRequest(buf: []u8, parsed: @import("../config/cli_ipc.zig").IpcRequest) .focus_down => protocol.encodeMessage(buf, .focus_down, ""), .focus_left => protocol.encodeMessage(buf, .focus_left, ""), .focus_right => protocol.encodeMessage(buf, .focus_right, ""), - .send_keys, .send_text => |cmd| blk: { + .send_keys => blk: { // Process C-style escape sequences: \n \t \x03 \\ etc. var esc_buf: [4096]u8 = undefined; const processed = unescapeKeys(parsed.text_arg, &esc_buf); - const is_keys = (cmd == .send_keys); // Pane-targeted variant: prepend [pane_id:u32 LE] to payload if (parsed.pane_id != 0) { - const msg_type: protocol.MessageType = if (is_keys) .send_keys_pane else .send_text_pane; var payload_buf: [4100]u8 = undefined; std.mem.writeInt(u32, payload_buf[0..4], parsed.pane_id, .little); const plen = @min(processed.len, payload_buf.len - 4); @memcpy(payload_buf[4 .. 4 + plen], processed[0..plen]); - break :blk protocol.encodeMessage(buf, msg_type, payload_buf[0 .. 4 + plen]); + break :blk protocol.encodeMessage(buf, .send_keys_pane, payload_buf[0 .. 4 + plen]); } - const msg_type: protocol.MessageType = if (is_keys) .send_keys else .send_text; - break :blk protocol.encodeMessage(buf, msg_type, processed); + break :blk protocol.encodeMessage(buf, .send_keys, processed); }, .get_text => blk: { if (parsed.pane_id != 0) { @@ -376,110 +454,87 @@ fn wrapSessionEnvelope(buf: []u8, inner_msg: []const u8, session_id: u32) ![]u8 return buf[0 .. protocol.header_size + envelope_payload_len]; } -/// Process C-style escape sequences in a send-keys string. -/// Supports: \n \t \r \\ \' \" \0 \a \b \e (ESC) \xHH -fn unescapeKeys(input: []const u8, out: []u8) []const u8 { - var i: usize = 0; - var o: usize = 0; - while (i < input.len and o < out.len) { - if (input[i] != '\\' or i + 1 >= input.len) { - out[o] = input[i]; - o += 1; - i += 1; - continue; +/// Delegate to keys.zig for escape processing + named key resolution. +const unescapeKeys = keys.unescapeKeys; + +/// Poll get-text until screen content stabilizes for `stable_ms` milliseconds. +/// Returns the final screen text. Hard timeout at 30 seconds. +fn waitStable(socket_path: []const u8, stable_ms: u32, pane_id: u32, target_session: u32) []const u8 { + const poll_interval_ms: u64 = 50; + const hard_timeout_ms: u64 = 30_000; + var elapsed_ms: u64 = 0; + var stable_since_ms: u64 = 0; + var prev_hash: u64 = 0; + var has_prev: bool = false; + + // Static buffers for get-text request and response + var gt_req_buf: [protocol.header_size + 4096]u8 = undefined; + var gt_resp_buf: [max_response]u8 = undefined; + var last_payload: [max_response]u8 = undefined; + var last_payload_len: usize = 0; + + // Build the get-text request once + const gt_request = buildGetTextRequest(>_req_buf, pane_id, target_session) catch return ""; + + while (elapsed_ms < hard_timeout_ms) { + std.posix.nanosleep(0, @intCast(poll_interval_ms * 1_000_000)); + elapsed_ms += poll_interval_ms; + + const gt_resp = sendCommand(socket_path, gt_request, >_resp_buf) catch continue; + if (gt_resp.msg_type != .success) continue; + + const hash = std.hash.Wyhash.hash(0, gt_resp.payload); + + if (has_prev and hash == prev_hash) { + stable_since_ms += poll_interval_ms; + if (stable_since_ms >= stable_ms) { + // Content stable — return it + @memcpy(last_payload[0..gt_resp.payload.len], gt_resp.payload); + last_payload_len = gt_resp.payload.len; + return last_payload[0..last_payload_len]; + } + } else { + stable_since_ms = 0; + prev_hash = hash; + has_prev = true; } - // Escape sequence - i += 1; // skip backslash - switch (input[i]) { - 'n' => { - out[o] = '\n'; - o += 1; - i += 1; - }, - 't' => { - out[o] = '\t'; - o += 1; - i += 1; - }, - 'r' => { - out[o] = '\r'; - o += 1; - i += 1; - }, - '\\' => { - out[o] = '\\'; - o += 1; - i += 1; - }, - '\'' => { - out[o] = '\''; - o += 1; - i += 1; - }, - '"' => { - out[o] = '"'; - o += 1; - i += 1; - }, - '0' => { - out[o] = 0; - o += 1; - i += 1; - }, - 'a' => { - out[o] = 0x07; // BEL - o += 1; - i += 1; - }, - 'b' => { - out[o] = 0x08; // BS - o += 1; - i += 1; - }, - 'e' => { - out[o] = 0x1b; // ESC - o += 1; - i += 1; - }, - 'x' => { - // \xHH — two hex digits - i += 1; - if (i + 1 < input.len) { - if (std.fmt.parseInt(u8, input[i .. i + 2], 16)) |byte| { - out[o] = byte; - o += 1; - i += 2; - } else |_| { - // Invalid hex — emit literal \x - out[o] = '\\'; - o += 1; - if (o < out.len) { - out[o] = 'x'; - o += 1; - } - } - } else { - out[o] = '\\'; - o += 1; - if (o < out.len) { - out[o] = 'x'; - o += 1; - } - } - }, - else => { - // Unknown escape — emit literal backslash + char - out[o] = '\\'; - o += 1; - if (o < out.len) { - out[o] = input[i]; - o += 1; - } - i += 1; - }, + + // Always save last payload + @memcpy(last_payload[0..gt_resp.payload.len], gt_resp.payload); + last_payload_len = gt_resp.payload.len; + } + + // Hard timeout — return what we have + warning + writeStderr("warning: --wait-stable timed out after 30s\n"); + return last_payload[0..last_payload_len]; +} + +/// Build a get-text IPC request, optionally wrapped in a session envelope. +fn buildGetTextRequest(buf: []u8, pane_id: u32, target_session: u32) ![]u8 { + var inner_buf: [protocol.header_size + 8]u8 = undefined; + const inner = if (pane_id != 0) blk: { + var payload: [4]u8 = undefined; + std.mem.writeInt(u32, &payload, pane_id, .little); + break :blk try protocol.encodeMessage(&inner_buf, .get_text_pane, &payload); + } else try protocol.encodeMessage(&inner_buf, .get_text, ""); + + if (target_session != 0) { + // Wrap in session envelope + const inner_type = inner[4]; + const inner_payload = inner[protocol.header_size..]; + const envelope_payload_len = 4 + 1 + inner_payload.len; + if (buf.len < protocol.header_size + envelope_payload_len) return error.BufferTooSmall; + protocol.encodeHeader(buf[0..protocol.header_size], .session_envelope, @intCast(envelope_payload_len)); + std.mem.writeInt(u32, buf[protocol.header_size..][0..4], target_session, .little); + buf[protocol.header_size + 4] = inner_type; + if (inner_payload.len > 0) { + @memcpy(buf[protocol.header_size + 5 .. protocol.header_size + 5 + inner_payload.len], inner_payload); } + return buf[0 .. protocol.header_size + envelope_payload_len]; } - return out[0..o]; + + @memcpy(buf[0..inner.len], inner); + return buf[0..inner.len]; } fn writeStderr(msg: []const u8) void { diff --git a/src/ipc/handler.zig b/src/ipc/handler.zig index 39e3f7dd..c0c51921 100644 --- a/src/ipc/handler.zig +++ b/src/ipc/handler.zig @@ -184,7 +184,7 @@ pub fn handle(cmd: *queue.IpcCommand, ctx: *PtyThreadCtx) void { sendOk(cmd, ""); }, - // ── Text / IO ── + // ── Text / IO ── (send_text is a deprecated alias, kept for wire compat) .send_keys, .send_text => { if (cmd.payload_len > 0) { const text = cmd.payload[0..cmd.payload_len]; @@ -192,7 +192,7 @@ pub fn handle(cmd: *queue.IpcCommand, ctx: *PtyThreadCtx) void { } sendOk(cmd, ""); }, - .send_keys_pane, .send_text_pane => { + .send_keys_pane, .send_text_pane => { // send_text_pane is deprecated alias if (cmd.payload_len < 5) { sendError(cmd, "missing pane ID or text"); return; diff --git a/src/ipc/keys.zig b/src/ipc/keys.zig new file mode 100644 index 00000000..acf37b7b --- /dev/null +++ b/src/ipc/keys.zig @@ -0,0 +1,644 @@ +// Attyx — Send-keys escape processing +// +// Handles C-style escape sequences (\n, \t, \xHH, etc.) and named key +// tokens ({Enter}, {Up}, {Ctrl-c}, {Ctrl-Shift-Up}, etc.) for the +// send-keys IPC command. +// +// Modifier combos on special keys use xterm-style encoding: +// {Ctrl-Up} → ESC[1;5A (modifier 5 = 1 + ctrl) +// {Shift-Tab} → ESC[Z (standard backtab) +// {Alt-a} → ESC a (meta prefix) +// {Ctrl-Shift-Up} → ESC[1;6A (modifier 6 = 1 + ctrl + shift) + +const std = @import("std"); + +/// Process C-style escape sequences and {KeyName} tokens in a send-keys string. +pub fn unescapeKeys(input: []const u8, out: []u8) []const u8 { + var i: usize = 0; + var o: usize = 0; + while (i < input.len and o < out.len) { + // Named key tokens: {KeyName} + if (input[i] == '{') { + if (resolveNamedKey(input[i..], out[o..])) |result| { + o += result.written; + i += result.advance; + continue; + } + // Not a valid key name — emit literal '{' + out[o] = '{'; + o += 1; + i += 1; + continue; + } + + if (input[i] != '\\' or i + 1 >= input.len) { + out[o] = input[i]; + o += 1; + i += 1; + continue; + } + // C-style escape sequence + i += 1; // skip backslash + switch (input[i]) { + 'n' => { + out[o] = '\n'; + o += 1; + i += 1; + }, + 't' => { + out[o] = '\t'; + o += 1; + i += 1; + }, + 'r' => { + out[o] = '\r'; + o += 1; + i += 1; + }, + '\\' => { + out[o] = '\\'; + o += 1; + i += 1; + }, + '\'' => { + out[o] = '\''; + o += 1; + i += 1; + }, + '"' => { + out[o] = '"'; + o += 1; + i += 1; + }, + '0' => { + out[o] = 0; + o += 1; + i += 1; + }, + 'a' => { + out[o] = 0x07; // BEL + o += 1; + i += 1; + }, + 'b' => { + out[o] = 0x08; // BS + o += 1; + i += 1; + }, + 'e' => { + out[o] = 0x1b; // ESC + o += 1; + i += 1; + }, + 'x' => { + i += 1; + if (i + 1 < input.len) { + if (std.fmt.parseInt(u8, input[i .. i + 2], 16)) |byte| { + out[o] = byte; + o += 1; + i += 2; + } else |_| { + out[o] = '\\'; + o += 1; + if (o < out.len) { + out[o] = 'x'; + o += 1; + } + } + } else { + out[o] = '\\'; + o += 1; + if (o < out.len) { + out[o] = 'x'; + o += 1; + } + } + }, + else => { + out[o] = '\\'; + o += 1; + if (o < out.len) { + out[o] = input[i]; + o += 1; + } + i += 1; + }, + } + } + return out[0..o]; +} + +// --------------------------------------------------------------------------- +// Named key resolution +// --------------------------------------------------------------------------- + +const ResolvedKey = struct { written: usize, advance: usize }; + +/// Parse a {KeyName} token at the start of `s`, write the escape sequence +/// into `out`. Returns bytes written + input bytes consumed, or null. +fn resolveNamedKey(s: []const u8, out: []u8) ?ResolvedKey { + if (s.len < 3 or s[0] != '{') return null; + const close = std.mem.indexOfScalar(u8, s[1..], '}') orelse return null; + const name = s[1 .. 1 + close]; + const adv = close + 2; // skip '{' + name + '}' + + // Lowercase the name for case-insensitive matching + if (name.len == 0 or name.len > 24) return null; + var lower: [24]u8 = undefined; + for (name, 0..) |ch, idx| { + lower[idx] = std.ascii.toLower(ch); + } + const key = lower[0..name.len]; + + // Parse modifier prefixes: ctrl-, shift-, alt-, super- (combinable) + var mods = Mods{}; + var base = key; + while (true) { + if (startsWith(base, "ctrl-")) { + mods.ctrl = true; + base = base[5..]; + } else if (startsWith(base, "shift-")) { + mods.shift = true; + base = base[6..]; + } else if (startsWith(base, "alt-")) { + mods.alt = true; + base = base[4..]; + } else if (startsWith(base, "super-")) { + mods.super = true; + base = base[6..]; + } else break; + } + + if (base.len == 0) return null; + + const written = encodeKey(base, mods, out) orelse return null; + return .{ .written = written, .advance = adv }; +} + +const Mods = struct { + shift: bool = false, + alt: bool = false, + ctrl: bool = false, + super: bool = false, + + fn hasMods(self: Mods) bool { + return self.shift or self.alt or self.ctrl or self.super; + } + + /// Xterm modifier parameter: 1 + shift(1) + alt(2) + ctrl(4) + super(8) + fn toCSI(self: Mods) u8 { + var m: u8 = 1; + if (self.shift) m += 1; + if (self.alt) m += 2; + if (self.ctrl) m += 4; + if (self.super) m += 8; + return m; + } +}; + +/// Encode a base key name + modifiers into `out`. Returns bytes written. +fn encodeKey(base: []const u8, mods: Mods, out: []u8) ?usize { + // Special case: Shift-Tab → backtab + if (eql(base, "tab") and mods.shift and !mods.ctrl and !mods.alt) { + return copyStatic("\x1b[Z", out); + } + + // Single letter with modifiers + if (base.len == 1 and base[0] >= 'a' and base[0] <= 'z') { + return encodeSingleChar(base[0], mods, out); + } + + // Try as a special key (arrow, function, etc.) + if (specialKeyInfo(base)) |info| { + return encodeSpecialKey(info, mods, out); + } + + // Unmodified simple keys (no modifiers parsed, or modifiers not applicable) + if (!mods.hasMods()) { + if (simpleKeySeq(base)) |seq| { + return copyStatic(seq, out); + } + } + + return null; +} + +/// Encode a single letter with modifiers. +fn encodeSingleChar(ch: u8, mods: Mods, out: []u8) ?usize { + if (!mods.hasMods()) return null; // bare letter not handled here + + // Ctrl+letter (no other mods) → control byte + if (mods.ctrl and !mods.shift and !mods.alt and !mods.super) { + if (out.len < 1) return null; + out[0] = ch - 'a' + 1; + return 1; + } + + // Alt+letter (no other mods) → ESC prefix + if (mods.alt and !mods.ctrl and !mods.shift and !mods.super) { + if (out.len < 2) return null; + out[0] = 0x1b; + out[1] = ch; + return 2; + } + + // Alt+Ctrl+letter → ESC + control byte + if (mods.alt and mods.ctrl and !mods.shift and !mods.super) { + if (out.len < 2) return null; + out[0] = 0x1b; + out[1] = ch - 'a' + 1; + return 2; + } + + // Complex modifier combos on letters → CSI u encoding + // ESC[{codepoint};{modifier}u + return fmtCSIu(ch, mods.toCSI(), out); +} + +// Keys that use ESC[1;{mod}{final} format when modified +const SpecialKeyInfo = struct { + // For SS3 keys (F1-F4): unmodified = ESC O {final}, modified = ESC[1;{mod}{final} + // For CSI keys (arrows, etc.): unmodified = ESC[{final}, modified = ESC[1;{mod}{final} + // For tilde keys (F5+, Ins, Del, PgUp, PgDn): unmodified = ESC[{N}~, modified = ESC[{N};{mod}~ + kind: enum { csi_final, tilde, ss3 }, + code: u8, // final char for csi_final/ss3, number for tilde + unmodified: []const u8, // fallback for no-modifier case +}; + +fn specialKeyInfo(base: []const u8) ?SpecialKeyInfo { + // Arrows + if (eql(base, "up")) return .{ .kind = .csi_final, .code = 'A', .unmodified = "\x1b[A" }; + if (eql(base, "down")) return .{ .kind = .csi_final, .code = 'B', .unmodified = "\x1b[B" }; + if (eql(base, "right")) return .{ .kind = .csi_final, .code = 'C', .unmodified = "\x1b[C" }; + if (eql(base, "left")) return .{ .kind = .csi_final, .code = 'D', .unmodified = "\x1b[D" }; + if (eql(base, "home")) return .{ .kind = .csi_final, .code = 'H', .unmodified = "\x1b[H" }; + if (eql(base, "end")) return .{ .kind = .csi_final, .code = 'F', .unmodified = "\x1b[F" }; + + // Tilde keys + if (eql(base, "insert") or eql(base, "ins")) return .{ .kind = .tilde, .code = 2, .unmodified = "\x1b[2~" }; + if (eql(base, "delete") or eql(base, "del")) return .{ .kind = .tilde, .code = 3, .unmodified = "\x1b[3~" }; + if (eql(base, "pgup") or eql(base, "pageup")) return .{ .kind = .tilde, .code = 5, .unmodified = "\x1b[5~" }; + if (eql(base, "pgdn") or eql(base, "pagedown")) return .{ .kind = .tilde, .code = 6, .unmodified = "\x1b[6~" }; + + // SS3 function keys (F1-F4) + if (eql(base, "f1")) return .{ .kind = .ss3, .code = 'P', .unmodified = "\x1bOP" }; + if (eql(base, "f2")) return .{ .kind = .ss3, .code = 'Q', .unmodified = "\x1bOQ" }; + if (eql(base, "f3")) return .{ .kind = .ss3, .code = 'R', .unmodified = "\x1bOR" }; + if (eql(base, "f4")) return .{ .kind = .ss3, .code = 'S', .unmodified = "\x1bOS" }; + + // Tilde function keys (F5-F12) + if (eql(base, "f5")) return .{ .kind = .tilde, .code = 15, .unmodified = "\x1b[15~" }; + if (eql(base, "f6")) return .{ .kind = .tilde, .code = 17, .unmodified = "\x1b[17~" }; + if (eql(base, "f7")) return .{ .kind = .tilde, .code = 18, .unmodified = "\x1b[18~" }; + if (eql(base, "f8")) return .{ .kind = .tilde, .code = 19, .unmodified = "\x1b[19~" }; + if (eql(base, "f9")) return .{ .kind = .tilde, .code = 20, .unmodified = "\x1b[20~" }; + if (eql(base, "f10")) return .{ .kind = .tilde, .code = 21, .unmodified = "\x1b[21~" }; + if (eql(base, "f11")) return .{ .kind = .tilde, .code = 23, .unmodified = "\x1b[23~" }; + if (eql(base, "f12")) return .{ .kind = .tilde, .code = 24, .unmodified = "\x1b[24~" }; + + return null; +} + +/// Encode a special key (arrow, F-key, etc.) with optional modifiers. +fn encodeSpecialKey(info: SpecialKeyInfo, mods: Mods, out: []u8) ?usize { + if (!mods.hasMods()) { + return copyStatic(info.unmodified, out); + } + + const mod = mods.toCSI(); + var buf: [16]u8 = undefined; + + switch (info.kind) { + // ESC[1;{mod}{final} + .csi_final, .ss3 => { + const n = std.fmt.bufPrint(&buf, "\x1b[1;{d}{c}", .{ mod, info.code }) catch return null; + return copyStatic(n, out); + }, + // ESC[{N};{mod}~ + .tilde => { + const n = std.fmt.bufPrint(&buf, "\x1b[{d};{d}~", .{ info.code, mod }) catch return null; + return copyStatic(n, out); + }, + } +} + +/// Simple keys that don't take modifiers (unmodified only). +fn simpleKeySeq(base: []const u8) ?[]const u8 { + if (eql(base, "enter") or eql(base, "return") or eql(base, "cr")) return "\r"; + if (eql(base, "tab")) return "\t"; + if (eql(base, "space")) return " "; + if (eql(base, "backspace") or eql(base, "bs")) return "\x7f"; + if (eql(base, "escape") or eql(base, "esc")) return "\x1b"; + return null; +} + +/// Format CSI u: ESC[{codepoint};{modifier}u +fn fmtCSIu(codepoint: u8, modifier: u8, out: []u8) ?usize { + var buf: [16]u8 = undefined; + const n = std.fmt.bufPrint(&buf, "\x1b[{d};{d}u", .{ codepoint, modifier }) catch return null; + return copyStatic(n, out); +} + +fn copyStatic(src: []const u8, dst: []u8) ?usize { + if (dst.len < src.len) return null; + @memcpy(dst[0..src.len], src); + return src.len; +} + +fn eql(a: []const u8, b: []const u8) bool { + return std.mem.eql(u8, a, b); +} + +fn startsWith(s: []const u8, prefix: []const u8) bool { + return std.mem.startsWith(u8, s, prefix); +} + +// --------------------------------------------------------------------------- +// Token iterator — splits input into named-key and text chunks +// --------------------------------------------------------------------------- + +pub const Token = struct { + /// The processed bytes for this token (written into caller's buffer). + len: usize, + /// Whether this token came from a {NamedKey} (vs plain text/escapes). + is_named_key: bool, +}; + +/// Yields one token at a time from the input. A token is either: +/// - A single {NamedKey} (is_named_key = true) +/// - A run of plain text / C-style escapes up to the next {NamedKey} (is_named_key = false) +/// +/// Usage: +/// var iter = KeyTokenIter{ .input = raw_text }; +/// while (iter.next(&buf)) |tok| { ... } +pub const KeyTokenIter = struct { + input: []const u8, + pos: usize = 0, + + /// Get the next token, writing processed bytes into `out`. + /// Returns null when input is exhausted. + pub fn next(self: *KeyTokenIter, out: []u8) ?Token { + if (self.pos >= self.input.len) return null; + + // If we're at a valid {NamedKey}, emit it as a single token + if (self.input[self.pos] == '{') { + if (resolveNamedKey(self.input[self.pos..], out)) |result| { + self.pos += result.advance; + return .{ .len = result.written, .is_named_key = true }; + } + } + + // Consume plain text + C-style escapes until next valid {NamedKey} or end + var o: usize = 0; + while (self.pos < self.input.len and o < out.len) { + // Stop before a valid named key (it becomes the next token) + if (self.input[self.pos] == '{' and self.pos > 0) { + // Peek: is this a valid named key? + if (resolveNamedKey(self.input[self.pos..], out[o..])) |_| { + break; // don't consume it — next call will + } + } else if (self.input[self.pos] == '{' and o > 0) { + if (resolveNamedKey(self.input[self.pos..], out[o..])) |_| { + break; + } + } + + // Process one character (possibly an escape sequence) + if (self.input[self.pos] == '{') { + // Invalid named key — emit literal '{' + out[o] = '{'; + o += 1; + self.pos += 1; + } else if (self.input[self.pos] == '\\' and self.pos + 1 < self.input.len) { + const consumed = unescapeOne(self.input[self.pos..], out[o..]); + o += consumed.written; + self.pos += consumed.advance; + } else { + out[o] = self.input[self.pos]; + o += 1; + self.pos += 1; + } + } + + if (o == 0) return null; + return .{ .len = o, .is_named_key = false }; + } +}; + +const UnescapeOne = struct { written: usize, advance: usize }; + +/// Process a single C-style escape at `input[0]` (which must be '\\'). Writes to `out`. +fn unescapeOne(input: []const u8, out: []u8) UnescapeOne { + if (out.len == 0) return .{ .written = 0, .advance = 1 }; + if (input.len < 2 or input[0] != '\\') { + out[0] = input[0]; + return .{ .written = 1, .advance = 1 }; + } + switch (input[1]) { + 'n' => { out[0] = '\n'; return .{ .written = 1, .advance = 2 }; }, + 't' => { out[0] = '\t'; return .{ .written = 1, .advance = 2 }; }, + 'r' => { out[0] = '\r'; return .{ .written = 1, .advance = 2 }; }, + '\\' => { out[0] = '\\'; return .{ .written = 1, .advance = 2 }; }, + '\'' => { out[0] = '\''; return .{ .written = 1, .advance = 2 }; }, + '"' => { out[0] = '"'; return .{ .written = 1, .advance = 2 }; }, + '0' => { out[0] = 0; return .{ .written = 1, .advance = 2 }; }, + 'a' => { out[0] = 0x07; return .{ .written = 1, .advance = 2 }; }, + 'b' => { out[0] = 0x08; return .{ .written = 1, .advance = 2 }; }, + 'e' => { out[0] = 0x1b; return .{ .written = 1, .advance = 2 }; }, + 'x' => { + if (input.len >= 4) { + if (std.fmt.parseInt(u8, input[2..4], 16)) |byte| { + out[0] = byte; + return .{ .written = 1, .advance = 4 }; + } else |_| {} + } + out[0] = '\\'; + if (out.len > 1) { out[1] = 'x'; return .{ .written = 2, .advance = 2 }; } + return .{ .written = 1, .advance = 1 }; + }, + else => { + out[0] = '\\'; + if (out.len > 1) { out[1] = input[1]; return .{ .written = 2, .advance = 2 }; } + return .{ .written = 1, .advance = 1 }; + }, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "basic escapes" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("hello\\n", &buf); + try std.testing.expectEqualStrings("hello\n", result); +} + +test "named key Enter" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("ls{Enter}", &buf); + try std.testing.expectEqualStrings("ls\r", result); +} + +test "named key arrows" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Up}{Down}{Left}{Right}", &buf); + try std.testing.expectEqualStrings("\x1b[A\x1b[B\x1b[D\x1b[C", result); +} + +test "named key Ctrl-c" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Ctrl-c}", &buf); + try std.testing.expectEqualStrings("\x03", result); +} + +test "named key case insensitive" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{ENTER}", &buf); + try std.testing.expectEqualStrings("\r", result); +} + +test "mixed named keys and text" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("hello{Tab}world{Enter}", &buf); + try std.testing.expectEqualStrings("hello\tworld\r", result); +} + +test "invalid brace passthrough" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{notakey}", &buf); + try std.testing.expectEqualStrings("{notakey}", result); +} + +test "literal brace without close" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("hello{world", &buf); + try std.testing.expectEqualStrings("hello{world", result); +} + +test "function keys" { + var buf: [256]u8 = undefined; + const f1 = unescapeKeys("{F1}", &buf); + try std.testing.expectEqualStrings("\x1bOP", f1); + const f12 = unescapeKeys("{F12}", &buf); + try std.testing.expectEqualStrings("\x1b[24~", f12); +} + +test "hex escape" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("\\x03", &buf); + try std.testing.expectEqualStrings("\x03", result); +} + +// Modifier combo tests + +test "Ctrl-Up arrow" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Ctrl-Up}", &buf); + try std.testing.expectEqualStrings("\x1b[1;5A", result); +} + +test "Ctrl-Shift-Up arrow" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Ctrl-Shift-Up}", &buf); + try std.testing.expectEqualStrings("\x1b[1;6A", result); +} + +test "Alt-Up arrow" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Alt-Up}", &buf); + try std.testing.expectEqualStrings("\x1b[1;3A", result); +} + +test "Shift-Tab backtab" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Shift-Tab}", &buf); + try std.testing.expectEqualStrings("\x1b[Z", result); +} + +test "Alt-a meta prefix" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Alt-a}", &buf); + try std.testing.expectEqualStrings("\x1ba", result); +} + +test "Alt-Ctrl-a" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Alt-Ctrl-a}", &buf); + try std.testing.expectEqualStrings("\x1b\x01", result); +} + +test "Ctrl-Right arrow" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Ctrl-Right}", &buf); + try std.testing.expectEqualStrings("\x1b[1;5C", result); +} + +test "Shift-F5" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Shift-F5}", &buf); + try std.testing.expectEqualStrings("\x1b[15;2~", result); +} + +test "Ctrl-Delete" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Ctrl-Delete}", &buf); + try std.testing.expectEqualStrings("\x1b[3;5~", result); +} + +test "Ctrl-Shift-p CSI u" { + var buf: [256]u8 = undefined; + const result = unescapeKeys("{Ctrl-Shift-p}", &buf); + // Ctrl+Shift on a letter → CSI u: ESC[112;6u (codepoint 'p'=112, mod=6) + try std.testing.expectEqualStrings("\x1b[112;6u", result); +} + +// Token iterator tests + +test "token iter: plain text only" { + var iter = KeyTokenIter{ .input = "hello" }; + var buf: [256]u8 = undefined; + const t1 = iter.next(&buf).?; + try std.testing.expectEqualStrings("hello", buf[0..t1.len]); + try std.testing.expect(!t1.is_named_key); + try std.testing.expect(iter.next(&buf) == null); +} + +test "token iter: named keys only" { + var iter = KeyTokenIter{ .input = "{Down}{Enter}" }; + var buf: [256]u8 = undefined; + const t1 = iter.next(&buf).?; + try std.testing.expectEqualStrings("\x1b[B", buf[0..t1.len]); + try std.testing.expect(t1.is_named_key); + const t2 = iter.next(&buf).?; + try std.testing.expectEqualStrings("\r", buf[0..t2.len]); + try std.testing.expect(t2.is_named_key); + try std.testing.expect(iter.next(&buf) == null); +} + +test "token iter: mixed text and keys" { + var iter = KeyTokenIter{ .input = "ls -la{Enter}" }; + var buf: [256]u8 = undefined; + const t1 = iter.next(&buf).?; + try std.testing.expectEqualStrings("ls -la", buf[0..t1.len]); + try std.testing.expect(!t1.is_named_key); + const t2 = iter.next(&buf).?; + try std.testing.expectEqualStrings("\r", buf[0..t2.len]); + try std.testing.expect(t2.is_named_key); + try std.testing.expect(iter.next(&buf) == null); +} + +test "token iter: text between keys" { + var iter = KeyTokenIter{ .input = "{Escape}:wq{Enter}" }; + var buf: [256]u8 = undefined; + const t1 = iter.next(&buf).?; + try std.testing.expectEqualStrings("\x1b", buf[0..t1.len]); + try std.testing.expect(t1.is_named_key); + const t2 = iter.next(&buf).?; + try std.testing.expectEqualStrings(":wq", buf[0..t2.len]); + try std.testing.expect(!t2.is_named_key); + const t3 = iter.next(&buf).?; + try std.testing.expectEqualStrings("\r", buf[0..t3.len]); + try std.testing.expect(t3.is_named_key); + try std.testing.expect(iter.next(&buf) == null); +} diff --git a/src/ipc/protocol.zig b/src/ipc/protocol.zig index e98b7b03..941e196d 100644 --- a/src/ipc/protocol.zig +++ b/src/ipc/protocol.zig @@ -37,7 +37,7 @@ pub const MessageType = enum(u8) { // ── Text / IO ── send_keys = 0x31, - send_text = 0x32, + send_text = 0x32, // Deprecated alias for send_keys — kept for wire compat with older clients get_text = 0x33, // ── Config ── @@ -72,7 +72,7 @@ pub const MessageType = enum(u8) { // ── Pane-targeted variants (payload: [pane_id:u32 LE][data...]) ── send_keys_pane = 0x46, - send_text_pane = 0x47, + send_text_pane = 0x47, // Deprecated alias for send_keys_pane — kept for wire compat get_text_pane = 0x48, // ── Targeted operations ──