package tui import ( "albedo/cli/internal/daemon" "albedo/cli/internal/presentation" "cmp" "fmt" "path/filepath" "strings" "time" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) // ── logo ─────────────────────────────────────────────────────────────────── // A four-point sparkle with a small companion star, drawn with half blocks so // each pixel is roughly square. var svLogoPixels = []string{ "........#...............", "........#...........#...", ".......###..........#...", ".......###........#####.", "......#####.........#...", ".....#######........#...", "...###########..........", "#################.......", "...###########..........", ".....#######............", "......#####.............", ".......###.........#....", ".......###..............", "........#...............", "........#...............", "........................", } func svLogo() []string { rows := len(svLogoPixels) / 2 width := len(svLogoPixels[0]) lines := make([]string, rows) for r := range rows { var b strings.Builder for c := range width { top := svLogoPixels[2*r][c] == '#' bottom := svLogoPixels[2*r+1][c] == '#' ch := " " switch { case top && bottom: ch = "█" case top: ch = "▀" case bottom: ch = "▄" } // Diagonal sweep so the gradient reads as light glancing off the mark. if ch != " " { t := (float64(c)/float64(width) + float64(r)/float64(rows)) / 2 ch = brandInk(t).Render(ch) } b.WriteString(ch) } lines[r] = b.String() } return lines } // ── helpers ──────────────────────────────────────────────────────────────── func svCompactAge(ts *int64, now time.Time) string { if ts == nil { return "—" } d := now.Sub(time.Unix(*ts, 0)) switch { case d < time.Minute: return "now" case d < time.Hour: return fmt.Sprintf("%dm", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%dh", int(d.Hours())) case d < 14*24*time.Hour: return fmt.Sprintf("%dd", int(d.Hours()/24)) case d < 60*24*time.Hour: return fmt.Sprintf("%dw", int(d.Hours()/24/7)) } return fmt.Sprintf("%dmo", int(d.Hours()/24/30)) } // svCell fits plain text into exactly w columns. func svCell(s string, w int, right bool) string { if w <= 0 { return "" } s = ansi.Truncate(s, w, "…") pad := strings.Repeat(" ", max(0, w-ansi.StringWidth(s))) if right { return pad + s } return s + pad } type svColumns struct { title, model, workspace, age int } func svLayout(width int) svColumns { // Row: bar(1) icon(2) space(1) title … model … workspace … age, two-space gaps. cols := svColumns{} rest := width - 4 if width >= 32 { cols.age = 4 rest -= cols.age + 2 } if width >= 56 { cols.model = min(20, max(14, width/5)) rest -= cols.model + 2 } if width >= 90 { cols.workspace = min(18, max(10, width/8)) rest -= cols.workspace + 2 } cols.title = max(1, rest-1) return cols } // svSep divides the columns. func svSep() string { return DefaultStyles.Decor.Render(" │ ") } // ── view ─────────────────────────────────────────────────────────────────── // View shows the grouped session list beside its preview when there is room, // and the list alone on narrow terminals. func (m SessionViewer) View() string { width := cmp.Or(m.Width, 80) now := m.clock() f := listFrame{ title: located(m.brandName(), sessionText(m.workspacePlace())), filter: promptLead() + m.SearchInput.View(), list: func(w, h int) []string { return m.column(w, h, now) }, pane: func(w, h int) []string { return m.preview(w, h, now) }, paneWidth: halfPane, footer: m.footer(width, now), } return f.view(m.Width, m.Height) } // svFit pads or cuts a styled line to exactly w columns. func svFit(s string, w int) string { return svCell(s, w, false) } // brandName is the title's brand: the archive says so. func (m SessionViewer) brandName() string { if m.ArchiveView { return "albedo archive" } return "albedo" } // footer is the keys and the status, or the question while one is asked. func (m SessionViewer) footer(width int, now time.Time) string { if m.confirm.asking() { return m.confirm.footer(width, "") } status, urgent := m.counts(now), false switch { case m.notice != "": status, urgent = DefaultStyles.Error.Render(m.notice), true case m.Loading: status = DefaultStyles.Faint.Render("loading…") } return footerLine(width, m.hints(), status, urgent) } // hints are the keys for the list, the archive, or a rename in progress. func (m SessionViewer) hints() []hint { switch { case m.rename.active(): return []hint{{"enter", "save"}, {"esc", "cancel"}, {"", "empty restores the automatic title"}} case m.ArchiveView: if _, ok := m.Highlighted(); !ok { return []hint{{"esc", "back"}} } return []hint{{"↑↓", "move"}, {"enter", "open"}, {"^r", "rename"}, {"^a", "restore"}, {"^d", "delete"}, {"esc", "back"}} } esc := "quit" if m.HasActive { esc = "back" } return []hint{{"↑↓", "move"}, {"tab", "switch"}, {"enter", "open"}, {"^r", "rename"}, {"^s", "pin"}, {"^a", "archive"}, {"^f", "folders"}, {"esc", esc}} } // counts is how many sessions the list holds, and how many of them today. func (m SessionViewer) counts(now time.Time) string { count, today := len(m.Sessions), 0 if m.ArchiveView { count = len(m.archivedIDs) } else { for _, s := range m.Sessions { if dateSection(s, now) == secToday { today++ } } } out := DefaultStyles.Faint.Render(counted(count, "session")) if today > 0 { out += DefaultStyles.Decor.Render(" · ") + DefaultStyles.Success.Render(fmt.Sprintf("%d today", today)) } return out } // column renders items with section headings at exactly width × height, // scrolled to keep the cursor visible. func (m SessionViewer) column(width, height int, now time.Time) []string { if len(m.Filtered) == 0 { message := " No matching sessions" switch { case m.ArchiveView && m.SearchInput.Value() == "": message = " No archived sessions yet" case m.Loading: message = " loading sessions…" } return scrollWindow([]string{DefaultStyles.Faint.Render(message)}, -1, width, height) } selectedAt, total := -1, 0 for _, group := range m.groups { if group.section != secAction { if total > 0 && height >= 10 { total++ } total++ } if m.Cursor >= group.first && m.Cursor < group.first+group.count { selectedAt = total + m.Cursor - group.first } total += group.count } start := 0 if total > height { start = min(max(0, selectedAt-height/2), total-height) } end := min(total, start+height) out := make([]string, height) blank := strings.Repeat(" ", width) for i := range out { out[i] = blank } // Overflow markers replace edge rows rather than adding to the window. first, last := start, end if total > height && height >= 3 { more := svFit(DefaultStyles.Faint.Render(" ···"), width) if start > 0 { out[0] = more first++ } if end < total { out[height-1] = more last-- } } cols := svLayout(width) position := 0 for _, group := range m.groups { if group.section != secAction { if position > 0 && height >= 10 { position++ } if position >= first && position < last { out[position-start] = svFit(sectionRule(sectionTitles[group.section], m.sectionCounts[group.section], width), width) } position++ } for offset := max(0, first-position); offset < min(group.count, last-position); offset++ { index := group.first + offset item := m.Filtered[index] session, _ := m.session(item.ID) out[position+offset-start] = svFit(m.row(item, session, group.section, index == m.Cursor, width, cols, now), width) } position += group.count } return out } // scrollWindow shows exactly width × height of lines, centred on selectedAt // when they overflow, with ··· where lines were cut off. func scrollWindow(all []string, selectedAt, width, height int) []string { return drawWindow(len(all), func(i int) string { return all[i] }, selectedAt, width, height) } // drawWindow is scrollWindow over n lines that line draws on demand, so a // long list draws only the lines that show. func drawWindow(n int, line func(int) string, selectedAt, width, height int) []string { start := 0 if n > height { start = min(max(0, selectedAt-height/2), n-height) } cut := n > height && height >= 3 out := make([]string, height) for i := range out { switch at := start + i; { case cut && (i == 0 && start > 0 || i == height-1 && at < n-1): out[i] = svFit(DefaultStyles.Faint.Render(" ···"), width) case at < n: out[i] = svFit(line(at), width) default: out[i] = strings.Repeat(" ", width) } } return out } func (m SessionViewer) row(item PickerItem, s daemon.Session, sec int, selected bool, width int, cols svColumns, now time.Time) string { // A row being renamed drops the selection surface and takes the prompt's // color, so it reads as a place to type rather than a highlight. editing := m.rename.id == item.ID selected = selected && !editing st := func(style lipgloss.Style) lipgloss.Style { if selected { return style.Inherit(DefaultStyles.Selected) } return style } bar := " " if selected { bar = st(DefaultStyles.Agent).Render("▌") } titleStyle := lipgloss.NewStyle() if selected { titleStyle = DefaultStyles.Bold } fill := func(line string) string { if pad := width - ansi.StringWidth(line); pad > 0 { line += st(lipgloss.NewStyle()).Render(strings.Repeat(" ", pad)) } return line } if sec == secAction { glyph, title, hint, fg := "✦ ", "New session", "in "+sessionText(filepath.Base(m.Workspace)), DefaultStyles.You switch item.ID { case "archive": glyph, title, hint, fg = "▤ ", "Archive", counted(len(m.archivedIDs), "session"), DefaultStyles.Muted case "login": glyph, title, hint, fg = "◇ ", "Accounts", "providers", DefaultStyles.Muted } titleWidth := min(ansi.StringWidth(title), cols.title) return fill(bar + st(fg).Render(glyph) + st(lipgloss.NewStyle()).Render(" ") + st(titleStyle.Inherit(fg)).Render(svCell(title, titleWidth, false)) + st(DefaultStyles.Faint).Render(svCell(" "+hint, max(0, width-4-titleWidth), false))) } glyph, iconStyle := "· ", DefaultStyles.Faint switch { case editing: bar, glyph, iconStyle = DefaultStyles.Prompt.Render("▌"), "✎ ", DefaultStyles.Prompt case selected: glyph, iconStyle = "◆ ", DefaultStyles.Agent case sec == secPinned: glyph, iconStyle = "★ ", DefaultStyles.Agent case warm(s, now): glyph, iconStyle = "● ", DefaultStyles.Success } gap := st(lipgloss.NewStyle()).Render(" ") title := st(titleStyle).Render(svCell(item.Label, cols.title, false)) if editing { title = m.rename.view(cols.title) } line := bar + st(iconStyle).Render(glyph) + st(lipgloss.NewStyle()).Render(" ") + title if cols.model > 0 { ms := DefaultStyles.Muted if selected { ms = lipgloss.NewStyle() } line += gap + st(ms).Render(svCell(cmp.Or(sessionText(s.Model), "—"), cols.model, false)) } if cols.workspace > 0 { ws := DefaultStyles.Muted if s.Workspace == m.Workspace { ws = DefaultStyles.Faint } line += gap + st(ws).Render(svCell(sessionText(filepath.Base(s.Workspace)), cols.workspace, false)) } if cols.age > 0 { as := DefaultStyles.Faint if warm(s, now) { as = DefaultStyles.Success } line += gap + st(as).Render(svCell(svCompactAge(s.LastAssistantAt, now), cols.age, true)) } return fill(line) } // ── preview ──────────────────────────────────────────────────────────────── // paneBox frames lines into width × height with a one-column side margin. func paneBox(lines []string, inner, width, height int) []string { out := make([]string, 0, height) for _, l := range lines[:min(len(lines), height)] { out = append(out, " "+svFit(l, inner)+" ") } for len(out) < height { out = append(out, strings.Repeat(" ", width)) } return out } // preview renders the highlighted row's pane at exactly width × height: the // session's title and metadata over its latest turns, newest at the bottom. func (m SessionViewer) preview(width, height int, now time.Time) []string { inner := max(1, width-2) item, ok := m.Highlighted() s, isSession := m.session(item.ID) switch { case !ok && m.SearchInput.Value() == "": return paneBox([]string{"", DefaultStyles.Muted.Render("Nothing listed here")}, inner, width, height) case !ok: return paneBox([]string{"", DefaultStyles.Muted.Render("No matching sessions"), DefaultStyles.Faint.Render("Try fewer search terms")}, inner, width, height) case !isSession: return paneBox(m.actionPreview(item.ID, inner, height), inner, width, height) } var top []string for _, l := range svWrap(sessionTitle(s), inner, 2) { top = append(top, DefaultStyles.Bold.Render(l)) } meta := []string{} if s.Pinned { meta = append(meta, DefaultStyles.Agent.Render("★ pinned")) } if s.Model != "" { meta = append(meta, DefaultStyles.Muted.Render(sessionText(s.Model))) } if s.Workspace != "" { meta = append(meta, DefaultStyles.Muted.Render(sessionText(sessionPlace(s)))) } meta = append(meta, DefaultStyles.Faint.Render(presentation.AssistantAge(s.LastAssistantAt, now))) if c := m.previews[s.ID]; c != nil && !c.loading && !c.err { meta = append(meta, DefaultStyles.Faint.Render(fmt.Sprintf("%d messages", s.TranscriptCount))) } top = append(top, strings.Join(meta, DefaultStyles.Decor.Render(" · ")), DefaultStyles.Decor.Render(strings.Repeat("─", inner))) bottom := m.transcript(s, inner, max(0, height-len(top)-1)) return paneBox(append(top, bottom...), inner, width, height) } // actionPreview is a centred splash for the New session and Accounts rows. func (m SessionViewer) actionPreview(id string, inner, height int) []string { var lines []string switch id { case "new": if logo := svLogo(); height >= len(logo)+8 { lines = append(append(lines, ""), logo...) } lines = append(lines, "", gradientText("New session", true), "", DefaultStyles.Muted.Render("start fresh in ")+lipgloss.NewStyle().Render(sessionText(m.workspacePlace())), "", DefaultStyles.Faint.Render("enter ")+DefaultStyles.Muted.Render("begin")) case "archive": lines = []string{"", "", DefaultStyles.Muted.Bold(true).Render("▤ Archive"), "", DefaultStyles.Muted.Render("Sessions kept out of the main list."), "", DefaultStyles.Faint.Render("enter ") + DefaultStyles.Muted.Render("browse")} default: lines = []string{"", "", DefaultStyles.Muted.Bold(true).Render("◇ Accounts"), "", DefaultStyles.Muted.Render("add a provider, sign in, or pick"), DefaultStyles.Muted.Render("which one new sessions use."), "", DefaultStyles.Faint.Render("enter ") + DefaultStyles.Muted.Render("manage")} } for i, l := range lines { lines[i] = strings.Repeat(" ", max(0, (inner-ansi.StringWidth(l))/2)) + l } return lines } // transcript is the tail of the session's conversation in at most height lines. func (m SessionViewer) transcript(s daemon.Session, width, height int) []string { if height <= 0 { return nil } c := m.previews[s.ID] note := func(lines ...string) []string { out := []string{""} for _, l := range lines { out = append(out, DefaultStyles.Faint.Render(l)) } return out } switch { case m.Fetch == nil: return nil case c == nil || c.loading: return note("loading conversation…") case c.err: return note("Could not load this preview.", "Open the session to read its messages.") case len(c.Items) == 0: return note("no messages yet") } // Consecutive tool calls collapse into one line of names with counts. type block struct { counts map[string]int kind, text string tools []string } var blocks []*block for _, it := range c.Items { text := sessionText(it.Preview) if it.Type == "assistant" { text = svMarkdownMarks.Replace(text) } if it.Type != "tool" { blocks = append(blocks, &block{kind: it.Type, text: text}) continue } if len(blocks) == 0 || blocks[len(blocks)-1].kind != "tool" { blocks = append(blocks, &block{kind: "tool", counts: map[string]int{}}) } b := blocks[len(blocks)-1] for name := range strings.SplitSeq(text, ", ") { if b.counts[name] == 0 { b.tools = append(b.tools, name) } b.counts[name]++ } } // the same rails as the transcript: one per turn, dimmer through tools r := NewTranscriptRenderer() lanes := map[string]lane{"user": laneYou, "assistant": laneAgent, "tool": laneBusy} var lines []string prev := laneNone for bi, b := range blocks { own := lanes[b.kind] textStyle, maxLines := lipgloss.NewStyle(), 3 if b.kind == "tool" { textStyle, maxLines = DefaultStyles.Faint, 2 for i, name := range b.tools { if n := b.counts[name]; n > 1 { b.tools[i] = fmt.Sprintf("%s ×%d", name, n) } } b.text = strings.Join(b.tools, ", ") } if bi > 0 && joint(prev, own) == laneNone { lines = append(lines, "") } for _, l := range svWrap(b.text, max(1, width-railWidth), maxLines) { lines = append(lines, r.rail(own)+textStyle.Render(l)) } prev = own } lines = append([]string{""}, lines...) if len(lines) > height { lines = lines[len(lines)-height:] lines[0] = DefaultStyles.Faint.Render("··· earlier") } return lines } // svMarkdownMarks drops emphasis and code markers that only cost width in a // one-paragraph excerpt. var svMarkdownMarks = strings.NewReplacer("**", "", "__", "", "`", "") // svWrap word-wraps plain text into at most maxLines lines of width columns. func svWrap(text string, width, maxLines int) []string { lines := strings.Split(ansi.Wrap(text, width, " -"), "\n") if len(lines) > maxLines { lines = lines[:maxLines] last := strings.TrimRight(lines[maxLines-1], " ") lines[maxLines-1] = ansi.Truncate(last+" …", width, "…") } return lines }