package tui
import (
"albedo/cli/internal/daemon"
"cmp"
"context"
"errors"
"fmt"
"slices"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
type PageTone = daemon.PageTone
const (
TonePlain = daemon.TonePlain
ToneActive = daemon.ToneActive
ToneWarning = daemon.ToneWarning
ToneMuted = daemon.ToneMuted
)
type PageRow = daemon.PageRow
type PageAction = daemon.PageAction
type PageGlance = daemon.PageGlance
type PageDocument = daemon.PageDocument
type PageCancelMsg struct{}
type PageViewChangedMsg struct{}
type pageLoadedMsg struct {
Doc *PageDocument
Err error
Gen int
}
type pageActionExecutedMsg struct {
Err error
Message string
Action PageAction
Gen int
}
type pageModeKind int
const (
modeBrowse pageModeKind = iota
modeText
modeChoice
modeConfirm
)
type PageViewModel struct {
Styles Styles
Conn *daemon.Connection
Doc *PageDocument
CurrentAction *PageAction
SessionID string
Command string
SelectedID string
TextInput textinput.Model
page
Mode pageModeKind
ChoiceIndex int
Busy bool
}
func NewPageViewModel(conn *daemon.Connection, sessionID, command string) PageViewModel {
return PageViewModel{
Conn: conn,
SessionID: sessionID,
Command: command,
TextInput: newField(),
page: page{Generation: nextPageGeneration()},
Busy: true,
Styles: DefaultStyles,
}
}
func (m *PageViewModel) SetSize(width, height int) {
m.Width = width
m.Height = height
m.TextInput.SetWidth(max(10, width-20))
}
func (m PageViewModel) Init() tea.Cmd {
return m.loadPageCmd(m.Generation)
}
func (m PageViewModel) loadPageCmd(gen int) tea.Cmd {
return func() tea.Msg {
if m.Conn == nil {
return pageLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
}
doc, err := daemon.LoadPage(context.Background(), m.Conn, m.SessionID, m.Command)
if err != nil {
return pageLoadedMsg{Err: err, Gen: gen}
}
return pageLoadedMsg{Doc: doc, Gen: gen}
}
}
func (m PageViewModel) executeActionCmd(act PageAction, row *PageRow, entered string, gen int) tea.Cmd {
return func() tea.Msg {
if m.Conn == nil {
return pageActionExecutedMsg{Action: act, Err: errors.New("daemon connection unavailable"), Gen: gen}
}
res, err := daemon.ExecutePageAction(context.Background(), m.Conn, m.SessionID, daemon.PageActionRequest{Command: m.Command, Action: act, Row: row, Value: entered})
return pageActionExecutedMsg{Message: res.Message, Action: act, Err: err, Gen: gen}
}
}
func (m PageViewModel) currentRow() *PageRow {
if m.Doc == nil || len(m.Doc.Rows) == 0 {
return nil
}
return &m.Doc.Rows[m.currentIndex()]
}
func (m PageViewModel) currentIndex() int {
if m.Doc != nil {
if i := slices.IndexFunc(m.Doc.Rows, func(r PageRow) bool { return r.ID == m.SelectedID }); i >= 0 {
return i
}
}
return 0
}
// runAction marks the page busy and asks the daemon to run act.
func (m *PageViewModel) runAction(act PageAction, entered string) tea.Cmd {
m.Mode, m.CurrentAction = modeBrowse, nil
m.Busy, m.Error, m.Notice = true, "", ""
m.Generation = nextPageGeneration()
return m.executeActionCmd(act, m.currentRow(), entered, m.Generation)
}
// beginAction opens the interaction act needs: Update asks for a
// confirmation first, then come text entry or a choice, or the action runs
// at once. fresh clears a stale notice and error, as acting from the row
// list does.
func (m *PageViewModel) beginAction(act PageAction, fresh bool) tea.Cmd {
if fresh {
m.Notice, m.Error = "", ""
}
m.CurrentAction = &act
switch act.Input {
case "text", "secret":
m.Mode = modeText
value := ""
if act.Prefill && m.currentRow() != nil {
value = m.currentRow().Text
}
return ask(&m.TextInput, value, act.Input == "secret")
case "choice":
m.Mode = modeChoice
m.ChoiceIndex = 0
if row := m.currentRow(); row != nil {
if i := slices.Index(act.Options, row.Badge); i >= 0 {
m.ChoiceIndex = i
}
}
return nil
}
return m.runAction(act, "")
}
func (m PageViewModel) Update(msg tea.Msg) (PageViewModel, tea.Cmd) {
switch msg := msg.(type) {
case pageLoadedMsg:
if !m.settle(msg.Gen, msg.Err, &m.Busy) {
return m, nil
}
m.Doc = msg.Doc
m.Error = ""
if row := m.currentRow(); row != nil {
m.SelectedID = row.ID
}
return m, nil
case pageActionExecutedMsg:
if !m.settle(msg.Gen, msg.Err, &m.Busy) {
return m, nil
}
m.Notice = pageNotice(msg)
m.Error = ""
m.Busy = true
m.Generation = nextPageGeneration()
return m, tea.Batch(
func() tea.Msg { return PageViewChangedMsg{} },
m.loadPageCmd(m.Generation),
)
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" || msg.String() == "esc" {
if m.Mode == modeBrowse {
return m, func() tea.Msg { return PageCancelMsg{} }
}
m.Mode = modeBrowse
m.CurrentAction = nil
return m, nil
}
if m.Busy {
return m, nil
}
if m.Doc == nil {
if strings.ToLower(msg.String()) == "r" {
m.Busy = true
m.Error = ""
m.Generation = nextPageGeneration()
return m, m.loadPageCmd(m.Generation)
}
return m, nil
}
switch m.Mode {
case modeConfirm:
if msg.String() == "enter" && m.CurrentAction != nil {
act := *m.CurrentAction
m.CurrentAction = nil
m.Mode = modeBrowse
return m, m.beginAction(act, false)
}
case modeChoice:
if m.CurrentAction != nil && len(m.CurrentAction.Options) > 0 {
opts := m.CurrentAction.Options
switch msg.String() {
case "left", "up":
m.ChoiceIndex = (m.ChoiceIndex - 1 + len(opts)) % len(opts)
case "right", "down":
m.ChoiceIndex = (m.ChoiceIndex + 1) % len(opts)
case "enter":
return m, m.runAction(*m.CurrentAction, opts[m.ChoiceIndex])
}
}
case modeText:
if msg.String() != "enter" {
var cmd tea.Cmd
m.TextInput, cmd = m.TextInput.Update(msg)
return m, cmd
}
if val := strings.TrimSpace(m.TextInput.Value()); val != "" && m.CurrentAction != nil {
act := *m.CurrentAction
m.TextInput.Reset()
return m, m.runAction(act, val)
}
case modeBrowse:
idx := m.currentIndex()
switch msg.String() {
case "up", "ctrl+p":
if idx > 0 {
m.SelectedID = m.Doc.Rows[idx-1].ID
}
case "down", "ctrl+n":
if idx < len(m.Doc.Rows)-1 {
m.SelectedID = m.Doc.Rows[idx+1].ID
}
default:
for _, act := range m.Doc.Actions {
if act.Key != msg.String() {
continue
}
if act.Row && m.currentRow() == nil {
continue
}
if act.Confirm {
m.Notice, m.Error = "", ""
m.Mode = modeConfirm
m.CurrentAction = &act
return m, nil
}
return m, m.beginAction(act, true)
}
}
}
}
if m.Mode == modeText && m.TextInput.Focused() {
var cmd tea.Cmd
m.TextInput, cmd = m.TextInput.Update(msg)
return m, cmd
}
return m, nil
}
// pageLayout places the list and the selected row's detail under the title
// the way the sessions view does: side by side when there is room, the
// detail under the list when there is not, and the list alone when no row
// has one or the stack would leave the detail too little.
type pageLayout struct {
list, pane int // widths side by side; pane is 0 when stacked or alone
listRows int // rows the list keeps
paneRows int // rows the detail keeps under the list; 0 unless stacked
}
func (m PageViewModel) layout(body, listed int) pageLayout {
l := pageLayout{list: m.Width, listRows: body}
if !slices.ContainsFunc(m.Doc.Rows, func(r PageRow) bool { return r.Detail != "" }) {
return l
}
if m.Width >= 96 && m.Height >= 14 {
l.list = m.Width / 2
l.pane = m.Width - l.list - ansi.StringWidth(svSep())
return l
}
// Stacked, the list keeps what it needs up to two fifths of the body, or
// more when the detail is short, and the rule between them takes a row.
rows := min(listed, max(3, body*2/5, body-1-len(m.detailLines(m.Width))))
if body-rows-1 >= 4 {
l.listRows, l.paneRows = rows, body-rows-1
}
return l
}
// list is the rows grouped under a rule per run of one badge, at exactly
// width × height, scrolled to keep the selection in view.
func (m PageViewModel) list(width, height int) []string {
return scrollWindow(m.listLines(width, height), m.selectedAt(height), width, height)
}
func (m PageViewModel) listLines(width, height int) []string {
if len(m.Doc.Rows) == 0 {
return []string{m.Styles.Faint.Render(" " + m.Doc.Empty)}
}
idW := 0
for _, row := range m.Doc.Rows {
idW = max(idW, ansi.StringWidth(rowID(row)))
}
selected := m.currentIndex()
var lines []string
for i, row := range m.Doc.Rows {
if i == 0 || row.Badge != m.Doc.Rows[i-1].Badge {
if i > 0 && height >= 10 {
lines = append(lines, "")
}
run := 1
for run < len(m.Doc.Rows)-i && m.Doc.Rows[i+run].Badge == row.Badge {
run++
}
lines = append(lines, sectionRule(row.Badge, run, width))
}
lines = append(lines, m.row(row, i == selected, width, idW))
}
return lines
}
// selectedAt is where the selected row sits in listLines.
func (m PageViewModel) selectedAt(height int) int {
if len(m.Doc.Rows) == 0 {
return 0
}
at := 0
for i, row := range m.Doc.Rows[:m.currentIndex()+1] {
if i == 0 || row.Badge != m.Doc.Rows[i-1].Badge {
at++
if i > 0 && height >= 10 {
at++
}
}
at++
}
return at - 1
}
// 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 ""
}
// row is one page row in the sessions view's grammar: bar, a glyph in the
// row's tone, the title, then its id right-aligned in idW columns.
func (m PageViewModel) row(row PageRow, selected bool, width, idW int) string {
glyph, glyphStyle := "· ", m.Styles.Faint
switch {
case selected:
glyph, glyphStyle = "◆ ", DefaultStyles.Agent
case row.Tone == ToneWarning:
glyph, glyphStyle = "● ", DefaultStyles.Warning
case row.Tone == ToneActive:
glyph, glyphStyle = "● ", DefaultStyles.Success
}
textStyle := lipgloss.NewStyle()
switch {
case selected:
textStyle = DefaultStyles.Bold
case row.Tone == ToneMuted:
textStyle = m.Styles.Faint
}
textW := width - 4
if idW > 0 {
textW -= idW + 2
}
textW = max(1, textW)
marker := " "
if selected {
marker = selectBar()
}
line := marker + glyphStyle.Render(glyph) + " " + textStyle.Render(svCell(row.Text, textW, false))
if idW > 0 {
line += " " + m.Styles.Faint.Render(svCell(rowID(row), idW, true))
}
if selected {
return selectedLine(line, width)
}
return line
}
// detail is the selected row at exactly width × height: its title and badge
// over a rule, then its detail wrapped to the pane, cut short with ··· when
// it runs past the bottom.
func (m PageViewModel) detail(width, height int) []string {
lines := m.detailLines(width)
if len(lines) > height && height > 0 {
lines = append(lines[:height-1], m.Styles.Faint.Render("···"))
}
return paneBox(lines, max(1, width-2), width, height)
}
func (m PageViewModel) detailLines(width int) []string {
inner := max(1, width-2)
row := m.currentRow()
if row == nil {
return nil
}
var lines []string
for _, l := range svWrap(row.Text, inner, 2) {
lines = append(lines, DefaultStyles.Bold.Render(l))
}
meta := []string{toneStyle(row.Tone, m.Styles).Render(row.Badge)}
if id := rowID(*row); id != "" {
meta = append(meta, m.Styles.Faint.Render(id))
}
lines = append(lines, strings.Join(meta, DefaultStyles.Decor.Render(" · ")), DefaultStyles.Decor.Render(strings.Repeat("─", inner)))
for paragraph := range strings.SplitSeq(row.Detail, "\n\n") {
if paragraph = strings.TrimSpace(paragraph); paragraph != "" {
lines = append(append(lines, ""), strings.Split(ansi.Wrap(paragraph, inner, " -"), "\n")...)
}
}
return lines
}
// toneStyle is the color a badge wears for its tone.
func toneStyle(tone PageTone, styles Styles) lipgloss.Style {
switch tone {
case ToneActive:
return DefaultStyles.Success
case ToneWarning:
return DefaultStyles.Warning
case ToneMuted:
return styles.Faint
}
return DefaultStyles.Muted
}
// 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
}
func (m PageViewModel) View() string {
width, height := cmp.Or(m.Width, 80), cmp.Or(m.Height, 24)
summary := ""
if m.Doc != nil {
summary = m.Styles.Faint.Render(m.Doc.Summary)
}
lines := []string{" " + titleRule(width-1, brand("albedo")+" "+m.Styles.Muted.Render(m.Command), summary), ""}
tail := []string{m.prompt(), m.footer(width)}
body := max(1, height-len(lines)-len(tail))
switch m.Doc {
case nil:
note := "loading " + m.Command + "…"
if m.Error != "" {
note = "r retry · esc back"
}
lines = append(lines, " "+m.Styles.Faint.Render(note))
lines = append(lines, make([]string, max(0, body-1))...)
default:
l := m.layout(body, len(m.listLines(width, body)))
list := m.list(l.list, l.listRows)
if l.pane > 0 {
pane := m.detail(l.pane, l.listRows)
for i := range list {
list[i] += svSep() + pane[i]
}
}
lines = append(lines, list...)
if l.paneRows > 0 {
lines = append(lines, DefaultStyles.Decor.Render(strings.Repeat("─", width)))
lines = append(lines, m.detail(width, l.paneRows)...)
}
}
lines = append(lines, tail...)
if len(lines) > height {
lines = append(lines[:max(0, height-1)], lines[len(lines)-1])
}
for i, line := range lines {
lines[i] = ansi.Truncate(line, width, "…")
}
return strings.Join(lines, "\n")
}
// prompt is the line above the footer where an action asks what it needs,
// blank while browsing.
func (m PageViewModel) prompt() string {
act, row := m.CurrentAction, m.currentRow()
if act == nil {
return ""
}
target := ""
if act.Row && row != nil {
target = " "
if id := rowID(*row); id != "" {
target += id + " "
}
target += row.Text
}
switch m.Mode {
case modeConfirm:
return " " + DefaultStyles.Warning.Render(act.Label+target+"?")
case modeChoice:
var choice strings.Builder
choice.WriteByte(' ')
choice.WriteString(m.Styles.Prompt.Render(act.Label + target))
choice.WriteByte(' ')
choice.WriteString(promptLead())
for i, opt := range act.Options {
label := " " + opt + " "
if i == m.ChoiceIndex {
label = selectedLine(label, 0)
}
choice.WriteString(label)
}
return choice.String()
case modeText:
return " " + m.Styles.Prompt.Render(act.Label+target+" · "+cmp.Or(act.Prompt, act.Label)) + " " + promptLead() + m.TextInput.View()
}
return ""
}
// fitHints is " " and the hints in room columns, shedding what the screen
// already says first: moving, then leaving, then every label but the keys.
// Empty when not even the keys fit.
func fitHints(hints []hint, room int) string {
keysOnly := make([]hint, len(hints))
for i, h := range hints {
keysOnly[i] = hint{key: h.key}
if h.key == "" {
keysOnly[i].does = h.does
}
}
still := slices.DeleteFunc(slices.Clone(hints), func(h hint) bool { return h.key == "↑↓" })
staying := slices.DeleteFunc(slices.Clone(still), func(h hint) bool { return h.key == "esc" })
for _, candidate := range [][]hint{hints, still, staying, keysOnly} {
if line := " " + keyHints(candidate...); len(candidate) > 0 && ansi.StringWidth(line) <= room {
return line
}
}
return ""
}
// footer is the keys on the left and the last outcome on the right, the
// way the folder picker words it; a long outcome takes the whole line.
func (m PageViewModel) footer(width int) string {
var hints []hint
switch {
case m.Busy:
hints = []hint{{"working…", ""}}
case m.Doc == nil:
hints = []hint{{"esc", "back"}}
case m.Mode == modeBrowse:
if len(m.Doc.Rows) > 1 {
hints = append(hints, hint{"↑↓", "move"})
}
for _, act := range m.Doc.Actions {
if !act.Row || m.currentRow() != nil {
hints = append(hints, hint{act.Key, act.Label})
}
}
hints = append(hints, hint{"esc", "back"})
case m.Mode == modeText:
hints = []hint{{"enter", "save"}, {"esc", "cancel"}}
case m.Mode == modeChoice:
hints = []hint{{"←→", "choose"}, {"enter", "apply"}, {"esc", "cancel"}}
default:
hints = []hint{{"enter", "confirm"}, {"esc", "cancel"}}
}
var right string
switch {
case m.Error != "":
right = DefaultStyles.Error.Render(m.Error)
case m.Notice != "":
right = m.Styles.Faint.Render(m.Notice)
}
room := width - 1
if right != "" {
room = width - ansi.StringWidth(right) - 3
}
left := fitHints(hints, room)
if left == "" && right == "" {
left = " " + ansi.Truncate(keyHints(hints...), width-2, "…")
}
return left + strings.Repeat(" ", max(1, width-ansi.StringWidth(left)-ansi.StringWidth(right)-1)) + right
}