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 | |||
| 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 packages/agent/modes/interactive.go +36 -0
| @@ -2224,6 +2224,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2224 | 2224 | if act.Select { | |
| 2225 | 2225 | i.applySessionSelection(act.Path) | |
| 2226 | 2226 | } | |
| 2227 | + | if act.Delete { | |
| 2228 | + | i.applySessionDeletion(act.Path) | |
| 2229 | + | } | |
| 2227 | 2230 | // Always request a redraw after handling a key here: when esc | |
| 2228 | 2231 | // closes the picker, the overlay-close detection in the render | |
| 2229 | 2232 | // pass needs to run so the tall dialog rows get repainted from | |
| @@ -5168,6 +5171,39 @@ func totalTurnsLocked(msgs []provider.Message) int { | |||
| 5168 | 5171 | 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 | 5207 | // applySessionSelection loads the given session via the cli-provided | |
| 5172 | 5208 | // callback and snaps the viewport to the bottom (the latest message) | |
| 5173 | 5209 | // 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 esc | 2230 | // Always request a redraw after handling a key here: when esc | ||
| 2228 | // closes the picker, the overlay-close detection in the render | 2231 | // closes the picker, the overlay-close detection in the render | ||
| 2229 | // pass needs to run so the tall dialog rows get repainted from | 2232 | // pass needs to run so the tall dialog rows get repainted from | ||
| @@ -5168,6 +5171,39 @@ func totalTurnsLocked(msgs []provider.Message) int { | |||||
| 5168 | return n | 5171 | 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-provided | 5207 | // 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 { | |||
| 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 | + | } | |||