Skip to content
Merged
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
140 changes: 111 additions & 29 deletions skills/claude/attyx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...).
Expand Down Expand Up @@ -43,10 +39,21 @@ 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"
```

### 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.

Expand Down Expand Up @@ -76,7 +83,7 @@ sid=$(attyx session create ~/Projects/myapp -b)
Use `-s`/`--session <id>` 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
```
Expand All @@ -101,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 <id>` 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 "ls -la\r"
attyx send-keys -p "$id" "3{Enter}"
```

### Reading Output — Don't Guess Sleep Times
Instead of blind `sleep N && attyx get-text`, poll until output stabilizes:
**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
# 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).
attyx send-keys -p "$id" "search query"
sleep 0.5 # let filter update
attyx send-keys -p "$id" "{Enter}"
```

**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
# 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
Expand All @@ -156,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 <id> "hello"`
- `/attyx send "hello" to the other pane` → `attyx send-keys -p <id> "hello{Enter}"`
- `/attyx close the other pane` → `attyx split close -p <id>`
- `/attyx what's on screen in the right pane` → `attyx get-text -p <id>`
- `/attyx create a background session for ~/Projects/api` → `attyx session create ~/Projects/api -b`
Expand Down
33 changes: 24 additions & 9 deletions src/cli/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
};
Expand All @@ -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 {
Expand All @@ -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;
};
Expand All @@ -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 =
Expand Down
11 changes: 5 additions & 6 deletions src/config/cli_help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const ipc_header =

const ipc_tabs =
" " ++ d ++ "Tabs" ++ r ++ "\n" ++
cmd("tab create [--cmd <cmd>] [--wait] ", "Create a new tab (returns index)") ++
cmd("tab create [--cmd <cmd>] [--wait] ", "Create a new tab (returns pane ID)") ++
cmd("tab close [<N>] ", "Close tab N (default: active)") ++
cmd("tab next" ++ r ++ " / " ++ c ++ "tab prev ", "Switch tabs") ++
cmd("tab select <1-9> ", "Switch to tab by number") ++
Expand All @@ -94,9 +94,8 @@ const ipc_focus =

const ipc_io =
" " ++ d ++ "Input / Output" ++ r ++ "\n" ++
cmd("send-keys [-p <id>] <keys> ", "Send keystrokes to a pane") ++
cont(" " ++ d ++ "Escapes: \\n \\t \\x03 \\x04 \\x1b \\x7f \\x1b[A/B/C/D" ++ r) ++
cmd("send-text [-p <id>] <text> ", "Send raw text (same escape support)") ++
cmd("send-keys [-p <id>] [--wait-stable] <keys> ", "Send keystrokes to a pane") ++
cont(" " ++ d ++ "Named: {Enter} {Up} {Down} {Tab} {Ctrl-c} ... or \\n \\xHH" ++ r) ++
cmd("get-text [-p <id>] [--json] ", "Read visible screen text from a pane") ++
"\n";

Expand All @@ -113,7 +112,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 <id> ", "Switch to a session by ID") ++
cmd("session rename [id] <name> ", "Rename a session") ++
cmd("session kill <id> ", "Kill a session and all its panes") ++
Expand All @@ -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";
Expand Down
32 changes: 18 additions & 14 deletions src/config/cli_ipc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ pub const IpcCommand = enum {
focus_left,
focus_right,
send_keys,
send_text,
get_text,
config_reload,
theme_set,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};
Expand All @@ -440,18 +436,26 @@ 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;
}
i += 1;
}

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;
Expand Down
Loading
Loading