detached 169b025

Add session deletion to picker

commit
169b025fa8f784e48cd0c11415c936d037605b91
author
patriceckhart · · 2026-09-02 15:32 UTC
parent
5d7a424
signature
unsigned
refs
tag: v0.3.57
4 files · +267 -4
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 packages/agent/modes/interactive.go +36 -0
@@ -2224,6 +2224,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22242224 if act.Select {
22252225 i.applySessionSelection(act.Path)
22262226 }
2227+ if act.Delete {
2228+ i.applySessionDeletion(act.Path)
2229+ }
22272230 // Always request a redraw after handling a key here: when esc
22282231 // closes the picker, the overlay-close detection in the render
22292232 // pass needs to run so the tall dialog rows get repainted from
@@ -5168,6 +5171,39 @@ func totalTurnsLocked(msgs []provider.Message) int {
51685171 return n
51695172 }
51705173
5174+// applySessionDeletion permanently removes a session selected in /sessions.
5175+// The active session is protected because unlinking its open file would lose
5176+// subsequent transcript writes on Unix and behaves differently on Windows.
5177+func (i *Interactive) applySessionDeletion(path string) {
5178+ if i.cfg.CurrentSessionPath == nil {
5179+ i.mu.Lock()
5180+ i.statusErr = "session deletion is not wired in this build"
5181+ i.statusOK = ""
5182+ i.mu.Unlock()
5183+ return
5184+ }
5185+ current := i.cfg.CurrentSessionPath()
5186+ if current != "" && filepath.Clean(current) == filepath.Clean(path) {
5187+ i.mu.Lock()
5188+ i.statusErr = "cannot delete the active session; resume another session first"
5189+ i.statusOK = ""
5190+ i.mu.Unlock()
5191+ return
5192+ }
5193+ if err := core.DeleteSession(path); err != nil {
5194+ i.mu.Lock()
5195+ i.statusErr = "delete session: " + err.Error()
5196+ i.statusOK = ""
5197+ i.mu.Unlock()
5198+ return
5199+ }
5200+ i.sessionDialog.Remove(path)
5201+ i.mu.Lock()
5202+ i.statusOK = "deleted session: " + friendlyPath(path)
5203+ i.statusErr = ""
5204+ i.mu.Unlock()
5205+}
5206+
51715207 // applySessionSelection loads the given session via the cli-provided
51725208 // callback and snaps the viewport to the bottom (the latest message)
51735209 // so the user lands at the live tail of the resumed conversation.
@@ -2224,6 +2224,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2224 if act.Select {2224 if act.Select {
2225 i.applySessionSelection(act.Path)2225 i.applySessionSelection(act.Path)
2226 }2226 }
2227+ if act.Delete {
2228+ i.applySessionDeletion(act.Path)
2229+ }
2227 // Always request a redraw after handling a key here: when esc2230 // Always request a redraw after handling a key here: when esc
2228 // closes the picker, the overlay-close detection in the render2231 // closes the picker, the overlay-close detection in the render
2229 // pass needs to run so the tall dialog rows get repainted from2232 // pass needs to run so the tall dialog rows get repainted from
@@ -5168,6 +5171,39 @@ func totalTurnsLocked(msgs []provider.Message) int {
5168 return n5171 return n
5169 }5172 }
5170 5173
5174+// applySessionDeletion permanently removes a session selected in /sessions.
5175+// The active session is protected because unlinking its open file would lose
5176+// subsequent transcript writes on Unix and behaves differently on Windows.
5177+func (i *Interactive) applySessionDeletion(path string) {
5178+ if i.cfg.CurrentSessionPath == nil {
5179+ i.mu.Lock()
5180+ i.statusErr = "session deletion is not wired in this build"
5181+ i.statusOK = ""
5182+ i.mu.Unlock()
5183+ return
5184+ }
5185+ current := i.cfg.CurrentSessionPath()
5186+ if current != "" && filepath.Clean(current) == filepath.Clean(path) {
5187+ i.mu.Lock()
5188+ i.statusErr = "cannot delete the active session; resume another session first"
5189+ i.statusOK = ""
5190+ i.mu.Unlock()
5191+ return
5192+ }
5193+ if err := core.DeleteSession(path); err != nil {
5194+ i.mu.Lock()
5195+ i.statusErr = "delete session: " + err.Error()
5196+ i.statusOK = ""
5197+ i.mu.Unlock()
5198+ return
5199+ }
5200+ i.sessionDialog.Remove(path)
5201+ i.mu.Lock()
5202+ i.statusOK = "deleted session: " + friendlyPath(path)
5203+ i.statusErr = ""
5204+ i.mu.Unlock()
5205+}
5206+
5171 // applySessionSelection loads the given session via the cli-provided5207 // applySessionSelection loads the given session via the cli-provided
5172 // callback and snaps the viewport to the bottom (the latest message)5208 // callback and snaps the viewport to the bottom (the latest message)
5173 // so the user lands at the live tail of the resumed conversation.5209 // 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+}

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