Merge remote-tracking branch 'origin/main'
12 files · +490 -17
M README.md +1 -1
| @@ -316,7 +316,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||
| 316 | 316 | ||
| 317 | 317 | ### `/sessions` | |
| 318 | 318 | ||
| 319 | - | 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. | |
| 319 | + | 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, `r` to rename, `d` to delete, or `esc` to cancel. Deletion requires confirmation and permanently removes the session file; the currently active session cannot be deleted. 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. | |
| 320 | 320 | ||
| 321 | 321 | ### `/session` | |
| 322 | 322 | ||
| @@ -316,7 +316,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||||
| 316 | 316 | ||||
| 317 | ### `/sessions` | 317 | ### `/sessions` | ||
| 318 | 318 | ||||
| 319 | - | 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. | 319 | + | 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, `r` to rename, `d` to delete, or `esc` to cancel. Deletion requires confirmation and permanently removes the session file; the currently active session cannot be deleted. 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. |
| 320 | 320 | ||||
| 321 | ### `/session` | 321 | ### `/session` | ||
| 322 | 322 | ||||
M examples/extensions/mcp-bridge/README.md +30 -0
| @@ -211,11 +211,41 @@ Standard MCP config — same as Claude Desktop, with zot-specific extensions: | |||
| 211 | 211 | "context7": { | |
| 212 | 212 | "command": "npx", | |
| 213 | 213 | "args": ["-y", "@upstash/context7-mcp@latest"] | |
| 214 | + | }, | |
| 215 | + | ||
| 216 | + | // You.com web search (streamable-http, keyless) | |
| 217 | + | "you": { | |
| 218 | + | "transport": "streamable-http", | |
| 219 | + | "url": "https://api.you.com/mcp?profile=free" | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ``` | |
| 224 | + | ||
| 225 | + | ### You.com template | |
| 226 | + | ||
| 227 | + | `/mcp setup add you` registers the keyless You.com MCP profile | |
| 228 | + | (`you-search`). It needs no account or API key. To use the authenticated | |
| 229 | + | server and its additional tools, edit `$ZOT_HOME/mcp.json` after adding | |
| 230 | + | the template: | |
| 231 | + | ||
| 232 | + | ```jsonc | |
| 233 | + | { | |
| 234 | + | "mcpServers": { | |
| 235 | + | "you": { | |
| 236 | + | "transport": "streamable-http", | |
| 237 | + | "url": "https://api.you.com/mcp", | |
| 238 | + | "headers": { | |
| 239 | + | "Authorization": "Bearer YOUR_YDC_API_KEY" | |
| 240 | + | } | |
| 214 | 241 | } | |
| 215 | 242 | } | |
| 216 | 243 | } | |
| 217 | 244 | ``` | |
| 218 | 245 | ||
| 246 | + | The authenticated endpoint does not expose `you-finance` by default. Request | |
| 247 | + | it explicitly with the `?tools=` URL parameter or the `X-Allowed-Tools` header. | |
| 248 | + | ||
| 219 | 249 | ## How It Works | |
| 220 | 250 | ||
| 221 | 251 | ``` | |
| @@ -211,11 +211,41 @@ Standard MCP config — same as Claude Desktop, with zot-specific extensions: | |||||
| 211 | "context7": { | 211 | "context7": { | ||
| 212 | "command": "npx", | 212 | "command": "npx", | ||
| 213 | "args": ["-y", "@upstash/context7-mcp@latest"] | 213 | "args": ["-y", "@upstash/context7-mcp@latest"] | ||
| 214 | + | }, | |||
| 215 | + | ||||
| 216 | + | // You.com web search (streamable-http, keyless) | |||
| 217 | + | "you": { | |||
| 218 | + | "transport": "streamable-http", | |||
| 219 | + | "url": "https://api.you.com/mcp?profile=free" | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ``` | |||
| 224 | + | ||||
| 225 | + | ### You.com template | |||
| 226 | + | ||||
| 227 | + | `/mcp setup add you` registers the keyless You.com MCP profile | |||
| 228 | + | (`you-search`). It needs no account or API key. To use the authenticated | |||
| 229 | + | server and its additional tools, edit `$ZOT_HOME/mcp.json` after adding | |||
| 230 | + | the template: | |||
| 231 | + | ||||
| 232 | + | ```jsonc | |||
| 233 | + | { | |||
| 234 | + | "mcpServers": { | |||
| 235 | + | "you": { | |||
| 236 | + | "transport": "streamable-http", | |||
| 237 | + | "url": "https://api.you.com/mcp", | |||
| 238 | + | "headers": { | |||
| 239 | + | "Authorization": "Bearer YOUR_YDC_API_KEY" | |||
| 240 | + | } | |||
| 214 | } | 241 | } | ||
| 215 | } | 242 | } | ||
| 216 | } | 243 | } | ||
| 217 | ``` | 244 | ``` | ||
| 218 | 245 | ||||
| 246 | + | The authenticated endpoint does not expose `you-finance` by default. Request | |||
| 247 | + | it explicitly with the `?tools=` URL parameter or the `X-Allowed-Tools` header. | |||
| 248 | + | ||||
| 219 | ## How It Works | 249 | ## How It Works | ||
| 220 | 250 | ||||
| 221 | ``` | 251 | ``` | ||
M examples/extensions/mcp-bridge/setup.go +10 -0
| @@ -62,6 +62,16 @@ func setupTemplates() map[string]serverTemplate { | |||
| 62 | 62 | } | |
| 63 | 63 | }, | |
| 64 | 64 | }, | |
| 65 | + | "you": { | |
| 66 | + | Name: "you", | |
| 67 | + | Description: "Current web search via the keyless You.com MCP server (you-search). Add an Authorization Bearer header with a YDC_API_KEY for additional authenticated tools.", | |
| 68 | + | Config: func(cwd string) ServerConfig { | |
| 69 | + | return ServerConfig{ | |
| 70 | + | Transport: "streamable-http", | |
| 71 | + | URL: "https://api.you.com/mcp?profile=free", | |
| 72 | + | } | |
| 73 | + | }, | |
| 74 | + | }, | |
| 65 | 75 | } | |
| 66 | 76 | } | |
| 67 | 77 | ||
| @@ -62,6 +62,16 @@ func setupTemplates() map[string]serverTemplate { | |||||
| 62 | } | 62 | } | ||
| 63 | }, | 63 | }, | ||
| 64 | }, | 64 | }, | ||
| 65 | + | "you": { | |||
| 66 | + | Name: "you", | |||
| 67 | + | Description: "Current web search via the keyless You.com MCP server (you-search). Add an Authorization Bearer header with a YDC_API_KEY for additional authenticated tools.", | |||
| 68 | + | Config: func(cwd string) ServerConfig { | |||
| 69 | + | return ServerConfig{ | |||
| 70 | + | Transport: "streamable-http", | |||
| 71 | + | URL: "https://api.you.com/mcp?profile=free", | |||
| 72 | + | } | |||
| 73 | + | }, | |||
| 74 | + | }, | |||
| 65 | } | 75 | } | ||
| 66 | } | 76 | } | ||
| 67 | 77 | ||||
M examples/extensions/mcp-bridge/setup_test.go +29 -0
| @@ -58,6 +58,35 @@ func TestHandleSetupAddFilesystemProject(t *testing.T) { | |||
| 58 | 58 | } | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | func TestHandleSetupAddYouGlobal(t *testing.T) { | |
| 62 | + | tmp := t.TempDir() | |
| 63 | + | t.Setenv("ZOT_HOME", tmp) | |
| 64 | + | ||
| 65 | + | out, err := handleSetup([]string{"add", "you"}, tmp) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatalf("handleSetup add you: %v", err) | |
| 68 | + | } | |
| 69 | + | if out == "" { | |
| 70 | + | t.Fatal("expected setup output") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | data, err := os.ReadFile(filepath.Join(tmp, "mcp.json")) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatalf("read mcp.json: %v", err) | |
| 76 | + | } | |
| 77 | + | var cfg Config | |
| 78 | + | if err := json.Unmarshal(data, &cfg); err != nil { | |
| 79 | + | t.Fatalf("parse mcp.json: %v", err) | |
| 80 | + | } | |
| 81 | + | you := cfg.MCPServers["you"] | |
| 82 | + | if you.Transport != "streamable-http" || you.URL != "https://api.you.com/mcp?profile=free" { | |
| 83 | + | t.Fatalf("unexpected you config: %+v", you) | |
| 84 | + | } | |
| 85 | + | if len(you.Headers) != 0 { | |
| 86 | + | t.Fatalf("keyless template must not write auth headers: %+v", you.Headers) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 61 | 90 | func TestHandleSetupDuplicate(t *testing.T) { | |
| 62 | 91 | tmp := t.TempDir() | |
| 63 | 92 | t.Setenv("ZOT_HOME", tmp) | |
| @@ -58,6 +58,35 @@ func TestHandleSetupAddFilesystemProject(t *testing.T) { | |||||
| 58 | } | 58 | } | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | + | func TestHandleSetupAddYouGlobal(t *testing.T) { | |||
| 62 | + | tmp := t.TempDir() | |||
| 63 | + | t.Setenv("ZOT_HOME", tmp) | |||
| 64 | + | ||||
| 65 | + | out, err := handleSetup([]string{"add", "you"}, tmp) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatalf("handleSetup add you: %v", err) | |||
| 68 | + | } | |||
| 69 | + | if out == "" { | |||
| 70 | + | t.Fatal("expected setup output") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | data, err := os.ReadFile(filepath.Join(tmp, "mcp.json")) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatalf("read mcp.json: %v", err) | |||
| 76 | + | } | |||
| 77 | + | var cfg Config | |||
| 78 | + | if err := json.Unmarshal(data, &cfg); err != nil { | |||
| 79 | + | t.Fatalf("parse mcp.json: %v", err) | |||
| 80 | + | } | |||
| 81 | + | you := cfg.MCPServers["you"] | |||
| 82 | + | if you.Transport != "streamable-http" || you.URL != "https://api.you.com/mcp?profile=free" { | |||
| 83 | + | t.Fatalf("unexpected you config: %+v", you) | |||
| 84 | + | } | |||
| 85 | + | if len(you.Headers) != 0 { | |||
| 86 | + | t.Fatalf("keyless template must not write auth headers: %+v", you.Headers) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 61 | func TestHandleSetupDuplicate(t *testing.T) { | 90 | func TestHandleSetupDuplicate(t *testing.T) { | ||
| 62 | tmp := t.TempDir() | 91 | tmp := t.TempDir() | ||
| 63 | t.Setenv("ZOT_HOME", tmp) | 92 | t.Setenv("ZOT_HOME", tmp) | ||
M packages/agent/modes/interactive.go +36 -0
| @@ -2228,6 +2228,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2228 | 2228 | if act.Select { | |
| 2229 | 2229 | i.applySessionSelection(act.Path) | |
| 2230 | 2230 | } | |
| 2231 | + | if act.Delete { | |
| 2232 | + | i.applySessionDeletion(act.Path) | |
| 2233 | + | } | |
| 2231 | 2234 | // Always request a redraw after handling a key here: when esc | |
| 2232 | 2235 | // closes the picker, the overlay-close detection in the render | |
| 2233 | 2236 | // pass needs to run so the tall dialog rows get repainted from | |
| @@ -5172,6 +5175,39 @@ func totalTurnsLocked(msgs []provider.Message) int { | |||
| 5172 | 5175 | return n | |
| 5173 | 5176 | } | |
| 5174 | 5177 | ||
| 5178 | + | // applySessionDeletion permanently removes a session selected in /sessions. | |
| 5179 | + | // The active session is protected because unlinking its open file would lose | |
| 5180 | + | // subsequent transcript writes on Unix and behaves differently on Windows. | |
| 5181 | + | func (i *Interactive) applySessionDeletion(path string) { | |
| 5182 | + | if i.cfg.CurrentSessionPath == nil { | |
| 5183 | + | i.mu.Lock() | |
| 5184 | + | i.statusErr = "session deletion is not wired in this build" | |
| 5185 | + | i.statusOK = "" | |
| 5186 | + | i.mu.Unlock() | |
| 5187 | + | return | |
| 5188 | + | } | |
| 5189 | + | current := i.cfg.CurrentSessionPath() | |
| 5190 | + | if current != "" && filepath.Clean(current) == filepath.Clean(path) { | |
| 5191 | + | i.mu.Lock() | |
| 5192 | + | i.statusErr = "cannot delete the active session; resume another session first" | |
| 5193 | + | i.statusOK = "" | |
| 5194 | + | i.mu.Unlock() | |
| 5195 | + | return | |
| 5196 | + | } | |
| 5197 | + | if err := core.DeleteSession(path); err != nil { | |
| 5198 | + | i.mu.Lock() | |
| 5199 | + | i.statusErr = "delete session: " + err.Error() | |
| 5200 | + | i.statusOK = "" | |
| 5201 | + | i.mu.Unlock() | |
| 5202 | + | return | |
| 5203 | + | } | |
| 5204 | + | i.sessionDialog.Remove(path) | |
| 5205 | + | i.mu.Lock() | |
| 5206 | + | i.statusOK = "deleted session: " + friendlyPath(path) | |
| 5207 | + | i.statusErr = "" | |
| 5208 | + | i.mu.Unlock() | |
| 5209 | + | } | |
| 5210 | + | ||
| 5175 | 5211 | // applySessionSelection loads the given session via the cli-provided | |
| 5176 | 5212 | // callback and snaps the viewport to the bottom (the latest message) | |
| 5177 | 5213 | // so the user lands at the live tail of the resumed conversation. | |
| @@ -2228,6 +2228,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2228 | if act.Select { | 2228 | if act.Select { | ||
| 2229 | i.applySessionSelection(act.Path) | 2229 | i.applySessionSelection(act.Path) | ||
| 2230 | } | 2230 | } | ||
| 2231 | + | if act.Delete { | |||
| 2232 | + | i.applySessionDeletion(act.Path) | |||
| 2233 | + | } | |||
| 2231 | // Always request a redraw after handling a key here: when esc | 2234 | // Always request a redraw after handling a key here: when esc | ||
| 2232 | // closes the picker, the overlay-close detection in the render | 2235 | // closes the picker, the overlay-close detection in the render | ||
| 2233 | // pass needs to run so the tall dialog rows get repainted from | 2236 | // pass needs to run so the tall dialog rows get repainted from | ||
| @@ -5172,6 +5175,39 @@ func totalTurnsLocked(msgs []provider.Message) int { | |||||
| 5172 | return n | 5175 | return n | ||
| 5173 | } | 5176 | } | ||
| 5174 | 5177 | ||||
| 5178 | + | // applySessionDeletion permanently removes a session selected in /sessions. | |||
| 5179 | + | // The active session is protected because unlinking its open file would lose | |||
| 5180 | + | // subsequent transcript writes on Unix and behaves differently on Windows. | |||
| 5181 | + | func (i *Interactive) applySessionDeletion(path string) { | |||
| 5182 | + | if i.cfg.CurrentSessionPath == nil { | |||
| 5183 | + | i.mu.Lock() | |||
| 5184 | + | i.statusErr = "session deletion is not wired in this build" | |||
| 5185 | + | i.statusOK = "" | |||
| 5186 | + | i.mu.Unlock() | |||
| 5187 | + | return | |||
| 5188 | + | } | |||
| 5189 | + | current := i.cfg.CurrentSessionPath() | |||
| 5190 | + | if current != "" && filepath.Clean(current) == filepath.Clean(path) { | |||
| 5191 | + | i.mu.Lock() | |||
| 5192 | + | i.statusErr = "cannot delete the active session; resume another session first" | |||
| 5193 | + | i.statusOK = "" | |||
| 5194 | + | i.mu.Unlock() | |||
| 5195 | + | return | |||
| 5196 | + | } | |||
| 5197 | + | if err := core.DeleteSession(path); err != nil { | |||
| 5198 | + | i.mu.Lock() | |||
| 5199 | + | i.statusErr = "delete session: " + err.Error() | |||
| 5200 | + | i.statusOK = "" | |||
| 5201 | + | i.mu.Unlock() | |||
| 5202 | + | return | |||
| 5203 | + | } | |||
| 5204 | + | i.sessionDialog.Remove(path) | |||
| 5205 | + | i.mu.Lock() | |||
| 5206 | + | i.statusOK = "deleted session: " + friendlyPath(path) | |||
| 5207 | + | i.statusErr = "" | |||
| 5208 | + | i.mu.Unlock() | |||
| 5209 | + | } | |||
| 5210 | + | ||||
| 5175 | // applySessionSelection loads the given session via the cli-provided | 5211 | // applySessionSelection loads the given session via the cli-provided | ||
| 5176 | // callback and snaps the viewport to the bottom (the latest message) | 5212 | // callback and snaps the viewport to the bottom (the latest message) | ||
| 5177 | // so the user lands at the live tail of the resumed conversation. | 5213 | // so the user lands at the live tail of the resumed conversation. | ||
M packages/agent/modes/session_dialog.go +60 -3
| @@ -16,6 +16,7 @@ type sessionDialog struct { | |||
| 16 | 16 | cursor int | |
| 17 | 17 | renaming bool | |
| 18 | 18 | rename string | |
| 19 | + | deleting bool | |
| 19 | 20 | ||
| 20 | 21 | // MaxRows is the maximum number of session rows the dialog | |
| 21 | 22 | // will render in a single frame. Set by the host right before | |
| @@ -35,6 +36,7 @@ type sessionDialog struct { | |||
| 35 | 36 | // sessionDialogAction is returned by HandleKey. | |
| 36 | 37 | type sessionDialogAction struct { | |
| 37 | 38 | Select bool | |
| 39 | + | Delete bool | |
| 38 | 40 | Path string | |
| 39 | 41 | Close bool | |
| 40 | 42 | Renamed bool | |
| @@ -59,6 +61,9 @@ func (d *sessionDialog) Open(root, cwd string) { | |||
| 59 | 61 | d.sessions = filtered | |
| 60 | 62 | d.cursor = 0 | |
| 61 | 63 | d.viewTop = 0 | |
| 64 | + | d.renaming = false | |
| 65 | + | d.rename = "" | |
| 66 | + | d.deleting = false | |
| 62 | 67 | d.active = true | |
| 63 | 68 | } | |
| 64 | 69 | ||
| @@ -73,8 +78,13 @@ func (d *sessionDialog) CursorPos() (row, col int) { | |||
| 73 | 78 | return 3, 4 + len([]rune(d.rename)) | |
| 74 | 79 | } | |
| 75 | 80 | ||
| 76 | - | // Close hides the dialog. | |
| 77 | - | func (d *sessionDialog) Close() { d.active = false } | |
| 81 | + | // Close hides the dialog and abandons any pending edit or deletion. | |
| 82 | + | func (d *sessionDialog) Close() { | |
| 83 | + | d.active = false | |
| 84 | + | d.renaming = false | |
| 85 | + | d.rename = "" | |
| 86 | + | d.deleting = false | |
| 87 | + | } | |
| 78 | 88 | ||
| 79 | 89 | // Active reports whether the dialog is visible and consumes input. | |
| 80 | 90 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | |
| @@ -104,7 +114,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 104 | 114 | lines = append(lines, frameRule(th, width)) | |
| 105 | 115 | return lines | |
| 106 | 116 | } | |
| 107 | - | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | |
| 117 | + | if d.deleting { | |
| 118 | + | s := d.sessions[d.cursor] | |
| 119 | + | lines = append(lines, th.FG256(th.Muted, "permanently delete this session?")) | |
| 120 | + | lines = append(lines, " "+formatSessionRowPlain(s, width-2)) | |
| 121 | + | lines = append(lines, th.FG256(th.Muted, "press y to delete, enter/esc to cancel")) | |
| 122 | + | lines = append(lines, frameRule(th, width)) | |
| 123 | + | return lines | |
| 124 | + | } | |
| 125 | + | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, d delete, esc cancel)")) | |
| 108 | 126 | ||
| 109 | 127 | // Viewport: windowed slice of d.sessions around d.cursor so a | |
| 110 | 128 | // list taller than the terminal still scrolls. Caller sets | |
| @@ -235,6 +253,23 @@ func formatRelative(t time.Time) string { | |||
| 235 | 253 | ||
| 236 | 254 | // HandleKey advances the dialog and returns an action to apply, if any. | |
| 237 | 255 | func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |
| 256 | + | if d.deleting { | |
| 257 | + | switch k.Kind { | |
| 258 | + | case tui.KeyRune: | |
| 259 | + | if (k.Rune == 'y' || k.Rune == 'Y') && d.cursor < len(d.sessions) { | |
| 260 | + | path := d.sessions[d.cursor].Path | |
| 261 | + | d.deleting = false | |
| 262 | + | return sessionDialogAction{Delete: true, Path: path} | |
| 263 | + | } | |
| 264 | + | if k.Rune == 'n' || k.Rune == 'N' { | |
| 265 | + | d.deleting = false | |
| 266 | + | } | |
| 267 | + | case tui.KeyEnter, tui.KeyEsc: | |
| 268 | + | d.deleting = false | |
| 269 | + | } | |
| 270 | + | return sessionDialogAction{} | |
| 271 | + | } | |
| 272 | + | ||
| 238 | 273 | // Rename mode: type the new name. | |
| 239 | 274 | if d.renaming { | |
| 240 | 275 | switch k.Kind { | |
| @@ -327,6 +362,28 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 327 | 362 | } | |
| 328 | 363 | return sessionDialogAction{} | |
| 329 | 364 | } | |
| 365 | + | if k.Rune == 'd' && len(d.sessions) > 0 { | |
| 366 | + | d.deleting = true | |
| 367 | + | } | |
| 330 | 368 | } | |
| 331 | 369 | return sessionDialogAction{} | |
| 332 | 370 | } | |
| 371 | + | ||
| 372 | + | // Remove deletes path from the visible rows after the host has removed the | |
| 373 | + | // corresponding file. The next row stays selected when possible. | |
| 374 | + | func (d *sessionDialog) Remove(path string) { | |
| 375 | + | for idx := range d.sessions { | |
| 376 | + | if d.sessions[idx].Path != path { | |
| 377 | + | continue | |
| 378 | + | } | |
| 379 | + | d.sessions = append(d.sessions[:idx], d.sessions[idx+1:]...) | |
| 380 | + | if d.cursor >= len(d.sessions) { | |
| 381 | + | d.cursor = len(d.sessions) - 1 | |
| 382 | + | } | |
| 383 | + | if d.cursor < 0 { | |
| 384 | + | d.cursor = 0 | |
| 385 | + | } | |
| 386 | + | d.viewTop = clampViewTop(d.viewTop, d.cursor, d.MaxRows, len(d.sessions)) | |
| 387 | + | return | |
| 388 | + | } | |
| 389 | + | } | |
| @@ -16,6 +16,7 @@ type sessionDialog struct { | |||||
| 16 | cursor int | 16 | cursor int | ||
| 17 | renaming bool | 17 | renaming bool | ||
| 18 | rename string | 18 | rename string | ||
| 19 | + | deleting bool | |||
| 19 | 20 | ||||
| 20 | // MaxRows is the maximum number of session rows the dialog | 21 | // MaxRows is the maximum number of session rows the dialog | ||
| 21 | // will render in a single frame. Set by the host right before | 22 | // will render in a single frame. Set by the host right before | ||
| @@ -35,6 +36,7 @@ type sessionDialog struct { | |||||
| 35 | // sessionDialogAction is returned by HandleKey. | 36 | // sessionDialogAction is returned by HandleKey. | ||
| 36 | type sessionDialogAction struct { | 37 | type sessionDialogAction struct { | ||
| 37 | Select bool | 38 | Select bool | ||
| 39 | + | Delete bool | |||
| 38 | Path string | 40 | Path string | ||
| 39 | Close bool | 41 | Close bool | ||
| 40 | Renamed bool | 42 | Renamed bool | ||
| @@ -59,6 +61,9 @@ func (d *sessionDialog) Open(root, cwd string) { | |||||
| 59 | d.sessions = filtered | 61 | d.sessions = filtered | ||
| 60 | d.cursor = 0 | 62 | d.cursor = 0 | ||
| 61 | d.viewTop = 0 | 63 | d.viewTop = 0 | ||
| 64 | + | d.renaming = false | |||
| 65 | + | d.rename = "" | |||
| 66 | + | d.deleting = false | |||
| 62 | d.active = true | 67 | d.active = true | ||
| 63 | } | 68 | } | ||
| 64 | 69 | ||||
| @@ -73,8 +78,13 @@ func (d *sessionDialog) CursorPos() (row, col int) { | |||||
| 73 | return 3, 4 + len([]rune(d.rename)) | 78 | return 3, 4 + len([]rune(d.rename)) | ||
| 74 | } | 79 | } | ||
| 75 | 80 | ||||
| 76 | - | // Close hides the dialog. | 81 | + | // Close hides the dialog and abandons any pending edit or deletion. |
| 77 | - | func (d *sessionDialog) Close() { d.active = false } | 82 | + | func (d *sessionDialog) Close() { |
| 83 | + | d.active = false | |||
| 84 | + | d.renaming = false | |||
| 85 | + | d.rename = "" | |||
| 86 | + | d.deleting = false | |||
| 87 | + | } | |||
| 78 | 88 | ||||
| 79 | // Active reports whether the dialog is visible and consumes input. | 89 | // Active reports whether the dialog is visible and consumes input. | ||
| 80 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | 90 | func (d *sessionDialog) Active() bool { return d != nil && d.active } | ||
| @@ -104,7 +114,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 104 | lines = append(lines, frameRule(th, width)) | 114 | lines = append(lines, frameRule(th, width)) | ||
| 105 | return lines | 115 | return lines | ||
| 106 | } | 116 | } | ||
| 107 | - | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | 117 | + | if d.deleting { |
| 118 | + | s := d.sessions[d.cursor] | |||
| 119 | + | lines = append(lines, th.FG256(th.Muted, "permanently delete this session?")) | |||
| 120 | + | lines = append(lines, " "+formatSessionRowPlain(s, width-2)) | |||
| 121 | + | lines = append(lines, th.FG256(th.Muted, "press y to delete, enter/esc to cancel")) | |||
| 122 | + | lines = append(lines, frameRule(th, width)) | |||
| 123 | + | return lines | |||
| 124 | + | } | |||
| 125 | + | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, d delete, esc cancel)")) | |||
| 108 | 126 | ||||
| 109 | // Viewport: windowed slice of d.sessions around d.cursor so a | 127 | // Viewport: windowed slice of d.sessions around d.cursor so a | ||
| 110 | // list taller than the terminal still scrolls. Caller sets | 128 | // list taller than the terminal still scrolls. Caller sets | ||
| @@ -235,6 +253,23 @@ func formatRelative(t time.Time) string { | |||||
| 235 | 253 | ||||
| 236 | // HandleKey advances the dialog and returns an action to apply, if any. | 254 | // HandleKey advances the dialog and returns an action to apply, if any. | ||
| 237 | func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | 255 | func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | ||
| 256 | + | if d.deleting { | |||
| 257 | + | switch k.Kind { | |||
| 258 | + | case tui.KeyRune: | |||
| 259 | + | if (k.Rune == 'y' || k.Rune == 'Y') && d.cursor < len(d.sessions) { | |||
| 260 | + | path := d.sessions[d.cursor].Path | |||
| 261 | + | d.deleting = false | |||
| 262 | + | return sessionDialogAction{Delete: true, Path: path} | |||
| 263 | + | } | |||
| 264 | + | if k.Rune == 'n' || k.Rune == 'N' { | |||
| 265 | + | d.deleting = false | |||
| 266 | + | } | |||
| 267 | + | case tui.KeyEnter, tui.KeyEsc: | |||
| 268 | + | d.deleting = false | |||
| 269 | + | } | |||
| 270 | + | return sessionDialogAction{} | |||
| 271 | + | } | |||
| 272 | + | ||||
| 238 | // Rename mode: type the new name. | 273 | // Rename mode: type the new name. | ||
| 239 | if d.renaming { | 274 | if d.renaming { | ||
| 240 | switch k.Kind { | 275 | switch k.Kind { | ||
| @@ -327,6 +362,28 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 327 | } | 362 | } | ||
| 328 | return sessionDialogAction{} | 363 | return sessionDialogAction{} | ||
| 329 | } | 364 | } | ||
| 365 | + | if k.Rune == 'd' && len(d.sessions) > 0 { | |||
| 366 | + | d.deleting = true | |||
| 367 | + | } | |||
| 330 | } | 368 | } | ||
| 331 | return sessionDialogAction{} | 369 | return sessionDialogAction{} | ||
| 332 | } | 370 | } | ||
| 371 | + | ||||
| 372 | + | // Remove deletes path from the visible rows after the host has removed the | |||
| 373 | + | // corresponding file. The next row stays selected when possible. | |||
| 374 | + | func (d *sessionDialog) Remove(path string) { | |||
| 375 | + | for idx := range d.sessions { | |||
| 376 | + | if d.sessions[idx].Path != path { | |||
| 377 | + | continue | |||
| 378 | + | } | |||
| 379 | + | d.sessions = append(d.sessions[:idx], d.sessions[idx+1:]...) | |||
| 380 | + | if d.cursor >= len(d.sessions) { | |||
| 381 | + | d.cursor = len(d.sessions) - 1 | |||
| 382 | + | } | |||
| 383 | + | if d.cursor < 0 { | |||
| 384 | + | d.cursor = 0 | |||
| 385 | + | } | |||
| 386 | + | d.viewTop = clampViewTop(d.viewTop, d.cursor, d.MaxRows, len(d.sessions)) | |||
| 387 | + | return | |||
| 388 | + | } | |||
| 389 | + | } | |||
A packages/agent/modes/session_dialog_test.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestSessionDialogDeleteRequiresConfirmation(t *testing.T) { | |
| 14 | + | d := newSessionDialog() | |
| 15 | + | d.active = true | |
| 16 | + | d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl"}} | |
| 17 | + | ||
| 18 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}); act.Delete { | |
| 19 | + | t.Fatal("delete hotkey immediately returned a delete action") | |
| 20 | + | } | |
| 21 | + | if !d.deleting { | |
| 22 | + | t.Fatal("delete hotkey did not open confirmation") | |
| 23 | + | } | |
| 24 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); act.Delete { | |
| 25 | + | t.Fatal("enter confirmed deletion; want safe default cancellation") | |
| 26 | + | } | |
| 27 | + | if d.deleting { | |
| 28 | + | t.Fatal("enter did not close deletion confirmation") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |
| 32 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'y'}) | |
| 33 | + | if !act.Delete || act.Path != "/sessions/one.jsonl" { | |
| 34 | + | t.Fatalf("confirmed action = %+v, want delete for selected path", act) | |
| 35 | + | } | |
| 36 | + | if len(d.sessions) != 1 { | |
| 37 | + | t.Fatal("dialog removed row before the host confirmed filesystem deletion") | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestSessionDialogDeleteConfirmationRender(t *testing.T) { | |
| 42 | + | d := newSessionDialog() | |
| 43 | + | d.active = true | |
| 44 | + | d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl", Title: "keep or delete"}} | |
| 45 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |
| 46 | + | ||
| 47 | + | plain := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |
| 48 | + | for _, want := range []string{"permanently delete", "keep or delete", "press y to delete"} { | |
| 49 | + | if !strings.Contains(plain, want) { | |
| 50 | + | t.Fatalf("confirmation render missing %q:\n%s", want, plain) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestSessionDialogRemoveKeepsCursorValid(t *testing.T) { | |
| 56 | + | tests := []struct { | |
| 57 | + | name string | |
| 58 | + | cursor int | |
| 59 | + | removePath string | |
| 60 | + | wantCursor int | |
| 61 | + | wantPath string | |
| 62 | + | }{ | |
| 63 | + | {name: "first", cursor: 0, removePath: "a", wantCursor: 0, wantPath: "b"}, | |
| 64 | + | {name: "middle", cursor: 1, removePath: "b", wantCursor: 1, wantPath: "c"}, | |
| 65 | + | {name: "last", cursor: 2, removePath: "c", wantCursor: 1, wantPath: "b"}, | |
| 66 | + | } | |
| 67 | + | for _, tt := range tests { | |
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |
| 69 | + | d := &sessionDialog{ | |
| 70 | + | sessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}}, | |
| 71 | + | cursor: tt.cursor, | |
| 72 | + | } | |
| 73 | + | d.Remove(tt.removePath) | |
| 74 | + | if d.cursor != tt.wantCursor { | |
| 75 | + | t.Fatalf("cursor = %d, want %d", d.cursor, tt.wantCursor) | |
| 76 | + | } | |
| 77 | + | if got := d.sessions[d.cursor].Path; got != tt.wantPath { | |
| 78 | + | t.Fatalf("selected path = %q, want %q", got, tt.wantPath) | |
| 79 | + | } | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | d := &sessionDialog{sessions: []core.SessionSummary{{Path: "only"}}} | |
| 84 | + | d.Remove("only") | |
| 85 | + | if len(d.sessions) != 0 || d.cursor != 0 { | |
| 86 | + | t.Fatalf("empty dialog has len %d and cursor %d, want 0 and 0", len(d.sessions), d.cursor) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestApplySessionDeletionRemovesFileAndRow(t *testing.T) { | |
| 91 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |
| 92 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | i := &Interactive{ | |
| 96 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }}, | |
| 97 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | i.applySessionDeletion(path) | |
| 101 | + | ||
| 102 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |
| 103 | + | t.Fatalf("deleted session still exists: %v", err) | |
| 104 | + | } | |
| 105 | + | if len(i.sessionDialog.sessions) != 0 { | |
| 106 | + | t.Fatal("deleted session remains in picker") | |
| 107 | + | } | |
| 108 | + | if !strings.Contains(i.statusOK, "deleted session") || i.statusErr != "" { | |
| 109 | + | t.Fatalf("statusOK = %q, statusErr = %q", i.statusOK, i.statusErr) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestApplySessionDeletionProtectsActiveSession(t *testing.T) { | |
| 114 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |
| 115 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |
| 116 | + | t.Fatal(err) | |
| 117 | + | } | |
| 118 | + | i := &Interactive{ | |
| 119 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return path }}, | |
| 120 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | i.applySessionDeletion(path) | |
| 124 | + | ||
| 125 | + | if _, err := os.Stat(path); err != nil { | |
| 126 | + | t.Fatalf("active session was deleted: %v", err) | |
| 127 | + | } | |
| 128 | + | if len(i.sessionDialog.sessions) != 1 { | |
| 129 | + | t.Fatal("active session was removed from picker") | |
| 130 | + | } | |
| 131 | + | if !strings.Contains(i.statusErr, "active session") { | |
| 132 | + | t.Fatalf("statusErr = %q, want active-session explanation", i.statusErr) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestApplySessionDeletionRequiresCurrentSessionWiring(t *testing.T) { | |
| 137 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |
| 138 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |
| 139 | + | t.Fatal(err) | |
| 140 | + | } | |
| 141 | + | i := &Interactive{ | |
| 142 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | i.applySessionDeletion(path) | |
| 146 | + | ||
| 147 | + | if _, err := os.Stat(path); err != nil { | |
| 148 | + | t.Fatalf("session was deleted without active-session wiring: %v", err) | |
| 149 | + | } | |
| 150 | + | if !strings.Contains(i.statusErr, "not wired") { | |
| 151 | + | t.Fatalf("statusErr = %q, want wiring error", i.statusErr) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestApplySessionDeletionPreservesRowOnError(t *testing.T) { | |
| 156 | + | path := filepath.Join(t.TempDir(), "missing.jsonl") | |
| 157 | + | i := &Interactive{ | |
| 158 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }}, | |
| 159 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | i.applySessionDeletion(path) | |
| 163 | + | ||
| 164 | + | if len(i.sessionDialog.sessions) != 1 { | |
| 165 | + | t.Fatal("failed deletion removed session from picker") | |
| 166 | + | } | |
| 167 | + | if !strings.Contains(i.statusErr, "delete session") { | |
| 168 | + | t.Fatalf("statusErr = %q, want deletion error", i.statusErr) | |
| 169 | + | } | |
| 170 | + | } | |
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestSessionDialogDeleteRequiresConfirmation(t *testing.T) { | |||
| 14 | + | d := newSessionDialog() | |||
| 15 | + | d.active = true | |||
| 16 | + | d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl"}} | |||
| 17 | + | ||||
| 18 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}); act.Delete { | |||
| 19 | + | t.Fatal("delete hotkey immediately returned a delete action") | |||
| 20 | + | } | |||
| 21 | + | if !d.deleting { | |||
| 22 | + | t.Fatal("delete hotkey did not open confirmation") | |||
| 23 | + | } | |||
| 24 | + | if act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); act.Delete { | |||
| 25 | + | t.Fatal("enter confirmed deletion; want safe default cancellation") | |||
| 26 | + | } | |||
| 27 | + | if d.deleting { | |||
| 28 | + | t.Fatal("enter did not close deletion confirmation") | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |||
| 32 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'y'}) | |||
| 33 | + | if !act.Delete || act.Path != "/sessions/one.jsonl" { | |||
| 34 | + | t.Fatalf("confirmed action = %+v, want delete for selected path", act) | |||
| 35 | + | } | |||
| 36 | + | if len(d.sessions) != 1 { | |||
| 37 | + | t.Fatal("dialog removed row before the host confirmed filesystem deletion") | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestSessionDialogDeleteConfirmationRender(t *testing.T) { | |||
| 42 | + | d := newSessionDialog() | |||
| 43 | + | d.active = true | |||
| 44 | + | d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl", Title: "keep or delete"}} | |||
| 45 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |||
| 46 | + | ||||
| 47 | + | plain := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |||
| 48 | + | for _, want := range []string{"permanently delete", "keep or delete", "press y to delete"} { | |||
| 49 | + | if !strings.Contains(plain, want) { | |||
| 50 | + | t.Fatalf("confirmation render missing %q:\n%s", want, plain) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestSessionDialogRemoveKeepsCursorValid(t *testing.T) { | |||
| 56 | + | tests := []struct { | |||
| 57 | + | name string | |||
| 58 | + | cursor int | |||
| 59 | + | removePath string | |||
| 60 | + | wantCursor int | |||
| 61 | + | wantPath string | |||
| 62 | + | }{ | |||
| 63 | + | {name: "first", cursor: 0, removePath: "a", wantCursor: 0, wantPath: "b"}, | |||
| 64 | + | {name: "middle", cursor: 1, removePath: "b", wantCursor: 1, wantPath: "c"}, | |||
| 65 | + | {name: "last", cursor: 2, removePath: "c", wantCursor: 1, wantPath: "b"}, | |||
| 66 | + | } | |||
| 67 | + | for _, tt := range tests { | |||
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 69 | + | d := &sessionDialog{ | |||
| 70 | + | sessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}}, | |||
| 71 | + | cursor: tt.cursor, | |||
| 72 | + | } | |||
| 73 | + | d.Remove(tt.removePath) | |||
| 74 | + | if d.cursor != tt.wantCursor { | |||
| 75 | + | t.Fatalf("cursor = %d, want %d", d.cursor, tt.wantCursor) | |||
| 76 | + | } | |||
| 77 | + | if got := d.sessions[d.cursor].Path; got != tt.wantPath { | |||
| 78 | + | t.Fatalf("selected path = %q, want %q", got, tt.wantPath) | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | d := &sessionDialog{sessions: []core.SessionSummary{{Path: "only"}}} | |||
| 84 | + | d.Remove("only") | |||
| 85 | + | if len(d.sessions) != 0 || d.cursor != 0 { | |||
| 86 | + | t.Fatalf("empty dialog has len %d and cursor %d, want 0 and 0", len(d.sessions), d.cursor) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestApplySessionDeletionRemovesFileAndRow(t *testing.T) { | |||
| 91 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |||
| 92 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | i := &Interactive{ | |||
| 96 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }}, | |||
| 97 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | i.applySessionDeletion(path) | |||
| 101 | + | ||||
| 102 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |||
| 103 | + | t.Fatalf("deleted session still exists: %v", err) | |||
| 104 | + | } | |||
| 105 | + | if len(i.sessionDialog.sessions) != 0 { | |||
| 106 | + | t.Fatal("deleted session remains in picker") | |||
| 107 | + | } | |||
| 108 | + | if !strings.Contains(i.statusOK, "deleted session") || i.statusErr != "" { | |||
| 109 | + | t.Fatalf("statusOK = %q, statusErr = %q", i.statusOK, i.statusErr) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestApplySessionDeletionProtectsActiveSession(t *testing.T) { | |||
| 114 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |||
| 115 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |||
| 116 | + | t.Fatal(err) | |||
| 117 | + | } | |||
| 118 | + | i := &Interactive{ | |||
| 119 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return path }}, | |||
| 120 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | i.applySessionDeletion(path) | |||
| 124 | + | ||||
| 125 | + | if _, err := os.Stat(path); err != nil { | |||
| 126 | + | t.Fatalf("active session was deleted: %v", err) | |||
| 127 | + | } | |||
| 128 | + | if len(i.sessionDialog.sessions) != 1 { | |||
| 129 | + | t.Fatal("active session was removed from picker") | |||
| 130 | + | } | |||
| 131 | + | if !strings.Contains(i.statusErr, "active session") { | |||
| 132 | + | t.Fatalf("statusErr = %q, want active-session explanation", i.statusErr) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestApplySessionDeletionRequiresCurrentSessionWiring(t *testing.T) { | |||
| 137 | + | path := filepath.Join(t.TempDir(), "session.jsonl") | |||
| 138 | + | if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { | |||
| 139 | + | t.Fatal(err) | |||
| 140 | + | } | |||
| 141 | + | i := &Interactive{ | |||
| 142 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | i.applySessionDeletion(path) | |||
| 146 | + | ||||
| 147 | + | if _, err := os.Stat(path); err != nil { | |||
| 148 | + | t.Fatalf("session was deleted without active-session wiring: %v", err) | |||
| 149 | + | } | |||
| 150 | + | if !strings.Contains(i.statusErr, "not wired") { | |||
| 151 | + | t.Fatalf("statusErr = %q, want wiring error", i.statusErr) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestApplySessionDeletionPreservesRowOnError(t *testing.T) { | |||
| 156 | + | path := filepath.Join(t.TempDir(), "missing.jsonl") | |||
| 157 | + | i := &Interactive{ | |||
| 158 | + | cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }}, | |||
| 159 | + | sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | i.applySessionDeletion(path) | |||
| 163 | + | ||||
| 164 | + | if len(i.sessionDialog.sessions) != 1 { | |||
| 165 | + | t.Fatal("failed deletion removed session from picker") | |||
| 166 | + | } | |||
| 167 | + | if !strings.Contains(i.statusErr, "delete session") { | |||
| 168 | + | t.Fatalf("statusErr = %q, want deletion error", i.statusErr) | |||
| 169 | + | } | |||
| 170 | + | } | |||
M packages/provider/anthropic.go +1 -1
| @@ -21,7 +21,7 @@ const anthropicAPIVersion = "2023-06-01" | |||
| 21 | 21 | // accepts the request; diverging from them causes 429 rate_limit_error | |
| 22 | 22 | // or 403 on the very first request. | |
| 23 | 23 | const ( | |
| 24 | - | claudeCodeVersion = "2.1.75" | |
| 24 | + | claudeCodeVersion = "2.1.258" | |
| 25 | 25 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | |
| 26 | 26 | ) | |
| 27 | 27 | ||
| @@ -21,7 +21,7 @@ const anthropicAPIVersion = "2023-06-01" | |||||
| 21 | // accepts the request; diverging from them causes 429 rate_limit_error | 21 | // accepts the request; diverging from them causes 429 rate_limit_error | ||
| 22 | // or 403 on the very first request. | 22 | // or 403 on the very first request. | ||
| 23 | const ( | 23 | const ( | ||
| 24 | - | claudeCodeVersion = "2.1.75" | 24 | + | claudeCodeVersion = "2.1.258" |
| 25 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | 25 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | ||
| 26 | ) | 26 | ) | ||
| 27 | 27 | ||||
M packages/provider/models.go +7 -3
| @@ -272,7 +272,7 @@ var Catalog = []Model{ | |||
| 272 | 272 | BaseURL: gondolaDefaultBaseURL, | |
| 273 | 273 | }, | |
| 274 | 274 | ||
| 275 | - | // ---- Speculative: Anthropic ---- | |
| 275 | + | // ---- Anthropic / newer families ---- | |
| 276 | 276 | { | |
| 277 | 277 | Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)", | |
| 278 | 278 | ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, | |
| @@ -316,8 +316,12 @@ var Catalog = []Model{ | |||
| 316 | 316 | { | |
| 317 | 317 | Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5", | |
| 318 | 318 | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, | |
| 319 | - | PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, | |
| 320 | - | Speculative: true, | |
| 319 | + | PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, | |
| 320 | + | }, | |
| 321 | + | { | |
| 322 | + | Provider: "anthropic", ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", | |
| 323 | + | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, | |
| 324 | + | PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.25, PriceCacheWrite: 12.5, | |
| 321 | 325 | }, | |
| 322 | 326 | ||
| 323 | 327 | // ---- Speculative: OpenAI ---- | |
| @@ -272,7 +272,7 @@ var Catalog = []Model{ | |||||
| 272 | BaseURL: gondolaDefaultBaseURL, | 272 | BaseURL: gondolaDefaultBaseURL, | ||
| 273 | }, | 273 | }, | ||
| 274 | 274 | ||||
| 275 | - | // ---- Speculative: Anthropic ---- | 275 | + | // ---- Anthropic / newer families ---- |
| 276 | { | 276 | { | ||
| 277 | Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)", | 277 | Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)", | ||
| 278 | ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, | 278 | ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, | ||
| @@ -316,8 +316,12 @@ var Catalog = []Model{ | |||||
| 316 | { | 316 | { | ||
| 317 | Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5", | 317 | Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5", | ||
| 318 | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, | 318 | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, | ||
| 319 | - | PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, | 319 | + | PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, |
| 320 | - | Speculative: true, | 320 | + | }, |
| 321 | + | { | |||
| 322 | + | Provider: "anthropic", ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", | |||
| 323 | + | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, | |||
| 324 | + | PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.25, PriceCacheWrite: 12.5, | |||
| 321 | }, | 325 | }, | ||
| 322 | 326 | ||||
| 323 | // ---- Speculative: OpenAI ---- | 327 | // ---- Speculative: OpenAI ---- | ||
M packages/provider/provider_test.go +59 -0
| @@ -96,6 +96,24 @@ func TestAnthropicErrorStatus(t *testing.T) { | |||
| 96 | 96 | } | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | func TestAnthropicOAuthUsesCurrentClaudeCodeVersion(t *testing.T) { | |
| 100 | + | var userAgent string | |
| 101 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 102 | + | userAgent = r.Header.Get("user-agent") | |
| 103 | + | w.WriteHeader(http.StatusBadRequest) | |
| 104 | + | })) | |
| 105 | + | defer srv.Close() | |
| 106 | + | ||
| 107 | + | c := NewAnthropicOAuth("test-token", srv.URL) | |
| 108 | + | _, err := c.Stream(context.Background(), Request{Model: "claude-fable-5-1"}) | |
| 109 | + | if err == nil { | |
| 110 | + | t.Fatal("expected test server error") | |
| 111 | + | } | |
| 112 | + | if want := "claude-cli/2.1.258"; userAgent != want { | |
| 113 | + | t.Fatalf("user-agent = %q, want %q", userAgent, want) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 99 | 117 | func TestOpenAIErrorStatus(t *testing.T) { | |
| 100 | 118 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 101 | 119 | w.WriteHeader(http.StatusBadRequest) | |
| @@ -382,6 +400,47 @@ func TestClaudeSonnet5Catalog(t *testing.T) { | |||
| 382 | 400 | } | |
| 383 | 401 | } | |
| 384 | 402 | ||
| 403 | + | func TestClaudeFable5Catalog(t *testing.T) { | |
| 404 | + | m, err := FindModel("anthropic", "claude-fable-5") | |
| 405 | + | if err != nil { | |
| 406 | + | t.Fatal(err) | |
| 407 | + | } | |
| 408 | + | if m.DisplayName != "Claude Fable 5" || m.ContextWindow != 1000000 || m.MaxOutput != 128000 || !m.Reasoning || !m.AdaptiveThinking || m.Speculative { | |
| 409 | + | t.Fatalf("unexpected Fable 5 model: %+v", m) | |
| 410 | + | } | |
| 411 | + | if m.PriceInput != 10 || m.PriceOutput != 50 || m.PriceCacheRead != 1 || m.PriceCacheWrite != 12.5 { | |
| 412 | + | t.Fatalf("unexpected Fable 5 pricing: %+v", m) | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func TestClaudeFable51Catalog(t *testing.T) { | |
| 417 | + | m, err := FindModel("anthropic", "claude-fable-5-1") | |
| 418 | + | if err != nil { | |
| 419 | + | t.Fatal(err) | |
| 420 | + | } | |
| 421 | + | if m.DisplayName != "Claude Fable 5.1" || m.ContextWindow != 1000000 || m.MaxOutput != 128000 || !m.Reasoning || !m.AdaptiveThinking || m.Speculative { | |
| 422 | + | t.Fatalf("unexpected Fable 5.1 model: %+v", m) | |
| 423 | + | } | |
| 424 | + | if m.PriceInput != 10 || m.PriceOutput != 50 || m.PriceCacheRead != 0.25 || m.PriceCacheWrite != 12.5 { | |
| 425 | + | t.Fatalf("unexpected Fable 5.1 pricing: %+v", m) | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | wire, err := NewAnthropic("x", "").(*anthropicClient).buildRequest(Request{ | |
| 429 | + | Model: m.ID, | |
| 430 | + | Reasoning: "max", | |
| 431 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 432 | + | }) | |
| 433 | + | if err != nil { | |
| 434 | + | t.Fatal(err) | |
| 435 | + | } | |
| 436 | + | if wire.Thinking == nil || wire.Thinking.Type != "adaptive" || wire.Thinking.BudgetTokens != 0 { | |
| 437 | + | t.Fatalf("unexpected Fable 5.1 thinking config: %+v", wire.Thinking) | |
| 438 | + | } | |
| 439 | + | if wire.OutputConfig == nil || wire.OutputConfig.Effort != "max" { | |
| 440 | + | t.Fatalf("unexpected Fable 5.1 output config: %+v", wire.OutputConfig) | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | ||
| 385 | 444 | func TestGondolaSeedCatalog(t *testing.T) { | |
| 386 | 445 | kimi, err := FindModel("gondola", "kimi-k3") | |
| 387 | 446 | if err != nil { | |
| @@ -96,6 +96,24 @@ func TestAnthropicErrorStatus(t *testing.T) { | |||||
| 96 | } | 96 | } | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | func TestAnthropicOAuthUsesCurrentClaudeCodeVersion(t *testing.T) { | |||
| 100 | + | var userAgent string | |||
| 101 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 102 | + | userAgent = r.Header.Get("user-agent") | |||
| 103 | + | w.WriteHeader(http.StatusBadRequest) | |||
| 104 | + | })) | |||
| 105 | + | defer srv.Close() | |||
| 106 | + | ||||
| 107 | + | c := NewAnthropicOAuth("test-token", srv.URL) | |||
| 108 | + | _, err := c.Stream(context.Background(), Request{Model: "claude-fable-5-1"}) | |||
| 109 | + | if err == nil { | |||
| 110 | + | t.Fatal("expected test server error") | |||
| 111 | + | } | |||
| 112 | + | if want := "claude-cli/2.1.258"; userAgent != want { | |||
| 113 | + | t.Fatalf("user-agent = %q, want %q", userAgent, want) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 99 | func TestOpenAIErrorStatus(t *testing.T) { | 117 | func TestOpenAIErrorStatus(t *testing.T) { | ||
| 100 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 118 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 101 | w.WriteHeader(http.StatusBadRequest) | 119 | w.WriteHeader(http.StatusBadRequest) | ||
| @@ -382,6 +400,47 @@ func TestClaudeSonnet5Catalog(t *testing.T) { | |||||
| 382 | } | 400 | } | ||
| 383 | } | 401 | } | ||
| 384 | 402 | ||||
| 403 | + | func TestClaudeFable5Catalog(t *testing.T) { | |||
| 404 | + | m, err := FindModel("anthropic", "claude-fable-5") | |||
| 405 | + | if err != nil { | |||
| 406 | + | t.Fatal(err) | |||
| 407 | + | } | |||
| 408 | + | if m.DisplayName != "Claude Fable 5" || m.ContextWindow != 1000000 || m.MaxOutput != 128000 || !m.Reasoning || !m.AdaptiveThinking || m.Speculative { | |||
| 409 | + | t.Fatalf("unexpected Fable 5 model: %+v", m) | |||
| 410 | + | } | |||
| 411 | + | if m.PriceInput != 10 || m.PriceOutput != 50 || m.PriceCacheRead != 1 || m.PriceCacheWrite != 12.5 { | |||
| 412 | + | t.Fatalf("unexpected Fable 5 pricing: %+v", m) | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func TestClaudeFable51Catalog(t *testing.T) { | |||
| 417 | + | m, err := FindModel("anthropic", "claude-fable-5-1") | |||
| 418 | + | if err != nil { | |||
| 419 | + | t.Fatal(err) | |||
| 420 | + | } | |||
| 421 | + | if m.DisplayName != "Claude Fable 5.1" || m.ContextWindow != 1000000 || m.MaxOutput != 128000 || !m.Reasoning || !m.AdaptiveThinking || m.Speculative { | |||
| 422 | + | t.Fatalf("unexpected Fable 5.1 model: %+v", m) | |||
| 423 | + | } | |||
| 424 | + | if m.PriceInput != 10 || m.PriceOutput != 50 || m.PriceCacheRead != 0.25 || m.PriceCacheWrite != 12.5 { | |||
| 425 | + | t.Fatalf("unexpected Fable 5.1 pricing: %+v", m) | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | wire, err := NewAnthropic("x", "").(*anthropicClient).buildRequest(Request{ | |||
| 429 | + | Model: m.ID, | |||
| 430 | + | Reasoning: "max", | |||
| 431 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |||
| 432 | + | }) | |||
| 433 | + | if err != nil { | |||
| 434 | + | t.Fatal(err) | |||
| 435 | + | } | |||
| 436 | + | if wire.Thinking == nil || wire.Thinking.Type != "adaptive" || wire.Thinking.BudgetTokens != 0 { | |||
| 437 | + | t.Fatalf("unexpected Fable 5.1 thinking config: %+v", wire.Thinking) | |||
| 438 | + | } | |||
| 439 | + | if wire.OutputConfig == nil || wire.OutputConfig.Effort != "max" { | |||
| 440 | + | t.Fatalf("unexpected Fable 5.1 output config: %+v", wire.OutputConfig) | |||
| 441 | + | } | |||
| 442 | + | } | |||
| 443 | + | ||||
| 385 | func TestGondolaSeedCatalog(t *testing.T) { | 444 | func TestGondolaSeedCatalog(t *testing.T) { | ||
| 386 | kimi, err := FindModel("gondola", "kimi-k3") | 445 | kimi, err := FindModel("gondola", "kimi-k3") | ||
| 387 | if err != nil { | 446 | if err != nil { | ||
M packages/tui/render.go +24 -9
| @@ -583,7 +583,18 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 583 | 583 | // real previous bg-colored row. | |
| 584 | 584 | ||
| 585 | 585 | wasInitialized := r.logInit | |
| 586 | - | full := !wasInitialized || len(r.logLines) == 0 | |
| 586 | + | bottomShrunkAboveViewport := false | |
| 587 | + | if len(bottomFrame) < len(r.logBottom) { | |
| 588 | + | newViewportTop := len(lines) - r.rows | |
| 589 | + | if newViewportTop < 0 { | |
| 590 | + | newViewportTop = 0 | |
| 591 | + | } | |
| 592 | + | // If the shorter bottom frame starts above the currently addressable | |
| 593 | + | // viewport, relative cursor movement cannot repaint its prefix. This | |
| 594 | + | // happens when returning from a moderately long dialog to a short one. | |
| 595 | + | bottomShrunkAboveViewport = newViewportTop < r.logViewportTop | |
| 596 | + | } | |
| 597 | + | full := !wasInitialized || len(r.logLines) == 0 || bottomShrunkAboveViewport | |
| 587 | 598 | if full { | |
| 588 | 599 | writeFull(true, !wasInitialized) | |
| 589 | 600 | r.logInit = true | |
| @@ -786,10 +797,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 786 | 797 | w.WriteString(lines[idx]) | |
| 787 | 798 | } | |
| 788 | 799 | finalRow := renderEnd | |
| 800 | + | fullRepaint := false | |
| 789 | 801 | if len(r.logLines) > len(lines) { | |
| 790 | 802 | extra := len(r.logLines) - len(lines) | |
| 791 | - | if extra > r.rows { | |
| 803 | + | if extra >= r.rows { | |
| 792 | 804 | writeFull(true, false) | |
| 805 | + | fullRepaint = true | |
| 793 | 806 | } else { | |
| 794 | 807 | for e := 0; e < extra; e++ { | |
| 795 | 808 | w.WriteString("\x1b[1B") | |
| @@ -804,13 +817,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 804 | 817 | } | |
| 805 | 818 | } | |
| 806 | 819 | } | |
| 807 | - | r.logHardwareRow = finalRow | |
| 808 | - | r.logViewportTop = viewportTop | |
| 809 | - | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | |
| 810 | - | r.logViewportTop = minTop | |
| 811 | - | } | |
| 812 | - | if r.logViewportTop < 0 { | |
| 813 | - | r.logViewportTop = 0 | |
| 820 | + | if !fullRepaint { | |
| 821 | + | r.logHardwareRow = finalRow | |
| 822 | + | r.logViewportTop = viewportTop | |
| 823 | + | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | |
| 824 | + | r.logViewportTop = minTop | |
| 825 | + | } | |
| 826 | + | if r.logViewportTop < 0 { | |
| 827 | + | r.logViewportTop = 0 | |
| 828 | + | } | |
| 814 | 829 | } | |
| 815 | 830 | } | |
| 816 | 831 | } | |
| @@ -583,7 +583,18 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 583 | // real previous bg-colored row. | 583 | // real previous bg-colored row. | ||
| 584 | 584 | ||||
| 585 | wasInitialized := r.logInit | 585 | wasInitialized := r.logInit | ||
| 586 | - | full := !wasInitialized || len(r.logLines) == 0 | 586 | + | bottomShrunkAboveViewport := false |
| 587 | + | if len(bottomFrame) < len(r.logBottom) { | |||
| 588 | + | newViewportTop := len(lines) - r.rows | |||
| 589 | + | if newViewportTop < 0 { | |||
| 590 | + | newViewportTop = 0 | |||
| 591 | + | } | |||
| 592 | + | // If the shorter bottom frame starts above the currently addressable | |||
| 593 | + | // viewport, relative cursor movement cannot repaint its prefix. This | |||
| 594 | + | // happens when returning from a moderately long dialog to a short one. | |||
| 595 | + | bottomShrunkAboveViewport = newViewportTop < r.logViewportTop | |||
| 596 | + | } | |||
| 597 | + | full := !wasInitialized || len(r.logLines) == 0 || bottomShrunkAboveViewport | |||
| 587 | if full { | 598 | if full { | ||
| 588 | writeFull(true, !wasInitialized) | 599 | writeFull(true, !wasInitialized) | ||
| 589 | r.logInit = true | 600 | r.logInit = true | ||
| @@ -786,10 +797,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 786 | w.WriteString(lines[idx]) | 797 | w.WriteString(lines[idx]) | ||
| 787 | } | 798 | } | ||
| 788 | finalRow := renderEnd | 799 | finalRow := renderEnd | ||
| 800 | + | fullRepaint := false | |||
| 789 | if len(r.logLines) > len(lines) { | 801 | if len(r.logLines) > len(lines) { | ||
| 790 | extra := len(r.logLines) - len(lines) | 802 | extra := len(r.logLines) - len(lines) | ||
| 791 | - | if extra > r.rows { | 803 | + | if extra >= r.rows { |
| 792 | writeFull(true, false) | 804 | writeFull(true, false) | ||
| 805 | + | fullRepaint = true | |||
| 793 | } else { | 806 | } else { | ||
| 794 | for e := 0; e < extra; e++ { | 807 | for e := 0; e < extra; e++ { | ||
| 795 | w.WriteString("\x1b[1B") | 808 | w.WriteString("\x1b[1B") | ||
| @@ -804,13 +817,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 804 | } | 817 | } | ||
| 805 | } | 818 | } | ||
| 806 | } | 819 | } | ||
| 807 | - | r.logHardwareRow = finalRow | 820 | + | if !fullRepaint { |
| 808 | - | r.logViewportTop = viewportTop | 821 | + | r.logHardwareRow = finalRow |
| 809 | - | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | 822 | + | r.logViewportTop = viewportTop |
| 810 | - | r.logViewportTop = minTop | 823 | + | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { |
| 811 | - | } | 824 | + | r.logViewportTop = minTop |
| 812 | - | if r.logViewportTop < 0 { | 825 | + | } |
| 813 | - | r.logViewportTop = 0 | 826 | + | if r.logViewportTop < 0 { |
| 827 | + | r.logViewportTop = 0 | |||
| 828 | + | } | |||
| 814 | } | 829 | } | ||
| 815 | } | 830 | } | ||
| 816 | } | 831 | } | ||
M packages/tui/render_test.go +63 -0
| @@ -61,6 +61,69 @@ func TestDrawLogContentChangeBreaksFastPath(t *testing.T) { | |||
| 61 | 61 | } | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | func TestDrawLogLargeBottomShrinkKeepsVisibleRowsAddressable(t *testing.T) { | |
| 65 | + | var buf bytes.Buffer | |
| 66 | + | r := NewRenderer(&buf) | |
| 67 | + | r.Resize(80, 4) | |
| 68 | + | ||
| 69 | + | // A long dialog, such as a swarm transcript, scrolls beyond the viewport. | |
| 70 | + | r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0) | |
| 71 | + | buf.Reset() | |
| 72 | + | ||
| 73 | + | // Returning to the short dashboard requires a full repaint because one | |
| 74 | + | // viewport of logical rows disappeared. | |
| 75 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |
| 76 | + | buf.Reset() | |
| 77 | + | ||
| 78 | + | // The dashboard remains interactive after that repaint. A cursor move must | |
| 79 | + | // update its visible selection instead of being discarded as inaccessible. | |
| 80 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0) | |
| 81 | + | if got := buf.String(); !strings.Contains(got, "selected agent 2") { | |
| 82 | + | t.Fatalf("visible update after large dialog shrink was suppressed: %q", got) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestDrawLogPartialViewportShrinkRepaintsFromDashboardStart(t *testing.T) { | |
| 87 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 88 | + | var buf bytes.Buffer | |
| 89 | + | r := NewRenderer(&buf) | |
| 90 | + | r.Resize(80, 4) | |
| 91 | + | ||
| 92 | + | // This transcript overflows the viewport, but returning to the dashboard | |
| 93 | + | // removes fewer than one viewport of rows. | |
| 94 | + | r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0) | |
| 95 | + | buf.Reset() | |
| 96 | + | ||
| 97 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |
| 98 | + | got := buf.String() | |
| 99 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |
| 100 | + | t.Fatalf("partial viewport shrink did not repaint the screen: %q", got) | |
| 101 | + | } | |
| 102 | + | for _, want := range []string{"dashboard", "selected agent 1"} { | |
| 103 | + | if !strings.Contains(got, want) { | |
| 104 | + | t.Fatalf("partial viewport repaint missing %q: %q", want, got) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestDrawLogChatShrinkDoesNotForceDialogRepaint(t *testing.T) { | |
| 110 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 111 | + | var buf bytes.Buffer | |
| 112 | + | r := NewRenderer(&buf) | |
| 113 | + | r.Resize(80, 4) | |
| 114 | + | ||
| 115 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0) | |
| 116 | + | buf.Reset() | |
| 117 | + | ||
| 118 | + | // Chat reflow has its own coordinate-rebasing path, which preserves native | |
| 119 | + | // terminal selections. Only a shrinking bottom frame should force the | |
| 120 | + | // repaint used when returning from a transcript to the dashboard. | |
| 121 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0) | |
| 122 | + | if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) { | |
| 123 | + | t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 64 | 127 | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | |
| 65 | 128 | // (no buffer change) still produces output. Without this, typing in | |
| 66 | 129 | // the editor would visually move the caret but the terminal would | |
| @@ -61,6 +61,69 @@ func TestDrawLogContentChangeBreaksFastPath(t *testing.T) { | |||||
| 61 | } | 61 | } | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | + | func TestDrawLogLargeBottomShrinkKeepsVisibleRowsAddressable(t *testing.T) { | |||
| 65 | + | var buf bytes.Buffer | |||
| 66 | + | r := NewRenderer(&buf) | |||
| 67 | + | r.Resize(80, 4) | |||
| 68 | + | ||||
| 69 | + | // A long dialog, such as a swarm transcript, scrolls beyond the viewport. | |||
| 70 | + | r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0) | |||
| 71 | + | buf.Reset() | |||
| 72 | + | ||||
| 73 | + | // Returning to the short dashboard requires a full repaint because one | |||
| 74 | + | // viewport of logical rows disappeared. | |||
| 75 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |||
| 76 | + | buf.Reset() | |||
| 77 | + | ||||
| 78 | + | // The dashboard remains interactive after that repaint. A cursor move must | |||
| 79 | + | // update its visible selection instead of being discarded as inaccessible. | |||
| 80 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0) | |||
| 81 | + | if got := buf.String(); !strings.Contains(got, "selected agent 2") { | |||
| 82 | + | t.Fatalf("visible update after large dialog shrink was suppressed: %q", got) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestDrawLogPartialViewportShrinkRepaintsFromDashboardStart(t *testing.T) { | |||
| 87 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 88 | + | var buf bytes.Buffer | |||
| 89 | + | r := NewRenderer(&buf) | |||
| 90 | + | r.Resize(80, 4) | |||
| 91 | + | ||||
| 92 | + | // This transcript overflows the viewport, but returning to the dashboard | |||
| 93 | + | // removes fewer than one viewport of rows. | |||
| 94 | + | r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0) | |||
| 95 | + | buf.Reset() | |||
| 96 | + | ||||
| 97 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |||
| 98 | + | got := buf.String() | |||
| 99 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |||
| 100 | + | t.Fatalf("partial viewport shrink did not repaint the screen: %q", got) | |||
| 101 | + | } | |||
| 102 | + | for _, want := range []string{"dashboard", "selected agent 1"} { | |||
| 103 | + | if !strings.Contains(got, want) { | |||
| 104 | + | t.Fatalf("partial viewport repaint missing %q: %q", want, got) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestDrawLogChatShrinkDoesNotForceDialogRepaint(t *testing.T) { | |||
| 110 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 111 | + | var buf bytes.Buffer | |||
| 112 | + | r := NewRenderer(&buf) | |||
| 113 | + | r.Resize(80, 4) | |||
| 114 | + | ||||
| 115 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0) | |||
| 116 | + | buf.Reset() | |||
| 117 | + | ||||
| 118 | + | // Chat reflow has its own coordinate-rebasing path, which preserves native | |||
| 119 | + | // terminal selections. Only a shrinking bottom frame should force the | |||
| 120 | + | // repaint used when returning from a transcript to the dashboard. | |||
| 121 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0) | |||
| 122 | + | if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) { | |||
| 123 | + | t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 64 | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | 127 | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | ||
| 65 | // (no buffer change) still produces output. Without this, typing in | 128 | // (no buffer change) still produces output. Without this, typing in | ||
| 66 | // the editor would visually move the caret but the terminal would | 129 | // the editor would visually move the caret but the terminal would | ||