okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: tree and context inspector on listView, shared confirm for agent delete78011da

main
7.7 KiB287 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"context"
6	"errors"
7	"fmt"
8	"regexp"
9	"slices"
10	"strings"
11
12	tea "charm.land/bubbletea/v2"
13)
14
15var whitespaceRegex = regexp.MustCompile(`[\p{Cc}\p{Cf}\p{Z}]+`)
16
17// previewText is a checkpoint's preview on one line, whole.
18func previewText(value string) string {
19	clean := strings.TrimSpace(whitespaceRegex.ReplaceAllString(value, " "))
20	if clean == "" {
21		return "(empty)"
22	}
23	return clean
24}
25
26// readablePreview is previewText cut short for a question.
27func readablePreview(value string) string {
28	runes := []rune(previewText(value))
29	if len(runes) <= 96 {
30		return string(runes)
31	}
32	return string(runes[:95]) + "…"
33}
34
35type TreeCancelMsg struct{}
36
37type TreeForkSuccessMsg struct {
38	Session    daemon.Session
39	Generation int
40}
41
42type treeLoadedMsg struct {
43	Err        error
44	NextCursor *int
45	Items      []daemon.TreeCheckpoint
46	Gen        int
47	HasMore    bool
48}
49
50type treeForkedMsg struct {
51	Err     error
52	Session daemon.Session
53	Gen     int
54}
55
56// TreePickerModel lists a session's checkpoints. Enter asks, and a second
57// enter branches the session from the checkpoint under the cursor.
58type TreePickerModel struct {
59	Conn        *daemon.Connection
60	NextCursor  *int
61	SessionID   string
62	ForkError   string
63	Cursors     []int
64	Checkpoints []daemon.TreeCheckpoint
65	pageStatus
66	listView
67	confirm
68	PageIndex int
69	HasMore   bool
70	Forking   bool
71}
72
73func NewTreePickerModel(conn *daemon.Connection, sessionID string) TreePickerModel {
74	return TreePickerModel{
75		Conn:       conn,
76		SessionID:  sessionID,
77		Cursors:    []int{0},
78		pageStatus: newPageStatus(true),
79		listView:   newListView("Search checkpoints"),
80	}
81}
82
83func (m *TreePickerModel) SetSize(width, height int) {
84	m.pageStatus.SetSize(width, height)
85	m.listView.setSize(width, height)
86}
87
88// Confirming reports a question waiting for enter or esc.
89func (m TreePickerModel) Confirming() bool { return m.confirm.asking() }
90
91func (m TreePickerModel) Init() tea.Cmd {
92	return m.loadTreeCmd(0, m.Generation)
93}
94
95func (m TreePickerModel) loadPage(after int) (TreePickerModel, tea.Cmd) {
96	m.Loading, m.Error = true, ""
97	m.Generation = nextPageGeneration()
98	return m, m.loadTreeCmd(after, m.Generation)
99}
100
101func (m TreePickerModel) loadTreeCmd(after int, gen int) tea.Cmd {
102	return func() tea.Msg {
103		if m.Conn == nil {
104			return treeLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
105		}
106
107		resp, err := daemon.GetSessionTree(context.Background(), m.Conn, m.SessionID, after, 50)
108		if err != nil {
109			return treeLoadedMsg{Err: err, Gen: gen}
110		}
111		return treeLoadedMsg{
112			Items:      resp.Items,
113			NextCursor: resp.NextCursor,
114			HasMore:    resp.HasMore,
115			Gen:        gen,
116		}
117	}
118}
119
120func (m TreePickerModel) forkCmd(checkpointID string, gen int) tea.Cmd {
121	return func() tea.Msg {
122		if m.Conn == nil {
123			return treeForkedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
124		}
125		branch, err := daemon.ForkSession(context.Background(), m.Conn, m.SessionID, daemon.ForkRequest{Checkpoint: checkpointID})
126		return treeForkedMsg{Session: branch, Err: err, Gen: gen}
127	}
128}
129
130// current is the checkpoint under the cursor.
131func (m TreePickerModel) current() (daemon.TreeCheckpoint, bool) {
132	row, ok := m.highlighted()
133	if !ok {
134		return daemon.TreeCheckpoint{}, false
135	}
136	i := slices.IndexFunc(m.Checkpoints, func(cp daemon.TreeCheckpoint) bool { return cp.ID == row.key })
137	if i < 0 {
138		return daemon.TreeCheckpoint{}, false
139	}
140	return m.Checkpoints[i], true
141}
142
143func (m *TreePickerModel) setCheckpoints(items []daemon.TreeCheckpoint) {
144	m.Checkpoints = items
145	rows := make([]listEntry, len(items))
146	for i, cp := range items {
147		rows[i] = listEntry{
148			key:    cp.ID,
149			lead:   DefaultStyles.Faint.Render(cp.Type),
150			name:   previewText(cp.Preview),
151			search: []string{cp.Type},
152			detail: func(width int) []string { return checkpointDetails(cp, width) },
153		}
154	}
155	m.setRows(rows)
156}
157
158// checkpointDetails is the pane: the checkpoint's type and whole preview,
159// and what branching from it does.
160func checkpointDetails(cp daemon.TreeCheckpoint, width int) []string {
161	lines := paneTitle(previewText(cp.Preview), "", width)
162	lines = append(lines, factRows("type", cp.Type, width)...)
163	lines = append(lines, "")
164	return append(lines, paneNote("This creates a new session with a fresh Python namespace. Workspace files stay unchanged.", width)...)
165}
166
167// fork branches the session from the checkpoint the question named.
168func (m *TreePickerModel) fork() tea.Cmd {
169	cp, ok := m.current()
170	if !ok || cp.ID != m.confirm.target {
171		m.confirm.dismiss()
172		return nil
173	}
174	m.Forking, m.ForkError, m.Error = true, "", ""
175	m.Generation = nextPageGeneration()
176	return m.forkCmd(cp.ID, m.Generation)
177}
178
179func (m TreePickerModel) Update(msg tea.Msg) (TreePickerModel, tea.Cmd) {
180	switch msg := msg.(type) {
181	case treeLoadedMsg:
182		if !m.settle(msg.Gen, msg.Err, &m.Loading) {
183			return m, nil
184		}
185		m.setCheckpoints(msg.Items)
186		m.NextCursor, m.HasMore = msg.NextCursor, msg.HasMore
187		m.confirm.dismiss()
188		m.ForkError, m.Error = "", ""
189		return m, nil
190
191	case treeForkedMsg:
192		if msg.Gen != m.Generation {
193			return m, nil
194		}
195		m.Forking = false
196		if msg.Err != nil {
197			// The question stays open, so enter retries.
198			m.ForkError = operationError(msg.Err, "", "Branch may have been created; check the session list before branching again.")
199			return m, nil
200		}
201		return m, func() tea.Msg { return TreeForkSuccessMsg{Session: msg.Session, Generation: msg.Gen} }
202
203	case tea.KeyPressMsg:
204		return m.key(msg)
205	}
206	cmd := m.listView.update(msg)
207	return m, cmd
208}
209
210func (m TreePickerModel) key(msg tea.KeyPressMsg) (TreePickerModel, tea.Cmd) {
211	key := msg.String()
212	switch {
213	case m.confirm.asking() && !m.Forking:
214		if m.confirm.key(msg) {
215			cmd := m.fork()
216			return m, cmd
217		}
218		if !m.confirm.asking() {
219			m.ForkError = ""
220		}
221		return m, nil
222	case key == "esc" || key == "ctrl+c" || key == "ctrl+d":
223		return m, func() tea.Msg { return TreeCancelMsg{} }
224	case m.Forking || m.Loading:
225		return m, nil
226	}
227
228	switch key {
229	case "enter":
230		if cp, ok := m.current(); ok {
231			m.ForkError = ""
232			m.confirm.ask("branch", cp.ID, "branch", fmt.Sprintf("Branch after %s · %s?", cp.Type, readablePreview(cp.Preview)))
233		}
234		return m, nil
235	case "ctrl+r":
236		return m.loadPage(m.Cursors[m.PageIndex])
237	case "pgdown":
238		// Past the last row, paging moves on to the next server page.
239		if m.HasMore && m.NextCursor != nil && m.Cursor >= len(m.shown)-1 {
240			next := *m.NextCursor
241			m.Cursors = append(m.Cursors[:m.PageIndex+1], next)
242			m.PageIndex++
243			return m.loadPage(next)
244		}
245	case "pgup":
246		if m.PageIndex > 0 && m.Cursor == 0 {
247			m.PageIndex--
248			return m.loadPage(m.Cursors[m.PageIndex])
249		}
250	}
251	cmd := m.listView.update(msg)
252	return m, cmd
253}
254
255func (m TreePickerModel) footer(width int) string {
256	if m.confirm.asking() && !m.Forking {
257		return m.confirm.footer(width, m.ForkError)
258	}
259	hints := []hint{{"↑↓", "move"}, {"enter", "branch"}, {"esc", "back"}, {"pgup/pgdn", "page"}, {"ctrl+r", "refresh"}}
260	if m.Error != "" {
261		hints = []hint{{"ctrl+r", "retry"}, {"esc", "back"}}
262	}
263	var status string
264	urgent := true
265	switch {
266	case m.Forking:
267		status = DefaultStyles.Busy.Render("branching…")
268	case m.Loading:
269		status, urgent = DefaultStyles.Faint.Render("loading…"), false
270	case m.Error != "":
271		status = DefaultStyles.Error.Render(m.Error)
272	}
273	return footerLine(width, hints, status, urgent)
274}
275
276func (m TreePickerModel) View() string {
277	lv := m.listView
278	lv.Empty = "No history checkpoints available to branch from."
279	if m.Loading {
280		lv.Empty = "loading history…"
281	}
282	title := brand("albedo") + " " + DefaultStyles.Muted.Render("/tree")
283	f := lv.frame(title, DefaultStyles.Faint.Render("branch history"), m.footer(max(1, m.Width)))
284	// Without the pane's room, the branch note still reads as one line.
285	f.summary = "this creates a new session with a fresh Python namespace"
286	return f.view(m.Width, m.Height)
287}