ctrl+b opens a pane on the right listing every subagent: status glyph, id and preset, with the model, elapsed, turns and cost under it. Enter reads one agent's own thread in the main pane — the only place its turns are visible at all — and p and k pause and stop it. Open and focused are separate. The pane answers every key while it has focus, which is what lets k be a kill rather than a letter, so clicking the prompt puts the keyboard back without closing what you were watching. Which pane is live is visible in the heading, the prompt frame and the pane's own footer. Reports arrive by themselves: a finished agent's report goes into the conversation and the agent is set going on it, held back only while a turn is running, an approval is up, a local command is running, or the prompt has text in it — a turn starting under your fingers would eat what you were typing. Two things the supervisor forced. Acting on an agent happens in a command rather than in Update, because the supervisor answers by sending this program a message and the channel that carries it is read by the update loop itself. And an approval no longer implies a running turn: a subagent can ask while you are idle, and answering has to put the session back where it found it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 13 changed files with 2376 additions and 52 deletions.
| @@ -0,0 +1,195 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "io" | |
| 8 | + "net/http" | |
| 9 | + "net/http/httptest" | |
| 10 | + "strings" | |
| 11 | + "sync" | |
| 12 | + "testing" | |
| 13 | + "time" | |
| 14 | + | |
| 15 | + "github.com/charmbracelet/bubbles/spinner" | |
| 16 | + tea "github.com/charmbracelet/bubbletea" | |
| 17 | + | |
| 18 | + "harness/internal/agent" | |
| 19 | + "harness/internal/approval" | |
| 20 | + "harness/internal/llm" | |
| 21 | + "harness/internal/metrics" | |
| 22 | + "harness/internal/subagent" | |
| 23 | + "harness/internal/tools" | |
| 24 | + ) | |
| 25 | + | |
| 26 | + // scriptedAPI answers the main agent and its children differently, so one | |
| 27 | + // server can play both halves of a spawn. | |
| 28 | + func scriptedAPI(t *testing.T) *httptest.Server { | |
| 29 | + t.Helper() | |
| 30 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 31 | + body, _ := io.ReadAll(r.Body) | |
| 32 | + var req llm.Request | |
| 33 | + json.Unmarshal(body, &req) | |
| 34 | + | |
| 35 | + var system string | |
| 36 | + for _, b := range req.System { | |
| 37 | + system += b.Text | |
| 38 | + } | |
| 39 | + var conversation string | |
| 40 | + for _, m := range req.Messages { | |
| 41 | + for _, b := range m.Content { | |
| 42 | + conversation += b.Text + b.Content | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + switch { | |
| 47 | + case strings.Contains(system, "You are a subagent"): | |
| 48 | + io.WriteString(w, liveMsg("end_turn", `{"type":"text","text":"found it in internal/approval/policy.go:70"}`)) | |
| 49 | + case strings.Contains(conversation, "[subagent a1"): | |
| 50 | + io.WriteString(w, liveMsg("end_turn", `{"type":"text","text":"thanks — that is the line I needed"}`)) | |
| 51 | + case strings.Contains(conversation, "started a1"): | |
| 52 | + io.WriteString(w, liveMsg("end_turn", `{"type":"text","text":"an agent is on it"}`)) | |
| 53 | + default: | |
| 54 | + io.WriteString(w, liveMsg("tool_use", `{"type":"tool_use","id":"tu_1","name":"spawn_agent",`+ | |
| 55 | + `"input":{"preset":"explore","task":"find where the approval policy is consulted"}}`)) | |
| 56 | + } | |
| 57 | + })) | |
| 58 | + t.Cleanup(srv.Close) | |
| 59 | + return srv | |
| 60 | + } | |
| 61 | + | |
| 62 | + func liveMsg(stop string, blocks ...string) string { | |
| 63 | + return `{"id":"msg","type":"message","role":"assistant","model":"m","content":[` + | |
| 64 | + strings.Join(blocks, ",") + `],"stop_reason":"` + stop + `",` + | |
| 65 | + `"usage":{"input_tokens":100,"output_tokens":20}}` | |
| 66 | + } | |
| 67 | + | |
| 68 | + // A whole spawn, end to end: the model asks for an agent, the supervisor runs | |
| 69 | + // it, the sidebar fills in, the agent's own thread is readable, and its report | |
| 70 | + // comes back into the conversation without anybody typing. | |
| 71 | + func TestSpawningFillsTheSidebarAndTheReportComesBack(t *testing.T) { | |
| 72 | + srv := scriptedAPI(t) | |
| 73 | + root := t.TempDir() | |
| 74 | + log := metrics.NewWriter(&bytes.Buffer{}, "test") | |
| 75 | + client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k")) | |
| 76 | + policy := approval.New(nil) | |
| 77 | + policy.YOLO = true // approvals have their own tests; this one is about the wiring | |
| 78 | + | |
| 79 | + newTools := func(state *agent.State, log *metrics.Logger) *tools.Registry { | |
| 80 | + return tools.NewRegistry(log).Register(tools.NewRead(root, state)) | |
| 81 | + } | |
| 82 | + sup := subagent.New(context.Background(), subagent.Options{ | |
| 83 | + Client: client, Log: log, Policy: policy, Tools: newTools, | |
| 84 | + }) | |
| 85 | + t.Cleanup(sup.Shutdown) | |
| 86 | + | |
| 87 | + state := agent.NewState() | |
| 88 | + reg := newTools(state, log) | |
| 89 | + reg.Register(sup.Tools()...) | |
| 90 | + a := agent.New(client, reg, state, log, agent.Options{}) | |
| 91 | + a.Approve = policy | |
| 92 | + | |
| 93 | + m := newModel(context.Background(), Options{ | |
| 94 | + Agent: a, Client: client, Policy: policy, Agents: sup, Root: root, | |
| 95 | + SessionDir: t.TempDir(), SessionID: "test", | |
| 96 | + }) | |
| 97 | + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) | |
| 98 | + | |
| 99 | + // Everything the supervisor and the agent report arrives here, and is | |
| 100 | + // pumped into the model from this goroutine — which is exactly what | |
| 101 | + // Bubble Tea's Send does in the real program. | |
| 102 | + msgs := make(chan tea.Msg, 256) | |
| 103 | + sup.Subscribe( | |
| 104 | + func(ev subagent.Event) { msgs <- evSub{ev: ev} }, | |
| 105 | + func() { msgs <- evAgents{} }, | |
| 106 | + ) | |
| 107 | + a.Observe = func(ev agent.Event) { | |
| 108 | + if ev.Kind == agent.EventToolResult { | |
| 109 | + msgs <- evToolResult{tool: ev.Tool, result: ev.Result} | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + // Send a prompt the way the prompt box does, and run the commands it | |
| 114 | + // returns off the update loop, as the program's scheduler would. | |
| 115 | + d := &driver{msgs: msgs} | |
| 116 | + // Everything the model started has to finish before the test's temporary | |
| 117 | + // directories go away: a turn saves its session as it ends. | |
| 118 | + defer d.wg.Wait() | |
| 119 | + | |
| 120 | + _, cmd := m.submit("find where the approval policy is consulted") | |
| 121 | + d.run(cmd) | |
| 122 | + | |
| 123 | + deadline := time.After(10 * time.Second) | |
| 124 | + var sawReport bool | |
| 125 | + for !sawReport { | |
| 126 | + select { | |
| 127 | + case msg := <-msgs: | |
| 128 | + _, next := m.Update(msg) | |
| 129 | + d.run(next) | |
| 130 | + sawReport = strings.Contains(historyText(a), "[subagent a1") | |
| 131 | + case <-deadline: | |
| 132 | + t.Fatalf("the report never came back. transcript:\n%s", m.tr.render()) | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + // The sidebar knows about it, and the thread is readable. | |
| 137 | + if len(m.bar.rows) != 1 || m.bar.rows[0].ID != "a1" { | |
| 138 | + t.Fatalf("sidebar rows = %+v", m.bar.rows) | |
| 139 | + } | |
| 140 | + m.Update(key("ctrl+b")) | |
| 141 | + m.Update(key("enter")) | |
| 142 | + if view := m.View(); !strings.Contains(view, "internal/approval/policy.go:70") { | |
| 143 | + t.Errorf("a1's own thread is not readable:\n%s", view) | |
| 144 | + } | |
| 145 | + | |
| 146 | + // And the main transcript said so in one line rather than repeating the | |
| 147 | + // whole report. | |
| 148 | + if !strings.Contains(m.tr.render(), "a1 (explore) finished") { | |
| 149 | + t.Errorf("the main transcript never noticed a1 finishing:\n%s", m.tr.render()) | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + // driver runs Bubble Tea commands off the update loop, the way the program's | |
| 154 | + // own scheduler does, and keeps count of them so a test can wait for the work | |
| 155 | + // it started. A batch is a list of commands rather than a message, so it is | |
| 156 | + // unpacked here exactly as the real scheduler unpacks it. | |
| 157 | + // | |
| 158 | + // Spinner ticks are dropped: feeding one back produces another for ever, and | |
| 159 | + // the animation is not what any of this is testing. | |
| 160 | + type driver struct { | |
| 161 | + wg sync.WaitGroup | |
| 162 | + msgs chan tea.Msg | |
| 163 | + } | |
| 164 | + | |
| 165 | + func (d *driver) run(cmd tea.Cmd) { | |
| 166 | + if cmd == nil { | |
| 167 | + return | |
| 168 | + } | |
| 169 | + d.wg.Add(1) | |
| 170 | + go func() { | |
| 171 | + defer d.wg.Done() | |
| 172 | + switch msg := cmd().(type) { | |
| 173 | + case nil, tea.QuitMsg, spinner.TickMsg: | |
| 174 | + case tea.BatchMsg: | |
| 175 | + for _, c := range msg { | |
| 176 | + d.run(c) | |
| 177 | + } | |
| 178 | + default: | |
| 179 | + select { | |
| 180 | + case d.msgs <- msg: | |
| 181 | + default: // nobody is reading any more; the test is done | |
| 182 | + } | |
| 183 | + } | |
| 184 | + }() | |
| 185 | + } | |
| 186 | + | |
| 187 | + func historyText(a *agent.Agent) string { | |
| 188 | + var s string | |
| 189 | + for _, m := range a.State().History() { | |
| 190 | + for _, b := range m.Content { | |
| 191 | + s += b.Text | |
| 192 | + } | |
| 193 | + } | |
| 194 | + return s | |
| 195 | + } | |
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "harness/internal/agent" |
| 9 | 9 | "harness/internal/approval" |
| 10 | 10 | "harness/internal/llm" |
| 11 | + "harness/internal/subagent" | |
| 11 | 12 | ) |
| 12 | 13 | |
| 13 | 14 | // The agent loop runs on its own goroutine and must not know a UI exists. It |
| @@ -38,6 +39,16 @@ type ( | ||
| 38 | 39 | evUsage struct{ usage llm.Usage } |
| 39 | 40 | evCompact struct{} |
| 40 | 41 | |
| 42 | + // evSub is one subagent's event, tagged with the agent it came from. | |
| 43 | + evSub struct{ ev subagent.Event } | |
| 44 | + // agentActed comes back from a pause, resume or kill asked for in the | |
| 45 | + // sidebar, carrying whatever the supervisor refused with. | |
| 46 | + agentActed struct{ err error } | |
| 47 | + // evAgents says a subagent's status moved or one finished. It carries | |
| 48 | + // nothing: the model reads the supervisor, which is the one copy of the | |
| 49 | + // truth, rather than a snapshot that was true when it was sent. | |
| 50 | + evAgents struct{} | |
| 51 | + | |
| 41 | 52 | // approvalMsg asks the user to decide. reply is buffered, so answering |
| 42 | 53 | // never blocks the UI even if the agent's context was cancelled first. |
| 43 | 54 | approvalMsg struct { |
| @@ -77,6 +88,18 @@ func (b *bridge) Observe(ev agent.Event) { | ||
| 77 | 88 | } |
| 78 | 89 | } |
| 79 | 90 | |
| 91 | + // Subagent is the subagent.Options.Observe callback, called from a child's | |
| 92 | + // own goroutine. | |
| 93 | + func (b *bridge) Subagent(ev subagent.Event) { b.p.Send(evSub{ev: ev}) } | |
| 94 | + | |
| 95 | + // AgentsChanged is the subagent.Options.Changed callback: something moved, | |
| 96 | + // come and look. | |
| 97 | + // | |
| 98 | + // Send blocks until the update loop reads it, so this must never be reached | |
| 99 | + // from inside Update — see controlSelected, which is why agent controls run | |
| 100 | + // as commands. | |
| 101 | + func (b *bridge) AgentsChanged() { b.p.Send(evAgents{}) } | |
| 102 | + | |
| 80 | 103 | // Prompt implements approval.Prompt. It hands the request to the UI and waits. |
| 81 | 104 | // |
| 82 | 105 | // The context is honoured while waiting, so ctrl-c during an approval prompt |
| @@ -26,6 +26,7 @@ var builtins = []command{ | ||
| 26 | 26 | {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"}, |
| 27 | 27 | {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"}, |
| 28 | 28 | {name: "setup", desc: "the full-screen wizard for the saved defaults"}, |
| 29 | + {name: "mouse", desc: "turn mouse reporting off, for selecting text with the terminal"}, | |
| 29 | 30 | {name: "clear", desc: "empty the transcript"}, |
| 30 | 31 | {name: "allowed", desc: "what is permanently allowed this session"}, |
| 31 | 32 | {name: "exit", desc: "leave the harness"}, |
| @@ -79,6 +80,7 @@ func (m *model) helpText() string { | ||
| 79 | 80 | fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them") |
| 80 | 81 | } |
| 81 | 82 | b.WriteString("\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" + |
| 83 | + "\n ctrl+b the agents sidebar — ↑↓ move · enter read one · p pause · k kill" + | |
| 82 | 84 | "\n ! runs a shell command · $ runs python — both on an empty prompt" + |
| 83 | 85 | "\n what they print goes to the agent too, so you can then say \"fix that\"" + |
| 84 | 86 | "\n / opens the command menu · ↑↓ move · tab or enter complete · esc dismiss" + |
| @@ -142,7 +142,7 @@ func (m *model) menuView() string { | ||
| 142 | 142 | for _, c := range m.menu.items[start:end] { |
| 143 | 143 | width = maxInt(width, lipgloss.Width("/"+c.name)) |
| 144 | 144 | } |
| 145 | - width = minInt(width, maxInt(12, m.width/3)) | |
| 145 | + width = minInt(width, maxInt(12, m.mainWidth()/3)) | |
| 146 | 146 | |
| 147 | 147 | var b strings.Builder |
| 148 | 148 | for i := start; i < end; i++ { |
| @@ -158,10 +158,10 @@ func (m *model) menuView() string { | ||
| 158 | 158 | } else { |
| 159 | 159 | line = " " + styDim.Render(name) + " " + styFaint.Render(desc) |
| 160 | 160 | } |
| 161 | - b.WriteString(truncateStyled(" "+line, maxInt(1, m.width)) + "\n") | |
| 161 | + b.WriteString(truncateStyled(" "+line, maxInt(1, m.mainWidth())) + "\n") | |
| 162 | 162 | } |
| 163 | 163 | if n := len(m.menu.items) - (end - start); n > 0 { |
| 164 | - b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), maxInt(1, m.width)) + "\n") | |
| 164 | + b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), maxInt(1, m.mainWidth())) + "\n") | |
| 165 | 165 | } |
| 166 | 166 | return strings.TrimRight(b.String(), "\n") |
| 167 | 167 | } |
| @@ -101,7 +101,7 @@ func (m *model) applyEffort(level string) { | ||
| 101 | 101 | // ─────────────────────────────▲─────────────────────────────────── |
| 102 | 102 | // low medium high xhigh max |
| 103 | 103 | func (m *model) effortView() string { |
| 104 | - width := maxInt(1, m.width) | |
| 104 | + width := maxInt(1, m.mainWidth()) | |
| 105 | 105 | |
| 106 | 106 | starts := effortSlots(width - 1) |
| 107 | 107 | if starts == nil { |
| @@ -19,6 +19,7 @@ import ( | ||
| 19 | 19 | "harness/internal/config" |
| 20 | 20 | "harness/internal/llm" |
| 21 | 21 | "harness/internal/skills" |
| 22 | + "harness/internal/subagent" | |
| 22 | 23 | ) |
| 23 | 24 | |
| 24 | 25 | // Options wires the TUI to an already-built agent. The TUI owns no policy of |
| @@ -46,6 +47,9 @@ type Options struct { | ||
| 46 | 47 | // Skills are the instruction sets discovered at startup, used to resolve |
| 47 | 48 | // /<slug> and to answer /skills. |
| 48 | 49 | Skills []skills.Skill |
| 50 | + // Agents is the subagent supervisor, or nil where there is none: the | |
| 51 | + // sidebar simply never opens, and no spawn tool was registered either. | |
| 52 | + Agents Agents | |
| 49 | 53 | |
| 50 | 54 | // Config is what was loaded from disk, and FirstRun says there was nothing |
| 51 | 55 | // to load — which is what opens the setup wizard unprompted. |
| @@ -133,9 +137,31 @@ type model struct { | ||
| 133 | 137 | modelIdx int |
| 134 | 138 | modelsLoading bool |
| 135 | 139 | |
| 140 | + // The agents sidebar: whether it is open, what it last drew, and which | |
| 141 | + // agent's thread has taken over the main pane. | |
| 142 | + bar sidebar | |
| 143 | + viewing string | |
| 144 | + // subTr is one scrollback per subagent, so a thread you open reads the | |
| 145 | + // way your own does. | |
| 146 | + subTr map[string]*transcript | |
| 147 | + // pendingReports are finished agents whose reports have not gone up yet, | |
| 148 | + // because a turn was running or the prompt had text in it. | |
| 149 | + pendingReports []subagent.Info | |
| 150 | + // mouseOn tracks whether mouse reporting is enabled, because turning it | |
| 151 | + // on takes the terminal's own text selection away. | |
| 152 | + mouseOn bool | |
| 153 | + // pill is where the agents pill was last drawn, so a click on it can be | |
| 154 | + // recognised. | |
| 155 | + pill rowHit | |
| 156 | + | |
| 136 | 157 | // pending approvals. The agent blocks on one at a time, but a queue costs |
| 137 | - // nothing and means a second request can never be dropped. | |
| 158 | + // nothing and means a second request can never be dropped — and with | |
| 159 | + // subagents there can genuinely be several, from different agents. | |
| 138 | 160 | pending []approvalMsg |
| 161 | + // beforeApproval is what the session was doing when the box went up. An | |
| 162 | + // approval no longer implies a turn in flight: a subagent can ask while | |
| 163 | + // you are idle, and answering has to put you back where you were. | |
| 164 | + beforeApproval phase | |
| 139 | 165 | |
| 140 | 166 | // ctx plumbing for the running turn. |
| 141 | 167 | parent context.Context |
| @@ -219,7 +245,10 @@ func newModel(ctx context.Context, opts Options) *model { | ||
| 219 | 245 | // Run starts the program and blocks until the user exits. |
| 220 | 246 | func Run(ctx context.Context, opts Options) error { |
| 221 | 247 | m := newModel(ctx, opts) |
| 222 | - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx)) | |
| 248 | + // Mouse reporting buys the clickable agents pill and a scroll wheel, and | |
| 249 | + // costs the terminal's own text selection. /mouse gives it back. | |
| 250 | + m.mouseOn = true | |
| 251 | + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx), tea.WithMouseCellMotion()) | |
| 223 | 252 | |
| 224 | 253 | // The bridge can only learn the program after it exists; the agent does |
| 225 | 254 | // not touch it until the first turn starts, which is after Run. |
| @@ -228,6 +257,9 @@ func Run(ctx context.Context, opts Options) error { | ||
| 228 | 257 | if opts.Policy != nil { |
| 229 | 258 | opts.Policy.Prompt = m.bridge.Prompt |
| 230 | 259 | } |
| 260 | + if opts.Agents != nil { | |
| 261 | + opts.Agents.Subscribe(m.bridge.Subagent, m.bridge.AgentsChanged) | |
| 262 | + } | |
| 231 | 263 | |
| 232 | 264 | final, err := p.Run() |
| 233 | 265 | if err != nil && !errors.Is(err, tea.ErrProgramKilled) && !errors.Is(err, context.Canceled) { |
| @@ -310,6 +342,26 @@ func (m *model) closeWizard() tea.Cmd { | ||
| 310 | 342 | return tea.Batch(m.sync(), textarea.Blink) |
| 311 | 343 | } |
| 312 | 344 | |
| 345 | + // runContinue drives a turn on the conversation as it stands — the report a | |
| 346 | + // subagent just filed is already in it. Everything else about the turn is the | |
| 347 | + // same, including saving the session when it ends. | |
| 348 | + func (m *model) runContinue() tea.Cmd { | |
| 349 | + ctx, cancel := context.WithCancel(m.parent) | |
| 350 | + m.cancel = cancel | |
| 351 | + | |
| 352 | + a := m.opts.Agent | |
| 353 | + return func() tea.Msg { | |
| 354 | + _, err := a.Continue(ctx) | |
| 355 | + cancel() | |
| 356 | + | |
| 357 | + if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); saveErr != nil && err == nil { | |
| 358 | + err = fmt.Errorf("saving session: %w", saveErr) | |
| 359 | + } | |
| 360 | + u, turns := a.State().Usage() | |
| 361 | + return turnDone{err: err, usage: u, turns: turns} | |
| 362 | + } | |
| 363 | + } | |
| 364 | + | |
| 313 | 365 | // runTurn drives one prompt on its own goroutine. Events reach the UI through |
| 314 | 366 | // the bridge while this runs; the command's return value ends the turn. |
| 315 | 367 | func (m *model) runTurn(prompt string) tea.Cmd { |
| @@ -397,10 +449,25 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 397 | 449 | return m, m.sync() |
| 398 | 450 | |
| 399 | 451 | case approvalMsg: |
| 452 | + if m.phase != phaseApproving { | |
| 453 | + m.beforeApproval = m.phase | |
| 454 | + } | |
| 400 | 455 | m.pending = append(m.pending, msg) |
| 401 | 456 | m.phase = phaseApproving |
| 402 | 457 | return m, m.sync() |
| 403 | 458 | |
| 459 | + case evSub: | |
| 460 | + return m, m.onSubEvent(msg.ev) | |
| 461 | + | |
| 462 | + case evAgents: | |
| 463 | + return m, m.onAgentsChanged() | |
| 464 | + | |
| 465 | + case agentActed: | |
| 466 | + return m, m.onAgentActed(msg) | |
| 467 | + | |
| 468 | + case tea.MouseMsg: | |
| 469 | + return m, m.onMouse(msg) | |
| 470 | + | |
| 404 | 471 | case turnDone: |
| 405 | 472 | return m, m.finishTurn(msg) |
| 406 | 473 | |
| @@ -413,21 +480,64 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 413 | 480 | |
| 414 | 481 | func (m *model) resize(w, h int) tea.Cmd { |
| 415 | 482 | m.width, m.height = w, h |
| 416 | - m.tr.setWidth(w) | |
| 417 | - m.input.SetWidth(m.innerWidth()) | |
| 418 | - | |
| 419 | 483 | if !m.ready { |
| 420 | - m.vp = viewport.New(w, maxInt(1, h-8)) | |
| 484 | + m.vp = viewport.New(m.mainWidth(), maxInt(1, h-8)) | |
| 421 | 485 | m.ready = true |
| 422 | - } else { | |
| 423 | - m.vp.Width = w | |
| 424 | 486 | } |
| 425 | - m.refreshSplash() | |
| 426 | - m.vp.SetContent(m.tr.render()) | |
| 487 | + m.relayout() | |
| 427 | 488 | m.vp.GotoBottom() |
| 428 | 489 | return nil |
| 429 | 490 | } |
| 430 | 491 | |
| 492 | + // relayout re-flows everything that is sized against the main column, which | |
| 493 | + // is narrower whenever the sidebar is open. | |
| 494 | + func (m *model) relayout() tea.Cmd { | |
| 495 | + if !m.ready { | |
| 496 | + return nil | |
| 497 | + } | |
| 498 | + w := m.mainWidth() | |
| 499 | + m.tr.setWidth(w) | |
| 500 | + for _, t := range m.subTr { | |
| 501 | + t.setWidth(w) | |
| 502 | + } | |
| 503 | + m.input.SetWidth(m.innerWidth()) | |
| 504 | + m.vp.Width = w | |
| 505 | + m.refreshSplash() | |
| 506 | + return m.sync() | |
| 507 | + } | |
| 508 | + | |
| 509 | + // mainWidth is what is left for the transcript and the prompt. | |
| 510 | + func (m *model) mainWidth() int { | |
| 511 | + if m.bar.visible() && !m.fullScreenSidebar() { | |
| 512 | + return maxInt(20, m.width-sidebarWidth) | |
| 513 | + } | |
| 514 | + return m.width | |
| 515 | + } | |
| 516 | + | |
| 517 | + // sidebarTakesOver reports whether the terminal is too narrow to show both. | |
| 518 | + // Squeezing a transcript into thirty columns shows neither. | |
| 519 | + func (m *model) fullScreenSidebar() bool { | |
| 520 | + return m.bar.visible() && m.width-sidebarWidth < sidebarMinMain | |
| 521 | + } | |
| 522 | + | |
| 523 | + func (m *model) sidebarWidth() int { | |
| 524 | + if m.fullScreenSidebar() { | |
| 525 | + return m.width | |
| 526 | + } | |
| 527 | + return sidebarWidth | |
| 528 | + } | |
| 529 | + | |
| 530 | + // activeTranscript is what the main pane is showing: your own conversation, or | |
| 531 | + // the thread of the agent you opened. | |
| 532 | + func (m *model) activeTranscript() *transcript { | |
| 533 | + if m.viewing != "" { | |
| 534 | + if t, ok := m.subTr[m.viewing]; ok { | |
| 535 | + return t | |
| 536 | + } | |
| 537 | + } | |
| 538 | + return m.tr | |
| 539 | + } | |
| 540 | + | |
| 431 | 541 | // refreshSplash re-renders the welcome block. It is the one entry that owns |
| 432 | 542 | // its own lines — laid out for a width, and naming the model and the endpoint |
| 433 | 543 | // — so when any of those change it is rebuilt rather than wrapped or left to |
| @@ -436,7 +546,7 @@ func (m *model) refreshSplash() { | ||
| 436 | 546 | if len(m.tr.entries) == 0 || m.tr.entries[0].kind != entryRaw { |
| 437 | 547 | return |
| 438 | 548 | } |
| 439 | - width := m.width | |
| 549 | + width := m.mainWidth() | |
| 440 | 550 | if width <= 0 { |
| 441 | 551 | width = 80 |
| 442 | 552 | } |
| @@ -451,7 +561,7 @@ func (m *model) sync() tea.Cmd { | ||
| 451 | 561 | return nil |
| 452 | 562 | } |
| 453 | 563 | atBottom := m.vp.AtBottom() |
| 454 | - m.vp.SetContent(m.tr.render()) | |
| 564 | + m.vp.SetContent(m.activeTranscript().render()) | |
| 455 | 565 | if atBottom { |
| 456 | 566 | m.vp.GotoBottom() |
| 457 | 567 | } |
| @@ -465,11 +575,22 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd { | ||
| 465 | 575 | m.addCost(subUsage(msg.usage, m.priced)) |
| 466 | 576 | m.tr.closeOpen() |
| 467 | 577 | |
| 468 | - // Any approval still outstanding belongs to a turn that no longer exists. | |
| 578 | + // An approval this turn raised belongs to a turn that no longer exists, so | |
| 579 | + // it is denied. A subagent's does not: that agent is still running, and | |
| 580 | + // the decision is still the user's to make. | |
| 581 | + var kept []approvalMsg | |
| 469 | 582 | for _, p := range m.pending { |
| 470 | - p.reply <- approval.Deny | |
| 583 | + if p.req.Agent == "" { | |
| 584 | + p.reply <- approval.Deny | |
| 585 | + continue | |
| 586 | + } | |
| 587 | + kept = append(kept, p) | |
| 588 | + } | |
| 589 | + m.pending = kept | |
| 590 | + if len(m.pending) > 0 { | |
| 591 | + m.phase = phaseApproving | |
| 592 | + m.beforeApproval = phaseIdle | |
| 471 | 593 | } |
| 472 | - m.pending = nil | |
| 473 | 594 | |
| 474 | 595 | switch { |
| 475 | 596 | case msg.err == nil: |
| @@ -478,8 +599,11 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd { | ||
| 478 | 599 | default: |
| 479 | 600 | m.tr.addError(msg.err.Error()) |
| 480 | 601 | } |
| 481 | - m.input.Focus() | |
| 482 | - return tea.Batch(m.sync(), textarea.Blink) | |
| 602 | + if !m.bar.hasKeys() { | |
| 603 | + m.input.Focus() | |
| 604 | + } | |
| 605 | + // Agents that finished mid-turn have been waiting for this. | |
| 606 | + return tea.Batch(m.sync(), textarea.Blink, m.deliverReports()) | |
| 483 | 607 | } |
| 484 | 608 | |
| 485 | 609 | func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { |
| @@ -493,6 +617,15 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 493 | 617 | return m.onModelKey(msg) |
| 494 | 618 | } |
| 495 | 619 | |
| 620 | + // The sidebar owns every key while it is open, the way the approval box | |
| 621 | + // does: a pane with a kill key cannot share the keyboard with a prompt. | |
| 622 | + if cmd, took := m.onSidebarKey(msg); took { | |
| 623 | + return m, cmd | |
| 624 | + } | |
| 625 | + if msg.String() == "ctrl+b" && m.opts.Agents != nil { | |
| 626 | + return m, m.toggleSidebar() | |
| 627 | + } | |
| 628 | + | |
| 496 | 629 | // An open menu owns the keys that drive it — including enter and esc, |
| 497 | 630 | // which mean something else the rest of the time. |
| 498 | 631 | if m.menuOpen() { |
| @@ -572,7 +705,8 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 572 | 705 | m.input, cmd = m.input.Update(msg) |
| 573 | 706 | m.growInput() |
| 574 | 707 | m.refreshMenu() |
| 575 | - return m, cmd | |
| 708 | + // Emptying the prompt is what a report held back for it was waiting on. | |
| 709 | + return m, tea.Batch(cmd, m.deliverReports()) | |
| 576 | 710 | } |
| 577 | 711 | |
| 578 | 712 | func (m *model) interrupt() tea.Cmd { |
| @@ -630,6 +764,9 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 630 | 764 | case line == "/effort" || strings.HasPrefix(line, "/effort "): |
| 631 | 765 | return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort"))) |
| 632 | 766 | |
| 767 | + case line == "/mouse": | |
| 768 | + return m, m.toggleMouse() | |
| 769 | + | |
| 633 | 770 | case line == "/clear": |
| 634 | 771 | m.tr.clear() |
| 635 | 772 | return m, m.sync() |
| @@ -668,6 +805,11 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 668 | 805 | return m, m.sync() |
| 669 | 806 | } |
| 670 | 807 | |
| 808 | + // Anything a subagent reported while you were typing goes up as its own | |
| 809 | + // message, ahead of yours: the model should not have to work out which | |
| 810 | + // half of one message was whose. | |
| 811 | + m.flushReportsAhead() | |
| 812 | + | |
| 671 | 813 | m.tr.addUser(line) |
| 672 | 814 | m.phase = phaseRunning |
| 673 | 815 | m.turn = 0 |
| @@ -753,18 +895,23 @@ func (m *model) choices(req approval.Request) []choice { | ||
| 753 | 895 | |
| 754 | 896 | func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { |
| 755 | 897 | if len(m.pending) == 0 { |
| 756 | - m.phase = phaseRunning | |
| 898 | + m.phase = m.beforeApproval | |
| 757 | 899 | return m, nil |
| 758 | 900 | } |
| 759 | 901 | req := m.pending[0] |
| 760 | 902 | key := msg.String() |
| 761 | 903 | |
| 762 | - // ctrl-c mid-prompt denies the call and cancels the turn. | |
| 904 | + // ctrl-c mid-prompt denies the call and cancels the turn it belonged to. | |
| 905 | + // With nothing of yours running there is no turn to cancel — the denial | |
| 906 | + // is the whole of it. | |
| 763 | 907 | if key == "ctrl+c" { |
| 764 | 908 | req.reply <- approval.Deny |
| 765 | 909 | m.pending = m.pending[1:] |
| 766 | - m.phase = phaseRunning | |
| 767 | - return m, tea.Batch(m.interrupt(), m.sync()) | |
| 910 | + m.phase = m.phaseAfterApproval() | |
| 911 | + if m.phase == phaseRunning { | |
| 912 | + return m, tea.Batch(m.interrupt(), m.sync()) | |
| 913 | + } | |
| 914 | + return m, m.sync() | |
| 768 | 915 | } |
| 769 | 916 | |
| 770 | 917 | // "always this exact one" is not offered for an opaque command, and |
| @@ -782,7 +929,7 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 782 | 929 | req.reply <- c.dec |
| 783 | 930 | m.pending = m.pending[1:] |
| 784 | 931 | if len(m.pending) == 0 { |
| 785 | - m.phase = phaseRunning | |
| 932 | + m.phase = m.beforeApproval | |
| 786 | 933 | } |
| 787 | 934 | // The decision belongs to the call it gated, not to whatever the |
| 788 | 935 | // transcript happens to end with once the result lands. |
| @@ -792,6 +939,15 @@ func (m *model) onApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 792 | 939 | return m, nil |
| 793 | 940 | } |
| 794 | 941 | |
| 942 | + // phaseAfterApproval is where the session goes once the queue empties: back to | |
| 943 | + // whatever it was doing, or straight on to the next request if one is waiting. | |
| 944 | + func (m *model) phaseAfterApproval() phase { | |
| 945 | + if len(m.pending) > 0 { | |
| 946 | + return phaseApproving | |
| 947 | + } | |
| 948 | + return m.beforeApproval | |
| 949 | + } | |
| 950 | + | |
| 795 | 951 | func matches(keys []string, key string) bool { |
| 796 | 952 | for _, k := range keys { |
| 797 | 953 | if k == key { |
| @@ -814,18 +970,36 @@ func (m *model) View() string { | ||
| 814 | 970 | return m.wiz.View() |
| 815 | 971 | } |
| 816 | 972 | |
| 973 | + // Too narrow for both: the pane takes the screen rather than leaving two | |
| 974 | + // unreadable columns. The status line goes with the transcript, so the | |
| 975 | + // pill it carries is not on screen and its click target is forgotten | |
| 976 | + // rather than left pointing at whatever is drawn there now. | |
| 977 | + if m.fullScreenSidebar() { | |
| 978 | + m.pill = rowHit{} | |
| 979 | + return m.sidebarView(m.height) | |
| 980 | + } | |
| 981 | + | |
| 817 | 982 | // The bottom chrome changes height — the prompt box grows, the approval |
| 818 | 983 | // box is taller than either — so the viewport is sized against what was |
| 819 | 984 | // actually rendered rather than against an assumed number of rows. |
| 820 | 985 | bottom := m.bottom() |
| 821 | - if h := maxInt(1, m.height-lipgloss.Height(bottom)); h != m.vp.Height { | |
| 986 | + top := "" | |
| 987 | + if m.viewing != "" { | |
| 988 | + top = m.agentHeader(m.mainWidth()) + "\n" | |
| 989 | + } | |
| 990 | + if h := maxInt(1, m.height-lipgloss.Height(bottom)-strings.Count(top, "\n")); h != m.vp.Height { | |
| 822 | 991 | atBottom := m.vp.AtBottom() |
| 823 | 992 | m.vp.Height = h |
| 824 | 993 | if atBottom { |
| 825 | 994 | m.vp.GotoBottom() |
| 826 | 995 | } |
| 827 | 996 | } |
| 828 | - return m.vp.View() + "\n" + bottom | |
| 997 | + | |
| 998 | + main := top + m.vp.View() + "\n" + bottom | |
| 999 | + if !m.bar.visible() { | |
| 1000 | + return main | |
| 1001 | + } | |
| 1002 | + return lipgloss.JoinHorizontal(lipgloss.Top, main, m.sidebarView(m.height)) | |
| 829 | 1003 | } |
| 830 | 1004 | |
| 831 | 1005 | func (m *model) bottom() string { |
| @@ -855,7 +1029,7 @@ func (m *model) bottom() string { | ||
| 855 | 1029 | // changes that is the one worth naming beside it. |
| 856 | 1030 | func (m *model) activity() string { |
| 857 | 1031 | if m.running != nil { |
| 858 | - return spread(m.localActivity(), "", m.width) | |
| 1032 | + return spread(m.localActivity(), "", m.mainWidth()) | |
| 859 | 1033 | } |
| 860 | 1034 | |
| 861 | 1035 | var left string |
| @@ -879,21 +1053,29 @@ func (m *model) activity() string { | ||
| 879 | 1053 | |
| 880 | 1054 | right := "" |
| 881 | 1055 | if m.opts.Effort != "" { |
| 882 | - right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort") | |
| 1056 | + // The pill is a claim about the next request. On a model that does | |
| 1057 | + // not take this level the harness leaves it out rather than earning a | |
| 1058 | + // 400, so the pill says which model is ignoring it instead of | |
| 1059 | + // reporting a setting that is not in force. | |
| 1060 | + note := " · /effort" | |
| 1061 | + if !llm.SupportsEffort(m.opts.Client.Model(), m.opts.Effort) { | |
| 1062 | + note = " · not on " + shortModel(m.opts.Client.Model()) | |
| 1063 | + } | |
| 1064 | + right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(note) | |
| 883 | 1065 | } |
| 884 | - return spread(left, right, m.width) | |
| 1066 | + return spread(left, right, m.mainWidth()) | |
| 885 | 1067 | } |
| 886 | 1068 | |
| 887 | 1069 | func (m *model) innerWidth() int { return maxInt(8, m.boxWidth()-4) } |
| 888 | 1070 | |
| 889 | 1071 | // boxWidth keeps the prompt frame usable on a narrow terminal rather than |
| 890 | 1072 | // letting it collapse into a border with no room inside it. |
| 891 | - func (m *model) boxWidth() int { return maxInt(14, m.width) } | |
| 1073 | + func (m *model) boxWidth() int { return maxInt(14, m.mainWidth()) } | |
| 892 | 1074 | |
| 893 | 1075 | func (m *model) inputBox() string { |
| 894 | 1076 | style := styBoxFocus |
| 895 | 1077 | switch { |
| 896 | - case m.phase == phaseRunning: | |
| 1078 | + case m.phase == phaseRunning, m.bar.hasKeys(): | |
| 897 | 1079 | style = styBoxBlur |
| 898 | 1080 | case m.local != localOff: |
| 899 | 1081 | // The frame is the always-present chrome, so it is where a mode that |
| @@ -917,6 +1099,10 @@ func (m *model) status() string { | ||
| 917 | 1099 | parts = append(parts, pill) |
| 918 | 1100 | } |
| 919 | 1101 | |
| 1102 | + if pill, ok := m.agentsPill(); ok { | |
| 1103 | + parts = append(parts, pill) | |
| 1104 | + } | |
| 1105 | + | |
| 920 | 1106 | // Cache reads are reported separately from fresh input: the gap between |
| 921 | 1107 | // the two is the whole point of the caching this harness measures. |
| 922 | 1108 | u := m.usage |
| @@ -931,7 +1117,45 @@ func (m *model) status() string { | ||
| 931 | 1117 | parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", m.cost))) |
| 932 | 1118 | } |
| 933 | 1119 | } |
| 934 | - return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width)) | |
| 1120 | + line := strings.Join(parts, " ") | |
| 1121 | + m.recordPill(parts) | |
| 1122 | + return truncateStyled(line, maxInt(1, m.mainWidth())) | |
| 1123 | + } | |
| 1124 | + | |
| 1125 | + // agentsPill is the clickable button in the status line: how many agents are | |
| 1126 | + // live, or just the way in when none are. | |
| 1127 | + func (m *model) agentsPill() (string, bool) { | |
| 1128 | + if m.opts.Agents == nil { | |
| 1129 | + return "", false | |
| 1130 | + } | |
| 1131 | + label := "⏵ agents" | |
| 1132 | + style := styPill | |
| 1133 | + if n := m.bar.live; n > 0 { | |
| 1134 | + label = fmt.Sprintf("⏵ %d agents", n) | |
| 1135 | + style = styPill.Foreground(colCoral) | |
| 1136 | + } else if len(m.bar.rows) == 0 { | |
| 1137 | + return "", false | |
| 1138 | + } | |
| 1139 | + return style.Render(label), true | |
| 1140 | + } | |
| 1141 | + | |
| 1142 | + // recordPill remembers where the agents pill was drawn, so a click on it can | |
| 1143 | + // be told from a click on the rest of the status line. The status line is the | |
| 1144 | + // second line from the bottom: the mode line is below it. | |
| 1145 | + func (m *model) recordPill(parts []string) { | |
| 1146 | + m.pill = rowHit{} | |
| 1147 | + pill, ok := m.agentsPill() | |
| 1148 | + if !ok { | |
| 1149 | + return | |
| 1150 | + } | |
| 1151 | + var x int | |
| 1152 | + for _, p := range parts { | |
| 1153 | + if p == pill { | |
| 1154 | + break | |
| 1155 | + } | |
| 1156 | + x += lipgloss.Width(p) + 2 | |
| 1157 | + } | |
| 1158 | + m.pill = rowHit{y0: m.height - 2, y1: m.height - 2, x0: x, x1: x + lipgloss.Width(pill) - 1} | |
| 935 | 1159 | } |
| 936 | 1160 | |
| 937 | 1161 | // contextPill is the context gauge: how much the model read last turn against |
| @@ -968,19 +1192,23 @@ func (m *model) modeLine() string { | ||
| 968 | 1192 | if m.phase == phaseApproving { |
| 969 | 1193 | return "" |
| 970 | 1194 | } |
| 1195 | + agents := "" | |
| 1196 | + if m.opts.Agents != nil { | |
| 1197 | + agents = " · ctrl+b agents" | |
| 1198 | + } | |
| 971 | 1199 | if m.opts.Policy != nil && m.opts.Policy.YOLO { |
| 972 | - return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"), maxInt(1, m.width)) | |
| 1200 | + return truncateStyled(" "+styWarn.Render("⏵⏵ accept-all on")+styFaint.Render(" (shift+tab to cycle)"+agents), maxInt(1, m.mainWidth())) | |
| 973 | 1201 | } |
| 974 | - return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle) · /help for commands"), maxInt(1, m.width)) | |
| 1202 | + return truncateStyled(" "+styFaint.Render("⏵ approvals on (shift+tab to cycle)"+agents+" · /help"), maxInt(1, m.mainWidth())) | |
| 975 | 1203 | } |
| 976 | 1204 | |
| 977 | 1205 | func (m *model) approvalView() string { |
| 978 | 1206 | req := m.pending[0].req |
| 979 | - width := maxInt(20, m.width-2) | |
| 1207 | + width := maxInt(20, m.mainWidth()-2) | |
| 980 | 1208 | inner := width - 4 |
| 981 | 1209 | |
| 982 | 1210 | var b strings.Builder |
| 983 | - b.WriteString(styHeading.Render(req.Tool) + styDim.Render(" wants to "+req.Summary) + "\n") | |
| 1211 | + b.WriteString(m.approvalHead(req) + styDim.Render(" wants to "+req.Summary) + "\n") | |
| 984 | 1212 | if req.Detail != "" { |
| 985 | 1213 | b.WriteString("\n") |
| 986 | 1214 | lines := strings.Split(strings.TrimRight(req.Detail, "\n"), "\n") |
| @@ -1002,6 +1230,21 @@ func (m *model) approvalView() string { | ||
| 1002 | 1230 | return styModal.Width(width - 2).Render(strings.TrimRight(b.String(), "\n")) |
| 1003 | 1231 | } |
| 1004 | 1232 | |
| 1233 | + // approvalHead is the first thing the box says: the tool, and — when it is not | |
| 1234 | + // the session's own agent asking — which subagent wants it. Four agents can be | |
| 1235 | + // running, and "bash wants to run rm -rf build" is not a question anybody can | |
| 1236 | + // answer without knowing whose it is. | |
| 1237 | + func (m *model) approvalHead(req approval.Request) string { | |
| 1238 | + if req.Agent == "" { | |
| 1239 | + return styHeading.Render(req.Tool) | |
| 1240 | + } | |
| 1241 | + who := req.Agent | |
| 1242 | + if info, ok := m.agentInfo(req.Agent); ok { | |
| 1243 | + who += " (" + info.Preset + ")" | |
| 1244 | + } | |
| 1245 | + return styCoral.Render(who) + styDim.Render(" · ") + styHeading.Render(req.Tool) | |
| 1246 | + } | |
| 1247 | + | |
| 1005 | 1248 | // spread puts left at the left margin and right at the right, and gives up on |
| 1006 | 1249 | // the right-hand side rather than overflowing when they cannot both fit. |
| 1007 | 1250 | func spread(left, right string, width int) string { |
| @@ -1024,6 +1267,26 @@ func username() string { | ||
| 1024 | 1267 | |
| 1025 | 1268 | // addCost prices one piece of usage at the model in force and adds it to the |
| 1026 | 1269 | // running total, recording that those tokens have now been paid for. |
| 1270 | + // addCostAt bills a subagent's usage to the session at that agent's own model: | |
| 1271 | + // a haiku agent spawned from an opus session was not billed at opus rates. | |
| 1272 | + // | |
| 1273 | + // Only the money is pooled. The token counts in the status line stay the main | |
| 1274 | + // conversation's, because the gauge beside them is about the window this | |
| 1275 | + // conversation is filling — a child's tokens went into a different window, and | |
| 1276 | + // the sidebar reports them per agent. | |
| 1277 | + func (m *model) addCostAt(u llm.Usage, model string) { | |
| 1278 | + if u == (llm.Usage{}) { | |
| 1279 | + return | |
| 1280 | + } | |
| 1281 | + if model == "" { | |
| 1282 | + model = m.opts.Client.Model() | |
| 1283 | + } | |
| 1284 | + if c, known := u.Cost(model); known { | |
| 1285 | + m.cost += c | |
| 1286 | + m.costKnown = true | |
| 1287 | + } | |
| 1288 | + } | |
| 1289 | + | |
| 1027 | 1290 | func (m *model) addCost(u llm.Usage) { |
| 1028 | 1291 | if u == (llm.Usage{}) { |
| 1029 | 1292 | return |
| @@ -141,7 +141,7 @@ func (m *model) applyModel(id string) { | ||
| 141 | 141 | // modelView is the picker: a heading, where the list came from, and a row per |
| 142 | 142 | // model with what it is called, its id, and what it costs. |
| 143 | 143 | func (m *model) modelView() string { |
| 144 | - width := maxInt(1, m.width) | |
| 144 | + width := maxInt(1, m.mainWidth()) | |
| 145 | 145 | head := " " + styHeading.Render("Select model") |
| 146 | 146 | from := styDim.Render("from " + endpointLabel(m.opts.Client)) |
| 147 | 147 | |
| @@ -85,6 +85,10 @@ func TestApprovalKeysMapToDecisions(t *testing.T) { | ||
| 85 | 85 | for _, tc := range cases { |
| 86 | 86 | t.Run(tc.key, func(t *testing.T) { |
| 87 | 87 | m := newTestModel(t) |
| 88 | + // The session's own agent asks during a turn; answering returns | |
| 89 | + // to it. (A subagent can ask while you are idle — see | |
| 90 | + // TestAnsweringASubagentsApprovalWhileIdleLeavesYouIdle.) | |
| 91 | + m.phase = phaseRunning | |
| 88 | 92 | reply := pend(m, approval.Request{Tool: "bash", Rememberable: true}) |
| 89 | 93 | if m.phase != phaseApproving { |
| 90 | 94 | t.Fatalf("phase = %v, want phaseApproving", m.phase) |
| @@ -153,6 +157,10 @@ func TestUnknownKeyDuringApprovalDoesNothing(t *testing.T) { | ||
| 153 | 157 | // Queued approvals are answered one at a time, in order. |
| 154 | 158 | func TestApprovalsAreQueued(t *testing.T) { |
| 155 | 159 | m := newTestModel(t) |
| 160 | + // These are the session's own agent asking, which only happens during a | |
| 161 | + // turn. A subagent can ask while you are idle, and then answering puts | |
| 162 | + // you back to idle rather than into a turn that is not running. | |
| 163 | + m.phase = phaseRunning | |
| 156 | 164 | first := pend(m, approval.Request{Tool: "bash", Rememberable: true}) |
| 157 | 165 | second := pend(m, approval.Request{Tool: "edit_file", Rememberable: true}) |
| 158 | 166 | |
| @@ -200,6 +208,7 @@ func TestTurnDoneDeniesOutstandingApprovals(t *testing.T) { | ||
| 200 | 208 | |
| 201 | 209 | func TestCtrlCDuringApprovalDeniesAndInterrupts(t *testing.T) { |
| 202 | 210 | m := newTestModel(t) |
| 211 | + m.phase = phaseRunning | |
| 203 | 212 | cancelled := false |
| 204 | 213 | m.cancel = func() { cancelled = true } |
| 205 | 214 | reply := pend(m, approval.Request{Tool: "bash"}) |
| @@ -72,6 +72,11 @@ var ( | ||
| 72 | 72 | styBoxFocus lipgloss.Style |
| 73 | 73 | styBoxBlur lipgloss.Style |
| 74 | 74 | |
| 75 | + // The agents pane hangs off the right-hand edge, divided from the | |
| 76 | + // transcript by a rule rather than boxed in: a full border beside a | |
| 77 | + // transcript reads as a second window. | |
| 78 | + styBar lipgloss.Style | |
| 79 | + | |
| 75 | 80 | // The approval box is the one place the UI is allowed to shout: it is the |
| 76 | 81 | // only moment a human has to decide something. |
| 77 | 82 | styModal lipgloss.Style |
| @@ -12,6 +12,11 @@ | ||
| 12 | 12 | // tool_result comes back it streams a closing sentence and stops. That covers |
| 13 | 13 | // streaming, tool rendering, approve, deny, and interrupt. |
| 14 | 14 | // |
| 15 | + // With -spawn the script changes: the first turn asks for two subagents | |
| 16 | + // instead of a shell command, and every request carrying a subagent's system | |
| 17 | + // prompt is answered as that subagent — a short report, after a pause long | |
| 18 | + // enough to watch the sidebar fill in. | |
| 19 | + // | |
| 15 | 20 | // Flags: -addr to move it, -fail to make the command exit non-zero, -slow to |
| 16 | 21 | // stretch the stream out for watching the spinner and ctrl-c. |
| 17 | 22 | package main |
| @@ -24,28 +29,65 @@ import ( | ||
| 24 | 29 | "log" |
| 25 | 30 | "net/http" |
| 26 | 31 | "strings" |
| 32 | + "sync" | |
| 27 | 33 | "time" |
| 28 | 34 | ) |
| 29 | 35 | |
| 30 | 36 | var ( |
| 31 | - addr = flag.String("addr", "127.0.0.1:8899", "listen address") | |
| 32 | - slow = flag.Duration("slow", 0, "pause between streamed words, e.g. 150ms") | |
| 33 | - cmd = flag.String("cmd", "git status --short && go build ./...", "command the model asks to run") | |
| 37 | + addr = flag.String("addr", "127.0.0.1:8899", "listen address") | |
| 38 | + slow = flag.Duration("slow", 0, "pause between streamed words, e.g. 150ms") | |
| 39 | + cmd = flag.String("cmd", "git status --short && go build ./...", "command the model asks to run") | |
| 40 | + spawn = flag.Bool("spawn", false, "ask for two subagents on the first turn instead of a shell command") | |
| 41 | + work = flag.Duration("work", 4*time.Second, "how long a subagent takes to answer, with -spawn") | |
| 34 | 42 | ) |
| 35 | 43 | |
| 44 | + // listedOnce keeps the model-listing note to one line however many turns run. | |
| 45 | + var listedOnce sync.Once | |
| 46 | + | |
| 36 | 47 | func main() { |
| 37 | 48 | flag.Parse() |
| 38 | 49 | http.HandleFunc("/v1/messages", handle) |
| 50 | + http.HandleFunc("/v1/models", models) | |
| 39 | 51 | log.Printf("mock Messages API on http://%s", *addr) |
| 40 | 52 | log.Fatal(http.ListenAndServe(*addr, nil)) |
| 41 | 53 | } |
| 42 | 54 | |
| 55 | + // models answers GET /v1/models, which the model picker opens on and which the | |
| 56 | + // harness reads at startup to tell the agent what it may put a subagent on. | |
| 57 | + func models(w http.ResponseWriter, r *http.Request) { | |
| 58 | + w.Header().Set("Content-Type", "application/json") | |
| 59 | + json.NewEncoder(w).Encode(map[string]any{ | |
| 60 | + "data": []map[string]any{ | |
| 61 | + {"id": "claude-fable-5-1", "display_name": "Claude Fable 5.1", "created_at": "2026-06-01T00:00:00Z", "max_input_tokens": 1000000}, | |
| 62 | + {"id": "claude-opus-5", "display_name": "Claude Opus 5", "created_at": "2026-04-01T00:00:00Z", "max_input_tokens": 1000000}, | |
| 63 | + {"id": "claude-sonnet-5", "display_name": "Claude Sonnet 5", "created_at": "2026-02-01T00:00:00Z", "max_input_tokens": 1000000}, | |
| 64 | + {"id": "claude-haiku-4-5", "display_name": "Claude Haiku 4.5", "created_at": "2025-10-01T00:00:00Z", "max_input_tokens": 200000}, | |
| 65 | + }, | |
| 66 | + "has_more": false, | |
| 67 | + }) | |
| 68 | + } | |
| 69 | + | |
| 43 | 70 | func handle(w http.ResponseWriter, r *http.Request) { |
| 44 | 71 | body, _ := io.ReadAll(r.Body) |
| 45 | 72 | // A tool_result in the history means the harness ran (or refused) our |
| 46 | 73 | // command and fed the outcome back, so the next turn is the last one. |
| 47 | 74 | final := strings.Contains(string(body), "tool_result") |
| 48 | 75 | |
| 76 | + // Say once whether the agent was actually told what it may spawn on: the | |
| 77 | + // listing is assembled at startup and nothing else makes it visible. | |
| 78 | + if strings.Contains(string(body), "Models this endpoint serves") { | |
| 79 | + listedOnce.Do(func() { log.Print("the agent's system prompt carries the model listing") }) | |
| 80 | + } | |
| 81 | + | |
| 82 | + // A subagent announces itself in its system prompt. It gets one turn: a | |
| 83 | + // report, after a pause long enough to see it working in the sidebar. | |
| 84 | + if strings.Contains(string(body), "You are a subagent") { | |
| 85 | + time.Sleep(*work) | |
| 86 | + answer(w, "Found it: the policy is consulted in internal/agent/loop.go:281, "+ | |
| 87 | + "through Agent.authorizedExecute.") | |
| 88 | + return | |
| 89 | + } | |
| 90 | + | |
| 49 | 91 | w.Header().Set("Content-Type", "text/event-stream") |
| 50 | 92 | w.WriteHeader(http.StatusOK) |
| 51 | 93 | |
| @@ -61,6 +103,9 @@ func handle(w http.ResponseWriter, r *http.Request) { | ||
| 61 | 103 | if final { |
| 62 | 104 | text = "That is everything I can tell from here." |
| 63 | 105 | } |
| 106 | + if *spawn && !final { | |
| 107 | + text = "Two independent questions — I will farm them out." | |
| 108 | + } | |
| 64 | 109 | send(w, "content_block_start", map[string]any{ |
| 65 | 110 | "type": "content_block_start", "index": 0, |
| 66 | 111 | "content_block": map[string]any{"type": "text", "text": ""}, |
| @@ -75,18 +120,23 @@ func handle(w http.ResponseWriter, r *http.Request) { | ||
| 75 | 120 | send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) |
| 76 | 121 | |
| 77 | 122 | stop := "end_turn" |
| 78 | - if !final { | |
| 123 | + switch { | |
| 124 | + case final: | |
| 125 | + case *spawn: | |
| 79 | 126 | stop = "tool_use" |
| 80 | - send(w, "content_block_start", map[string]any{ | |
| 81 | - "type": "content_block_start", "index": 1, | |
| 82 | - "content_block": map[string]any{"type": "tool_use", "id": "toolu_1", "name": "bash"}, | |
| 127 | + // One on a model that takes no effort level and one that does, so a | |
| 128 | + // run exercises both halves of the answer. | |
| 129 | + toolCall(w, 1, "toolu_1", "spawn_agent", map[string]string{ | |
| 130 | + "preset": "explore", "task": "find where the approval policy is consulted", | |
| 131 | + "model": "claude-haiku-4-5", "effort": "low", | |
| 83 | 132 | }) |
| 84 | - args, _ := json.Marshal(map[string]string{"command": *cmd}) | |
| 85 | - send(w, "content_block_delta", map[string]any{ | |
| 86 | - "type": "content_block_delta", "index": 1, | |
| 87 | - "delta": map[string]any{"type": "input_json_delta", "partial_json": string(args)}, | |
| 133 | + toolCall(w, 2, "toolu_2", "spawn_agent", map[string]string{ | |
| 134 | + "preset": "plan", "task": "work out how subagent transcripts could survive a resume", | |
| 135 | + "model": "claude-fable-5-1", "effort": "low", | |
| 88 | 136 | }) |
| 89 | - send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 1}) | |
| 137 | + default: | |
| 138 | + stop = "tool_use" | |
| 139 | + toolCall(w, 1, "toolu_1", "bash", map[string]string{"command": *cmd}) | |
| 90 | 140 | } |
| 91 | 141 | |
| 92 | 142 | send(w, "message_delta", map[string]any{ |
| @@ -97,6 +147,49 @@ func handle(w http.ResponseWriter, r *http.Request) { | ||
| 97 | 147 | send(w, "message_stop", map[string]any{"type": "message_stop"}) |
| 98 | 148 | } |
| 99 | 149 | |
| 150 | + // toolCall streams one tool_use block, arguments and all. | |
| 151 | + func toolCall(w http.ResponseWriter, index int, id, name string, args map[string]string) { | |
| 152 | + send(w, "content_block_start", map[string]any{ | |
| 153 | + "type": "content_block_start", "index": index, | |
| 154 | + "content_block": map[string]any{"type": "tool_use", "id": id, "name": name}, | |
| 155 | + }) | |
| 156 | + raw, _ := json.Marshal(args) | |
| 157 | + send(w, "content_block_delta", map[string]any{ | |
| 158 | + "type": "content_block_delta", "index": index, | |
| 159 | + "delta": map[string]any{"type": "input_json_delta", "partial_json": string(raw)}, | |
| 160 | + }) | |
| 161 | + send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) | |
| 162 | + } | |
| 163 | + | |
| 164 | + // answer streams a whole turn that is nothing but text, which is what a | |
| 165 | + // subagent's report is. | |
| 166 | + func answer(w http.ResponseWriter, text string) { | |
| 167 | + w.Header().Set("Content-Type", "text/event-stream") | |
| 168 | + w.WriteHeader(http.StatusOK) | |
| 169 | + send(w, "message_start", map[string]any{ | |
| 170 | + "type": "message_start", | |
| 171 | + "message": map[string]any{ | |
| 172 | + "id": "msg_mock", "model": "claude-haiku-4-5", | |
| 173 | + "usage": map[string]any{"input_tokens": 900}, | |
| 174 | + }, | |
| 175 | + }) | |
| 176 | + send(w, "content_block_start", map[string]any{ | |
| 177 | + "type": "content_block_start", "index": 0, | |
| 178 | + "content_block": map[string]any{"type": "text", "text": ""}, | |
| 179 | + }) | |
| 180 | + send(w, "content_block_delta", map[string]any{ | |
| 181 | + "type": "content_block_delta", "index": 0, | |
| 182 | + "delta": map[string]any{"type": "text_delta", "text": text}, | |
| 183 | + }) | |
| 184 | + send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) | |
| 185 | + send(w, "message_delta", map[string]any{ | |
| 186 | + "type": "message_delta", | |
| 187 | + "delta": map[string]any{"stop_reason": "end_turn"}, | |
| 188 | + "usage": map[string]any{"output_tokens": 60}, | |
| 189 | + }) | |
| 190 | + send(w, "message_stop", map[string]any{"type": "message_stop"}) | |
| 191 | + } | |
| 192 | + | |
| 100 | 193 | func send(w http.ResponseWriter, event string, data any) { |
| 101 | 194 | b, _ := json.Marshal(data) |
| 102 | 195 | fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b) |
| @@ -178,6 +178,11 @@ func applyTheme(name string) { | ||
| 178 | 178 | BorderForeground(colFaint). |
| 179 | 179 | Padding(0, 1) |
| 180 | 180 | |
| 181 | + styBar = lipgloss.NewStyle(). | |
| 182 | + Border(lipgloss.NormalBorder(), false, false, false, true). | |
| 183 | + BorderForeground(colFaint). | |
| 184 | + Padding(0, 1) | |
| 185 | + | |
| 181 | 186 | styModal = lipgloss.NewStyle(). |
| 182 | 187 | Border(lipgloss.RoundedBorder()). |
| 183 | 188 | BorderForeground(colCoral). |