modes: adapt slide-back chord hint to terminal
The "Press Option+up to slide back into input" hint shown under the sliding-in queue was correct on Ghostty, iTerm2 (Meta=Option), Terminal.app (Use Option as Meta), Alacritty, and Kitty -- all of which send CSI 1;3A for Option+Up, which the input parser reads as KeyUp + Alt. VS Code's integrated terminal (xterm.js on macOS) swallows plain Option as a compose modifier by default, so Option+Up never reaches zot as an Alt-modified arrow. Option+Shift+Up does work there: xterm.js emits CSI 1;4A (Shift+Alt), which the parser already accepts as alt=true. The binding has always worked in VS Code; only the displayed hint was misleading. Fix: a small slideBackChordHint() helper that returns "Option+Shift+up" when TERM_PROGRAM=vscode and "Option+up" otherwise. interactive.go's queue-hint row calls it instead of hardcoding the chord. The binding itself is unchanged -- both chords work on every terminal -- the hint just adapts to what the host actually delivers. README.md gains a one-sentence note under Queued messages documenting both chords and that the hint adapts via $TERM_PROGRAM. Tests cover the VS Code branch, case-insensitive detection (VSCode / VSCODE / VsCode), and the default for "", "ghostty", "iTerm.app", "Apple_Terminal", "alacritty", "kitty".
- commit
- 467ad0a9908c7d59109609daacec2f723f5ffe06
- author
- patriceckhart · · 2026-05-16 10:01 UTC
- parent
- b11e6ed
- signature
- unsigned
4 files · +85 -1
M README.md +2 -0
| @@ -499,6 +499,8 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||
| 499 | 499 | ||
| 500 | 500 | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zot. | |
| 501 | 501 | ||
| 502 | + | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | |
| 503 | + | ||
| 502 | 504 | Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | |
| 503 | 505 | ||
| 504 | 506 | ||
| @@ -499,6 +499,8 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||||
| 499 | 499 | ||||
| 500 | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zot. | 500 | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zot. | ||
| 501 | 501 | ||||
| 502 | + | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | |||
| 503 | + | ||||
| 502 | Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | 504 | Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | ||
| 503 | 505 | ||||
| 504 | 506 | ||||
M internal/agent/modes/interactive.go +1 -1
| @@ -1030,7 +1030,7 @@ func (i *Interactive) redraw() { | |||
| 1030 | 1030 | // info on the status bar so it reads as ambient metadata | |
| 1031 | 1031 | // rather than a chip. Tells the user how to recover the | |
| 1032 | 1032 | // most recent queued message back into the editor. | |
| 1033 | - | hint := " Press Option+↑ to slide back into input" | |
| 1033 | + | hint := " Press " + slideBackChordHint() + " to slide back into input" | |
| 1034 | 1034 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | |
| 1035 | 1035 | } | |
| 1036 | 1036 | ||
| @@ -1030,7 +1030,7 @@ func (i *Interactive) redraw() { | |||||
| 1030 | // info on the status bar so it reads as ambient metadata | 1030 | // info on the status bar so it reads as ambient metadata | ||
| 1031 | // rather than a chip. Tells the user how to recover the | 1031 | // rather than a chip. Tells the user how to recover the | ||
| 1032 | // most recent queued message back into the editor. | 1032 | // most recent queued message back into the editor. | ||
| 1033 | - | hint := " Press Option+↑ to slide back into input" | 1033 | + | hint := " Press " + slideBackChordHint() + " to slide back into input" |
| 1034 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | 1034 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | ||
| 1035 | } | 1035 | } | ||
| 1036 | 1036 | ||||
A internal/agent/modes/keychord_hint.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // slideBackChordHint returns the keyboard chord the user must press | |
| 9 | + | // to peel the most recently queued ("sliding in") message back into | |
| 10 | + | // the editor. The chord itself is decoded the same way everywhere | |
| 11 | + | // (see KeyUp with k.Alt in handleKey) — what changes is which | |
| 12 | + | // physical combo actually delivers a modified-arrow escape sequence | |
| 13 | + | // to the process: | |
| 14 | + | // | |
| 15 | + | // - Ghostty, iTerm2 (with the "Esc+" / Meta=Option setting), | |
| 16 | + | // Terminal.app (with "Use Option as Meta key"), Alacritty, | |
| 17 | + | // Kitty: bare Option+Up sends \x1b[1;3A (mod=3 / Alt) which | |
| 18 | + | // our parser reads as KeyUp + Alt. Good. | |
| 19 | + | // | |
| 20 | + | // - VS Code's integrated terminal (xterm.js) on macOS does NOT | |
| 21 | + | // forward Option+Up as an Alt-modified arrow by default — it | |
| 22 | + | // either swallows the Option as a compose modifier or emits a | |
| 23 | + | // character. But Option+Shift+Up reliably sends \x1b[1;4A | |
| 24 | + | // (mod=4 / Shift+Alt) because the Shift bit forces the | |
| 25 | + | // CSI-with-modifier path. Our parser already treats mod=4 as | |
| 26 | + | // alt=true, so the binding fires; we just need to tell the | |
| 27 | + | // user the right chord. | |
| 28 | + | // | |
| 29 | + | // Detection is by $TERM_PROGRAM; VS Code sets this to "vscode" on | |
| 30 | + | // every platform it ships an integrated terminal on. The decoder | |
| 31 | + | // accepts both chords on any terminal, so getting the env-var sniff | |
| 32 | + | // wrong only ever shows a slightly-off hint string — never breaks | |
| 33 | + | // the binding itself. | |
| 34 | + | func slideBackChordHint() string { | |
| 35 | + | if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") { | |
| 36 | + | return "Option+Shift+↑" | |
| 37 | + | } | |
| 38 | + | return "Option+↑" | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // slideBackChordHint returns the keyboard chord the user must press | |||
| 9 | + | // to peel the most recently queued ("sliding in") message back into | |||
| 10 | + | // the editor. The chord itself is decoded the same way everywhere | |||
| 11 | + | // (see KeyUp with k.Alt in handleKey) — what changes is which | |||
| 12 | + | // physical combo actually delivers a modified-arrow escape sequence | |||
| 13 | + | // to the process: | |||
| 14 | + | // | |||
| 15 | + | // - Ghostty, iTerm2 (with the "Esc+" / Meta=Option setting), | |||
| 16 | + | // Terminal.app (with "Use Option as Meta key"), Alacritty, | |||
| 17 | + | // Kitty: bare Option+Up sends \x1b[1;3A (mod=3 / Alt) which | |||
| 18 | + | // our parser reads as KeyUp + Alt. Good. | |||
| 19 | + | // | |||
| 20 | + | // - VS Code's integrated terminal (xterm.js) on macOS does NOT | |||
| 21 | + | // forward Option+Up as an Alt-modified arrow by default — it | |||
| 22 | + | // either swallows the Option as a compose modifier or emits a | |||
| 23 | + | // character. But Option+Shift+Up reliably sends \x1b[1;4A | |||
| 24 | + | // (mod=4 / Shift+Alt) because the Shift bit forces the | |||
| 25 | + | // CSI-with-modifier path. Our parser already treats mod=4 as | |||
| 26 | + | // alt=true, so the binding fires; we just need to tell the | |||
| 27 | + | // user the right chord. | |||
| 28 | + | // | |||
| 29 | + | // Detection is by $TERM_PROGRAM; VS Code sets this to "vscode" on | |||
| 30 | + | // every platform it ships an integrated terminal on. The decoder | |||
| 31 | + | // accepts both chords on any terminal, so getting the env-var sniff | |||
| 32 | + | // wrong only ever shows a slightly-off hint string — never breaks | |||
| 33 | + | // the binding itself. | |||
| 34 | + | func slideBackChordHint() string { | |||
| 35 | + | if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") { | |||
| 36 | + | return "Option+Shift+↑" | |||
| 37 | + | } | |||
| 38 | + | return "Option+↑" | |||
| 39 | + | } | |||
A internal/agent/modes/keychord_hint_test.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // TestSlideBackChordHintVSCode pins the VS Code branch: when the | |
| 6 | + | // process is running under VS Code's integrated terminal the hint | |
| 7 | + | // must direct the user to Option+Shift+↑, because plain Option+↑ | |
| 8 | + | // gets swallowed by xterm.js's default macOS key handling. | |
| 9 | + | func TestSlideBackChordHintVSCode(t *testing.T) { | |
| 10 | + | t.Setenv("TERM_PROGRAM", "vscode") | |
| 11 | + | if got, want := slideBackChordHint(), "Option+Shift+↑"; got != want { | |
| 12 | + | t.Errorf("VS Code hint = %q; want %q", got, want) | |
| 13 | + | } | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // TestSlideBackChordHintVSCodeCaseInsensitive guards against a | |
| 17 | + | // future VS Code change that capitalises the env-var value | |
| 18 | + | // ("VSCode" / "VSCODE"). EqualFold keeps the detection robust. | |
| 19 | + | func TestSlideBackChordHintVSCodeCaseInsensitive(t *testing.T) { | |
| 20 | + | for _, v := range []string{"VSCode", "VSCODE", "VsCode"} { | |
| 21 | + | t.Run(v, func(t *testing.T) { | |
| 22 | + | t.Setenv("TERM_PROGRAM", v) | |
| 23 | + | if got, want := slideBackChordHint(), "Option+Shift+↑"; got != want { | |
| 24 | + | t.Errorf("TERM_PROGRAM=%q hint = %q; want %q", v, got, want) | |
| 25 | + | } | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // TestSlideBackChordHintDefault pins the default for every | |
| 31 | + | // non-VS-Code terminal: the snappier Option+↑ chord, which Ghostty, | |
| 32 | + | // iTerm2 (with Meta=Option), Terminal.app (with Use Option as Meta), | |
| 33 | + | // Alacritty and Kitty all deliver natively. | |
| 34 | + | func TestSlideBackChordHintDefault(t *testing.T) { | |
| 35 | + | for _, v := range []string{"", "ghostty", "iTerm.app", "Apple_Terminal", "alacritty", "kitty"} { | |
| 36 | + | t.Run(v, func(t *testing.T) { | |
| 37 | + | t.Setenv("TERM_PROGRAM", v) | |
| 38 | + | if got, want := slideBackChordHint(), "Option+↑"; got != want { | |
| 39 | + | t.Errorf("TERM_PROGRAM=%q hint = %q; want %q", v, got, want) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // TestSlideBackChordHintVSCode pins the VS Code branch: when the | |||
| 6 | + | // process is running under VS Code's integrated terminal the hint | |||
| 7 | + | // must direct the user to Option+Shift+↑, because plain Option+↑ | |||
| 8 | + | // gets swallowed by xterm.js's default macOS key handling. | |||
| 9 | + | func TestSlideBackChordHintVSCode(t *testing.T) { | |||
| 10 | + | t.Setenv("TERM_PROGRAM", "vscode") | |||
| 11 | + | if got, want := slideBackChordHint(), "Option+Shift+↑"; got != want { | |||
| 12 | + | t.Errorf("VS Code hint = %q; want %q", got, want) | |||
| 13 | + | } | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // TestSlideBackChordHintVSCodeCaseInsensitive guards against a | |||
| 17 | + | // future VS Code change that capitalises the env-var value | |||
| 18 | + | // ("VSCode" / "VSCODE"). EqualFold keeps the detection robust. | |||
| 19 | + | func TestSlideBackChordHintVSCodeCaseInsensitive(t *testing.T) { | |||
| 20 | + | for _, v := range []string{"VSCode", "VSCODE", "VsCode"} { | |||
| 21 | + | t.Run(v, func(t *testing.T) { | |||
| 22 | + | t.Setenv("TERM_PROGRAM", v) | |||
| 23 | + | if got, want := slideBackChordHint(), "Option+Shift+↑"; got != want { | |||
| 24 | + | t.Errorf("TERM_PROGRAM=%q hint = %q; want %q", v, got, want) | |||
| 25 | + | } | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // TestSlideBackChordHintDefault pins the default for every | |||
| 31 | + | // non-VS-Code terminal: the snappier Option+↑ chord, which Ghostty, | |||
| 32 | + | // iTerm2 (with Meta=Option), Terminal.app (with Use Option as Meta), | |||
| 33 | + | // Alacritty and Kitty all deliver natively. | |||
| 34 | + | func TestSlideBackChordHintDefault(t *testing.T) { | |||
| 35 | + | for _, v := range []string{"", "ghostty", "iTerm.app", "Apple_Terminal", "alacritty", "kitty"} { | |||
| 36 | + | t.Run(v, func(t *testing.T) { | |||
| 37 | + | t.Setenv("TERM_PROGRAM", v) | |||
| 38 | + | if got, want := slideBackChordHint(), "Option+↑"; got != want { | |||
| 39 | + | t.Errorf("TERM_PROGRAM=%q hint = %q; want %q", v, got, want) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||