detached cde9298

Add !command shell escape and fix VS Code terminal repaints

- Shell escape: typing "!cmd" runs it via the bash tool's shell in the
  session cwd, honoring the /jail sandbox. Output is parked below the
  transcript as a styled terminal-log block until the next prompt or
  /clear, so it never enters the model conversation. Shares busy state
  with the agent: esc cancels it and no turn or other escape can start
  while one is in flight.
- VS Code terminal: full repaints used \x1b[2J, which xterm.js scrolls
  into scrollback and duplicates the frame. Clear in place via cursor
  home + erase-to-end under keepScrollback; Clear()/Resize() no longer
  eagerly wipe. Force a viewport-safe Invalidate on slash/file popup
  open and close transitions there.
- Restore the live tool-call overlay behavior (keep in-flight boxes
  visible until the tool_result reaches the transcript) and drop the
  forced repaint at turn start.
- Document the shell escape in the README.
commit
cde92984102f586a67910afbc2e00831fa8f7b9d
author
patriceckhart · · 2026-06-04 16:05 UTC
parent
ec5eb20
signature
unsigned
7 files · +395 -42
M README.md +4 -0
@@ -210,6 +210,10 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
210210
211211 Extension-registered commands appear under a divider at the bottom of the popup, sorted by name.
212212
213+### Shell escape (`!command`)
214+
215+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.
216+
213217 ### `/sessions`
214218
215219 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, `esc` to cancel. 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.
@@ -210,6 +210,10 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
210 210
211 Extension-registered commands appear under a divider at the bottom of the popup, sorted by name.211 Extension-registered commands appear under a divider at the bottom of the popup, sorted by name.
212 212
213+### Shell escape (`!command`)
214+
215+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.
216+
213 ### `/sessions`217 ### `/sessions`
214 218
215 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, `esc` to cancel. 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.219 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, `esc` to cancel. 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/interactive.go +184 -10
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "encoding/json"
56 "fmt"
67 "os"
78 "path/filepath"
@@ -213,6 +214,7 @@ type chatCacheKey struct {
213214 statusErr string
214215 help string
215216 extNotes string
217+ shellBlock string
216218 updateAvailable bool
217219 updateCurrent string
218220 updateLatest string
@@ -378,6 +380,15 @@ type Interactive struct {
378380 // transcript) until cleared by /clear or another reset.
379381 extNotes []string
380382
383+ // shellBlock holds the rendered terminal-log lines of the most
384+ // recent !command shell escape. It lives below the transcript
385+ // (under extNotes) until the user sends their next prompt or runs
386+ // /clear. shellRunning is true while a !command is executing; it
387+ // shares i.busy/i.cancelTurn so esc cancels it and no turn or
388+ // other shell escape can start while one is in flight.
389+ shellBlock []string
390+ shellRunning bool
391+
381392 // sessionLoading is true while a /sessions selection is being read
382393 // on a background goroutine. Keeping this off the input goroutine
383394 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
@@ -754,6 +765,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
754765 statusErr: i.statusErr,
755766 help: strings.Join(i.helpBlock, "\n"),
756767 extNotes: strings.Join(i.extNotes, "\n"),
768+ shellBlock: strings.Join(i.shellBlock, "\n"),
757769 updateAvailable: i.updateInfo.Available,
758770 updateCurrent: i.updateInfo.Current,
759771 updateLatest: i.updateInfo.Latest,
@@ -872,6 +884,14 @@ func (i *Interactive) buildChatLocked(cols int) []string {
872884 chat = append(chat, "")
873885 }
874886
887+ // Shell-escape terminal-log block (!command). Rendered below the
888+ // transcript and extension notes; cleared when the next prompt is
889+ // sent or on /clear so it never leaks into the model conversation.
890+ if len(i.shellBlock) > 0 {
891+ chat = append(chat, i.shellBlock...)
892+ chat = append(chat, "")
893+ }
894+
875895 // Strip trailing blank rows so the chat content sits flush
876896 // against the new "blank above status bar" row added by the
877897 // bottom-region assembly. Build() ends every message with a
@@ -1041,8 +1061,19 @@ func (i *Interactive) redraw() {
10411061 // from the chat below, instead of the diff path leaving stale
10421062 // dialog content behind. Equivalent to the user pressing ctrl+l.
10431063 overlayOpen := len(dialog) > 0 || len(suggest) > 0
1044- if i.prevOverlayOpen && !overlayOpen && i.rend != nil {
1045- i.rend.Clear()
1064+ if i.rend != nil && i.prevOverlayOpen != overlayOpen {
1065+ // VS Code's terminal keeps scrollback (no \x1b[3J), so the
1066+ // diff path leaves stale rows behind when the slash/file popup
1067+ // opens AND when it closes. Force a full repaint on both
1068+ // transitions there, using Invalidate so we don't trigger VS
1069+ // Code's viewport-snap the way Clear would. Other terminals
1070+ // only need the close-transition Clear (the open paint is
1071+ // clean because scrollback was wiped).
1072+ if i.rend.KeepsScrollback() {
1073+ i.rend.Invalidate()
1074+ } else if i.prevOverlayOpen && !overlayOpen {
1075+ i.rend.Clear()
1076+ }
10461077 }
10471078 i.prevOverlayOpen = overlayOpen
10481079 if len(suggest) > 0 {
@@ -1845,14 +1876,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18451876 i.mu.Lock()
18461877 hadHelp := len(i.helpBlock) > 0
18471878 hadNotes := len(i.extNotes) > 0
1879+ // Only dismiss a parked shell-escape log on esc when nothing is
1880+ // running; if a !command is in flight, esc must fall through to
1881+ // the cancel path below instead of just hiding the (empty) block.
1882+ hadShell := len(i.shellBlock) > 0 && !i.shellRunning
18481883 if hadHelp {
18491884 i.helpBlock = nil
18501885 }
18511886 if hadNotes {
18521887 i.extNotes = nil
18531888 }
1889+ if hadShell {
1890+ i.shellBlock = nil
1891+ }
18541892 i.mu.Unlock()
1855- if hadHelp || hadNotes {
1893+ if hadHelp || hadNotes || hadShell {
18561894 i.invalidate()
18571895 return false
18581896 }
@@ -2072,6 +2110,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
20722110 i.suggest.Reset()
20732111 i.fileSuggest.Reset()
20742112
2113+ if cmd, ok := shellEscapeCommand(text); ok {
2114+ i.startShellEscape(ctx, cmd)
2115+ return false
2116+ }
2117+
20752118 if looksLikeSlashCommand(text) {
20762119 head := text
20772120 rest := ""
@@ -2308,6 +2351,10 @@ func (i *Interactive) Notify(extName, level, message string) {
23082351
23092352 // Submit feeds text through the agent loop as if the user had typed it.
23102353 func (i *Interactive) Submit(text string) {
2354+ if cmd, ok := shellEscapeCommand(text); ok {
2355+ i.startShellEscape(i.runCtx, cmd)
2356+ return
2357+ }
23112358 i.startTurn(i.runCtx, text)
23122359 }
23132360
@@ -2368,6 +2415,10 @@ func (i *Interactive) SubmitSlash(text string) {
23682415 // forwarded — because the queued-prompt path is text-only; a
23692416 // follow-up can expand the queue entry to carry images.
23702417 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2418+ if cmd, ok := shellEscapeCommand(text); ok {
2419+ i.startShellEscape(i.runCtx, cmd)
2420+ return
2421+ }
23712422 i.mu.Lock()
23722423 if i.agent == nil {
23732424 i.statusErr = "not logged in. type /login first."
@@ -3000,6 +3051,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
30003051 i.parkedTotal = 0
30013052 i.scrollOffset = 0
30023053 i.extNotes = nil
3054+ i.shellBlock = nil
30033055 i.view.InvalidateRenderCache()
30043056 i.mu.Unlock()
30053057 case "/help":
@@ -3937,6 +3989,134 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
39373989 }()
39383990 }
39393991
3992+// shellEscapeCommand reports whether text is a "!command" shell
3993+// escape and, if so, returns the command with the leading '!' (and
3994+// surrounding whitespace) stripped. A bare "!" with no command is
3995+// treated as not an escape so it falls through to the normal prompt
3996+// path rather than running an empty shell.
3997+func shellEscapeCommand(text string) (string, bool) {
3998+ trimmed := strings.TrimLeft(text, " \t")
3999+ if !strings.HasPrefix(trimmed, "!") {
4000+ return "", false
4001+ }
4002+ cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
4003+ if cmd == "" {
4004+ return "", false
4005+ }
4006+ return cmd, true
4007+}
4008+
4009+// startShellEscape runs a "!command" in the same shell the bash tool
4010+// uses, in the session working directory, honoring the /jail sandbox.
4011+// It shares the busy/cancel state with the agent: esc cancels it, and
4012+// it refuses to start while a turn or another shell escape is already
4013+// in flight. The terminal-log output is parked in i.shellBlock below
4014+// the transcript until the next prompt or /clear, so it never enters
4015+// the model conversation.
4016+func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
4017+ i.mu.Lock()
4018+ if i.busy || i.shellRunning {
4019+ i.statusErr = "busy — wait for the current turn to finish before running a shell command"
4020+ i.statusOK = ""
4021+ i.mu.Unlock()
4022+ i.invalidate()
4023+ return
4024+ }
4025+ if parent == nil {
4026+ parent = i.runCtx
4027+ }
4028+ if parent == nil {
4029+ parent = context.Background()
4030+ }
4031+ ctx, cancel := context.WithCancel(parent)
4032+ i.busy = true
4033+ i.shellRunning = true
4034+ i.cancelTurn = cancel
4035+ i.statusErr = ""
4036+ i.statusOK = ""
4037+ i.spin.StartFixed("running shell command")
4038+ // A new shell escape replaces the previous block; clear stale
4039+ // extension notes the same way a new turn would so the screen
4040+ // doesn't accumulate transient state.
4041+ i.shellBlock = nil
4042+ i.scrollOffset = 0
4043+ i.parkedTurn = 0
4044+ i.parkedTotal = 0
4045+ i.helpBlock = nil
4046+ sandbox := i.cfg.Sandbox
4047+ cwd := i.cfg.CWD
4048+ i.mu.Unlock()
4049+ i.invalidate()
4050+
4051+ go func() {
4052+ defer cancel()
4053+ raw, _ := json.Marshal(map[string]any{"command": cmd})
4054+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
4055+ res, err := bash.Execute(ctx, raw, nil)
4056+
4057+ var out string
4058+ if err != nil {
4059+ out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
4060+ } else {
4061+ for _, c := range res.Content {
4062+ if tb, ok := c.(provider.TextBlock); ok {
4063+ out += tb.Text
4064+ }
4065+ }
4066+ }
4067+ cancelled := ctx.Err() != nil
4068+ failed := err != nil || res.IsError || cancelled
4069+ if cancelled {
4070+ out += "\n\n[cancelled]"
4071+ }
4072+
4073+ block := i.renderShellBlock(out, failed)
4074+
4075+ i.mu.Lock()
4076+ i.shellRunning = false
4077+ i.busy = false
4078+ i.cancelTurn = nil
4079+ i.shellBlock = block
4080+ if failed {
4081+ if cancelled {
4082+ i.statusErr = "shell command cancelled"
4083+ } else {
4084+ i.statusErr = "shell command failed"
4085+ }
4086+ i.statusOK = ""
4087+ } else {
4088+ i.statusOK = "shell command finished"
4089+ i.statusErr = ""
4090+ }
4091+ i.mu.Unlock()
4092+ i.invalidate()
4093+ }()
4094+}
4095+
4096+// renderShellBlock turns merged bash output into a styled terminal-log
4097+// block: each line colored by overall success (tool/green) or failure
4098+// (error/red), with the [exit ...] / [error] footer dimmed via the
4099+// muted color so it reads as metadata.
4100+func (i *Interactive) renderShellBlock(out string, failed bool) []string {
4101+ th := i.cfg.Theme
4102+ base := th.Tool
4103+ if failed {
4104+ base = th.Error
4105+ }
4106+ out = strings.TrimRight(out, "\n")
4107+ lines := strings.Split(out, "\n")
4108+ styled := make([]string, 0, len(lines))
4109+ for _, line := range lines {
4110+ color := base
4111+ trimmed := strings.TrimSpace(line)
4112+ if strings.HasPrefix(trimmed, "[exit ") || strings.HasPrefix(trimmed, "[error]") || strings.HasPrefix(trimmed, "[cancelled]") {
4113+ color = th.Muted
4114+ }
4115+ styled = append(styled, th.FG256(color, line))
4116+ }
4117+ return styled
4118+}
4119+
39404120 func (i *Interactive) startTurn(parent context.Context, prompt string) {
39414121 i.startTurnWithImages(parent, prompt, nil)
39424122 }
@@ -3945,13 +4125,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
39454125 if i.agent == nil {
39464126 return
39474127 }
3948- // Force a full repaint when a new turn begins so any stray dialog,
3949- // popup, or stale tool-progress rows don't leak into the visible
3950- // chat area before the assistant starts streaming. Equivalent to
3951- // the user pressing ctrl+l right before submit.
3952- if i.rend != nil {
3953- i.rend.Clear()
3954- }
39554128 // Pre-turn safety: if the most recent context measurement is
39564129 // already past the auto-compact threshold, condense before
39574130 // sending so the next outbound request stays under the limit.
@@ -3984,6 +4157,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
39844157 i.streamOn = true
39854158 i.toolCalls = map[string]*tui.ToolCallView{}
39864159 i.toolOrder = nil
4160+ i.shellBlock = nil // sending a prompt clears any parked shell-escape log
39874161 i.scrollOffset = 0 // jump back to the bottom on new turn
39884162 // Reset the auto-follow baseline so the very next render at
39894163 // interactive.go:1053 doesn't see a synthetic shrink between
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "fmt"6 "fmt"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
@@ -213,6 +214,7 @@ type chatCacheKey struct {
213 statusErr string214 statusErr string
214 help string215 help string
215 extNotes string216 extNotes string
217+ shellBlock string
216 updateAvailable bool218 updateAvailable bool
217 updateCurrent string219 updateCurrent string
218 updateLatest string220 updateLatest string
@@ -378,6 +380,15 @@ type Interactive struct {
378 // transcript) until cleared by /clear or another reset.380 // transcript) until cleared by /clear or another reset.
379 extNotes []string381 extNotes []string
380 382
383+ // shellBlock holds the rendered terminal-log lines of the most
384+ // recent !command shell escape. It lives below the transcript
385+ // (under extNotes) until the user sends their next prompt or runs
386+ // /clear. shellRunning is true while a !command is executing; it
387+ // shares i.busy/i.cancelTurn so esc cancels it and no turn or
388+ // other shell escape can start while one is in flight.
389+ shellBlock []string
390+ shellRunning bool
391+
381 // sessionLoading is true while a /sessions selection is being read392 // sessionLoading is true while a /sessions selection is being read
382 // on a background goroutine. Keeping this off the input goroutine393 // on a background goroutine. Keeping this off the input goroutine
383 // lets ctrl+c/exit remain responsive for very large JSONL sessions.394 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
@@ -754,6 +765,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
754 statusErr: i.statusErr,765 statusErr: i.statusErr,
755 help: strings.Join(i.helpBlock, "\n"),766 help: strings.Join(i.helpBlock, "\n"),
756 extNotes: strings.Join(i.extNotes, "\n"),767 extNotes: strings.Join(i.extNotes, "\n"),
768+ shellBlock: strings.Join(i.shellBlock, "\n"),
757 updateAvailable: i.updateInfo.Available,769 updateAvailable: i.updateInfo.Available,
758 updateCurrent: i.updateInfo.Current,770 updateCurrent: i.updateInfo.Current,
759 updateLatest: i.updateInfo.Latest,771 updateLatest: i.updateInfo.Latest,
@@ -872,6 +884,14 @@ func (i *Interactive) buildChatLocked(cols int) []string {
872 chat = append(chat, "")884 chat = append(chat, "")
873 }885 }
874 886
887+ // Shell-escape terminal-log block (!command). Rendered below the
888+ // transcript and extension notes; cleared when the next prompt is
889+ // sent or on /clear so it never leaks into the model conversation.
890+ if len(i.shellBlock) > 0 {
891+ chat = append(chat, i.shellBlock...)
892+ chat = append(chat, "")
893+ }
894+
875 // Strip trailing blank rows so the chat content sits flush895 // Strip trailing blank rows so the chat content sits flush
876 // against the new "blank above status bar" row added by the896 // against the new "blank above status bar" row added by the
877 // bottom-region assembly. Build() ends every message with a897 // bottom-region assembly. Build() ends every message with a
@@ -1041,8 +1061,19 @@ func (i *Interactive) redraw() {
1041 // from the chat below, instead of the diff path leaving stale1061 // from the chat below, instead of the diff path leaving stale
1042 // dialog content behind. Equivalent to the user pressing ctrl+l.1062 // dialog content behind. Equivalent to the user pressing ctrl+l.
1043 overlayOpen := len(dialog) > 0 || len(suggest) > 01063 overlayOpen := len(dialog) > 0 || len(suggest) > 0
1044- if i.prevOverlayOpen && !overlayOpen && i.rend != nil {1064+ if i.rend != nil && i.prevOverlayOpen != overlayOpen {
1045- i.rend.Clear()1065+ // VS Code's terminal keeps scrollback (no \x1b[3J), so the
1066+ // diff path leaves stale rows behind when the slash/file popup
1067+ // opens AND when it closes. Force a full repaint on both
1068+ // transitions there, using Invalidate so we don't trigger VS
1069+ // Code's viewport-snap the way Clear would. Other terminals
1070+ // only need the close-transition Clear (the open paint is
1071+ // clean because scrollback was wiped).
1072+ if i.rend.KeepsScrollback() {
1073+ i.rend.Invalidate()
1074+ } else if i.prevOverlayOpen && !overlayOpen {
1075+ i.rend.Clear()
1076+ }
1046 }1077 }
1047 i.prevOverlayOpen = overlayOpen1078 i.prevOverlayOpen = overlayOpen
1048 if len(suggest) > 0 {1079 if len(suggest) > 0 {
@@ -1845,14 +1876,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1845 i.mu.Lock()1876 i.mu.Lock()
1846 hadHelp := len(i.helpBlock) > 01877 hadHelp := len(i.helpBlock) > 0
1847 hadNotes := len(i.extNotes) > 01878 hadNotes := len(i.extNotes) > 0
1879+ // Only dismiss a parked shell-escape log on esc when nothing is
1880+ // running; if a !command is in flight, esc must fall through to
1881+ // the cancel path below instead of just hiding the (empty) block.
1882+ hadShell := len(i.shellBlock) > 0 && !i.shellRunning
1848 if hadHelp {1883 if hadHelp {
1849 i.helpBlock = nil1884 i.helpBlock = nil
1850 }1885 }
1851 if hadNotes {1886 if hadNotes {
1852 i.extNotes = nil1887 i.extNotes = nil
1853 }1888 }
1889+ if hadShell {
1890+ i.shellBlock = nil
1891+ }
1854 i.mu.Unlock()1892 i.mu.Unlock()
1855- if hadHelp || hadNotes {1893+ if hadHelp || hadNotes || hadShell {
1856 i.invalidate()1894 i.invalidate()
1857 return false1895 return false
1858 }1896 }
@@ -2072,6 +2110,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2072 i.suggest.Reset()2110 i.suggest.Reset()
2073 i.fileSuggest.Reset()2111 i.fileSuggest.Reset()
2074 2112
2113+ if cmd, ok := shellEscapeCommand(text); ok {
2114+ i.startShellEscape(ctx, cmd)
2115+ return false
2116+ }
2117+
2075 if looksLikeSlashCommand(text) {2118 if looksLikeSlashCommand(text) {
2076 head := text2119 head := text
2077 rest := ""2120 rest := ""
@@ -2308,6 +2351,10 @@ func (i *Interactive) Notify(extName, level, message string) {
2308 2351
2309 // Submit feeds text through the agent loop as if the user had typed it.2352 // Submit feeds text through the agent loop as if the user had typed it.
2310 func (i *Interactive) Submit(text string) {2353 func (i *Interactive) Submit(text string) {
2354+ if cmd, ok := shellEscapeCommand(text); ok {
2355+ i.startShellEscape(i.runCtx, cmd)
2356+ return
2357+ }
2311 i.startTurn(i.runCtx, text)2358 i.startTurn(i.runCtx, text)
2312 }2359 }
2313 2360
@@ -2368,6 +2415,10 @@ func (i *Interactive) SubmitSlash(text string) {
2368 // forwarded — because the queued-prompt path is text-only; a2415 // forwarded — because the queued-prompt path is text-only; a
2369 // follow-up can expand the queue entry to carry images.2416 // follow-up can expand the queue entry to carry images.
2370 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {2417 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2418+ if cmd, ok := shellEscapeCommand(text); ok {
2419+ i.startShellEscape(i.runCtx, cmd)
2420+ return
2421+ }
2371 i.mu.Lock()2422 i.mu.Lock()
2372 if i.agent == nil {2423 if i.agent == nil {
2373 i.statusErr = "not logged in. type /login first."2424 i.statusErr = "not logged in. type /login first."
@@ -3000,6 +3051,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
3000 i.parkedTotal = 03051 i.parkedTotal = 0
3001 i.scrollOffset = 03052 i.scrollOffset = 0
3002 i.extNotes = nil3053 i.extNotes = nil
3054+ i.shellBlock = nil
3003 i.view.InvalidateRenderCache()3055 i.view.InvalidateRenderCache()
3004 i.mu.Unlock()3056 i.mu.Unlock()
3005 case "/help":3057 case "/help":
@@ -3937,6 +3989,134 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
3937 }()3989 }()
3938 }3990 }
3939 3991
3992+// shellEscapeCommand reports whether text is a "!command" shell
3993+// escape and, if so, returns the command with the leading '!' (and
3994+// surrounding whitespace) stripped. A bare "!" with no command is
3995+// treated as not an escape so it falls through to the normal prompt
3996+// path rather than running an empty shell.
3997+func shellEscapeCommand(text string) (string, bool) {
3998+ trimmed := strings.TrimLeft(text, " \t")
3999+ if !strings.HasPrefix(trimmed, "!") {
4000+ return "", false
4001+ }
4002+ cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
4003+ if cmd == "" {
4004+ return "", false
4005+ }
4006+ return cmd, true
4007+}
4008+
4009+// startShellEscape runs a "!command" in the same shell the bash tool
4010+// uses, in the session working directory, honoring the /jail sandbox.
4011+// It shares the busy/cancel state with the agent: esc cancels it, and
4012+// it refuses to start while a turn or another shell escape is already
4013+// in flight. The terminal-log output is parked in i.shellBlock below
4014+// the transcript until the next prompt or /clear, so it never enters
4015+// the model conversation.
4016+func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
4017+ i.mu.Lock()
4018+ if i.busy || i.shellRunning {
4019+ i.statusErr = "busy — wait for the current turn to finish before running a shell command"
4020+ i.statusOK = ""
4021+ i.mu.Unlock()
4022+ i.invalidate()
4023+ return
4024+ }
4025+ if parent == nil {
4026+ parent = i.runCtx
4027+ }
4028+ if parent == nil {
4029+ parent = context.Background()
4030+ }
4031+ ctx, cancel := context.WithCancel(parent)
4032+ i.busy = true
4033+ i.shellRunning = true
4034+ i.cancelTurn = cancel
4035+ i.statusErr = ""
4036+ i.statusOK = ""
4037+ i.spin.StartFixed("running shell command")
4038+ // A new shell escape replaces the previous block; clear stale
4039+ // extension notes the same way a new turn would so the screen
4040+ // doesn't accumulate transient state.
4041+ i.shellBlock = nil
4042+ i.scrollOffset = 0
4043+ i.parkedTurn = 0
4044+ i.parkedTotal = 0
4045+ i.helpBlock = nil
4046+ sandbox := i.cfg.Sandbox
4047+ cwd := i.cfg.CWD
4048+ i.mu.Unlock()
4049+ i.invalidate()
4050+
4051+ go func() {
4052+ defer cancel()
4053+ raw, _ := json.Marshal(map[string]any{"command": cmd})
4054+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
4055+ res, err := bash.Execute(ctx, raw, nil)
4056+
4057+ var out string
4058+ if err != nil {
4059+ out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
4060+ } else {
4061+ for _, c := range res.Content {
4062+ if tb, ok := c.(provider.TextBlock); ok {
4063+ out += tb.Text
4064+ }
4065+ }
4066+ }
4067+ cancelled := ctx.Err() != nil
4068+ failed := err != nil || res.IsError || cancelled
4069+ if cancelled {
4070+ out += "\n\n[cancelled]"
4071+ }
4072+
4073+ block := i.renderShellBlock(out, failed)
4074+
4075+ i.mu.Lock()
4076+ i.shellRunning = false
4077+ i.busy = false
4078+ i.cancelTurn = nil
4079+ i.shellBlock = block
4080+ if failed {
4081+ if cancelled {
4082+ i.statusErr = "shell command cancelled"
4083+ } else {
4084+ i.statusErr = "shell command failed"
4085+ }
4086+ i.statusOK = ""
4087+ } else {
4088+ i.statusOK = "shell command finished"
4089+ i.statusErr = ""
4090+ }
4091+ i.mu.Unlock()
4092+ i.invalidate()
4093+ }()
4094+}
4095+
4096+// renderShellBlock turns merged bash output into a styled terminal-log
4097+// block: each line colored by overall success (tool/green) or failure
4098+// (error/red), with the [exit ...] / [error] footer dimmed via the
4099+// muted color so it reads as metadata.
4100+func (i *Interactive) renderShellBlock(out string, failed bool) []string {
4101+ th := i.cfg.Theme
4102+ base := th.Tool
4103+ if failed {
4104+ base = th.Error
4105+ }
4106+ out = strings.TrimRight(out, "\n")
4107+ lines := strings.Split(out, "\n")
4108+ styled := make([]string, 0, len(lines))
4109+ for _, line := range lines {
4110+ color := base
4111+ trimmed := strings.TrimSpace(line)
4112+ if strings.HasPrefix(trimmed, "[exit ") || strings.HasPrefix(trimmed, "[error]") || strings.HasPrefix(trimmed, "[cancelled]") {
4113+ color = th.Muted
4114+ }
4115+ styled = append(styled, th.FG256(color, line))
4116+ }
4117+ return styled
4118+}
4119+
3940 func (i *Interactive) startTurn(parent context.Context, prompt string) {4120 func (i *Interactive) startTurn(parent context.Context, prompt string) {
3941 i.startTurnWithImages(parent, prompt, nil)4121 i.startTurnWithImages(parent, prompt, nil)
3942 }4122 }
@@ -3945,13 +4125,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
3945 if i.agent == nil {4125 if i.agent == nil {
3946 return4126 return
3947 }4127 }
3948- // Force a full repaint when a new turn begins so any stray dialog,
3949- // popup, or stale tool-progress rows don't leak into the visible
3950- // chat area before the assistant starts streaming. Equivalent to
3951- // the user pressing ctrl+l right before submit.
3952- if i.rend != nil {
3953- i.rend.Clear()
3954- }
3955 // Pre-turn safety: if the most recent context measurement is4128 // Pre-turn safety: if the most recent context measurement is
3956 // already past the auto-compact threshold, condense before4129 // already past the auto-compact threshold, condense before
3957 // sending so the next outbound request stays under the limit.4130 // sending so the next outbound request stays under the limit.
@@ -3984,6 +4157,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
3984 i.streamOn = true4157 i.streamOn = true
3985 i.toolCalls = map[string]*tui.ToolCallView{}4158 i.toolCalls = map[string]*tui.ToolCallView{}
3986 i.toolOrder = nil4159 i.toolOrder = nil
4160+ i.shellBlock = nil // sending a prompt clears any parked shell-escape log
3987 i.scrollOffset = 0 // jump back to the bottom on new turn4161 i.scrollOffset = 0 // jump back to the bottom on new turn
3988 // Reset the auto-follow baseline so the very next render at4162 // Reset the auto-follow baseline so the very next render at
3989 // interactive.go:1053 doesn't see a synthetic shrink between4163 // interactive.go:1053 doesn't see a synthetic shrink between
A packages/agent/modes/shell_escape_test.go +52 -0
@@ -0,0 +1,52 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+func TestShellEscapeCommand(t *testing.T) {
11+ cases := []struct {
12+ in string
13+ wantCmd string
14+ wantOK bool
15+ }{
16+ {"!ls -la", "ls -la", true},
17+ {" !pwd", "pwd", true},
18+ {"! go test ./... ", "go test ./...", true},
19+ {"!", "", false},
20+ {"! ", "", false},
21+ {"ls -la", "", false},
22+ {"/help", "", false},
23+ {"hello !world", "", false},
24+ }
25+ for _, c := range cases {
26+ cmd, ok := shellEscapeCommand(c.in)
27+ if ok != c.wantOK || cmd != c.wantCmd {
28+ t.Errorf("shellEscapeCommand(%q) = (%q,%v); want (%q,%v)",
29+ c.in, cmd, ok, c.wantCmd, c.wantOK)
30+ }
31+ }
32+}
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+}
@@ -0,0 +1,52 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+func TestShellEscapeCommand(t *testing.T) {
11+ cases := []struct {
12+ in string
13+ wantCmd string
14+ wantOK bool
15+ }{
16+ {"!ls -la", "ls -la", true},
17+ {" !pwd", "pwd", true},
18+ {"! go test ./... ", "go test ./...", true},
19+ {"!", "", false},
20+ {"! ", "", false},
21+ {"ls -la", "", false},
22+ {"/help", "", false},
23+ {"hello !world", "", false},
24+ }
25+ for _, c := range cases {
26+ cmd, ok := shellEscapeCommand(c.in)
27+ if ok != c.wantOK || cmd != c.wantCmd {
28+ t.Errorf("shellEscapeCommand(%q) = (%q,%v); want (%q,%v)",
29+ c.in, cmd, ok, c.wantCmd, c.wantOK)
30+ }
31+ }
32+}
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/tui/render.go +36 -5
@@ -122,14 +122,16 @@ func (r *Renderer) Resize(cols, rows int) {
122122 r.logViewportTop = 0
123123 r.logHardwareRow = 0
124124 r.logInit = false
125- if r.out != nil {
125+ if r.out != nil && !r.keepScrollback {
126126 // Clear both screen and (where safe) scrollback so stale
127127 // content from the old width doesn't bleed through. Move
128128 // to (1,1) so the next DrawLog/writeFull starts from a
129129 // clean slate. Use the no-home variant: the explicit
130130 // MoveTo below sets the cursor without triggering VS
131131 // Code's viewport-snap. See Renderer.keepScrollback for
132- // why we skip \x1b[3J on VS Code's terminal.
132+ // why we skip \x1b[3J on VS Code's terminal. On VS Code we
133+ // skip the eager wipe entirely and let the next DrawLog
134+ // repaint in place (see Clear).
133135 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
134136 }
135137 }
@@ -148,6 +150,15 @@ func (r *Renderer) Clear() {
148150 r.logViewportTop = 0
149151 r.logHardwareRow = 0
150152 r.logInit = false
153+ if r.keepScrollback {
154+ // Don't eagerly emit \x1b[2J here: on VS Code's xterm.js that
155+ // scrolls the current frame up into scrollback, leaving a
156+ // duplicate above the repaint. Just drop the diff state; the
157+ // next DrawLog's writeFull does an in-place viewport clear
158+ // (home + erase-to-end) that overwrites the old frame without
159+ // duplicating it.
160+ return
161+ }
151162 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
152163 }
153164
@@ -163,6 +174,12 @@ func (r *Renderer) clearScrollbackSeq() string {
163174 return SeqClearScrollback
164175 }
165176
177+// KeepsScrollback reports whether this renderer suppresses the
178+// scrollback-clear escape (true under VS Code's terminal). Callers
179+// use it to pick a viewport-safe full repaint (Invalidate) over a
180+// scrollback-clearing one (Clear) when redrawing overlays.
181+func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback }
182+
166183 // Invalidate forces a full repaint on the next Draw without clearing the
167184 // whole terminal first. Useful when the cached diff is unreliable but a
168185 // visible full-screen flash would be too distracting.
@@ -452,9 +469,23 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
452469 writeFull := func(clear bool) {
453470 if clear {
454471 w.WriteString(SeqDeleteKittyImages)
455- w.WriteString(SeqClearScreenNoHome)
456- w.WriteString(r.clearScrollbackSeq())
457- w.WriteString(MoveTo(1, 1))
472+ if r.keepScrollback {
473+ // VS Code's xterm.js scrolls the visible content up into
474+ // scrollback on \x1b[2J, which duplicates the frame (the
475+ // old paint stays above the new one). Home to the
476+ // viewport top and erase-to-end (\x1b[0J) instead: that
477+ // clears the visible screen in place without pushing the
478+ // previous frame into scrollback. We still cannot drop
479+ // existing scrollback (\x1b[3J snaps the viewport there),
480+ // but a full repaint no longer stacks a fresh copy below
481+ // the old one.
482+ w.WriteString(SeqCursorHome)
483+ w.WriteString(SeqClearToEnd)
484+ } else {
485+ w.WriteString(SeqClearScreenNoHome)
486+ w.WriteString(r.clearScrollbackSeq())
487+ w.WriteString(MoveTo(1, 1))
488+ }
458489 }
459490 for idx, line := range lines {
460491 if idx > 0 {
@@ -122,14 +122,16 @@ func (r *Renderer) Resize(cols, rows int) {
122 r.logViewportTop = 0122 r.logViewportTop = 0
123 r.logHardwareRow = 0123 r.logHardwareRow = 0
124 r.logInit = false124 r.logInit = false
125- if r.out != nil {125+ if r.out != nil && !r.keepScrollback {
126 // Clear both screen and (where safe) scrollback so stale126 // Clear both screen and (where safe) scrollback so stale
127 // content from the old width doesn't bleed through. Move127 // content from the old width doesn't bleed through. Move
128 // to (1,1) so the next DrawLog/writeFull starts from a128 // to (1,1) so the next DrawLog/writeFull starts from a
129 // clean slate. Use the no-home variant: the explicit129 // clean slate. Use the no-home variant: the explicit
130 // MoveTo below sets the cursor without triggering VS130 // MoveTo below sets the cursor without triggering VS
131 // Code's viewport-snap. See Renderer.keepScrollback for131 // Code's viewport-snap. See Renderer.keepScrollback for
132- // why we skip \x1b[3J on VS Code's terminal.132+ // why we skip \x1b[3J on VS Code's terminal. On VS Code we
133+ // skip the eager wipe entirely and let the next DrawLog
134+ // repaint in place (see Clear).
133 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))135 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
134 }136 }
135 }137 }
@@ -148,6 +150,15 @@ func (r *Renderer) Clear() {
148 r.logViewportTop = 0150 r.logViewportTop = 0
149 r.logHardwareRow = 0151 r.logHardwareRow = 0
150 r.logInit = false152 r.logInit = false
153+ if r.keepScrollback {
154+ // Don't eagerly emit \x1b[2J here: on VS Code's xterm.js that
155+ // scrolls the current frame up into scrollback, leaving a
156+ // duplicate above the repaint. Just drop the diff state; the
157+ // next DrawLog's writeFull does an in-place viewport clear
158+ // (home + erase-to-end) that overwrites the old frame without
159+ // duplicating it.
160+ return
161+ }
151 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))162 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
152 }163 }
153 164
@@ -163,6 +174,12 @@ func (r *Renderer) clearScrollbackSeq() string {
163 return SeqClearScrollback174 return SeqClearScrollback
164 }175 }
165 176
177+// KeepsScrollback reports whether this renderer suppresses the
178+// scrollback-clear escape (true under VS Code's terminal). Callers
179+// use it to pick a viewport-safe full repaint (Invalidate) over a
180+// scrollback-clearing one (Clear) when redrawing overlays.
181+func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback }
182+
166 // Invalidate forces a full repaint on the next Draw without clearing the183 // Invalidate forces a full repaint on the next Draw without clearing the
167 // whole terminal first. Useful when the cached diff is unreliable but a184 // whole terminal first. Useful when the cached diff is unreliable but a
168 // visible full-screen flash would be too distracting.185 // visible full-screen flash would be too distracting.
@@ -452,9 +469,23 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
452 writeFull := func(clear bool) {469 writeFull := func(clear bool) {
453 if clear {470 if clear {
454 w.WriteString(SeqDeleteKittyImages)471 w.WriteString(SeqDeleteKittyImages)
455- w.WriteString(SeqClearScreenNoHome)472+ if r.keepScrollback {
456- w.WriteString(r.clearScrollbackSeq())473+ // VS Code's xterm.js scrolls the visible content up into
457- w.WriteString(MoveTo(1, 1))474+ // scrollback on \x1b[2J, which duplicates the frame (the
475+ // old paint stays above the new one). Home to the
476+ // viewport top and erase-to-end (\x1b[0J) instead: that
477+ // clears the visible screen in place without pushing the
478+ // previous frame into scrollback. We still cannot drop
479+ // existing scrollback (\x1b[3J snaps the viewport there),
480+ // but a full repaint no longer stacks a fresh copy below
481+ // the old one.
482+ w.WriteString(SeqCursorHome)
483+ w.WriteString(SeqClearToEnd)
484+ } else {
485+ w.WriteString(SeqClearScreenNoHome)
486+ w.WriteString(r.clearScrollbackSeq())
487+ w.WriteString(MoveTo(1, 1))
488+ }
458 }489 }
459 for idx, line := range lines {490 for idx, line := range lines {
460 if idx > 0 {491 if idx > 0 {
M packages/tui/terminal.go +7 -0
@@ -95,6 +95,13 @@ const (
9595 // cursor identically without triggering that snap.
9696 SeqClearScreenNoHome = "\x1b[2J"
9797 SeqClearScrollback = "\x1b[3J"
98+ // SeqCursorHome moves to the top-left of the visible viewport.
99+ // SeqClearToEnd erases from the cursor to the end of the screen
100+ // without scrolling content into scrollback (unlike \x1b[2J on
101+ // xterm.js). Together they clear the visible frame in place, which
102+ // the VS Code terminal needs for duplicate-free full repaints.
103+ SeqCursorHome = "\x1b[H"
104+ SeqClearToEnd = "\x1b[0J"
98105 SeqClearLine = "\x1b[2K"
99106 SeqResetScrollRegion = "\x1b[r"
100107 SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\"
@@ -95,6 +95,13 @@ const (
95 // cursor identically without triggering that snap.95 // cursor identically without triggering that snap.
96 SeqClearScreenNoHome = "\x1b[2J"96 SeqClearScreenNoHome = "\x1b[2J"
97 SeqClearScrollback = "\x1b[3J"97 SeqClearScrollback = "\x1b[3J"
98+ // SeqCursorHome moves to the top-left of the visible viewport.
99+ // SeqClearToEnd erases from the cursor to the end of the screen
100+ // without scrolling content into scrollback (unlike \x1b[2J on
101+ // xterm.js). Together they clear the visible frame in place, which
102+ // the VS Code terminal needs for duplicate-free full repaints.
103+ SeqCursorHome = "\x1b[H"
104+ SeqClearToEnd = "\x1b[0J"
98 SeqClearLine = "\x1b[2K"105 SeqClearLine = "\x1b[2K"
99 SeqResetScrollRegion = "\x1b[r"106 SeqResetScrollRegion = "\x1b[r"
100 SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\"107 SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\"
M packages/tui/view.go +18 -27
@@ -245,10 +245,7 @@ func (v *View) BuildLive(width int) []string {
245245 finalised := map[string]bool{}
246246 for _, m := range v.Messages {
247247 for _, c := range m.Content {
248- switch b := c.(type) {
249- case provider.ToolCallBlock:
250- finalised[b.ID] = true
251- case provider.ToolResultBlock:
248+ if b, ok := c.(provider.ToolResultBlock); ok {
252249 finalised[b.CallID] = true
253250 }
254251 }
@@ -370,21 +367,15 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
370367 }
371368 out = append(out, "")
372369 }
373- // Live tool-call overlay: skip any entry whose assistant
374- // tool_use block OR tool_result has already made it into the
375- // transcript. The EvAssistantMessage for a tool-use turn
376- // lands BEFORE executeTools runs, so between that moment and
377- // the tool-result being appended the overlay and the
378- // finalised transcript both render the same call. Checking
379- // for either side of the pair suppresses the duplicate in
380- // both windows.
370+ // Live tool-call overlay: keep the in-flight box visible after
371+ // the assistant tool_use is appended, then hide it only when the
372+ // matching tool_result reaches the transcript. Assistant tool_use
373+ // blocks render no rows of their own, so suppressing the overlay
374+ // at that point makes the box disappear while the tool is running.
381375 finalised := map[string]bool{}
382376 for _, m := range v.Messages {
383377 for _, c := range m.Content {
384- switch b := c.(type) {
385- case provider.ToolCallBlock:
386- finalised[b.ID] = true
387- case provider.ToolResultBlock:
378+ if b, ok := c.(provider.ToolResultBlock); ok {
388379 finalised[b.CallID] = true
389380 }
390381 }
@@ -712,20 +703,20 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
712703 // here would double the gap during streaming and visibly tighten
713704 // when the overlay disappears.
714705
715- // Streaming body (write/edit): top edge with the label, body
716- // rows wrapped with vertical edges, bottom edge to close the
717- // box. When the call finalises, the live overlay disappears
718- // the same frame the transcript renders the closed box, so
719- // there's no visible hop.
720- if tc.Streaming && tc.Result == "" {
721- lines = append(lines, toolBoxTop(v.Theme, label, width))
722- lines = append(lines, toolBoxSide(v.Theme, "", width))
706+ // Live body (write/edit): keep the streamed preview visible until
707+ // a real tool result arrives. The provider can finish the tool_use
708+ // JSON before zot has executed the tool, so keying this on
709+ // tc.Streaming makes write/edit boxes collapse for a moment between
710+ // EvToolUseEnd and EvToolResult.
711+ if tc.Result == "" {
723712 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
713+ lines = append(lines, toolBoxTop(v.Theme, label, width))
714+ lines = append(lines, toolBoxSide(v.Theme, "", width))
724715 lines = append(lines, body...)
716+ lines = append(lines, toolBoxSide(v.Theme, "", width))
717+ lines = append(lines, toolBoxBottom(v.Theme, width))
718+ return lines
725719 }
726- lines = append(lines, toolBoxSide(v.Theme, "", width))
727- lines = append(lines, toolBoxBottom(v.Theme, width))
728- return lines
729720 }
730721
731722 // Finished tool call with no body: just the labelled top edge
@@ -245,10 +245,7 @@ func (v *View) BuildLive(width int) []string {
245 finalised := map[string]bool{}245 finalised := map[string]bool{}
246 for _, m := range v.Messages {246 for _, m := range v.Messages {
247 for _, c := range m.Content {247 for _, c := range m.Content {
248- switch b := c.(type) {248+ if b, ok := c.(provider.ToolResultBlock); ok {
249- case provider.ToolCallBlock:
250- finalised[b.ID] = true
251- case provider.ToolResultBlock:
252 finalised[b.CallID] = true249 finalised[b.CallID] = true
253 }250 }
254 }251 }
@@ -370,21 +367,15 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
370 }367 }
371 out = append(out, "")368 out = append(out, "")
372 }369 }
373- // Live tool-call overlay: skip any entry whose assistant370+ // Live tool-call overlay: keep the in-flight box visible after
374- // tool_use block OR tool_result has already made it into the371+ // the assistant tool_use is appended, then hide it only when the
375- // transcript. The EvAssistantMessage for a tool-use turn372+ // matching tool_result reaches the transcript. Assistant tool_use
376- // lands BEFORE executeTools runs, so between that moment and373+ // blocks render no rows of their own, so suppressing the overlay
377- // the tool-result being appended the overlay and the374+ // at that point makes the box disappear while the tool is running.
378- // finalised transcript both render the same call. Checking
379- // for either side of the pair suppresses the duplicate in
380- // both windows.
381 finalised := map[string]bool{}375 finalised := map[string]bool{}
382 for _, m := range v.Messages {376 for _, m := range v.Messages {
383 for _, c := range m.Content {377 for _, c := range m.Content {
384- switch b := c.(type) {378+ if b, ok := c.(provider.ToolResultBlock); ok {
385- case provider.ToolCallBlock:
386- finalised[b.ID] = true
387- case provider.ToolResultBlock:
388 finalised[b.CallID] = true379 finalised[b.CallID] = true
389 }380 }
390 }381 }
@@ -712,20 +703,20 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
712 // here would double the gap during streaming and visibly tighten703 // here would double the gap during streaming and visibly tighten
713 // when the overlay disappears.704 // when the overlay disappears.
714 705
715- // Streaming body (write/edit): top edge with the label, body706+ // Live body (write/edit): keep the streamed preview visible until
716- // rows wrapped with vertical edges, bottom edge to close the707+ // a real tool result arrives. The provider can finish the tool_use
717- // box. When the call finalises, the live overlay disappears708+ // JSON before zot has executed the tool, so keying this on
718- // the same frame the transcript renders the closed box, so709+ // tc.Streaming makes write/edit boxes collapse for a moment between
719- // there's no visible hop.710+ // EvToolUseEnd and EvToolResult.
720- if tc.Streaming && tc.Result == "" {711+ if tc.Result == "" {
721- lines = append(lines, toolBoxTop(v.Theme, label, width))
722- lines = append(lines, toolBoxSide(v.Theme, "", width))
723 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {712 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
713+ lines = append(lines, toolBoxTop(v.Theme, label, width))
714+ lines = append(lines, toolBoxSide(v.Theme, "", width))
724 lines = append(lines, body...)715 lines = append(lines, body...)
716+ lines = append(lines, toolBoxSide(v.Theme, "", width))
717+ lines = append(lines, toolBoxBottom(v.Theme, width))
718+ return lines
725 }719 }
726- lines = append(lines, toolBoxSide(v.Theme, "", width))
727- lines = append(lines, toolBoxBottom(v.Theme, width))
728- return lines
729 }720 }
730 721
731 // Finished tool call with no body: just the labelled top edge722 // Finished tool call with no body: just the labelled top edge
A packages/tui/view_tool_overlay_test.go +94 -0
@@ -0,0 +1,94 @@
1+package tui
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) {
12+ args := json.RawMessage(`{"command":"sleep 1"}`)
13+ v := View{
14+ Theme: Dark,
15+ Messages: []provider.Message{
16+ {
17+ Role: provider.RoleAssistant,
18+ Content: []provider.Content{
19+ provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
20+ },
21+ },
22+ },
23+ ToolCalls: []ToolCallView{
24+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Done: false},
25+ },
26+ }
27+
28+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
29+ if !strings.Contains(plain, "bash sleep 1") {
30+ t.Fatalf("live tool overlay disappeared after assistant tool_use was appended:\n%s", plain)
31+ }
32+}
33+
34+func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
35+ args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
36+ v := View{
37+ Theme: Dark,
38+ Messages: []provider.Message{
39+ {
40+ Role: provider.RoleAssistant,
41+ Content: []provider.Content{
42+ provider.ToolCallBlock{ID: "toolu_1", Name: "write", Arguments: args},
43+ },
44+ },
45+ },
46+ ToolCalls: []ToolCallView{
47+ {
48+ ID: "toolu_1",
49+ Name: "write",
50+ Args: ShortArgs("write", args),
51+ Streaming: false,
52+ RawJSONBuf: string(args),
53+ LivePath: "/tmp/sample.ts",
54+ },
55+ },
56+ }
57+
58+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
59+ if !strings.Contains(plain, "export const n = 1") {
60+ t.Fatalf("write preview collapsed after tool args ended but before tool_result arrived:\n%s", plain)
61+ }
62+}
63+
64+func TestLiveToolOverlayHidesAfterToolResult(t *testing.T) {
65+ args := json.RawMessage(`{"command":"sleep 1"}`)
66+ v := View{
67+ Theme: Dark,
68+ Messages: []provider.Message{
69+ {
70+ Role: provider.RoleAssistant,
71+ Content: []provider.Content{
72+ provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
73+ },
74+ },
75+ {
76+ Role: provider.RoleTool,
77+ Content: []provider.Content{
78+ provider.ToolResultBlock{
79+ CallID: "toolu_1",
80+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
81+ },
82+ },
83+ },
84+ },
85+ ToolCalls: []ToolCallView{
86+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "done", Done: true},
87+ },
88+ }
89+
90+ plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
91+ if strings.Contains(plain, "bash sleep 1") {
92+ t.Fatalf("live tool overlay still rendered after tool_result was appended:\n%s", plain)
93+ }
94+}
@@ -0,0 +1,94 @@
1+package tui
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/provider"
9+)
10+
11+func TestLiveToolOverlayRemainsAfterAssistantToolUse(t *testing.T) {
12+ args := json.RawMessage(`{"command":"sleep 1"}`)
13+ v := View{
14+ Theme: Dark,
15+ Messages: []provider.Message{
16+ {
17+ Role: provider.RoleAssistant,
18+ Content: []provider.Content{
19+ provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
20+ },
21+ },
22+ },
23+ ToolCalls: []ToolCallView{
24+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Done: false},
25+ },
26+ }
27+
28+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
29+ if !strings.Contains(plain, "bash sleep 1") {
30+ t.Fatalf("live tool overlay disappeared after assistant tool_use was appended:\n%s", plain)
31+ }
32+}
33+
34+func TestLiveToolOverlayKeepsWritePreviewAfterArgsEnd(t *testing.T) {
35+ args := json.RawMessage(`{"path":"/tmp/sample.ts","content":"export const n = 1\n"}`)
36+ v := View{
37+ Theme: Dark,
38+ Messages: []provider.Message{
39+ {
40+ Role: provider.RoleAssistant,
41+ Content: []provider.Content{
42+ provider.ToolCallBlock{ID: "toolu_1", Name: "write", Arguments: args},
43+ },
44+ },
45+ },
46+ ToolCalls: []ToolCallView{
47+ {
48+ ID: "toolu_1",
49+ Name: "write",
50+ Args: ShortArgs("write", args),
51+ Streaming: false,
52+ RawJSONBuf: string(args),
53+ LivePath: "/tmp/sample.ts",
54+ },
55+ },
56+ }
57+
58+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
59+ if !strings.Contains(plain, "export const n = 1") {
60+ t.Fatalf("write preview collapsed after tool args ended but before tool_result arrived:\n%s", plain)
61+ }
62+}
63+
64+func TestLiveToolOverlayHidesAfterToolResult(t *testing.T) {
65+ args := json.RawMessage(`{"command":"sleep 1"}`)
66+ v := View{
67+ Theme: Dark,
68+ Messages: []provider.Message{
69+ {
70+ Role: provider.RoleAssistant,
71+ Content: []provider.Content{
72+ provider.ToolCallBlock{ID: "toolu_1", Name: "bash", Arguments: args},
73+ },
74+ },
75+ {
76+ Role: provider.RoleTool,
77+ Content: []provider.Content{
78+ provider.ToolResultBlock{
79+ CallID: "toolu_1",
80+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
81+ },
82+ },
83+ },
84+ },
85+ ToolCalls: []ToolCallView{
86+ {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), Result: "done", Done: true},
87+ },
88+ }
89+
90+ plain := stripANSI(strings.Join(v.BuildLive(80), "\n"))
91+ if strings.Contains(plain, "bash sleep 1") {
92+ t.Fatalf("live tool overlay still rendered after tool_result was appended:\n%s", plain)
93+ }
94+}

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