fix(tui): keep replies and paste in their lane
Reject stale screen replies, finish chat loads under modals, and feed paste to the focused input.
40 unmodified lines4142434445464748495040 unmodified lines- `/context [section] [page]` — the prepared model request: a summary, or one bounded section page.- `/raise-cap [on|off] [model]` — raise a model's context window to the provider's maximum, or restore its default (user only). Without a state it toggles; without a model it uses the session's. The choice is saved per model in `raisedCaps` and applies to every session on that model from its next request. See [raised caps](models.md#raised-caps).
skills contributes one command per cataloged skill; `/tree`, `/fork`, `/login`, and the other picker-style entries stay presentation-only in the CLI.
## CLI page lifecycle
`cli/internal/tui/page.go` allocates a unique generation for each page instance and each superseding request. Generic pages, capability pages, extension pickers, tree pickers, context inspectors, and webhook pages reject responses from earlier generations before changing data or operation state. A delayed tree-fork completion also carries its generation to the app.
The app forwards history and context-window responses to the active chat while a modal is open. Chat checks the session and generation before accepting them. Generic page text and secret inputs receive paste and cursor messages while focused, including the input component's returned command.612 unmodified linesExpand all613614615616617618619620621622623624371 unmodified linesExpand all9969979989991000100110021003100410051006259 unmodified linesExpand all612 unmodified linesExpand all // First: forward chat lifecycle messages even while a modal is open. var sid string var forward bool switch sm := msg.(type) { case ChatOlderLoadedMsg: sid, forward = sm.SessionID, true case ChatWindowMsg: sid, forward = sm.SessionID, true case ChatEditorFinishedMsg: sid, forward = sm.SessionID, true case ChatClearCopyStatusMsg: sid, forward = sm.SessionID, true371 unmodified linesExpand all case ChatOpenTreePickerMsg: return m, m.openTreePicker()
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()259 unmodified linesExpand all9 unmodified lines10111213141415161736 unmodified lines5455565759606162635859666061626364108 unmodified linesExpand all17317417517618317717817918018112 unmodified lines1941951961972041981992002012024 unmodified lines20720820921021721121221321421552 unmodified lines268269270271278272273274275276199 unmodified linesExpand all9 unmodified lines "net/url" "regexp" "slices" "strings" "sync/atomic"
tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi")36 unmodified linestype CapabilityPageChangedMsg struct{}
type ChatOpenCapabilityPageMsg struct{ Kind string }
// Commands outlive closed pages; globally unique generations reject their replies.var capabilityGen atomic.Int64
func nextCapabilityGen() int { return int(capabilityGen.Add(1)) }
func NewCapabilityPageModel(conn *daemon.Connection, sessionID, kind string) CapabilityPageModel { // Pages open on the global defaults; s scopes changes to this session. return CapabilityPageModel{Conn: conn, SessionID: sessionID, Kind: kind, Global: true, page: page{Loading: true, Generation: nextCapabilityGen()}} return CapabilityPageModel{Conn: conn, SessionID: sessionID, Kind: kind, Global: true, page: page{Loading: true, Generation: nextPageGeneration()}}}func (m CapabilityPageModel) Init() tea.Cmd { return m.loadCmd(m.Generation) }func (m CapabilityPageModel) loadCmd(gen int) tea.Cmd { kind := m.Kind108 unmodified linesExpand all}
func (m CapabilityPageModel) save(cmd func(int) tea.Cmd) (CapabilityPageModel, tea.Cmd) { m.Saving = true m.Generation = nextCapabilityGen() m.Generation = nextPageGeneration() return m, cmd(m.Generation)}
func (m CapabilityPageModel) Update(msg tea.Msg) (CapabilityPageModel, tea.Cmd) {12 unmodified lines if msg.Gen == m.Generation { if apiErr, ok := errors.AsType[*daemon.APIError](msg.Err); ok && apiErr.Code == "stale_catalog" { m.Saving, m.Loading, m.Error = false, true, "" m.Notice = "The list changed. Review it before trying again." m.Generation = nextCapabilityGen() m.Generation = nextPageGeneration() return m, m.loadCmd(m.Generation) } } if !m.settle(msg.Gen, msg.Err, &m.Saving) {4 unmodified lines m.Notice = "Saved. Session reloaded." if msg.Warning != "" { m.Notice = msg.Warning } m.Loading, m.Generation = true, nextCapabilityGen() m.Loading, m.Generation = true, nextPageGeneration() return m, tea.Batch(m.loadCmd(m.Generation), func() tea.Msg { return CapabilityPageChangedMsg{} }) case tea.PasteMsg: if m.Form == nil || m.Saving { return m, nil52 unmodified lines hasItems := len(m.Items) > 0 switch msg.String() { case "r": m.Loading = true m.Generation = nextCapabilityGen() m.Generation = nextPageGeneration() return m, m.loadCmd(m.Generation) case "g", "s": m.Global = msg.String() == "g" case "E":199 unmodified linesExpand all98 unmodified lines99100101102103103104105106107126 unmodified linesExpand all23423523623723823823924024124213 unmodified lines25625725825926026026126226326412 unmodified lines2772782792802812812822832842859 unmodified lines295296297298299299300301302303130 unmodified linesExpand all98 unmodified linesfunc NewContextInspectorModel(conn *daemon.Connection, sessionID string) ContextInspectorModel { return ContextInspectorModel{ Conn: conn, SessionID: sessionID, page: page{Loading: true}, page: page{Loading: true, Generation: nextPageGeneration()}, Styles: DefaultStyles, }}
126 unmodified linesExpand all switch msg.String() { case "esc", "ctrl+c", "ctrl+d": if m.Detail != nil { m.Detail = nil m.Generation++ m.Generation = nextPageGeneration() return m, nil } return m, func() tea.Msg { return ContextDoneMsg{} } }13 unmodified lines page = m.Detail.Page + 1 } if page >= 0 && page < m.Detail.Section.Pages { m.Detail.Page, m.Detail.Value, m.Detail.Scroll, m.Detail.Error = page, nil, 0, "" m.Generation++ m.Generation = nextPageGeneration() return m, m.loadPageCmd(m.Detail.Section.ID, m.Detail.Page, m.Generation) } case "up": if m.Detail.Scroll > 0 {12 unmodified lines }
if strings.EqualFold(msg.String(), "r") { m.Loading, m.Snapshot, m.Detail, m.Error = true, nil, nil, "" m.Generation++ m.Generation = nextPageGeneration() return m, m.loadSnapshotCmd(m.Generation) }
switch msg.String() {9 unmodified lines if m.Snapshot != nil && len(m.Snapshot.Sections) > m.Cursor { sec := m.Snapshot.Sections[m.Cursor] if sec.Pages > 0 { m.Detail = &ContextDetail{Section: sec} m.Generation++ m.Generation = nextPageGeneration() return m, m.loadPageCmd(sec.ID, 0, m.Generation) } } }130 unmodified linesExpand all46 unmodified lines4748495051515253545587 unmodified lines1431441451461471471481491501511 unmodified line153154155156157157158159160161191 unmodified linesExpand all46 unmodified linesfunc NewExtensionPickerModel(conn *daemon.Connection, sessionID string) ExtensionPickerModel { return ExtensionPickerModel{ Conn: conn, SessionID: sessionID, page: page{Loading: true}, page: page{Loading: true, Generation: nextPageGeneration()}, Styles: DefaultStyles, }}
87 unmodified lines
if len(m.Extensions) == 0 && m.Error != "" { if strings.EqualFold(msg.String(), "r") { m.Loading, m.Error = true, "" m.Generation++ m.Generation = nextPageGeneration() return m, m.loadExtensionsCmd(m.Generation) } return m, nil }1 unmodified line if m.Confirming { if msg.String() == "enter" && len(m.Extensions) > m.Cursor { ext := m.Extensions[m.Cursor] m.Saving, m.Error = true, "" m.Generation++ m.Generation = nextPageGeneration() scope, val := "global", !ext.GlobalEnabled if m.Inheriting { scope, val = "inherit", false } else if m.Session {191 unmodified linesExpand all12 unmodified lines1314151617171819202128 unmodified lines12 unmodified lines
func TestExtensionsOpenOnGlobalDefaultsAndScopeToSessionOnRequest(t *testing.T) { m := NewExtensionPickerModel(nil, "s") m.SetSize(120, 30) m, _ = m.Update(extensionsLoadedMsg{Extensions: []ExtensionItem{ m, _ = m.Update(extensionsLoadedMsg{Gen: m.Generation, Extensions: []ExtensionItem{ {Name: "view", Enabled: true, GlobalEnabled: false, Overridden: true}, {Name: "bash", Enabled: true, GlobalEnabled: true}, }}) view := ansi.Strip(m.View())28 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162// Screen replacement and modal races depend on command delivery order, which// daemon E2E cannot control. These tests drive the actual Bubble Tea models.package tui
import ( "albedo/cli/internal/daemon" "errors" "strings" "testing"
"charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2")
func TestReopenedScreensRejectPreviousRequestReplies(t *testing.T) { for _, replyErr := range []error{nil, errors.New("discarded request failed")} { t.Run("page", func(t *testing.T) { old := NewPageViewModel(nil, "s", "/notes") old, _ = old.Update(pageLoadedMsg{Gen: old.Generation, Err: errors.New("retry")}) old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) current := NewPageViewModel(nil, "s", "/notes") current, cmd := current.Update(pageLoadedMsg{Gen: old.Generation, Err: replyErr, Doc: &PageDocument{Title: "obsolete"}}) if !current.Busy || current.Doc != nil || current.Error != "" || cmd != nil { t.Fatal("old load changed reopened page") } current, cmd = current.Update(pageActionExecutedMsg{Gen: old.Generation, Err: replyErr}) if !current.Busy || current.Error != "" || cmd != nil { t.Fatal("old action completed reopened page") } current, _ = current.Update(pageLoadedMsg{Gen: current.Generation, Doc: &PageDocument{Title: "current"}}) if current.Busy || current.Doc.Title != "current" { t.Fatal("current load was rejected") } }) t.Run("extensions", func(t *testing.T) { old := NewExtensionPickerModel(nil, "s") old, _ = old.Update(extensionsLoadedMsg{Gen: old.Generation, Err: errors.New("retry")}) old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) current := NewExtensionPickerModel(nil, "s") current.Saving = true current, _ = current.Update(extensionsLoadedMsg{Gen: old.Generation, Err: replyErr, Extensions: []ExtensionItem{{Name: "obsolete"}}}) current, cmd := current.Update(extensionToggledMsg{Gen: old.Generation, Err: replyErr}) if !current.Loading || !current.Saving || len(current.Extensions) != 0 || current.Error != "" || cmd != nil { t.Fatal("old reply changed reopened extensions") } }) t.Run("tree", func(t *testing.T) { old := NewTreePickerModel(nil, "s") old, _ = old.loadPage(10) current := NewTreePickerModel(nil, "s") current.Forking = true current, _ = current.Update(treeLoadedMsg{Gen: old.Generation, Err: replyErr}) current, cmd := current.Update(treeForkedMsg{Gen: old.Generation, Err: replyErr}) if !current.Loading || !current.Forking || current.Error != "" || current.ForkError != "" || cmd != nil { t.Fatal("old reply changed reopened tree") } }) t.Run("context", func(t *testing.T) { old := NewContextInspectorModel(nil, "s") old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) current := NewContextInspectorModel(nil, "s") current.Detail = &ContextDetail{Section: ContextSection{ID: "history"}} current, _ = current.Update(contextSnapshotLoadedMsg{Gen: old.Generation, Err: replyErr, Snapshot: &ContextSnapshot{State: "ready"}}) current, _ = current.Update(contextPageLoadedMsg{Gen: old.Generation, Err: replyErr, SectionID: "history", Data: &ContextPage{Content: "obsolete"}}) if !current.Loading || current.Snapshot != nil || current.Error != "" || current.Detail.Value != nil || current.Detail.Error != "" { t.Fatal("old reply changed reopened context") } }) t.Run("webhooks", func(t *testing.T) { old := NewWebhooksPageModel(nil, "s") old.Loading = false old, _ = old.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) current := NewWebhooksPageModel(nil, "s") current.Saving = true current, _ = current.Update(webhooksLoadedMsg{Gen: old.Generation, Err: replyErr}) current, cmd := current.Update(webhooksSavedMsg{Gen: old.Generation, Err: replyErr, Notice: "obsolete"}) if !current.Loading || !current.Saving || current.Error != "" || current.Notice != "" || cmd != nil { t.Fatal("old reply changed reopened webhooks") } }) }}
func TestPageFocusedInputReceivesPasteAndCursorCommands(t *testing.T) { for _, inputKind := range []string{"text", "secret"} { t.Run(inputKind, func(t *testing.T) { page := NewPageViewModel(nil, "s", "/notes") page.Busy = false page.Doc = &PageDocument{Actions: []PageAction{{Key: "e", Label: "edit", Input: inputKind}}} styles := page.TextInput.Styles() styles.Cursor.Blink = true page.TextInput.SetStyles(styles) page, _ = page.Update(tea.KeyPressMsg{Code: 'e', Text: "e"}) page, _ = page.Update(tea.PasteMsg{Content: "pasted value"}) if page.TextInput.Value() != "pasted value" { t.Fatal("bracketed paste was lost") } if inputKind == "secret" && strings.Contains(page.View(), "pasted value") { t.Fatal("pasted secret appeared in view") } page, cmd := page.Update(textinput.Blink()) if cmd == nil { t.Fatal("input cursor command was dropped") } page, _ = page.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) page, _ = page.Update(tea.PasteMsg{Content: "ignored"}) if page.TextInput.Value() != "pasted value" { t.Fatal("browse mode accepted paste") } }) }}
func TestChatHistoryAndWindowCompletionsSurviveModal(t *testing.T) { conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, "") session := &daemon.Session{ID: "s"} chat := NewChatModel(session, daemon.NewChatClient(conn, "s")) defer chat.Close() chat.SetSize(80, 24) chat.loadingOlder = true model := AppModel{ActiveSession: session, Chat: chat, State: AppStateContextInspector} updated, _ := model.Update(ChatOlderLoadedMsg{SessionID: "other", Generation: chat.Generation, Page: &daemon.HistoryPage{}}) model = updated.(AppModel) if !model.Chat.loadingOlder { t.Fatal("another session completed the active history request") } updated, _ = model.Update(ChatOlderLoadedMsg{SessionID: "s", Generation: chat.Generation - 1, Page: &daemon.HistoryPage{}}) model = updated.(AppModel) if !model.Chat.loadingOlder { t.Fatal("stale generation completed the active history request") } updated, _ = model.Update(ChatOlderLoadedMsg{SessionID: "s", Generation: chat.Generation, Page: &daemon.HistoryPage{Before: 10, More: true}}) model = updated.(AppModel) if model.Chat.loadingOlder || !model.Chat.olderMore { t.Fatal("modal lost history completion") } tokens := 123456 updated, _ = model.Update(ChatWindowMsg{SessionID: "s", Generation: chat.Generation, Model: "model", Tokens: &tokens}) model = updated.(AppModel) if model.Chat.window == nil || *model.Chat.window != tokens { t.Fatal("modal lost context window completion") } model.Chat.Follow = false if model.Chat.loadOlder() == nil || !model.Chat.loadingOlder { t.Fatal("paging stayed stuck after modal completion") }}
func TestDelayedForkCompletionCannotReplaceReopenedTree(t *testing.T) { old := NewTreePickerModel(nil, "s") old.Forking = true old, cmd := old.Update(treeForkedMsg{Gen: old.Generation, Session: daemon.Session{ID: "fork"}}) if old.Forking || cmd == nil { t.Fatal("current fork did not complete") } model := AppModel{State: AppStateTreePicker, TreePicker: NewTreePickerModel(nil, "s"), ActiveSession: &daemon.Session{ID: "s"}} updated, next := model.Update(cmd()) model = updated.(AppModel) if model.ActiveSession.ID != "s" || next != nil { t.Fatal("discarded tree completion changed session") }}1 unmodified line23456789106 unmodified lines1718192021222324252627282974 unmodified lines1 unmodified line
import ( "slices" "strings" "sync/atomic"
"github.com/charmbracelet/x/ansi")
6 unmodified lines Cursor, Width, Height, Generation int Loading, Saving bool}
// Commands outlive closed screens; unique generations reject their replies.var pageGeneration atomic.Int64
func nextPageGeneration() int { return int(pageGeneration.Add(1)) }
func (p *page) SetSize(width, height int) { p.Width, p.Height = width, height }
// settle takes a reply for the request busy tracks: false when it is stale// or failed, with the failure shown.74 unmodified lines73 unmodified lines74757677787980818273 unmodified lines15615715815915916016116216316445 unmodified lines21021121221321321421521621721813 unmodified lines23223323423523523623723823924057 unmodified lines298299300301302303304305306307308309310325 unmodified linesExpand all73 unmodified lines Conn: conn, SessionID: sessionID, Command: command, TextInput: newField(), page: page{Generation: nextPageGeneration()}, Busy: true, Styles: DefaultStyles, }}73 unmodified lines// runAction marks the page busy and asks the daemon to run act.func (m *PageViewModel) runAction(act PageAction, entered string) tea.Cmd { m.Mode, m.CurrentAction = modeBrowse, nil m.Busy, m.Error, m.Notice = true, "", "" m.Generation++ m.Generation = nextPageGeneration() return m.executeActionCmd(act, m.currentRow(), entered, m.Generation)}
// beginAction opens the interaction act needs: Update asks for a45 unmodified lines } m.Notice = pageNotice(msg) m.Error = "" m.Busy = true m.Generation++ m.Generation = nextPageGeneration() return m, tea.Batch( func() tea.Msg { return PageViewChangedMsg{} }, m.loadPageCmd(m.Generation), )13 unmodified lines if m.Doc == nil { if strings.ToLower(msg.String()) == "r" { m.Busy = true m.Error = "" m.Generation++ m.Generation = nextPageGeneration() return m, m.loadPageCmd(m.Generation) } return m, nil }57 unmodified lines } } } } if m.Mode == modeText && m.TextInput.Focused() { var cmd tea.Cmd m.TextInput, cmd = m.TextInput.Update(msg) return m, cmd } return m, nil}
// pageLayout places the list and the selected row's detail under the title325 unmodified linesExpand all35 unmodified lines363738394040414243444529 unmodified lines757677787879808182832 unmodified lines8687888989909192939458 unmodified lines15315415515615615715815916016111 unmodified lines17317417517617617717817918018188 unmodified lines35 unmodified lines
type TreeCancelMsg struct{}
type TreeForkSuccessMsg struct { Session daemon.Session Session daemon.Session Generation int}
type treeLoadedMsg struct { Err error29 unmodified lines Conn: conn, SessionID: sessionID, Cursors: []int{0}, PageIndex: 0, page: page{Loading: true}, page: page{Loading: true, Generation: nextPageGeneration()}, Styles: DefaultStyles, }}
2 unmodified lines}
func (m TreePickerModel) loadPage(after int) (TreePickerModel, tea.Cmd) { m.Loading, m.Error = true, "" m.Generation++ m.Generation = nextPageGeneration() return m, m.loadTreeCmd(after, m.Generation)}
func (m TreePickerModel) loadTreeCmd(after int, gen int) tea.Cmd {58 unmodified lines if msg.Err != nil { m.ForkError = operationError(msg.Err, "", "Branch may have been created; check the session list before branching again.") return m, nil } return m, func() tea.Msg { return TreeForkSuccessMsg{Session: msg.Session} } return m, func() tea.Msg { return TreeForkSuccessMsg{Session: msg.Session, Generation: msg.Gen} }
case tea.KeyPressMsg: if msg.String() == "esc" || msg.String() == "ctrl+c" || msg.String() == "ctrl+d" { if m.Confirming {11 unmodified lines if msg.String() == "enter" && len(m.Checkpoints) > m.Cursor { cp := m.Checkpoints[m.Cursor] m.Forking = true m.ForkError, m.Error = "", "" m.Generation++ m.Generation = nextPageGeneration() return m, m.forkCmd(cp.ID, m.Generation) } return m, nil }88 unmodified lines76 unmodified lines77787980818182838485130 unmodified linesExpand all21621721821922022022122222322421 unmodified lines24624724824925025025125225325452 unmodified lines307308309310311311312313314315390 unmodified linesExpand all76 unmodified lines defaultSignaturePrefix = "sha256=")
func NewWebhooksPageModel(conn *daemon.Connection, sessionID string) WebhooksPageModel { return WebhooksPageModel{Conn: conn, SessionID: sessionID, Mounted: true, page: page{Loading: true}} return WebhooksPageModel{Conn: conn, SessionID: sessionID, Mounted: true, page: page{Loading: true, Generation: nextPageGeneration()}}}
func (m WebhooksPageModel) Init() tea.Cmd { return m.loadCmd(m.Generation) }
130 unmodified linesExpand all
func (m WebhooksPageModel) begin(notice string, steps ...[2]string) (WebhooksPageModel, tea.Cmd) { m.Error, m.Notice = "", "" m.Saving = true m.Generation++ m.Generation = nextPageGeneration() return m, m.save(m.Generation, notice, steps...)}
func (m WebhooksPageModel) Update(msg tea.Msg) (WebhooksPageModel, tea.Cmd) {21 unmodified lines } } else { m.Form, m.Notice = nil, msg.Notice } m.Loading, m.Generation = true, m.Generation+1 m.Loading, m.Generation = true, nextPageGeneration() return m, m.loadCmd(m.Generation) case tea.KeyPressMsg: key := msg.String() if m.Saving {52 unmodified lines } if key == "r" { m.Error, m.Notice = "", "" m.Loading = true m.Generation++ m.Generation = nextPageGeneration() return m, m.loadCmd(m.Generation) } if !m.Loaded { return m, nil390 unmodified linesExpand all19 unmodified lines2021222324242526272864 unmodified lines19 unmodified linesfunc loadedWebhooksPage(t *testing.T) WebhooksPageModel { t.Helper() m := NewWebhooksPageModel(nil, "s") m.SetSize(100, 30) m, _ = m.Update(webhooksLoadedMsg{Mounted: true, Sessions: webhookSessions, Hooks: []webhookEntry{ m, _ = m.Update(webhooksLoadedMsg{Gen: m.Generation, Mounted: true, Sessions: webhookSessions, Hooks: []webhookEntry{ {ID: "wh1", Session: "s", Name: "deploy", Enabled: true, URL: "/webhooks/wh1", Header: "x-hub-signature-256", Prefix: "sha256=", Queued: 2, Deferred: "session busy"}, {ID: "wh2", Session: "t", Name: "grafana", URL: "/webhooks/wh2", Header: "x-albedo-signature", Prefix: "sha256="}, }}) return m64 unmodified lines