package tui import ( "albedo/cli/internal/daemon" "context" "errors" "fmt" "regexp" "strings" tea "charm.land/bubbletea/v2" ) var whitespaceRegex = regexp.MustCompile(`[\p{Cc}\p{Cf}\p{Z}]+`) func readablePreview(value string) string { clean := strings.TrimSpace(whitespaceRegex.ReplaceAllString(value, " ")) if clean == "" { return "(empty)" } runes := []rune(clean) if len(runes) <= 96 { return clean } return string(runes[:95]) + "…" } type TreeCancelMsg struct{} type TreeForkSuccessMsg struct { Session daemon.Session Generation int } type treeLoadedMsg struct { Err error NextCursor *int Items []daemon.TreeCheckpoint Gen int HasMore bool } type treeForkedMsg struct { Err error Session daemon.Session Gen int } type TreePickerModel struct { Conn *daemon.Connection NextCursor *int SessionID string ForkError string Cursors []int Checkpoints []daemon.TreeCheckpoint page PageIndex int HasMore bool Confirming bool Forking bool } func NewTreePickerModel(conn *daemon.Connection, sessionID string) TreePickerModel { return TreePickerModel{ Conn: conn, SessionID: sessionID, Cursors: []int{0}, PageIndex: 0, page: page{Loading: true, Generation: nextPageGeneration()}, } } 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) 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} } } 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.Checkpoints, m.NextCursor, m.HasMore = msg.Items, msg.NextCursor, msg.HasMore m.Cursor, m.Confirming = 0, false m.ForkError, m.Error = "", "" return m, nil case treeForkedMsg: if msg.Gen != m.Generation { return m, nil } m.Forking = false if msg.Err != nil { 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: if msg.String() == "esc" || msg.String() == "ctrl+c" || msg.String() == "ctrl+d" { if m.Confirming { m.Confirming, m.Error = false, "" return m, nil } return m, func() tea.Msg { return TreeCancelMsg{} } } if m.Forking || m.Loading { return m, nil } if m.Confirming { if msg.String() == "enter" && len(m.Checkpoints) > m.Cursor { cp := m.Checkpoints[m.Cursor] m.Forking = true m.ForkError, m.Error = "", "" m.Generation = nextPageGeneration() return m, m.forkCmd(cp.ID, m.Generation) } return m, nil } if m.step(msg.String(), len(m.Checkpoints)) { return m, nil } switch msg.String() { case "left", "pgup": if m.PageIndex > 0 { m.PageIndex-- return m.loadPage(m.Cursors[m.PageIndex]) } case "right", "pgdown": if m.HasMore && m.NextCursor != nil { next := *m.NextCursor m.Cursors = append(m.Cursors[:m.PageIndex+1], next) m.PageIndex++ return m.loadPage(next) } case "enter": if len(m.Checkpoints) > 0 && m.Cursor < len(m.Checkpoints) { m.ForkError = "" m.Confirming = true } } } return m, nil } func (m TreePickerModel) View() string { var b strings.Builder b.WriteString(titleRule(m.Width, brand("albedo")+" "+DefaultStyles.Muted.Render("/tree"), DefaultStyles.Faint.Render("branch history"))) b.WriteByte('\n') b.WriteString(DefaultStyles.Faint.Render(inkWrap("Choose where the new session should branch from.", m.Width))) b.WriteByte('\n') if m.Error != "" { b.WriteString(DefaultStyles.Error.Render("error:")) b.WriteByte(' ') b.WriteString(m.Error) b.WriteByte('\n') } if m.Loading || len(m.Checkpoints) == 0 { if m.Error == "" { msg := "No history checkpoints available to branch from." if m.Loading { msg = "loading history…" } b.WriteString(DefaultStyles.Faint.Render(msg)) b.WriteByte('\n') } b.WriteString(keyHints(hint{"enter", "confirm"}, hint{"esc", "cancel"})) return b.String() } lines := make([]string, len(m.Checkpoints)) for i, cp := range m.Checkpoints { lines[i] = DefaultStyles.Faint.Render(fmt.Sprintf("%-9s", cp.Type)) + " " + readablePreview(cp.Preview) } b.WriteString(selectableRows(lines, m.Cursor, m.Height, m.Height, m.Width)) b.WriteByte('\n') if m.Confirming && m.Cursor < len(m.Checkpoints) { current := m.Checkpoints[m.Cursor] b.WriteByte('\n') b.WriteString(DefaultStyles.Faint.Render(inkWrap("This creates a new session with a fresh Python namespace. Workspace files stay unchanged.", m.Width))) b.WriteByte('\n') b.WriteString(DefaultStyles.Warning.Render(fmt.Sprintf("Branch after %s · %s?", current.Type, readablePreview(current.Preview)))) b.WriteByte('\n') if m.ForkError != "" { b.WriteString(DefaultStyles.Error.Render(m.ForkError)) b.WriteByte('\n') } } if m.Forking { b.WriteString(DefaultStyles.Faint.Render("Creating the new session…")) } else if m.Confirming { confirm := hint{"enter", "confirm"} if m.ForkError != "" { confirm = hint{"enter", "retry"} } b.WriteString(keyHints(confirm, hint{"esc", "cancel"})) } else { b.WriteString(inkWrap(keyHints(hint{"↑↓", "select"}, hint{"←→/pgup/pgdn", "page"}, hint{"enter", "branch"}, hint{"esc", "return to chat"}), m.Width)) } return b.String() }