Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
Go
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039package tui
import ( "albedo/cli/internal/config" "albedo/cli/internal/daemon" "context" "encoding/json" "errors" "fmt" "net/url" "strings" "time"
tea "github.com/charmbracelet/bubbletea")
type AppState int
const ( AppStateChat AppState = iota AppStateSessionPicker AppStateModelPicker AppStateExtensionPicker AppStateTreePicker AppStateContextInspector AppStatePageView AppStateLogin AppStateCapabilityPage)
type sessionsLoadedMsg struct { Sessions []daemon.Session Err error Gen int}
type sessionCreatedMsg struct { Session daemon.Session Err error Gen int}
type commandCatalogLoadedMsg struct { Commands []daemon.SessionCommand Err error Gen int}
type modelChangedMsg struct { Model string Provider string Protocol string Err error Gen int}
type commandExecutedMsg struct { Name string Message string Err error Gen int}
type ChatWorkspaceChangedMsg struct { SessionID string Workspace string}
type glancesPolledMsg struct { Glances []PageGlance Err error Gen int}
type glancePollTickMsg struct { SessionID string Gen int}
type profilesLoadedMsg struct { Profiles config.Profiles Provider string Err error Gen int}
type AppModel struct { Conn *daemon.Connection Profiles config.Profiles State AppState ActiveSession *daemon.Session Sessions []daemon.Session Workspace string Width int Height int Notice string Error string CommandCatalog []daemon.SessionCommand Glances []PageGlance ExtensionRevision int GlanceRevision int StandaloneLogin bool BrowserOpener func(url string)
// Independent generation counters for concurrent async tasks SessionGen int CatalogGen int ModelGen int CommandGen int GlanceGen int ProfileGen int
// Sub-models Chat ChatModel SessionPicker SessionViewer ModelPicker ModelPickerModel ExtensionPicker ExtensionPickerModel TreePicker TreePickerModel ContextInspector ContextInspectorModel PageView PageViewModel Login LoginModel CapabilityPage CapabilityPageModel}
func (m AppModel) newChatClient(sessionID string) *daemon.ChatClient { var baseURL, token string if m.Conn != nil { baseURL = fmt.Sprintf("http://127.0.0.1:%d", m.Conn.Port) token = m.Conn.Token } return daemon.NewChatClient(daemon.ChatClientOptions{ BaseURL: baseURL, Token: token, AgentID: sessionID, })}
func NewAppModel(conn *daemon.Connection, profiles config.Profiles, initialSession *daemon.Session, workspace string, needsLogin bool) AppModel { m := AppModel{ Conn: conn, Profiles: profiles, ActiveSession: initialSession, Workspace: workspace, }
if needsLogin { m.State = AppStateLogin m.Login = NewLoginModel(conn, "") m.Login.BrowserOpener = m.BrowserOpener } else if initialSession != nil { m.State = AppStateChat m.Chat = NewChatModel(initialSession, m.newChatClient(initialSession.ID)) } else { m.State = AppStateSessionPicker } // Built even when starting in a session so /sessions has a working search. m.SessionPicker = NewSessionViewer(workspace) if conn != nil { m.SessionPicker.Fetch = m.sessionPreviewCmd }
return m}
func (m AppModel) Init() tea.Cmd { m.Login.BrowserOpener = m.BrowserOpener switch m.State { case AppStateChat: return tea.Batch( m.Chat.Init(), m.loadCommandCatalogCmd(m.CatalogGen), mouseModeCmd(AppStateChat), ) case AppStateLogin: m.Login.BrowserOpener = m.BrowserOpener if !m.StandaloneLogin && m.ActiveSession == nil { return tea.Batch(m.Login.Init(), m.loadSessionsCmd(m.SessionGen)) } return m.Login.Init() case AppStateSessionPicker: return tea.Batch( m.SessionPicker.Init(), m.loadSessionsCmd(m.SessionGen), ) } return nil}
func (m AppModel) loadSessionsCmd(gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil { return sessionsLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen} } sessions, err := daemon.Request[[]daemon.Session](context.Background(), m.Conn, "/sessions", nil) return sessionsLoadedMsg{Sessions: sessions, Err: err, Gen: gen} }}
func (m AppModel) sessionPreviewCmd(id string) tea.Cmd { conn := m.Conn return func() tea.Msg { preview, err := daemon.Request[SessionPreview](context.Background(), conn, "/sessions/"+url.PathEscape(id)+"/preview?limit=16", nil) return SessionPreviewMsg{ID: id, Preview: preview, Err: err} }}
func (m AppModel) createSessionCmd(gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil { return sessionCreatedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen} } body := map[string]string{"workspace": m.Workspace} s, err := daemon.Request[daemon.Session](context.Background(), m.Conn, "/sessions", body) return sessionCreatedMsg{Session: s, Err: err, Gen: gen} }}
func (m AppModel) loadProfilesCmd(provider string, gen int) tea.Cmd { return func() tea.Msg { home := config.HomeDir() p, err := config.LoadProfiles(home) return profilesLoadedMsg{ Profiles: p, Provider: provider, Err: err, Gen: gen, } }}
func (m AppModel) loadCommandCatalogCmd(gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil || m.ActiveSession == nil { return commandCatalogLoadedMsg{Gen: gen} }
health, err := daemon.Request[struct { Capabilities []string `json:"capabilities"` }](context.Background(), m.Conn, "/health", nil) if err == nil { hasCap := false for _, c := range health.Capabilities { if c == "session_commands" { hasCap = true break } } if !hasCap { return commandCatalogLoadedMsg{ Err: errors.New("daemon upgrade needed for the command menu; when ready, run albedo daemon --stop, then albedo (this clears python variables)"), Gen: gen, } } }
path := fmt.Sprintf("/sessions/%s/commands", url.PathEscape(m.ActiveSession.ID)) raw, err := daemon.Request[any](context.Background(), m.Conn, path, nil) if err != nil { return commandCatalogLoadedMsg{Err: err, Gen: gen} }
rawBytes, err := json.Marshal(raw) if err != nil { return commandCatalogLoadedMsg{Err: err, Gen: gen} }
cmds, err := daemon.ParseCommandCatalog(rawBytes) return commandCatalogLoadedMsg{Commands: cmds, Err: err, Gen: gen} }}
func (m AppModel) changeModelCmd(model, provider string, gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil || m.ActiveSession == nil { return modelChangedMsg{Err: errors.New("no active session or connection"), Gen: gen} }
if provider != "" && provider != m.ActiveSession.Provider { health, err := daemon.Request[struct { Capabilities []string `json:"capabilities"` }](context.Background(), m.Conn, "/health", nil) if err == nil { hasCap := false for _, c := range health.Capabilities { if c == "session_provider" { hasCap = true break } } if !hasCap { return modelChangedMsg{ Err: errors.New("daemon upgrade needed to switch providers; when ready, run albedo daemon --stop, then albedo (this clears python variables)"), Gen: gen, } } } }
path := fmt.Sprintf("/sessions/%s/commands", url.PathEscape(m.ActiveSession.ID)) args := map[string]string{"model": model} if provider != "" { args["provider"] = provider } body := map[string]any{ "name": "/model", "args": args, }
res, err := daemon.Request[map[string]any](context.Background(), m.Conn, path, body) if err != nil { return modelChangedMsg{Err: err, Gen: gen} }
newModel := model newProvider := provider var newProtocol string
if r, ok := res["result"].(map[string]any); ok { if mVal, ok := r["model"].(string); ok && mVal != "" { newModel = mVal } if pVal, ok := r["provider"].(string); ok && pVal != "" { newProvider = pVal } if protoVal, ok := r["protocol"].(string); ok && protoVal != "" { newProtocol = protoVal } }
return modelChangedMsg{ Model: newModel, Provider: newProvider, Protocol: newProtocol, Gen: gen, } }}
func (m AppModel) executeCommandCmd(name, args string, gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil || m.ActiveSession == nil { return commandExecutedMsg{Name: name, Err: errors.New("no active session or connection"), Gen: gen} }
path := fmt.Sprintf("/sessions/%s/commands", url.PathEscape(m.ActiveSession.ID)) body := map[string]any{ "name": name, } if args != "" { body["arguments"] = args }
res, err := daemon.Request[map[string]any](context.Background(), m.Conn, path, body) if err != nil { return commandExecutedMsg{Name: name, Err: err, Gen: gen} }
msg := fmt.Sprintf("%s done", name) if res != nil { if r, ok := res["result"].(map[string]any); ok { if mStr, ok := r["message"].(string); ok && mStr != "" { msg = mStr } } else if mStr, ok := res["message"].(string); ok && mStr != "" { msg = mStr } }
return commandExecutedMsg{Name: name, Message: msg, Gen: gen} }}
func (m AppModel) hasPageCommands() bool { for _, cmd := range m.CommandCatalog { if cmd.Page != nil && *cmd.Page { return true } } return false}
func glancePollTickCmd(sessionID string, gen int) tea.Cmd { return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return glancePollTickMsg{ SessionID: sessionID, Gen: gen, } })}
func (m AppModel) pollGlancesCmd(gen int) tea.Cmd { return func() tea.Msg { if m.Conn == nil || m.ActiveSession == nil { return glancesPolledMsg{Gen: gen} }
var pageCmds []string for _, cmd := range m.CommandCatalog { if cmd.Page != nil && *cmd.Page { pageCmds = append(pageCmds, cmd.Name) } } if len(pageCmds) == 0 { return glancesPolledMsg{Gen: gen} }
var glances []PageGlance for _, name := range pageCmds { path := fmt.Sprintf("/sessions/%s/commands", url.PathEscape(m.ActiveSession.ID)) body := map[string]any{"name": name, "args": map[string]string{}} res, err := daemon.Request[map[string]any](context.Background(), m.Conn, path, body) if err == nil { var target any = res if r, ok := res["result"]; ok && r != nil { target = r } doc, err := parsePageDocument(target) if err == nil && doc != nil && doc.Glance != nil { glances = append(glances, *doc.Glance) } } }
return glancesPolledMsg{Glances: glances, Gen: gen} }}
func (m *AppModel) updateSessionPickerItems() { m.SessionPicker.SetSessions(m.Sessions, m.ActiveSession)}
func mouseModeCmd(state AppState) tea.Cmd { return func() tea.Msg { if state == AppStateChat { return tea.EnableMouseCellMotion() } return tea.DisableMouse() }}
func (m *AppModel) syncChatNotices() { if m.ActiveSession == nil || m.Chat.History == nil { return } if m.Chat.ExternalNotice != m.Notice || m.Chat.ExternalError != m.Error { m.Chat.ExternalNotice = m.Notice m.Chat.ExternalError = m.Error m.Chat.SetSize(m.Width, m.Height) }}
func (m AppModel) Update(msg tea.Msg) (result tea.Model, command tea.Cmd) { previous := m.State defer func() { updated, ok := result.(AppModel) if !ok { return } updated.syncChatNotices() result = updated if updated.State != previous { command = tea.Batch(command, mouseModeCmd(updated.State)) } }() // First: forward chat lifecycle messages even while a modal is open. switch streamMsg := msg.(type) { case ChatClearCopyStatusMsg: var cmd tea.Cmd if m.ActiveSession != nil && streamMsg.SessionID == m.ActiveSession.ID { m.Chat, cmd = m.Chat.Update(msg) } return m, cmd case ChatStatusPollMsg: var cmd tea.Cmd if m.ActiveSession != nil && streamMsg.SessionID == m.ActiveSession.ID { m.Chat, cmd = m.Chat.Update(msg) } return m, cmd case ChatStatusMsg: var cmd tea.Cmd if m.ActiveSession != nil && streamMsg.SessionID == m.ActiveSession.ID { m.Chat, cmd = m.Chat.Update(msg) } return m, cmd case ChatStreamEventMsg: var cmd tea.Cmd if m.ActiveSession != nil && streamMsg.SessionID == m.ActiveSession.ID { m.Chat, cmd = m.Chat.Update(msg) } return m, cmd case ChatStreamClosedMsg: var cmd tea.Cmd if m.ActiveSession != nil && streamMsg.SessionID == m.ActiveSession.ID { m.Chat, cmd = m.Chat.Update(msg) } return m, cmd case ChatTurnSentMsg: var cmd tea.Cmd if m.ActiveSession != nil && streamMsg.SessionID == m.ActiveSession.ID { m.Chat, cmd = m.Chat.Update(msg) } return m, cmd case ChatInterruptMsg: var cmd tea.Cmd if m.ActiveSession != nil && streamMsg.SessionID == m.ActiveSession.ID { m.Chat, cmd = m.Chat.Update(msg) } return m, cmd case ClipboardImagePastedMsg: var cmd tea.Cmd if m.ActiveSession != nil && streamMsg.SessionID == m.ActiveSession.ID { m.Chat, cmd = m.Chat.Update(msg) } 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.Login.SetSize(msg.Width, msg.Height) return m, nil
case ChatWorkspaceChangedMsg: if m.ActiveSession != nil && (msg.SessionID == "" || msg.SessionID == m.ActiveSession.ID) { m.ActiveSession.Workspace = msg.Workspace m.Workspace = msg.Workspace for i := range m.Sessions { if m.Sessions[i].ID == m.ActiveSession.ID { m.Sessions[i].Workspace = msg.Workspace break } } } return m, nil
case sessionsLoadedMsg: if msg.Gen != m.SessionGen { return m, nil } if msg.Err != nil { m.SessionPicker.Loading = false m.Error = "Error: " + msg.Err.Error() } else { m.Sessions = msg.Sessions m.Error = "" m.SessionPicker.Prune(msg.Sessions) m.updateSessionPickerItems() return m, m.SessionPicker.PreviewCmd() } return m, nil
case sessionCreatedMsg: if msg.Gen != m.SessionGen { return m, nil } if msg.Err != nil { m.Error = "Error: " + msg.Err.Error() return m, nil } m.Chat.Close() s := msg.Session m.ActiveSession = &s m.Sessions = append([]daemon.Session{s}, m.Sessions...) m.Chat = NewChatModel(&s, m.newChatClient(s.ID)) m.Chat.SetSize(m.Width, m.Height) m.State = AppStateChat m.Error = "" m.CatalogGen++ return m, tea.Batch( m.Chat.Init(), m.loadCommandCatalogCmd(m.CatalogGen), )
case commandCatalogLoadedMsg: if msg.Gen != m.CatalogGen { return m, nil } if msg.Err != nil { if strings.Contains(msg.Err.Error(), "daemon upgrade") { m.Error = "Error: " + msg.Err.Error() if m.ActiveSession != nil { } } } else { m.CommandCatalog = msg.Commands m.Chat.CommandMenu.Catalog = msg.Commands m.GlanceGen++ var cmds []tea.Cmd cmds = append(cmds, m.pollGlancesCmd(m.GlanceGen)) if m.ActiveSession != nil && m.hasPageCommands() { cmds = append(cmds, glancePollTickCmd(m.ActiveSession.ID, m.GlanceGen)) } return m, tea.Batch(cmds...) } return m, nil
case glancePollTickMsg: if m.ActiveSession == nil || msg.SessionID != m.ActiveSession.ID || msg.Gen != m.GlanceGen { return m, nil // Obsolete tick dropped } if !m.hasPageCommands() { return m, nil } return m, tea.Batch( m.pollGlancesCmd(m.GlanceGen), glancePollTickCmd(m.ActiveSession.ID, m.GlanceGen), )
case modelChangedMsg: if msg.Gen != m.ModelGen { return m, nil } if msg.Err != nil { if m.State == AppStateModelPicker { m.ModelPicker.Saving = false m.ModelPicker.Error = msg.Err.Error() } else { m.Error = "Error: " + msg.Err.Error() } return m, nil } if m.ActiveSession != nil { if msg.Model != "" { m.ActiveSession.Model = msg.Model m.Chat.Model = msg.Model } if msg.Provider != "" { m.ActiveSession.Provider = msg.Provider m.Chat.Provider = msg.Provider } if msg.Protocol != "" { m.ActiveSession.Protocol = msg.Protocol } } m.ModelPicker.Saving = false m.State = AppStateChat m.Error = "" return m, nil
case commandExecutedMsg: if msg.Gen != m.CommandGen { return m, nil } if msg.Err != nil { m.Error = "Error: " + msg.Err.Error() m.Notice = "" } else { m.Notice = msg.Message m.Error = "" } return m, nil
case glancesPolledMsg: if msg.Gen != m.GlanceGen { return m, nil } if msg.Err == nil { m.Glances = msg.Glances m.Chat.Glances = msg.Glances m.Chat.SetSize(m.Width, m.Height) } return m, nil
case profilesLoadedMsg: if msg.Gen != m.ProfileGen { return m, nil } if msg.Err != nil { m.Error = "Error: " + msg.Err.Error() } else { m.Profiles = msg.Profiles m.Error = "" m.Notice = fmt.Sprintf("%s selected for new sessions", msg.Provider) if m.ActiveSession != nil { m.Notice += fmt.Sprintf("; use /model to switch this session from %s", m.ActiveSession.Provider) } } if m.StandaloneLogin { return m, tea.Quit } if msg.Err == nil && m.ActiveSession == nil && len(m.Sessions) == 0 { m.SessionGen++ return m, m.createSessionCmd(m.SessionGen) } if m.ActiveSession == nil { m.State = AppStateSessionPicker m.updateSessionPickerItems() return m, nil } m.State = AppStateChat return m, nil
case ChatQuitMsg: m.Chat.Close() return m, tea.Quit
case ChatBackToSessionsMsg: m.State = AppStateSessionPicker m.SessionGen++ m.GlanceGen++ // Cancels pending glance poll ticks return m, tea.Batch( m.SessionPicker.Init(), m.loadSessionsCmd(m.SessionGen), )
case ChatNewSessionMsg: m.SessionGen++ return m, m.createSessionCmd(m.SessionGen)
case ChatOpenModelPickerMsg: if m.ActiveSession != nil { m.ModelPicker = NewModelPickerModel(m.Conn, m.Profiles, m.ActiveSession.Model, m.ActiveSession.Provider) m.ModelPicker.SetSize(m.Width, m.Height) m.State = AppStateModelPicker return m, m.ModelPicker.Init() }
case ModelPickerSelectMsg: m.ModelPicker.Saving = true m.ModelPicker.Error = "" m.ModelGen++ return m, m.changeModelCmd(msg.Model, msg.Provider, m.ModelGen)
case ModelPickerCancelMsg: m.State = AppStateChat return m, nil
case ChatOpenExtensionPickerMsg: if m.ActiveSession != nil { m.ExtensionPicker = NewExtensionPickerModel(m.Conn, m.ActiveSession.ID) m.ExtensionPicker.SetSize(m.Width, m.Height) m.State = AppStateExtensionPicker return m, m.ExtensionPicker.Init() }
case ExtensionPickerDoneMsg: m.State = AppStateChat return m, nil
case ExtensionPickerChangedMsg: m.ExtensionRevision++ m.CatalogGen++ return m, m.loadCommandCatalogCmd(m.CatalogGen)
case ChatOpenTreePickerMsg: if m.ActiveSession != nil { m.TreePicker = NewTreePickerModel(m.Conn, m.ActiveSession.ID) m.TreePicker.SetSize(m.Width, m.Height) m.State = AppStateTreePicker return m, m.TreePicker.Init() }
case TreeCancelMsg: m.State = AppStateChat return m, nil
case TreeForkSuccessMsg: m.Chat.Close() branch := msg.Session m.ActiveSession = &branch m.Sessions = append([]daemon.Session{branch}, m.Sessions...) m.Chat = NewChatModel(&branch, m.newChatClient(branch.ID)) m.Chat.SetSize(m.Width, m.Height) m.State = AppStateChat m.CatalogGen++ return m, tea.Batch( m.Chat.Init(), m.loadCommandCatalogCmd(m.CatalogGen), )
case ChatOpenContextInspectorMsg: if m.ActiveSession != nil { m.ContextInspector = NewContextInspectorModel(m.Conn, m.ActiveSession.ID) m.ContextInspector.SetSize(m.Width, m.Height) m.State = AppStateContextInspector return m, m.ContextInspector.Init() }
case ContextDoneMsg: m.State = AppStateChat return m, nil
case ChatOpenCapabilityPageMsg: if m.ActiveSession != nil { m.CapabilityPage = NewCapabilityPageModel(m.Conn, m.ActiveSession.ID, m.ActiveSession.Workspace, msg.Kind) m.CapabilityPage.SetSize(m.Width, m.Height) m.State = AppStateCapabilityPage return m, m.CapabilityPage.Init() }
case CapabilityPageDoneMsg: m.State = AppStateChat return m, nil
case CapabilityPageChangedMsg: m.CatalogGen++ return m, m.loadCommandCatalogCmd(m.CatalogGen)
case ChatOpenPageMsg: if m.ActiveSession != nil { m.PageView = NewPageViewModel(m.Conn, m.ActiveSession.ID, msg.Command) m.PageView.SetSize(m.Width, m.Height) m.State = AppStatePageView return m, m.PageView.Init() }
case PageCancelMsg: m.State = AppStateChat return m, nil
case PageViewChangedMsg: m.GlanceRevision++ m.GlanceGen++ var cmds []tea.Cmd cmds = append(cmds, m.pollGlancesCmd(m.GlanceGen)) if m.ActiveSession != nil && m.hasPageCommands() { cmds = append(cmds, glancePollTickCmd(m.ActiveSession.ID, m.GlanceGen)) } return m, tea.Batch(cmds...)
case ChatOpenLoginMsg: previous := m.Login.Close() m.Login = NewLoginModel(m.Conn, msg.Name) m.Login.BrowserOpener = m.BrowserOpener m.Login.SetSize(m.Width, m.Height) m.State = AppStateLogin return m, tea.Batch(previous, m.Login.Init())
case LoginCancelMsg: m.ProfileGen++ // /login may have removed providers even when it ends without a choice. if profiles, err := config.LoadProfiles(config.HomeDir()); err == nil { m.Profiles = profiles } if m.StandaloneLogin { return m, tea.Quit } if m.ActiveSession != nil { m.State = AppStateChat return m, nil } m.State = AppStateSessionPicker m.updateSessionPickerItems() m.SessionGen++ return m, tea.Batch(m.SessionPicker.Init(), m.loadSessionsCmd(m.SessionGen))
case LoginDoneMsg: if m.State != AppStateLogin { return m, nil } m.ProfileGen++ return m, m.loadProfilesCmd(msg.Name, m.ProfileGen)
case ChatExecuteCommandMsg: switch { case msg.Name == "/login": previous := m.Login.Close() m.Login = NewLoginModel(m.Conn, msg.Args) m.Login.BrowserOpener = m.BrowserOpener m.Login.SetSize(m.Width, m.Height) m.State = AppStateLogin return m, tea.Batch(previous, m.Login.Init()) case msg.Name == "/model" && msg.Args != "": m.ModelGen++ return m, m.changeModelCmd(msg.Args, "", m.ModelGen) case msg.Name == "/model": if m.ActiveSession != nil { m.ModelPicker = NewModelPickerModel(m.Conn, m.Profiles, m.ActiveSession.Model, m.ActiveSession.Provider) m.ModelPicker.SetSize(m.Width, m.Height) m.State = AppStateModelPicker return m, m.ModelPicker.Init() } case msg.Name == "/new": m.SessionGen++ return m, m.createSessionCmd(m.SessionGen) case msg.Name == "/sessions" || msg.Name == "/agents" || msg.Name == "/a": m.State = AppStateSessionPicker m.SessionGen++ m.GlanceGen++ return m, tea.Batch(m.SessionPicker.Init(), m.loadSessionsCmd(m.SessionGen)) case msg.Name == "/extensions" || msg.Name == "/plugins": if m.ActiveSession != nil { m.ExtensionPicker = NewExtensionPickerModel(m.Conn, m.ActiveSession.ID) m.ExtensionPicker.SetSize(m.Width, m.Height) m.State = AppStateExtensionPicker return m, m.ExtensionPicker.Init() } case msg.Name == "/tree": if m.ActiveSession != nil { m.TreePicker = NewTreePickerModel(m.Conn, m.ActiveSession.ID) m.TreePicker.SetSize(m.Width, m.Height) m.State = AppStateTreePicker return m, m.TreePicker.Init() } case msg.Name == "/context": if m.ActiveSession != nil { m.ContextInspector = NewContextInspectorModel(m.Conn, m.ActiveSession.ID) m.ContextInspector.SetSize(m.Width, m.Height) m.State = AppStateContextInspector return m, m.ContextInspector.Init() } default: m.CommandGen++ return m, m.executeCommandCmd(msg.Name, msg.Args, m.CommandGen) }
case PickerSelectMsg: if m.State == AppStateSessionPicker { if msg.ID == "new" { m.SessionGen++ return m, m.createSessionCmd(m.SessionGen) } if msg.ID == "login" { previous := m.Login.Close() m.Login = NewLoginModel(m.Conn, "") m.Login.BrowserOpener = m.BrowserOpener m.Login.SetSize(m.Width, m.Height) m.State = AppStateLogin return m, tea.Batch(previous, m.Login.Init()) } // The active session may not yet appear in the daemon's recent list. listed := m.Sessions if m.ActiveSession != nil { found := false for _, s := range listed { if s.ID == m.ActiveSession.ID { found = true break } } if !found { listed = append([]daemon.Session{*m.ActiveSession}, listed...) } } for _, s := range listed { if s.ID == msg.ID { m.SessionPicker.RecordOpen(s.ID) m.Chat.Close() session := s m.ActiveSession = &session m.Chat = NewChatModel(&session, m.newChatClient(session.ID)) m.Chat.SetSize(m.Width, m.Height) m.State = AppStateChat m.CatalogGen++ m.GlanceGen++ return m, tea.Batch( m.Chat.Init(), m.loadCommandCatalogCmd(m.CatalogGen), ) } } }
case PickerCancelMsg: if m.State == AppStateSessionPicker { 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: m.Chat, cmd = m.Chat.Update(msg) 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 AppStateLogin: m.Login, cmd = m.Login.Update(msg) }
return m, cmd}
func (m AppModel) View() string { var prefix strings.Builder if m.State == AppStateSessionPicker { if m.Notice != "" { prefix.WriteString(DefaultStyles.Faint.Render(m.Notice) + "\n") } if m.Error != "" { prefix.WriteString(DefaultStyles.Error.Render(m.Error) + "\n") } }
var content string switch m.State { case AppStateChat: content = m.Chat.View() case AppStateSessionPicker: content = m.SessionPicker.View() case AppStateModelPicker: content = m.ModelPicker.View() case AppStateExtensionPicker: content = m.ExtensionPicker.View() case AppStateTreePicker: content = m.TreePicker.View() case AppStateContextInspector: content = m.ContextInspector.View() case AppStatePageView: content = m.PageView.View() case AppStateCapabilityPage: content = m.CapabilityPage.View() case AppStateLogin: content = m.Login.View() }
if prefix.Len() > 0 { return prefix.String() + content } return content}