Vectrum
Sign In
717 lines · 21.1 KB · Go
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 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")
}