detached 860c2f8

fix: recall shell commands with bang in input history

commit
860c2f85044a4a0b311866bbf027a435f9dc4602
author
patriceckhart · · 2026-09-16 14:49 UTC
parent
1866c03
signature
unsigned
4 files · +50 -2
M README.md +2 -0
@@ -332,6 +332,8 @@ Extension-registered commands appear under a divider at the bottom of the popup,
332332
333333 Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight.
334334
335+After a shell command finishes, `up` / `down` input history recalls it with the leading `!`, without its output or exit status. This also works when resuming sessions saved with command metadata. Shell commands from older sessions without that metadata remain excluded from input history.
336+
335337 ### `/sessions`
336338
337339 Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `r` to rename, `d` to delete, or `esc` to cancel. Deletion requires confirmation and permanently removes the session file; the currently active session cannot be deleted. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed.
@@ -332,6 +332,8 @@ Extension-registered commands appear under a divider at the bottom of the popup,
332 332
333 Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight.333 Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight.
334 334
335+After a shell command finishes, `up` / `down` input history recalls it with the leading `!`, without its output or exit status. This also works when resuming sessions saved with command metadata. Shell commands from older sessions without that metadata remain excluded from input history.
336+
335 ### `/sessions`337 ### `/sessions`
336 338
337 Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `r` to rename, `d` to delete, or `esc` to cancel. Deletion requires confirmation and permanently removes the session file; the currently active session cannot be deleted. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed.339 Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `r` to rename, `d` to delete, or `esc` to cancel. Deletion requires confirmation and permanently removes the session file; the currently active session cannot be deleted. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed.
M packages/agent/modes/input_history_test.go +29 -0
@@ -64,6 +64,35 @@ func TestInputHistorySkipsShellEscapeContext(t *testing.T) {
6464 }
6565 }
6666
67+func TestInputHistoryRecallsShellCommandWithBang(t *testing.T) {
68+ for _, cmd := range []string{"pwd", "printf 'first\\n'\nprintf 'second\\n'"} {
69+ t.Run(cmd, func(t *testing.T) {
70+ ag := core.NewAgent(nil, "", "", nil)
71+ ag.SetMessages([]provider.Message{
72+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
73+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "$ " + cmd + "\n\noutput\n\n[exit 0]"}}, Meta: map[string]string{"shell_escape": "true", "shell_escape_command": cmd}},
74+ })
75+ i := &Interactive{agent: ag, ed: tui.NewEditor(""), inputHistoryIndex: -1}
76+ for _, step := range []struct {
77+ key tui.KeyKind
78+ want string
79+ }{
80+ {tui.KeyUp, "!" + cmd},
81+ {tui.KeyUp, "prompt"},
82+ {tui.KeyDown, "!" + cmd},
83+ {tui.KeyDown, ""},
84+ } {
85+ if !i.handleInputHistoryKey(tui.Key{Kind: step.key}) {
86+ t.Fatal("expected history navigation")
87+ }
88+ if got := i.ed.Value(); got != step.want {
89+ t.Fatalf("editor = %q, want %q", got, step.want)
90+ }
91+ }
92+ })
93+ }
94+}
95+
6796 func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {
6897 ag := core.NewAgent(nil, "", "", nil)
6998 ag.SetMessages([]provider.Message{
@@ -64,6 +64,35 @@ func TestInputHistorySkipsShellEscapeContext(t *testing.T) {
64 }64 }
65 }65 }
66 66
67+func TestInputHistoryRecallsShellCommandWithBang(t *testing.T) {
68+ for _, cmd := range []string{"pwd", "printf 'first\\n'\nprintf 'second\\n'"} {
69+ t.Run(cmd, func(t *testing.T) {
70+ ag := core.NewAgent(nil, "", "", nil)
71+ ag.SetMessages([]provider.Message{
72+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
73+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "$ " + cmd + "\n\noutput\n\n[exit 0]"}}, Meta: map[string]string{"shell_escape": "true", "shell_escape_command": cmd}},
74+ })
75+ i := &Interactive{agent: ag, ed: tui.NewEditor(""), inputHistoryIndex: -1}
76+ for _, step := range []struct {
77+ key tui.KeyKind
78+ want string
79+ }{
80+ {tui.KeyUp, "!" + cmd},
81+ {tui.KeyUp, "prompt"},
82+ {tui.KeyDown, "!" + cmd},
83+ {tui.KeyDown, ""},
84+ } {
85+ if !i.handleInputHistoryKey(tui.Key{Kind: step.key}) {
86+ t.Fatal("expected history navigation")
87+ }
88+ if got := i.ed.Value(); got != step.want {
89+ t.Fatalf("editor = %q, want %q", got, step.want)
90+ }
91+ }
92+ })
93+ }
94+}
95+
67 func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {96 func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {
68 ag := core.NewAgent(nil, "", "", nil)97 ag := core.NewAgent(nil, "", "", nil)
69 ag.SetMessages([]provider.Message{98 ag.SetMessages([]provider.Message{
M packages/agent/modes/interactive.go +15 -2
@@ -1839,6 +1839,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
18391839 const (
18401840 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
18411841 shellEscapeMetaKey = "shell_escape"
1842+ shellEscapeCommandMetaKey = "shell_escape_command"
18421843 )
18431844
18441845 func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
@@ -2935,10 +2936,19 @@ func (i *Interactive) inputHistory() []string {
29352936 msgs := i.agent.Messages()
29362937 hist := make([]string, 0, len(msgs))
29372938 for _, m := range msgs {
2938- if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" {
2939+ if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
29392940 continue
29402941 }
29412942 text := userMessageText(m)
2943+ if m.Meta[shellEscapeMetaKey] == "true" {
2944+ // Older sessions lack command metadata. Never recall shell output
2945+ // as input or try to recover a command from the terminal log.
2946+ cmd := m.Meta[shellEscapeCommandMetaKey]
2947+ if strings.TrimSpace(cmd) == "" {
2948+ continue
2949+ }
2950+ text = "!" + cmd
2951+ }
29422952 if strings.TrimSpace(text) == "" {
29432953 continue
29442954 }
@@ -5830,7 +5840,10 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
58305840 }
58315841
58325842 if i.agent != nil {
5833- i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5843+ i.agent.AppendUserContext(out, map[string]string{
5844+ shellEscapeMetaKey: "true",
5845+ shellEscapeCommandMetaKey: cmd,
5846+ })
58345847 }
58355848
58365849 i.mu.Lock()
@@ -1839,6 +1839,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
1839 const (1839 const (
1840 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"1840 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1841 shellEscapeMetaKey = "shell_escape"1841 shellEscapeMetaKey = "shell_escape"
1842+ shellEscapeCommandMetaKey = "shell_escape_command"
1842 )1843 )
1843 1844
1844 func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {1845 func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
@@ -2935,10 +2936,19 @@ func (i *Interactive) inputHistory() []string {
2935 msgs := i.agent.Messages()2936 msgs := i.agent.Messages()
2936 hist := make([]string, 0, len(msgs))2937 hist := make([]string, 0, len(msgs))
2937 for _, m := range msgs {2938 for _, m := range msgs {
2938- if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" {2939+ if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
2939 continue2940 continue
2940 }2941 }
2941 text := userMessageText(m)2942 text := userMessageText(m)
2943+ if m.Meta[shellEscapeMetaKey] == "true" {
2944+ // Older sessions lack command metadata. Never recall shell output
2945+ // as input or try to recover a command from the terminal log.
2946+ cmd := m.Meta[shellEscapeCommandMetaKey]
2947+ if strings.TrimSpace(cmd) == "" {
2948+ continue
2949+ }
2950+ text = "!" + cmd
2951+ }
2942 if strings.TrimSpace(text) == "" {2952 if strings.TrimSpace(text) == "" {
2943 continue2953 continue
2944 }2954 }
@@ -5830,7 +5840,10 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5830 }5840 }
5831 5841
5832 if i.agent != nil {5842 if i.agent != nil {
5833- i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})5843+ i.agent.AppendUserContext(out, map[string]string{
5844+ shellEscapeMetaKey: "true",
5845+ shellEscapeCommandMetaKey: cmd,
5846+ })
5834 }5847 }
5835 5848
5836 i.mu.Lock()5849 i.mu.Lock()
M packages/agent/modes/shell_escape_test.go +4 -0
@@ -34,6 +34,10 @@ func TestShellEscapeAddsOutputToAgentContext(t *testing.T) {
3434 if messages[0].Meta[shellEscapeMetaKey] != "true" {
3535 t.Fatalf("shell context metadata = %v", messages[0].Meta)
3636 }
37+ history := i.inputHistory()
38+ if len(history) != 1 || history[0] != "!"+cmd {
39+ t.Fatalf("input history = %q, want shell command with bang", history)
40+ }
3741 return
3842 }
3943 time.Sleep(10 * time.Millisecond)
@@ -34,6 +34,10 @@ func TestShellEscapeAddsOutputToAgentContext(t *testing.T) {
34 if messages[0].Meta[shellEscapeMetaKey] != "true" {34 if messages[0].Meta[shellEscapeMetaKey] != "true" {
35 t.Fatalf("shell context metadata = %v", messages[0].Meta)35 t.Fatalf("shell context metadata = %v", messages[0].Meta)
36 }36 }
37+ history := i.inputHistory()
38+ if len(history) != 1 || history[0] != "!"+cmd {
39+ t.Fatalf("input history = %q, want shell command with bang", history)
40+ }
37 return41 return
38 }42 }
39 time.Sleep(10 * time.Millisecond)43 time.Sleep(10 * time.Millisecond)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close