// Package tui implements the interactive CLI and its terminal views. package tui import ( "albedo/cli/internal/config" "albedo/cli/internal/daemon" "context" "errors" "maps" "slices" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) type AppState int const ( AppStateChat AppState = iota AppStateSessionPicker AppStateModelPicker AppStateExtensionPicker AppStateTreePicker AppStateContextInspector AppStatePageView AppStateLogin AppStateCapabilityPage AppStateWebhooksPage AppStateDiscordPage AppStateConfigPage AppStateAgents AppStateFolderPicker AppStateQuota ) type pendingSessionIntents struct { users []PendingUserTurn continuations map[string]pendingOperation } type AppModel struct { pendingBySession map[string]pendingSessionIntents // afterReset is a note for the chat to show once its stream's first reset has // hydrated it, which clears any notice added sooner. afterReset string pendingCreations map[string]pendingOperation ActiveSession *daemon.Session openBrowser func(url string) Conn *daemon.Connection Profiles config.Profiles SettingsETags map[string]string UI daemon.UIPreferences Workspace string Sessions []daemon.Session Notices Notices CommandCatalog []daemon.SessionCommand ExtensionPicker ExtensionPickerModel CapabilityPage CapabilityPageModel WebhooksPage WebhooksPageModel DiscordPage DiscordPageModel ConfigPage ConfigPageModel TreePicker TreePickerModel SessionPicker SessionViewer PageView PageViewModel ContextInspector ContextInspectorModel Login LoginModel ModelPicker ModelPickerModel FolderPicker FolderPicker Agents AgentsViewModel Quota QuotaModel Chat ChatModel SettingsGen int State AppState Width int Height int // SessionGen invalidates earlier session-list and session-creation replies. SessionGen int CatalogGen int ModelGen int CommandGen int ProfileGen int // folderReturn is the screen the folder picker goes back to. folderReturn AppState UISaving bool StandaloneLogin bool // listingSessions says a session listing is in flight. listingSessions bool // refreshingSession says a session read is in flight, and SessionFactsGen // dates the reply. A read asked for meanwhile is read once more when the // answer arrives. refreshingSession bool sessionRefreshPending bool SessionFactsGen int // Graphemes says the terminal measures grapheme clusters; see ChatModel. Graphemes bool Tick TickFunc } func (m *AppModel) newChatModel(session *daemon.Session) ChatModel { chat := NewChatModel(session, daemon.NewChatClient(m.Conn, session.ID)) chat.artifactConn = m.Conn chat.openBrowser = m.openBrowser if pending, ok := m.pendingBySession[session.ID]; ok { chat.pendingUsers = slices.Clone(pending.users) chat.pendingContinuations = maps.Clone(pending.continuations) delete(m.pendingBySession, session.ID) } chat.Flags.Thinking = m.UI.Thinking chat.Flags.Tools = m.UI.Tools chat.Notices = slices.Clone(m.Notices) chat.graphemes = m.Graphemes m.Notices = nil return chat } // NewAppModel requires an established daemon connection and panics if conn is nil. func NewAppModel(conn *daemon.Connection, bootstrap Bootstrap, initialSession *daemon.Session, workspace string, needsLogin bool, openBrowser func(string)) *AppModel { if conn == nil { panic("tui.NewAppModel requires a daemon connection") } m := &AppModel{ Conn: conn, Profiles: bootstrap.Settings.Profiles, ActiveSession: initialSession, Workspace: workspace, openBrowser: openBrowser, Tick: tea.Tick, } // Seed the shared owner before constructing a screen that reads preferences. m.SessionPicker = NewSessionViewer(workspace) m.SessionPicker.Fetch = func(id string) tea.Cmd { current, _ := m.session(id) return sessionPreviewCmd(conn, id, current.ETag) } m.SettingsETags = maps.Clone(bootstrap.Settings.ETags) m.applySessionList(bootstrap.Sessions) m.ApplyUI(bootstrap.Settings.UI) if needsLogin { m.State = AppStateLogin m.Login = NewLoginModel(conn, "", openBrowser) } else if initialSession != nil { m.State = AppStateChat m.Chat = m.newChatModel(m.ActiveSession) } else { m.State = AppStateSessionPicker } return m } // NewLoginAppModel starts the standalone login flow, which exits when finished. func NewLoginAppModel(conn *daemon.Connection, bootstrap Bootstrap, workspace, nameHint string, openBrowser func(string)) *AppModel { m := NewAppModel(conn, bootstrap, nil, workspace, false, openBrowser) m.State = AppStateLogin m.StandaloneLogin = true m.Login = NewLoginModel(conn, nameHint, openBrowser) return m } func (m *AppModel) Init() tea.Cmd { return m.initScreen() } func (m *AppModel) AddNotice(message string) { if m.ActiveSession != nil && m.Chat.History != nil { m.Chat.AddNotice(message) return } m.Notices.AddNotice(message) } func (m *AppModel) AddError(message string) { if m.ActiveSession != nil && m.Chat.History != nil { m.Chat.AddError(message) return } m.Notices.AddError(message) } func (m *AppModel) ClearNotices() { m.Notices.Clear() if m.ActiveSession != nil && m.Chat.History != nil { m.Chat.ClearNotices() } } func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd, handled := m.handleCommandInvocation(msg); handled { return m, cmd } if handled, cmd := m.handleLoginOutcome(msg); handled { return m, cmd } if cmd, handled := m.handleChatLifecycle(msg); handled { return m, cmd } if cmd, handled := m.handlePreferences(msg); handled { return m, cmd } if cmd, handled := m.handleSessionResults(msg); handled { return m, cmd } if cmd, handled := m.handleCommandResults(msg); handled { return m, cmd } switch msg := msg.(type) { case tea.WindowSizeMsg: m.Width = msg.Width m.Height = msg.Height if m.ActiveSession != nil && m.Chat.History != nil { m.Chat.SetSize(msg.Width, msg.Height) } m.SessionPicker.SetSize(msg.Width, msg.Height) m.ModelPicker.SetSize(msg.Width, msg.Height) m.ExtensionPicker.SetSize(msg.Width, msg.Height) m.TreePicker.SetSize(msg.Width, msg.Height) m.ContextInspector.SetSize(msg.Width, msg.Height) m.PageView.SetSize(msg.Width, msg.Height) m.CapabilityPage.SetSize(msg.Width, msg.Height) m.WebhooksPage.SetSize(msg.Width, msg.Height) m.DiscordPage.SetSize(msg.Width, msg.Height) m.ConfigPage.SetSize(msg.Width, msg.Height) m.Agents.SetSize(msg.Width, msg.Height) m.FolderPicker.SetSize(msg.Width, msg.Height) m.Login.SetSize(msg.Width, msg.Height) m.Quota.SetSize(msg.Width, msg.Height) return m, nil case tea.FocusMsg: // the chat is the one screen that wakes up for input return m, m.Chat.focus(true) case tea.BlurMsg: return m, m.Chat.focus(false) case tea.ModeReportMsg: // the reply Bubble Tea switches to grapheme widths on if msg.Mode == ansi.ModeUnicodeCore { switch msg.Value { case ansi.ModeReset, ansi.ModeSet, ansi.ModePermanentlySet: m.Graphemes = true m.Chat.graphemes = true } } return m, nil case SessionDeleteMsg: if m.State != AppStateSessionPicker || !m.SessionPicker.ArchiveView || m.ActiveSession != nil && m.ActiveSession.ID == msg.ID { return m, nil } return m, m.deleteSessionCmd(msg.ID, msg.Condition) case commandCatalogLoadedMsg: if msg.Gen != m.CatalogGen { return m, nil } if msg.Err != nil { if _, ok := errors.AsType[*daemon.UpgradeRequiredError](msg.Err); ok { m.AddError("Could not load commands: " + msg.Err.Error()) } return m, nil } m.CommandCatalog = msg.Commands m.Chat.CommandMenu.Catalog = msg.Commands return m, nil case ChatQuitMsg: m.Chat.Close() return m, tea.Quit case ChatBackToSessionsMsg: return m, m.openSessions() case ChatNewSessionMsg: return m, m.newSessionCmd() case ChatOpenModelPickerMsg: return m, m.openModelPicker() case ModelPickerSelectMsg: m.ModelPicker.Close() m.ModelPicker.Saving = true m.ModelPicker.Error = "" m.ModelGen++ return m, m.changeModelCmd(msg.Model, msg.Provider, msg.Effort, m.ModelGen) case ModelPickerCapsMsg: return m, m.saveCapsCmd(msg) case modelCapsSavedMsg: if msg.Err == nil { if m.SettingsETags == nil { m.SettingsETags = map[string]string{} } m.SettingsETags["models"] = msg.ETag } var cmd tea.Cmd m.ModelPicker, cmd = m.ModelPicker.Update(msg) return m, cmd case ChatOpenExtensionPickerMsg: return m, m.openExtensionPicker() case ChatOpenWebhooksPageMsg: if m.ActiveSession != nil { m.WebhooksPage = NewWebhooksPageModel(m.Conn, m.ActiveSession.ID) return m, m.openScreen(AppStateWebhooksPage, &m.WebhooksPage) } case ChatOpenDiscordPageMsg: if m.ActiveSession != nil { m.DiscordPage = NewDiscordPageModel(m.Conn, m.ActiveSession.ID) return m, m.openScreen(AppStateDiscordPage, &m.DiscordPage) } case ChatOpenConfigPageMsg: return m, m.openConfigPage() case ChatOpenAgentsMsg: if m.ActiveSession != nil { m.Agents.Close() pending := m.Agents.pendingOperations draft := m.Agents.input.Value() m.Agents = NewAgentsViewModel(m.Conn, m.ActiveSession.ID) m.Agents.pendingOperations = pending m.Agents.input.SetValue(draft) return m, m.openScreen(AppStateAgents, &m.Agents) } case agentsSentMsg: if m.State != AppStateAgents && msg.Handle != nil { var cmd tea.Cmd m.Agents, cmd = m.Agents.Update(msg) return m, cmd } case ChatOpenFolderPickerMsg: return m, m.openFolderPicker(msg.Retry) case FolderMovedMsg: return m, m.moved(msg) case FolderPickerCancelMsg: m.FolderPicker.Close() m.State = m.folderReturn return m, nil case SessionFoldersMsg: return m, m.openFolderBrowser(msg.Query) case FolderOpenSessionMsg: m.FolderPicker.Close() return m, m.openSession(msg.Session) case FolderNewSessionMsg: m.FolderPicker.Close() m.SessionGen++ return m, m.createSessionCmd(m.SessionGen, msg.Workspace) case AgentsDoneMsg: m.Agents.Close() m.State = AppStateChat return m, nil case AgentsAttachMsg: m.Agents.Close() session := msg.Session if i := slices.IndexFunc(m.Sessions, func(s daemon.Session) bool { return s.ID == session.ID }); i >= 0 { session = m.Sessions[i] } return m, m.openSession(session) case ModelPickerCancelMsg: m.ModelPicker.Close() m.State = AppStateChat return m, nil case WebhooksPageDoneMsg, DiscordPageDoneMsg, ExtensionPickerDoneMsg, TreeCancelMsg, ContextDoneMsg, CapabilityPageDoneMsg, PageCancelMsg, QuotaCancelMsg, ConfigPageDoneMsg: m.State = AppStateChat return m, nil case ExtensionPickerChangedMsg: m.CatalogGen++ return m, m.loadCommandCatalogCmd(m.CatalogGen) case CapabilityPageChangedMsg: m.CatalogGen++ return m, m.loadCommandCatalogCmd(m.CatalogGen) case ChatOpenTreePickerMsg: return m, m.openTreePicker(false) case ChatOpenForkPickerMsg: return m, m.openTreePicker(true) case ChatOpenQuotaMsg: return m, m.openQuota() case TreeRewindSuccessMsg: if m.State != AppStateTreePicker || msg.Generation != m.TreePicker.Generation || m.ActiveSession == nil { return m, nil } conn, id, outcome, gen := m.Conn, m.ActiveSession.ID, msg.Outcome, msg.Generation return m, func() tea.Msg { s, err := daemon.GetSession(context.Background(), conn, id) return TreeRewindReloadMsg{Session: s, Outcome: outcome, Generation: gen, Err: err} } case TreeRewindReloadMsg: if msg.Err != nil { m.AddError(operationError(msg.Err, "Could not reload rewound session: ", "Reconnect to refresh this session.")) m.State = AppStateChat return m, nil } note := "Rewound. The earlier conversation is archived as " + msg.Outcome.ArchiveSessionID[:min(8, len(msg.Outcome.ArchiveSessionID))] + "." if msg.Outcome.Summary != "" { note += " Summary added." } draft := m.Chat.TextArea.Value() cmd := m.setChatSession(msg.Session, false) m.afterReset = note // A draft the person was writing outranks the message taken back. switch { case draft != "": m.Chat.TextArea.SetValue(draft) case msg.Outcome.HasInput: m.Chat.TextArea.SetValue(msg.Outcome.Input) } return m, cmd case TreeForkSuccessMsg: if m.State != AppStateTreePicker || msg.Generation != m.TreePicker.Generation { return m, nil } return m, m.setChatSession(msg.Session, true) case ChatOpenContextInspectorMsg: return m, m.openContextInspector() case ChatOpenCapabilityPageMsg: if m.ActiveSession != nil { m.CapabilityPage = NewCapabilityPageModel(m.Conn, m.ActiveSession.ID, msg.Kind) return m, m.openScreen(AppStateCapabilityPage, &m.CapabilityPage) } case ChatOpenPageMsg: if m.ActiveSession != nil { m.PageView = NewPageViewModel(m.Conn, m.ActiveSession.ID, msg.Command) return m, m.openScreen(AppStatePageView, &m.PageView) } case PageViewChangedMsg: m.Chat.statusRevision++ return m, m.Chat.statusCmd() case ChatOpenLoginMsg: return m, m.openLogin(msg.Name) case LoginCancelMsg: cleanup := m.Login.Close() m.ProfileGen++ // /login may have removed providers even when it ends without a choice. if m.StandaloneLogin { return m, tea.Batch(cleanup, tea.Quit) } if m.ActiveSession != nil { return m, tea.Batch(cleanup, m.returnToChat(), m.loadProfilesCmd("", m.ProfileGen)) } m.State = AppStateSessionPicker m.updateSessionPickerItems() m.SessionGen++ return m, tea.Batch(cleanup, m.SessionPicker.Init(), m.loadSessionsCmd(m.SessionGen)) case LoginDoneMsg: if m.State != AppStateLogin || msg.Gen != m.Login.Generation { return m, nil } cleanup := m.Login.Close() m.ProfileGen++ return m, tea.Batch(cleanup, m.loadProfilesCmd(msg.Name, m.ProfileGen)) case PickerSelectMsg: if m.State == AppStateSessionPicker { switch msg.ID { case "new": return m, m.newSessionCmd() case "archive": m.SessionPicker.OpenArchive() return m, nil case "login": return m, m.openLogin("") default: // The active session may not yet appear in the daemon's recent list. listed := m.Sessions if m.ActiveSession != nil && !slices.ContainsFunc(m.Sessions, func(s daemon.Session) bool { return s.ID == m.ActiveSession.ID }) { listed = append([]daemon.Session{*m.ActiveSession}, listed...) } if i := slices.IndexFunc(listed, func(s daemon.Session) bool { return s.ID == msg.ID }); i >= 0 { return m, m.openSession(listed[i]) } } } case PickerCancelMsg: if m.State == AppStateSessionPicker { if m.SessionPicker.ArchiveView { m.SessionPicker.CloseArchive() return m, nil } if m.ActiveSession != nil { m.State = AppStateChat return m, nil } return m, tea.Quit } } // Route to active state model var cmd tea.Cmd switch m.State { case AppStateChat: beforePending := len(m.Chat.pendingUsers) before := m.Chat.Flags m.Chat, cmd = m.Chat.Update(msg) if len(m.Chat.pendingUsers) > beforePending { m.ClearNotices() } if m.Chat.Flags.Thinking != before.Thinking || m.Chat.Flags.Tools != before.Tools { draft := m.Chat.Flags m.Chat.Flags = before if !m.UISaving { m.UISaving = true m.SettingsGen++ patch := daemon.UIPreferencesPatch{} if draft.Thinking != before.Thinking { patch.Thinking = &draft.Thinking } if draft.Tools != before.Tools { patch.Tools = &draft.Tools } cmd = tea.Batch(cmd, m.patchUICmd("", patch, m.SettingsGen)) } } case AppStateSessionPicker: m.SessionPicker, cmd = m.SessionPicker.Update(msg) case AppStateModelPicker: m.ModelPicker, cmd = m.ModelPicker.Update(msg) case AppStateExtensionPicker: m.ExtensionPicker, cmd = m.ExtensionPicker.Update(msg) case AppStateTreePicker: m.TreePicker, cmd = m.TreePicker.Update(msg) case AppStateContextInspector: m.ContextInspector, cmd = m.ContextInspector.Update(msg) case AppStatePageView: m.PageView, cmd = m.PageView.Update(msg) case AppStateCapabilityPage: m.CapabilityPage, cmd = m.CapabilityPage.Update(msg) case AppStateWebhooksPage: m.WebhooksPage, cmd = m.WebhooksPage.Update(msg) case AppStateDiscordPage: m.DiscordPage, cmd = m.DiscordPage.Update(msg) case AppStateConfigPage: m.ConfigPage, cmd = m.ConfigPage.Update(msg) case AppStateAgents: m.Agents, cmd = m.Agents.Update(msg) case AppStateFolderPicker: m.FolderPicker, cmd = m.FolderPicker.Update(msg) case AppStateLogin: m.Login, cmd = m.Login.Update(msg) case AppStateQuota: m.Quota, cmd = m.Quota.Update(msg) } return m, cmd }