v0.0.80 1f28b62

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
18	// MaxRows is the maximum number of session rows the dialog
19	// will render in a single frame. Set by the host right before
20	// Render based on the available chat space; if 0, the dialog
21	// falls back to rendering every row (original behaviour).
22	// When the list is longer than MaxRows the dialog scrolls so
23	// the cursor stays visible and tags the first/last visible
24	// entry with a muted "↑ N more" / "↓ N more" row so the user
25	// knows there's offscreen content.
26	MaxRows int
27
28	// viewTop is the index of the first session currently drawn.
29	// Adjusted to follow the cursor on up/down moves.
30	viewTop int
31}
32
33// sessionDialogAction is returned by HandleKey.
34type sessionDialogAction struct {
35	Select bool
36	Path   string
37	Close  bool
38}
39
40func newSessionDialog() *sessionDialog { return &sessionDialog{} }
41
42// Open populates the dialog from root + cwd and shows it. Empty
43// sessions (zero messages) are filtered out so the currently-running
44// session, a freshly-opened one that hasn't received a prompt yet,
45// and any stale empties that haven't been pruned yet all stay out
46// of the picker. Resuming an empty session is a no-op anyway.
47func (d *sessionDialog) Open(root, cwd string) {
48	all := core.DescribeSessions(root, cwd)
49	filtered := make([]core.SessionSummary, 0, len(all))
50	for _, s := range all {
51		if s.MessageCount == 0 {
52			continue
53		}
54		filtered = append(filtered, s)
55	}
56	d.sessions = filtered
57	d.cursor = 0
58	d.viewTop = 0
59	d.active = true
60}
61
62// Close hides the dialog.
63func (d *sessionDialog) Close() { d.active = false }
64
65// Active reports whether the dialog is visible and consumes input.
66func (d *sessionDialog) Active() bool { return d != nil && d.active }
67
68// Render returns the dialog lines.
69func (d *sessionDialog) Render(th tui.Theme, width int) []string {
70	if !d.Active() {
71		return nil
72	}
73	var lines []string
74	lines = append(lines, frameHeader(th, "sessions", width))
75	if len(d.sessions) == 0 {
76		lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory"))
77		lines = append(lines, th.FG256(th.Muted, "press esc to close"))
78		lines = append(lines, frameRule(th, width))
79		return lines
80	}
81	lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, pgup/pgdn, enter, esc to cancel)"))
82
83	// Viewport: windowed slice of d.sessions around d.cursor so a
84	// list taller than the terminal still scrolls. Caller sets
85	// MaxRows to the number of rows available for session entries
86	// (i.e. excluding the header, hint, chrome). When it's zero or
87	// bigger than the list, we draw everything.
88	total := len(d.sessions)
89	window := d.MaxRows
90	if window <= 0 || window >= total {
91		window = total
92	}
93	d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total)
94	viewBot := d.viewTop + window
95	if viewBot > total {
96		viewBot = total
97	}
98
99	// Top indicator: how many rows are above the viewport.
100	if d.viewTop > 0 {
101		hidden := d.viewTop
102		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  ↑ %d more above", hidden)))
103	}
104	for i := d.viewTop; i < viewBot; i++ {
105		s := d.sessions[i]
106		plain := "  " + formatSessionRowPlain(s, width-2)
107		if i == d.cursor {
108			lines = append(lines, th.PadHighlight(plain, width))
109		} else {
110			lines = append(lines, th.FG256(th.Muted, plain))
111		}
112	}
113	// Bottom indicator: how many rows are below the viewport.
114	if viewBot < total {
115		hidden := total - viewBot
116		lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("  ↓ %d more below", hidden)))
117	}
118	lines = append(lines, frameRule(th, width))
119	return lines
120}
121
122// clampViewTop returns a viewTop that keeps cursor visible in a
123// window of the given size over a list of `total` rows. Leaves one
124// row of padding above/below where possible so moving the cursor
125// doesn't land right on the top/bottom edge — easier to see what
126// direction you're moving.
127func clampViewTop(viewTop, cursor, window, total int) int {
128	if window <= 0 || total <= 0 {
129		return 0
130	}
131	if window >= total {
132		return 0
133	}
134	pad := 2
135	if window < 6 {
136		pad = 0
137	}
138	if cursor < viewTop+pad {
139		viewTop = cursor - pad
140	}
141	if cursor >= viewTop+window-pad {
142		viewTop = cursor - window + pad + 1
143	}
144	if viewTop < 0 {
145		viewTop = 0
146	}
147	if viewTop+window > total {
148		viewTop = total - window
149	}
150	return viewTop
151}
152
153// formatSessionRowPlain returns the session row body without any ANSI
154// styling so the caller can wrap it in either a plain mute color or a
155// full-row selection highlight.
156func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
157	when := formatRelative(s.Started)
158	summary := strings.TrimSpace(s.FirstUserText)
159	if summary == "" {
160		summary = "(empty)"
161	}
162	left := fmt.Sprintf("%-14s  %s/%s  %d msgs  $%.4f  ",
163		when, s.Provider, s.Model, s.MessageCount, s.TotalCost)
164	room := maxWidth - len(left)
165	if room < 10 {
166		room = 10
167	}
168	if len(summary) > room {
169		summary = summary[:room-1] + "…"
170	}
171	summary = strings.ReplaceAll(summary, "\n", " ")
172	return left + summary
173}
174
175func formatRelative(t time.Time) string {
176	if t.IsZero() {
177		return "unknown"
178	}
179	d := time.Since(t)
180	switch {
181	case d < time.Minute:
182		return "just now"
183	case d < time.Hour:
184		return fmt.Sprintf("%d min ago", int(d.Minutes()))
185	case d < 24*time.Hour:
186		return fmt.Sprintf("%d h ago", int(d.Hours()))
187	case d < 7*24*time.Hour:
188		return fmt.Sprintf("%d d ago", int(d.Hours()/24))
189	default:
190		return t.Local().Format("2006-01-02")
191	}
192}
193
194// HandleKey advances the dialog and returns an action to apply, if any.
195func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
196	page := d.MaxRows
197	if page <= 0 {
198		page = 10
199	}
200	if page > 1 {
201		page--
202	}
203	switch k.Kind {
204	case tui.KeyUp:
205		if d.cursor > 0 {
206			d.cursor--
207		}
208	case tui.KeyDown:
209		if d.cursor < len(d.sessions)-1 {
210			d.cursor++
211		}
212	case tui.KeyPageUp:
213		d.cursor -= page
214		if d.cursor < 0 {
215			d.cursor = 0
216		}
217	case tui.KeyPageDown:
218		d.cursor += page
219		if d.cursor >= len(d.sessions) {
220			d.cursor = len(d.sessions) - 1
221			if d.cursor < 0 {
222				d.cursor = 0
223			}
224		}
225	case tui.KeyHome:
226		d.cursor = 0
227	case tui.KeyEnd:
228		if len(d.sessions) > 0 {
229			d.cursor = len(d.sessions) - 1
230		}
231	case tui.KeyEsc:
232		d.Close()
233		return sessionDialogAction{Close: true}
234	case tui.KeyEnter:
235		if len(d.sessions) == 0 {
236			d.Close()
237			return sessionDialogAction{Close: true}
238		}
239		s := d.sessions[d.cursor]
240		d.Close()
241		return sessionDialogAction{Select: true, Path: s.Path}
242	}
243	return sessionDialogAction{}
244}

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