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 {
Styles Styles
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()},
Styles: DefaultStyles,
}
}
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 int, 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")+" "+m.Styles.Muted.Render("/tree"), m.Styles.Faint.Render("branch history")))
b.WriteByte('\n')
b.WriteString(m.Styles.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(m.Styles.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, m.Styles))
b.WriteByte('\n')
if m.Confirming && m.Cursor < len(m.Checkpoints) {
current := m.Checkpoints[m.Cursor]
b.WriteByte('\n')
b.WriteString(m.Styles.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(m.Styles.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()
}