okami.mom/albedo

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

Aly Raffaufrefactor(cli): remove unused state fields5924d14

main
13.4 KiB495 linesraw
1// Package tui implements the interactive CLI and its terminal views.
2package tui
3
4import (
5	"albedo/cli/internal/config"
6	"albedo/cli/internal/daemon"
7	"errors"
8	"maps"
9	"slices"
10
11	tea "charm.land/bubbletea/v2"
12	"github.com/charmbracelet/x/ansi"
13)
14
15type AppState int
16
17const (
18	AppStateChat AppState = iota
19	AppStateSessionPicker
20	AppStateModelPicker
21	AppStateExtensionPicker
22	AppStateTreePicker
23	AppStateContextInspector
24	AppStatePageView
25	AppStateLogin
26	AppStateCapabilityPage
27	AppStateWebhooksPage
28	AppStateAgents
29	AppStateFolderPicker
30	AppStateQuota
31)
32
33type pendingSessionIntents struct {
34	users         []PendingUserTurn
35	continuations map[string]pendingOperation
36}
37
38type AppModel struct {
39	pendingBySession map[string]pendingSessionIntents
40	pendingCreations map[string]pendingOperation
41	ActiveSession    *daemon.Session
42	openBrowser      func(url string)
43	Conn             *daemon.Connection
44	Profiles         config.Profiles
45	SettingsETags    map[string]string
46	UI               daemon.UIPreferences
47	Workspace        string
48	Sessions         []daemon.Session
49	Notices          Notices
50	CommandCatalog   []daemon.SessionCommand
51	ExtensionPicker  ExtensionPickerModel
52	CapabilityPage   CapabilityPageModel
53	WebhooksPage     WebhooksPageModel
54	TreePicker       TreePickerModel
55	SessionPicker    SessionViewer
56	PageView         PageViewModel
57	ContextInspector ContextInspectorModel
58	Login            LoginModel
59	ModelPicker      ModelPickerModel
60	FolderPicker     FolderPicker
61	Agents           AgentsViewModel
62	Quota            QuotaModel
63
64	Chat        ChatModel
65	SettingsGen int
66	State       AppState
67	Width       int
68	Height      int
69
70	// SessionGen invalidates earlier session-list and session-creation replies.
71	SessionGen int
72	CatalogGen int
73	ModelGen   int
74	CommandGen int
75	ProfileGen int
76	// folderReturn is the screen the folder picker goes back to.
77	folderReturn    AppState
78	UISaving        bool
79	StandaloneLogin bool
80	// listingSessions says a session listing is in flight.
81	listingSessions bool
82
83	// Graphemes says the terminal measures grapheme clusters; see ChatModel.
84	Graphemes bool
85}
86
87func (m *AppModel) newChatModel(session *daemon.Session) ChatModel {
88	chat := NewChatModel(session, daemon.NewChatClient(m.Conn, session.ID))
89	if pending, ok := m.pendingBySession[session.ID]; ok {
90		chat.pendingUsers = slices.Clone(pending.users)
91		chat.pendingContinuations = maps.Clone(pending.continuations)
92		delete(m.pendingBySession, session.ID)
93	}
94	chat.Flags.Thinking = m.UI.Thinking
95	chat.Flags.Tools = m.UI.Tools
96	chat.Notices = slices.Clone(m.Notices)
97	chat.graphemes = m.Graphemes
98	m.Notices = nil
99	return chat
100}
101
102// NewAppModel requires an established daemon connection and panics if conn is nil.
103func NewAppModel(conn *daemon.Connection, bootstrap Bootstrap, initialSession *daemon.Session, workspace string, needsLogin bool, openBrowser func(string)) *AppModel {
104	if conn == nil {
105		panic("tui.NewAppModel requires a daemon connection")
106	}
107	m := &AppModel{
108		Conn:          conn,
109		Profiles:      bootstrap.Settings.Profiles,
110		ActiveSession: initialSession,
111		Workspace:     workspace,
112		openBrowser:   openBrowser,
113	}
114
115	// Seed the shared owner before constructing a screen that reads preferences.
116	m.SessionPicker = NewSessionViewer(workspace)
117	m.SessionPicker.Fetch = func(id string) tea.Cmd {
118		current, _ := m.session(id)
119		return sessionPreviewCmd(conn, id, current.ETag)
120	}
121	m.SettingsETags = maps.Clone(bootstrap.Settings.ETags)
122	m.applySessionList(bootstrap.Sessions)
123	m.ApplyUI(bootstrap.Settings.UI)
124	if needsLogin {
125		m.State = AppStateLogin
126		m.Login = NewLoginModel(conn, "", openBrowser)
127	} else if initialSession != nil {
128		m.State = AppStateChat
129		m.Chat = m.newChatModel(m.ActiveSession)
130	} else {
131		m.State = AppStateSessionPicker
132	}
133
134	return m
135}
136
137// NewLoginAppModel starts the standalone login flow, which exits when finished.
138func NewLoginAppModel(conn *daemon.Connection, bootstrap Bootstrap, workspace, nameHint string, openBrowser func(string)) *AppModel {
139	m := NewAppModel(conn, bootstrap, nil, workspace, false, openBrowser)
140	m.State = AppStateLogin
141	m.StandaloneLogin = true
142	m.Login = NewLoginModel(conn, nameHint, openBrowser)
143	return m
144}
145
146func (m *AppModel) Init() tea.Cmd {
147	return m.initScreen()
148}
149
150func (m *AppModel) AddNotice(message string) {
151	if m.ActiveSession != nil && m.Chat.History != nil {
152		m.Chat.AddNotice(message)
153		return
154	}
155	m.Notices.AddNotice(message)
156}
157
158func (m *AppModel) AddError(message string) {
159	if m.ActiveSession != nil && m.Chat.History != nil {
160		m.Chat.AddError(message)
161		return
162	}
163	m.Notices.AddError(message)
164}
165
166func (m *AppModel) ClearNotices() {
167	m.Notices.Clear()
168	if m.ActiveSession != nil && m.Chat.History != nil {
169		m.Chat.ClearNotices()
170	}
171}
172
173func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
174	if cmd, handled := m.handleCommandInvocation(msg); handled {
175		return m, cmd
176	}
177	if handled, cmd := m.handleLoginOutcome(msg); handled {
178		return m, cmd
179	}
180	if cmd, handled := m.handleChatLifecycle(msg); handled {
181		return m, cmd
182	}
183
184	if cmd, handled := m.handlePreferences(msg); handled {
185		return m, cmd
186	}
187	if cmd, handled := m.handleSessionResults(msg); handled {
188		return m, cmd
189	}
190	if cmd, handled := m.handleCommandResults(msg); handled {
191		return m, cmd
192	}
193
194	switch msg := msg.(type) {
195
196	case tea.WindowSizeMsg:
197		m.Width = msg.Width
198		m.Height = msg.Height
199		if m.ActiveSession != nil && m.Chat.History != nil {
200			m.Chat.SetSize(msg.Width, msg.Height)
201		}
202		m.SessionPicker.SetSize(msg.Width, msg.Height)
203		m.ModelPicker.SetSize(msg.Width, msg.Height)
204		m.ExtensionPicker.SetSize(msg.Width, msg.Height)
205		m.TreePicker.SetSize(msg.Width, msg.Height)
206		m.ContextInspector.SetSize(msg.Width, msg.Height)
207		m.PageView.SetSize(msg.Width, msg.Height)
208		m.CapabilityPage.SetSize(msg.Width, msg.Height)
209		m.WebhooksPage.SetSize(msg.Width, msg.Height)
210		m.Agents.SetSize(msg.Width, msg.Height)
211		m.FolderPicker.SetSize(msg.Width, msg.Height)
212		m.Login.SetSize(msg.Width, msg.Height)
213		m.Quota.SetSize(msg.Width, msg.Height)
214		return m, nil
215
216	case tea.ModeReportMsg:
217		// the reply Bubble Tea switches to grapheme widths on
218		if msg.Mode == ansi.ModeUnicodeCore {
219			switch msg.Value {
220			case ansi.ModeReset, ansi.ModeSet, ansi.ModePermanentlySet:
221				m.Graphemes = true
222				m.Chat.graphemes = true
223			}
224		}
225		return m, nil
226
227	case SessionDeleteMsg:
228		if m.State != AppStateSessionPicker || !m.SessionPicker.ArchiveView || m.ActiveSession != nil && m.ActiveSession.ID == msg.ID {
229			return m, nil
230		}
231		return m, m.deleteSessionCmd(msg.ID, msg.Condition)
232
233	case commandCatalogLoadedMsg:
234		if msg.Gen != m.CatalogGen {
235			return m, nil
236		}
237		if msg.Err != nil {
238			if _, ok := errors.AsType[*daemon.UpgradeRequiredError](msg.Err); ok {
239				m.AddError("Could not load commands: " + msg.Err.Error())
240			}
241			return m, nil
242		}
243		m.CommandCatalog = msg.Commands
244		m.Chat.CommandMenu.Catalog = msg.Commands
245		return m, nil
246
247	case ChatQuitMsg:
248		m.Chat.Close()
249		return m, tea.Quit
250
251	case ChatBackToSessionsMsg:
252		return m, m.openSessions()
253
254	case ChatNewSessionMsg:
255		return m, m.newSessionCmd()
256
257	case ChatOpenModelPickerMsg:
258		return m, m.openModelPicker()
259
260	case ModelPickerSelectMsg:
261		m.ModelPicker.Close()
262		m.ModelPicker.Saving = true
263		m.ModelPicker.Error = ""
264		m.ModelGen++
265		return m, m.changeModelCmd(msg.Model, msg.Provider, msg.Effort, msg.RaiseCap, m.ModelGen, msg.CapKey)
266
267	case ChatOpenExtensionPickerMsg:
268		return m, m.openExtensionPicker()
269
270	case ChatOpenWebhooksPageMsg:
271		if m.ActiveSession != nil {
272			m.WebhooksPage = NewWebhooksPageModel(m.Conn, m.ActiveSession.ID)
273			return m, m.openScreen(AppStateWebhooksPage, &m.WebhooksPage)
274		}
275
276	case ChatOpenAgentsMsg:
277		if m.ActiveSession != nil {
278			m.Agents.Close()
279			pending := m.Agents.pendingOperations
280			draft := m.Agents.input.Value()
281			m.Agents = NewAgentsViewModel(m.Conn, m.ActiveSession.ID)
282			m.Agents.pendingOperations = pending
283			m.Agents.input.SetValue(draft)
284			return m, m.openScreen(AppStateAgents, &m.Agents)
285		}
286	case agentsSentMsg:
287		if m.State != AppStateAgents && msg.Handle != nil {
288			var cmd tea.Cmd
289			m.Agents, cmd = m.Agents.Update(msg)
290			return m, cmd
291		}
292
293	case ChatOpenFolderPickerMsg:
294		return m, m.openFolderPicker(msg.Retry)
295
296	case FolderMovedMsg:
297		return m, m.moved(msg)
298
299	case FolderPickerCancelMsg:
300		m.FolderPicker.Close()
301		m.State = m.folderReturn
302		return m, nil
303
304	case SessionFoldersMsg:
305		return m, m.openFolderBrowser(msg.Query)
306
307	case FolderOpenSessionMsg:
308		m.FolderPicker.Close()
309		return m, m.openSession(msg.Session)
310
311	case FolderNewSessionMsg:
312		m.FolderPicker.Close()
313		m.SessionGen++
314		return m, m.createSessionCmd(m.SessionGen, msg.Workspace)
315
316	case AgentsDoneMsg:
317		m.Agents.Close()
318		m.State = AppStateChat
319		return m, nil
320
321	case AgentsAttachMsg:
322		m.Agents.Close()
323		session := msg.Session
324		if i := slices.IndexFunc(m.Sessions, func(s daemon.Session) bool { return s.ID == session.ID }); i >= 0 {
325			session = m.Sessions[i]
326		}
327		return m, m.openSession(session)
328
329	case ModelPickerCancelMsg:
330		m.ModelPicker.Close()
331		m.State = AppStateChat
332		return m, nil
333
334	case WebhooksPageDoneMsg, ExtensionPickerDoneMsg,
335		TreeCancelMsg, ContextDoneMsg, CapabilityPageDoneMsg, PageCancelMsg,
336		QuotaCancelMsg:
337		m.State = AppStateChat
338		return m, nil
339
340	case ExtensionPickerChangedMsg:
341		m.CatalogGen++
342		return m, m.loadCommandCatalogCmd(m.CatalogGen)
343
344	case CapabilityPageChangedMsg:
345		m.CatalogGen++
346		return m, m.loadCommandCatalogCmd(m.CatalogGen)
347
348	case ChatOpenTreePickerMsg:
349		return m, m.openTreePicker()
350
351	case ChatOpenQuotaMsg:
352		return m, m.openQuota()
353
354	case TreeForkSuccessMsg:
355		if m.State != AppStateTreePicker || msg.Generation != m.TreePicker.Generation {
356			return m, nil
357		}
358		return m, m.setChatSession(msg.Session, true)
359
360	case ChatOpenContextInspectorMsg:
361		return m, m.openContextInspector()
362
363	case ChatOpenCapabilityPageMsg:
364		if m.ActiveSession != nil {
365			m.CapabilityPage = NewCapabilityPageModel(m.Conn, m.ActiveSession.ID, msg.Kind)
366			return m, m.openScreen(AppStateCapabilityPage, &m.CapabilityPage)
367		}
368
369	case ChatOpenPageMsg:
370		if m.ActiveSession != nil {
371			m.PageView = NewPageViewModel(m.Conn, m.ActiveSession.ID, msg.Command)
372			return m, m.openScreen(AppStatePageView, &m.PageView)
373		}
374
375	case PageViewChangedMsg:
376		m.Chat.statusRevision++
377		return m, m.Chat.statusCmd()
378
379	case ChatOpenLoginMsg:
380		return m, m.openLogin(msg.Name)
381
382	case LoginCancelMsg:
383		cleanup := m.Login.Close()
384		m.ProfileGen++
385		// /login may have removed providers even when it ends without a choice.
386
387		if m.StandaloneLogin {
388			return m, tea.Batch(cleanup, tea.Quit)
389		}
390		if m.ActiveSession != nil {
391			return m, tea.Batch(cleanup, m.returnToChat(), m.loadProfilesCmd("", m.ProfileGen))
392		}
393		m.State = AppStateSessionPicker
394		m.updateSessionPickerItems()
395		m.SessionGen++
396		return m, tea.Batch(cleanup, m.SessionPicker.Init(), m.loadSessionsCmd(m.SessionGen))
397
398	case LoginDoneMsg:
399		if m.State != AppStateLogin || msg.Gen != m.Login.Generation {
400			return m, nil
401		}
402		cleanup := m.Login.Close()
403		m.ProfileGen++
404		return m, tea.Batch(cleanup, m.loadProfilesCmd(msg.Name, m.ProfileGen))
405
406	case PickerSelectMsg:
407		if m.State == AppStateSessionPicker {
408			switch msg.ID {
409			case "new":
410				return m, m.newSessionCmd()
411			case "archive":
412				m.SessionPicker.OpenArchive()
413				return m, nil
414			case "login":
415				return m, m.openLogin("")
416			default:
417				// The active session may not yet appear in the daemon's recent list.
418				listed := m.Sessions
419				if m.ActiveSession != nil && !slices.ContainsFunc(m.Sessions, func(s daemon.Session) bool { return s.ID == m.ActiveSession.ID }) {
420					listed = append([]daemon.Session{*m.ActiveSession}, listed...)
421				}
422				if i := slices.IndexFunc(listed, func(s daemon.Session) bool { return s.ID == msg.ID }); i >= 0 {
423					return m, m.openSession(listed[i])
424				}
425			}
426		}
427
428	case PickerCancelMsg:
429		if m.State == AppStateSessionPicker {
430			if m.SessionPicker.ArchiveView {
431				m.SessionPicker.CloseArchive()
432				return m, nil
433			}
434			if m.ActiveSession != nil {
435				m.State = AppStateChat
436				return m, nil
437			}
438			return m, tea.Quit
439		}
440	}
441
442	// Route to active state model
443	var cmd tea.Cmd
444	switch m.State {
445	case AppStateChat:
446		beforePending := len(m.Chat.pendingUsers)
447		before := m.Chat.Flags
448		m.Chat, cmd = m.Chat.Update(msg)
449		if len(m.Chat.pendingUsers) > beforePending {
450			m.ClearNotices()
451		}
452		if m.Chat.Flags.Thinking != before.Thinking || m.Chat.Flags.Tools != before.Tools {
453			draft := m.Chat.Flags
454			m.Chat.Flags = before
455			if !m.UISaving {
456				m.UISaving = true
457				m.SettingsGen++
458				patch := daemon.UIPreferencesPatch{}
459				if draft.Thinking != before.Thinking {
460					patch.Thinking = &draft.Thinking
461				}
462				if draft.Tools != before.Tools {
463					patch.Tools = &draft.Tools
464				}
465				cmd = tea.Batch(cmd, m.patchUICmd("", patch, m.SettingsGen))
466			}
467		}
468	case AppStateSessionPicker:
469		m.SessionPicker, cmd = m.SessionPicker.Update(msg)
470	case AppStateModelPicker:
471		m.ModelPicker, cmd = m.ModelPicker.Update(msg)
472	case AppStateExtensionPicker:
473		m.ExtensionPicker, cmd = m.ExtensionPicker.Update(msg)
474	case AppStateTreePicker:
475		m.TreePicker, cmd = m.TreePicker.Update(msg)
476	case AppStateContextInspector:
477		m.ContextInspector, cmd = m.ContextInspector.Update(msg)
478	case AppStatePageView:
479		m.PageView, cmd = m.PageView.Update(msg)
480	case AppStateCapabilityPage:
481		m.CapabilityPage, cmd = m.CapabilityPage.Update(msg)
482	case AppStateWebhooksPage:
483		m.WebhooksPage, cmd = m.WebhooksPage.Update(msg)
484	case AppStateAgents:
485		m.Agents, cmd = m.Agents.Update(msg)
486	case AppStateFolderPicker:
487		m.FolderPicker, cmd = m.FolderPicker.Update(msg)
488	case AppStateLogin:
489		m.Login, cmd = m.Login.Update(msg)
490	case AppStateQuota:
491		m.Quota, cmd = m.Quota.Update(msg)
492	}
493
494	return m, cmd
495}