Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657package tui
import ( "albedo/cli/internal/daemon" "cmp" "fmt" "math" "path" "slices" "strings" "time"
"charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi")
// folderChrome is the rows around the list: the title, the filter with its// rules, and the footer.const folderChrome = 6
// visibleRows is the range of rows the list window shows, the way// scrollWindow cuts it under the section rule.func (m FolderPicker) visibleRows() (first, last int) { h, n := max(1, cmp.Or(m.Height, 24)-folderChrome), len(m.rows)+1 if n <= h { return 0, len(m.rows) } start := min(max(0, m.cursor+1-h/2), n-h) return max(0, start-1), min(len(m.rows), start+h-1)}
func (m FolderPicker) View() string { width := cmp.Or(m.Width, 80) heading, title := "sessions by folder", "" if !m.browse { heading, title = "move session", DefaultStyles.Faint.Render(sessionTitle(m.session)) } return listFrame{ title: brand("albedo") + " " + DefaultStyles.Muted.Render(heading), right: title, filter: promptLead() + m.input.View(), list: m.list, paneWidth: halfPane, pane: func(w, h int) []string { pane := m.preview(w-1, h) for i := range pane { pane[i] = svFit(pane[i], w-1) + " " } return pane }, footer: m.footer(width), }.view(m.Width, m.Height)}
// narrowKeys is the width the full keys need beside a status; less than that// and a failed session list keeps only its retry.const narrowKeys = 28
// footer is the keys on the left and the most pressing thing to say on the// right: an urgent one whole, for footerLine to place, a quiet one in what// the keys leave.func (m FolderPicker) footer(width int) string { hints := m.hints() keys := " " + keyHints(hints...) if m.sessionsError != "" && width-ansi.StringWidth(keys) < narrowKeys { hints = []hint{m.retryHint()} keys = " " + keyHints(hints...) } status, urgent := m.status(width - 2) if !urgent { status, _ = m.status(width - ansi.StringWidth(keys) - 3) } return footerLine(width, hints, status, urgent)}
// status is the most pressing thing to say in room columns: a move in// flight, a refusal, a failed listing, a folder that went missing, or else// where the session is.func (m FolderPicker) status(room int) (string, bool) { switch { case m.moving: return DefaultStyles.Faint.Render("moving…"), true case m.notice != "": return DefaultStyles.Warning.Render(tailFit(m.notice, room)), true case m.sessionsError != "": return DefaultStyles.Warning.Render(ansi.Truncate("Recent sessions: "+m.sessionsError, room, "…")), true case m.retry != nil: const missing = "Folder not found: " return DefaultStyles.Warning.Render(missing) + DefaultStyles.Muted.Render(tailFit(m.homed(m.retry.Missing), room-ansi.StringWidth(missing))), true case m.browse: // nothing moves, so there is no "now in" return "", false } const here = "now in " where := tailFit(m.homed(m.workspace()), room-ansi.StringWidth(here)) if where == "" { return "", false } return DefaultStyles.Faint.Render(here) + DefaultStyles.Muted.Render(where), false}
// hints are the footer's keys for where the cursor is; → shows only when// the highlighted folder has sessions to step into.func (m FolderPicker) hints() []hint { var hints []hint if m.sessionsError != "" { hints = append(hints, m.retryHint()) } if m.inSessions { return append(hints, hint{"↑↓", "move"}, hint{"enter", "open"}, hint{"←", "folders"}) } row, ok := m.highlighted() if ok { hints = append(hints, hint{"↑↓", "move"}, hint{"tab", "open"}) } hints = append(hints, hint{"shift+tab", "up"}) if ok && len(m.sessionsIn(row.path)) > 0 { hints = append(hints, hint{"→", "sessions"}) } if ok && m.probe(row.hostKey).State == "needs_auth" { hints = append(hints, hint{"ctrl+l", "sign in"}) } if m.target() != "" { action := "move here" if m.browse { action = "new session" } hints = append(hints, hint{"enter", action}) } return append(hints, hint{"esc", "back"})}
// retryHint is the way back into a session list that failed to load.func (m FolderPicker) retryHint() hint { if m.sessionsLoading { return hint{"ctrl+r", "retrying…"} } return hint{"ctrl+r", "retry sessions"}}
// tailFit keeps the end of plain text, where a path names its folder.func tailFit(s string, w int) string { if w <= 0 { return "" } if n := ansi.StringWidth(s); n > w { return "…" + ansi.TruncateLeft(s, n-w+1, "") } return s}
// list is the section rule over the rows, at exactly width × height.func (m FolderPicker) list(width, height int) []string { if len(m.rows) == 0 { return scrollWindow([]string{sectionRule(m.section, 0, width), DefaultStyles.Faint.Render(" " + m.emptyNote())}, 1, width, height) } here, latest := m.workspace(), m.latestOther() tagW := 0 if slices.ContainsFunc(m.rows, func(r folderRow) bool { return repoTag(m.repos[r.path]) != "" }) { tagW = 12 } now := time.Now() return drawWindow(len(m.rows)+1, func(i int) string { if i == 0 { return sectionRule(m.section, len(m.rows), width) } row := m.rows[i-1] return m.row(row, i-1 == m.cursor, row.path == here, row.path == latest, width, tagW, now) }, m.cursor+1, width, height)}
// typedHost is the host a remote listing is typed for, as a row.func (m FolderPicker) typedHost() folderRow { if host, _, remote := splitHost(m.listed); remote { return folderRow{hostKey: m.canonical(host), hostRow: true} } return folderRow{}}
func (m FolderPicker) emptyNote() string { l := m.listings[m.listed] host, _, remote := splitHost(m.listed) switch { case m.listed == "" && m.section == "hosts": return "no known hosts match; type host: to browse one" case m.listed == "": return "no matches" case l == nil && remote && m.probe(m.canonical(host)).State == "warming": label := m.labelOf(host) return reachingText(label, m.probe(m.canonical(host)).Step) + " " + DefaultStyles.Agent.Render(connectingFace(label, m.frame)) case l == nil: return "loading…" case l.Err != nil: return DefaultStyles.Error.Render(l.Err.Error()) case len(l.List.Entries) == 0: return "No folders here" } return "no matches"}
// latestOther is the most recently used recent folder besides this one.func (m FolderPicker) latestOther() string { best, at := "", int64(0) for _, row := range m.rows { if row.recent && row.age != nil && *row.age > at && row.path != m.workspace() { best, at = row.path, *row.age } } return best}
// where is the folder a row is in, led by its host when it is remote; a// host row says where the host is known from.func (m FolderPicker) where(row folderRow) string { switch { case row.hostRow && row.source == "config": return "ssh config" case row.hostRow: return "recent host" } host, p := daemon.SplitLocation(row.path) if host == "" { return strings.Join(crumbParts(m.homed(path.Dir(p))), " › ") } // the host leads the first crumb, as in the preview's rule crumbs := crumbParts(scpRelative(underHome(path.Dir(p), m.homes[m.canonical(host)]))) crumbs[0] = cmp.Or(row.host, m.labelOf(host)) + ":" + crumbs[0] return strings.Join(crumbs, " › ")}
// reach is a remote row's host state where its head and age would go:// nothing once ready or while warming (the row is faint then), what is// wrong otherwise, and the way in when a person must sign in.func (m FolderPicker) reach(row folderRow) (note string, style lipgloss.Style, dim bool) { p := m.probe(row.hostKey) switch p.State { case "warming": return "", DefaultStyles.Faint, true case "needs_auth": return "sign in · ctrl+l", DefaultStyles.Warning, false case "unreachable", "unsupported": return cmp.Or(p.Detail, p.State), DefaultStyles.Error, false } return "", DefaultStyles.Faint, false}
// row is a folder in the sessions view's grammar: bar, glyph, name with// the matched letters lit, then quiet columns for where, head and age. The// head column is tagW wide, none when no row has a head, and where takes// what the others leave.func (m FolderPicker) row(row folderRow, selected, here, latest bool, width, tagW int, now time.Time) string { glyph, glyphStyle := "· ", DefaultStyles.Faint switch { case selected: glyph, glyphStyle = "◆ ", DefaultStyles.Agent case here: glyph = "○ " case latest: glyph, glyphStyle = "● ", DefaultStyles.Success } ageW, tail := 4, 6 if tagW > 0 { tail += tagW + 2 } nameW, whereW, noteW := min(18, max(4, width-4-tail)), 0, max(tail-2, 18) if (row.recent || row.hostRow) && width >= 48 { nameW = min(18, max(4, width-4-tail-14)) whereW = width - 4 - nameW - 2 - tail } bar := " " if selected { bar = selectBar() } note, noteStyle, dim := m.reach(row) base := lipgloss.NewStyle() switch { case dim: base = DefaultStyles.Faint case selected: base = DefaultStyles.Bold } line := bar + glyphStyle.Render(glyph) + " " + litName(row.name, row.matched, nameW, base) if note != "" { if whereW -= noteW - (tail - 2); whereW < 8 { whereW, noteW = 0, width-6-nameW } } if whereW > 0 { line += " " + DefaultStyles.Faint.Render(svCell(m.where(row), whereW, false)) } if note != "" { line += " " + noteStyle.Render(svCell(note, noteW, false)) if selected { return selectedLine(line, width) } return line } if tagW > 0 { line += " " + DefaultStyles.Faint.Render(svCell(repoTag(m.repos[row.path]), tagW, false)) } age := "" if here { age = "here" } else if row.age != nil { age = svCompactAge(row.age, now) } line += " " + DefaultStyles.Faint.Render(svCell(age, ageW, true)) if selected { return selectedLine(line, width) } return line}
// litName fits name into w columns in base, its fuzzy-matched letters lit.func litName(name string, matched []int, w int, base lipgloss.Style) string { lit := DefaultStyles.Prompt.Bold(true).Underline(true) fitted := ansi.Truncate(name, w, "…") var b strings.Builder for i, r := range fitted { style := base if slices.Contains(matched, i) { style = lit } b.WriteString(style.Render(string(r))) } return b.String() + strings.Repeat(" ", max(0, w-ansi.StringWidth(fitted)))}
// crumbParts splits a homed path into the folders it passes through.func crumbParts(p string) []string { if p == "/" { return []string{"/"} } if strings.HasPrefix(p, "/") { return append([]string{"/"}, strings.Split(p[1:], "/")...) } return strings.Split(p, "/")}
// repoTag is a list row's head: a git branch or detached commit, or a jj// bookmark or change.func repoTag(r *daemon.Repo) string { switch { case r == nil: return "" case r.Kind == "jj" && r.Bookmark != nil: return "⚑ " + r.Bookmark.Name case r.Kind == "jj": if r.Change != "" { return "@ " + r.Change } return "" } if r.Branch != "" || r.Commit != "" { return "⎇ " + cmp.Or(r.Branch, r.Commit) } return ""}
// ── preview ────────────────────────────────────────────────────────────────
// preview is the highlighted folder in exactly height rows: its path// over its languages, the repository as the root of a two-level tree, then// the sessions working there.func (m FolderPicker) preview(width, height int) []string { row, ok := m.highlighted() if !ok { if row = m.typedHost(); row.hostKey == "" { return make([]string, height) } } if row.hostRow || !m.reachable(row) { return m.hostPane(row, width, height) } c := m.previews[row.path] var body []string var languages []daemon.LanguageShare var tree *daemon.FolderPreview switch { case c == nil || c.loading: body = []string{DefaultStyles.Faint.Render("looking…")} case c.err: body = []string{DefaultStyles.Faint.Render("Could not open this folder")} default: languages, tree = c.Languages, &c.FolderPreview if c.Repo != nil { body = append(body, repoLine(c.Repo, time.Now())) } } // The tree fits around a third of the pane kept for the sessions, then // the sessions fill whatever the tree left. sessions := m.sessionsIn(row.path) if tree != nil { kept := 0 if len(sessions) > 0 { kept = sessionHead + min(len(sessions), max(3, height/3)) } body = append(body, fitTree(*tree, height-2-len(body)-kept)...) } lines := []string{m.paneRule(row.path, languages, width), ""} if m.inSessions && height-len(lines)-len(body)-sessionHead < min(3, len(sessions)) { lines = append(lines[:1], m.sessionRows(sessions, width, height-1)...) return append(lines, make([]string, max(0, height-len(lines)))...)[:height] } if len(body) == 0 { lines = lines[:1] } for _, l := range body { lines = append(lines, " "+l) } lines = append(lines, m.sessionBlock(sessions, width, height-len(lines)-sessionHead)...) return append(lines, make([]string, max(0, height-len(lines)))...)[:height]}
// stacked is whether the frame draws the pane under the list, below its own// divider, so the pane's heading needs no rule of its own.func (m FolderPicker) stacked() bool { return cmp.Or(m.Width, 80) < paneMinWidth || cmp.Or(m.Height, 24) < paneMinHeight}
// hostPane is a host, or a folder on a host not reached yet: how the host// answers, then what it runs and where ~ is once it is ready.func (m FolderPicker) hostPane(row folderRow, width, height int) []string { p := m.probe(row.hostKey) label := m.labelOf(row.hostKey) var body []string switch p.State { case "", "warming": body = []string{DefaultStyles.Faint.Render(reachingText(label, p.Step)+" ") + DefaultStyles.Agent.Render(connectingFace(label, m.frame))} case "ready": if p.OS != "" { body = append(body, DefaultStyles.Muted.Render(strings.TrimSpace(p.OS+" "+p.Arch))) } if home := m.homes[m.canonical(row.hostKey)]; home != "" { body = append(body, DefaultStyles.Faint.Render("~ is ")+DefaultStyles.Muted.Render(home)) } case "needs_auth": body = []string{DefaultStyles.Warning.Render(cmp.Or(p.Detail, "needs a person to sign in")), DefaultStyles.Faint.Render("ctrl+l signs in here")} default: body = []string{DefaultStyles.Error.Render(cmp.Or(p.Detail, p.State))} } head := DefaultStyles.Decor.Render("─ ") + hostStyle(label).Render(label) + " " + DefaultStyles.Decor.Render(strings.Repeat("─", max(0, width-3-ansi.StringWidth(label)))) if m.stacked() { head = hostStyle(label).Bold(true).Render(label) } lines := []string{head, ""} for _, l := range body { lines = append(lines, " "+ansi.Truncate(l, max(0, width-2), "…")) } if !row.hostRow { lines = append(lines, m.sessionBlock(m.sessionsIn(row.path), width, height-len(lines)-sessionHead)...) } return append(lines, make([]string, max(0, height-len(lines)))...)[:height]}
// sessionHead is the rows above the sessions: their rule between blanks.const sessionHead = 3
// sessionBlock is the sessions working in the previewed folder under their// rule, in at most room rows. Stepped into, it follows the cursor.func (m FolderPicker) sessionBlock(sessions []daemon.Session, width, room int) []string { if len(sessions) == 0 || room <= 0 { return nil } return append([]string{"", plainRule("sessions", width), ""}, m.sessionRows(sessions, width, room)...)}
// sessionRows are the sessions in at most room rows, without their rule.func (m FolderPicker) sessionRows(sessions []daemon.Session, width, room int) []string { now := time.Now() rows := make([]string, len(sessions)) for i, s := range sessions { selected := m.inSessions && i == m.sessionCursor glyph, style := "· ", DefaultStyles.Faint switch { case selected: glyph, style = "◆ ", DefaultStyles.Agent case s.LastAssistantAt != nil && now.Sub(time.Unix(*s.LastAssistantAt, 0)) < time.Hour: glyph, style = "● ", DefaultStyles.Success } titleStyle := DefaultStyles.Muted bar := " " if selected { titleStyle = DefaultStyles.Bold bar = selectBar() } title := titleStyle.Render(sessionTitle(s)) line := bar + " " + style.Render(glyph) + title if s.LastAssistantAt != nil { line += DefaultStyles.Faint.Render(" " + svCompactAge(s.LastAssistantAt, now)) } if selected { line = selectedLine(line, width) } rows[i] = line } if len(rows) > room && !m.inSessions { rows = append(rows[:room-1], DefaultStyles.Faint.Render(fmt.Sprintf(" … %d more", len(sessions)-room+1))) } return scrollWindow(rows, m.sessionCursor, width, min(room, len(rows)))}
func (m FolderPicker) sessionsIn(p string) []daemon.Session { return slices.DeleteFunc(slices.Clone(m.sessions), func(s daemon.Session) bool { return s.Workspace != p })}
// paneRule heads the preview like sectionRule heads the list: the folder's// breadcrumbs, then a rule split by language share, then the language that// leads.func (m FolderPicker) paneRule(p string, languages []daemon.LanguageShare, w int) string { parts := crumbParts(m.homed(p)) lead := func() string { return DefaultStyles.Decor.Render("─ ") + crumbs(parts) + " " } tail := "" if len(languages) > 0 { tail = " " + languageStyle(languages[0].Name, knownLanguageColor(languages[0].Color)).Render(strings.ToLower(languages[0].Name)) + " " } for len(parts) > 2 && ansi.StringWidth(lead()+tail) > w-8 { parts = append([]string{"…"}, parts[2:]...) } if m.stacked() { return crumbs(parts) + tail } n := max(0, w-ansi.StringWidth(lead())-ansi.StringWidth(tail)) var rule strings.Builder used, sum := 0, 0.0 for _, l := range languages { sum += l.Share k := min(n, int(float64(n)*sum+0.5)) - used rule.WriteString(languageStyle(l.Name, knownLanguageColor(l.Color)).Render(strings.Repeat("─", k))) used += k } rule.WriteString(DefaultStyles.Decor.Render(strings.Repeat("─", n-used))) return lead() + rule.String() + tail}
// crumbs is a path as soft breadcrumbs, the leaf in bold.func crumbs(parts []string) string { out := make([]string, len(parts)) for i, p := range parts { out[i] = DefaultStyles.Muted.Bold(i == len(parts)-1).Render(p) } return strings.Join(out, DefaultStyles.Decor.Render(" › "))}
// plainRule is a section rule without a count.func plainRule(label string, w int) string { head := " " + label + " " return DefaultStyles.Decor.Render("─") + DefaultStyles.Muted.Bold(true).Render(head) + DefaultStyles.Decor.Render(strings.Repeat("─", max(0, w-1-ansi.StringWidth(head))))}
// repoLine is the repository at the root of the tree: the head, how many// files changed when any did, and when it was last touched.func repoLine(r *daemon.Repo, now time.Time) string { var parts []string switch { case r.Kind == "jj": head := "" if r.Bookmark != nil { head = DefaultStyles.Agent.Render("⚑ " + r.Bookmark.Name) if r.Bookmark.Ahead > 0 { head += DefaultStyles.Faint.Render(fmt.Sprintf("+%d", r.Bookmark.Ahead)) } parts = append(parts, head) } if r.Change != "" { parts = append(parts, DefaultStyles.Faint.Render("@ ")+DefaultStyles.Muted.Render(r.Change)) } case r.Branch != "" || r.Commit != "": parts = append(parts, DefaultStyles.Agent.Render("⎇ "+cmp.Or(r.Branch, r.Commit))) } if r.Changed != nil && *r.Changed > 0 { parts = append(parts, DefaultStyles.Warning.Render(fmt.Sprintf("✎ %d", *r.Changed))) } if r.Touched != nil { parts = append(parts, DefaultStyles.Faint.Render(svCompactAge(r.Touched, now))) } return strings.Join(parts, " ")}
// fitTree is the preview's tree in at most rows lines. When the two levels// do not fit, directories show fewer children, then none, and last the// top level itself is cut short.func fitTree(p daemon.FolderPreview, rows int) []string { for _, children := range []int{math.MaxInt, 2, 0} { if out := folderTree(p, children, len(p.Tree)); len(out) <= rows { return out } } for top := len(p.Tree) - 1; top > 0; top-- { if out := folderTree(p, 0, top); len(out) <= rows { return out } } return nil}
// folderTree draws the preview's two levels, at most children under each// directory and top entries in all: directories muted with a slash, files// in their language's color, changes noted beside them. With no children// shown, directories stand alone.func folderTree(p daemon.FolderPreview, children, top int) []string { colors := map[string]string{} for _, l := range p.Languages { colors[l.Name] = knownLanguageColor(l.Color) } branch := func(last bool) string { text := "├─ " if last { text = "╰─ " } return DefaultStyles.Decor.Render(text) } more := func(n int) string { return DefaultStyles.Faint.Render(fmt.Sprintf("… %d more", n)) } name := func(n daemon.FolderNode) string { s := DefaultStyles.Muted.Render(n.Name) + DefaultStyles.Decor.Render("/") if !n.Dir { s = languageStyle(n.Language, colors[n.Language]).Render(n.Name) } if n.Changed > 0 { s += " " + DefaultStyles.Faint.Render(fmt.Sprintf("✎ %d", n.Changed)) } return s } tree, hidden := p.Tree[:min(top, len(p.Tree))], p.More+max(0, len(p.Tree)-top) var out []string for i, entry := range tree { last := i == len(tree)-1 && hidden == 0 out = append(out, branch(last)+name(entry)) if children == 0 { continue } indentText := "│ " if last { indentText = " " } indent := DefaultStyles.Decor.Render(indentText) shown := entry.Children[:min(children, len(entry.Children))] rest := entry.More + len(entry.Children) - len(shown) for j, child := range shown { out = append(out, indent+branch(j == len(shown)-1 && rest == 0)+name(child)) } if rest > 0 { out = append(out, indent+branch(true)+more(rest)) } } if hidden > 0 { out = append(out, branch(true)+more(hidden)) } return out}
func knownLanguageColor(color *string) string { if color != nil { return *color } return ""}