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/config" "codey/internal/llm" "codey/internal/skills" ) // 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 Client *llm.Client Root string SessionDir string SessionID string ToolNames []string Verbose bool YOLO bool // 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 // 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 // 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 } 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 ) // 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 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. setIdx settingRow setTouched [numSettingRows]bool setFrom bool setModelWas 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 // 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. pending []approvalMsg // 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 // 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)) 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 { m := newModel(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(m, 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. m.bridge.p = p opts.Agent.Observe = m.bridge.Observe if opts.Policy != nil { opts.Policy.Prompt = m.bridge.Prompt } final, err := p.Run() if err != nil && !errors.Is(err, tea.ErrProgramKilled) && !errors.Is(err, context.Canceled) { return err } if fm, ok := final.(*model); ok { return fm.err } return nil } 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, 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) 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) } // 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 a := m.opts.Agent return func() tea.Msg { _, err := a.Run(ctx, prompt) cancel() if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); 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 modelsMsg: return m, m.onModels(msg) case spinner.TickMsg: if m.phase == phaseRunning || m.modelsLoading || m.compacting || 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: m.pending = append(m.pending, msg) m.phase = phaseApproving return m, m.sync() case turnDone: return m, m.finishTurn(msg) case localDone: return m, m.finishLocal(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 m.tr.setWidth(w) m.input.SetWidth(m.innerWidth()) if !m.ready { m.vp = viewport.New(w, maxInt(1, h-8)) m.ready = true } else { m.vp.Width = w } m.refreshSplash() m.vp.SetContent(m.tr.render()) m.vp.GotoBottom() return nil } // 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.width 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.tr.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() // Any approval still outstanding belongs to a turn that no longer exists. for _, p := range m.pending { p.reply <- approval.Deny } m.pending = nil 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()) } m.input.Focus() return tea.Batch(m.sync(), textarea.Blink) } func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.phase == phaseApproving { return m.onApprovalKey(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) } // 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 } 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 { return m, m.interrupt() } m.quitting = true return m, tea.Quit case "esc": m.clearSelection() if m.phase == phaseRunning || m.compacting { 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.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() return m, cmd } 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) } } // 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": m.quitting = true return m, tea.Quit 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 == "/clear": m.clearSelection() m.tr.clear() return m, m.sync() 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() } m.tr.addUser(line) m.phase = phaseRunning m.turn = 0 m.started = time.Now() m.verbSeed++ return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line)) } // 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 = phaseRunning return m, nil } req := m.pending[0] key := msg.String() // ctrl-c mid-prompt denies the call and cancels the turn. if key == "ctrl+c" { req.reply <- approval.Deny m.pending = m.pending[1:] m.phase = phaseRunning return m, tea.Batch(m.interrupt(), 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 } // 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:] if len(m.pending) == 0 { m.phase = phaseRunning } // 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() } // 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() if h := maxInt(1, m.height-lipgloss.Height(bottom)); 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. view := m.vp.View() if m.sel.on { if from, to, ok := m.sel.span(m.tr.lines()); ok { view = paintSelection(view, m.vp.YOffset, from, to) } } return view + "\n" + bottom } // 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.width) } if m.compacting { return spread(m.compactActivity(), "", 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") } // 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(" · ") } right += styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort") } return spread(left, right, m.width) } 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.width) } func (m *model) inputBox() string { style := styBoxFocus switch { case m.phase == phaseRunning: 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) } // 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))) } } return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width)) } // 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 { if m.phase == phaseApproving { return "" } if m.opts.Policy != nil && m.opts.Policy.YOLO { return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"), maxInt(1, m.width)) } return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle) · /help for commands"), maxInt(1, m.width)) } func (m *model) approvalView() string { req := m.pending[0].req width := maxInt(20, m.width-2) inner := width - 4 var b strings.Builder b.WriteString(styHeading.Render(req.Tool) + 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")) } // 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. 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 }