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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285package tui
import ( "cmp" "fmt" "slices" "strings"
"charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi")
// setDoc takes a document and its rows, keeping the cursor on the row it was on.func (m *PageViewModel) setDoc(doc *PageDocument) { m.Doc = doc var rows []listEntry if doc != nil { rows = make([]listEntry, len(doc.Rows)) for i, row := range doc.Rows { rows[i] = pageEntry(row) } } m.setRows(rows)}
// focusRow moves the cursor to row id when the list shows it.func (m *PageViewModel) focusRow(id string) { if i := slices.IndexFunc(m.shown, func(s listShown) bool { return m.rows[s.row].key == id }); i >= 0 { m.Cursor = i }}
// pageEntry is how a row reads in the list: its tone dot in front, its text,// its quiet #id at the edge, grouped under its badge.func pageEntry(row PageRow) listEntry { tag := "" if id := rowID(row); id != "" { tag = DefaultStyles.Faint.Render(id) } return listEntry{ key: row.ID, section: row.Badge, lead: toneGlyph(row.Tone), name: row.Text, tag: tag, faint: row.Tone == ToneMuted, search: []string{row.ID, row.Badge}, detail: func(width int) []string { return pageDetail(row, width) }, }}
// toneGlyph is the dot a row wears for its tone; plain rows get a quiet one.func toneGlyph(tone PageTone) string { switch tone { case ToneWarning, ToneActive: return toneStyle(tone).Render("●") } return DefaultStyles.Faint.Render("·")}
// pageDetail is a row's pane: its text over its badge and id, then its// detail paragraphs wrapped to the pane.func pageDetail(row PageRow, width int) []string { meta := row.Badge if id := rowID(row); id != "" { meta += " · " + id } lines := paneTitle(row.Text, meta, width) for paragraph := range strings.SplitSeq(row.Detail, "\n\n") { if paragraph = strings.TrimSpace(paragraph); paragraph != "" { lines = append(lines, "") lines = append(lines, strings.Split(ansi.Wrap(paragraph, width, " -"), "\n")...) } } return lines}
// hasDetail reports whether any row has a detail, the only case the pane shows.func (m PageViewModel) hasDetail() bool { return m.Doc != nil && slices.ContainsFunc(m.Doc.Rows, func(r PageRow) bool { return r.Detail != "" })}
// emptyText is what the list says when it has no rows.func (m PageViewModel) emptyText() string { switch { case m.Doc != nil: return m.Doc.Empty case m.Error != "": return strings.TrimPrefix(m.Command, "/") + " did not load" } return "loading " + strings.TrimPrefix(m.Command, "/") + "…"}
// toneStyle is the color a badge wears for its tone.func toneStyle(tone PageTone) lipgloss.Style { switch tone { case ToneActive: return DefaultStyles.Success case ToneWarning: return DefaultStyles.Warning case ToneMuted: return DefaultStyles.Faint } return DefaultStyles.Muted}
// rowID is the quiet #id a row shows, unless its id is its text.func rowID(row PageRow) string { if row.ID != row.Text { return "#" + row.ID } return ""}
// pageNotice picks the wording for a finished action: the daemon's message// when it sent one, else the action's label.func pageNotice(msg pageActionExecutedMsg) string { notice := fmt.Sprintf("%s completed", msg.Action.Label) if msg.Message != "" { return msg.Message } return notice}
// View draws the rows beside the highlighted row's detail; a prompt or a// question takes the footer, and the filter rests while one is open.func (m PageViewModel) View() string { width := cmp.Or(m.Width, 80) lv := m.listView lv.Empty = m.emptyText() if m.Mode != modeBrowse { lv.input.Blur() } right := "" if m.Doc != nil { right = DefaultStyles.Faint.Render(m.Doc.Summary) } f := lv.frame(brand("albedo")+" "+DefaultStyles.Muted.Render(m.Command), right, m.footer(width)) if m.Doc != nil && len(m.Doc.Rows) > 0 && !m.hasDetail() { f.pane = nil } return f.view(m.Width, m.Height)}
// prompt is the line above the keys where an action asks for what it needs,// blank while browsing or asking a question.func (m PageViewModel) prompt() string { if m.Mode == modeActions { labels := make([]string, len(m.Doc.Actions)) for i, action := range m.Doc.Actions { labels[i] = action.Label if i == m.ActionIndex { labels[i] = "[" + action.Label + "]" } } return " Actions: " + strings.Join(labels, " · ") } if m.CurrentAction == nil { return "" } switch m.Mode { case modeChoice: var options []string for i, opt := range m.fieldChoices() { label := " " + opt + " " if i == m.ChoiceIndex { label = selectedLine(label, 0) } options = append(options, label) } return " " + DefaultStyles.Prompt.Render(m.actionTitle()) + " " + promptLead() + strings.Join(options, "") case modeText: return m.textLead() + m.TextInput.View() } return ""}
// actionTitle is the open action's label and the #id of the row it acts on.func (m PageViewModel) actionTitle() string { act := m.CurrentAction if row := m.currentRow(); act.Row && row != nil { if id := rowID(*row); id != "" { return act.Label + " " + id } } return act.Label}
// textLead is what a text prompt says before its field.func (m PageViewModel) textLead() string { if m.CurrentAction == nil { return "" } field := cmp.Or(m.currentField().Label, m.CurrentAction.Label) return " " + DefaultStyles.Prompt.Render(m.actionTitle()+" · "+field) + " " + promptLead()}
// footer is the prompt or question above the keys and one status: a failure,// a notice, or work in flight.func (m PageViewModel) footer(width int) string { if m.Mode == modeConfirm { return m.confirm.footer(width, m.Error) } status, urgent := m.status() line := footerLine(width, m.hints(), status, urgent) if prompt := m.prompt(); prompt != "" { return prompt + "\n" + line } return line}
// status is the footer's state beside the keys; an urgent one takes a row of// its own when the keys do not leave room for it.func (m PageViewModel) status() (string, bool) { switch { case m.Error != "": return DefaultStyles.Error.Render(m.Error), true case m.Busy: return DefaultStyles.Busy.Render("working…"), true case m.Notice != "": return DefaultStyles.Success.Render(m.Notice), true } return "", false}
// hints is the keys for the mode the page is in, most important first.func (m PageViewModel) hints() []hint { switch { case m.Busy: return []hint{{"esc", "back"}} case m.Doc == nil: return []hint{{"ctrl+r", "retry"}, {"esc", "back"}} case m.Mode == modeText: return []hint{{"enter", "save"}, {"esc", "cancel"}} case m.Mode == modeChoice: return []hint{{"←→", "choose"}, {"enter", "apply"}, {"esc", "cancel"}} case m.Mode == modeActions: return []hint{{"↑↓", "action"}, {"enter", "choose"}, {"esc", "cancel"}} } return m.browseHints()}
// browseHints lists the first two of the row's chords, the menu that lists// them all, the rest of the chords, then refresh, and esc last.func (m PageViewModel) browseHints() []hint { var hints, chords []hint if len(m.rows) > 1 { hints = append(hints, hint{"↑↓", "move"}) } for _, act := range m.Doc.Actions { if act.Key != "" && (!act.Row || m.currentRow() != nil) { chords = append(chords, hint{act.Key, act.Label}) } } first := min(2, len(chords)) hints = append(hints, chords[:first]...) if len(m.Doc.Actions) > 0 { hints = append(hints, hint{"ctrl+a", "actions"}) } hints = append(hints, chords[first:]...) return append(hints, hint{"ctrl+r", "refresh"}, hint{"esc", "back"})}
// fitHints is " " and the labelled hints in room columns. A screen lists its// hints most important first and esc last. What the screen already says goes// first: moving, then the least important hints one at a time from the end,// always keeping esc. Empty when not even three of them fit.func fitHints(hints []hint, room int) string { still := slices.DeleteFunc(slices.Clone(hints), func(h hint) bool { return h.key == "↑↓" }) candidates := [][]hint{hints, still} leaving := slices.IndexFunc(still, func(h hint) bool { return h.key == "esc" }) if leaving >= 0 { staying := slices.Delete(slices.Clone(still), leaving, leaving+1) for n := len(staying) - 1; n >= min(2, len(staying)); n-- { candidates = append(candidates, append(slices.Clone(staying[:n]), still[leaving])) } } for _, candidate := range candidates { if line := " " + keyHints(candidate...); len(candidate) > 0 && ansi.StringWidth(line) <= room { return line } } return ""}