package tui import ( "fmt" "strings" "time" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "codey/internal/agent" "codey/internal/llm" "codey/internal/subagent" ) // Agents is the part of the subagent supervisor the TUI uses: what is running, // what has finished and not been handed over yet, and the three things a // person can do to one. It is an interface so the front end can be driven // without starting real agents. type Agents interface { Snapshot() []subagent.Info TakeReports() []subagent.Info Live() int Pause(id string) error Resume(id string) error Kill(id string) error // ClaimReport asks for the right to put one finished agent's report into // the conversation, and reports whether this front end got it. It can be // refused: agent_wait may have handed the same report to the model while // this one sat in the pending queue. ClaimReport(id string) bool // Subscribe hands the supervisor the two callbacks the front end wants: // one per child event, one when anything about the set of agents moves. Subscribe(observe func(subagent.Event), changed func()) } // sidebarWidth is the pane's width when there is room for it. Thirty columns // fits "claude-haiku-4-5 · 22s · 3 turns" and not much else, which is exactly // what a row has to say. const sidebarWidth = 30 // barFrame is what the pane's own chrome costs: one column of rule on the // left and a column of padding either side. const barFrame = 3 // sidebarMinMain is how much has to be left for the transcript. Below this the // sidebar takes the whole screen instead of squeezing both into unreadability. const sidebarMinMain = 40 // sidebar is the agents pane: whether it is open, whether it has the keyboard, // where the cursor is, and the rows it last drew. // // Open and focused are separate because the pane is worth watching while you // type. It owns every key while it has focus — that is what makes k a kill // rather than a letter — so there has to be a way to put the keyboard back in // the prompt without closing what you were looking at. type sidebar struct { open bool focused bool cursor int rows []subagent.Info live int // hits records where each row was drawn, so a click can be turned back // into an agent. It is rebuilt on every render. hits []rowHit } // rowHit is where something clickable was last drawn. type rowHit struct { y0, y1 int x0, x1 int id string } // hit reports whether a click at (x, y) landed on this region. func (h rowHit) hit(x, y int) bool { if y < h.y0 || y > h.y1 { return false } return (h.x0 == 0 && h.x1 == 0) || (x >= h.x0 && x <= h.x1) } // visible reports whether the pane is drawn at all. func (s *sidebar) visible() bool { return s.open } // hasKeys reports whether the pane is the thing keystrokes belong to. func (s *sidebar) hasKeys() bool { return s.open && s.focused } // selected is the agent the cursor is on. func (s *sidebar) selected() (subagent.Info, bool) { if s.cursor < 0 || s.cursor >= len(s.rows) { return subagent.Info{}, false } return s.rows[s.cursor], true } func (s *sidebar) move(delta int) { if len(s.rows) == 0 { return } s.cursor += delta switch { case s.cursor < 0: s.cursor = 0 case s.cursor >= len(s.rows): s.cursor = len(s.rows) - 1 } } // selectID puts the cursor on an agent by id. func (s *sidebar) selectID(id string) { for i, r := range s.rows { if r.ID == id { s.cursor = i return } } } // refresh pulls the current rows. The cursor follows the agent it was on // rather than the index, so a row appearing above it does not move the // selection out from under a keystroke. func (m *model) refreshAgents() { if m.opts.Agents == nil { return } var on string if cur, ok := m.bar.selected(); ok { on = cur.ID } m.bar.rows = m.opts.Agents.Snapshot() m.bar.live = m.opts.Agents.Live() if on != "" { m.bar.selectID(on) } if m.bar.cursor >= len(m.bar.rows) { m.bar.cursor = maxInt(0, len(m.bar.rows)-1) } } // toggleSidebar is what ctrl+b does, and it always moves towards the pane: // closed opens it, open-but-unfocused hands it the keyboard, and only a pane // that already has the keyboard is closed. Closing what you are looking at is // never what someone reaching for this key meant. func (m *model) toggleSidebar() tea.Cmd { switch { case !m.bar.open: return m.openSidebar() case !m.bar.focused: return m.focusSidebar() default: return m.closeSidebar() } } // togglePane is what the agents pill does: a plain switch, showing the pane or // hiding it. Clicking a button labelled with what it shows should not depend // on which pane happens to have the keyboard — and the click that reached the // pill has just given the keyboard to the prompt, so the three-way ctrl+b // reading would never close anything. func (m *model) togglePane() tea.Cmd { if m.bar.open { return m.closeSidebar() } return m.openSidebar() } func (m *model) openSidebar() tea.Cmd { m.bar.open = true m.refreshAgents() return m.focusSidebar() } func (m *model) closeSidebar() tea.Cmd { m.bar.open, m.bar.focused = false, false m.viewing = "" m.input.Focus() return tea.Batch(m.relayout(), textarea.Blink) } // focusSidebar gives the pane the keyboard. The prompt is blurred, so the // frame says where the next keystroke is going. func (m *model) focusSidebar() tea.Cmd { m.bar.focused = true m.input.Blur() return tea.Batch(m.relayout(), textarea.Blink) } // focusPrompt puts the keyboard back in the prompt, leaving the pane on screen // to watch while you type. func (m *model) focusPrompt() tea.Cmd { if !m.bar.focused { return nil } m.bar.focused = false m.input.Focus() return tea.Batch(m.sync(), textarea.Blink) } // onSidebarKey handles the keys while the pane has them, and reports whether // it took the key. func (m *model) onSidebarKey(msg tea.KeyMsg) (tea.Cmd, bool) { if !m.bar.hasKeys() { return nil, false } switch msg.String() { case "ctrl+c", "ctrl+d": // The keys that stop things are never the pane's. Opening it to watch // an agent must not cost you the ability to interrupt your own turn or // to leave. return nil, false case "ctrl+b": return m.toggleSidebar(), true case "esc": // One esc leaves the agent's thread, the next closes the pane: the // same key walks back out the way you came in. if m.viewing != "" { m.viewing = "" return m.sync(), true } return m.toggleSidebar(), true case "up": m.bar.move(-1) return m.followSelection(), true case "down": m.bar.move(1) return m.followSelection(), true case "enter": if cur, ok := m.bar.selected(); ok { m.viewing = cur.ID return m.sync(), true } return nil, true case "p": return m.controlSelected("p"), true case "k": return m.controlSelected("k"), true } // Everything else is swallowed: the pane has the keys while it is open, so // a stray letter cannot end up in a prompt nobody is looking at. return nil, true } // followSelection keeps the thread on screen in step with the cursor, so // moving down the list while reading walks through the threads. func (m *model) followSelection() tea.Cmd { if m.viewing != "" { if cur, ok := m.bar.selected(); ok { m.viewing = cur.ID } } return m.sync() } // controlSelected applies p (pause or resume, whichever the row is not) or k // (kill) to the agent under the cursor. // // The supervisor is called from the returned command rather than from here, // and that is not a style choice: acting on an agent makes the supervisor // announce the change, which it does by sending this program a message — and // the channel that carries it is read by the update loop we would still be // inside. Calling it here deadlocks the whole program, keyboard included. func (m *model) controlSelected(key string) tea.Cmd { cur, ok := m.bar.selected() if !ok || m.opts.Agents == nil { return nil } agents, id := m.opts.Agents, cur.ID paused := cur.Status == subagent.Paused return m.tag(func() tea.Msg { switch { case key == "k": return agentActed{err: agents.Kill(id)} case paused: return agentActed{err: agents.Resume(id)} default: return agentActed{err: agents.Pause(id)} } }) } // onAgentActed reports a refusal — an agent that has already finished, or one // that has not started. The change itself arrives separately, as the // supervisor's own notification. func (m *model) onAgentActed(msg agentActed) tea.Cmd { if msg.err == nil { return nil } m.tr.addError(msg.err.Error()) return m.sync() } // sidebarView draws the pane at the given height. func (m *model) sidebarView(height int) string { width := m.sidebarWidth() inner := maxInt(8, width-barFrame) var b strings.Builder // The heading carries the accent only while the pane has the keyboard, // which is the same grammar the prompt frame uses. head := styDim.Render("Agents") if m.bar.focused { head = styHeading.Render("Agents") } count := styFaint.Render("none yet") if n := m.bar.live; n > 0 { count = styCoral.Render(fmt.Sprintf("%d ⏵", n)) } else if len(m.bar.rows) > 0 { count = styFaint.Render(fmt.Sprintf("%d done", len(m.bar.rows))) } b.WriteString(spread(head, count, inner) + "\n\n") m.bar.hits = nil // The pane has no top border, so its first line is the screen's first // line: the header and the blank line under it are all that come before // the rows. y := 2 if len(m.bar.rows) == 0 { b.WriteString(styFaint.Render(wrapTo("nothing running. The agent starts these itself, with your say-so.", inner)) + "\n") } for i, r := range m.bar.rows { lines := m.agentRow(r, i == m.bar.cursor, inner) b.WriteString(lines + "\n") h := lipgloss.Height(lines) m.bar.hits = append(m.bar.hits, rowHit{y0: y, y1: y + h - 1, id: r.ID}) y += h } if m.bar.focused { b.WriteString("\n" + styFaint.Render(truncate("↑↓ move · enter open", inner)) + "\n" + styFaint.Render(truncate("p pause · k kill · esc back", inner))) } else { b.WriteString("\n" + styFaint.Render(truncate("ctrl+b or click here", inner)) + "\n" + styFaint.Render(truncate("for these agents' keys", inner))) } // Width is the block inside the border, padding included, so the rule // itself is the one column not accounted for here. return styBar.Width(width - 1).Height(maxInt(1, height)).Render(strings.TrimRight(b.String(), "\n")) } // agentRow is one agent: what it is on the first line, how it is going on the // second. Two lines because one cannot hold both without abbreviating the // numbers into uselessness. func (m *model) agentRow(r subagent.Info, selected bool, width int) string { glyph, tone := statusLook(r.Status) marker := " " name := styText.Render(r.ID + " " + r.Preset) if selected { marker = styCoral.Render("❯ ") name = styBold.Foreground(colText).Render(r.ID + " " + r.Preset) } if m.viewing == r.ID { name += styCoral.Render(" ◂") } first := marker + tone.Render(glyph) + " " + name // The cost goes at the right of the first line rather than at the end of // the second: thirty columns cannot hold model, elapsed, turns and money // on one line, and the number you look for is the money. // Below a cent, two decimal places say "$0.00", which reads as free. The // exact figure is in the line the transcript gets when the agent ends. if cost, ok := r.Cost(); ok && cost >= 0.005 { first = spread(first, styFaint.Render(fmt.Sprintf("$%.2f", cost)), width) } var parts []string if r.Status == subagent.Queued { parts = append(parts, "queued") } else { parts = append(parts, shortModel(r.Model)) } switch r.Status { case subagent.Waiting: parts = append(parts, "waiting") case subagent.Paused: parts = append(parts, "paused") case subagent.Failed: parts = append(parts, "failed") case subagent.Killed: parts = append(parts, "stopped") case subagent.Done: parts = append(parts, "done") default: if d := r.Elapsed(); d > 0 { parts = append(parts, compactDuration(d)) } } if r.Turns > 0 { parts = append(parts, plural(r.Turns, "turn")) } second := " " + styFaint.Render(truncate(strings.Join(parts, " · "), maxInt(6, width-2))) return truncateStyled(first, width) + "\n" + second } // shortModel is the family alone — "Haiku", "Opus" — which is all a // thirty-column row has space for. The full id is in the thread's header, one // keystroke away. func shortModel(id string) string { pretty := prettyModel(id) if pretty != id { if i := strings.IndexByte(pretty, ' '); i > 0 { return pretty[:i] } } return pretty } // compactDuration is how long an agent has been at it. Past a minute, a count // of seconds stops reading as a duration. func compactDuration(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) } return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) } // statusLook is the glyph and colour one status gets. The running glyph is the // same star the status line pulses with, so a live agent looks live. func statusLook(s subagent.Status) (string, lipgloss.Style) { switch s { case subagent.Queued: return "◦", styFaint case subagent.Running: return "✳", styCoral case subagent.Waiting: return "●", styWarn case subagent.Paused: return "‖", styDim case subagent.Done: return "✓", styOK case subagent.Failed: return "✗", styErr case subagent.Killed: return "✗", styDim } return "·", styDim } // agentHeader is the bar above a subagent's thread, so a transcript that looks // like yours cannot be mistaken for yours. func (m *model) agentHeader(width int) string { var r subagent.Info var found bool for _, row := range m.bar.rows { if row.ID == m.viewing { r, found = row, true break } } if !found { return "" } glyph, tone := statusLook(r.Status) detail := " · " + prettyModel(r.Model) if r.Effort != "" { detail += " · " + r.Effort + " effort" } detail += " · " + string(r.Status) left := tone.Render(glyph) + " " + styHeading.Render(r.ID+" · "+r.Preset) + styDim.Render(detail) right := styFaint.Render("esc back") return spread(left, right, maxInt(1, width)) } // subTranscript is the scrollback for one subagent, created on first sight. func (m *model) subTranscript(id string) *transcript { if m.subTr == nil { m.subTr = map[string]*transcript{} } t, ok := m.subTr[id] if !ok { t = newTranscript(m.mainWidth()) t.setVerbose(m.opts.Verbose) m.subTr[id] = t if info, found := m.agentInfo(id); found { t.addUser(info.Task) } } return t } func (m *model) agentInfo(id string) (subagent.Info, bool) { for _, r := range m.bar.rows { if r.ID == id { return r, true } } return subagent.Info{}, false } // onSubEvent routes one subagent's event into that agent's transcript. It is // the same handling the main thread gets, against a different transcript: // tool calls, diffs and elision all behave identically. func (m *model) onSubEvent(ev subagent.Event) tea.Cmd { t := m.subTranscript(ev.ID) switch ev.Agent.Kind { case agent.EventTextDelta: t.appendDelta(ev.Agent.Text) case agent.EventText: t.add(entry{kind: entryAssistant, text: strings.TrimSpace(ev.Agent.Text)}) case agent.EventToolStart: t.startTool(ev.Agent.Tool, summarizeInput(ev.Agent.Input)) case agent.EventToolResult: t.finishTool(ev.Agent.Tool, ev.Agent.Result, ev.Agent.IsError) case agent.EventCompact: t.addNotice("compacted the conversation") case agent.EventPaused: t.addNotice("paused") case agent.EventResumed: t.addNotice("resumed") case agent.EventUsage: // A child's tokens are the session's tokens: they are spent on the // same key, and priced at the child's own model rather than at // whatever the session happens to be on. m.addCostAt(ev.Agent.Usage, ev.Model) return nil default: return nil } if m.viewing == ev.ID { return m.sync() } return nil } // onAgentsChanged is the supervisor telling us something moved: a status, or a // finished agent with a report to hand over. func (m *model) onAgentsChanged() tea.Cmd { if m.opts.Agents == nil { return nil } m.refreshAgents() for _, r := range m.opts.Agents.TakeReports() { // Queued, not delivered: whether this one is ours to hand over is // decided when there is a turn to put it in, because agent_wait can // take it in the meantime. m.pendingReports = append(m.pendingReports, r) m.tr.addNotice(agentFinishedLine(r)) } return tea.Batch(m.sync(), m.deliverReports()) } // agentFinishedLine is the one line your own transcript gets when an agent // ends. The report itself goes to the model; this is for you. func agentFinishedLine(r subagent.Info) string { var b strings.Builder fmt.Fprintf(&b, "%s (%s) %s", r.ID, r.Preset, endedWord(r.Status)) var parts []string if r.Turns > 0 { parts = append(parts, plural(r.Turns, "turn")) } if d := r.Elapsed(); d > 0 { parts = append(parts, fmt.Sprintf("%ds", int(d.Seconds()))) } if cost, ok := r.Cost(); ok && cost > 0 { parts = append(parts, fmt.Sprintf("$%.4f", cost)) } if len(parts) > 0 { fmt.Fprintf(&b, " — %s", strings.Join(parts, " · ")) } if r.Status == subagent.Failed && r.Err != "" { fmt.Fprintf(&b, ": %s", r.Err) } b.WriteString(" · ctrl+b to read it") return b.String() } func endedWord(s subagent.Status) string { switch s { case subagent.Failed: return "failed" case subagent.Killed: return "was stopped" } return "finished" } // readyForReports reports whether a report can go up right now. Everything it // refuses on is a case where starting a turn would take the screen away from // something the user is in the middle of — most of all a prompt with text in // it, which a starting turn would blur under their fingers. func (m *model) readyForReports() bool { return m.opts.Agent != nil && m.phase == phaseIdle && m.wiz == nil && m.running == nil && len(m.pending) == 0 && m.local == localOff && strings.TrimSpace(m.input.Value()) == "" } // deliverReports gives the agent everything that has finished, and sets it // going. This is the half of the feature that makes spawning worth doing: you // do not have to come back and ask how the agents got on. func (m *model) deliverReports() tea.Cmd { if len(m.pendingReports) == 0 || !m.readyForReports() { return nil } // Every pending report may have been collected by agent_wait since it was // queued, in which case there is nothing to say and no turn to start. if !m.flushReportsAhead() { return nil } m.phase = phaseRunning m.started = time.Now() m.turn = 0 m.verbSeed++ // The prompt keeps focus, exactly as it does for a turn you started // yourself: typing ahead while the agent works is allowed, and this turn // is not special for having started on its own. return tea.Batch(m.runContinue(), m.spin.Tick, m.sync()) } // takeReportText folds the reports this front end is still entitled to hand // over into one message, and clears the queue. // // The claim is made here rather than when the report was drained, because the // gap between the two can be minutes: the parent's own agent_wait may have // collected the report in that time, and putting it in again would charge the // model twice for something already in its context. func (m *model) takeReportText() string { var parts []string for _, r := range m.pendingReports { if m.opts.Agents != nil && !m.opts.Agents.ClaimReport(r.ID) { continue } parts = append(parts, r.Notification()) } m.pendingReports = nil return strings.Join(parts, "\n\n") } // flushReportsAhead puts pending reports into the conversation as their own // message, ahead of whatever the user is about to send. Gluing the two // together would leave the model unable to tell which words were whose. // // It reports whether anything was actually added: every pending report may // turn out to belong to agent_wait instead. func (m *model) flushReportsAhead() bool { if len(m.pendingReports) == 0 || m.opts.Agent == nil { return false } text := m.takeReportText() if text == "" { return false } m.opts.Agent.State().Append(llm.Message{ Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(text)}, }) return true } // onSidebarMouse answers an event that landed in the pane's own column. A // click selects the row under it and gives the pane the keyboard; clicking the // row already selected opens its thread, which is the double-click anyone // tries first. func (m *model) onSidebarMouse(msg tea.MouseMsg) tea.Cmd { if isWheel(msg) { // The list is short enough to fit; scrolling it would be a scroll bar // with nothing behind it. return nil } if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { return nil } cmds := []tea.Cmd{m.focusSidebar()} for i, h := range m.bar.hits { if !h.hit(msg.X, msg.Y) { continue } if m.bar.cursor == i && m.viewing != h.id { m.viewing = h.id } else { m.bar.cursor = i } return tea.Batch(append(cmds, m.followSelection())...) } return tea.Batch(cmds...) } // wrapTo is a minimal hard wrap for the pane's own hint text. func wrapTo(s string, width int) string { if width <= 0 { return s } var lines []string line := "" for _, word := range strings.Fields(s) { switch { case line == "": line = word case len(line)+1+len(word) <= width: line += " " + word default: lines = append(lines, line) line = word } } if line != "" { lines = append(lines, line) } return strings.Join(lines, "\n") }