okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: sessions search back to substring match per word, listed order1697fee

main
17.9 KiB570 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"albedo/cli/internal/presentation"
6	"cmp"
7	"fmt"
8	"path/filepath"
9	"strings"
10	"time"
11
12	"charm.land/lipgloss/v2"
13	"github.com/charmbracelet/x/ansi"
14)
15
16// ── logo ───────────────────────────────────────────────────────────────────
17
18// A four-point sparkle with a small companion star, drawn with half blocks so
19// each pixel is roughly square.
20var svLogoPixels = []string{
21	"........#...............",
22	"........#...........#...",
23	".......###..........#...",
24	".......###........#####.",
25	"......#####.........#...",
26	".....#######........#...",
27	"...###########..........",
28	"#################.......",
29	"...###########..........",
30	".....#######............",
31	"......#####.............",
32	".......###.........#....",
33	".......###..............",
34	"........#...............",
35	"........#...............",
36	"........................",
37}
38
39func svLogo() []string {
40	rows := len(svLogoPixels) / 2
41	width := len(svLogoPixels[0])
42	lines := make([]string, rows)
43	for r := range rows {
44		var b strings.Builder
45		for c := range width {
46			top := svLogoPixels[2*r][c] == '#'
47			bottom := svLogoPixels[2*r+1][c] == '#'
48			ch := " "
49			switch {
50			case top && bottom:
51				ch = "█"
52			case top:
53				ch = "▀"
54			case bottom:
55				ch = "▄"
56			}
57			// Diagonal sweep so the gradient reads as light glancing off the mark.
58			if ch != " " {
59				t := (float64(c)/float64(width) + float64(r)/float64(rows)) / 2
60				ch = brandInk(t).Render(ch)
61			}
62			b.WriteString(ch)
63		}
64		lines[r] = b.String()
65	}
66	return lines
67}
68
69// ── helpers ────────────────────────────────────────────────────────────────
70
71func svCompactAge(ts *int64, now time.Time) string {
72	if ts == nil {
73		return "—"
74	}
75	d := now.Sub(time.Unix(*ts, 0))
76	switch {
77	case d < time.Minute:
78		return "now"
79	case d < time.Hour:
80		return fmt.Sprintf("%dm", int(d.Minutes()))
81	case d < 24*time.Hour:
82		return fmt.Sprintf("%dh", int(d.Hours()))
83	case d < 14*24*time.Hour:
84		return fmt.Sprintf("%dd", int(d.Hours()/24))
85	case d < 60*24*time.Hour:
86		return fmt.Sprintf("%dw", int(d.Hours()/24/7))
87	}
88	return fmt.Sprintf("%dmo", int(d.Hours()/24/30))
89}
90
91// svCell fits plain text into exactly w columns.
92func svCell(s string, w int, right bool) string {
93	if w <= 0 {
94		return ""
95	}
96	s = ansi.Truncate(s, w, "…")
97	pad := strings.Repeat(" ", max(0, w-ansi.StringWidth(s)))
98	if right {
99		return pad + s
100	}
101	return s + pad
102}
103
104type svColumns struct {
105	title, model, workspace, age int
106}
107
108func svLayout(width int) svColumns {
109	// Row: bar(1) icon(2) space(1) title … model … workspace … age, two-space gaps.
110	cols := svColumns{}
111	rest := width - 4
112	if width >= 32 {
113		cols.age = 4
114		rest -= cols.age + 2
115	}
116	if width >= 56 {
117		cols.model = min(20, max(14, width/5))
118		rest -= cols.model + 2
119	}
120	if width >= 90 {
121		cols.workspace = min(18, max(10, width/8))
122		rest -= cols.workspace + 2
123	}
124	cols.title = max(1, rest-1)
125	return cols
126}
127
128// svSep divides the columns.
129func svSep() string { return DefaultStyles.Decor.Render(" │ ") }
130
131// ── view ───────────────────────────────────────────────────────────────────
132
133// View shows the grouped session list beside its preview when there is room,
134// and the list alone on narrow terminals.
135func (m SessionViewer) View() string {
136	width := cmp.Or(m.Width, 80)
137	now := m.clock()
138	f := listFrame{
139		title:     located(m.brandName(), sessionText(m.workspacePlace())),
140		filter:    promptLead() + m.SearchInput.View(),
141		list:      func(w, h int) []string { return m.column(w, h, now) },
142		pane:      func(w, h int) []string { return m.preview(w, h, now) },
143		paneWidth: halfPane,
144		footer:    m.footer(width, now),
145	}
146	return f.view(m.Width, m.Height)
147}
148
149// svFit pads or cuts a styled line to exactly w columns.
150func svFit(s string, w int) string { return svCell(s, w, false) }
151
152// brandName is the title's brand: the archive says so.
153func (m SessionViewer) brandName() string {
154	if m.ArchiveView {
155		return "albedo  archive"
156	}
157	return "albedo"
158}
159
160// footer is the keys and the status, or the question while one is asked.
161func (m SessionViewer) footer(width int, now time.Time) string {
162	if m.confirm.asking() {
163		return m.confirm.footer(width, "")
164	}
165	status, urgent := m.counts(now), false
166	switch {
167	case m.notice != "":
168		status, urgent = DefaultStyles.Error.Render(m.notice), true
169	case m.Loading:
170		status = DefaultStyles.Faint.Render("loading…")
171	}
172	return footerLine(width, m.hints(), status, urgent)
173}
174
175// hints are the keys for the list, the archive, or a rename in progress.
176func (m SessionViewer) hints() []hint {
177	switch {
178	case m.rename.active():
179		return []hint{{"enter", "save"}, {"esc", "cancel"}, {"", "empty restores the automatic title"}}
180	case m.ArchiveView:
181		if _, ok := m.Highlighted(); !ok {
182			return []hint{{"esc", "back"}}
183		}
184		return []hint{{"↑↓", "move"}, {"enter", "open"}, {"^r", "rename"}, {"^a", "restore"}, {"^d", "delete"}, {"esc", "back"}}
185	}
186	esc := "quit"
187	if m.HasActive {
188		esc = "back"
189	}
190	return []hint{{"↑↓", "move"}, {"tab", "switch"}, {"enter", "open"}, {"^r", "rename"}, {"^s", "pin"}, {"^a", "archive"}, {"^f", "folders"}, {"esc", esc}}
191}
192
193// counts is how many sessions the list holds, and how many of them today.
194func (m SessionViewer) counts(now time.Time) string {
195	count, today := len(m.Sessions), 0
196	if m.ArchiveView {
197		count = len(m.archivedIDs)
198	} else {
199		for _, s := range m.Sessions {
200			if dateSection(s, now) == secToday {
201				today++
202			}
203		}
204	}
205	out := DefaultStyles.Faint.Render(counted(count, "session"))
206	if today > 0 {
207		out += DefaultStyles.Decor.Render(" · ") + DefaultStyles.Success.Render(fmt.Sprintf("%d today", today))
208	}
209	return out
210}
211
212// column renders items with section headings at exactly width × height,
213// scrolled to keep the cursor visible.
214func (m SessionViewer) column(width, height int, now time.Time) []string {
215	if len(m.Filtered) == 0 {
216		message := "   No matching sessions"
217		switch {
218		case m.ArchiveView && m.SearchInput.Value() == "":
219			message = "   No archived sessions yet"
220		case m.Loading:
221			message = "   loading sessions…"
222		}
223		return scrollWindow([]string{DefaultStyles.Faint.Render(message)}, -1, width, height)
224	}
225
226	selectedAt, total := -1, 0
227	for _, group := range m.groups {
228		if group.section != secAction {
229			if total > 0 && height >= 10 {
230				total++
231			}
232			total++
233		}
234		if m.Cursor >= group.first && m.Cursor < group.first+group.count {
235			selectedAt = total + m.Cursor - group.first
236		}
237		total += group.count
238	}
239	start := 0
240	if total > height {
241		start = min(max(0, selectedAt-height/2), total-height)
242	}
243	end := min(total, start+height)
244	out := make([]string, height)
245	blank := strings.Repeat(" ", width)
246	for i := range out {
247		out[i] = blank
248	}
249	// Overflow markers replace edge rows rather than adding to the window.
250	first, last := start, end
251	if total > height && height >= 3 {
252		more := svFit(DefaultStyles.Faint.Render("   ···"), width)
253		if start > 0 {
254			out[0] = more
255			first++
256		}
257		if end < total {
258			out[height-1] = more
259			last--
260		}
261	}
262
263	cols := svLayout(width)
264	position := 0
265	for _, group := range m.groups {
266		if group.section != secAction {
267			if position > 0 && height >= 10 {
268				position++
269			}
270			if position >= first && position < last {
271				out[position-start] = svFit(sectionRule(sectionTitles[group.section], m.sectionCounts[group.section], width), width)
272			}
273			position++
274		}
275		for offset := max(0, first-position); offset < min(group.count, last-position); offset++ {
276			index := group.first + offset
277			item := m.Filtered[index]
278			session, _ := m.session(item.ID)
279			out[position+offset-start] = svFit(m.row(item, session, group.section, index == m.Cursor, width, cols, now), width)
280		}
281		position += group.count
282	}
283	return out
284}
285
286// scrollWindow shows exactly width × height of lines, centred on selectedAt
287// when they overflow, with ··· where lines were cut off.
288func scrollWindow(all []string, selectedAt, width, height int) []string {
289	return drawWindow(len(all), func(i int) string { return all[i] }, selectedAt, width, height)
290}
291
292// drawWindow is scrollWindow over n lines that line draws on demand, so a
293// long list draws only the lines that show.
294func drawWindow(n int, line func(int) string, selectedAt, width, height int) []string {
295	start := 0
296	if n > height {
297		start = min(max(0, selectedAt-height/2), n-height)
298	}
299	cut := n > height && height >= 3
300	out := make([]string, height)
301	for i := range out {
302		switch at := start + i; {
303		case cut && (i == 0 && start > 0 || i == height-1 && at < n-1):
304			out[i] = svFit(DefaultStyles.Faint.Render("   ···"), width)
305		case at < n:
306			out[i] = svFit(line(at), width)
307		default:
308			out[i] = strings.Repeat(" ", width)
309		}
310	}
311	return out
312}
313
314func (m SessionViewer) row(item PickerItem, s daemon.Session, sec int, selected bool, width int, cols svColumns, now time.Time) string {
315	// A row being renamed drops the selection surface and takes the prompt's
316	// color, so it reads as a place to type rather than a highlight.
317	editing := m.rename.id == item.ID
318	selected = selected && !editing
319	st := func(style lipgloss.Style) lipgloss.Style {
320		if selected {
321			return style.Inherit(DefaultStyles.Selected)
322		}
323		return style
324	}
325	bar := " "
326	if selected {
327		bar = st(DefaultStyles.Agent).Render("▌")
328	}
329	titleStyle := lipgloss.NewStyle()
330	if selected {
331		titleStyle = DefaultStyles.Bold
332	}
333	fill := func(line string) string {
334		if pad := width - ansi.StringWidth(line); pad > 0 {
335			line += st(lipgloss.NewStyle()).Render(strings.Repeat(" ", pad))
336		}
337		return line
338	}
339
340	if sec == secAction {
341		glyph, title, hint, fg := "✦ ", "New session", "in "+sessionText(filepath.Base(m.Workspace)), DefaultStyles.You
342		switch item.ID {
343		case "archive":
344			glyph, title, hint, fg = "▤ ", "Archive", counted(len(m.archivedIDs), "session"), DefaultStyles.Muted
345		case "login":
346			glyph, title, hint, fg = "◇ ", "Accounts", "providers", DefaultStyles.Muted
347		}
348		titleWidth := min(ansi.StringWidth(title), cols.title)
349		return fill(bar + st(fg).Render(glyph) + st(lipgloss.NewStyle()).Render(" ") +
350			st(titleStyle.Inherit(fg)).Render(svCell(title, titleWidth, false)) +
351			st(DefaultStyles.Faint).Render(svCell("  "+hint, max(0, width-4-titleWidth), false)))
352	}
353
354	glyph, iconStyle := "· ", DefaultStyles.Faint
355	switch {
356	case editing:
357		bar, glyph, iconStyle = DefaultStyles.Prompt.Render("▌"), "✎ ", DefaultStyles.Prompt
358	case selected:
359		glyph, iconStyle = "◆ ", DefaultStyles.Agent
360	case sec == secPinned:
361		glyph, iconStyle = "★ ", DefaultStyles.Agent
362	case warm(s, now):
363		glyph, iconStyle = "● ", DefaultStyles.Success
364	}
365	gap := st(lipgloss.NewStyle()).Render("  ")
366	title := st(titleStyle).Render(svCell(item.Label, cols.title, false))
367	if editing {
368		title = m.rename.view(cols.title)
369	}
370	line := bar + st(iconStyle).Render(glyph) + st(lipgloss.NewStyle()).Render(" ") + title
371	if cols.model > 0 {
372		ms := DefaultStyles.Muted
373		if selected {
374			ms = lipgloss.NewStyle()
375		}
376		line += gap + st(ms).Render(svCell(cmp.Or(sessionText(s.Model), "—"), cols.model, false))
377	}
378	if cols.workspace > 0 {
379		ws := DefaultStyles.Muted
380		if s.Workspace == m.Workspace {
381			ws = DefaultStyles.Faint
382		}
383		line += gap + st(ws).Render(svCell(sessionText(filepath.Base(s.Workspace)), cols.workspace, false))
384	}
385	if cols.age > 0 {
386		as := DefaultStyles.Faint
387		if warm(s, now) {
388			as = DefaultStyles.Success
389		}
390		line += gap + st(as).Render(svCell(svCompactAge(s.LastAssistantAt, now), cols.age, true))
391	}
392	return fill(line)
393}
394
395// ── preview ────────────────────────────────────────────────────────────────
396
397// paneBox frames lines into width × height with a one-column side margin.
398func paneBox(lines []string, inner, width, height int) []string {
399	out := make([]string, 0, height)
400	for _, l := range lines[:min(len(lines), height)] {
401		out = append(out, " "+svFit(l, inner)+" ")
402	}
403	for len(out) < height {
404		out = append(out, strings.Repeat(" ", width))
405	}
406	return out
407}
408
409// preview renders the highlighted row's pane at exactly width × height: the
410// session's title and metadata over its latest turns, newest at the bottom.
411func (m SessionViewer) preview(width, height int, now time.Time) []string {
412	inner := max(1, width-2)
413	item, ok := m.Highlighted()
414	s, isSession := m.session(item.ID)
415	switch {
416	case !ok && m.SearchInput.Value() == "":
417		return paneBox([]string{"", DefaultStyles.Muted.Render("Nothing listed here")}, inner, width, height)
418	case !ok:
419		return paneBox([]string{"", DefaultStyles.Muted.Render("No matching sessions"), DefaultStyles.Faint.Render("Try fewer search terms")}, inner, width, height)
420	case !isSession:
421		return paneBox(m.actionPreview(item.ID, inner, height), inner, width, height)
422	}
423
424	var top []string
425	for _, l := range svWrap(sessionTitle(s), inner, 2) {
426		top = append(top, DefaultStyles.Bold.Render(l))
427	}
428	meta := []string{}
429	if s.Pinned {
430		meta = append(meta, DefaultStyles.Agent.Render("★ pinned"))
431	}
432	if s.Model != "" {
433		meta = append(meta, DefaultStyles.Muted.Render(sessionText(s.Model)))
434	}
435	if s.Workspace != "" {
436		meta = append(meta, DefaultStyles.Muted.Render(sessionText(sessionPlace(s))))
437	}
438	meta = append(meta, DefaultStyles.Faint.Render(presentation.AssistantAge(s.LastAssistantAt, now)))
439	if c := m.previews[s.ID]; c != nil && !c.loading && !c.err {
440		meta = append(meta, DefaultStyles.Faint.Render(fmt.Sprintf("%d messages", s.TranscriptCount)))
441	}
442	top = append(top, strings.Join(meta, DefaultStyles.Decor.Render(" · ")), DefaultStyles.Decor.Render(strings.Repeat("─", inner)))
443	bottom := m.transcript(s, inner, max(0, height-len(top)-1))
444	return paneBox(append(top, bottom...), inner, width, height)
445}
446
447// actionPreview is a centred splash for the New session and Accounts rows.
448func (m SessionViewer) actionPreview(id string, inner, height int) []string {
449	var lines []string
450	switch id {
451	case "new":
452		if logo := svLogo(); height >= len(logo)+8 {
453			lines = append(append(lines, ""), logo...)
454		}
455		lines = append(lines, "", gradientText("New session", true), "",
456			DefaultStyles.Muted.Render("start fresh in ")+lipgloss.NewStyle().Render(sessionText(m.workspacePlace())), "",
457			DefaultStyles.Faint.Render("enter ")+DefaultStyles.Muted.Render("begin"))
458	case "archive":
459		lines = []string{"", "", DefaultStyles.Muted.Bold(true).Render("▤ Archive"), "",
460			DefaultStyles.Muted.Render("Sessions kept out of the main list."), "",
461			DefaultStyles.Faint.Render("enter ") + DefaultStyles.Muted.Render("browse")}
462	default:
463		lines = []string{"", "", DefaultStyles.Muted.Bold(true).Render("◇ Accounts"), "",
464			DefaultStyles.Muted.Render("add a provider, sign in, or pick"), DefaultStyles.Muted.Render("which one new sessions use."), "",
465			DefaultStyles.Faint.Render("enter ") + DefaultStyles.Muted.Render("manage")}
466	}
467	for i, l := range lines {
468		lines[i] = strings.Repeat(" ", max(0, (inner-ansi.StringWidth(l))/2)) + l
469	}
470	return lines
471}
472
473// transcript is the tail of the session's conversation in at most height lines.
474func (m SessionViewer) transcript(s daemon.Session, width, height int) []string {
475	if height <= 0 {
476		return nil
477	}
478	c := m.previews[s.ID]
479	note := func(lines ...string) []string {
480		out := []string{""}
481		for _, l := range lines {
482			out = append(out, DefaultStyles.Faint.Render(l))
483		}
484		return out
485	}
486	switch {
487	case m.Fetch == nil:
488		return nil
489	case c == nil || c.loading:
490		return note("loading conversation…")
491	case c.err:
492		return note("Could not load this preview.", "Open the session to read its messages.")
493	case len(c.Items) == 0:
494		return note("no messages yet")
495	}
496
497	// Consecutive tool calls collapse into one line of names with counts.
498	type block struct {
499		counts     map[string]int
500		kind, text string
501		tools      []string
502	}
503	var blocks []*block
504	for _, it := range c.Items {
505		text := sessionText(it.Preview)
506		if it.Type == "assistant" {
507			text = svMarkdownMarks.Replace(text)
508		}
509		if it.Type != "tool" {
510			blocks = append(blocks, &block{kind: it.Type, text: text})
511			continue
512		}
513		if len(blocks) == 0 || blocks[len(blocks)-1].kind != "tool" {
514			blocks = append(blocks, &block{kind: "tool", counts: map[string]int{}})
515		}
516		b := blocks[len(blocks)-1]
517		for name := range strings.SplitSeq(text, ", ") {
518			if b.counts[name] == 0 {
519				b.tools = append(b.tools, name)
520			}
521			b.counts[name]++
522		}
523	}
524	// the same rails as the transcript: one per turn, dimmer through tools
525	r := NewTranscriptRenderer()
526	lanes := map[string]lane{"user": laneYou, "assistant": laneAgent, "tool": laneBusy}
527	var lines []string
528	prev := laneNone
529	for bi, b := range blocks {
530		own := lanes[b.kind]
531		textStyle, maxLines := lipgloss.NewStyle(), 3
532		if b.kind == "tool" {
533			textStyle, maxLines = DefaultStyles.Faint, 2
534			for i, name := range b.tools {
535				if n := b.counts[name]; n > 1 {
536					b.tools[i] = fmt.Sprintf("%s ×%d", name, n)
537				}
538			}
539			b.text = strings.Join(b.tools, ", ")
540		}
541		if bi > 0 && joint(prev, own) == laneNone {
542			lines = append(lines, "")
543		}
544		for _, l := range svWrap(b.text, max(1, width-railWidth), maxLines) {
545			lines = append(lines, r.rail(own)+textStyle.Render(l))
546		}
547		prev = own
548	}
549	lines = append([]string{""}, lines...)
550	if len(lines) > height {
551		lines = lines[len(lines)-height:]
552		lines[0] = DefaultStyles.Faint.Render("··· earlier")
553	}
554	return lines
555}
556
557// svMarkdownMarks drops emphasis and code markers that only cost width in a
558// one-paragraph excerpt.
559var svMarkdownMarks = strings.NewReplacer("**", "", "__", "", "`", "")
560
561// svWrap word-wraps plain text into at most maxLines lines of width columns.
562func svWrap(text string, width, maxLines int) []string {
563	lines := strings.Split(ansi.Wrap(text, width, " -"), "\n")
564	if len(lines) > maxLines {
565		lines = lines[:maxLines]
566		last := strings.TrimRight(lines[maxLines-1], " ")
567		lines[maxLines-1] = ansi.Truncate(last+" …", width, "…")
568	}
569	return lines
570}