detached 90b3571

Merge remote-tracking branch 'origin/main'

commit
90b357180fde0759e156d07cd9ba360d1f0be257
author
DaviSM · · 2026-09-03 15:14 UTC
merge of
1bb0ce7 + 532d7f6 · diff against the first parent
signature
unsigned
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
316316
317317 ### `/sessions`
318318
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.
320320
321321 ### `/session`
322322
@@ -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:
211211 "context7": {
212212 "command": "npx",
213213 "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+ }
214241 }
215242 }
216243 }
217244 ```
218245
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+
219249 ## How It Works
220250
221251 ```
@@ -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 Works249 ## 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 {
6262 }
6363 },
6464 },
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+ },
6575 }
6676 }
6777
@@ -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) {
5858 }
5959 }
6060
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+
6190 func TestHandleSetupDuplicate(t *testing.T) {
6291 tmp := t.TempDir()
6392 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) {
22282228 if act.Select {
22292229 i.applySessionSelection(act.Path)
22302230 }
2231+ if act.Delete {
2232+ i.applySessionDeletion(act.Path)
2233+ }
22312234 // Always request a redraw after handling a key here: when esc
22322235 // closes the picker, the overlay-close detection in the render
22332236 // pass needs to run so the tall dialog rows get repainted from
@@ -5172,6 +5175,39 @@ func totalTurnsLocked(msgs []provider.Message) int {
51725175 return n
51735176 }
51745177
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+
51755211 // applySessionSelection loads the given session via the cli-provided
51765212 // callback and snaps the viewport to the bottom (the latest message)
51775213 // 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 esc2234 // Always request a redraw after handling a key here: when esc
2232 // closes the picker, the overlay-close detection in the render2235 // closes the picker, the overlay-close detection in the render
2233 // pass needs to run so the tall dialog rows get repainted from2236 // pass needs to run so the tall dialog rows get repainted from
@@ -5172,6 +5175,39 @@ func totalTurnsLocked(msgs []provider.Message) int {
5172 return n5175 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-provided5211 // 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 {
1616 cursor int
1717 renaming bool
1818 rename string
19+ deleting bool
1920
2021 // MaxRows is the maximum number of session rows the dialog
2122 // will render in a single frame. Set by the host right before
@@ -35,6 +36,7 @@ type sessionDialog struct {
3536 // sessionDialogAction is returned by HandleKey.
3637 type sessionDialogAction struct {
3738 Select bool
39+ Delete bool
3840 Path string
3941 Close bool
4042 Renamed bool
@@ -59,6 +61,9 @@ func (d *sessionDialog) Open(root, cwd string) {
5961 d.sessions = filtered
6062 d.cursor = 0
6163 d.viewTop = 0
64+ d.renaming = false
65+ d.rename = ""
66+ d.deleting = false
6267 d.active = true
6368 }
6469
@@ -73,8 +78,13 @@ func (d *sessionDialog) CursorPos() (row, col int) {
7378 return 3, 4 + len([]rune(d.rename))
7479 }
7580
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+}
7888
7989 // Active reports whether the dialog is visible and consumes input.
8090 func (d *sessionDialog) Active() bool { return d != nil && d.active }
@@ -104,7 +114,15 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
104114 lines = append(lines, frameRule(th, width))
105115 return lines
106116 }
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)"))
108126
109127 // Viewport: windowed slice of d.sessions around d.cursor so a
110128 // list taller than the terminal still scrolls. Caller sets
@@ -235,6 +253,23 @@ func formatRelative(t time.Time) string {
235253
236254 // HandleKey advances the dialog and returns an action to apply, if any.
237255 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+
238273 // Rename mode: type the new name.
239274 if d.renaming {
240275 switch k.Kind {
@@ -327,6 +362,28 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
327362 }
328363 return sessionDialogAction{}
329364 }
365+ if k.Rune == 'd' && len(d.sessions) > 0 {
366+ d.deleting = true
367+ }
330368 }
331369 return sessionDialogAction{}
332370 }
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 int16 cursor int
17 renaming bool17 renaming bool
18 rename string18 rename string
19+ deleting bool
19 20
20 // MaxRows is the maximum number of session rows the dialog21 // MaxRows is the maximum number of session rows the dialog
21 // will render in a single frame. Set by the host right before22 // 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 bool38 Select bool
39+ Delete bool
38 Path string40 Path string
39 Close bool41 Close bool
40 Renamed bool42 Renamed bool
@@ -59,6 +61,9 @@ func (d *sessionDialog) Open(root, cwd string) {
59 d.sessions = filtered61 d.sessions = filtered
60 d.cursor = 062 d.cursor = 0
61 d.viewTop = 063 d.viewTop = 0
64+ d.renaming = false
65+ d.rename = ""
66+ d.deleting = false
62 d.active = true67 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 lines115 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 a127 // Viewport: windowed slice of d.sessions around d.cursor so a
110 // list taller than the terminal still scrolls. Caller sets128 // 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"
2121 // accepts the request; diverging from them causes 429 rate_limit_error
2222 // or 403 on the very first request.
2323 const (
24- claudeCodeVersion = "2.1.75"
24+ claudeCodeVersion = "2.1.258"
2525 claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
2626 )
2727
@@ -21,7 +21,7 @@ const anthropicAPIVersion = "2023-06-01"
21 // accepts the request; diverging from them causes 429 rate_limit_error21 // 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{
272272 BaseURL: gondolaDefaultBaseURL,
273273 },
274274
275- // ---- Speculative: Anthropic ----
275+ // ---- Anthropic / newer families ----
276276 {
277277 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",
278278 ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
@@ -316,8 +316,12 @@ var Catalog = []Model{
316316 {
317317 Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5",
318318 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,
321325 },
322326
323327 // ---- 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) {
9696 }
9797 }
9898
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+
99117 func TestOpenAIErrorStatus(t *testing.T) {
100118 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101119 w.WriteHeader(http.StatusBadRequest)
@@ -382,6 +400,47 @@ func TestClaudeSonnet5Catalog(t *testing.T) {
382400 }
383401 }
384402
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+
385444 func TestGondolaSeedCatalog(t *testing.T) {
386445 kimi, err := FindModel("gondola", "kimi-k3")
387446 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
583583 // real previous bg-colored row.
584584
585585 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
587598 if full {
588599 writeFull(true, !wasInitialized)
589600 r.logInit = true
@@ -786,10 +797,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
786797 w.WriteString(lines[idx])
787798 }
788799 finalRow := renderEnd
800+ fullRepaint := false
789801 if len(r.logLines) > len(lines) {
790802 extra := len(r.logLines) - len(lines)
791- if extra > r.rows {
803+ if extra >= r.rows {
792804 writeFull(true, false)
805+ fullRepaint = true
793806 } else {
794807 for e := 0; e < extra; e++ {
795808 w.WriteString("\x1b[1B")
@@ -804,13 +817,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
804817 }
805818 }
806819 }
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+ }
814829 }
815830 }
816831 }
@@ -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.logInit585 wasInitialized := r.logInit
586- full := !wasInitialized || len(r.logLines) == 0586+ 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 = true600 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 := renderEnd799 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 = finalRow820+ if !fullRepaint {
808- r.logViewportTop = viewportTop821+ r.logHardwareRow = finalRow
809- if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {822+ r.logViewportTop = viewportTop
810- r.logViewportTop = minTop823+ if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
811- }824+ r.logViewportTop = minTop
812- if r.logViewportTop < 0 {825+ }
813- r.logViewportTop = 0826+ 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) {
6161 }
6262 }
6363
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+
64127 // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change
65128 // (no buffer change) still produces output. Without this, typing in
66129 // 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 change127 // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change
65 // (no buffer change) still produces output. Without this, typing in128 // (no buffer change) still produces output. Without this, typing in
66 // the editor would visually move the caret but the terminal would129 // the editor would visually move the caret but the terminal would

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close