v0.2.3 ecb3b02

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(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. The returned string is guaranteed to
176// fit within maxWidth visible characters so the terminal never soft-
177// wraps it into the next row.
178func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
179	when := formatRelative(s.Started)
180	summary := strings.TrimSpace(s.Title)
181	if summary == "" {
182		summary = strings.TrimSpace(s.FirstUserText)
183	}
184	if summary == "" {
185		summary = "(empty)"
186	}
187	summary = strings.ReplaceAll(summary, "\n", " ")
188	left := fmt.Sprintf("%-14s  %s/%s  %d msgs  $%.4f  ",
189		when, s.Provider, s.Model, s.MessageCount, s.TotalCost)
190	room := maxWidth - len([]rune(left))
191	if room < 4 {
192		room = 4
193	}
194	runes := []rune(summary)
195	if len(runes) > room {
196		summary = string(runes[:room-3]) + "..."
197	}
198	row := left + summary
199	// Hard clamp: ensure the full row never exceeds maxWidth.
200	rowRunes := []rune(row)
201	if len(rowRunes) > maxWidth {
202		if maxWidth <= 3 {
203			row = strings.Repeat(".", maxWidth)
204		} else {
205			row = string(rowRunes[:maxWidth-3]) + "..."
206		}
207	}
208	return row
209}
210
211func formatRelative(t time.Time) string {
212	if t.IsZero() {
213		return "unknown"
214	}
215	d := time.Since(t)
216	switch {
217	case d < time.Minute:
218		return "just now"
219	case d < time.Hour:
220		return fmt.Sprintf("%d min ago", int(d.Minutes()))
221	case d < 24*time.Hour:
222		return fmt.Sprintf("%d h ago", int(d.Hours()))
223	case d < 7*24*time.Hour:
224		return fmt.Sprintf("%d d ago", int(d.Hours()/24))
225	default:
226		return t.Local().Format("2006-01-02")
227	}
228}
229
230// HandleKey advances the dialog and returns an action to apply, if any.
231func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
232	// Rename mode: type the new name.
233	if d.renaming {
234		switch k.Kind {
235		case tui.KeyEnter:
236			title := strings.TrimSpace(d.rename)
237			if title != "" && d.cursor < len(d.sessions) {
238				path := d.sessions[d.cursor].Path
239				_ = core.RenameSession(path, title)
240				d.sessions[d.cursor].Title = title
241			}
242			d.renaming = false
243			d.rename = ""
244			return sessionDialogAction{Renamed: true}
245		case tui.KeyEsc:
246			d.renaming = false
247			d.rename = ""
248			return sessionDialogAction{}
249		case tui.KeyBackspace:
250			if len(d.rename) > 0 {
251				r := []rune(d.rename)
252				d.rename = string(r[:len(r)-1])
253			}
254			return sessionDialogAction{}
255		case tui.KeyPaste:
256			d.rename += k.Paste
257			return sessionDialogAction{}
258		case tui.KeyRune:
259			if k.Rune != 0 {
260				d.rename += string(k.Rune)
261			}
262			return sessionDialogAction{}
263		}
264		return sessionDialogAction{}
265	}
266
267	page := d.MaxRows
268	if page <= 0 {
269		page = 10
270	}
271	if page > 1 {
272		page--
273	}
274	switch k.Kind {
275	case tui.KeyUp:
276		if d.cursor > 0 {
277			d.cursor--
278		}
279	case tui.KeyDown:
280		if d.cursor < len(d.sessions)-1 {
281			d.cursor++
282		}
283	case tui.KeyPageUp:
284		d.cursor -= page
285		if d.cursor < 0 {
286			d.cursor = 0
287		}
288	case tui.KeyPageDown:
289		d.cursor += page
290		if d.cursor >= len(d.sessions) {
291			d.cursor = len(d.sessions) - 1
292			if d.cursor < 0 {
293				d.cursor = 0
294			}
295		}
296	case tui.KeyHome:
297		d.cursor = 0
298	case tui.KeyEnd:
299		if len(d.sessions) > 0 {
300			d.cursor = len(d.sessions) - 1
301		}
302	case tui.KeyEsc:
303		d.Close()
304		return sessionDialogAction{Close: true}
305	case tui.KeyEnter:
306		if len(d.sessions) == 0 {
307			d.Close()
308			return sessionDialogAction{Close: true}
309		}
310		s := d.sessions[d.cursor]
311		d.Close()
312		return sessionDialogAction{Select: true, Path: s.Path}
313	case tui.KeyRune:
314		if k.Rune == 'r' && len(d.sessions) > 0 {
315			s := d.sessions[d.cursor]
316			d.renaming = true
317			if s.Title != "" {
318				d.rename = s.Title
319			} else {
320				d.rename = ""
321			}
322			return sessionDialogAction{}
323		}
324	}
325	return sessionDialogAction{}
326}

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