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}