v0.3.67 80dd4c6

session_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"strings"
6	"time"
7
8	"github.com/patriceckhart/zot/packages/core"
9	"github.com/patriceckhart/zot/packages/tui"
10)
11
12// sessionDialog is the inline picker shown when the user runs /sessions.
13type sessionDialog struct {
14	active   bool
15	sessions []core.SessionSummary
16	cursor   int
17	renaming bool
18	rename   string
19	deleting bool
20
21	// MaxRows is the maximum number of session rows the dialog
22	// will render in a single frame. Set by the host right before
23	// Render based on the available chat space; if 0, the dialog
24	// falls back to rendering every row (original behaviour).
25	// When the list is longer than MaxRows the dialog scrolls so
26	// the cursor stays visible and tags the first/last visible
27	// entry with a muted "↑ N more" / "↓ N more" row so the user
28	// knows there's offscreen content.
29	MaxRows int
30
31	// viewTop is the index of the first session currently drawn.
32	// Adjusted to follow the cursor on up/down moves.
33	viewTop int
34}
35
36// sessionDialogAction is returned by HandleKey.
37type sessionDialogAction struct {
38	Select  bool
39	Delete  bool
40	Path    string
41	Close   bool
42	Renamed bool
43}
44
45func newSessionDialog() *sessionDialog { return &sessionDialog{} }
46
47// Open populates the dialog from root + cwd and shows it. Empty
48// sessions (zero messages) are filtered out so the currently-running
49// session, a freshly-opened one that hasn't received a prompt yet,
50// and any stale empties that haven't been pruned yet all stay out
51// of the picker. Resuming an empty session is a no-op anyway.
52func (d *sessionDialog) Open(root, cwd string) {
53	all := core.DescribeSessions(root, cwd)
54	filtered := make([]core.SessionSummary, 0, len(all))
55	for _, s := range all {
56		if s.MessageCount == 0 {
57			continue
58		}
59		filtered = append(filtered, s)
60	}
61	d.sessions = filtered
62	d.cursor = 0
63	d.viewTop = 0
64	d.renaming = false
65	d.rename = ""
66	d.deleting = false
67	d.active = true
68}
69
70// CursorPos returns the row/col for the terminal cursor when in
71// rename mode. Returns -1, -1 otherwise.
72func (d *sessionDialog) CursorPos() (row, col int) {
73	if !d.renaming {
74		return -1, -1
75	}
76	// Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed).
77	// Col: "  ▌ " prefix + text length.
78	return 3, 4 + len([]rune(d.rename))
79}
80
81// Close hides the dialog and abandons any pending edit or deletion.
82func (d *sessionDialog) Close() {
83	d.active = false
84	d.renaming = false
85	d.rename = ""
86	d.deleting = false
87}
88
89// Active reports whether the dialog is visible and consumes input.
90func (d *sessionDialog) Active() bool { return d != nil && d.active }
91
92// Render returns the dialog lines.
93func (d *sessionDialog) Render(th tui.Theme, width int) []string {
94	if !d.Active() {
95		return nil
96	}
97	var lines []string
98	lines = append(lines, frameHeader(th, "sessions", width))
99	if len(d.sessions) == 0 {
100		lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))
101		lines = append(lines, th.FG256(th.Muted, "press esc to close"))
102		lines = append(lines, frameRule(th, width))
103		return lines
104	}
105	if d.renaming {
106		lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):"))
107		text := d.rename
108		if text == "" {
109			text = th.FG256(th.Muted, "session name")
110		} else {
111			text = th.FG256(th.FG, text)
112		}
113		lines = append(lines, "  "+th.FG256(th.Accent, "▌ ")+text)
114		lines = append(lines, frameRule(th, width))
115		return lines
116	}
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)"))
126
127	// Viewport: windowed slice of d.sessions around d.cursor so a
128	// list taller than the terminal still scrolls. Caller sets
129	// MaxRows to the number of rows available for session entries
130	// (i.e. excluding the header, hint, chrome). When it's zero or
131	// bigger than the list, we draw everything.
132	total := len(d.sessions)
133	window := d.MaxRows
134	if window <= 0 || window >= total {
135		window = total
136	}
137	d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total)
138	viewBot := d.viewTop + window
139	if viewBot > total {
140		viewBot = total
141	}
142
143	// Top indicator: how many rows are above the viewport.
144	if d.viewTop > 0 {
145		hidden := d.viewTop
146		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  ↑ %d more above", hidden)))
147	}
148	for i := d.viewTop; i < viewBot; i++ {
149		s := d.sessions[i]
150		plain := "  " + formatSessionRowPlain(s, width-2)
151		if i == d.cursor {
152			lines = append(lines, th.PadHighlight(plain, width))
153		} else {
154			lines = append(lines, th.FG256(th.Muted, plain))
155		}
156	}
157	// Bottom indicator: how many rows are below the viewport.
158	if viewBot < total {
159		hidden := total - viewBot
160		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  ↓ %d more below", hidden)))
161	}
162	lines = append(lines, frameRule(th, width))
163	return lines
164}
165
166// clampViewTop returns a viewTop that keeps cursor visible in a
167// window of the given size over a list of `total` rows. Leaves one
168// row of padding above/below where possible so moving the cursor
169// doesn't land right on the top/bottom edge — easier to see what
170// direction you're moving.
171func clampViewTop(viewTop, cursor, window, total int) int {
172	if window <= 0 || total <= 0 {
173		return 0
174	}
175	if window >= total {
176		return 0
177	}
178	pad := 2
179	if window < 6 {
180		pad = 0
181	}
182	if cursor < viewTop+pad {
183		viewTop = cursor - pad
184	}
185	if cursor >= viewTop+window-pad {
186		viewTop = cursor - window + pad + 1
187	}
188	if viewTop < 0 {
189		viewTop = 0
190	}
191	if viewTop+window > total {
192		viewTop = total - window
193	}
194	return viewTop
195}
196
197// formatSessionRowPlain returns the session row body without any ANSI
198// styling so the caller can wrap it in either a plain mute color or a
199// full-row selection highlight. The returned string is guaranteed to
200// fit within maxWidth visible characters so the terminal never soft-
201// wraps it into the next row.
202func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
203	when := formatRelative(s.Started)
204	summary := strings.TrimSpace(s.Title)
205	if summary == "" {
206		summary = strings.TrimSpace(s.FirstUserText)
207	}
208	if summary == "" {
209		summary = "(empty)"
210	}
211	summary = strings.ReplaceAll(summary, "\n", " ")
212	left := fmt.Sprintf("%-14s  %s/%s  %d msgs  $%.4f  ",
213		when, s.Provider, s.Model, s.MessageCount, s.TotalCost)
214	room := maxWidth - len([]rune(left))
215	if room < 4 {
216		room = 4
217	}
218	runes := []rune(summary)
219	if len(runes) > room {
220		summary = string(runes[:room-3]) + "..."
221	}
222	row := left + summary
223	// Hard clamp: ensure the full row never exceeds maxWidth.
224	rowRunes := []rune(row)
225	if len(rowRunes) > maxWidth {
226		if maxWidth <= 3 {
227			row = strings.Repeat(".", maxWidth)
228		} else {
229			row = string(rowRunes[:maxWidth-3]) + "..."
230		}
231	}
232	return row
233}
234
235func formatRelative(t time.Time) string {
236	if t.IsZero() {
237		return "unknown"
238	}
239	d := time.Since(t)
240	switch {
241	case d < time.Minute:
242		return "just now"
243	case d < time.Hour:
244		return fmt.Sprintf("%d min ago", int(d.Minutes()))
245	case d < 24*time.Hour:
246		return fmt.Sprintf("%d h ago", int(d.Hours()))
247	case d < 7*24*time.Hour:
248		return fmt.Sprintf("%d d ago", int(d.Hours()/24))
249	default:
250		return t.Local().Format("2006-01-02")
251	}
252}
253
254// HandleKey advances the dialog and returns an action to apply, if any.
255func (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
273	// Rename mode: type the new name.
274	if d.renaming {
275		switch k.Kind {
276		case tui.KeyEnter:
277			title := strings.TrimSpace(d.rename)
278			if title != "" && d.cursor < len(d.sessions) {
279				path := d.sessions[d.cursor].Path
280				_ = core.RenameSession(path, title)
281				d.sessions[d.cursor].Title = title
282			}
283			d.renaming = false
284			d.rename = ""
285			return sessionDialogAction{Renamed: true}
286		case tui.KeyEsc:
287			d.renaming = false
288			d.rename = ""
289			return sessionDialogAction{}
290		case tui.KeyBackspace:
291			if len(d.rename) > 0 {
292				r := []rune(d.rename)
293				d.rename = string(r[:len(r)-1])
294			}
295			return sessionDialogAction{}
296		case tui.KeyPaste:
297			d.rename += k.Paste
298			return sessionDialogAction{}
299		case tui.KeyRune:
300			if k.Rune != 0 {
301				d.rename += string(k.Rune)
302			}
303			return sessionDialogAction{}
304		}
305		return sessionDialogAction{}
306	}
307
308	page := d.MaxRows
309	if page <= 0 {
310		page = 10
311	}
312	if page > 1 {
313		page--
314	}
315	switch k.Kind {
316	case tui.KeyUp:
317		if d.cursor > 0 {
318			d.cursor--
319		}
320	case tui.KeyDown:
321		if d.cursor < len(d.sessions)-1 {
322			d.cursor++
323		}
324	case tui.KeyPageUp:
325		d.cursor -= page
326		if d.cursor < 0 {
327			d.cursor = 0
328		}
329	case tui.KeyPageDown:
330		d.cursor += page
331		if d.cursor >= len(d.sessions) {
332			d.cursor = len(d.sessions) - 1
333			if d.cursor < 0 {
334				d.cursor = 0
335			}
336		}
337	case tui.KeyHome:
338		d.cursor = 0
339	case tui.KeyEnd:
340		if len(d.sessions) > 0 {
341			d.cursor = len(d.sessions) - 1
342		}
343	case tui.KeyEsc:
344		d.Close()
345		return sessionDialogAction{Close: true}
346	case tui.KeyEnter:
347		if len(d.sessions) == 0 {
348			d.Close()
349			return sessionDialogAction{Close: true}
350		}
351		s := d.sessions[d.cursor]
352		d.Close()
353		return sessionDialogAction{Select: true, Path: s.Path}
354	case tui.KeyRune:
355		if k.Rune == 'r' && len(d.sessions) > 0 {
356			s := d.sessions[d.cursor]
357			d.renaming = true
358			if s.Title != "" {
359				d.rename = s.Title
360			} else {
361				d.rename = ""
362			}
363			return sessionDialogAction{}
364		}
365		if k.Rune == 'd' && len(d.sessions) > 0 {
366			d.deleting = true
367		}
368	}
369	return sessionDialogAction{}
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.
374func (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}

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 477

↑↓ moveenter openesc close