v0.2.76 9559c57

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

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