detached 471dc8c

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,
290290
291291 ### Shell escape (`!command`)
292292
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.
294294
295295 ### `/sessions`
296296
@@ -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) {
5050 }
5151 }
5252
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+
5367 func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {
5468 ag := core.NewAgent(nil, "", "", nil)
5569 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 {
282282 statusErr string
283283 help string
284284 extNotes string
285- shellBlock string
286285 updateAvailable bool
287286 updateCurrent string
288287 updateLatest string
@@ -482,13 +481,9 @@ type Interactive struct {
482481 // transcript) until cleared by /clear or another reset.
483482 extNotes []string
484483
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.
492487 shellRunning bool
493488
494489 // sessionLoading is true while a /sessions selection is being read
@@ -906,7 +901,6 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
906901 statusErr: i.statusErr,
907902 help: strings.Join(i.helpBlock, "\n"),
908903 extNotes: strings.Join(i.extNotes, "\n"),
909- shellBlock: strings.Join(i.shellBlock, "\n"),
910904 updateAvailable: i.updateInfo.Available,
911905 updateCurrent: i.updateInfo.Current,
912906 updateLatest: i.updateInfo.Latest,
@@ -1033,14 +1027,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
10331027 chat = append(chat, "")
10341028 }
10351029
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-
10441030 // Strip trailing blank rows so the chat content sits flush
10451031 // against the new "blank above status bar" row added by the
10461032 // bottom-region assembly. Build() ends every message with a
@@ -1662,7 +1648,10 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
16621648 return start
16631649 }
16641650
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+)
16661655
16671656 func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
16681657 if len(msgs) == 0 {
@@ -2319,21 +2308,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
23192308 i.mu.Lock()
23202309 hadHelp := len(i.helpBlock) > 0
23212310 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
23262311 if hadHelp {
23272312 i.helpBlock = nil
23282313 }
23292314 if hadNotes {
23302315 i.extNotes = nil
23312316 }
2332- if hadShell {
2333- i.shellBlock = nil
2334- }
23352317 i.mu.Unlock()
2336- if hadHelp || hadNotes || hadShell {
2318+ if hadHelp || hadNotes {
23372319 i.invalidate()
23382320 return false
23392321 }
@@ -2738,7 +2720,7 @@ func (i *Interactive) inputHistory() []string {
27382720 msgs := i.agent.Messages()
27392721 hist := make([]string, 0, len(msgs))
27402722 for _, m := range msgs {
2741- if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
2723+ if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" {
27422724 continue
27432725 }
27442726 text := userMessageText(m)
@@ -4077,7 +4059,6 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
40774059 i.parkedTotal = 0
40784060 i.scrollOffset = 0
40794061 i.extNotes = nil
4080- i.shellBlock = nil
40814062 i.view.InvalidateRenderCache()
40824063 i.mu.Unlock()
40834064 case "/help":
@@ -5195,9 +5176,8 @@ func shellEscapeCommand(text string) (string, bool) {
51955176 // uses, in the session working directory, honoring the /jail sandbox.
51965177 // It shares the busy/cancel state with the agent: esc cancels it, and
51975178 // 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.
52015181 func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
52025182 i.mu.Lock()
52035183 if i.busy || i.shellRunning {
@@ -5220,10 +5200,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
52205200 i.statusErr = ""
52215201 i.statusOK = ""
52225202 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
52275206 i.scrollOffset = 0
52285207 i.parkedTurn = 0
52295208 i.parkedTotal = 0
@@ -5255,13 +5234,12 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
52555234 out += "\n\n[cancelled]"
52565235 }
52575236
5258- block := i.renderShellBlock(out, failed)
5237+ i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
52595238
52605239 i.mu.Lock()
52615240 i.shellRunning = false
52625241 i.busy = false
52635242 i.cancelTurn = nil
5264- i.shellBlock = block
52655243 if failed {
52665244 if cancelled {
52675245 i.statusErr = "shell command cancelled"
@@ -5278,30 +5256,6 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
52785256 }()
52795257 }
52805258
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-
53055259 func (i *Interactive) startTurn(parent context.Context, prompt string) {
53065260 i.startTurnWithImages(parent, prompt, nil)
53075261 }
@@ -5343,7 +5297,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
53435297 i.toolCalls = map[string]*tui.ToolCallView{}
53445298 i.toolOrder = nil
53455299 i.toolGate = map[string]int{}
5346- i.shellBlock = nil // sending a prompt clears any parked shell-escape log
53475300 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
53485301 i.scrollOffset = 0 // jump back to the bottom on new turn
53495302 // Lift the resume tail cap once the user starts interacting. The
@@ -282,7 +282,6 @@ type chatCacheKey struct {
282 statusErr string282 statusErr string
283 help string283 help string
284 extNotes string284 extNotes string
285- shellBlock string
286 updateAvailable bool285 updateAvailable bool
287 updateCurrent string286 updateCurrent string
288 updateLatest string287 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 []string482 extNotes []string
484 483
485- // shellBlock holds the rendered terminal-log lines of the most484+ // shellRunning is true while a !command is executing. It shares
486- // recent !command shell escape. It lives below the transcript485+ // 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 runs486+ // 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 bool487 shellRunning bool
493 488
494 // sessionLoading is true while a /sessions selection is being read489 // 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 flush1030 // Strip trailing blank rows so the chat content sits flush
1045 // against the new "blank above status bar" row added by the1031 // against the new "blank above status bar" row added by the
1046 // bottom-region assembly. Build() ends every message with a1032 // bottom-region assembly. Build() ends every message with a
@@ -1662,7 +1648,10 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
1662 return start1648 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) > 02309 hadHelp := len(i.helpBlock) > 0
2321 hadNotes := len(i.extNotes) > 02310 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 = nil2312 i.helpBlock = nil
2328 }2313 }
2329 if hadNotes {2314 if hadNotes {
2330 i.extNotes = nil2315 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 false2320 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 continue2724 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 = 04059 i.parkedTotal = 0
4078 i.scrollOffset = 04060 i.scrollOffset = 0
4079 i.extNotes = nil4061 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, and5177 // 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 already5178 // 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 below5179+// in flight. Its terminal-log output is appended to the transcript as
5199-// the transcript until the next prompt or /clear, so it never enters5180+// 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 stale5203+ // 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 screen5204+ // screen doesn't accumulate transient state.
5225- // doesn't accumulate transient state.5205+ i.extNotes = nil
5226- i.shellBlock = nil
5227 i.scrollOffset = 05206 i.scrollOffset = 0
5228 i.parkedTurn = 05207 i.parkedTurn = 0
5229 i.parkedTotal = 05208 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 = false5240 i.shellRunning = false
5262 i.busy = false5241 i.busy = false
5263 i.cancelTurn = nil5242 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 = nil5298 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 them5300 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
5348 i.scrollOffset = 0 // jump back to the bottom on new turn5301 i.scrollOffset = 0 // jump back to the bottom on new turn
5349 // Lift the resume tail cap once the user starts interacting. The5302 // 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 @@
11 package modes
22
33 import (
4+ "context"
5+ "runtime"
46 "strings"
57 "testing"
8+ "time"
69
7- "github.com/patriceckhart/zot/packages/tui"
10+ "github.com/patriceckhart/zot/packages/core"
811 )
912
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+
1044 func TestShellEscapeCommand(t *testing.T) {
1145 cases := []struct {
1246 in string
@@ -30,23 +64,3 @@ func TestShellEscapeCommand(t *testing.T) {
3064 }
3165 }
3266 }
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 modes1 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 string46 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) {
238238 a.rev++
239239 }
240240
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+
241258 // Cost returns the cumulative usage.
242259 func (a *Agent) Cost() provider.Usage {
243260 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+}

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