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, | |||
| 332 | 332 | ||
| 333 | 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 | 337 | ### `/sessions` | |
| 336 | 338 | ||
| 337 | 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. | |
| @@ -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) { | |||
| 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 | 96 | func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) { | |
| 68 | 97 | ag := core.NewAgent(nil, "", "", nil) | |
| 69 | 98 | 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 { | |||
| 1839 | 1839 | const ( | |
| 1840 | 1840 | hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:" | |
| 1841 | 1841 | shellEscapeMetaKey = "shell_escape" | |
| 1842 | + | shellEscapeCommandMetaKey = "shell_escape_command" | |
| 1842 | 1843 | ) | |
| 1843 | 1844 | ||
| 1844 | 1845 | func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message { | |
| @@ -2935,10 +2936,19 @@ func (i *Interactive) inputHistory() []string { | |||
| 2935 | 2936 | msgs := i.agent.Messages() | |
| 2936 | 2937 | hist := make([]string, 0, len(msgs)) | |
| 2937 | 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 | 2940 | continue | |
| 2940 | 2941 | } | |
| 2941 | 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 | 2952 | if strings.TrimSpace(text) == "" { | |
| 2943 | 2953 | continue | |
| 2944 | 2954 | } | |
| @@ -5830,7 +5840,10 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5830 | 5840 | } | |
| 5831 | 5841 | ||
| 5832 | 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 | 5849 | 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 | continue | 2940 | 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 | continue | 2953 | 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) { | |||
| 34 | 34 | if messages[0].Meta[shellEscapeMetaKey] != "true" { | |
| 35 | 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 | 41 | return | |
| 38 | 42 | } | |
| 39 | 43 | 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 | return | 41 | return | ||
| 38 | } | 42 | } | ||
| 39 | time.Sleep(10 * time.Millisecond) | 43 | time.Sleep(10 * time.Millisecond) | ||