detached 4d6cbeb

Merge branch 'main' into feat/issue-templates

commit
4d6cbeb2113792874356e09c9c78ae7c931397ad
author
Patric Eckhart · · 2026-06-04 17:47 UTC
committer
GitHub · · 2026-06-04 17:47 UTC
merge of
c7ca5ae + 4dffc85 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +670 -52
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/build.go +50 -1
@@ -213,6 +213,51 @@ func isKnownProvider(name string) bool {
213213 return false
214214 }
215215
216+// providerAliases maps common short / alternate provider names to the
217+// canonical id in knownProviders. Users (and other agents) reach for
218+// "bedrock" or "vertex" far more naturally than the fully-qualified
219+// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
220+// treated as an unknown provider and Resolve silently falls back to
221+// anthropic, producing a misleading "no credential for anthropic" error
222+// after the user explicitly picked, say, bedrock.
223+var providerAliases = map[string]string{
224+ "bedrock": "amazon-bedrock",
225+ "aws-bedrock": "amazon-bedrock",
226+ "amazon": "amazon-bedrock",
227+ "vertex": "google-vertex",
228+ "gcp-vertex": "google-vertex",
229+ "gemini": "google",
230+ "googleai": "google",
231+ "google-ai": "google",
232+ "azure": "azure-openai-responses",
233+ "azure-openai": "azure-openai-responses",
234+ "copilot": "github-copilot",
235+ "github": "github-copilot",
236+ "codex": "openai-codex",
237+ "moonshot": "moonshotai",
238+ "kimi-code": "kimi",
239+ "ai-gateway": "vercel-ai-gateway",
240+ "vercel": "vercel-ai-gateway",
241+ "cloudflare": "cloudflare-workers-ai",
242+ "workers-ai": "cloudflare-workers-ai",
243+ "hf": "huggingface",
244+}
245+
246+// canonicalProvider normalises a user-supplied provider name: trims
247+// surrounding whitespace, lower-cases it, and resolves any known alias
248+// to its canonical id. Unknown names are returned trimmed/lower-cased
249+// and unchanged so the existing unknown-provider handling still runs.
250+func canonicalProvider(name string) string {
251+ n := strings.ToLower(strings.TrimSpace(name))
252+ if n == "" {
253+ return n
254+ }
255+ if canon, ok := providerAliases[n]; ok {
256+ return canon
257+ }
258+ return n
259+}
260+
216261 // Resolve merges args, config, and env into a Resolved set.
217262 //
218263 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -223,7 +268,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
223268 cfg, _ := LoadConfig()
224269
225270 // User-requested provider (explicit > config > default).
226- provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
271+ // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
272+ // before validation so an alias is never mistaken for an unknown
273+ // provider and silently downgraded to anthropic.
274+ argProvider := canonicalProvider(args.Provider)
275+ provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
227276 if !isKnownProvider(provName) {
228277 // Unknown provider (maybe removed or renamed). Fall back to
229278 // the first provider that has credentials, or anthropic.
@@ -213,6 +213,51 @@ func isKnownProvider(name string) bool {
213 return false213 return false
214 }214 }
215 215
216+// providerAliases maps common short / alternate provider names to the
217+// canonical id in knownProviders. Users (and other agents) reach for
218+// "bedrock" or "vertex" far more naturally than the fully-qualified
219+// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
220+// treated as an unknown provider and Resolve silently falls back to
221+// anthropic, producing a misleading "no credential for anthropic" error
222+// after the user explicitly picked, say, bedrock.
223+var providerAliases = map[string]string{
224+ "bedrock": "amazon-bedrock",
225+ "aws-bedrock": "amazon-bedrock",
226+ "amazon": "amazon-bedrock",
227+ "vertex": "google-vertex",
228+ "gcp-vertex": "google-vertex",
229+ "gemini": "google",
230+ "googleai": "google",
231+ "google-ai": "google",
232+ "azure": "azure-openai-responses",
233+ "azure-openai": "azure-openai-responses",
234+ "copilot": "github-copilot",
235+ "github": "github-copilot",
236+ "codex": "openai-codex",
237+ "moonshot": "moonshotai",
238+ "kimi-code": "kimi",
239+ "ai-gateway": "vercel-ai-gateway",
240+ "vercel": "vercel-ai-gateway",
241+ "cloudflare": "cloudflare-workers-ai",
242+ "workers-ai": "cloudflare-workers-ai",
243+ "hf": "huggingface",
244+}
245+
246+// canonicalProvider normalises a user-supplied provider name: trims
247+// surrounding whitespace, lower-cases it, and resolves any known alias
248+// to its canonical id. Unknown names are returned trimmed/lower-cased
249+// and unchanged so the existing unknown-provider handling still runs.
250+func canonicalProvider(name string) string {
251+ n := strings.ToLower(strings.TrimSpace(name))
252+ if n == "" {
253+ return n
254+ }
255+ if canon, ok := providerAliases[n]; ok {
256+ return canon
257+ }
258+ return n
259+}
260+
216 // Resolve merges args, config, and env into a Resolved set.261 // Resolve merges args, config, and env into a Resolved set.
217 //262 //
218 // Unlike the earlier version, Resolve NEVER returns an error for263 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -223,7 +268,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
223 cfg, _ := LoadConfig()268 cfg, _ := LoadConfig()
224 269
225 // User-requested provider (explicit > config > default).270 // User-requested provider (explicit > config > default).
226- provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")271+ // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
272+ // before validation so an alias is never mistaken for an unknown
273+ // provider and silently downgraded to anthropic.
274+ argProvider := canonicalProvider(args.Provider)
275+ provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
227 if !isKnownProvider(provName) {276 if !isKnownProvider(provName) {
228 // Unknown provider (maybe removed or renamed). Fall back to277 // Unknown provider (maybe removed or renamed). Fall back to
229 // the first provider that has credentials, or anthropic.278 // the first provider that has credentials, or anthropic.
M packages/agent/build_test.go +34 -0
@@ -108,3 +108,37 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
108108 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
109109 }
110110 }
111+
112+func TestCanonicalProviderResolvesAliases(t *testing.T) {
113+ cases := map[string]string{
114+ "bedrock": "amazon-bedrock",
115+ "AWS-Bedrock": "amazon-bedrock",
116+ " bedrock ": "amazon-bedrock",
117+ "vertex": "google-vertex",
118+ "gemini": "google",
119+ "azure": "azure-openai-responses",
120+ "copilot": "github-copilot",
121+ "codex": "openai-codex",
122+ "moonshot": "moonshotai",
123+ "vercel": "vercel-ai-gateway",
124+ "hf": "huggingface",
125+ "anthropic": "anthropic", // canonical passes through
126+ "amazon-bedrock": "amazon-bedrock", // already canonical
127+ "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
128+ "Totally-UNKNOWN": "totally-unknown",
129+ "": "",
130+ }
131+ for in, want := range cases {
132+ if got := canonicalProvider(in); got != want {
133+ t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
134+ }
135+ }
136+}
137+
138+func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
139+ for alias, canon := range providerAliases {
140+ if !isKnownProvider(canon) {
141+ t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
142+ }
143+ }
144+}
@@ -108,3 +108,37 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
108 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)108 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
109 }109 }
110 }110 }
111+
112+func TestCanonicalProviderResolvesAliases(t *testing.T) {
113+ cases := map[string]string{
114+ "bedrock": "amazon-bedrock",
115+ "AWS-Bedrock": "amazon-bedrock",
116+ " bedrock ": "amazon-bedrock",
117+ "vertex": "google-vertex",
118+ "gemini": "google",
119+ "azure": "azure-openai-responses",
120+ "copilot": "github-copilot",
121+ "codex": "openai-codex",
122+ "moonshot": "moonshotai",
123+ "vercel": "vercel-ai-gateway",
124+ "hf": "huggingface",
125+ "anthropic": "anthropic", // canonical passes through
126+ "amazon-bedrock": "amazon-bedrock", // already canonical
127+ "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
128+ "Totally-UNKNOWN": "totally-unknown",
129+ "": "",
130+ }
131+ for in, want := range cases {
132+ if got := canonicalProvider(in); got != want {
133+ t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
134+ }
135+ }
136+}
137+
138+func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
139+ for alias, canon := range providerAliases {
140+ if !isKnownProvider(canon) {
141+ t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
142+ }
143+ }
144+}
M packages/agent/modes/interactive.go +190 -9
@@ -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,7 +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 {
1064+ if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
1065+ // An overlay (dialog or slash/file popup) just closed, so the
1066+ // bottom band shrinks. On terminals where we can drop
1067+ // scrollback, a full Clear is the simplest way to guarantee
1068+ // the vacated rows are repainted from the chat below.
1069+ //
1070+ // On VS Code's terminal closing a dialog leaves the stale
1071+ // overlay rows in the retained scrollback (we can't drop them
1072+ // with the quiet in-place diff). Run the same full Clear() that
1073+ // Ctrl+L uses so the scrollback is purged and the conversation
1074+ // is repainted clean, matching what the user expects after
1075+ // dismissing a picker. Clear() is keepScrollback-aware and
1076+ // emits \x1b[3J there.
10451077 i.rend.Clear()
10461078 }
10471079 i.prevOverlayOpen = overlayOpen
@@ -1620,6 +1652,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
16201652 if act.Select {
16211653 i.applySessionSelection(act.Path)
16221654 }
1655+ // Always request a redraw after handling a key here: when esc
1656+ // closes the picker, the overlay-close detection in the render
1657+ // pass needs to run so the tall dialog rows get repainted from
1658+ // the chat (otherwise VS Code's retained scrollback leaves a
1659+ // duplicate frame on screen).
1660+ i.invalidate()
16231661 return false
16241662 }
16251663 if i.swarmDialog.Active() {
@@ -1845,14 +1883,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18451883 i.mu.Lock()
18461884 hadHelp := len(i.helpBlock) > 0
18471885 hadNotes := len(i.extNotes) > 0
1886+ // Only dismiss a parked shell-escape log on esc when nothing is
1887+ // running; if a !command is in flight, esc must fall through to
1888+ // the cancel path below instead of just hiding the (empty) block.
1889+ hadShell := len(i.shellBlock) > 0 && !i.shellRunning
18481890 if hadHelp {
18491891 i.helpBlock = nil
18501892 }
18511893 if hadNotes {
18521894 i.extNotes = nil
18531895 }
1896+ if hadShell {
1897+ i.shellBlock = nil
1898+ }
18541899 i.mu.Unlock()
1855- if hadHelp || hadNotes {
1900+ if hadHelp || hadNotes || hadShell {
18561901 i.invalidate()
18571902 return false
18581903 }
@@ -2072,6 +2117,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
20722117 i.suggest.Reset()
20732118 i.fileSuggest.Reset()
20742119
2120+ if cmd, ok := shellEscapeCommand(text); ok {
2121+ i.startShellEscape(ctx, cmd)
2122+ return false
2123+ }
2124+
20752125 if looksLikeSlashCommand(text) {
20762126 head := text
20772127 rest := ""
@@ -2308,6 +2358,10 @@ func (i *Interactive) Notify(extName, level, message string) {
23082358
23092359 // Submit feeds text through the agent loop as if the user had typed it.
23102360 func (i *Interactive) Submit(text string) {
2361+ if cmd, ok := shellEscapeCommand(text); ok {
2362+ i.startShellEscape(i.runCtx, cmd)
2363+ return
2364+ }
23112365 i.startTurn(i.runCtx, text)
23122366 }
23132367
@@ -2368,6 +2422,10 @@ func (i *Interactive) SubmitSlash(text string) {
23682422 // forwarded — because the queued-prompt path is text-only; a
23692423 // follow-up can expand the queue entry to carry images.
23702424 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2425+ if cmd, ok := shellEscapeCommand(text); ok {
2426+ i.startShellEscape(i.runCtx, cmd)
2427+ return
2428+ }
23712429 i.mu.Lock()
23722430 if i.agent == nil {
23732431 i.statusErr = "not logged in. type /login first."
@@ -3000,6 +3058,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
30003058 i.parkedTotal = 0
30013059 i.scrollOffset = 0
30023060 i.extNotes = nil
3061+ i.shellBlock = nil
30033062 i.view.InvalidateRenderCache()
30043063 i.mu.Unlock()
30053064 case "/help":
@@ -3937,6 +3996,134 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
39373996 }()
39383997 }
39393998
3999+// shellEscapeCommand reports whether text is a "!command" shell
4000+// escape and, if so, returns the command with the leading '!' (and
4001+// surrounding whitespace) stripped. A bare "!" with no command is
4002+// treated as not an escape so it falls through to the normal prompt
4003+// path rather than running an empty shell.
4004+func shellEscapeCommand(text string) (string, bool) {
4005+ trimmed := strings.TrimLeft(text, " \t")
4006+ if !strings.HasPrefix(trimmed, "!") {
4007+ return "", false
4008+ }
4009+ cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
4010+ if cmd == "" {
4011+ return "", false
4012+ }
4013+ return cmd, true
4014+}
4015+
4016+// startShellEscape runs a "!command" in the same shell the bash tool
4017+// uses, in the session working directory, honoring the /jail sandbox.
4018+// It shares the busy/cancel state with the agent: esc cancels it, and
4019+// it refuses to start while a turn or another shell escape is already
4020+// in flight. The terminal-log output is parked in i.shellBlock below
4021+// the transcript until the next prompt or /clear, so it never enters
4022+// the model conversation.
4023+func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
4024+ i.mu.Lock()
4025+ if i.busy || i.shellRunning {
4026+ i.statusErr = "busy — wait for the current turn to finish before running a shell command"
4027+ i.statusOK = ""
4028+ i.mu.Unlock()
4029+ i.invalidate()
4030+ return
4031+ }
4032+ if parent == nil {
4033+ parent = i.runCtx
4034+ }
4035+ if parent == nil {
4036+ parent = context.Background()
4037+ }
4038+ ctx, cancel := context.WithCancel(parent)
4039+ i.busy = true
4040+ i.shellRunning = true
4041+ i.cancelTurn = cancel
4042+ i.statusErr = ""
4043+ i.statusOK = ""
4044+ i.spin.StartFixed("running shell command")
4045+ // A new shell escape replaces the previous block; clear stale
4046+ // extension notes the same way a new turn would so the screen
4047+ // doesn't accumulate transient state.
4048+ i.shellBlock = nil
4049+ i.scrollOffset = 0
4050+ i.parkedTurn = 0
4051+ i.parkedTotal = 0
4052+ i.helpBlock = nil
4053+ sandbox := i.cfg.Sandbox
4054+ cwd := i.cfg.CWD
4055+ i.mu.Unlock()
4056+ i.invalidate()
4057+
4058+ go func() {
4059+ defer cancel()
4060+ raw, _ := json.Marshal(map[string]any{"command": cmd})
4061+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
4062+ res, err := bash.Execute(ctx, raw, nil)
4063+
4064+ var out string
4065+ if err != nil {
4066+ out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
4067+ } else {
4068+ for _, c := range res.Content {
4069+ if tb, ok := c.(provider.TextBlock); ok {
4070+ out += tb.Text
4071+ }
4072+ }
4073+ }
4074+ cancelled := ctx.Err() != nil
4075+ failed := err != nil || res.IsError || cancelled
4076+ if cancelled {
4077+ out += "\n\n[cancelled]"
4078+ }
4079+
4080+ block := i.renderShellBlock(out, failed)
4081+
4082+ i.mu.Lock()
4083+ i.shellRunning = false
4084+ i.busy = false
4085+ i.cancelTurn = nil
4086+ i.shellBlock = block
4087+ if failed {
4088+ if cancelled {
4089+ i.statusErr = "shell command cancelled"
4090+ } else {
4091+ i.statusErr = "shell command failed"
4092+ }
4093+ i.statusOK = ""
4094+ } else {
4095+ i.statusOK = "shell command finished"
4096+ i.statusErr = ""
4097+ }
4098+ i.mu.Unlock()
4099+ i.invalidate()
4100+ }()
4101+}
4102+
4103+// renderShellBlock turns merged bash output into a styled terminal-log
4104+// block: each line colored by overall success (tool/green) or failure
4105+// (error/red), with the [exit ...] / [error] footer dimmed via the
4106+// muted color so it reads as metadata.
4107+func (i *Interactive) renderShellBlock(out string, failed bool) []string {
4108+ th := i.cfg.Theme
4109+ base := th.Tool
4110+ if failed {
4111+ base = th.Error
4112+ }
4113+ out = strings.TrimRight(out, "\n")
4114+ lines := strings.Split(out, "\n")
4115+ styled := make([]string, 0, len(lines))
4116+ for _, line := range lines {
4117+ color := base
4118+ trimmed := strings.TrimSpace(line)
4119+ if strings.HasPrefix(trimmed, "[exit ") || strings.HasPrefix(trimmed, "[error]") || strings.HasPrefix(trimmed, "[cancelled]") {
4120+ color = th.Muted
4121+ }
4122+ styled = append(styled, th.FG256(color, line))
4123+ }
4124+ return styled
4125+}
4126+
39404127 func (i *Interactive) startTurn(parent context.Context, prompt string) {
39414128 i.startTurnWithImages(parent, prompt, nil)
39424129 }
@@ -3945,13 +4132,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
39454132 if i.agent == nil {
39464133 return
39474134 }
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- }
39554135 // Pre-turn safety: if the most recent context measurement is
39564136 // already past the auto-compact threshold, condense before
39574137 // sending so the next outbound request stays under the limit.
@@ -3984,6 +4164,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
39844164 i.streamOn = true
39854165 i.toolCalls = map[string]*tui.ToolCallView{}
39864166 i.toolOrder = nil
4167+ i.shellBlock = nil // sending a prompt clears any parked shell-escape log
39874168 i.scrollOffset = 0 // jump back to the bottom on new turn
39884169 // Reset the auto-follow baseline so the very next render at
39894170 // 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,7 +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 {
1065+ // An overlay (dialog or slash/file popup) just closed, so the
1066+ // bottom band shrinks. On terminals where we can drop
1067+ // scrollback, a full Clear is the simplest way to guarantee
1068+ // the vacated rows are repainted from the chat below.
1069+ //
1070+ // On VS Code's terminal closing a dialog leaves the stale
1071+ // overlay rows in the retained scrollback (we can't drop them
1072+ // with the quiet in-place diff). Run the same full Clear() that
1073+ // Ctrl+L uses so the scrollback is purged and the conversation
1074+ // is repainted clean, matching what the user expects after
1075+ // dismissing a picker. Clear() is keepScrollback-aware and
1076+ // emits \x1b[3J there.
1045 i.rend.Clear()1077 i.rend.Clear()
1046 }1078 }
1047 i.prevOverlayOpen = overlayOpen1079 i.prevOverlayOpen = overlayOpen
@@ -1620,6 +1652,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1620 if act.Select {1652 if act.Select {
1621 i.applySessionSelection(act.Path)1653 i.applySessionSelection(act.Path)
1622 }1654 }
1655+ // Always request a redraw after handling a key here: when esc
1656+ // closes the picker, the overlay-close detection in the render
1657+ // pass needs to run so the tall dialog rows get repainted from
1658+ // the chat (otherwise VS Code's retained scrollback leaves a
1659+ // duplicate frame on screen).
1660+ i.invalidate()
1623 return false1661 return false
1624 }1662 }
1625 if i.swarmDialog.Active() {1663 if i.swarmDialog.Active() {
@@ -1845,14 +1883,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1845 i.mu.Lock()1883 i.mu.Lock()
1846 hadHelp := len(i.helpBlock) > 01884 hadHelp := len(i.helpBlock) > 0
1847 hadNotes := len(i.extNotes) > 01885 hadNotes := len(i.extNotes) > 0
1886+ // Only dismiss a parked shell-escape log on esc when nothing is
1887+ // running; if a !command is in flight, esc must fall through to
1888+ // the cancel path below instead of just hiding the (empty) block.
1889+ hadShell := len(i.shellBlock) > 0 && !i.shellRunning
1848 if hadHelp {1890 if hadHelp {
1849 i.helpBlock = nil1891 i.helpBlock = nil
1850 }1892 }
1851 if hadNotes {1893 if hadNotes {
1852 i.extNotes = nil1894 i.extNotes = nil
1853 }1895 }
1896+ if hadShell {
1897+ i.shellBlock = nil
1898+ }
1854 i.mu.Unlock()1899 i.mu.Unlock()
1855- if hadHelp || hadNotes {1900+ if hadHelp || hadNotes || hadShell {
1856 i.invalidate()1901 i.invalidate()
1857 return false1902 return false
1858 }1903 }
@@ -2072,6 +2117,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2072 i.suggest.Reset()2117 i.suggest.Reset()
2073 i.fileSuggest.Reset()2118 i.fileSuggest.Reset()
2074 2119
2120+ if cmd, ok := shellEscapeCommand(text); ok {
2121+ i.startShellEscape(ctx, cmd)
2122+ return false
2123+ }
2124+
2075 if looksLikeSlashCommand(text) {2125 if looksLikeSlashCommand(text) {
2076 head := text2126 head := text
2077 rest := ""2127 rest := ""
@@ -2308,6 +2358,10 @@ func (i *Interactive) Notify(extName, level, message string) {
2308 2358
2309 // Submit feeds text through the agent loop as if the user had typed it.2359 // Submit feeds text through the agent loop as if the user had typed it.
2310 func (i *Interactive) Submit(text string) {2360 func (i *Interactive) Submit(text string) {
2361+ if cmd, ok := shellEscapeCommand(text); ok {
2362+ i.startShellEscape(i.runCtx, cmd)
2363+ return
2364+ }
2311 i.startTurn(i.runCtx, text)2365 i.startTurn(i.runCtx, text)
2312 }2366 }
2313 2367
@@ -2368,6 +2422,10 @@ func (i *Interactive) SubmitSlash(text string) {
2368 // forwarded — because the queued-prompt path is text-only; a2422 // forwarded — because the queued-prompt path is text-only; a
2369 // follow-up can expand the queue entry to carry images.2423 // follow-up can expand the queue entry to carry images.
2370 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {2424 func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2425+ if cmd, ok := shellEscapeCommand(text); ok {
2426+ i.startShellEscape(i.runCtx, cmd)
2427+ return
2428+ }
2371 i.mu.Lock()2429 i.mu.Lock()
2372 if i.agent == nil {2430 if i.agent == nil {
2373 i.statusErr = "not logged in. type /login first."2431 i.statusErr = "not logged in. type /login first."
@@ -3000,6 +3058,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
3000 i.parkedTotal = 03058 i.parkedTotal = 0
3001 i.scrollOffset = 03059 i.scrollOffset = 0
3002 i.extNotes = nil3060 i.extNotes = nil
3061+ i.shellBlock = nil
3003 i.view.InvalidateRenderCache()3062 i.view.InvalidateRenderCache()
3004 i.mu.Unlock()3063 i.mu.Unlock()
3005 case "/help":3064 case "/help":
@@ -3937,6 +3996,134 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
3937 }()3996 }()
3938 }3997 }
3939 3998
3999+// shellEscapeCommand reports whether text is a "!command" shell
4000+// escape and, if so, returns the command with the leading '!' (and
4001+// surrounding whitespace) stripped. A bare "!" with no command is
4002+// treated as not an escape so it falls through to the normal prompt
4003+// path rather than running an empty shell.
4004+func shellEscapeCommand(text string) (string, bool) {
4005+ trimmed := strings.TrimLeft(text, " \t")
4006+ if !strings.HasPrefix(trimmed, "!") {
4007+ return "", false
4008+ }
4009+ cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
4010+ if cmd == "" {
4011+ return "", false
4012+ }
4013+ return cmd, true
4014+}
4015+
4016+// startShellEscape runs a "!command" in the same shell the bash tool
4017+// uses, in the session working directory, honoring the /jail sandbox.
4018+// It shares the busy/cancel state with the agent: esc cancels it, and
4019+// it refuses to start while a turn or another shell escape is already
4020+// in flight. The terminal-log output is parked in i.shellBlock below
4021+// the transcript until the next prompt or /clear, so it never enters
4022+// the model conversation.
4023+func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
4024+ i.mu.Lock()
4025+ if i.busy || i.shellRunning {
4026+ i.statusErr = "busy — wait for the current turn to finish before running a shell command"
4027+ i.statusOK = ""
4028+ i.mu.Unlock()
4029+ i.invalidate()
4030+ return
4031+ }
4032+ if parent == nil {
4033+ parent = i.runCtx
4034+ }
4035+ if parent == nil {
4036+ parent = context.Background()
4037+ }
4038+ ctx, cancel := context.WithCancel(parent)
4039+ i.busy = true
4040+ i.shellRunning = true
4041+ i.cancelTurn = cancel
4042+ i.statusErr = ""
4043+ i.statusOK = ""
4044+ i.spin.StartFixed("running shell command")
4045+ // A new shell escape replaces the previous block; clear stale
4046+ // extension notes the same way a new turn would so the screen
4047+ // doesn't accumulate transient state.
4048+ i.shellBlock = nil
4049+ i.scrollOffset = 0
4050+ i.parkedTurn = 0
4051+ i.parkedTotal = 0
4052+ i.helpBlock = nil
4053+ sandbox := i.cfg.Sandbox
4054+ cwd := i.cfg.CWD
4055+ i.mu.Unlock()
4056+ i.invalidate()
4057+
4058+ go func() {
4059+ defer cancel()
4060+ raw, _ := json.Marshal(map[string]any{"command": cmd})
4061+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
4062+ res, err := bash.Execute(ctx, raw, nil)
4063+
4064+ var out string
4065+ if err != nil {
4066+ out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
4067+ } else {
4068+ for _, c := range res.Content {
4069+ if tb, ok := c.(provider.TextBlock); ok {
4070+ out += tb.Text
4071+ }
4072+ }
4073+ }
4074+ cancelled := ctx.Err() != nil
4075+ failed := err != nil || res.IsError || cancelled
4076+ if cancelled {
4077+ out += "\n\n[cancelled]"
4078+ }
4079+
4080+ block := i.renderShellBlock(out, failed)
4081+
4082+ i.mu.Lock()
4083+ i.shellRunning = false
4084+ i.busy = false
4085+ i.cancelTurn = nil
4086+ i.shellBlock = block
4087+ if failed {
4088+ if cancelled {
4089+ i.statusErr = "shell command cancelled"
4090+ } else {
4091+ i.statusErr = "shell command failed"
4092+ }
4093+ i.statusOK = ""
4094+ } else {
4095+ i.statusOK = "shell command finished"
4096+ i.statusErr = ""
4097+ }
4098+ i.mu.Unlock()
4099+ i.invalidate()
4100+ }()
4101+}
4102+
4103+// renderShellBlock turns merged bash output into a styled terminal-log
4104+// block: each line colored by overall success (tool/green) or failure
4105+// (error/red), with the [exit ...] / [error] footer dimmed via the
4106+// muted color so it reads as metadata.
4107+func (i *Interactive) renderShellBlock(out string, failed bool) []string {
4108+ th := i.cfg.Theme
4109+ base := th.Tool
4110+ if failed {
4111+ base = th.Error
4112+ }
4113+ out = strings.TrimRight(out, "\n")
4114+ lines := strings.Split(out, "\n")
4115+ styled := make([]string, 0, len(lines))
4116+ for _, line := range lines {
4117+ color := base
4118+ trimmed := strings.TrimSpace(line)
4119+ if strings.HasPrefix(trimmed, "[exit ") || strings.HasPrefix(trimmed, "[error]") || strings.HasPrefix(trimmed, "[cancelled]") {
4120+ color = th.Muted
4121+ }
4122+ styled = append(styled, th.FG256(color, line))
4123+ }
4124+ return styled
4125+}
4126+
3940 func (i *Interactive) startTurn(parent context.Context, prompt string) {4127 func (i *Interactive) startTurn(parent context.Context, prompt string) {
3941 i.startTurnWithImages(parent, prompt, nil)4128 i.startTurnWithImages(parent, prompt, nil)
3942 }4129 }
@@ -3945,13 +4132,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
3945 if i.agent == nil {4132 if i.agent == nil {
3946 return4133 return
3947 }4134 }
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 is4135 // Pre-turn safety: if the most recent context measurement is
3956 // already past the auto-compact threshold, condense before4136 // already past the auto-compact threshold, condense before
3957 // sending so the next outbound request stays under the limit.4137 // sending so the next outbound request stays under the limit.
@@ -3984,6 +4164,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
3984 i.streamOn = true4164 i.streamOn = true
3985 i.toolCalls = map[string]*tui.ToolCallView{}4165 i.toolCalls = map[string]*tui.ToolCallView{}
3986 i.toolOrder = nil4166 i.toolOrder = nil
4167+ i.shellBlock = nil // sending a prompt clears any parked shell-escape log
3987 i.scrollOffset = 0 // jump back to the bottom on new turn4168 i.scrollOffset = 0 // jump back to the bottom on new turn
3988 // Reset the auto-follow baseline so the very next render at4169 // Reset the auto-follow baseline so the very next render at
3989 // interactive.go:1053 doesn't see a synthetic shrink between4170 // 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/provider/amazon_bedrock.go +102 -2
@@ -285,6 +285,94 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
285285 return out, nil
286286 }
287287
288+// resolveBedrockInferenceProfileID maps a bare foundation-model ID to
289+// its region-matched cross-region inference-profile ID.
290+//
291+// Several newer Bedrock models (Anthropic Claude 4.x, DeepSeek, etc.)
292+// cannot be invoked with on-demand throughput by their plain
293+// foundation-model ID; Bedrock returns HTTP 400 demanding "the ID or
294+// ARN of an inference profile that contains this model". The profile
295+// ID is the same model ID with a geographic prefix (us/eu/apac/...).
296+//
297+// We only rewrite IDs that (a) lack an existing geo prefix and (b)
298+// belong to a model family that requires a profile. IDs that already
299+// carry a prefix (e.g. "eu.anthropic...", "global.anthropic...") or
300+// fully-qualified ARNs are returned unchanged, so explicit user
301+// choices and custom application inference profiles still work.
302+func resolveBedrockInferenceProfileID(modelID, region string) string {
303+ if modelID == "" {
304+ return modelID
305+ }
306+ // ARNs are already inference-profile references; leave untouched.
307+ if strings.HasPrefix(modelID, "arn:") {
308+ return modelID
309+ }
310+ // Already geo-prefixed (us. / eu. / apac. / ap. / us-gov. / global.)?
311+ if bedrockHasGeoPrefix(modelID) {
312+ return modelID
313+ }
314+ if !bedrockRequiresInferenceProfile(modelID) {
315+ return modelID
316+ }
317+ prefix := bedrockGeoPrefixForRegion(region)
318+ if prefix == "" {
319+ return modelID
320+ }
321+ return prefix + "." + modelID
322+}
323+
324+// bedrockGeoPrefixes are the cross-region inference-profile geo
325+// prefixes Bedrock uses. A model ID that starts with one of these
326+// (followed by a dot) is already a profile reference.
327+var bedrockGeoPrefixes = []string{"us-gov", "us", "eu", "apac", "ap", "global", "au"}
328+
329+func bedrockHasGeoPrefix(modelID string) bool {
330+ for _, p := range bedrockGeoPrefixes {
331+ if strings.HasPrefix(modelID, p+".") {
332+ return true
333+ }
334+ }
335+ return false
336+}
337+
338+// bedrockRequiresInferenceProfile reports whether a bare
339+// foundation-model ID is one of the families AWS only exposes through
340+// a cross-region inference profile for on-demand throughput.
341+func bedrockRequiresInferenceProfile(modelID string) bool {
342+ switch {
343+ case strings.HasPrefix(modelID, "anthropic.claude-"):
344+ return true
345+ case strings.HasPrefix(modelID, "deepseek."):
346+ return true
347+ default:
348+ return false
349+ }
350+}
351+
352+// bedrockGeoPrefixForRegion maps an AWS region to the geo prefix used
353+// by its cross-region inference profiles. Returns "" when the region
354+// has no known mapping, in which case the model ID is left unchanged.
355+func bedrockGeoPrefixForRegion(region string) string {
356+ switch {
357+ case region == "":
358+ return "us"
359+ case strings.HasPrefix(region, "us-gov-"):
360+ return "us-gov"
361+ case strings.HasPrefix(region, "us-"):
362+ return "us"
363+ case strings.HasPrefix(region, "eu-"):
364+ return "eu"
365+ case strings.HasPrefix(region, "ap-"):
366+ return "apac"
367+ case strings.HasPrefix(region, "ca-"):
368+ return "us"
369+ case strings.HasPrefix(region, "sa-"):
370+ return "us"
371+ default:
372+ return "us"
373+ }
374+}
375+
288376 func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
289377 wire, err := c.buildRequest(req)
290378 if err != nil {
@@ -294,7 +382,8 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
294382 if err != nil {
295383 return nil, err
296384 }
297- url := c.baseURL + "/model/" + req.Model + "/converse-stream"
385+ modelID := resolveBedrockInferenceProfileID(req.Model, c.region)
386+ url := c.baseURL + "/model/" + modelID + "/converse-stream"
298387 httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
299388 if err != nil {
300389 return nil, err
@@ -317,7 +406,18 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
317406 if resp.StatusCode != http.StatusOK {
318407 b, _ := io.ReadAll(resp.Body)
319408 resp.Body.Close()
320- return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
409+ msg := strings.TrimSpace(string(b))
410+ // A 403 on the bearer route is almost always a region mismatch:
411+ // short-term Bedrock API keys are scoped to the region of the
412+ // console session that minted them, but zot defaults to
413+ // us-east-1. Surface the resolved region and the fix so the user
414+ // is not left guessing why a freshly-copied key is "invalid".
415+ if resp.StatusCode == http.StatusForbidden && c.bearerToken != "" {
416+ return nil, fmt.Errorf(
417+ "bedrock: http 403 (region=%s): %s\nhint: Bedrock API keys are region-scoped. If your key was created in another region, set AWS_REGION (e.g. AWS_REGION=eu-central-1) or pass --base-url https://bedrock-runtime.<region>.amazonaws.com",
418+ c.region, msg)
419+ }
420+ return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, msg)
321421 }
322422 out := make(chan Event, 16)
323423 go c.runStream(ctx, resp, req, out)
@@ -285,6 +285,94 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
285 return out, nil285 return out, nil
286 }286 }
287 287
288+// resolveBedrockInferenceProfileID maps a bare foundation-model ID to
289+// its region-matched cross-region inference-profile ID.
290+//
291+// Several newer Bedrock models (Anthropic Claude 4.x, DeepSeek, etc.)
292+// cannot be invoked with on-demand throughput by their plain
293+// foundation-model ID; Bedrock returns HTTP 400 demanding "the ID or
294+// ARN of an inference profile that contains this model". The profile
295+// ID is the same model ID with a geographic prefix (us/eu/apac/...).
296+//
297+// We only rewrite IDs that (a) lack an existing geo prefix and (b)
298+// belong to a model family that requires a profile. IDs that already
299+// carry a prefix (e.g. "eu.anthropic...", "global.anthropic...") or
300+// fully-qualified ARNs are returned unchanged, so explicit user
301+// choices and custom application inference profiles still work.
302+func resolveBedrockInferenceProfileID(modelID, region string) string {
303+ if modelID == "" {
304+ return modelID
305+ }
306+ // ARNs are already inference-profile references; leave untouched.
307+ if strings.HasPrefix(modelID, "arn:") {
308+ return modelID
309+ }
310+ // Already geo-prefixed (us. / eu. / apac. / ap. / us-gov. / global.)?
311+ if bedrockHasGeoPrefix(modelID) {
312+ return modelID
313+ }
314+ if !bedrockRequiresInferenceProfile(modelID) {
315+ return modelID
316+ }
317+ prefix := bedrockGeoPrefixForRegion(region)
318+ if prefix == "" {
319+ return modelID
320+ }
321+ return prefix + "." + modelID
322+}
323+
324+// bedrockGeoPrefixes are the cross-region inference-profile geo
325+// prefixes Bedrock uses. A model ID that starts with one of these
326+// (followed by a dot) is already a profile reference.
327+var bedrockGeoPrefixes = []string{"us-gov", "us", "eu", "apac", "ap", "global", "au"}
328+
329+func bedrockHasGeoPrefix(modelID string) bool {
330+ for _, p := range bedrockGeoPrefixes {
331+ if strings.HasPrefix(modelID, p+".") {
332+ return true
333+ }
334+ }
335+ return false
336+}
337+
338+// bedrockRequiresInferenceProfile reports whether a bare
339+// foundation-model ID is one of the families AWS only exposes through
340+// a cross-region inference profile for on-demand throughput.
341+func bedrockRequiresInferenceProfile(modelID string) bool {
342+ switch {
343+ case strings.HasPrefix(modelID, "anthropic.claude-"):
344+ return true
345+ case strings.HasPrefix(modelID, "deepseek."):
346+ return true
347+ default:
348+ return false
349+ }
350+}
351+
352+// bedrockGeoPrefixForRegion maps an AWS region to the geo prefix used
353+// by its cross-region inference profiles. Returns "" when the region
354+// has no known mapping, in which case the model ID is left unchanged.
355+func bedrockGeoPrefixForRegion(region string) string {
356+ switch {
357+ case region == "":
358+ return "us"
359+ case strings.HasPrefix(region, "us-gov-"):
360+ return "us-gov"
361+ case strings.HasPrefix(region, "us-"):
362+ return "us"
363+ case strings.HasPrefix(region, "eu-"):
364+ return "eu"
365+ case strings.HasPrefix(region, "ap-"):
366+ return "apac"
367+ case strings.HasPrefix(region, "ca-"):
368+ return "us"
369+ case strings.HasPrefix(region, "sa-"):
370+ return "us"
371+ default:
372+ return "us"
373+ }
374+}
375+
288 func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {376 func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
289 wire, err := c.buildRequest(req)377 wire, err := c.buildRequest(req)
290 if err != nil {378 if err != nil {
@@ -294,7 +382,8 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
294 if err != nil {382 if err != nil {
295 return nil, err383 return nil, err
296 }384 }
297- url := c.baseURL + "/model/" + req.Model + "/converse-stream"385+ modelID := resolveBedrockInferenceProfileID(req.Model, c.region)
386+ url := c.baseURL + "/model/" + modelID + "/converse-stream"
298 httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))387 httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
299 if err != nil {388 if err != nil {
300 return nil, err389 return nil, err
@@ -317,7 +406,18 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
317 if resp.StatusCode != http.StatusOK {406 if resp.StatusCode != http.StatusOK {
318 b, _ := io.ReadAll(resp.Body)407 b, _ := io.ReadAll(resp.Body)
319 resp.Body.Close()408 resp.Body.Close()
320- return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))409+ msg := strings.TrimSpace(string(b))
410+ // A 403 on the bearer route is almost always a region mismatch:
411+ // short-term Bedrock API keys are scoped to the region of the
412+ // console session that minted them, but zot defaults to
413+ // us-east-1. Surface the resolved region and the fix so the user
414+ // is not left guessing why a freshly-copied key is "invalid".
415+ if resp.StatusCode == http.StatusForbidden && c.bearerToken != "" {
416+ return nil, fmt.Errorf(
417+ "bedrock: http 403 (region=%s): %s\nhint: Bedrock API keys are region-scoped. If your key was created in another region, set AWS_REGION (e.g. AWS_REGION=eu-central-1) or pass --base-url https://bedrock-runtime.<region>.amazonaws.com",
418+ c.region, msg)
419+ }
420+ return nil, fmt.Errorf("bedrock: http %d: %s", resp.StatusCode, msg)
321 }421 }
322 out := make(chan Event, 16)422 out := make(chan Event, 16)
323 go c.runStream(ctx, resp, req, out)423 go c.runStream(ctx, resp, req, out)
M packages/provider/amazon_bedrock_test.go +31 -0
@@ -81,3 +81,34 @@ func TestReadAWSCredentialsFile(t *testing.T) {
8181 t.Logf("no aws creds available (expected on CI): %v", err)
8282 }
8383 }
84+
85+func TestResolveBedrockInferenceProfileID(t *testing.T) {
86+ cases := []struct {
87+ model string
88+ region string
89+ want string
90+ }{
91+ // Bare Anthropic foundation IDs get the region-matched prefix.
92+ {"anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"},
93+ {"anthropic.claude-sonnet-4-5-20250929-v1:0", "eu-central-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
94+ {"anthropic.claude-opus-4-6-v1", "ap-southeast-2", "apac.anthropic.claude-opus-4-6-v1"},
95+ {"anthropic.claude-opus-4-6-v1", "us-gov-west-1", "us-gov.anthropic.claude-opus-4-6-v1"},
96+ {"deepseek.r1-v1:0", "eu-west-1", "eu.deepseek.r1-v1:0"},
97+ // Empty region defaults to us.
98+ {"anthropic.claude-opus-4-6-v1", "", "us.anthropic.claude-opus-4-6-v1"},
99+ // Already-prefixed IDs are left untouched.
100+ {"eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
101+ {"global.anthropic.claude-opus-4-6-v1", "us-east-1", "global.anthropic.claude-opus-4-6-v1"},
102+ {"us.anthropic.claude-opus-4-6-v1", "eu-central-1", "us.anthropic.claude-opus-4-6-v1"},
103+ // ARNs are passed through verbatim.
104+ {"arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1", "eu-west-1", "arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1"},
105+ // Families that don't need a profile are untouched.
106+ {"amazon.nova-pro-v1:0", "us-east-1", "amazon.nova-pro-v1:0"},
107+ {"", "us-east-1", ""},
108+ }
109+ for _, c := range cases {
110+ if got := resolveBedrockInferenceProfileID(c.model, c.region); got != c.want {
111+ t.Errorf("resolveBedrockInferenceProfileID(%q, %q) = %q; want %q", c.model, c.region, got, c.want)
112+ }
113+ }
114+}
@@ -81,3 +81,34 @@ func TestReadAWSCredentialsFile(t *testing.T) {
81 t.Logf("no aws creds available (expected on CI): %v", err)81 t.Logf("no aws creds available (expected on CI): %v", err)
82 }82 }
83 }83 }
84+
85+func TestResolveBedrockInferenceProfileID(t *testing.T) {
86+ cases := []struct {
87+ model string
88+ region string
89+ want string
90+ }{
91+ // Bare Anthropic foundation IDs get the region-matched prefix.
92+ {"anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"},
93+ {"anthropic.claude-sonnet-4-5-20250929-v1:0", "eu-central-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
94+ {"anthropic.claude-opus-4-6-v1", "ap-southeast-2", "apac.anthropic.claude-opus-4-6-v1"},
95+ {"anthropic.claude-opus-4-6-v1", "us-gov-west-1", "us-gov.anthropic.claude-opus-4-6-v1"},
96+ {"deepseek.r1-v1:0", "eu-west-1", "eu.deepseek.r1-v1:0"},
97+ // Empty region defaults to us.
98+ {"anthropic.claude-opus-4-6-v1", "", "us.anthropic.claude-opus-4-6-v1"},
99+ // Already-prefixed IDs are left untouched.
100+ {"eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
101+ {"global.anthropic.claude-opus-4-6-v1", "us-east-1", "global.anthropic.claude-opus-4-6-v1"},
102+ {"us.anthropic.claude-opus-4-6-v1", "eu-central-1", "us.anthropic.claude-opus-4-6-v1"},
103+ // ARNs are passed through verbatim.
104+ {"arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1", "eu-west-1", "arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1"},
105+ // Families that don't need a profile are untouched.
106+ {"amazon.nova-pro-v1:0", "us-east-1", "amazon.nova-pro-v1:0"},
107+ {"", "us-east-1", ""},
108+ }
109+ for _, c := range cases {
110+ if got := resolveBedrockInferenceProfileID(c.model, c.region); got != c.want {
111+ t.Errorf("resolveBedrockInferenceProfileID(%q, %q) = %q; want %q", c.model, c.region, got, c.want)
112+ }
113+ }
114+}
M packages/tui/render.go +59 -11
@@ -123,14 +123,24 @@ func (r *Renderer) Resize(cols, rows int) {
123123 r.logHardwareRow = 0
124124 r.logInit = false
125125 if r.out != nil {
126- // Clear both screen and (where safe) scrollback so stale
127- // content from the old width doesn't bleed through. Move
128- // to (1,1) so the next DrawLog/writeFull starts from a
129- // clean slate. Use the no-home variant: the explicit
130- // MoveTo below sets the cursor without triggering VS
131- // Code's viewport-snap. See Renderer.keepScrollback for
132- // why we skip \x1b[3J on VS Code's terminal.
133- _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
126+ if r.keepScrollback {
127+ // A resize is a discrete user action, like Ctrl+L: the
128+ // old-width frame must be fully purged or the in-place
129+ // viewport clear leaves the scrolled-away rows behind
130+ // (the user otherwise has to press Ctrl+L to fix it).
131+ // Emit \x1b[3J to drop the retained scrollback and
132+ // repaint clean, accepting VS Code's viewport-snap the
133+ // same way Clear does.
134+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
135+ } else {
136+ // Clear both screen and (where safe) scrollback so stale
137+ // content from the old width doesn't bleed through. Move
138+ // to (1,1) so the next DrawLog/writeFull starts from a
139+ // clean slate. Use the no-home variant: the explicit
140+ // MoveTo below sets the cursor without triggering VS
141+ // Code's viewport-snap.
142+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
143+ }
134144 }
135145 }
136146 }
@@ -148,6 +158,24 @@ func (r *Renderer) Clear() {
148158 r.logViewportTop = 0
149159 r.logHardwareRow = 0
150160 r.logInit = false
161+ if r.keepScrollback {
162+ // On VS Code's xterm.js the transcript is taller than the
163+ // viewport, so an in-place clear (home + erase-to-end) only
164+ // wipes the visible rows: the part that scrolled above the
165+ // viewport stays in the retained buffer and the next full
166+ // repaint stacks a duplicate above the live frame.
167+ //
168+ // Clear() is an explicit user refresh (Ctrl+L), so here we do
169+ // emit \x1b[3J to actually drop that scrollback, then home and
170+ // repaint. This is the one place we accept VS Code's
171+ // viewport-snap, because the user asked for a clean screen.
172+ // (Implicit repaints, e.g. closing a dialog, deliberately
173+ // avoid this path so they never snap the viewport.)
174+ if r.out != nil {
175+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
176+ }
177+ return
178+ }
151179 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
152180 }
153181
@@ -163,6 +191,12 @@ func (r *Renderer) clearScrollbackSeq() string {
163191 return SeqClearScrollback
164192 }
165193
194+// KeepsScrollback reports whether this renderer suppresses the
195+// scrollback-clear escape (true under VS Code's terminal). Callers
196+// use it to pick a viewport-safe full repaint (Invalidate) over a
197+// scrollback-clearing one (Clear) when redrawing overlays.
198+func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback }
199+
166200 // Invalidate forces a full repaint on the next Draw without clearing the
167201 // whole terminal first. Useful when the cached diff is unreliable but a
168202 // visible full-screen flash would be too distracting.
@@ -452,9 +486,23 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
452486 writeFull := func(clear bool) {
453487 if clear {
454488 w.WriteString(SeqDeleteKittyImages)
455- w.WriteString(SeqClearScreenNoHome)
456- w.WriteString(r.clearScrollbackSeq())
457- w.WriteString(MoveTo(1, 1))
489+ if r.keepScrollback {
490+ // VS Code's xterm.js scrolls the visible content up into
491+ // scrollback on \x1b[2J, which duplicates the frame (the
492+ // old paint stays above the new one). Home to the
493+ // viewport top and erase-to-end (\x1b[0J) instead: that
494+ // clears the visible screen in place without pushing the
495+ // previous frame into scrollback. We still cannot drop
496+ // existing scrollback (\x1b[3J snaps the viewport there),
497+ // but a full repaint no longer stacks a fresh copy below
498+ // the old one.
499+ w.WriteString(SeqCursorHome)
500+ w.WriteString(SeqClearToEnd)
501+ } else {
502+ w.WriteString(SeqClearScreenNoHome)
503+ w.WriteString(r.clearScrollbackSeq())
504+ w.WriteString(MoveTo(1, 1))
505+ }
458506 }
459507 for idx, line := range lines {
460508 if idx > 0 {
@@ -123,14 +123,24 @@ func (r *Renderer) Resize(cols, rows int) {
123 r.logHardwareRow = 0123 r.logHardwareRow = 0
124 r.logInit = false124 r.logInit = false
125 if r.out != nil {125 if r.out != nil {
126- // Clear both screen and (where safe) scrollback so stale126+ if r.keepScrollback {
127- // content from the old width doesn't bleed through. Move127+ // A resize is a discrete user action, like Ctrl+L: the
128- // to (1,1) so the next DrawLog/writeFull starts from a128+ // old-width frame must be fully purged or the in-place
129- // clean slate. Use the no-home variant: the explicit129+ // viewport clear leaves the scrolled-away rows behind
130- // MoveTo below sets the cursor without triggering VS130+ // (the user otherwise has to press Ctrl+L to fix it).
131- // Code's viewport-snap. See Renderer.keepScrollback for131+ // Emit \x1b[3J to drop the retained scrollback and
132- // why we skip \x1b[3J on VS Code's terminal.132+ // repaint clean, accepting VS Code's viewport-snap the
133- _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))133+ // same way Clear does.
134+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
135+ } else {
136+ // Clear both screen and (where safe) scrollback so stale
137+ // content from the old width doesn't bleed through. Move
138+ // to (1,1) so the next DrawLog/writeFull starts from a
139+ // clean slate. Use the no-home variant: the explicit
140+ // MoveTo below sets the cursor without triggering VS
141+ // Code's viewport-snap.
142+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
143+ }
134 }144 }
135 }145 }
136 }146 }
@@ -148,6 +158,24 @@ func (r *Renderer) Clear() {
148 r.logViewportTop = 0158 r.logViewportTop = 0
149 r.logHardwareRow = 0159 r.logHardwareRow = 0
150 r.logInit = false160 r.logInit = false
161+ if r.keepScrollback {
162+ // On VS Code's xterm.js the transcript is taller than the
163+ // viewport, so an in-place clear (home + erase-to-end) only
164+ // wipes the visible rows: the part that scrolled above the
165+ // viewport stays in the retained buffer and the next full
166+ // repaint stacks a duplicate above the live frame.
167+ //
168+ // Clear() is an explicit user refresh (Ctrl+L), so here we do
169+ // emit \x1b[3J to actually drop that scrollback, then home and
170+ // repaint. This is the one place we accept VS Code's
171+ // viewport-snap, because the user asked for a clean screen.
172+ // (Implicit repaints, e.g. closing a dialog, deliberately
173+ // avoid this path so they never snap the viewport.)
174+ if r.out != nil {
175+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
176+ }
177+ return
178+ }
151 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))179 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
152 }180 }
153 181
@@ -163,6 +191,12 @@ func (r *Renderer) clearScrollbackSeq() string {
163 return SeqClearScrollback191 return SeqClearScrollback
164 }192 }
165 193
194+// KeepsScrollback reports whether this renderer suppresses the
195+// scrollback-clear escape (true under VS Code's terminal). Callers
196+// use it to pick a viewport-safe full repaint (Invalidate) over a
197+// scrollback-clearing one (Clear) when redrawing overlays.
198+func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback }
199+
166 // Invalidate forces a full repaint on the next Draw without clearing the200 // Invalidate forces a full repaint on the next Draw without clearing the
167 // whole terminal first. Useful when the cached diff is unreliable but a201 // whole terminal first. Useful when the cached diff is unreliable but a
168 // visible full-screen flash would be too distracting.202 // visible full-screen flash would be too distracting.
@@ -452,9 +486,23 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
452 writeFull := func(clear bool) {486 writeFull := func(clear bool) {
453 if clear {487 if clear {
454 w.WriteString(SeqDeleteKittyImages)488 w.WriteString(SeqDeleteKittyImages)
455- w.WriteString(SeqClearScreenNoHome)489+ if r.keepScrollback {
456- w.WriteString(r.clearScrollbackSeq())490+ // VS Code's xterm.js scrolls the visible content up into
457- w.WriteString(MoveTo(1, 1))491+ // scrollback on \x1b[2J, which duplicates the frame (the
492+ // old paint stays above the new one). Home to the
493+ // viewport top and erase-to-end (\x1b[0J) instead: that
494+ // clears the visible screen in place without pushing the
495+ // previous frame into scrollback. We still cannot drop
496+ // existing scrollback (\x1b[3J snaps the viewport there),
497+ // but a full repaint no longer stacks a fresh copy below
498+ // the old one.
499+ w.WriteString(SeqCursorHome)
500+ w.WriteString(SeqClearToEnd)
501+ } else {
502+ w.WriteString(SeqClearScreenNoHome)
503+ w.WriteString(r.clearScrollbackSeq())
504+ w.WriteString(MoveTo(1, 1))
505+ }
458 }506 }
459 for idx, line := range lines {507 for idx, line := range lines {
460 if idx > 0 {508 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 +47 -29
@@ -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 }
@@ -266,12 +263,39 @@ func (v *View) BuildLive(width int) []string {
266263 // out = append(out, "")
267264 }
268265 if v.Err != "" {
269- out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
266+ out = append(out, v.renderErr(width)...)
270267 // out = append(out, "")
271268 }
272269 return out
273270 }
274271
272+// renderErr formats v.Err into one or more colored rows, word-wrapped
273+// to width so long provider error messages (e.g. a Bedrock 400 with an
274+// embedded JSON body) aren't cut off at the terminal edge. The first
275+// row carries the "✖ " marker; continuation rows align under the text
276+// with a two-cell indent.
277+func (v *View) renderErr(width int) []string {
278+ const marker = "✖ "
279+ const indent = " "
280+ wrapWidth := width - len([]rune(marker))
281+ if wrapWidth < 8 {
282+ wrapWidth = 8
283+ }
284+ wrapped := wrapLine(v.Err, wrapWidth, "")
285+ if len(wrapped) == 0 {
286+ wrapped = []string{""}
287+ }
288+ out := make([]string, 0, len(wrapped))
289+ for idx, line := range wrapped {
290+ prefix := marker
291+ if idx > 0 {
292+ prefix = indent
293+ }
294+ out = append(out, v.Theme.FG256(v.Theme.Error, prefix+line))
295+ }
296+ return out
297+}
298+
275299 // BuildWithAnchors is like Build but additionally reports the first
276300 // row occupied by each message in v.Messages. Callers that need to
277301 // scroll to a specific turn (the /jump dialog) use the anchor slice
@@ -370,21 +394,15 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
370394 }
371395 out = append(out, "")
372396 }
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.
397+ // Live tool-call overlay: keep the in-flight box visible after
398+ // the assistant tool_use is appended, then hide it only when the
399+ // matching tool_result reaches the transcript. Assistant tool_use
400+ // blocks render no rows of their own, so suppressing the overlay
401+ // at that point makes the box disappear while the tool is running.
381402 finalised := map[string]bool{}
382403 for _, m := range v.Messages {
383404 for _, c := range m.Content {
384- switch b := c.(type) {
385- case provider.ToolCallBlock:
386- finalised[b.ID] = true
387- case provider.ToolResultBlock:
405+ if b, ok := c.(provider.ToolResultBlock); ok {
388406 finalised[b.CallID] = true
389407 }
390408 }
@@ -397,7 +415,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
397415 out = append(out, "")
398416 }
399417 if v.Err != "" {
400- out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
418+ out = append(out, v.renderErr(width)...)
401419 out = append(out, "")
402420 }
403421 return out, anchors
@@ -712,20 +730,20 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
712730 // here would double the gap during streaming and visibly tighten
713731 // when the overlay disappears.
714732
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))
733+ // Live body (write/edit): keep the streamed preview visible until
734+ // a real tool result arrives. The provider can finish the tool_use
735+ // JSON before zot has executed the tool, so keying this on
736+ // tc.Streaming makes write/edit boxes collapse for a moment between
737+ // EvToolUseEnd and EvToolResult.
738+ if tc.Result == "" {
723739 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
740+ lines = append(lines, toolBoxTop(v.Theme, label, width))
741+ lines = append(lines, toolBoxSide(v.Theme, "", width))
724742 lines = append(lines, body...)
743+ lines = append(lines, toolBoxSide(v.Theme, "", width))
744+ lines = append(lines, toolBoxBottom(v.Theme, width))
745+ return lines
725746 }
726- lines = append(lines, toolBoxSide(v.Theme, "", width))
727- lines = append(lines, toolBoxBottom(v.Theme, width))
728- return lines
729747 }
730748
731749 // 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 }
@@ -266,12 +263,39 @@ func (v *View) BuildLive(width int) []string {
266 // out = append(out, "")263 // out = append(out, "")
267 }264 }
268 if v.Err != "" {265 if v.Err != "" {
269- out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))266+ out = append(out, v.renderErr(width)...)
270 // out = append(out, "")267 // out = append(out, "")
271 }268 }
272 return out269 return out
273 }270 }
274 271
272+// renderErr formats v.Err into one or more colored rows, word-wrapped
273+// to width so long provider error messages (e.g. a Bedrock 400 with an
274+// embedded JSON body) aren't cut off at the terminal edge. The first
275+// row carries the "✖ " marker; continuation rows align under the text
276+// with a two-cell indent.
277+func (v *View) renderErr(width int) []string {
278+ const marker = "✖ "
279+ const indent = " "
280+ wrapWidth := width - len([]rune(marker))
281+ if wrapWidth < 8 {
282+ wrapWidth = 8
283+ }
284+ wrapped := wrapLine(v.Err, wrapWidth, "")
285+ if len(wrapped) == 0 {
286+ wrapped = []string{""}
287+ }
288+ out := make([]string, 0, len(wrapped))
289+ for idx, line := range wrapped {
290+ prefix := marker
291+ if idx > 0 {
292+ prefix = indent
293+ }
294+ out = append(out, v.Theme.FG256(v.Theme.Error, prefix+line))
295+ }
296+ return out
297+}
298+
275 // BuildWithAnchors is like Build but additionally reports the first299 // BuildWithAnchors is like Build but additionally reports the first
276 // row occupied by each message in v.Messages. Callers that need to300 // row occupied by each message in v.Messages. Callers that need to
277 // scroll to a specific turn (the /jump dialog) use the anchor slice301 // scroll to a specific turn (the /jump dialog) use the anchor slice
@@ -370,21 +394,15 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
370 }394 }
371 out = append(out, "")395 out = append(out, "")
372 }396 }
373- // Live tool-call overlay: skip any entry whose assistant397+ // Live tool-call overlay: keep the in-flight box visible after
374- // tool_use block OR tool_result has already made it into the398+ // the assistant tool_use is appended, then hide it only when the
375- // transcript. The EvAssistantMessage for a tool-use turn399+ // matching tool_result reaches the transcript. Assistant tool_use
376- // lands BEFORE executeTools runs, so between that moment and400+ // blocks render no rows of their own, so suppressing the overlay
377- // the tool-result being appended the overlay and the401+ // 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{}402 finalised := map[string]bool{}
382 for _, m := range v.Messages {403 for _, m := range v.Messages {
383 for _, c := range m.Content {404 for _, c := range m.Content {
384- switch b := c.(type) {405+ if b, ok := c.(provider.ToolResultBlock); ok {
385- case provider.ToolCallBlock:
386- finalised[b.ID] = true
387- case provider.ToolResultBlock:
388 finalised[b.CallID] = true406 finalised[b.CallID] = true
389 }407 }
390 }408 }
@@ -397,7 +415,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
397 out = append(out, "")415 out = append(out, "")
398 }416 }
399 if v.Err != "" {417 if v.Err != "" {
400- out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))418+ out = append(out, v.renderErr(width)...)
401 out = append(out, "")419 out = append(out, "")
402 }420 }
403 return out, anchors421 return out, anchors
@@ -712,20 +730,20 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
712 // here would double the gap during streaming and visibly tighten730 // here would double the gap during streaming and visibly tighten
713 // when the overlay disappears.731 // when the overlay disappears.
714 732
715- // Streaming body (write/edit): top edge with the label, body733+ // Live body (write/edit): keep the streamed preview visible until
716- // rows wrapped with vertical edges, bottom edge to close the734+ // a real tool result arrives. The provider can finish the tool_use
717- // box. When the call finalises, the live overlay disappears735+ // JSON before zot has executed the tool, so keying this on
718- // the same frame the transcript renders the closed box, so736+ // tc.Streaming makes write/edit boxes collapse for a moment between
719- // there's no visible hop.737+ // EvToolUseEnd and EvToolResult.
720- if tc.Streaming && tc.Result == "" {738+ 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 {739 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
740+ lines = append(lines, toolBoxTop(v.Theme, label, width))
741+ lines = append(lines, toolBoxSide(v.Theme, "", width))
724 lines = append(lines, body...)742 lines = append(lines, body...)
743+ lines = append(lines, toolBoxSide(v.Theme, "", width))
744+ lines = append(lines, toolBoxBottom(v.Theme, width))
745+ return lines
725 }746 }
726- lines = append(lines, toolBoxSide(v.Theme, "", width))
727- lines = append(lines, toolBoxBottom(v.Theme, width))
728- return lines
729 }747 }
730 748
731 // Finished tool call with no body: just the labelled top edge749 // 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 477

↑↓ moveenter openesc close