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}