okami.mom/albedo

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

dawntui: gofmt extension picker799087c

main
11.6 KiB397 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"context"
6	"errors"
7	"fmt"
8	"slices"
9	"strings"
10
11	tea "charm.land/bubbletea/v2"
12)
13
14type ExtensionItem = daemon.ExtensionSummary
15
16type ExtensionPickerDoneMsg struct{}
17type ExtensionPickerChangedMsg struct{}
18
19type extensionsLoadedMsg struct {
20	Err        error
21	Extensions []ExtensionItem
22	Gen        int
23}
24
25type extensionToggledMsg struct {
26	Err        error
27	Extensions []ExtensionItem
28	Notice     string
29	Gen        int
30}
31
32// The scopes a change can be made in, and the confirmation each asks.
33const (
34	scopeGlobal  = "global"
35	scopeSession = "session"
36	scopeInherit = "inherit"
37)
38
39// ExtensionPickerModel lists the extensions with the global default and
40// this session's own choice side by side. Enter changes the global default;
41// shift+enter changes this session only.
42type ExtensionPickerModel struct {
43	Conn       *daemon.Connection
44	SessionID  string
45	Extensions []ExtensionItem
46	pageStatus
47	listView
48	confirm
49}
50
51func NewExtensionPickerModel(conn *daemon.Connection, sessionID string) ExtensionPickerModel {
52	return ExtensionPickerModel{
53		Conn:       conn,
54		SessionID:  sessionID,
55		pageStatus: newPageStatus(true),
56		listView:   newListView("Search extensions"),
57	}
58}
59
60func (m *ExtensionPickerModel) SetSize(width, height int) {
61	m.pageStatus.SetSize(width, height)
62	m.listView.setSize(width, height)
63}
64
65// Confirming reports a question waiting for enter or esc.
66func (m ExtensionPickerModel) Confirming() bool { return m.confirm.asking() }
67
68func (m ExtensionPickerModel) Init() tea.Cmd {
69	return m.loadExtensionsCmd(m.Generation)
70}
71
72func (m ExtensionPickerModel) loadExtensionsCmd(gen int) tea.Cmd {
73	return func() tea.Msg {
74		if m.Conn == nil {
75			return extensionsLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
76		}
77		items, err := daemon.ListExtensions(context.Background(), m.Conn, m.SessionID)
78		return extensionsLoadedMsg{Extensions: items, Err: err, Gen: gen}
79	}
80}
81
82// changeExtensionCmd sends one change: scope "global" or "session" with a
83// value, or "inherit" to drop this session's choice.
84func (m ExtensionPickerModel) changeExtensionCmd(name, scope string, enabled bool, gen int) tea.Cmd {
85	return func() tea.Msg {
86		if m.Conn == nil {
87			return extensionToggledMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
88		}
89		etag := ""
90		for _, item := range m.Extensions {
91			if item.Name == name {
92				etag = item.SessionETag
93				if scope == "global" {
94					etag = item.GlobalETag
95				}
96			}
97		}
98		body := daemon.ExtensionSelectionRequest{Name: name, Scope: scope, ETag: etag}
99		if scope != "inherit" {
100			body.Enabled = &enabled
101		}
102		updated, err := daemon.SelectExtension(context.Background(), m.Conn, m.SessionID, body)
103		if err != nil {
104			return extensionToggledMsg{Err: err, Gen: gen}
105		}
106		notice := "Saved the global default. Reload sessions to apply it."
107		if scope != "global" {
108			reloaded, reloadErr := daemon.ReloadSession(context.Background(), m.Conn, m.SessionID, daemon.ReloadRequest{Target: "session"})
109			switch {
110			case reloadErr != nil:
111				notice = "Selection saved; reload failed: " + reloadErr.Error()
112				if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](reloadErr); uncertain {
113					notice = "Selection saved; reload not confirmed: " + reloadErr.Error()
114				}
115			case reloaded.Session == nil:
116				notice = "Selection saved; reload returned no session outcome."
117			case reloaded.Session.State != "applied":
118				notice = "Selection saved; session reload " + reloaded.Session.State + ". " + reloaded.Message()
119			default:
120				notice = reloaded.Message()
121			}
122		}
123		return extensionToggledMsg{Extensions: updated, Notice: notice, Gen: gen}
124	}
125}
126
127func (m ExtensionPickerModel) capabilitiesList(ext ExtensionItem) []string {
128	var caps []string
129	if ext.Context {
130		caps = append(caps, "context")
131	}
132	if len(ext.Tools) > 0 {
133		caps = append(caps, fmt.Sprintf("tools (%s)", strings.Join(ext.Tools, ", ")))
134	}
135	if len(ext.PythonModules) > 0 {
136		caps = append(caps, fmt.Sprintf("python modules (%s)", strings.Join(ext.PythonModules, ", ")))
137	}
138	return caps
139}
140
141// current is the extension under the cursor.
142func (m ExtensionPickerModel) current() (ExtensionItem, bool) {
143	row, ok := m.highlighted()
144	if !ok {
145		return ExtensionItem{}, false
146	}
147	i := slices.IndexFunc(m.Extensions, func(e ExtensionItem) bool { return e.Name == row.key })
148	if i < 0 {
149		return ExtensionItem{}, false
150	}
151	return m.Extensions[i], true
152}
153
154func (m *ExtensionPickerModel) setExtensions(items []ExtensionItem) {
155	m.Extensions = items
156	rows := make([]listEntry, len(items))
157	for i, ext := range items {
158		rows[i] = m.entry(ext)
159	}
160	m.setRows(rows)
161}
162
163// askToggle asks before changing an extension in scope, or before dropping
164// this session's own choice.
165func (m *ExtensionPickerModel) askToggle(scope string) {
166	ext, ok := m.current()
167	if !ok {
168		return
169	}
170	if ext.Quarantined != "" {
171		m.Error = ext.Quarantined
172		return
173	}
174	m.Error = ""
175	on := ext.GlobalEnabled
176	if scope == scopeSession {
177		on = ext.Enabled
178	}
179	verb := "enable"
180	if on {
181		verb = "disable"
182	}
183	switch scope {
184	case scopeInherit:
185		m.confirm.ask(scope, ext.Name, "follow global", fmt.Sprintf("This session will follow the global default for %s. Remove its own choice?", ext.Name))
186	case scopeSession:
187		m.confirm.ask(scope, ext.Name, verb, fmt.Sprintf("This will reload this session. %s %s for this session only?", strings.ToUpper(verb[:1])+verb[1:], ext.Name))
188	default:
189		m.confirm.ask(scope, ext.Name, verb, fmt.Sprintf("Sessions following global defaults will use this change. %s %s globally?", strings.ToUpper(verb[:1])+verb[1:], ext.Name))
190	}
191}
192
193// save sends the change the confirmation asked about.
194func (m *ExtensionPickerModel) save() tea.Cmd {
195	ext, ok := m.current()
196	if !ok || ext.Name != m.confirm.target {
197		m.confirm.dismiss()
198		return nil
199	}
200	m.Saving, m.Error = true, ""
201	m.Generation = nextPageGeneration()
202	enabled := !ext.GlobalEnabled
203	switch m.confirm.what {
204	case scopeSession:
205		enabled = !ext.Enabled
206	case scopeInherit:
207		enabled = false
208	}
209	return m.changeExtensionCmd(ext.Name, m.confirm.what, enabled, m.Generation)
210}
211
212func (m ExtensionPickerModel) Update(msg tea.Msg) (ExtensionPickerModel, tea.Cmd) {
213	switch msg := msg.(type) {
214	case extensionsLoadedMsg:
215		if !m.settle(msg.Gen, msg.Err, &m.Loading) {
216			return m, nil
217		}
218		m.Error = ""
219		m.setExtensions(msg.Extensions)
220		return m, nil
221
222	case extensionToggledMsg:
223		if msg.Gen == m.Generation {
224			if apiErr, ok := errors.AsType[*daemon.APIError](msg.Err); ok && apiErr.StatusCode == 412 {
225				m.Saving, m.Loading, m.Error = false, true, ""
226				m.confirm.dismiss()
227				m.Notice = "The configuration changed. Review the refreshed choices before saving."
228				m.Generation = nextPageGeneration()
229				return m, m.loadExtensionsCmd(m.Generation)
230			}
231		}
232		if !m.settle(msg.Gen, msg.Err, &m.Saving) {
233			m.confirm.failed = msg.Gen == m.Generation
234			return m, nil
235		}
236		m.confirm.dismiss()
237		m.setExtensions(msg.Extensions)
238		m.Error = ""
239		m.Notice = msg.Notice
240		return m, func() tea.Msg { return ExtensionPickerChangedMsg{} }
241
242	case tea.KeyPressMsg:
243		key := msg.String()
244		switch {
245		case m.confirm.asking() && !m.Saving:
246			if m.confirm.key(msg) {
247				cmd := m.save()
248				return m, cmd
249			}
250			if !m.confirm.asking() {
251				m.Error = ""
252			}
253			return m, nil
254		case key == "esc" || key == "ctrl+c" || key == "ctrl+d":
255			return m, func() tea.Msg { return ExtensionPickerDoneMsg{} }
256		case m.Loading || m.Saving:
257			return m, nil
258		}
259		ext, _ := m.current()
260		switch key {
261		case "enter":
262			m.askToggle(scopeGlobal)
263		case "shift+enter", "alt+enter":
264			m.askToggle(scopeSession)
265		case "ctrl+x":
266			if ext.Overridden {
267				m.askToggle(scopeInherit)
268			}
269		case "ctrl+w":
270			if ext.Name == "webhooks" && ext.Enabled {
271				return m, func() tea.Msg { return ChatOpenWebhooksPageMsg{} }
272			}
273		case "ctrl+r":
274			m.Loading, m.Error, m.Notice = true, "", ""
275			m.Generation = nextPageGeneration()
276			return m, m.loadExtensionsCmd(m.Generation)
277		default:
278			cmd := m.listView.update(msg)
279			return m, cmd
280		}
281		return m, nil
282	}
283	cmd := m.listView.update(msg)
284	return m, cmd
285}
286
287// entry is how an extension reads in the list: its global default in front,
288// and this session's own choice at the edge when it differs.
289func (m ExtensionPickerModel) entry(ext ExtensionItem) listEntry {
290	lead, tag := DefaultStyles.Faint.Render("off"), ""
291	switch {
292	case ext.Quarantined != "":
293		lead, tag = DefaultStyles.Error.Render("bad"), DefaultStyles.Error.Render("quarantined")
294	case ext.GlobalEnabled:
295		lead = DefaultStyles.Success.Render("on")
296	}
297	if ext.Overridden && ext.Quarantined == "" {
298		tag = DefaultStyles.Muted.Render("this session: " + onOff(ext.Enabled))
299	}
300	return listEntry{
301		key:    ext.Name,
302		lead:   lead,
303		name:   ext.Name,
304		desc:   ext.Description,
305		tag:    tag,
306		search: append(slices.Clone(ext.Plugins), ext.Requires...),
307		detail: func(width int) []string { return m.details(ext, width) },
308	}
309}
310
311func onOff(on bool) string {
312	if on {
313		return "on"
314	}
315	return "off"
316}
317
318// details is the pane: both choices, what the extension provides, and what
319// each key would change.
320func (m ExtensionPickerModel) details(ext ExtensionItem, width int) []string {
321	lines := paneTitle(ext.Name, ext.Description, width)
322	session := "follows global"
323	if ext.Overridden {
324		session = onOff(ext.Enabled) + " (its own choice)"
325	}
326	lines = append(lines, factRows("global", onOff(ext.GlobalEnabled)+" default", width)...)
327	lines = append(lines, factRows("session", session, width)...)
328	lines = append(lines, factRows("plugins", joinOr(ext.Plugins, ", ", "not reported"), width)...)
329	lines = append(lines, factRows("provides", joinOr(m.capabilitiesList(ext), " · ", "not reported"), width)...)
330	lines = append(lines, factRows("requires", joinOr(ext.Requires, ", ", "none"), width)...)
331	if ext.Quarantined != "" {
332		lines = append(lines, "")
333		for _, l := range svWrap("quarantined: "+ext.Quarantined, width, 6) {
334			lines = append(lines, DefaultStyles.Error.Render(l))
335		}
336		return lines
337	}
338	lines = append(lines, "")
339	lines = append(lines, paneNote("A global change applies as sessions reload. A session change reloads this session and may lose Python variables that cannot be saved.", width)...)
340	return lines
341}
342
343func joinOr(items []string, sep, empty string) string {
344	if len(items) == 0 {
345		return empty
346	}
347	return strings.Join(items, sep)
348}
349
350func (m ExtensionPickerModel) footer(width int) string {
351	if m.confirm.asking() && !m.Saving {
352		return m.confirm.footer(width, m.Error)
353	}
354	ext, _ := m.current()
355	hints := []hint{{"↑↓", "move"}, {"enter", "global"}, {"shift+enter", "this session"}}
356	if ext.Overridden {
357		hints = append(hints, hint{"ctrl+x", "follow global"})
358	}
359	if ext.Name == "webhooks" && ext.Enabled {
360		hints = append(hints, hint{"ctrl+w", "webhooks"})
361	}
362	if len(m.Extensions) == 0 && m.Error != "" {
363		hints = append(hints, hint{"ctrl+r", "retry"})
364	}
365	hints = append(hints, hint{"esc", "back"})
366
367	var status string
368	urgent := true
369	switch {
370	case m.Saving:
371		status = DefaultStyles.Busy.Render("saving…")
372	case m.Loading:
373		status, urgent = DefaultStyles.Faint.Render("loading…"), false
374	case m.Error != "":
375		status = DefaultStyles.Error.Render(m.Error)
376	case m.Notice != "":
377		status = DefaultStyles.Warning.Render(m.Notice)
378	default:
379		on := 0
380		for _, e := range m.Extensions {
381			if e.Enabled {
382				on++
383			}
384		}
385		status, urgent = DefaultStyles.Faint.Render(fmt.Sprintf("%s · %d on here", counted(len(m.Extensions), "extension"), on)), false
386	}
387	return footerLine(width, hints, status, urgent)
388}
389
390func (m ExtensionPickerModel) View() string {
391	lv := m.listView
392	lv.Empty = "No extensions are available for this session."
393	if m.Loading {
394		lv.Empty = "loading extensions…"
395	}
396	return lv.frame(brand("albedo")+" "+DefaultStyles.Muted.Render("/extensions"), "", m.footer(max(1, m.Width))).view(m.Width, m.Height)
397}