v0.1.21 b2fa0e3

session_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"strings"
6	"time"
7
8	"github.com/patriceckhart/zot/internal/core"
9	"github.com/patriceckhart/zot/internal/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(1) + rename hint(1) = row 2 (0-indexed)
72	// Col: 2 spaces indent + text length
73	return 2, 2 + 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 to save, esc to cancel):"))
97		lines = append(lines, "  "+th.FG256(th.FG, d.rename))
98		lines = append(lines, frameRule(th, width))
99		return lines
100	}
101	lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)"))
102
103	// Viewport: windowed slice of d.sessions around d.cursor so a
104	// list taller than the terminal still scrolls. Caller sets
105	// MaxRows to the number of rows available for session entries
106	// (i.e. excluding the header, hint, chrome). When it's zero or
107	// bigger than the list, we draw everything.
108	total := len(d.sessions)
109	window := d.MaxRows
110	if window <= 0 || window >= total {
111		window = total
112	}
113	d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total)
114	viewBot := d.viewTop + window
115	if viewBot > total {
116		viewBot = total
117	}
118
119	// Top indicator: how many rows are above the viewport.
120	if d.viewTop > 0 {
121		hidden := d.viewTop
122		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  ↑ %d more above", hidden)))
123	}
124	for i := d.viewTop; i < viewBot; i++ {
125		s := d.sessions[i]
126		plain := "  " + formatSessionRowPlain(s, width-2)
127		if i == d.cursor {
128			lines = append(lines, th.PadHighlight(plain, width))
129		} else {
130			lines = append(lines, th.FG256(th.Muted, plain))
131		}
132	}
133	// Bottom indicator: how many rows are below the viewport.
134	if viewBot < total {
135		hidden := total - viewBot
136		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  ↓ %d more below", hidden)))
137	}
138	lines = append(lines, frameRule(th, width))
139	return lines
140}
141
142// clampViewTop returns a viewTop that keeps cursor visible in a
143// window of the given size over a list of `total` rows. Leaves one
144// row of padding above/below where possible so moving the cursor
145// doesn't land right on the top/bottom edge — easier to see what
146// direction you're moving.
147func clampViewTop(viewTop, cursor, window, total int) int {
148	if window <= 0 || total <= 0 {
149		return 0
150	}
151	if window >= total {
152		return 0
153	}
154	pad := 2
155	if window < 6 {
156		pad = 0
157	}
158	if cursor < viewTop+pad {
159		viewTop = cursor - pad
160	}
161	if cursor >= viewTop+window-pad {
162		viewTop = cursor - window + pad + 1
163	}
164	if viewTop < 0 {
165		viewTop = 0
166	}
167	if viewTop+window > total {
168		viewTop = total - window
169	}
170	return viewTop
171}
172
173// formatSessionRowPlain returns the session row body without any ANSI
174// styling so the caller can wrap it in either a plain mute color or a
175// full-row selection highlight.
176func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
177	when := formatRelative(s.Started)
178	summary := strings.TrimSpace(s.Title)
179	if summary == "" {
180		summary = strings.TrimSpace(s.FirstUserText)
181	}
182	if summary == "" {
183		summary = "(empty)"
184	}
185	left := fmt.Sprintf("%-14s  %s/%s  %d msgs  $%.4f  ",
186		when, s.Provider, s.Model, s.MessageCount, s.TotalCost)
187	room := maxWidth - len(left)
188	if room < 10 {
189		room = 10
190	}
191	if len(summary) > room {
192		summary = summary[:room-1] + "…"
193	}
194	summary = strings.ReplaceAll(summary, "\n", " ")
195	return left + summary
196}
197
198func formatRelative(t time.Time) string {
199	if t.IsZero() {
200		return "unknown"
201	}
202	d := time.Since(t)
203	switch {
204	case d < time.Minute:
205		return "just now"
206	case d < time.Hour:
207		return fmt.Sprintf("%d min ago", int(d.Minutes()))
208	case d < 24*time.Hour:
209		return fmt.Sprintf("%d h ago", int(d.Hours()))
210	case d < 7*24*time.Hour:
211		return fmt.Sprintf("%d d ago", int(d.Hours()/24))
212	default:
213		return t.Local().Format("2006-01-02")
214	}
215}
216
217// HandleKey advances the dialog and returns an action to apply, if any.
218func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
219	// Rename mode: type the new name.
220	if d.renaming {
221		switch k.Kind {
222		case tui.KeyEnter:
223			title := strings.TrimSpace(d.rename)
224			if title != "" && d.cursor < len(d.sessions) {
225				path := d.sessions[d.cursor].Path
226				_ = core.RenameSession(path, title)
227				d.sessions[d.cursor].Title = title
228			}
229			d.renaming = false
230			d.rename = ""
231			return sessionDialogAction{Renamed: true}
232		case tui.KeyEsc:
233			d.renaming = false
234			d.rename = ""
235			return sessionDialogAction{}
236		case tui.KeyBackspace:
237			if len(d.rename) > 0 {
238				r := []rune(d.rename)
239				d.rename = string(r[:len(r)-1])
240			}
241			return sessionDialogAction{}
242		case tui.KeyPaste:
243			d.rename += k.Paste
244			return sessionDialogAction{}
245		case tui.KeyRune:
246			if k.Rune != 0 {
247				d.rename += string(k.Rune)
248			}
249			return sessionDialogAction{}
250		}
251		return sessionDialogAction{}
252	}
253
254	page := d.MaxRows
255	if page <= 0 {
256		page = 10
257	}
258	if page > 1 {
259		page--
260	}
261	switch k.Kind {
262	case tui.KeyUp:
263		if d.cursor > 0 {
264			d.cursor--
265		}
266	case tui.KeyDown:
267		if d.cursor < len(d.sessions)-1 {
268			d.cursor++
269		}
270	case tui.KeyPageUp:
271		d.cursor -= page
272		if d.cursor < 0 {
273			d.cursor = 0
274		}
275	case tui.KeyPageDown:
276		d.cursor += page
277		if d.cursor >= len(d.sessions) {
278			d.cursor = len(d.sessions) - 1
279			if d.cursor < 0 {
280				d.cursor = 0
281			}
282		}
283	case tui.KeyHome:
284		d.cursor = 0
285	case tui.KeyEnd:
286		if len(d.sessions) > 0 {
287			d.cursor = len(d.sessions) - 1
288		}
289	case tui.KeyEsc:
290		d.Close()
291		return sessionDialogAction{Close: true}
292	case tui.KeyEnter:
293		if len(d.sessions) == 0 {
294			d.Close()
295			return sessionDialogAction{Close: true}
296		}
297		s := d.sessions[d.cursor]
298		d.Close()
299		return sessionDialogAction{Select: true, Path: s.Path}
300	case tui.KeyRune:
301		if k.Rune == 'r' && len(d.sessions) > 0 {
302			s := d.sessions[d.cursor]
303			d.renaming = true
304			if s.Title != "" {
305				d.rename = s.Title
306			} else {
307				d.rename = ""
308			}
309			return sessionDialogAction{}
310		}
311	}
312	return sessionDialogAction{}
313}

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