Add direct shell output to conversation context
- commit
- 471dc8ca33908f26217425b1799f5c9cb9ffd78e
- author
- patriceckhart · · 2026-07-26 11:20 UTC
- parent
- 966d045
- signature
- unsigned
- refs
- tag: v0.3.10
6 files · +128 -84
M README.md +1 -1
| @@ -290,7 +290,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||
| 290 | 290 | ||
| 291 | 291 | ### Shell escape (`!command`) | |
| 292 | 292 | ||
| 293 | - | 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/sh -c` on Unix, `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The output is appended below the transcript as a terminal-log block (command echo, output, exit code), styled by success or failure. It stays on screen until you send your next prompt (or run `/clear`), so it doesn't bleed into the model conversation. 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. | |
| 293 | + | 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/sh -c` on Unix, `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. | |
| 294 | 294 | ||
| 295 | 295 | ### `/sessions` | |
| 296 | 296 | ||
| @@ -290,7 +290,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||||
| 290 | 290 | ||||
| 291 | ### Shell escape (`!command`) | 291 | ### Shell escape (`!command`) | ||
| 292 | 292 | ||||
| 293 | - | 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/sh -c` on Unix, `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The output is appended below the transcript as a terminal-log block (command echo, output, exit code), styled by success or failure. It stays on screen until you send your next prompt (or run `/clear`), so it doesn't bleed into the model conversation. 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. | 293 | + | 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/sh -c` on Unix, `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. |
| 294 | 294 | ||||
| 295 | ### `/sessions` | 295 | ### `/sessions` | ||
| 296 | 296 | ||||
M packages/agent/modes/input_history_test.go +14 -0
| @@ -50,6 +50,20 @@ func TestInputHistoryUsesUpDown(t *testing.T) { | |||
| 50 | 50 | } | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | func TestInputHistorySkipsShellEscapeContext(t *testing.T) { | |
| 54 | + | ag := core.NewAgent(nil, "", "", nil) | |
| 55 | + | ag.SetMessages([]provider.Message{ | |
| 56 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}, | |
| 57 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "$ pwd\n\n/tmp\n\n[exit 0]"}}, Meta: map[string]string{shellEscapeMetaKey: "true"}}, | |
| 58 | + | }) | |
| 59 | + | i := &Interactive{agent: ag} | |
| 60 | + | ||
| 61 | + | history := i.inputHistory() | |
| 62 | + | if len(history) != 1 || history[0] != "prompt" { | |
| 63 | + | t.Fatalf("inputHistory() = %q, want [prompt]", history) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 53 | 67 | func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) { | |
| 54 | 68 | ag := core.NewAgent(nil, "", "", nil) | |
| 55 | 69 | ag.SetMessages([]provider.Message{ | |
| @@ -50,6 +50,20 @@ func TestInputHistoryUsesUpDown(t *testing.T) { | |||||
| 50 | } | 50 | } | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | + | func TestInputHistorySkipsShellEscapeContext(t *testing.T) { | |||
| 54 | + | ag := core.NewAgent(nil, "", "", nil) | |||
| 55 | + | ag.SetMessages([]provider.Message{ | |||
| 56 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}, | |||
| 57 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "$ pwd\n\n/tmp\n\n[exit 0]"}}, Meta: map[string]string{shellEscapeMetaKey: "true"}}, | |||
| 58 | + | }) | |||
| 59 | + | i := &Interactive{agent: ag} | |||
| 60 | + | ||||
| 61 | + | history := i.inputHistory() | |||
| 62 | + | if len(history) != 1 || history[0] != "prompt" { | |||
| 63 | + | t.Fatalf("inputHistory() = %q, want [prompt]", history) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 53 | func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) { | 67 | func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) { | ||
| 54 | ag := core.NewAgent(nil, "", "", nil) | 68 | ag := core.NewAgent(nil, "", "", nil) | ||
| 55 | ag.SetMessages([]provider.Message{ | 69 | ag.SetMessages([]provider.Message{ | ||
M packages/agent/modes/interactive.go +15 -62
| @@ -282,7 +282,6 @@ type chatCacheKey struct { | |||
| 282 | 282 | statusErr string | |
| 283 | 283 | help string | |
| 284 | 284 | extNotes string | |
| 285 | - | shellBlock string | |
| 286 | 285 | updateAvailable bool | |
| 287 | 286 | updateCurrent string | |
| 288 | 287 | updateLatest string | |
| @@ -482,13 +481,9 @@ type Interactive struct { | |||
| 482 | 481 | // transcript) until cleared by /clear or another reset. | |
| 483 | 482 | extNotes []string | |
| 484 | 483 | ||
| 485 | - | // shellBlock holds the rendered terminal-log lines of the most | |
| 486 | - | // recent !command shell escape. It lives below the transcript | |
| 487 | - | // (under extNotes) until the user sends their next prompt or runs | |
| 488 | - | // /clear. shellRunning is true while a !command is executing; it | |
| 489 | - | // shares i.busy/i.cancelTurn so esc cancels it and no turn or | |
| 490 | - | // other shell escape can start while one is in flight. | |
| 491 | - | shellBlock []string | |
| 484 | + | // shellRunning is true while a !command is executing. It shares | |
| 485 | + | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | |
| 486 | + | // escape can start while one is in flight. | |
| 492 | 487 | shellRunning bool | |
| 493 | 488 | ||
| 494 | 489 | // sessionLoading is true while a /sessions selection is being read | |
| @@ -906,7 +901,6 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |||
| 906 | 901 | statusErr: i.statusErr, | |
| 907 | 902 | help: strings.Join(i.helpBlock, "\n"), | |
| 908 | 903 | extNotes: strings.Join(i.extNotes, "\n"), | |
| 909 | - | shellBlock: strings.Join(i.shellBlock, "\n"), | |
| 910 | 904 | updateAvailable: i.updateInfo.Available, | |
| 911 | 905 | updateCurrent: i.updateInfo.Current, | |
| 912 | 906 | updateLatest: i.updateInfo.Latest, | |
| @@ -1033,14 +1027,6 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 1033 | 1027 | chat = append(chat, "") | |
| 1034 | 1028 | } | |
| 1035 | 1029 | ||
| 1036 | - | // Shell-escape terminal-log block (!command). Rendered below the | |
| 1037 | - | // transcript and extension notes; cleared when the next prompt is | |
| 1038 | - | // sent or on /clear so it never leaks into the model conversation. | |
| 1039 | - | if len(i.shellBlock) > 0 { | |
| 1040 | - | chat = append(chat, i.shellBlock...) | |
| 1041 | - | chat = append(chat, "") | |
| 1042 | - | } | |
| 1043 | - | ||
| 1044 | 1030 | // Strip trailing blank rows so the chat content sits flush | |
| 1045 | 1031 | // against the new "blank above status bar" row added by the | |
| 1046 | 1032 | // bottom-region assembly. Build() ends every message with a | |
| @@ -1662,7 +1648,10 @@ func snapViewportStartToImageBlock(chat []string, start int) int { | |||
| 1662 | 1648 | return start | |
| 1663 | 1649 | } | |
| 1664 | 1650 | ||
| 1665 | - | const hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:" | |
| 1651 | + | const ( | |
| 1652 | + | hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:" | |
| 1653 | + | shellEscapeMetaKey = "shell_escape" | |
| 1654 | + | ) | |
| 1666 | 1655 | ||
| 1667 | 1656 | func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message { | |
| 1668 | 1657 | if len(msgs) == 0 { | |
| @@ -2319,21 +2308,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2319 | 2308 | i.mu.Lock() | |
| 2320 | 2309 | hadHelp := len(i.helpBlock) > 0 | |
| 2321 | 2310 | hadNotes := len(i.extNotes) > 0 | |
| 2322 | - | // Only dismiss a parked shell-escape log on esc when nothing is | |
| 2323 | - | // running; if a !command is in flight, esc must fall through to | |
| 2324 | - | // the cancel path below instead of just hiding the (empty) block. | |
| 2325 | - | hadShell := len(i.shellBlock) > 0 && !i.shellRunning | |
| 2326 | 2311 | if hadHelp { | |
| 2327 | 2312 | i.helpBlock = nil | |
| 2328 | 2313 | } | |
| 2329 | 2314 | if hadNotes { | |
| 2330 | 2315 | i.extNotes = nil | |
| 2331 | 2316 | } | |
| 2332 | - | if hadShell { | |
| 2333 | - | i.shellBlock = nil | |
| 2334 | - | } | |
| 2335 | 2317 | i.mu.Unlock() | |
| 2336 | - | if hadHelp || hadNotes || hadShell { | |
| 2318 | + | if hadHelp || hadNotes { | |
| 2337 | 2319 | i.invalidate() | |
| 2338 | 2320 | return false | |
| 2339 | 2321 | } | |
| @@ -2738,7 +2720,7 @@ func (i *Interactive) inputHistory() []string { | |||
| 2738 | 2720 | msgs := i.agent.Messages() | |
| 2739 | 2721 | hist := make([]string, 0, len(msgs)) | |
| 2740 | 2722 | for _, m := range msgs { | |
| 2741 | - | if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) { | |
| 2723 | + | if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" { | |
| 2742 | 2724 | continue | |
| 2743 | 2725 | } | |
| 2744 | 2726 | text := userMessageText(m) | |
| @@ -4077,7 +4059,6 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4077 | 4059 | i.parkedTotal = 0 | |
| 4078 | 4060 | i.scrollOffset = 0 | |
| 4079 | 4061 | i.extNotes = nil | |
| 4080 | - | i.shellBlock = nil | |
| 4081 | 4062 | i.view.InvalidateRenderCache() | |
| 4082 | 4063 | i.mu.Unlock() | |
| 4083 | 4064 | case "/help": | |
| @@ -5195,9 +5176,8 @@ func shellEscapeCommand(text string) (string, bool) { | |||
| 5195 | 5176 | // uses, in the session working directory, honoring the /jail sandbox. | |
| 5196 | 5177 | // It shares the busy/cancel state with the agent: esc cancels it, and | |
| 5197 | 5178 | // it refuses to start while a turn or another shell escape is already | |
| 5198 | - | // in flight. The terminal-log output is parked in i.shellBlock below | |
| 5199 | - | // the transcript until the next prompt or /clear, so it never enters | |
| 5200 | - | // the model conversation. | |
| 5179 | + | // in flight. Its terminal-log output is appended to the transcript as | |
| 5180 | + | // user context without automatically starting a model turn. | |
| 5201 | 5181 | func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |
| 5202 | 5182 | i.mu.Lock() | |
| 5203 | 5183 | if i.busy || i.shellRunning { | |
| @@ -5220,10 +5200,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5220 | 5200 | i.statusErr = "" | |
| 5221 | 5201 | i.statusOK = "" | |
| 5222 | 5202 | i.spin.StartFixed("running shell command") | |
| 5223 | - | // A new shell escape replaces the previous block; clear stale | |
| 5224 | - | // extension notes the same way a new turn would so the screen | |
| 5225 | - | // doesn't accumulate transient state. | |
| 5226 | - | i.shellBlock = nil | |
| 5203 | + | // Clear stale extension notes the same way a new turn would so the | |
| 5204 | + | // screen doesn't accumulate transient state. | |
| 5205 | + | i.extNotes = nil | |
| 5227 | 5206 | i.scrollOffset = 0 | |
| 5228 | 5207 | i.parkedTurn = 0 | |
| 5229 | 5208 | i.parkedTotal = 0 | |
| @@ -5255,13 +5234,12 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5255 | 5234 | out += "\n\n[cancelled]" | |
| 5256 | 5235 | } | |
| 5257 | 5236 | ||
| 5258 | - | block := i.renderShellBlock(out, failed) | |
| 5237 | + | i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"}) | |
| 5259 | 5238 | ||
| 5260 | 5239 | i.mu.Lock() | |
| 5261 | 5240 | i.shellRunning = false | |
| 5262 | 5241 | i.busy = false | |
| 5263 | 5242 | i.cancelTurn = nil | |
| 5264 | - | i.shellBlock = block | |
| 5265 | 5243 | if failed { | |
| 5266 | 5244 | if cancelled { | |
| 5267 | 5245 | i.statusErr = "shell command cancelled" | |
| @@ -5278,30 +5256,6 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5278 | 5256 | }() | |
| 5279 | 5257 | } | |
| 5280 | 5258 | ||
| 5281 | - | // renderShellBlock turns merged bash output into a styled terminal-log | |
| 5282 | - | // block: each line colored by overall success (tool/green) or failure | |
| 5283 | - | // (error/red), with the [exit ...] / [error] footer dimmed via the | |
| 5284 | - | // muted color so it reads as metadata. | |
| 5285 | - | func (i *Interactive) renderShellBlock(out string, failed bool) []string { | |
| 5286 | - | th := i.cfg.Theme | |
| 5287 | - | base := th.Tool | |
| 5288 | - | if failed { | |
| 5289 | - | base = th.Error | |
| 5290 | - | } | |
| 5291 | - | out = strings.TrimRight(out, "\n") | |
| 5292 | - | lines := strings.Split(out, "\n") | |
| 5293 | - | styled := make([]string, 0, len(lines)) | |
| 5294 | - | for _, line := range lines { | |
| 5295 | - | color := base | |
| 5296 | - | trimmed := strings.TrimSpace(line) | |
| 5297 | - | if strings.HasPrefix(trimmed, "[exit ") || strings.HasPrefix(trimmed, "[error]") || strings.HasPrefix(trimmed, "[cancelled]") { | |
| 5298 | - | color = th.Muted | |
| 5299 | - | } | |
| 5300 | - | styled = append(styled, th.FG256(color, line)) | |
| 5301 | - | } | |
| 5302 | - | return styled | |
| 5303 | - | } | |
| 5304 | - | ||
| 5305 | 5259 | func (i *Interactive) startTurn(parent context.Context, prompt string) { | |
| 5306 | 5260 | i.startTurnWithImages(parent, prompt, nil) | |
| 5307 | 5261 | } | |
| @@ -5343,7 +5297,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5343 | 5297 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 5344 | 5298 | i.toolOrder = nil | |
| 5345 | 5299 | i.toolGate = map[string]int{} | |
| 5346 | - | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | |
| 5347 | 5300 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 5348 | 5301 | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 5349 | 5302 | // Lift the resume tail cap once the user starts interacting. The | |
| @@ -282,7 +282,6 @@ type chatCacheKey struct { | |||||
| 282 | statusErr string | 282 | statusErr string | ||
| 283 | help string | 283 | help string | ||
| 284 | extNotes string | 284 | extNotes string | ||
| 285 | - | shellBlock string | |||
| 286 | updateAvailable bool | 285 | updateAvailable bool | ||
| 287 | updateCurrent string | 286 | updateCurrent string | ||
| 288 | updateLatest string | 287 | updateLatest string | ||
| @@ -482,13 +481,9 @@ type Interactive struct { | |||||
| 482 | // transcript) until cleared by /clear or another reset. | 481 | // transcript) until cleared by /clear or another reset. | ||
| 483 | extNotes []string | 482 | extNotes []string | ||
| 484 | 483 | ||||
| 485 | - | // shellBlock holds the rendered terminal-log lines of the most | 484 | + | // shellRunning is true while a !command is executing. It shares |
| 486 | - | // recent !command shell escape. It lives below the transcript | 485 | + | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell |
| 487 | - | // (under extNotes) until the user sends their next prompt or runs | 486 | + | // escape can start while one is in flight. |
| 488 | - | // /clear. shellRunning is true while a !command is executing; it | |||
| 489 | - | // shares i.busy/i.cancelTurn so esc cancels it and no turn or | |||
| 490 | - | // other shell escape can start while one is in flight. | |||
| 491 | - | shellBlock []string | |||
| 492 | shellRunning bool | 487 | shellRunning bool | ||
| 493 | 488 | ||||
| 494 | // sessionLoading is true while a /sessions selection is being read | 489 | // sessionLoading is true while a /sessions selection is being read | ||
| @@ -906,7 +901,6 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |||||
| 906 | statusErr: i.statusErr, | 901 | statusErr: i.statusErr, | ||
| 907 | help: strings.Join(i.helpBlock, "\n"), | 902 | help: strings.Join(i.helpBlock, "\n"), | ||
| 908 | extNotes: strings.Join(i.extNotes, "\n"), | 903 | extNotes: strings.Join(i.extNotes, "\n"), | ||
| 909 | - | shellBlock: strings.Join(i.shellBlock, "\n"), | |||
| 910 | updateAvailable: i.updateInfo.Available, | 904 | updateAvailable: i.updateInfo.Available, | ||
| 911 | updateCurrent: i.updateInfo.Current, | 905 | updateCurrent: i.updateInfo.Current, | ||
| 912 | updateLatest: i.updateInfo.Latest, | 906 | updateLatest: i.updateInfo.Latest, | ||
| @@ -1033,14 +1027,6 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 1033 | chat = append(chat, "") | 1027 | chat = append(chat, "") | ||
| 1034 | } | 1028 | } | ||
| 1035 | 1029 | ||||
| 1036 | - | // Shell-escape terminal-log block (!command). Rendered below the | |||
| 1037 | - | // transcript and extension notes; cleared when the next prompt is | |||
| 1038 | - | // sent or on /clear so it never leaks into the model conversation. | |||
| 1039 | - | if len(i.shellBlock) > 0 { | |||
| 1040 | - | chat = append(chat, i.shellBlock...) | |||
| 1041 | - | chat = append(chat, "") | |||
| 1042 | - | } | |||
| 1043 | - | ||||
| 1044 | // Strip trailing blank rows so the chat content sits flush | 1030 | // Strip trailing blank rows so the chat content sits flush | ||
| 1045 | // against the new "blank above status bar" row added by the | 1031 | // against the new "blank above status bar" row added by the | ||
| 1046 | // bottom-region assembly. Build() ends every message with a | 1032 | // bottom-region assembly. Build() ends every message with a | ||
| @@ -1662,7 +1648,10 @@ func snapViewportStartToImageBlock(chat []string, start int) int { | |||||
| 1662 | return start | 1648 | return start | ||
| 1663 | } | 1649 | } | ||
| 1664 | 1650 | ||||
| 1665 | - | const hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:" | 1651 | + | const ( |
| 1652 | + | hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:" | |||
| 1653 | + | shellEscapeMetaKey = "shell_escape" | |||
| 1654 | + | ) | |||
| 1666 | 1655 | ||||
| 1667 | func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message { | 1656 | func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message { | ||
| 1668 | if len(msgs) == 0 { | 1657 | if len(msgs) == 0 { | ||
| @@ -2319,21 +2308,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2319 | i.mu.Lock() | 2308 | i.mu.Lock() | ||
| 2320 | hadHelp := len(i.helpBlock) > 0 | 2309 | hadHelp := len(i.helpBlock) > 0 | ||
| 2321 | hadNotes := len(i.extNotes) > 0 | 2310 | hadNotes := len(i.extNotes) > 0 | ||
| 2322 | - | // Only dismiss a parked shell-escape log on esc when nothing is | |||
| 2323 | - | // running; if a !command is in flight, esc must fall through to | |||
| 2324 | - | // the cancel path below instead of just hiding the (empty) block. | |||
| 2325 | - | hadShell := len(i.shellBlock) > 0 && !i.shellRunning | |||
| 2326 | if hadHelp { | 2311 | if hadHelp { | ||
| 2327 | i.helpBlock = nil | 2312 | i.helpBlock = nil | ||
| 2328 | } | 2313 | } | ||
| 2329 | if hadNotes { | 2314 | if hadNotes { | ||
| 2330 | i.extNotes = nil | 2315 | i.extNotes = nil | ||
| 2331 | } | 2316 | } | ||
| 2332 | - | if hadShell { | |||
| 2333 | - | i.shellBlock = nil | |||
| 2334 | - | } | |||
| 2335 | i.mu.Unlock() | 2317 | i.mu.Unlock() | ||
| 2336 | - | if hadHelp || hadNotes || hadShell { | 2318 | + | if hadHelp || hadNotes { |
| 2337 | i.invalidate() | 2319 | i.invalidate() | ||
| 2338 | return false | 2320 | return false | ||
| 2339 | } | 2321 | } | ||
| @@ -2738,7 +2720,7 @@ func (i *Interactive) inputHistory() []string { | |||||
| 2738 | msgs := i.agent.Messages() | 2720 | msgs := i.agent.Messages() | ||
| 2739 | hist := make([]string, 0, len(msgs)) | 2721 | hist := make([]string, 0, len(msgs)) | ||
| 2740 | for _, m := range msgs { | 2722 | for _, m := range msgs { | ||
| 2741 | - | if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) { | 2723 | + | if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" { |
| 2742 | continue | 2724 | continue | ||
| 2743 | } | 2725 | } | ||
| 2744 | text := userMessageText(m) | 2726 | text := userMessageText(m) | ||
| @@ -4077,7 +4059,6 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4077 | i.parkedTotal = 0 | 4059 | i.parkedTotal = 0 | ||
| 4078 | i.scrollOffset = 0 | 4060 | i.scrollOffset = 0 | ||
| 4079 | i.extNotes = nil | 4061 | i.extNotes = nil | ||
| 4080 | - | i.shellBlock = nil | |||
| 4081 | i.view.InvalidateRenderCache() | 4062 | i.view.InvalidateRenderCache() | ||
| 4082 | i.mu.Unlock() | 4063 | i.mu.Unlock() | ||
| 4083 | case "/help": | 4064 | case "/help": | ||
| @@ -5195,9 +5176,8 @@ func shellEscapeCommand(text string) (string, bool) { | |||||
| 5195 | // uses, in the session working directory, honoring the /jail sandbox. | 5176 | // uses, in the session working directory, honoring the /jail sandbox. | ||
| 5196 | // It shares the busy/cancel state with the agent: esc cancels it, and | 5177 | // It shares the busy/cancel state with the agent: esc cancels it, and | ||
| 5197 | // it refuses to start while a turn or another shell escape is already | 5178 | // it refuses to start while a turn or another shell escape is already | ||
| 5198 | - | // in flight. The terminal-log output is parked in i.shellBlock below | 5179 | + | // in flight. Its terminal-log output is appended to the transcript as |
| 5199 | - | // the transcript until the next prompt or /clear, so it never enters | 5180 | + | // user context without automatically starting a model turn. |
| 5200 | - | // the model conversation. | |||
| 5201 | func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | 5181 | func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | ||
| 5202 | i.mu.Lock() | 5182 | i.mu.Lock() | ||
| 5203 | if i.busy || i.shellRunning { | 5183 | if i.busy || i.shellRunning { | ||
| @@ -5220,10 +5200,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 5220 | i.statusErr = "" | 5200 | i.statusErr = "" | ||
| 5221 | i.statusOK = "" | 5201 | i.statusOK = "" | ||
| 5222 | i.spin.StartFixed("running shell command") | 5202 | i.spin.StartFixed("running shell command") | ||
| 5223 | - | // A new shell escape replaces the previous block; clear stale | 5203 | + | // Clear stale extension notes the same way a new turn would so the |
| 5224 | - | // extension notes the same way a new turn would so the screen | 5204 | + | // screen doesn't accumulate transient state. |
| 5225 | - | // doesn't accumulate transient state. | 5205 | + | i.extNotes = nil |
| 5226 | - | i.shellBlock = nil | |||
| 5227 | i.scrollOffset = 0 | 5206 | i.scrollOffset = 0 | ||
| 5228 | i.parkedTurn = 0 | 5207 | i.parkedTurn = 0 | ||
| 5229 | i.parkedTotal = 0 | 5208 | i.parkedTotal = 0 | ||
| @@ -5255,13 +5234,12 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 5255 | out += "\n\n[cancelled]" | 5234 | out += "\n\n[cancelled]" | ||
| 5256 | } | 5235 | } | ||
| 5257 | 5236 | ||||
| 5258 | - | block := i.renderShellBlock(out, failed) | 5237 | + | i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"}) |
| 5259 | 5238 | ||||
| 5260 | i.mu.Lock() | 5239 | i.mu.Lock() | ||
| 5261 | i.shellRunning = false | 5240 | i.shellRunning = false | ||
| 5262 | i.busy = false | 5241 | i.busy = false | ||
| 5263 | i.cancelTurn = nil | 5242 | i.cancelTurn = nil | ||
| 5264 | - | i.shellBlock = block | |||
| 5265 | if failed { | 5243 | if failed { | ||
| 5266 | if cancelled { | 5244 | if cancelled { | ||
| 5267 | i.statusErr = "shell command cancelled" | 5245 | i.statusErr = "shell command cancelled" | ||
| @@ -5278,30 +5256,6 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 5278 | }() | 5256 | }() | ||
| 5279 | } | 5257 | } | ||
| 5280 | 5258 | ||||
| 5281 | - | // renderShellBlock turns merged bash output into a styled terminal-log | |||
| 5282 | - | // block: each line colored by overall success (tool/green) or failure | |||
| 5283 | - | // (error/red), with the [exit ...] / [error] footer dimmed via the | |||
| 5284 | - | // muted color so it reads as metadata. | |||
| 5285 | - | func (i *Interactive) renderShellBlock(out string, failed bool) []string { | |||
| 5286 | - | th := i.cfg.Theme | |||
| 5287 | - | base := th.Tool | |||
| 5288 | - | if failed { | |||
| 5289 | - | base = th.Error | |||
| 5290 | - | } | |||
| 5291 | - | out = strings.TrimRight(out, "\n") | |||
| 5292 | - | lines := strings.Split(out, "\n") | |||
| 5293 | - | styled := make([]string, 0, len(lines)) | |||
| 5294 | - | for _, line := range lines { | |||
| 5295 | - | color := base | |||
| 5296 | - | trimmed := strings.TrimSpace(line) | |||
| 5297 | - | if strings.HasPrefix(trimmed, "[exit ") || strings.HasPrefix(trimmed, "[error]") || strings.HasPrefix(trimmed, "[cancelled]") { | |||
| 5298 | - | color = th.Muted | |||
| 5299 | - | } | |||
| 5300 | - | styled = append(styled, th.FG256(color, line)) | |||
| 5301 | - | } | |||
| 5302 | - | return styled | |||
| 5303 | - | } | |||
| 5304 | - | ||||
| 5305 | func (i *Interactive) startTurn(parent context.Context, prompt string) { | 5259 | func (i *Interactive) startTurn(parent context.Context, prompt string) { | ||
| 5306 | i.startTurnWithImages(parent, prompt, nil) | 5260 | i.startTurnWithImages(parent, prompt, nil) | ||
| 5307 | } | 5261 | } | ||
| @@ -5343,7 +5297,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 5343 | i.toolCalls = map[string]*tui.ToolCallView{} | 5297 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 5344 | i.toolOrder = nil | 5298 | i.toolOrder = nil | ||
| 5345 | i.toolGate = map[string]int{} | 5299 | i.toolGate = map[string]int{} | ||
| 5346 | - | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | |||
| 5347 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | 5300 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | ||
| 5348 | i.scrollOffset = 0 // jump back to the bottom on new turn | 5301 | i.scrollOffset = 0 // jump back to the bottom on new turn | ||
| 5349 | // Lift the resume tail cap once the user starts interacting. The | 5302 | // Lift the resume tail cap once the user starts interacting. The | ||
M packages/agent/modes/shell_escape_test.go +35 -21
| @@ -1,12 +1,46 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "runtime" | |
| 4 | 6 | "strings" | |
| 5 | 7 | "testing" | |
| 8 | + | "time" | |
| 6 | 9 | ||
| 7 | - | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 13 | + | func TestShellEscapeAddsOutputToAgentContext(t *testing.T) { | |
| 14 | + | agent := core.NewAgent(nil, "", "", nil) | |
| 15 | + | i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()}) | |
| 16 | + | cmd := "printf zot-shell-context" | |
| 17 | + | if runtime.GOOS == "windows" { | |
| 18 | + | cmd = "echo zot-shell-context" | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | i.startShellEscape(context.Background(), cmd) | |
| 22 | + | ||
| 23 | + | deadline := time.Now().Add(5 * time.Second) | |
| 24 | + | for time.Now().Before(deadline) { | |
| 25 | + | messages := agent.Messages() | |
| 26 | + | i.mu.Lock() | |
| 27 | + | running := i.shellRunning | |
| 28 | + | i.mu.Unlock() | |
| 29 | + | if len(messages) == 1 && !running { | |
| 30 | + | text := userMessageText(messages[0]) | |
| 31 | + | if !strings.Contains(text, "zot-shell-context") { | |
| 32 | + | t.Fatalf("shell context = %q, want command output", text) | |
| 33 | + | } | |
| 34 | + | if messages[0].Meta[shellEscapeMetaKey] != "true" { | |
| 35 | + | t.Fatalf("shell context metadata = %v", messages[0].Meta) | |
| 36 | + | } | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | time.Sleep(10 * time.Millisecond) | |
| 40 | + | } | |
| 41 | + | t.Fatal("shell output was not appended to agent context") | |
| 42 | + | } | |
| 43 | + | ||
| 10 | 44 | func TestShellEscapeCommand(t *testing.T) { | |
| 11 | 45 | cases := []struct { | |
| 12 | 46 | in string | |
| @@ -30,23 +64,3 @@ func TestShellEscapeCommand(t *testing.T) { | |||
| 30 | 64 | } | |
| 31 | 65 | } | |
| 32 | 66 | } | |
| 33 | - | ||
| 34 | - | func TestRenderShellBlockStylesFooterDimmed(t *testing.T) { | |
| 35 | - | i := &Interactive{} | |
| 36 | - | i.cfg.Theme = tui.Theme{Tool: 2, Error: 1, Muted: 8} | |
| 37 | - | ||
| 38 | - | ok := i.renderShellBlock("$ echo hi\n\nhi\n\n[exit 0] Took 0.1s", false) | |
| 39 | - | if len(ok) == 0 { | |
| 40 | - | t.Fatal("expected non-empty block") | |
| 41 | - | } | |
| 42 | - | // The success body uses the Tool color; the footer uses Muted. | |
| 43 | - | body := strings.Join(ok, "\n") | |
| 44 | - | if !strings.Contains(body, "echo hi") || !strings.Contains(body, "[exit 0]") { | |
| 45 | - | t.Fatalf("block missing expected content: %q", body) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | fail := i.renderShellBlock("$ false\n\n[exit 1] Took 0.0s", true) | |
| 49 | - | if len(fail) == 0 { | |
| 50 | - | t.Fatal("expected non-empty failure block") | |
| 51 | - | } | |
| 52 | - | } | |
| @@ -1,12 +1,46 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "runtime" | |||
| 4 | "strings" | 6 | "strings" | ||
| 5 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 6 | 9 | ||||
| 7 | - | "github.com/patriceckhart/zot/packages/tui" | 10 | + | "github.com/patriceckhart/zot/packages/core" |
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 13 | + | func TestShellEscapeAddsOutputToAgentContext(t *testing.T) { | |||
| 14 | + | agent := core.NewAgent(nil, "", "", nil) | |||
| 15 | + | i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()}) | |||
| 16 | + | cmd := "printf zot-shell-context" | |||
| 17 | + | if runtime.GOOS == "windows" { | |||
| 18 | + | cmd = "echo zot-shell-context" | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | i.startShellEscape(context.Background(), cmd) | |||
| 22 | + | ||||
| 23 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 24 | + | for time.Now().Before(deadline) { | |||
| 25 | + | messages := agent.Messages() | |||
| 26 | + | i.mu.Lock() | |||
| 27 | + | running := i.shellRunning | |||
| 28 | + | i.mu.Unlock() | |||
| 29 | + | if len(messages) == 1 && !running { | |||
| 30 | + | text := userMessageText(messages[0]) | |||
| 31 | + | if !strings.Contains(text, "zot-shell-context") { | |||
| 32 | + | t.Fatalf("shell context = %q, want command output", text) | |||
| 33 | + | } | |||
| 34 | + | if messages[0].Meta[shellEscapeMetaKey] != "true" { | |||
| 35 | + | t.Fatalf("shell context metadata = %v", messages[0].Meta) | |||
| 36 | + | } | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | time.Sleep(10 * time.Millisecond) | |||
| 40 | + | } | |||
| 41 | + | t.Fatal("shell output was not appended to agent context") | |||
| 42 | + | } | |||
| 43 | + | ||||
| 10 | func TestShellEscapeCommand(t *testing.T) { | 44 | func TestShellEscapeCommand(t *testing.T) { | ||
| 11 | cases := []struct { | 45 | cases := []struct { | ||
| 12 | in string | 46 | in string | ||
| @@ -30,23 +64,3 @@ func TestShellEscapeCommand(t *testing.T) { | |||||
| 30 | } | 64 | } | ||
| 31 | } | 65 | } | ||
| 32 | } | 66 | } | ||
| 33 | - | ||||
| 34 | - | func TestRenderShellBlockStylesFooterDimmed(t *testing.T) { | |||
| 35 | - | i := &Interactive{} | |||
| 36 | - | i.cfg.Theme = tui.Theme{Tool: 2, Error: 1, Muted: 8} | |||
| 37 | - | ||||
| 38 | - | ok := i.renderShellBlock("$ echo hi\n\nhi\n\n[exit 0] Took 0.1s", false) | |||
| 39 | - | if len(ok) == 0 { | |||
| 40 | - | t.Fatal("expected non-empty block") | |||
| 41 | - | } | |||
| 42 | - | // The success body uses the Tool color; the footer uses Muted. | |||
| 43 | - | body := strings.Join(ok, "\n") | |||
| 44 | - | if !strings.Contains(body, "echo hi") || !strings.Contains(body, "[exit 0]") { | |||
| 45 | - | t.Fatalf("block missing expected content: %q", body) | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | fail := i.renderShellBlock("$ false\n\n[exit 1] Took 0.0s", true) | |||
| 49 | - | if len(fail) == 0 { | |||
| 50 | - | t.Fatal("expected non-empty failure block") | |||
| 51 | - | } | |||
| 52 | - | } | |||
M packages/core/agent.go +17 -0
| @@ -238,6 +238,23 @@ func (a *Agent) SetMessages(msgs []provider.Message) { | |||
| 238 | 238 | a.rev++ | |
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | + | // AppendUserContext adds a user-role message to the transcript without | |
| 242 | + | // starting a model turn. Hosts use it for context gathered outside the agent | |
| 243 | + | // loop, such as the output of an explicitly invoked shell command. | |
| 244 | + | func (a *Agent) AppendUserContext(text string, meta map[string]string) { | |
| 245 | + | msg := provider.Message{ | |
| 246 | + | Role: provider.RoleUser, | |
| 247 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 248 | + | Time: time.Now(), | |
| 249 | + | Meta: meta, | |
| 250 | + | } | |
| 251 | + | a.mu.Lock() | |
| 252 | + | a.messages = append(a.messages, msg) | |
| 253 | + | a.rev++ | |
| 254 | + | a.mu.Unlock() | |
| 255 | + | a.fireMessageAppended(msg) | |
| 256 | + | } | |
| 257 | + | ||
| 241 | 258 | // Cost returns the cumulative usage. | |
| 242 | 259 | func (a *Agent) Cost() provider.Usage { | |
| 243 | 260 | a.mu.Lock() | |
| @@ -238,6 +238,23 @@ func (a *Agent) SetMessages(msgs []provider.Message) { | |||||
| 238 | a.rev++ | 238 | a.rev++ | ||
| 239 | } | 239 | } | ||
| 240 | 240 | ||||
| 241 | + | // AppendUserContext adds a user-role message to the transcript without | |||
| 242 | + | // starting a model turn. Hosts use it for context gathered outside the agent | |||
| 243 | + | // loop, such as the output of an explicitly invoked shell command. | |||
| 244 | + | func (a *Agent) AppendUserContext(text string, meta map[string]string) { | |||
| 245 | + | msg := provider.Message{ | |||
| 246 | + | Role: provider.RoleUser, | |||
| 247 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 248 | + | Time: time.Now(), | |||
| 249 | + | Meta: meta, | |||
| 250 | + | } | |||
| 251 | + | a.mu.Lock() | |||
| 252 | + | a.messages = append(a.messages, msg) | |||
| 253 | + | a.rev++ | |||
| 254 | + | a.mu.Unlock() | |||
| 255 | + | a.fireMessageAppended(msg) | |||
| 256 | + | } | |||
| 257 | + | ||||
| 241 | // Cost returns the cumulative usage. | 258 | // Cost returns the cumulative usage. | ||
| 242 | func (a *Agent) Cost() provider.Usage { | 259 | func (a *Agent) Cost() provider.Usage { | ||
| 243 | a.mu.Lock() | 260 | a.mu.Lock() | ||
A packages/core/agent_context_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestAppendUserContextAddsAndPublishesMessage(t *testing.T) { | |
| 10 | + | agent := NewAgent(nil, "", "", nil) | |
| 11 | + | var appended []provider.Message | |
| 12 | + | agent.OnMessageAppended = func(msg provider.Message) { | |
| 13 | + | appended = append(appended, msg) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | before := agent.Revision() | |
| 17 | + | agent.AppendUserContext("$ pwd\n\n/tmp\n\n[exit 0]", map[string]string{"shell_escape": "true"}) | |
| 18 | + | ||
| 19 | + | messages := agent.Messages() | |
| 20 | + | if len(messages) != 1 { | |
| 21 | + | t.Fatalf("Messages() length = %d, want 1", len(messages)) | |
| 22 | + | } | |
| 23 | + | msg := messages[0] | |
| 24 | + | if msg.Role != provider.RoleUser { | |
| 25 | + | t.Fatalf("message role = %q, want %q", msg.Role, provider.RoleUser) | |
| 26 | + | } | |
| 27 | + | if msg.Meta["shell_escape"] != "true" { | |
| 28 | + | t.Fatalf("message metadata = %v, want shell_escape marker", msg.Meta) | |
| 29 | + | } | |
| 30 | + | if len(msg.Content) != 1 { | |
| 31 | + | t.Fatalf("message content length = %d, want 1", len(msg.Content)) | |
| 32 | + | } | |
| 33 | + | text, ok := msg.Content[0].(provider.TextBlock) | |
| 34 | + | if !ok || text.Text != "$ pwd\n\n/tmp\n\n[exit 0]" { | |
| 35 | + | t.Fatalf("message content = %#v", msg.Content[0]) | |
| 36 | + | } | |
| 37 | + | if msg.Time.IsZero() { | |
| 38 | + | t.Fatal("message timestamp is zero") | |
| 39 | + | } | |
| 40 | + | if agent.Revision() != before+1 { | |
| 41 | + | t.Fatalf("Revision() = %d, want %d", agent.Revision(), before+1) | |
| 42 | + | } | |
| 43 | + | if len(appended) != 1 || appended[0].Meta["shell_escape"] != "true" { | |
| 44 | + | t.Fatalf("OnMessageAppended messages = %#v", appended) | |
| 45 | + | } | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestAppendUserContextAddsAndPublishesMessage(t *testing.T) { | |||
| 10 | + | agent := NewAgent(nil, "", "", nil) | |||
| 11 | + | var appended []provider.Message | |||
| 12 | + | agent.OnMessageAppended = func(msg provider.Message) { | |||
| 13 | + | appended = append(appended, msg) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | before := agent.Revision() | |||
| 17 | + | agent.AppendUserContext("$ pwd\n\n/tmp\n\n[exit 0]", map[string]string{"shell_escape": "true"}) | |||
| 18 | + | ||||
| 19 | + | messages := agent.Messages() | |||
| 20 | + | if len(messages) != 1 { | |||
| 21 | + | t.Fatalf("Messages() length = %d, want 1", len(messages)) | |||
| 22 | + | } | |||
| 23 | + | msg := messages[0] | |||
| 24 | + | if msg.Role != provider.RoleUser { | |||
| 25 | + | t.Fatalf("message role = %q, want %q", msg.Role, provider.RoleUser) | |||
| 26 | + | } | |||
| 27 | + | if msg.Meta["shell_escape"] != "true" { | |||
| 28 | + | t.Fatalf("message metadata = %v, want shell_escape marker", msg.Meta) | |||
| 29 | + | } | |||
| 30 | + | if len(msg.Content) != 1 { | |||
| 31 | + | t.Fatalf("message content length = %d, want 1", len(msg.Content)) | |||
| 32 | + | } | |||
| 33 | + | text, ok := msg.Content[0].(provider.TextBlock) | |||
| 34 | + | if !ok || text.Text != "$ pwd\n\n/tmp\n\n[exit 0]" { | |||
| 35 | + | t.Fatalf("message content = %#v", msg.Content[0]) | |||
| 36 | + | } | |||
| 37 | + | if msg.Time.IsZero() { | |||
| 38 | + | t.Fatal("message timestamp is zero") | |||
| 39 | + | } | |||
| 40 | + | if agent.Revision() != before+1 { | |||
| 41 | + | t.Fatalf("Revision() = %d, want %d", agent.Revision(), before+1) | |||
| 42 | + | } | |||
| 43 | + | if len(appended) != 1 || appended[0].Meta["shell_escape"] != "true" { | |||
| 44 | + | t.Fatalf("OnMessageAppended messages = %#v", appended) | |||
| 45 | + | } | |||
| 46 | + | } | |||