package tui import ( "context" "errors" "fmt" "os" "strings" "time" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "codey/internal/agent" "codey/internal/approval" "codey/internal/ask" "codey/internal/attach" "codey/internal/config" "codey/internal/llm" "codey/internal/skills" "codey/internal/subagent" "codey/internal/tools" ) // Options wires the TUI to an already-built agent. The TUI owns no policy of // its own: it renders what the agent reports and asks what the policy asks. type Options struct { Agent *agent.Agent Policy *approval.Policy // Asker is what the ask_user tool reaches the user through. Nil means the // session has no such tool, and nothing here will try to draw a question. Asker *ask.Asker Client *llm.Client // Root is where the work happens: what the tools are jailed to and what // a shell command runs in. /worktree moves it into a worktree, which is // why it is not also where state is kept. Root string // HomeRoot is the checkout Codey was started in, and never moves. The // sessions and the event log stay there even after the work has moved // elsewhere, so one directory holds the whole history of what was done // from it rather than a trail scattered across worktrees. HomeRoot string // Worktree is the tree the work has moved into, empty until /worktree // moves it. It is saved with the session so reopening one puts the work // back where it was. Worktree string SessionDir string SessionID string ToolNames []string Verbose bool YOLO bool // NewTools rebuilds the whole tool set against a root — subagent tools // included — and re-points the supervisor, so children spawned afterwards // land where the parent now is. /worktree needs it because each tool // captures the root it was built with: moving the work means building the // tools again, not setting a field. Nil disables /worktree, which is what // a test that does not wire it gets. NewTools func(root string) *tools.Registry // SystemPrefix is the identity block an OAuth session must send ahead of // everything else. /worktree's naming call is built by hand rather than // through the agent, so it needs its own copy. SystemPrefix string // Version, Effort and ContextWindow are display only: they let the // welcome block and the status bar report the run's shape rather than // guess at it. Version string Effort string ContextWindow int // Banner is an optional first line of scrollback, used for things the CLI // wants to say before the loop starts: a resumed session, a non-default // endpoint. Banner []string // Skills are the instruction sets discovered at startup, used to resolve // / and to answer /skills. Skills []skills.Skill // Agents is the subagent supervisor, or nil where there is none: the // sidebar simply never opens, and no spawn tool was registered either. Agents Agents // Config is what was loaded from disk, and FirstRun says there was nothing // to load — which is what opens the setup wizard unprompted. Config config.Config FirstRun bool // WebSearch is the mode this process is actually running with, resolved at // startup and fixed: the tool set and the request's hosted tool definitions // were both built from it before the first turn. // // It is deliberately not the same thing as Config.WebSearch. The config // says what was asked for; this says what was delivered, and they differ // when -no-web was passed, when the search command turned out not to be // installed, or when the wizard has just written an answer this process // cannot adopt. Screens compare against this one, so that "takes effect // next start" is said exactly when it is true. WebSearch string // SaveConfig persists a finished wizard's answers. Applying them is the // TUI's own job — it is the same work /model and /effort already do — so // all that is left for the caller is the file, which is the one part the // TUI has no business knowing about. SaveConfig func(config.Config) error // NewSession builds a second conversation for a new tab, rooted at the // directory it is given. The TUI cannot build one itself: an agent is a // client, a tool set, a state and a policy, and which of those a run is // entitled to was decided by the flags and the config before the screen // existed. Nil disables the whole tab strip, which is what a test that // does not wire it gets, and what the single-session case has always been. NewSession func(root string) (SessionSpec, error) } // SessionSpec is one fresh conversation: the pieces a tab needs that only the // caller can build. Each tab owns its own of every one of them, so two tabs // share nothing but the client and the screen — a turn in one cannot be // interrupted, approved or compacted by what happens in another. type SessionSpec struct { Agent *agent.Agent Policy *approval.Policy // SessionID is the name this conversation will be saved under. It must not // be one already in use: two tabs writing the same file would each save // half a history over the other's. SessionID string ToolNames []string NewTools func(root string) *tools.Registry // Agents is this tab's own subagent supervisor, or nil where the run has // no subagents at all. Agents Agents // Close releases what the session holds — its supervisor above all — when // the tab is closed. Nil when there is nothing to release. Close func() } type phase int const ( phaseIdle phase = iota phaseRunning phaseApproving // phaseEffort is the effort slider: the prompt is gone until a level is // picked or the slider is dismissed. phaseEffort // phaseModel is the model picker, which starts on a wait: the list comes // from the endpoint. phaseModel // phaseSettings is the settings panel, where the saved defaults are // changed in place. phaseSettings // phaseAsking is a question the agent stopped to ask. Like an approval it // is the agent waiting on a human rather than the other way round. phaseAsking // phaseSessions is the session chooser: the saved conversations for this // working root, one of which replaces the one on screen. phaseSessions // phaseWorktrees is the worktree chooser behind /worktree resume: the // trees this repository has, narrowed by typing, one of which the work // moves into with a new chat in it. phaseWorktrees ) // inputMaxLines caps how far the prompt box grows before it scrolls. Past // about six lines the box is eating the transcript it exists to talk about. const inputMaxLines = 6 // defaultPlaceholder is what the prompt box says when it is pointed at the // model, which the local modes replace with their own. const defaultPlaceholder = "ask for a change, /help, ! for a shell" type model struct { opts Options bridge *bridge // id is which tab this conversation is, as handed out by the tab strip. // Everything that reaches the UI from somewhere else — an agent event, a // finished turn, a command that went off to the network — is tagged with // it on the way out, so that work in a tab nobody is looking at lands in // its own conversation instead of the one on screen. Zero means there is // no strip: a model driven on its own, which is what the tests do and what // a session with a single tab amounts to. id int // title is what the tab strip calls this conversation: the first thing // asked of it, which is what someone scanning a row of tabs is looking // for. Empty until something is asked. title string vp viewport.Model input textarea.Model spin spinner.Model tr *transcript phase phase width int height int ready bool // Turn bookkeeping. turn int started time.Time usage llm.Usage turns int // cost is the running bill, accumulated as usage is reported and priced // at the model in force at that moment: a session that switched models // was not all billed at the model it happens to end on. priced is what // has already been counted, so a turn's totals — which on a resumed // session include history this process never saw — are charged once. cost float64 costKnown bool priced llm.Usage // ctxTokens is the last turn's input size — what the model actually read, // which is the number the context gauge is about. It replaces itself each // turn rather than accumulating, and compaction makes it drop. ctxTokens int // verbSeed varies the working verb per turn so two turns in a row do not // open with the same word. verbSeed int // menu is the slash-command completion list under the prompt. menu menu // local is which interpreter the prompt box is pointed at: off, "!" for // the shell, "$" for python. running is the command one of them started, // or nil. local localMode running *localRun // effortIdx is where the marker sits while the effort slider is up. effortIdx int // The settings panel. setIdx is the row under the cursor and setTouched // is which rows were actually changed, which is what gets written back — // see settingsConfig. setFrom records that the model picker was opened // from the panel, so it knows to return there, and setModelWas is the // model the panel opened on, which is how a trip through the picker is // noticed without the two having to agree on an order. setWeb is the // web-search mode the panel is showing, held here rather than read back // off the session because it is the one setting the session cannot change: // the tool set is built at startup, so cycling that row edits what will be // saved instead of pretending to take effect under a running agent. setIdx settingRow setTouched [numSettingRows]bool setFrom bool setModelWas string setWeb string // The model picker: what the endpoint offered, where the cursor is, and // whether we are still waiting to be told. models []llm.Model modelIdx int modelsLoading bool // The session chooser: the saved conversations for this root, and where // the cursor is in them. Both are dropped when it closes. sessions []agent.SessionInfo sessionIdx int // The worktree chooser: every tree of this repository, what has been typed // to narrow them, what is left after that, and where the cursor is in it. // The cursor indexes the narrowed list, which is the one on screen. worktrees []worktreeInfo worktreeShown []worktreeInfo worktreeFilter string worktreeIdx int // The agents sidebar: whether it is open, what it last drew, and which // agent's thread has taken over the main pane. bar sidebar viewing string // subTr is one scrollback per subagent, so a thread you open reads the // way your own does. subTr map[string]*transcript // pendingReports are finished agents whose reports have not gone up yet, // because a turn was running or the prompt had text in it. pendingReports []subagent.Info // pill is where the agents pill was last drawn inside the status line, so // a click on it can be told from a click on the rest of that line. pill rowHit // worktreeing is a /worktree in flight: a naming call and a script, with // no turn behind them. Like compacting, it owns the spinner and the // interrupt key while it lasts. worktreeing bool // compacting is a /compact in flight: a model call with no turn behind it, // which still wants the spinner and the interrupt key. compacting bool // pending approvals. The agent blocks on one at a time, but a queue costs // nothing and means a second request can never be dropped — and with // subagents there can genuinely be several, from different agents. pending []approvalMsg // asking is the queue of questions the agent has stopped to ask, the same // shape and for the same reasons as pending. Each carries its own // half-finished answers, so it is state rather than a bare message. asking []*askState // beforeModal is what the session was doing when the first box went up — // an approval or a question. Neither implies a turn in flight: a subagent // can ask while you are idle, and answering has to put you back where you // were. beforeModal phase // ctx plumbing for the running turn. parent context.Context cancel context.CancelFunc // interrupting records that the user asked to stop, so the status line can // say so while the agent unwinds. interrupting bool // The mouse. mouseOn is whether Codey is asking the terminal for // mouse reporting at all: it costs the terminal's own text selection, so // /mouse gives it back. sel is the selection that replaces it, copied is // what the chrome says about the last copy, and the three click fields // are what turns a run of presses on one cell into a word or a line. mouseOn bool sel selection copied string clicks int clickAt cell clickTime time.Time // copyFn is how text leaves Codey. It is a field so a test can // watch what was copied without a clipboard, or a terminal, being there. copyFn func(string) error // imgs are the pictures pasted into the prompt being typed, each standing // in the box as [image 1]. grabFn is how one is fetched, a field for the // same reason copyFn is: no test should need a desktop. imgs attach.Set grabFn func() (attach.Image, error) textFn func() (string, error) // wiz is the setup wizard. While it is non-nil it owns the screen and // every key, and this model does nothing but hold its place. wiz *wizard quitting bool err error } // pulse is the spinner: a star opening and closing rather than a rotating // bar, which reads as thought rather than as a progress bar that is lying // about knowing how far along it is. var pulse = spinner.Spinner{ Frames: []string{"✢", "✳", "∗", "✻", "✽", "✻", "∗", "✳"}, FPS: time.Second / 8, } // verbs are what the status line calls the wait. They rotate so a long turn // does not look frozen even when nothing is streaming. var verbs = []string{ "Thinking", "Pondering", "Deliberating", "Noodling", "Percolating", "Puzzling", "Wrangling", "Simmering", "Cogitating", "Tinkering", "Untangling", "Ruminating", } // newModel builds the model. Construction lives here rather than inside Run so // tests drive the same wiring the program does; a hand-built model with a // zero-valued textarea panics on the first resize. func newModel(ctx context.Context, opts Options) *model { // The palette has to be in force before the first style is read, which is // the textarea's below. Everything afterwards reads the theme through the // package-level styles, so this is the only place it has to happen. applyTheme(resolveTheme(opts.Config)) // A session whose work has not moved keeps its state where the work is, // which is the ordinary case and the only one there was before /worktree. if opts.HomeRoot == "" { opts.HomeRoot = opts.Root } ta := textarea.New() ta.Placeholder = defaultPlaceholder ta.Prompt = "› " ta.ShowLineNumbers = false ta.CharLimit = 0 ta.SetHeight(1) ta.FocusedStyle.CursorLine = lipgloss.NewStyle() ta.FocusedStyle.Prompt = styCoral ta.BlurredStyle.Prompt = styFaint ta.FocusedStyle.Placeholder = styFaint ta.BlurredStyle.Placeholder = styFaint ta.Focus() // Enter submits; the model handles it before the textarea sees it. ta.KeyMap.InsertNewline.SetKeys("ctrl+j", "alt+enter") sp := spinner.New() sp.Spinner = pulse sp.Style = styCoral m := &model{ opts: opts, bridge: &bridge{}, input: ta, spin: sp, tr: newTranscript(80), parent: ctx, mouseOn: true, copyFn: copyToClipboard, } m.tr.setVerbose(opts.Verbose) m.tr.addRaw(splash(opts, 80)) for _, line := range opts.Banner { m.tr.addNotice(line) } if opts.YOLO { m.tr.addNotice("-yolo — no approval prompts. Use a throwaway clone or a container.") } return m } // Run starts the program and blocks until the user exits. func Run(ctx context.Context, opts Options) error { t := newTabs(ctx, opts) // Cell motion rather than all motion: the drag is what has to be // followed, and reporting every pixel of idle movement would put a // message on the wire for a hand resting on the desk. p := tea.NewProgram(t, tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithContext(ctx)) // The bridge can only learn the program after it exists; the agent does // not touch it until the first turn starts, which is after Run. t.attach(p) final, err := p.Run() if err != nil && !errors.Is(err, tea.ErrProgramKilled) && !errors.Is(err, context.Canceled) { return err } if ft, ok := final.(*tabs); ok { return ft.err() } return nil } // wire points the agent, the policy and the supervisor at this model's bridge. // It is one method rather than four lines at the top of Run because a tab // opened later has to be connected the same way the first one was, and two // copies of that list would be one copy too many. func (m *model) wire(p sender) { m.bridge.p = p if m.opts.Agent != nil { m.opts.Agent.Observe = m.bridge.Observe } if m.opts.Policy != nil { m.opts.Policy.Prompt = m.bridge.Prompt } if m.opts.Asker != nil { // Until this line the tool would have reached whatever the line front // end installed, which is reading from a stdin the alt screen has just // taken over. m.opts.Asker.Use(m.bridge.Ask) } if m.opts.Agents != nil { m.opts.Agents.Subscribe(m.bridge.Subagent, m.bridge.AgentsChanged) } } // tag marks everything a command produces as belonging to this tab. It is // wrapped around the commands that answer from somewhere else — a turn, a // local run, a compaction, a request to the endpoint — because by the time // their answer arrives the tab on screen may be a different one, and the // answer still belongs where it was asked. // // Only Codey's own messages are ever wrapped. Bubble Tea's — a batch, a quit, // the mouse toggles — pass through untouched, because the runtime reads them // itself and would never see one hidden inside an envelope. func (m *model) tag(cmd tea.Cmd) tea.Cmd { if cmd == nil || m.id == 0 { return cmd } id := m.id return func() tea.Msg { msg := cmd() if msg == nil { return nil } return forTab{id: id, msg: msg} } } // busy says whether anything is in flight here: a turn, a fold, a local // command, a worktree being made, a model list being fetched. The tab strip // asks so it can mark the tab, and so it can restart the spinner of a tab that // is put back on screen with work still running in it. func (m *model) busy() bool { return m.phase == phaseRunning || m.compacting || m.worktreeing || m.running != nil || m.modelsLoading } func (m *model) Init() tea.Cmd { if m.opts.FirstRun { return tea.Batch(textarea.Blink, m.openWizard()) } return textarea.Blink } // openWizard hands the screen to the setup wizard. The chat model keeps its // state untouched underneath: nothing about a running turn is disturbed, // which is why /setup is safe to answer at any time the agent is idle. func (m *model) openWizard() tea.Cmd { // The wizard takes the screen, and may come back with a different // palette; nothing selected underneath it survives that. m.clearSelection() m.wiz = newWizard(m.opts.Client, m.opts.Config) cmds := []tea.Cmd{m.wiz.Init()} if m.ready { // The wizard has never seen a resize, so it is told the size the // model already knows rather than rendering into a zero-sized screen // until the terminal happens to send another one. cmds = append(cmds, m.tag(func() tea.Msg { return tea.WindowSizeMsg{Width: m.width, Height: m.height} })) } return tea.Batch(cmds...) } // closeWizard puts the chat back on screen, applying the answers if there // were any. A cancelled wizard changes nothing at all. // // Applying goes through the same setters /model and /effort use, so a setting // chosen in the wizard and the same setting chosen from the prompt cannot come // out different. What the wizard adds is one summary line instead of their // three separate notices — the done screen has already shown the detail. func (m *model) closeWizard() tea.Cmd { w := m.wiz m.wiz = nil if w.result == nil { m.tr.addNotice("setup cancelled — nothing was changed") m.refreshSplash() m.input.Focus() return tea.Batch(m.sync(), textarea.Blink) } cfg := *w.result m.opts.Config = cfg m.useModel(cfg.Model) m.useEffort(cfg.Effort) if m.opts.Policy != nil { m.opts.Policy.YOLO = cfg.Approvals == config.ApprovalsAcceptAll } note := fmt.Sprintf("setup: %s · effort %s · approvals %s · %s theme — from the next turn on", prettyModel(cfg.Model), cfg.Effort, cfg.Approvals, cfg.Theme) if m.opts.SaveConfig != nil { if err := m.opts.SaveConfig(cfg); err != nil { // The settings are already in force; only the file failed. Saying // so beats a wizard that looks like it did nothing. note += "\nthese are in force for this session, but saving them failed: " + err.Error() } } m.tr.addNotice(note) // Web search is the one answer this session cannot adopt: the tool set and // the hosted tool definitions were both built before the first turn. Saying // nothing is the worst of the options — the wizard would look like it had // accepted an answer it quietly dropped, and the turns after it would go by // with the agent unable to search and nobody able to see why. if want := cfg.WebSearchMode(); want != m.webSearchInForce() { m.tr.addNotice(webSearchRestartNote(want)) } if m.opts.Policy != nil && m.opts.Policy.YOLO { m.tr.addNotice("accept-all is on — tool calls run without asking.") } // Every entry was laid out in the old palette, and the welcome block names // a model that may have just changed. m.refreshSplash() m.input.Focus() return tea.Batch(m.sync(), textarea.Blink) } // saveSession writes the conversation out. Every caller goes through here // because the pair of directories is easy to get the wrong way round: the // session is filed under the home root, never the worktree the work may have // moved into, and the worktree rides along as a field so reopening the session // can go back there. func (m *model) saveSession() error { return m.opts.Agent.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.HomeRoot, m.opts.Worktree, m.opts.Client.Model()) } // runContinue drives a turn on the conversation as it stands — the report a // subagent just filed is already in it. Everything else about the turn is the // same, including saving the session when it ends. func (m *model) runContinue() tea.Cmd { ctx, cancel := context.WithCancel(m.parent) m.cancel = cancel a, save := m.opts.Agent, m.saveSession return m.tag(func() tea.Msg { _, err := a.Continue(ctx) cancel() if saveErr := save(); saveErr != nil && err == nil { err = fmt.Errorf("saving session: %w", saveErr) } u, turns := a.State().Usage() return turnDone{err: err, usage: u, turns: turns} }) } // runTurn drives one prompt on its own goroutine. Events reach the UI through // the bridge while this runs; the command's return value ends the turn. func (m *model) runTurn(prompt string) tea.Cmd { ctx, cancel := context.WithCancel(m.parent) m.cancel = cancel // The markers in the text become the pictures they stand for, here at the // last moment: up to now they were characters someone could still delete, // and deleting one is how a paste is taken back. content := m.imgs.Blocks(prompt) m.imgs.Reset() a, save := m.opts.Agent, m.saveSession return m.tag(func() tea.Msg { _, err := a.RunBlocks(ctx, content) cancel() if saveErr := save(); saveErr != nil && err == nil { err = fmt.Errorf("saving session: %w", saveErr) } u, turns := a.State().Usage() return turnDone{err: err, usage: u, turns: turns} }) } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // The wizard owns the screen while it is open. A resize still reaches the // chat model underneath, so that what is revealed when the wizard closes // is laid out for the terminal as it is now. if m.wiz != nil { var cmds []tea.Cmd if size, ok := msg.(tea.WindowSizeMsg); ok { cmds = append(cmds, m.resize(size.Width, size.Height)) } cmds = append(cmds, m.wiz.Update(msg)) if m.wiz.result != nil || m.wiz.cancelled { cmds = append(cmds, m.closeWizard()) } return m, tea.Batch(cmds...) } switch msg := msg.(type) { case tea.WindowSizeMsg: return m, m.resize(msg.Width, msg.Height) case tea.KeyMsg: return m.onKey(msg) case tea.MouseMsg: return m.onMouse(msg) case copiedMsg: return m, m.finishCopy(msg) case imageMsg: return m, m.onImage(msg) case modelsMsg: return m, m.onModels(msg) case spinner.TickMsg: if m.phase == phaseRunning || m.modelsLoading || m.compacting || m.worktreeing || m.running != nil { var cmd tea.Cmd m.spin, cmd = m.spin.Update(msg) return m, cmd } return m, nil // ---- agent events ------------------------------------------------- case evTurnStart: m.turn = msg.turn return m, nil case evTextDelta: m.tr.appendDelta(msg.text) return m, m.sync() case evText: m.tr.add(entry{kind: entryAssistant, text: strings.TrimSpace(msg.text)}) return m, m.sync() case evToolStart: m.tr.startTool(msg.tool, msg.input) return m, m.sync() case evToolResult: m.tr.finishTool(msg.tool, msg.result, msg.isError) return m, m.sync() case evUsage: m.usage = addUsage(m.usage, msg.usage) m.addCost(msg.usage) m.ctxTokens = msg.usage.InputTokens + msg.usage.CacheReadInputTokens + msg.usage.CacheCreationInputTokens return m, nil case evCompact: m.tr.addNotice("compacted the conversation") return m, m.sync() case evNotice: m.tr.addNotice(msg.text) return m, m.sync() case compactDone: return m, m.finishCompact(msg) case approvalMsg: if !m.inModal() { m.beforeModal = m.phase } m.pending = append(m.pending, msg) // An approval outranks a question already on screen: the question can // wait, and a tool call cannot start until someone says so. m.phase = phaseApproving return m, m.sync() case askMsg: if !m.inModal() { m.beforeModal = m.phase } m.asking = append(m.asking, newAskState(msg.req, msg.reply)) // An approval already on screen keeps it, the way it does when a // second approval arrives: the question is behind it, not lost. if m.phase != phaseApproving { m.phase = phaseAsking } return m, m.sync() case evSub: return m, m.onSubEvent(msg.ev) case evAgents: return m, m.onAgentsChanged() case agentActed: return m, m.onAgentActed(msg) case turnDone: return m, m.finishTurn(msg) case localDone: return m, m.finishLocal(msg) case worktreeReady: return m, m.finishWorktree(msg) } return m, nil } func (m *model) resize(w, h int) tea.Cmd { // Every line is about to be laid out again, so the line numbers a // selection is made of stop pointing at the text they were taken from. m.clearSelection() m.width, m.height = w, h if !m.ready { m.vp = viewport.New(m.mainWidth(), maxInt(1, h-8)) m.ready = true } m.relayout() m.vp.GotoBottom() return nil } // relayout re-flows everything that is sized against the main column, which // is narrower whenever the sidebar is open. func (m *model) relayout() tea.Cmd { if !m.ready { return nil } w := m.mainWidth() m.tr.setWidth(w) for _, t := range m.subTr { t.setWidth(w) } m.input.SetWidth(m.innerWidth()) m.vp.Width = w m.refreshSplash() return m.sync() } // mainWidth is what is left for the transcript and the prompt. func (m *model) mainWidth() int { if m.bar.visible() && !m.fullScreenSidebar() { return maxInt(20, m.width-sidebarWidth) } return m.width } // sidebarTakesOver reports whether the terminal is too narrow to show both. // Squeezing a transcript into thirty columns shows neither. func (m *model) fullScreenSidebar() bool { return m.bar.visible() && m.width-sidebarWidth < sidebarMinMain } func (m *model) sidebarWidth() int { if m.fullScreenSidebar() { return m.width } return sidebarWidth } // activeTranscript is what the main pane is showing: your own conversation, or // the thread of the agent you opened. func (m *model) activeTranscript() *transcript { if m.viewing != "" { if t, ok := m.subTr[m.viewing]; ok { return t } } return m.tr } // refreshSplash re-renders the welcome block. It is the one entry that owns // its own lines — laid out for a width, and naming the model and the endpoint // — so when any of those change it is rebuilt rather than wrapped or left to // describe a session that has moved on. func (m *model) refreshSplash() { if len(m.tr.entries) == 0 || m.tr.entries[0].kind != entryRaw { return } width := m.mainWidth() if width <= 0 { width = 80 } m.tr.entries[0].text = splash(m.opts, width) m.tr.dirty = true } // sync re-renders the scrollback, keeping the view pinned to the bottom unless // the user has scrolled up to read something. func (m *model) sync() tea.Cmd { if !m.ready { return nil } atBottom := m.vp.AtBottom() m.vp.SetContent(m.activeTranscript().render()) if atBottom { m.vp.GotoBottom() } return nil } func (m *model) finishTurn(msg turnDone) tea.Cmd { m.phase = phaseIdle m.interrupting = false m.usage, m.turns = msg.usage, msg.turns m.addCost(subUsage(msg.usage, m.priced)) m.tr.closeOpen() // An approval this turn raised belongs to a turn that no longer exists, so // it is denied. A subagent's does not: that agent is still running, and // the decision is still the user's to make. var kept []approvalMsg for _, p := range m.pending { if p.req.Agent == "" { p.reply <- approval.Deny continue } kept = append(kept, p) } m.pending = kept // A question outlives its turn no better than an approval does: the tool // call that asked it is gone, and nobody is left to read the answer. The // half-typed reply goes with it, and a subagent's question stays, for the // same reasons. var asking []*askState for _, s := range m.asking { if s.req.Agent == "" { s.reply <- ask.Response{Dismissed: true} continue } asking = append(asking, s) } m.asking = asking if len(m.pending) > 0 || len(m.asking) > 0 { m.beforeModal = phaseIdle m.phase = m.afterModal() } var full *agent.ContextFullError switch { case msg.err == nil: case errors.Is(msg.err, context.Canceled): m.tr.addNotice("interrupted — the session is intact; say what to do differently") case errors.As(msg.err, &full): // The agent package has no idea what a slash command is, so the ways // out are named here rather than in the error it raised. m.tr.addError(full.Error() + " — /compact folds it into a summary, /clear starts over") default: m.tr.addError(msg.err.Error()) } if !m.bar.hasKeys() { m.input.Focus() } // Agents that finished mid-turn have been waiting for this. return tea.Batch(m.sync(), textarea.Blink, m.deliverReports()) } func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.phase == phaseApproving { return m.onApprovalKey(msg) } if m.phase == phaseAsking { return m.onAskKey(msg) } if m.phase == phaseEffort { return m.onEffortKey(msg) } if m.phase == phaseModel { return m.onModelKey(msg) } if m.phase == phaseSettings { return m.onSettingsKey(msg) } if m.phase == phaseSessions { return m.onSessionKey(msg) } if m.phase == phaseWorktrees { return m.onWorktreeKey(msg) } // The sidebar owns every key while it is open, the way the approval box // does: a pane with a kill key cannot share the keyboard with a prompt. if cmd, took := m.onSidebarKey(msg); took { return m, cmd } if msg.String() == "ctrl+b" && m.opts.Agents != nil { return m, m.toggleSidebar() } // An open menu owns the keys that drive it — including enter and esc, // which mean something else the rest of the time. if m.menuOpen() { if cmd, took := m.onMenuKey(msg); took { return m, cmd } } // The local-command modes own their opening marker, and once one is open, // the keys that run and leave it. if cmd, took := m.onLocalKey(msg); took { return m, cmd } // A paste is inspected before the prompt box sees it, because the one // carrying an image carries it as a path, or as nothing at all. Only the // prompt aimed at the model does this: a picture is not something to hand // to a shell. if m.local == localOff { if cmd, took := m.onPasteKey(msg); took { return m, cmd } else if cmd != nil { // Something was said about the paste but it did not become a // picture, so the text still belongs in the box. m.input, _ = m.input.Update(msg) m.growInput() m.refreshMenu() return m, cmd } if msg.String() == "ctrl+v" { // The textarea binds this key to a paste of its own, but its // answer comes back as a message this model does not forward, so // the key did nothing before it was taken over here. It now means // the picture on the clipboard, or failing that the words on it. return m, m.grabImage(true, true) } } switch msg.String() { case "ctrl+c": // While a turn runs, ctrl-c interrupts it and leaves the session // intact. Idle, it exits. if m.phase == phaseRunning || m.compacting || m.worktreeing { return m, m.interrupt() } m.quitting = true return m, tea.Quit case "esc": m.clearSelection() if m.phase == phaseRunning || m.compacting || m.worktreeing { return m, m.interrupt() } if m.running != nil { return m, m.interruptLocal() } return m, nil case "ctrl+d": m.quitting = true return m, tea.Quit case "ctrl+r": // Collapsing or expanding tool output lays every line out again, so // the selection is about text that has moved. m.clearSelection() m.tr.setVerbose(!m.tr.verbose) state := "collapsed" if m.tr.verbose { state = "full" } m.tr.addNotice("tool output: " + state) return m, m.sync() case "shift+tab": return m, m.cycleMode() case "pgup", "pgdown", "shift+up", "shift+down", "ctrl+home", "ctrl+end": var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) return m, cmd case "enter": if m.phase == phaseRunning { return m, nil // the agent is busy; queueing input would be a lie } if m.compacting { // The compaction is about to replace the history a new prompt // would be appended to. Holding the text in the box keeps it. return m, nil } if m.worktreeing { // The work is about to move directories, and a prompt sent now // would be answered with tools pointing at the old one. Holding // the text in the box keeps it until the move lands. return m, nil } if m.running != nil { // A local command is still running and its output is the next // thing to reach the model. Sending now would put the message // ahead of the output it is almost certainly about, so the text // is held in the box rather than dropped. return m, nil } line := strings.TrimSpace(m.input.Value()) if line == "" { return m, nil } m.input.Reset() m.growInput() m.closeMenu() return m.submit(line) } // Typing moves on from whatever was selected, the way clicking elsewhere // does. Scrolling does not: reading further up the transcript with the // keys is no reason to lose what is marked. m.clearSelection() var cmd tea.Cmd m.input, cmd = m.input.Update(msg) m.growInput() m.refreshMenu() // Emptying the prompt is what a report held back for it was waiting on. return m, tea.Batch(cmd, m.deliverReports()) } func (m *model) interrupt() tea.Cmd { if m.cancel != nil { m.cancel() } m.interrupting = true return nil } // cycleMode flips the approval policy between asking and accepting. It is the // one control in the chrome that changes what the agent is allowed to do, so // it says so in the transcript rather than only in the mode line. func (m *model) cycleMode() tea.Cmd { if m.opts.Policy == nil { return nil } m.opts.Policy.YOLO = !m.opts.Policy.YOLO if m.opts.Policy.YOLO { m.tr.addNotice("accept-all on — tool calls run without asking. Use a throwaway clone or a container.") } else { m.tr.addNotice("accept-all off — you will be asked before anything is changed.") } return m.sync() } // growInput sizes the prompt box to what has been typed into it. func (m *model) growInput() { inner := m.innerWidth() n := 0 for _, line := range strings.Split(m.input.Value(), "\n") { w := lipgloss.Width(line) + 2 // the "› " prompt rows := (w + inner - 1) / inner n += maxInt(1, rows) } if h := minInt(maxInt(1, n), inputMaxLines); h != m.input.Height() { m.input.SetHeight(h) } } // quit ends the program. It is a method because leaving is asked for from // several places — /exit, ctrl+d, closing the last tab — and every one of them // has to put the screen back the same way. func (m *model) quit() tea.Cmd { m.quitting = true return tea.Quit } // submit handles slash commands locally and sends anything else to the agent. func (m *model) submit(line string) (tea.Model, tea.Cmd) { switch { case line == "/exit", line == "/quit": return m, m.quit() case line == "/new": // The strip owns the tabs; a model has no idea there are any. Saying // what is wanted and letting it be done there is what keeps a tab out // of the conversation's business. return m, func() tea.Msg { return newTabMsg{} } case line == "/close": return m, func() tea.Msg { return closeTabMsg{} } case line == "/help": m.tr.addNotice(m.helpText()) return m, m.sync() case line == "/model" || strings.HasPrefix(line, "/model "): return m, m.setModel(strings.TrimSpace(strings.TrimPrefix(line, "/model"))) case line == "/effort" || strings.HasPrefix(line, "/effort "): return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort"))) case line == "/compact": if m.phase == phaseRunning { m.tr.addError("/compact would rewrite the history the turn is building on — interrupt first") return m, m.sync() } if m.compacting { return m, nil } return m, m.startCompact() case line == "/worktree" || strings.HasPrefix(line, "/worktree "): return m, m.startWorktree(strings.TrimPrefix(line, "/worktree")) case line == "/clear": m.clearSelection() m.tr.clear() return m, m.sync() case line == "/resume" || strings.HasPrefix(line, "/resume "): if id := strings.TrimSpace(strings.TrimPrefix(line, "/resume")); id != "" { m.resumeSession(id) return m, m.sync() } return m, m.openSessionPicker() case line == "/image" || strings.HasPrefix(line, "/image "): return m, m.attachPath(strings.TrimPrefix(line, "/image")) case line == "/mouse": return m, m.toggleMouse() case line == "/allowed": allowed := m.opts.Policy.Remembered() if len(allowed) == 0 { m.tr.addNotice("nothing is permanently allowed this session") } else { m.tr.addNotice("always allowed:\n " + strings.Join(allowed, "\n ")) } return m, m.sync() case line == "/settings": return m, m.openSettings() case line == "/setup": if m.phase == phaseRunning { m.tr.addError("/setup would change the model mid-turn — interrupt first") return m, m.sync() } return m, m.openWizard() case line == "/skills": if len(m.opts.Skills) == 0 { m.tr.addNotice("no skills found in ~/" + skills.Dirname + " or ./" + skills.Dirname) } else { var b strings.Builder b.WriteString("skills:") for _, s := range m.opts.Skills { fmt.Fprintf(&b, "\n /%s (%s) — %s", s.Name, s.Source, s.Description) } m.tr.addNotice(b.String()) } return m, m.sync() case strings.HasPrefix(line, "/") && !m.isSkill(line): m.tr.addError("unknown command " + line + " — /help lists them") return m, m.sync() } return m, m.startTurn(line, line) } // startTurn puts a prompt to the agent and moves the UI into a running turn. // shown is what the transcript displays and prompt is what the model is sent: // they are the same for anything typed, and differ for /worktree, which has // to tell the model where it now is without that preamble appearing as though // you had written it. func (m *model) startTurn(shown, prompt string) tea.Cmd { // Anything a subagent reported while you were typing goes up as its own // message, ahead of yours: the model should not have to work out which // half of one message was whose. m.flushReportsAhead() // A path to a picture becomes the picture, however it got into the box. // The paste key cannot catch it on every terminal — bracketed paste is // not universal — so the last word on it is here, where the prompt is // already whole. It happens once: expanding what is shown and what is // sent separately would attach the same file twice. if expanded := m.imgs.ExpandPaths(prompt); expanded != prompt { if shown == prompt { shown = expanded } prompt = expanded } if shown != "" { m.tr.addUser(shown) } // The first thing asked is what the tab is called from here on. It is what // someone reading a row of tabs is looking for, and it does not change // afterwards: a tab that renamed itself every turn would be a tab you had // to re-find every turn. if m.title == "" { m.title = firstLine(shown) } m.phase = phaseRunning m.turn = 0 m.started = time.Now() m.verbSeed++ return tea.Batch(m.sync(), m.spin.Tick, m.runTurn(prompt)) } // setModel answers /model. Bare, it asks the endpoint what it serves and // opens the picker on the answer; with an id, it switches straight away, // which also works on an endpoint that does not list its models. func (m *model) setModel(id string) tea.Cmd { if id == "" { return m.openModelPicker() } m.applyModel(id) return m.sync() } // setEffort answers /effort. Bare, it opens the slider; with a level, it // applies it directly, which is the fast path for anyone who knows what they // want and can type it. func (m *model) setEffort(level string) tea.Cmd { switch { case level == "": return m.openEffort() case !llm.ValidEffort(level): m.tr.addError("unknown effort " + level + " — one of " + strings.Join(llm.EffortLevels, ", ")) default: m.applyEffort(level) } return m.sync() } // isSkill reports whether a slash command names a discovered skill. Matching // here rather than sending every unrecognised slash to the model means a typo // is caught by Codey instead of puzzled over by the agent. func (m *model) isSkill(line string) bool { name := strings.TrimPrefix(line, "/") if i := strings.IndexAny(name, " \t"); i >= 0 { name = name[:i] } for _, s := range m.opts.Skills { if s.Name == name { return true } } return false } // ---- approvals ---------------------------------------------------------- // choice is one answer offered by the approval box. Numbering it here rather // than in the renderer keeps the keys and the labels from drifting apart. type choice struct { label string keys []string dec approval.Decision note string } func (m *model) choices(req approval.Request) []choice { out := []choice{{label: "Yes", keys: []string{"y", "enter"}, dec: approval.Once, note: "approved once"}} if req.Rememberable { out = append(out, choice{ label: "Yes, and always this exact one", keys: []string{"a"}, dec: approval.AlwaysThisCommand, note: "always allowing this exact " + req.Tool + " this session", }) } out = append(out, choice{ label: "Yes, and always this tool", keys: []string{"t"}, dec: approval.AlwaysThisTool, note: "always allowing tool " + req.Tool + " this session", }, choice{label: "No, tell it to do something else", keys: []string{"n", "esc"}, dec: approval.Deny, note: "denied"}, ) return out } func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.pending) == 0 { m.phase = m.afterModal() return m, nil } req := m.pending[0] key := msg.String() // ctrl-c mid-prompt denies the call and cancels the turn it belonged to. // With nothing of yours running there is no turn to cancel — the denial // is the whole of it. if key == "ctrl+c" { req.reply <- approval.Deny m.pending = m.pending[1:] m.phase = m.afterModal() if m.phase == phaseRunning { return m, tea.Batch(m.interrupt(), m.sync()) } return m, m.sync() } // "always this exact one" is not offered for an opaque command, and // pressing its key must explain that rather than silently doing nothing. opts := m.choices(req.req) if key == "a" && !req.req.Rememberable { m.tr.addError("this one cannot be remembered: its text does not determine what it runs") return m, m.sync() } for i, c := range opts { if !matches(c.keys, key) && key != fmt.Sprint(i+1) { continue } return m, m.answerApproval(c) } return m, nil } // afterModal is where the session goes once a box is answered: on to the next // thing waiting — another approval, or a question that had to stand aside for // one — and otherwise back to whatever it was doing. Neither kind of box // implies a turn, since a subagent can raise one while you are idle, so // answering puts the session back where it found it. func (m *model) afterModal() phase { switch { case len(m.pending) > 0: return phaseApproving case len(m.asking) > 0: return phaseAsking } return m.beforeModal } // inModal says a box is on screen, which is how the session knows whether // m.beforeModal already holds something worth going back to. func (m *model) inModal() bool { return m.phase == phaseApproving || m.phase == phaseAsking } // answerApproval sends a decision and moves on. It is shared by the keys and // by a click on the choice, which must not be able to decide differently. func (m *model) answerApproval(c choice) tea.Cmd { if len(m.pending) == 0 { return nil } req := m.pending[0] req.reply <- c.dec m.pending = m.pending[1:] m.phase = m.afterModal() // The decision belongs to the call it gated, not to whatever the // transcript happens to end with once the result lands. m.tr.annotateTool(req.req.Tool, c.note) return tea.Batch(m.sync(), m.spin.Tick) } func matches(keys []string, key string) bool { for _, k := range keys { if k == key { return true } } return false } // ---- view --------------------------------------------------------------- func (m *model) View() string { if !m.ready { return "starting…" } if m.quitting { return "" } if m.wiz != nil { return m.wiz.View() } // Too narrow for both: the pane takes the screen rather than leaving two // unreadable columns. The status line goes with the transcript, so the // pill it carries is not on screen and its click target is forgotten // rather than left pointing at whatever is drawn there now. if m.fullScreenSidebar() { m.pill = rowHit{} return m.sidebarView(m.height) } // The bottom chrome changes height — the prompt box grows, the approval // box is taller than either — so the viewport is sized against what was // actually rendered rather than against an assumed number of rows. bottom := m.bottom() top := "" if m.viewing != "" { top = m.agentHeader(m.mainWidth()) + "\n" } if h := maxInt(1, m.height-lipgloss.Height(bottom)-strings.Count(top, "\n")); h != m.vp.Height { atBottom := m.vp.AtBottom() m.vp.Height = h if atBottom { m.vp.GotoBottom() } } // The guard is not only tidiness: asking the transcript for its lines // splits the whole scrollback, and a streaming turn redraws every frame. // Nothing pays for that until there is a selection to paint. It is the // transcript on screen that is painted, which is a subagent's thread when // one is open. view := m.vp.View() if m.sel.on { if from, to, ok := m.sel.span(m.activeTranscript().lines()); ok { view = paintSelection(view, m.vp.YOffset, from, to) } } main := top + view + "\n" + bottom if !m.bar.visible() { return main } // The pane is a column beside the whole screen, chrome included: the // transcript reflows into what is left rather than being covered by it. return lipgloss.JoinHorizontal(lipgloss.Top, main, m.sidebarView(m.height)) } // bottom is the chrome under the transcript. It is assembled from layout() // rather than written out here, because a mouse click is resolved against // that same list and the two must be the same screen. func (m *model) bottom() string { parts := make([]string, 0, 5) for _, s := range m.layout() { parts = append(parts, s.text) } return strings.Join(parts, "\n") } // activity is the line above the prompt: what the agent is doing on the left, // the effort setting on the right. The pill names /effort rather than /setup: // it reports the level this session is running at, and the command that // changes that is the one worth naming beside it. func (m *model) activity() string { if m.running != nil { return spread(m.localActivity(), "", m.mainWidth()) } if m.compacting { return spread(m.compactActivity(), "", m.width) } if m.worktreeing { return spread(m.worktreeActivity(), "", m.width) } var left string switch m.phase { case phaseRunning: verb := verbs[(m.verbSeed+int(time.Since(m.started).Seconds())/4)%len(verbs)] if m.interrupting { verb = "Interrupting" } detail := fmt.Sprintf("%ds", int(time.Since(m.started).Seconds())) if m.turn > 0 { detail += fmt.Sprintf(" · turn %d", m.turn) } if m.usage.OutputTokens > 0 { detail += " · ↓ " + human(m.usage.OutputTokens) + " tokens" } left = m.spin.View() + " " + styCoral.Render(verb+"…") + styDim.Render(" ("+detail+" · esc to interrupt)") case phaseApproving: left = styWarn.Render("● waiting for you") case phaseAsking: left = styWarn.Render("● the agent has a question") } // What was copied is reported here rather than in the transcript: five // copies should not cost five lines of scrollback, and this line is on // screen in every phase. right := "" if m.copied != "" { right = styDim.Render(m.copied) } if m.opts.Effort != "" { if right != "" { right += styFaint.Render(" · ") } // The pill is a claim about the next request. On a model that does // not take this level the request goes out without it rather than // earning a 400, so the pill says which model is ignoring it instead // of reporting a setting that is not in force. note := " · /effort" if !llm.SupportsEffort(m.opts.Client.Model(), m.opts.Effort) { note = " · not on " + shortModel(m.opts.Client.Model()) } right += styDim.Render("● "+m.opts.Effort) + styFaint.Render(note) } return spread(left, right, m.mainWidth()) } func (m *model) innerWidth() int { return maxInt(8, m.boxWidth()-4) } // boxWidth keeps the prompt frame usable on a narrow terminal rather than // letting it collapse into a border with no room inside it. func (m *model) boxWidth() int { return maxInt(14, m.mainWidth()) } func (m *model) inputBox() string { style := styBoxFocus switch { case m.phase == phaseRunning, m.bar.hasKeys(): style = styBoxBlur case m.local != localOff: // The frame is the always-present chrome, so it is where a mode that // changes what enter does has to be visible. style = styBoxFocus.BorderForeground(localStyle(m.local).GetForeground()) } return style.Width(m.boxWidth() - 2).Render(m.input.View()) } // status is the pill line under the prompt: who, which model, how full the // context is, and what it has cost so far. The fields are separated by space // rather than by a dot, because the pills already carry their own padding and // a dot between them reads as a gap twice as wide as it is. func (m *model) status() string { parts := []string{ styName.Render(username()), styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model())), } if pill, ok := m.contextPill(); ok { parts = append(parts, pill) } if pill, ok := m.agentsPill(); ok { parts = append(parts, pill) } // Cache reads are reported separately from fresh input: the gap between // the two is the whole point of the caching Codey measures. u := m.usage if u.InputTokens+u.OutputTokens+u.CacheReadInputTokens > 0 { io := fmt.Sprintf("↑ %s", human(u.InputTokens)) if u.CacheReadInputTokens > 0 { io += fmt.Sprintf(" ⟳ %s", human(u.CacheReadInputTokens)) } io += fmt.Sprintf(" ↓ %s", human(u.OutputTokens)) parts = append(parts, styDim.Render(io)) if m.costKnown { parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", m.cost))) } } line := strings.Join(parts, " ") m.recordPill(parts) return truncateStyled(line, maxInt(1, m.mainWidth())) } // agentsPill is the clickable button in the status line: how many agents are // live, or just the way in when none are. func (m *model) agentsPill() (string, bool) { if m.opts.Agents == nil { return "", false } label := "⏵ agents" style := styPill if n := m.bar.live; n > 0 { label = fmt.Sprintf("⏵ %d agents", n) style = styPill.Foreground(colCoral) } else if len(m.bar.rows) == 0 { return "", false } return style.Render(label), true } // recordPill remembers where in the status line the agents pill was drawn, so // a click on it can be told from a click on the rest of that line. Only the // columns are recorded: which row the line landed on is the layout's business, // and a click arrives already resolved to it. func (m *model) recordPill(parts []string) { m.pill = rowHit{} pill, ok := m.agentsPill() if !ok { return } var x int for _, p := range parts { if p == pill { break } x += lipgloss.Width(p) + 2 } m.pill = rowHit{x0: x, x1: x + lipgloss.Width(pill) - 1} } // contextPill is the context gauge: how much the model read last turn against // the window it has, coloured by how close that is to full. // // It is one style over the whole pill rather than a coloured glyph inside a // background, because a nested style ends with a reset — which drops the // background for the rest of the pill and leaves a visible tear in it. func (m *model) contextPill() (string, bool) { if m.ctxTokens <= 0 { return "", false } pct := m.ctxTokens * 100 / m.contextWindow() gauge := colOK switch { case pct >= 80: gauge = colErr case pct >= 60: gauge = colWarn } return styPill.Foreground(gauge).Render(fmt.Sprintf("◐ %s (%d%%)", human(m.ctxTokens), pct)), true } func (m *model) contextWindow() int { if m.opts.ContextWindow > 0 { return m.opts.ContextWindow } return agent.DefaultContextWindow } // modeLine reports what the approval policy will do next, because a mode that // silently lets the agent write to disk has to be visible at all times. func (m *model) modeLine() string { // While a box is up, shift+tab belongs to it and the line would be // advertising a key that does something else. if m.inModal() { return "" } agents := "" if m.opts.Agents != nil { agents = " · ctrl+b agents" } if m.opts.Policy != nil && m.opts.Policy.YOLO { return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"+agents), maxInt(1, m.mainWidth())) } return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle)"+agents+" · /help"), maxInt(1, m.mainWidth())) } func (m *model) approvalView() string { req := m.pending[0].req width := maxInt(20, m.mainWidth()-2) inner := width - 4 var b strings.Builder b.WriteString(m.approvalHead(req) + styDim.Render(" wants to "+req.Summary) + "\n") if req.Detail != "" { b.WriteString("\n") lines := strings.Split(strings.TrimRight(req.Detail, "\n"), "\n") for _, line := range clip(lines, 10) { b.WriteString(styText.Render(truncate(line, maxInt(10, inner))) + "\n") } if n := len(lines) - 10; n > 0 { b.WriteString(styFaint.Render("… +"+plural(n, "more line")) + "\n") } } b.WriteString("\n" + styDim.Render("Do you want to proceed?") + "\n") for i, c := range m.choices(req) { b.WriteString(styKey.Render(fmt.Sprint(i+1)+".") + " " + styText.Render(c.label)) if alias := c.keys[0]; alias != "enter" { b.WriteString(styFaint.Render(" (" + alias + ")")) } b.WriteString("\n") } return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n")) } // approvalHead is the first thing the box says: the tool, and — when it is not // the session's own agent asking — which subagent wants it. Four agents can be // running, and "bash wants to run rm -rf build" is not a question anybody can // answer without knowing whose it is. func (m *model) approvalHead(req approval.Request) string { if req.Agent == "" { return styHeading.Render(req.Tool) } who := req.Agent if info, ok := m.agentInfo(req.Agent); ok { who += " (" + info.Preset + ")" } return styCoral.Render(who) + styDim.Render(" · ") + styHeading.Render(req.Tool) } // spread puts left at the left margin and right at the right, and gives up on // the right-hand side rather than overflowing when they cannot both fit. func spread(left, right string, width int) string { width = maxInt(1, width) lw, rw := lipgloss.Width(left), lipgloss.Width(right) if gap := width - lw - rw - 1; gap >= 1 { return " " + left + strings.Repeat(" ", gap) + right } return truncateStyled(" "+left, width) } func username() string { for _, k := range []string{"USER", "LOGNAME", "USERNAME"} { if v := os.Getenv(k); v != "" { return v } } return "you" } // addCost prices one piece of usage at the model in force and adds it to the // running total, recording that those tokens have now been paid for. // addCostAt bills a subagent's usage to the session at that agent's own model: // a haiku agent spawned from an opus session was not billed at opus rates. // // Only the money is pooled. The token counts in the status line stay the main // conversation's, because the gauge beside them is about the window this // conversation is filling — a child's tokens went into a different window, and // the sidebar reports them per agent. func (m *model) addCostAt(u llm.Usage, model string) { if u == (llm.Usage{}) { return } if model == "" { model = m.opts.Client.Model() } if c, known := u.Cost(model); known { m.cost += c m.costKnown = true } } func (m *model) addCost(u llm.Usage) { if u == (llm.Usage{}) { return } m.priced = addUsage(m.priced, u) if c, known := u.Cost(m.opts.Client.Model()); known { m.cost += c m.costKnown = true } } // subUsage is a minus b, floored at zero per field: totals only ever grow, and // a negative remainder would mean charging backwards. func subUsage(a, b llm.Usage) llm.Usage { return llm.Usage{ InputTokens: maxInt(0, a.InputTokens-b.InputTokens), OutputTokens: maxInt(0, a.OutputTokens-b.OutputTokens), CacheReadInputTokens: maxInt(0, a.CacheReadInputTokens-b.CacheReadInputTokens), CacheCreationInputTokens: maxInt(0, a.CacheCreationInputTokens-b.CacheCreationInputTokens), } } func addUsage(a, b llm.Usage) llm.Usage { a.InputTokens += b.InputTokens a.OutputTokens += b.OutputTokens a.CacheReadInputTokens += b.CacheReadInputTokens a.CacheCreationInputTokens += b.CacheCreationInputTokens return a }