package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/johannSo/codey/internal/agent"
"github.com/johannSo/codey/internal/llm"
"github.com/johannSo/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")
}