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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381package tui
import ( "albedo/cli/internal/daemon" "context" "errors" "fmt" "regexp" "slices" "strings"
tea "charm.land/bubbletea/v2")
var whitespaceRegex = regexp.MustCompile(`[\p{Cc}\p{Cf}\p{Z}]+`)
// previewText is a checkpoint's preview on one line, whole.func previewText(value string) string { clean := strings.TrimSpace(whitespaceRegex.ReplaceAllString(value, " ")) if clean == "" { return "(empty)" } return clean}
// readablePreview is previewText cut short for a question.func readablePreview(value string) string { runes := []rune(previewText(value)) if len(runes) <= 96 { return string(runes) } return string(runes[:95]) + "…"}
type TreeCancelMsg struct{}
type TreeRewindSuccessMsg struct { Outcome daemon.RewindOutcome Generation int}type TreeRewindReloadMsg struct { Session daemon.Session Outcome daemon.RewindOutcome Generation int Err error}
type TreeForkSuccessMsg struct { Session daemon.Session Generation int}
type treeLoadedMsg struct { Err error NextCursor *int Items []daemon.TreeCheckpoint Gen int HasMore bool}
type treeRewoundMsg struct { Outcome daemon.RewindOutcome Err error Gen int Summarized bool}
type treeForkedMsg struct { Err error Session daemon.Session Gen int}
// TreePickerModel lists a session's checkpoints. Enter asks, and a second// enter branches the session from the checkpoint under the cursor.type TreePickerModel struct { Conn *daemon.Connection NextCursor *int SessionID string ForkError string Cursors []int Checkpoints []daemon.TreeCheckpoint pageStatus listView confirm PageIndex int HasMore bool Forking bool Rewinding bool Summarizing bool ForkOnly bool}
func NewTreePickerModel(conn *daemon.Connection, sessionID string) TreePickerModel { return TreePickerModel{ Conn: conn, SessionID: sessionID, Cursors: []int{0}, pageStatus: newPageStatus(true), listView: newListView("Search checkpoints"), }}
// NewForkPickerModel lists the same checkpoints, but only forks from them.func NewForkPickerModel(conn *daemon.Connection, sessionID string) TreePickerModel { m := NewTreePickerModel(conn, sessionID) m.ForkOnly = true return m}
func (m *TreePickerModel) SetSize(width, height int) { m.pageStatus.SetSize(width, height) m.listView.setSize(width, height)}
// Confirming reports a question waiting for enter or esc.func (m TreePickerModel) Confirming() bool { return m.confirm.asking() }
func (m TreePickerModel) Init() tea.Cmd { return m.loadTreeCmd(0, m.Generation)}
func (m TreePickerModel) loadPage(after int) (TreePickerModel, tea.Cmd) { m.Loading, m.Error = true, "" m.Generation = nextPageGeneration() return m, m.loadTreeCmd(after, m.Generation)}
func (m TreePickerModel) loadTreeCmd(after int, gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil { return treeLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen} }
resp, err := daemon.GetSessionTree(context.Background(), m.Conn, m.SessionID, after, 50) if err != nil { return treeLoadedMsg{Err: err, Gen: gen} } return treeLoadedMsg{ Items: resp.Items, NextCursor: resp.NextCursor, HasMore: resp.HasMore, Gen: gen, } }}
func (m TreePickerModel) rewindCmd(checkpointID string, summarize bool, gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil { return treeRewoundMsg{Err: errors.New("daemon connection unavailable"), Gen: gen} } outcome, err := daemon.RewindSession(context.Background(), m.Conn, m.SessionID, checkpointID, summarize) return treeRewoundMsg{Outcome: outcome, Err: err, Gen: gen, Summarized: summarize} }}
func (m TreePickerModel) forkCmd(checkpointID string, gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil { return treeForkedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen} } branch, err := daemon.ForkSession(context.Background(), m.Conn, m.SessionID, daemon.ForkRequest{Checkpoint: checkpointID}) return treeForkedMsg{Session: branch, Err: err, Gen: gen} }}
// current is the checkpoint under the cursor.func (m TreePickerModel) current() (daemon.TreeCheckpoint, bool) { row, ok := m.highlighted() if !ok { return daemon.TreeCheckpoint{}, false } i := slices.IndexFunc(m.Checkpoints, func(cp daemon.TreeCheckpoint) bool { return cp.ID == row.key }) if i < 0 { return daemon.TreeCheckpoint{}, false } return m.Checkpoints[i], true}
func (m *TreePickerModel) setCheckpoints(items []daemon.TreeCheckpoint) { m.Checkpoints = items rows := make([]listEntry, len(items)) for i, cp := range items { rows[i] = listEntry{ key: cp.ID, lead: DefaultStyles.Faint.Render(cp.Type), name: previewText(cp.Preview), search: []string{cp.Type}, detail: func(width int) []string { return checkpointDetails(cp, width, m.ForkOnly) }, } } m.setRows(rows)}
// checkpointDetails is the pane: the checkpoint's type and whole preview,// and what rewinding or branching from it does.func checkpointDetails(cp daemon.TreeCheckpoint, width int, forkOnly bool) []string { lines := paneTitle(previewText(cp.Preview), "", width) lines = append(lines, factRows("type", cp.Type, width)...) lines = append(lines, "") if forkOnly { return append(lines, paneNote("This creates a new session with a fresh Python namespace. Workspace files stay unchanged.", width)...) } return append(lines, paneNote("Everything from this message on moves to an archived session, and the message returns to the composer. Workspace files and the Python namespace are not rewound.", width)...)}
// fork branches the session from the checkpoint the question named.func (m *TreePickerModel) fork() tea.Cmd { cp, ok := m.current() if !ok || cp.ID != m.confirm.target { m.confirm.dismiss() return nil } m.Forking, m.ForkError, m.Error = true, "", "" m.Generation = nextPageGeneration() return m.forkCmd(cp.ID, m.Generation)}
func (m *TreePickerModel) rewind(summarize bool) tea.Cmd { cp, ok := m.current() if !ok || cp.ID != m.confirm.target { m.confirm.dismiss() return nil } m.Rewinding, m.Summarizing = true, summarize m.Generation = nextPageGeneration() return m.rewindCmd(cp.ID, summarize, m.Generation)}
func (m TreePickerModel) Update(msg tea.Msg) (TreePickerModel, tea.Cmd) { switch msg := msg.(type) { case treeLoadedMsg: if !m.settle(msg.Gen, msg.Err, &m.Loading) { return m, nil } m.setCheckpoints(msg.Items) m.NextCursor, m.HasMore = msg.NextCursor, msg.HasMore m.confirm.dismiss() m.ForkError, m.Error = "", "" return m, nil
case treeRewoundMsg: if msg.Gen != m.Generation { return m, nil } m.Rewinding, m.Summarizing = false, false if msg.Err != nil { m.ForkError = operationError(msg.Err, "", "Rewind failed; try again.") return m, nil } return m, func() tea.Msg { return TreeRewindSuccessMsg{Outcome: msg.Outcome, Generation: msg.Gen} }
case treeForkedMsg: if msg.Gen != m.Generation { return m, nil } m.Forking = false if msg.Err != nil { // The question stays open, so enter retries. m.ForkError = operationError(msg.Err, "", "Branch may have been created; check the session list before branching again.") return m, nil } return m, func() tea.Msg { return TreeForkSuccessMsg{Session: msg.Session, Generation: msg.Gen} }
case tea.KeyPressMsg: return m.key(msg) } cmd := m.listView.update(msg) return m, cmd}
func (m TreePickerModel) key(msg tea.KeyPressMsg) (TreePickerModel, tea.Cmd) { key := msg.String() switch { case m.confirm.asking() && !m.Forking && !m.Rewinding: if m.confirm.key(msg) { var cmd tea.Cmd if m.confirm.what == "branch" { cmd = m.fork() } else { cmd = m.rewind(m.confirm.what == "summary") } return m, cmd } if !m.confirm.asking() { m.ForkError = "" } return m, nil case key == "esc" || key == "ctrl+c" || key == "ctrl+d": return m, func() tea.Msg { return TreeCancelMsg{} } case m.Forking || m.Rewinding || m.Loading: return m, nil }
switch key { case "enter", "ctrl+s", "ctrl+g": if cp, ok := m.current(); ok && (key == "enter" || !m.ForkOnly) { m.ForkError = "" if key == "ctrl+g" || m.ForkOnly { m.confirm.ask("branch", cp.ID, "branch", fmt.Sprintf("Branch after %s · %s?", cp.Type, readablePreview(cp.Preview))) } else if key == "ctrl+s" { m.confirm.ask("summary", cp.ID, "summary", fmt.Sprintf("Rewind to before %q and summarize what you leave?", readablePreview(cp.Preview))) } else { m.confirm.ask("rewind", cp.ID, "rewind", fmt.Sprintf("Rewind to before %q? Everything after it moves to an archived session.", readablePreview(cp.Preview))) } } return m, nil case "ctrl+r": return m.loadPage(m.Cursors[m.PageIndex]) case "pgdown": // Past the last row, paging moves on to the next server page. if m.HasMore && m.NextCursor != nil && m.Cursor >= len(m.shown)-1 { next := *m.NextCursor m.Cursors = append(m.Cursors[:m.PageIndex+1], next) m.PageIndex++ return m.loadPage(next) } case "pgup": if m.PageIndex > 0 && m.Cursor == 0 { m.PageIndex-- return m.loadPage(m.Cursors[m.PageIndex]) } } cmd := m.listView.update(msg) return m, cmd}
func (m TreePickerModel) footer(width int) string { if m.confirm.asking() && !m.Forking && !m.Rewinding { return m.confirm.footer(width, m.ForkError) } hints := []hint{{"↑↓", "move"}, {"enter", "rewind"}, {"ctrl+s", "rewind + summarize"}, {"ctrl+g", "fork"}, {"esc", "back"}, {"pgup/pgdn", "page"}, {"ctrl+r", "refresh"}} if m.ForkOnly { hints = []hint{{"↑↓", "move"}, {"enter", "branch"}, {"esc", "back"}, {"pgup/pgdn", "page"}, {"ctrl+r", "refresh"}} } if m.Error != "" { hints = []hint{{"ctrl+r", "retry"}, {"esc", "back"}} } var status string urgent := true switch { case m.Forking: status = DefaultStyles.Busy.Render("forking…") case m.Rewinding: if m.Summarizing { status = DefaultStyles.Busy.Render("summarizing…") } else { status = DefaultStyles.Busy.Render("rewinding…") } case m.Loading: status, urgent = DefaultStyles.Faint.Render("loading…"), false case m.Error != "": status = DefaultStyles.Error.Render(m.Error) } return footerLine(width, hints, status, urgent)}
func (m TreePickerModel) View() string { lv := m.listView command, subtitle, summary := "/tree", "rewind history", "everything from this message on moves to an archived session" lv.Empty = "No history checkpoints available to rewind to." if m.ForkOnly { command, subtitle, summary = "/fork", "branch history", "this creates a new session with a fresh Python namespace" lv.Empty = "No history checkpoints available to branch from." } switch { case m.Loading: lv.Empty = "loading history…" case m.Error != "": lv.Empty = "could not load history" } title := brand("albedo") + " " + DefaultStyles.Muted.Render(command) f := lv.frame(title, DefaultStyles.Faint.Render(subtitle), m.footer(max(1, m.Width))) // Without the pane's room, the note still reads as one line. if m.Error == "" { f.summary = summary } return f.view(m.Width, m.Height)}