Merge branch 'main' into feat/issue-templates
11 files · +670 -52
M README.md +4 -0
| @@ -210,6 +210,10 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||
| 210 | 210 | ||
| 211 | 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 | 217 | ### `/sessions` | |
| 214 | 218 | ||
| 215 | 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. | |
| @@ -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 { | |||
| 213 | 213 | 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 | 261 | // Resolve merges args, config, and env into a Resolved set. | |
| 217 | 262 | // | |
| 218 | 263 | // Unlike the earlier version, Resolve NEVER returns an error for | |
| @@ -223,7 +268,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 223 | 268 | cfg, _ := LoadConfig() | |
| 224 | 269 | ||
| 225 | 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 | 276 | if !isKnownProvider(provName) { | |
| 228 | 277 | // Unknown provider (maybe removed or renamed). Fall back to | |
| 229 | 278 | // the first provider that has credentials, or anthropic. | |
| @@ -213,6 +213,51 @@ func isKnownProvider(name string) bool { | |||||
| 213 | return false | 213 | 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 for | 263 | // 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 to | 277 | // 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) { | |||
| 108 | 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 | + | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| @@ -213,6 +214,7 @@ type chatCacheKey struct { | |||
| 213 | 214 | statusErr string | |
| 214 | 215 | help string | |
| 215 | 216 | extNotes string | |
| 217 | + | shellBlock string | |
| 216 | 218 | updateAvailable bool | |
| 217 | 219 | updateCurrent string | |
| 218 | 220 | updateLatest string | |
| @@ -378,6 +380,15 @@ type Interactive struct { | |||
| 378 | 380 | // transcript) until cleared by /clear or another reset. | |
| 379 | 381 | 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 | 392 | // sessionLoading is true while a /sessions selection is being read | |
| 382 | 393 | // on a background goroutine. Keeping this off the input goroutine | |
| 383 | 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 | 765 | statusErr: i.statusErr, | |
| 755 | 766 | help: strings.Join(i.helpBlock, "\n"), | |
| 756 | 767 | extNotes: strings.Join(i.extNotes, "\n"), | |
| 768 | + | shellBlock: strings.Join(i.shellBlock, "\n"), | |
| 757 | 769 | updateAvailable: i.updateInfo.Available, | |
| 758 | 770 | updateCurrent: i.updateInfo.Current, | |
| 759 | 771 | updateLatest: i.updateInfo.Latest, | |
| @@ -872,6 +884,14 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 872 | 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 | 895 | // Strip trailing blank rows so the chat content sits flush | |
| 876 | 896 | // against the new "blank above status bar" row added by the | |
| 877 | 897 | // bottom-region assembly. Build() ends every message with a | |
| @@ -1041,7 +1061,19 @@ func (i *Interactive) redraw() { | |||
| 1041 | 1061 | // from the chat below, instead of the diff path leaving stale | |
| 1042 | 1062 | // dialog content behind. Equivalent to the user pressing ctrl+l. | |
| 1043 | 1063 | 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 | 1077 | i.rend.Clear() | |
| 1046 | 1078 | } | |
| 1047 | 1079 | i.prevOverlayOpen = overlayOpen | |
| @@ -1620,6 +1652,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1620 | 1652 | if act.Select { | |
| 1621 | 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 | 1661 | return false | |
| 1624 | 1662 | } | |
| 1625 | 1663 | if i.swarmDialog.Active() { | |
| @@ -1845,14 +1883,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1845 | 1883 | i.mu.Lock() | |
| 1846 | 1884 | hadHelp := len(i.helpBlock) > 0 | |
| 1847 | 1885 | 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 | 1890 | if hadHelp { | |
| 1849 | 1891 | i.helpBlock = nil | |
| 1850 | 1892 | } | |
| 1851 | 1893 | if hadNotes { | |
| 1852 | 1894 | i.extNotes = nil | |
| 1853 | 1895 | } | |
| 1896 | + | if hadShell { | |
| 1897 | + | i.shellBlock = nil | |
| 1898 | + | } | |
| 1854 | 1899 | i.mu.Unlock() | |
| 1855 | - | if hadHelp || hadNotes { | |
| 1900 | + | if hadHelp || hadNotes || hadShell { | |
| 1856 | 1901 | i.invalidate() | |
| 1857 | 1902 | return false | |
| 1858 | 1903 | } | |
| @@ -2072,6 +2117,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2072 | 2117 | i.suggest.Reset() | |
| 2073 | 2118 | i.fileSuggest.Reset() | |
| 2074 | 2119 | ||
| 2120 | + | if cmd, ok := shellEscapeCommand(text); ok { | |
| 2121 | + | i.startShellEscape(ctx, cmd) | |
| 2122 | + | return false | |
| 2123 | + | } | |
| 2124 | + | ||
| 2075 | 2125 | if looksLikeSlashCommand(text) { | |
| 2076 | 2126 | head := text | |
| 2077 | 2127 | rest := "" | |
| @@ -2308,6 +2358,10 @@ func (i *Interactive) Notify(extName, level, message string) { | |||
| 2308 | 2358 | ||
| 2309 | 2359 | // Submit feeds text through the agent loop as if the user had typed it. | |
| 2310 | 2360 | func (i *Interactive) Submit(text string) { | |
| 2361 | + | if cmd, ok := shellEscapeCommand(text); ok { | |
| 2362 | + | i.startShellEscape(i.runCtx, cmd) | |
| 2363 | + | return | |
| 2364 | + | } | |
| 2311 | 2365 | i.startTurn(i.runCtx, text) | |
| 2312 | 2366 | } | |
| 2313 | 2367 | ||
| @@ -2368,6 +2422,10 @@ func (i *Interactive) SubmitSlash(text string) { | |||
| 2368 | 2422 | // forwarded — because the queued-prompt path is text-only; a | |
| 2369 | 2423 | // follow-up can expand the queue entry to carry images. | |
| 2370 | 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 | 2429 | i.mu.Lock() | |
| 2372 | 2430 | if i.agent == nil { | |
| 2373 | 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 | 3058 | i.parkedTotal = 0 | |
| 3001 | 3059 | i.scrollOffset = 0 | |
| 3002 | 3060 | i.extNotes = nil | |
| 3061 | + | i.shellBlock = nil | |
| 3003 | 3062 | i.view.InvalidateRenderCache() | |
| 3004 | 3063 | i.mu.Unlock() | |
| 3005 | 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 | 4127 | func (i *Interactive) startTurn(parent context.Context, prompt string) { | |
| 3941 | 4128 | i.startTurnWithImages(parent, prompt, nil) | |
| 3942 | 4129 | } | |
| @@ -3945,13 +4132,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 3945 | 4132 | if i.agent == nil { | |
| 3946 | 4133 | 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 | 4135 | // Pre-turn safety: if the most recent context measurement is | |
| 3956 | 4136 | // already past the auto-compact threshold, condense before | |
| 3957 | 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 | 4164 | i.streamOn = true | |
| 3985 | 4165 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 3986 | 4166 | i.toolOrder = nil | |
| 4167 | + | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | |
| 3987 | 4168 | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 3988 | 4169 | // Reset the auto-follow baseline so the very next render at | |
| 3989 | 4170 | // 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 string | 214 | statusErr string | ||
| 214 | help string | 215 | help string | ||
| 215 | extNotes string | 216 | extNotes string | ||
| 217 | + | shellBlock string | |||
| 216 | updateAvailable bool | 218 | updateAvailable bool | ||
| 217 | updateCurrent string | 219 | updateCurrent string | ||
| 218 | updateLatest string | 220 | 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 []string | 381 | 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 read | 392 | // sessionLoading is true while a /sessions selection is being read | ||
| 382 | // on a background goroutine. Keeping this off the input goroutine | 393 | // 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 flush | 895 | // Strip trailing blank rows so the chat content sits flush | ||
| 876 | // against the new "blank above status bar" row added by the | 896 | // against the new "blank above status bar" row added by the | ||
| 877 | // bottom-region assembly. Build() ends every message with a | 897 | // 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 stale | 1061 | // 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) > 0 | 1063 | 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 = overlayOpen | 1079 | 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 false | 1661 | 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) > 0 | 1884 | hadHelp := len(i.helpBlock) > 0 | ||
| 1847 | hadNotes := len(i.extNotes) > 0 | 1885 | 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 = nil | 1891 | i.helpBlock = nil | ||
| 1850 | } | 1892 | } | ||
| 1851 | if hadNotes { | 1893 | if hadNotes { | ||
| 1852 | i.extNotes = nil | 1894 | 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 false | 1902 | 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 := text | 2126 | 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; a | 2422 | // 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 = 0 | 3058 | i.parkedTotal = 0 | ||
| 3001 | i.scrollOffset = 0 | 3059 | i.scrollOffset = 0 | ||
| 3002 | i.extNotes = nil | 3060 | 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 | return | 4133 | 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 is | 4135 | // Pre-turn safety: if the most recent context measurement is | ||
| 3956 | // already past the auto-compact threshold, condense before | 4136 | // 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 = true | 4164 | i.streamOn = true | ||
| 3985 | i.toolCalls = map[string]*tui.ToolCallView{} | 4165 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 3986 | i.toolOrder = nil | 4166 | 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 turn | 4168 | i.scrollOffset = 0 // jump back to the bottom on new turn | ||
| 3988 | // Reset the auto-follow baseline so the very next render at | 4169 | // Reset the auto-follow baseline so the very next render at | ||
| 3989 | // interactive.go:1053 doesn't see a synthetic shrink between | 4170 | // 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) { | |||
| 285 | 285 | 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 | 376 | func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 289 | 377 | wire, err := c.buildRequest(req) | |
| 290 | 378 | if err != nil { | |
| @@ -294,7 +382,8 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, | |||
| 294 | 382 | if err != nil { | |
| 295 | 383 | 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 | 387 | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) | |
| 299 | 388 | if err != nil { | |
| 300 | 389 | return nil, err | |
| @@ -317,7 +406,18 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, | |||
| 317 | 406 | if resp.StatusCode != http.StatusOK { | |
| 318 | 407 | b, _ := io.ReadAll(resp.Body) | |
| 319 | 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 | 422 | out := make(chan Event, 16) | |
| 323 | 423 | go c.runStream(ctx, resp, req, out) | |
| @@ -285,6 +285,94 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||||
| 285 | return out, nil | 285 | 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, err | 383 | 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, err | 389 | 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) { | |||
| 81 | 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 | + | } | |
| @@ -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) { | |||
| 123 | 123 | r.logHardwareRow = 0 | |
| 124 | 124 | r.logInit = false | |
| 125 | 125 | 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 | + | } | |
| 134 | 144 | } | |
| 135 | 145 | } | |
| 136 | 146 | } | |
| @@ -148,6 +158,24 @@ func (r *Renderer) Clear() { | |||
| 148 | 158 | r.logViewportTop = 0 | |
| 149 | 159 | r.logHardwareRow = 0 | |
| 150 | 160 | 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 | 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 | 191 | 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 | 200 | // Invalidate forces a full repaint on the next Draw without clearing the | |
| 167 | 201 | // whole terminal first. Useful when the cached diff is unreliable but a | |
| 168 | 202 | // visible full-screen flash would be too distracting. | |
| @@ -452,9 +486,23 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 452 | 486 | writeFull := func(clear bool) { | |
| 453 | 487 | if clear { | |
| 454 | 488 | 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 | + | } | |
| 458 | 506 | } | |
| 459 | 507 | for idx, line := range lines { | |
| 460 | 508 | if idx > 0 { | |
| @@ -123,14 +123,24 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 123 | r.logHardwareRow = 0 | 123 | r.logHardwareRow = 0 | ||
| 124 | r.logInit = false | 124 | r.logInit = false | ||
| 125 | if r.out != nil { | 125 | if r.out != nil { | ||
| 126 | - | // Clear both screen and (where safe) scrollback so stale | 126 | + | if r.keepScrollback { |
| 127 | - | // content from the old width doesn't bleed through. Move | 127 | + | // A resize is a discrete user action, like Ctrl+L: the |
| 128 | - | // to (1,1) so the next DrawLog/writeFull starts from a | 128 | + | // old-width frame must be fully purged or the in-place |
| 129 | - | // clean slate. Use the no-home variant: the explicit | 129 | + | // viewport clear leaves the scrolled-away rows behind |
| 130 | - | // MoveTo below sets the cursor without triggering VS | 130 | + | // (the user otherwise has to press Ctrl+L to fix it). |
| 131 | - | // Code's viewport-snap. See Renderer.keepScrollback for | 131 | + | // 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 = 0 | 158 | r.logViewportTop = 0 | ||
| 149 | r.logHardwareRow = 0 | 159 | r.logHardwareRow = 0 | ||
| 150 | r.logInit = false | 160 | 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 SeqClearScrollback | 191 | 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 the | 200 | // Invalidate forces a full repaint on the next Draw without clearing the | ||
| 167 | // whole terminal first. Useful when the cached diff is unreliable but a | 201 | // 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 ( | |||
| 95 | 95 | // cursor identically without triggering that snap. | |
| 96 | 96 | SeqClearScreenNoHome = "\x1b[2J" | |
| 97 | 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 | 105 | SeqClearLine = "\x1b[2K" | |
| 99 | 106 | SeqResetScrollRegion = "\x1b[r" | |
| 100 | 107 | 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 { | |||
| 245 | 245 | finalised := map[string]bool{} | |
| 246 | 246 | for _, m := range v.Messages { | |
| 247 | 247 | 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 { | |
| 252 | 249 | finalised[b.CallID] = true | |
| 253 | 250 | } | |
| 254 | 251 | } | |
| @@ -266,12 +263,39 @@ func (v *View) BuildLive(width int) []string { | |||
| 266 | 263 | // out = append(out, "") | |
| 267 | 264 | } | |
| 268 | 265 | if v.Err != "" { | |
| 269 | - | out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err)) | |
| 266 | + | out = append(out, v.renderErr(width)...) | |
| 270 | 267 | // out = append(out, "") | |
| 271 | 268 | } | |
| 272 | 269 | 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 | 299 | // BuildWithAnchors is like Build but additionally reports the first | |
| 276 | 300 | // row occupied by each message in v.Messages. Callers that need to | |
| 277 | 301 | // 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 | 395 | out = append(out, "") | |
| 372 | 396 | } | |
| 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. | |
| 381 | 402 | finalised := map[string]bool{} | |
| 382 | 403 | for _, m := range v.Messages { | |
| 383 | 404 | 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 { | |
| 388 | 406 | finalised[b.CallID] = true | |
| 389 | 407 | } | |
| 390 | 408 | } | |
| @@ -397,7 +415,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 397 | 415 | out = append(out, "") | |
| 398 | 416 | } | |
| 399 | 417 | if v.Err != "" { | |
| 400 | - | out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err)) | |
| 418 | + | out = append(out, v.renderErr(width)...) | |
| 401 | 419 | out = append(out, "") | |
| 402 | 420 | } | |
| 403 | 421 | return out, anchors | |
| @@ -712,20 +730,20 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 712 | 730 | // here would double the gap during streaming and visibly tighten | |
| 713 | 731 | // when the overlay disappears. | |
| 714 | 732 | ||
| 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 == "" { | |
| 723 | 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 | 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 | 749 | // 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] = true | 249 | 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 out | 269 | 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 first | 299 | // BuildWithAnchors is like Build but additionally reports the first | ||
| 276 | // row occupied by each message in v.Messages. Callers that need to | 300 | // row occupied by each message in v.Messages. Callers that need to | ||
| 277 | // scroll to a specific turn (the /jump dialog) use the anchor slice | 301 | // 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 assistant | 397 | + | // Live tool-call overlay: keep the in-flight box visible after |
| 374 | - | // tool_use block OR tool_result has already made it into the | 398 | + | // the assistant tool_use is appended, then hide it only when the |
| 375 | - | // transcript. The EvAssistantMessage for a tool-use turn | 399 | + | // matching tool_result reaches the transcript. Assistant tool_use |
| 376 | - | // lands BEFORE executeTools runs, so between that moment and | 400 | + | // blocks render no rows of their own, so suppressing the overlay |
| 377 | - | // the tool-result being appended the overlay and the | 401 | + | // 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] = true | 406 | 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, anchors | 421 | 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 tighten | 730 | // 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, body | 733 | + | // Live body (write/edit): keep the streamed preview visible until |
| 716 | - | // rows wrapped with vertical edges, bottom edge to close the | 734 | + | // a real tool result arrives. The provider can finish the tool_use |
| 717 | - | // box. When the call finalises, the live overlay disappears | 735 | + | // JSON before zot has executed the tool, so keying this on |
| 718 | - | // the same frame the transcript renders the closed box, so | 736 | + | // 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 edge | 749 | // 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 | + | } | |||