From 9423c01f67265a98ab8b9bed447b899b02a3ffec Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sat, 10 Oct 2026 20:32:11 -0400 Subject: [PATCH 01/24] feat: add dedicated subagent view to TUI Adds OpenSpec change tui-subagents-panel with proposal, design, specs, and tasks for capturing the stream namespace in callProvider, routing subagent events to a dedicated store, and rendering a SUBAGENTS panel. Closes #1360 --- .../tui-subagents-panel/.openspec.yaml | 2 + .../changes/tui-subagents-panel/design.md | 41 +++++++++ .../changes/tui-subagents-panel/proposal.md | 35 ++++++++ .../specs/tui-streaming/spec.md | 24 ++++++ .../specs/tui-subagents-panel/spec.md | 83 +++++++++++++++++++ openspec/changes/tui-subagents-panel/tasks.md | 25 ++++++ 6 files changed, 210 insertions(+) create mode 100644 openspec/changes/tui-subagents-panel/.openspec.yaml create mode 100644 openspec/changes/tui-subagents-panel/design.md create mode 100644 openspec/changes/tui-subagents-panel/proposal.md create mode 100644 openspec/changes/tui-subagents-panel/specs/tui-streaming/spec.md create mode 100644 openspec/changes/tui-subagents-panel/specs/tui-subagents-panel/spec.md create mode 100644 openspec/changes/tui-subagents-panel/tasks.md diff --git a/openspec/changes/tui-subagents-panel/.openspec.yaml b/openspec/changes/tui-subagents-panel/.openspec.yaml new file mode 100644 index 00000000..a3692196 --- /dev/null +++ b/openspec/changes/tui-subagents-panel/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/tui-subagents-panel/design.md b/openspec/changes/tui-subagents-panel/design.md new file mode 100644 index 00000000..4d0cf618 --- /dev/null +++ b/openspec/changes/tui-subagents-panel/design.md @@ -0,0 +1,41 @@ +## Context + +The TUI currently renders all stream events into a single assistant bubble. When the orchestrator delegates via the `task` tool, the subagent's reasoning and message segments are flattened into the same bubble as the orchestrator's output because the stream namespace is discarded in `callProvider` (`index.js`). With `subgraphs: true`, `agent.stream()` yields `[namespace, mode, payload]` where the root graph has an empty namespace and each subagent subgraph has a populated namespace (e.g. `["task:"]`). + +## Goals / Non-Goals + +**Goals:** +- Capture the stream namespace in `callProvider` so subagent activity is distinguishable from the root graph. +- Emit dedicated `subagent_*` event types carrying the namespace for subagent reasoning, message, and tool activity. +- Route `subagent_*` events to a dedicated subagent store in the TUI, not the orchestrator's assistant bubble. +- Add a `SUBAGENTS` panel that renders each subagent's reasoning/message segments and tool calls. +- Wire the panel into the App render switch. + +**Non-Goals:** +- Not changing the orchestrator's conversation bubble behavior for root-graph events. +- Not adding new npm packages or dependencies. +- Not altering subagent delegation logic in `src/agent/deepAgents.js`. + +## Decisions + +### Decision 1: Emit subagent event types at the source (`callProvider`) +The namespace is only available at the `agent.stream()` iteration site in `callProvider`. By the time events reach the TUI handler, the namespace is lost. Therefore, the capture must happen in `callProvider`. When `namespace.length > 0`, emit `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, `subagent_tool_end` carrying the namespace. The root graph (empty namespace) continues to emit the existing `message`/`reasoning`/`on_tool_*` event types. + +**Alternatives considered:** Routing the namespace through the existing event types (e.g. adding a `namespace` field to `message`/`reasoning`). Rejected because it would require the TUI handler to branch on a field for every event and would not cleanly separate subagent tool activity from orchestrator tool activity. + +### Decision 2: Route subagent events to a dedicated store in the TUI +In `createStreamingHandler` (`src/tui/conversationArea.js`), handle the new `subagent_*` event types by routing them to a dedicated subagent store keyed by namespace, not to `streamingMsgIdRef.current`. This keeps the orchestrator's assistant bubble clean while capturing subagent activity separately. + +**Alternatives considered:** Folding subagent events into the same bubble with a namespace prefix. Rejected because it defeats the purpose of separating subagent activity from the orchestrator's conversation. + +### Decision 3: Dedicated `SUBAGENTS` panel +Add `SUBAGENTS` to `PANELS` and register it in `getPanelOrder()`. Create `src/tui/subagentsPanel.js` rendering each subagent's reasoning/message segments and tool calls, reusing `MessageBubble` or a variant. This is consistent with the existing panel system (SKILLS/MEMORIES/SETTINGS/SESSIONS/PROJECTS). + +**Alternatives considered:** Inline collapsible sections in the conversation (busier, harder to scan); a second always-visible message list (more complex layout). The dedicated panel provides clean separation and is consistent with the existing panel architecture. + +## Risks / Trade-offs + +- [Subagent events emitted before the subagent store is initialized] → The store is created lazily on first subagent event; events for unknown namespaces create a new entry. +- [Multiple subagents in one turn] → The store is keyed by namespace, so each subagent gets its own entry. +- [Nested subagent delegation] → Each subgraph has a distinct namespace, so nested subagents are captured separately. +- [Subagent tool calls with errors] → `subagent_tool_end` carries the namespace; the panel renders tool activity per subagent. diff --git a/openspec/changes/tui-subagents-panel/proposal.md b/openspec/changes/tui-subagents-panel/proposal.md new file mode 100644 index 00000000..555885ad --- /dev/null +++ b/openspec/changes/tui-subagents-panel/proposal.md @@ -0,0 +1,35 @@ +## Why + +When the orchestrator delegates work via the `task` tool, the subagent's reasoning and message segments are currently flattened into the same assistant bubble as the orchestrator's own output. The stream namespace that identifies which subgraph produced each event is discarded in `callProvider`, so there is no way to distinguish what the orchestrator said from what each subagent did. This makes multi-agent runs hard to follow and audit. + +## What Changes + +- Capture the stream namespace in `callProvider` (`index.js`) so subagent subgraphs can be distinguished from the root graph. +- When the namespace is populated (subagent activity), emit new event types (`subagent_message`, `subagent_reasoning`, `subagent_tool_start`, `subagent_tool_end`) carrying the namespace, instead of the flat `message`/`reasoning`/`on_tool_*` events. +- Keep the root graph (empty namespace) emitting the existing event types unchanged. +- Route `subagent_*` events in the TUI streaming handler to a dedicated subagent store keyed by namespace, not to the orchestrator's assistant bubble. +- Add a `SUBAGENTS` panel to the TUI that renders each subagent's reasoning/message segments and tool calls. +- Wire the `SUBAGENTS` panel into the App render switch. + +## Capabilities + +### New Capabilities +- `tui-subagents-panel`: A dedicated TUI panel that captures and renders each subagent's reasoning and message segments separately from the orchestrator's conversation, including subagent tool calls. + +### Modified Capabilities +- `tui-streaming`: The streaming handler must route subagent events to a dedicated subagent store instead of the orchestrator's assistant bubble. + +## Impact + +- `index.js` — `callProvider` captures the stream namespace and emits subagent event types. +- `src/tui/conversationArea.js` — `createStreamingHandler` routes `subagent_*` events to a subagent store. +- `src/tui/panels.js` — `PANELS` enum gains `SUBAGENTS`; `getPanelOrder()` registers it. +- `src/tui/subagentsPanel.js` — new panel rendering subagent activity. +- `src/tui/app.js` — render switch gains a `SUBAGENTS` branch. +- `tests/unit/tui/subagentsPanel.test.js` — new unit tests. + +## Non-goals + +- Not changing the orchestrator's conversation bubble behavior for root-graph events. +- Not adding new npm packages or dependencies. +- Not altering subagent delegation logic in `src/agent/deepAgents.js`. diff --git a/openspec/changes/tui-subagents-panel/specs/tui-streaming/spec.md b/openspec/changes/tui-subagents-panel/specs/tui-streaming/spec.md new file mode 100644 index 00000000..c79a654a --- /dev/null +++ b/openspec/changes/tui-subagents-panel/specs/tui-streaming/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Streaming handler SHALL route subagent events to a subagent store +The `createStreamingHandler` function SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, instead of updating `streamingMsgIdRef.current`. The orchestrator's assistant bubble SHALL continue to receive the existing `message`, `reasoning`, `on_tool_start`, `on_tool_end`, `tool`, and `on_tool_error` event types. + +#### Scenario: Subagent message routed to subagent store +- **WHEN** a `subagent_message` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent reasoning routed to subagent store +- **WHEN** a `subagent_reasoning` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent tool start routed to subagent store +- **WHEN** a `subagent_tool_start` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent tool end routed to subagent store +- **WHEN** a `subagent_tool_end` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Root graph events continue to update assistant bubble +- **WHEN** a `message` or `reasoning` event is received without a subagent namespace +- **THEN** the event continues to update `streamingMsgIdRef.current` diff --git a/openspec/changes/tui-subagents-panel/specs/tui-subagents-panel/spec.md b/openspec/changes/tui-subagents-panel/specs/tui-subagents-panel/spec.md new file mode 100644 index 00000000..75a2ede3 --- /dev/null +++ b/openspec/changes/tui-subagents-panel/specs/tui-subagents-panel/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: callProvider SHALL capture the stream namespace +`callProvider` in `index.js` SHALL capture the `_namespace` from `agent.stream()` when iterating with `subgraphs: true`. When `namespace.length > 0`, it SHALL emit subagent event types carrying the namespace instead of the flat `message`/`reasoning`/`on_tool_*` events. The root graph (empty namespace) SHALL continue emitting the existing event types. + +#### Scenario: Root graph emits existing event types +- **WHEN** `callProvider` iterates a stream event with an empty namespace (`[]`) +- **THEN** it emits the existing `message`, `reasoning`, `on_tool_start`, or `on_tool_end` event types + +#### Scenario: Subagent emits subagent_message +- **WHEN** `callProvider` iterates a stream event with a populated namespace (e.g. `["task:"]`) and mode `messages` with message text +- **THEN** it emits a `subagent_message` event carrying the namespace and text + +#### Scenario: Subagent emits subagent_reasoning +- **WHEN** `callProvider` iterates a stream event with a populated namespace and reasoning content +- **THEN** it emits a `subagent_reasoning` event carrying the namespace and reasoning text + +#### Scenario: Subagent emits subagent_tool_start +- **WHEN** `callProvider` iterates a stream event with a populated namespace and mode `tools` with `on_tool_start` +- **THEN** it emits a `subagent_tool_start` event carrying the namespace and tool name + +#### Scenario: Subagent emits subagent_tool_end +- **WHEN** `callProvider` iterates a stream event with a populated namespace and mode `tools` with `on_tool_end` +- **THEN** it emits a `subagent_tool_end` event carrying the namespace and tool name + +### Requirement: Subagent events SHALL carry the namespace +Each `subagent_*` event emitted by `callProvider` SHALL include the namespace that identifies the subgraph that produced it. + +#### Scenario: Event carries namespace +- **WHEN** a `subagent_message` event is emitted +- **THEN** the event includes a `namespace` field equal to the populated namespace array + +### Requirement: TUI streaming handler SHALL route subagent events to a subagent store +`createStreamingHandler` in `src/tui/conversationArea.js` SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, not to `streamingMsgIdRef.current`. + +#### Scenario: Subagent message routed to subagent store +- **WHEN** a `subagent_message` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent reasoning routed to subagent store +- **WHEN** a `subagent_reasoning` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent tool start routed to subagent store +- **WHEN** a `subagent_tool_start` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent tool end routed to subagent store +- **WHEN** a `subagent_tool_end` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +### Requirement: SUBAGENTS panel SHALL be registered in PANELS +`PANELS` in `src/tui/panels.js` SHALL include a `SUBAGENTS` entry, and `getPanelOrder()` SHALL include it in the tab navigation order. + +#### Scenario: PANELS includes SUBAGENTS +- **WHEN** `PANELS` is inspected +- **THEN** it includes a `SUBAGENTS` entry + +#### Scenario: getPanelOrder includes SUBAGENTS +- **WHEN** `getPanelOrder()` is called +- **THEN** the returned order array includes `PANELS.SUBAGENTS` + +### Requirement: SUBAGENTS panel SHALL render subagent activity +`src/tui/subagentsPanel.js` SHALL render each subagent's reasoning/message segments and tool calls, keyed by namespace. + +#### Scenario: Panel renders subagent message segments +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's message segments +- **THEN** the panel renders the subagent's message content + +#### Scenario: Panel renders subagent reasoning segments +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's reasoning segments +- **THEN** the panel renders the subagent's reasoning content + +#### Scenario: Panel renders subagent tool calls +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's tool calls +- **THEN** the panel renders the subagent's tool call activity + +### Requirement: App SHALL render the SUBAGENTS panel +The App render switch in `src/tui/app.js` SHALL include a `currentView === PANELS.SUBAGENTS` branch that renders the subagents panel, passing the subagent store. + +#### Scenario: App renders SUBAGENTS panel +- **WHEN** `currentView === PANELS.SUBAGENTS` +- **THEN** the App renders the subagents panel component diff --git a/openspec/changes/tui-subagents-panel/tasks.md b/openspec/changes/tui-subagents-panel/tasks.md new file mode 100644 index 00000000..3f8ea2f1 --- /dev/null +++ b/openspec/changes/tui-subagents-panel/tasks.md @@ -0,0 +1,25 @@ +## 1. Capture the namespace in callProvider + +- [ ] 1.1 In `index.js` `callProvider`, capture `_namespace` from the `agent.stream()` iteration and emit `subagent_message`/`subagent_reasoning`/`subagent_tool_start`/`subagent_tool_end` events carrying the namespace when `namespace.length > 0` +- [ ] 1.2 Keep the root graph (empty namespace) emitting the existing `message`/`reasoning`/`on_tool_start`/`on_tool_end` event types unchanged + +## 2. Route subagent events in the TUI + +- [ ] 2.1 In `src/tui/conversationArea.js` `createStreamingHandler`, handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, `subagent_tool_end` by routing them to a dedicated subagent store keyed by namespace, not `streamingMsgIdRef.current` + +## 3. Add the SUBAGENTS panel + +- [ ] 3.1 Add `SUBAGENTS` to `PANELS` in `src/tui/panels.js` and register it in `getPanelOrder()` +- [ ] 3.2 Create `src/tui/subagentsPanel.js` rendering each subagent's reasoning/message segments and tool calls, reusing `MessageBubble` or a variant + +## 4. Wire the panel into App + +- [ ] 4.1 Add a `currentView === PANELS.SUBAGENTS` branch in the App render switch (`src/tui/app.js`) rendering the new panel, passing the subagent store + +## 5. Write unit tests + +- [ ] 5.1 Add `tests/unit/tui/subagentsPanel.test.js` covering namespace discrimination, event routing, and tool-call redirection + +## 6. Verify + +- [ ] 6.1 Run `npm run test`, `npm run lint`, and `npm run coverage` to confirm everything passes From c5dc6e419a67633b972a38e1fcd7446768613d61 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sat, 10 Oct 2026 20:54:50 -0400 Subject: [PATCH 02/24] feat: add dedicated subagent view to TUI Captures the stream namespace in callProvider so subagent subgraphs emit distinct subagent_* event types, routes them to a dedicated subagent store in the TUI streaming handler, and renders a SUBAGENTS panel. - index.js: emit subagent_message/subagent_reasoning/subagent_tool/ subagent_tool_start/subagent_tool_end when namespace.length > 0 - src/tui/subagentStore.js: pure store for folding subagent events - src/tui/subagentsPanel.js: renders subagent segments and tool calls - src/tui/conversationArea.js: route subagent events to the store - src/tui/panels.js: add SUBAGENTS panel - src/tui/app.js: wire SUBAGENTS branch - tests: subagentsPanel.test.js + update panels/tui tests Closes #1360 --- coverage.txt | 18 +- index.js | 54 ++++- openspec/changes/tui-subagents-panel/tasks.md | 16 +- src/tui/app.js | 7 + src/tui/conversationArea.js | 26 +++ src/tui/panels.js | 2 + src/tui/subagentStore.js | 116 ++++++++++ src/tui/subagentsPanel.js | 88 ++++++++ tests/unit/tui.test.js | 8 +- tests/unit/tui/panels.test.js | 11 +- tests/unit/tui/subagentsPanel.test.js | 207 ++++++++++++++++++ 11 files changed, 521 insertions(+), 32 deletions(-) create mode 100644 src/tui/subagentStore.js create mode 100644 src/tui/subagentsPanel.js create mode 100644 tests/unit/tui/subagentsPanel.test.js diff --git a/coverage.txt b/coverage.txt index 1838c1fc..df29ecc5 100644 --- a/coverage.txt +++ b/coverage.txt @@ -30,7 +30,7 @@ ℹ tui.js | 100.00 | 100.00 | 100.00 | ℹ vector.js | 100.00 | 100.00 | 100.00 | ℹ memory | | | | -ℹ context.js | 97.44 | 78.79 | 100.00 | 114-116 +ℹ context.js | 95.73 | 69.70 | 100.00 | 112-116 ℹ expireEphemeralMemories.js | 93.15 | 73.68 | 100.00 | 25-27 68-69 ℹ gc.js | 100.00 | 100.00 | 100.00 | ℹ index.js | 100.00 | 100.00 | 100.00 | @@ -70,10 +70,10 @@ ℹ logger.js | 79.53 | 66.67 | 100.00 | 87-98 123-132 137 139-141 164-166 176-180 203-211 217-222 239 273-274 276-278 297-299 307-309 317-319 327-329 337-339 ℹ skills | | | | ℹ agentMapper.js | 83.33 | 62.50 | 100.00 | 12-13 23-25 -ℹ discoverer.js | 99.10 | 92.31 | 100.00 | 174-175 -ℹ registry.js | 87.77 | 72.97 | 77.78 | 104-105 153-162 173-175 201-207 215-219 227-231 264-265 +ℹ discoverer.js | 99.10 | 91.94 | 100.00 | 174-175 +ℹ registry.js | 87.77 | 61.29 | 77.78 | 104-105 153-162 173-175 201-207 215-219 227-231 264-265 ℹ types.js | 100.00 | 100.00 | 100.00 | -ℹ validator.js | 100.00 | 100.00 | 80.00 | +ℹ validator.js | 96.35 | 97.50 | 80.00 | 130-134 ℹ stream | | | | ℹ transformers | | | | ℹ index.js | 100.00 | 100.00 | 100.00 | @@ -159,7 +159,7 @@ ℹ reflection | | | | ℹ index.js | 95.18 | 82.00 | 91.67 | 58-62 127-128 151-152 206-207 ℹ sampling | | | | -ℹ index.js | 94.97 | 81.82 | 80.00 | 27 180-188 +ℹ index.js | 94.97 | 84.38 | 80.00 | 27 180-188 ℹ scanAgents | | | | ℹ index.js | 100.00 | 80.00 | 100.00 | ℹ session | | | | @@ -182,7 +182,7 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 44.33 | 57.14 | 21.21 | 120-126 138-142 152-162 170-174 182-185 206-231 238-247 256-281 297-313 323-324 331-332 339-384 388-507 520-524 527-537 540-553 569-574 576-580 582-586 588-593 595-602 609-619 626-654 679-680 +ℹ app.js | 44.17 | 55.81 | 21.21 | 121-127 139-143 153-163 171-175 183-186 207-232 239-248 257-282 298-314 324-325 332-333 340-385 389-508 521-525 528-538 541-554 570-575 577-581 583-587 589-594 596-602 604-609 616-626 633-661 686-687 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | ℹ commandParser.js | 96.12 | 90.54 | 90.00 | 89-96 118 @@ -193,7 +193,7 @@ ℹ inputPanel.js | 100.00 | 100.00 | 100.00 | ℹ markdownText.js | 95.12 | 86.02 | 96.00 | 69 199-205 220-228 231-233 ℹ memoryPanel.js | 13.04 | 100.00 | 0.00 | 14-16 24-32 43-230 -ℹ messageBubble.js | 85.64 | 85.23 | 68.57 | 292-326 343-348 396-401 408-436 476-478 499-502 514-517 +ℹ messageBubble.js | 88.61 | 86.36 | 71.43 | 292-326 343-348 396-401 409-422 499-502 514-517 ℹ messageList.js | 64.86 | 81.40 | 29.55 | 157-162 167-171 176-179 210-243 253-325 334 341-346 392 404-414 429-454 464-466 473-475 483 491 499 508-512 521-528 536 544 552 562 571-577 586 594-599 621-623 641-645 655-656 680-694 707-744 780-788 ℹ messages.js | 100.00 | 100.00 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | @@ -205,6 +205,8 @@ ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 +ℹ subagentsPanel.js | 100.00 | 75.00 | 100.00 | +ℹ subagentStore.js | 100.00 | 70.37 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | ℹ chunker.js | 98.81 | 95.45 | 100.00 | 159-160 @@ -215,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 90.80 | 88.97 | 86.39 | +ℹ all files | 90.89 | 88.74 | 86.55 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/index.js b/index.js index 84470807..7dc29c76 100644 --- a/index.js +++ b/index.js @@ -225,12 +225,21 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, messages: [new HumanMessage(message)], }; - for await (const [_namespace, mode, payload] of await agent.stream(input, { + for await (const [namespace, mode, payload] of await agent.stream(input, { ...config, ...options, streamMode: ["messages", "tools"], subgraphs: true, })) { + // With `subgraphs: true`, the root graph yields an empty namespace `[]` + // and each subagent subgraph yields a populated namespace (e.g. + // `["task:"]`). `namespace.length > 0` discriminates subagent + // activity from the orchestrator's own output. Subagent events are + // emitted with a `subagent_` prefix and carry the namespace so the TUI + // can route them to a dedicated SUBAGENTS store instead of folding them + // into the orchestrator's assistant bubble. + const isSubagent = namespace.length > 0; + if (mode === "messages") { const [msg] = payload; const msgType = msg?._getType ? msg._getType() : msg?.type; @@ -243,16 +252,27 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, // is false, tool text is skipped entirely. if (msgType === "tool") { if (showToolResults !== false && streamingCallback) { - streamingCallback({ type: "tool", text, name: msg?.name }); + streamingCallback({ + type: isSubagent ? "subagent_tool" : "tool", + text, + name: msg?.name, + namespace, + }); } continue; } - collectedContent += text; + if (!isSubagent) { + collectedContent += text; + } if (streamingCallback) { - streamingCallback({ type: "message", text }); + streamingCallback({ + type: isSubagent ? "subagent_message" : "message", + text, + namespace, + }); } } @@ -260,9 +280,15 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, const reasoningContent = msg?.additional_kwargs?.reasoning_content ?? msg?.additional_kwargs?.reasoning; if (reasoningContent) { - collectedReasoning += reasoningContent; + if (!isSubagent) { + collectedReasoning += reasoningContent; + } if (streamingCallback) { - streamingCallback({ type: "reasoning", text: reasoningContent }); + streamingCallback({ + type: isSubagent ? "subagent_reasoning" : "reasoning", + text: reasoningContent, + namespace, + }); } } @@ -270,9 +296,15 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, if (Array.isArray(msg?.content)) { for (const block of msg.content) { if (block?.type === "reasoning" && block.reasoning) { - collectedReasoning += block.reasoning; + if (!isSubagent) { + collectedReasoning += block.reasoning; + } if (streamingCallback) { - streamingCallback({ type: "reasoning", text: block.reasoning }); + streamingCallback({ + type: isSubagent ? "subagent_reasoning" : "reasoning", + text: block.reasoning, + namespace, + }); } } } @@ -281,16 +313,18 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, // payload is StreamToolsOutput with event, name, etc. if (payload?.event === "on_tool_start") { streamingCallback({ - type: "on_tool_start", + type: isSubagent ? "subagent_tool_start" : "on_tool_start", name: payload.name, data: { input: payload.input }, + namespace, }); } if (payload?.event === "on_tool_end") { streamingCallback({ - type: "on_tool_end", + type: isSubagent ? "subagent_tool_end" : "on_tool_end", name: payload.name, data: { output: payload.output }, + namespace, }); } } diff --git a/openspec/changes/tui-subagents-panel/tasks.md b/openspec/changes/tui-subagents-panel/tasks.md index 3f8ea2f1..123c58e6 100644 --- a/openspec/changes/tui-subagents-panel/tasks.md +++ b/openspec/changes/tui-subagents-panel/tasks.md @@ -1,25 +1,25 @@ ## 1. Capture the namespace in callProvider -- [ ] 1.1 In `index.js` `callProvider`, capture `_namespace` from the `agent.stream()` iteration and emit `subagent_message`/`subagent_reasoning`/`subagent_tool_start`/`subagent_tool_end` events carrying the namespace when `namespace.length > 0` -- [ ] 1.2 Keep the root graph (empty namespace) emitting the existing `message`/`reasoning`/`on_tool_start`/`on_tool_end` event types unchanged +- [x] 1.1 In `index.js` `callProvider`, capture `_namespace` from the `agent.stream()` iteration and emit `subagent_message`/`subagent_reasoning`/`subagent_tool_start`/`subagent_tool_end` events carrying the namespace when `namespace.length > 0` +- [x] 1.2 Keep the root graph (empty namespace) emitting the existing `message`/`reasoning`/`on_tool_start`/`on_tool_end` event types unchanged ## 2. Route subagent events in the TUI -- [ ] 2.1 In `src/tui/conversationArea.js` `createStreamingHandler`, handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, `subagent_tool_end` by routing them to a dedicated subagent store keyed by namespace, not `streamingMsgIdRef.current` +- [x] 2.1 In `src/tui/conversationArea.js` `createStreamingHandler`, handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, `subagent_tool_end` by routing them to a dedicated subagent store keyed by namespace, not `streamingMsgIdRef.current` ## 3. Add the SUBAGENTS panel -- [ ] 3.1 Add `SUBAGENTS` to `PANELS` in `src/tui/panels.js` and register it in `getPanelOrder()` -- [ ] 3.2 Create `src/tui/subagentsPanel.js` rendering each subagent's reasoning/message segments and tool calls, reusing `MessageBubble` or a variant +- [x] 3.1 Add `SUBAGENTS` to `PANELS` in `src/tui/panels.js` and register it in `getPanelOrder()` +- [x] 3.2 Create `src/tui/subagentsPanel.js` rendering each subagent's reasoning/message segments and tool calls, reusing `MessageBubble` or a variant ## 4. Wire the panel into App -- [ ] 4.1 Add a `currentView === PANELS.SUBAGENTS` branch in the App render switch (`src/tui/app.js`) rendering the new panel, passing the subagent store +- [x] 4.1 Add a `currentView === PANELS.SUBAGENTS` branch in the App render switch (`src/tui/app.js`) rendering the new panel, passing the subagent store ## 5. Write unit tests -- [ ] 5.1 Add `tests/unit/tui/subagentsPanel.test.js` covering namespace discrimination, event routing, and tool-call redirection +- [x] 5.1 Add `tests/unit/tui/subagentsPanel.test.js` covering namespace discrimination, event routing, and tool-call redirection ## 6. Verify -- [ ] 6.1 Run `npm run test`, `npm run lint`, and `npm run coverage` to confirm everything passes +- [x] 6.1 Run `npm run test`, `npm run lint`, and `npm run coverage` to confirm everything passes diff --git a/src/tui/app.js b/src/tui/app.js index 1e6f0a71..7e20e577 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -11,6 +11,7 @@ import { MemoryPanel } from "./memoryPanel.js"; import { SettingsPanel } from "./settingsPanel.js"; import { SessionsPanel } from "./sessionsPanel.js"; import { ProjectsPanel } from "./projectsPanel.js"; +import { SubagentsPanel } from "./subagentsPanel.js"; import { getActiveProviderConfig } from "../provider/index.js"; import { useMouseScroll, scaleScrollDelta } from "./useMouseScroll.js"; import { buildLayout, extractSelection, mapCoordToChar } from "./selectionLayout.js"; @@ -599,6 +600,12 @@ function App({ onClearProject: handleClearProject, activeView: currentView, }); + } else if (currentView === PANELS.SUBAGENTS) { + panelComponent = React.createElement(SubagentsPanel, { + subagentStore: conversationAreaRef.current?.getSubagentStore?.() || {}, + onViewChange: handleViewChange, + activeView: currentView, + }); } return React.createElement( diff --git a/src/tui/conversationArea.js b/src/tui/conversationArea.js index 2cb02852..b945f26b 100644 --- a/src/tui/conversationArea.js +++ b/src/tui/conversationArea.js @@ -14,6 +14,7 @@ import { createSession } from "../session/factory.js"; import { setConfigValue } from "../config/loader.js"; import { isAvailable, getGcCalls } from "../memory/gc.js"; import { logger } from "../shared/logger.js"; +import { createSubagentStore, updateSubagentStore } from "./subagentStore.js"; /** /** @@ -106,6 +107,10 @@ const ConversationArea = forwardRef(function ConversationArea( const toolMessageTokensRef = useRef(0); const contextUpdateTimerRef = useRef(null); const pendingContextRef = useRef({ content: "" }); + // Subagent store — accumulates subagent reasoning/message segments and tool + // calls keyed by stream namespace. Populated by `createStreamingHandler` + // when it receives `subagent_*` events, and rendered by the SUBAGENTS panel. + const subagentStoreRef = useRef(createSubagentStore()); const skillList = registry ? registry.list() : []; const parser = new CommandParser(); @@ -648,6 +653,21 @@ const ConversationArea = forwardRef(function ConversationArea( return async (event) => { if (shouldAbort()) return; try { + // Subagent events are routed to the dedicated subagent store + // keyed by namespace, never to the orchestrator's assistant + // bubble. This keeps each subagent's reasoning/message + // segments and tool calls separate from the main conversation. + if ( + event.type === "subagent_message" || + event.type === "subagent_reasoning" || + event.type === "subagent_tool" || + event.type === "subagent_tool_start" || + event.type === "subagent_tool_end" + ) { + subagentStoreRef.current = updateSubagentStore(subagentStoreRef.current, event); + return; + } + const currentEvents = messageListRef.current?.getMessageData(streamingMsgIdRef.current)?.events || []; messageListRef.current?.updateMessage(streamingMsgIdRef.current, { @@ -821,6 +841,12 @@ const ConversationArea = forwardRef(function ConversationArea( newSession: handleNewSession, clear: () => messageListRef.current?.clear(), addMessage, + /** + * Get the current subagent store. The SUBAGENTS panel reads this to + * render each subagent's reasoning/message segments and tool calls. + * @returns {Object} The subagent store keyed by namespace. + */ + getSubagentStore: () => subagentStoreRef.current, /** * Bulk-load conversation exchanges into the message list. * Clears existing messages and repopulates from the array. diff --git a/src/tui/panels.js b/src/tui/panels.js index b8101ba6..358d9f2c 100644 --- a/src/tui/panels.js +++ b/src/tui/panels.js @@ -8,6 +8,7 @@ export const PANELS = Object.freeze({ SETTINGS: "settings", SESSIONS: "sessions", PROJECTS: "projects", + SUBAGENTS: "subagents", }); /** @@ -22,6 +23,7 @@ export function getPanelOrder() { PANELS.SETTINGS, PANELS.SESSIONS, PANELS.PROJECTS, + PANELS.SUBAGENTS, ]; } diff --git a/src/tui/subagentStore.js b/src/tui/subagentStore.js new file mode 100644 index 00000000..f5091b10 --- /dev/null +++ b/src/tui/subagentStore.js @@ -0,0 +1,116 @@ +/** + * Subagent store — accumulates subagent reasoning/message segments and tool + * calls keyed by stream namespace. + * + * The orchestrator's `callProvider` emits `subagent_*` events carrying a + * namespace (e.g. `["task:"]`) for each subagent subgraph. This module + * provides pure functions to fold those events into a store that the + * SUBAGENTS panel renders. Keeping the logic pure makes it trivially testable + * without a React render. + */ + +/** + * Create an empty subagent store. + * @returns {Object} An empty store keyed by namespace string. + */ +export function createSubagentStore() { + return {}; +} + +/** + * Get the namespace key for a subagent event. + * @param {string[]} namespace - The stream namespace array (e.g. `["task:"]`) + * @returns {string} The namespace joined into a stable key. + */ +export function namespaceKey(namespace) { + return Array.isArray(namespace) ? namespace.join("/") : ""; +} + +/** + * Ensure a subagent entry exists for the given namespace. + * @param {Object} store - The subagent store. + * @param {string} key - The namespace key. + * @returns {Object} The subagent entry (created if absent). + */ +function ensureEntry(store, key) { + if (!store[key]) { + store[key] = { + namespace: key, + segments: [], + toolCalls: [], + }; + } + return store[key]; +} + +/** + * Fold a single subagent event into the store. Returns a new store object so + * React state updates are immutable. + * @param {Object} store - The current subagent store. + * @param {Object} event - A `subagent_*` event from `callProvider`. + * @returns {Object} The updated store. + */ +export function updateSubagentStore(store, event) { + const key = namespaceKey(event.namespace); + if (!key) return store; + + const next = { ...store }; + const entry = ensureEntry(next, key); + const segments = [...entry.segments]; + const toolCalls = [...entry.toolCalls]; + + switch (event.type) { + case "subagent_message": { + const text = event.data?.text || event.text || ""; + if (text) { + segments.push({ type: "message", content: text }); + } + break; + } + case "subagent_reasoning": { + const text = event.data?.text || event.text || ""; + if (text) { + segments.push({ type: "reasoning", content: text }); + } + break; + } + case "subagent_tool": { + const text = event.data?.text || event.text || ""; + const name = event.data?.name || event.name || ""; + if (text) { + segments.push({ type: "tool", content: text, name }); + } + break; + } + case "subagent_tool_start": { + toolCalls.push({ + name: event.name, + input: event.data?.input, + status: "running", + }); + break; + } + case "subagent_tool_end": { + const last = toolCalls[toolCalls.length - 1]; + if (last && last.status === "running") { + toolCalls[toolCalls.length - 1] = { + ...last, + output: event.data?.output, + status: "done", + }; + } else { + toolCalls.push({ + name: event.name, + output: event.data?.output, + status: "done", + }); + } + break; + } + default: + break; + } + + next[key] = { ...entry, segments, toolCalls }; + return next; +} diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js new file mode 100644 index 00000000..9587b51b --- /dev/null +++ b/src/tui/subagentsPanel.js @@ -0,0 +1,88 @@ +import React, { useMemo } from "react"; +import { Box, Text, useWindowSize } from "ink"; +import { MessageBubble } from "./messageBubble.js"; +import { getRoleLabel } from "./messages.js"; + +/** + * Subagents panel — renders each subagent's reasoning/message segments and + * tool calls captured during a multi-agent turn. + * + * The orchestrator's `callProvider` emits `subagent_*` events carrying a + * stream namespace (e.g. `["task:"]`). `createStreamingHandler` routes + * those events into a subagent store keyed by namespace. This panel reads that + * store and renders each subagent as a distinct block, reusing `MessageBubble` + * to render the reasoning/message segments and a compact tool-call list. + * + * Props: + * subagentStore - Object keyed by namespace string, each value being + * `{ namespace, segments, toolCalls }`. + * activeView - The current active view name (from PANELS). + */ +export function SubagentsPanel({ subagentStore = {}, activeView }) { + const isActive = activeView === "subagents"; + const { rows } = useWindowSize(); + const limit = Math.max(1, rows - 4); + + const entries = useMemo(() => { + return Object.values(subagentStore || {}); + }, [subagentStore]); + + if (!isActive) { + return React.createElement(Box, null); + } + + if (entries.length === 0) { + return React.createElement( + Box, + { flexDirection: "column", padding: 1 }, + React.createElement(Text, { color: "gray" }, "No subagent activity yet."), + ); + } + + return React.createElement( + Box, + { flexDirection: "column", padding: 1 }, + React.createElement(Text, { bold: true }, `Subagents (${entries.length})`), + ...entries.slice(0, limit).map((entry) => + React.createElement( + Box, + { key: entry.namespace, flexDirection: "column", marginBottom: 1 }, + React.createElement( + Text, + { color: "cyan", bold: true }, + `▸ ${getRoleLabel("assistant", entry.namespace)}`, + ), + React.createElement(MessageBubble, { + role: "assistant", + content: (entry.segments || []).map((s) => s.content).join(""), + segments: entry.segments, + streaming: false, + assistantName: entry.namespace, + }), + renderToolCalls(entry.toolCalls), + ), + ), + ); +} + +/** + * Render a subagent's tool calls as a compact list. + * @param {Array} toolCalls - The subagent's tool call entries. + * @returns {React.ReactElement|null} The rendered tool-call list, or null. + */ +function renderToolCalls(toolCalls) { + if (!toolCalls || toolCalls.length === 0) return null; + + return React.createElement( + Box, + { flexDirection: "column", marginTop: 1 }, + React.createElement(Text, { color: "yellow", bold: true }, "Tool calls:"), + ...toolCalls.map((call, idx) => + React.createElement( + Text, + { key: idx, color: call.status === "error" ? "red" : "gray" }, + ` • ${call.name}${call.status === "running" ? " (running)" : ""}`, + ), + ), + ); +} diff --git a/tests/unit/tui.test.js b/tests/unit/tui.test.js index 3e2b1f49..72cc3030 100644 --- a/tests/unit/tui.test.js +++ b/tests/unit/tui.test.js @@ -303,6 +303,7 @@ describe("TUI - panel navigation", () => { "settings", "sessions", "projects", + "subagents", ]); }); @@ -312,21 +313,24 @@ describe("TUI - panel navigation", () => { assert.strictEqual(nextPanel("memories"), "settings"); assert.strictEqual(nextPanel("settings"), "sessions"); assert.strictEqual(nextPanel("sessions"), "projects"); - assert.strictEqual(nextPanel("projects"), "conversation"); + assert.strictEqual(nextPanel("projects"), "subagents"); + assert.strictEqual(nextPanel("subagents"), "conversation"); }); it("cycles to prev panel", () => { - assert.strictEqual(prevPanel("conversation"), "projects"); + assert.strictEqual(prevPanel("conversation"), "subagents"); assert.strictEqual(prevPanel("skills"), "conversation"); assert.strictEqual(prevPanel("memories"), "skills"); assert.strictEqual(prevPanel("settings"), "memories"); assert.strictEqual(prevPanel("sessions"), "settings"); assert.strictEqual(prevPanel("projects"), "sessions"); + assert.strictEqual(prevPanel("subagents"), "projects"); }); it("constants are frozen objects", () => { assert.ok(typeof PANELS === "object"); assert.strictEqual(PANELS.CONVERSATION, "conversation"); + assert.strictEqual(PANELS.SUBAGENTS, "subagents"); }); }); diff --git a/tests/unit/tui/panels.test.js b/tests/unit/tui/panels.test.js index 216eb808..f398ef5a 100644 --- a/tests/unit/tui/panels.test.js +++ b/tests/unit/tui/panels.test.js @@ -6,8 +6,9 @@ describe("getPanelOrder", () => { it("returns the panel order array", () => { const order = getPanelOrder(); assert.ok(Array.isArray(order)); - assert.strictEqual(order.length, 6); + assert.strictEqual(order.length, 7); assert.strictEqual(order[0], PANELS.CONVERSATION); + assert.strictEqual(order[order.length - 1], PANELS.SUBAGENTS); }); }); @@ -18,10 +19,11 @@ describe("nextPanel", () => { assert.strictEqual(nextPanel(PANELS.MEMORIES), PANELS.SETTINGS); assert.strictEqual(nextPanel(PANELS.SETTINGS), PANELS.SESSIONS); assert.strictEqual(nextPanel(PANELS.SESSIONS), PANELS.PROJECTS); + assert.strictEqual(nextPanel(PANELS.PROJECTS), PANELS.SUBAGENTS); }); it("wraps around from last to first", () => { - assert.strictEqual(nextPanel(PANELS.PROJECTS), PANELS.CONVERSATION); + assert.strictEqual(nextPanel(PANELS.SUBAGENTS), PANELS.CONVERSATION); }); it("handles unknown panel by wrapping to first", () => { @@ -32,6 +34,7 @@ describe("nextPanel", () => { describe("prevPanel", () => { it("cycles to the previous panel", () => { + assert.strictEqual(prevPanel(PANELS.SUBAGENTS), PANELS.PROJECTS); assert.strictEqual(prevPanel(PANELS.PROJECTS), PANELS.SESSIONS); assert.strictEqual(prevPanel(PANELS.SESSIONS), PANELS.SETTINGS); assert.strictEqual(prevPanel(PANELS.SETTINGS), PANELS.MEMORIES); @@ -40,11 +43,11 @@ describe("prevPanel", () => { }); it("wraps around from first to last", () => { - assert.strictEqual(prevPanel(PANELS.CONVERSATION), PANELS.PROJECTS); + assert.strictEqual(prevPanel(PANELS.CONVERSATION), PANELS.SUBAGENTS); }); it("handles unknown panel by wrapping to last", () => { const result = prevPanel("unknown"); - assert.strictEqual(result, PANELS.SESSIONS); + assert.strictEqual(result, PANELS.PROJECTS); }); }); diff --git a/tests/unit/tui/subagentsPanel.test.js b/tests/unit/tui/subagentsPanel.test.js new file mode 100644 index 00000000..963c6399 --- /dev/null +++ b/tests/unit/tui/subagentsPanel.test.js @@ -0,0 +1,207 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import React from "react"; +import { renderToString } from "ink"; +import { + createSubagentStore, + updateSubagentStore, + namespaceKey, +} from "../../../src/tui/subagentStore.js"; +import { SubagentsPanel } from "../../../src/tui/subagentsPanel.js"; + +describe("namespaceKey", () => { + it("joins a populated namespace into a stable key", () => { + assert.strictEqual(namespaceKey(["task:abc"]), "task:abc"); + assert.strictEqual(namespaceKey(["task:abc", "task:def"]), "task:abc/task:def"); + }); + + it("returns empty string for empty or non-array namespace", () => { + assert.strictEqual(namespaceKey([]), ""); + assert.strictEqual(namespaceKey(undefined), ""); + assert.strictEqual(namespaceKey(null), ""); + }); +}); + +describe("createSubagentStore", () => { + it("creates an empty store", () => { + assert.deepStrictEqual(createSubagentStore(), {}); + }); +}); + +describe("updateSubagentStore — namespace discrimination", () => { + it("ignores events with an empty namespace (root graph)", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_message", + namespace: [], + text: "root message", + }); + assert.deepStrictEqual(next, {}); + }); + + it("routes a subagent message to the namespace entry", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "subagent says hi", + }); + assert.ok(next["task:abc"]); + assert.deepStrictEqual(next["task:abc"].segments, [ + { type: "message", content: "subagent says hi" }, + ]); + }); + + it("routes a subagent reasoning segment to the namespace entry", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "thinking...", + }); + assert.deepStrictEqual(next["task:abc"].segments, [ + { type: "reasoning", content: "thinking..." }, + ]); + }); + + it("keeps distinct namespaces separate", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "first", + }); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:def"], + text: "second", + }); + assert.deepStrictEqual(store["task:abc"].segments, [{ type: "message", content: "first" }]); + assert.deepStrictEqual(store["task:def"].segments, [{ type: "message", content: "second" }]); + }); +}); + +describe("updateSubagentStore — tool-call redirection", () => { + it("records a subagent tool start", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_tool_start", + namespace: ["task:abc"], + name: "read_file", + data: { input: { path: "/tmp/x" } }, + }); + assert.deepStrictEqual(next["task:abc"].toolCalls, [ + { name: "read_file", input: { path: "/tmp/x" }, status: "running" }, + ]); + }); + + it("marks a running tool as done on tool end", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_tool_start", + namespace: ["task:abc"], + name: "read_file", + data: { input: { path: "/tmp/x" } }, + }); + store = updateSubagentStore(store, { + type: "subagent_tool_end", + namespace: ["task:abc"], + name: "read_file", + data: { output: "file contents" }, + }); + assert.strictEqual(store["task:abc"].toolCalls.length, 1); + assert.strictEqual(store["task:abc"].toolCalls[0].status, "done"); + assert.strictEqual(store["task:abc"].toolCalls[0].output, "file contents"); + }); + + it("routes a subagent tool text segment", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_tool", + namespace: ["task:abc"], + text: "tool output", + name: "read_file", + }); + assert.deepStrictEqual(next["task:abc"].segments, [ + { type: "tool", content: "tool output", name: "read_file" }, + ]); + }); + + it("records a tool end when no tool is running", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_tool_end", + namespace: ["task:abc"], + name: "read_file", + data: { output: "file contents" }, + }); + assert.deepStrictEqual(next["task:abc"].toolCalls, [ + { name: "read_file", output: "file contents", status: "done" }, + ]); + }); + + it("ignores unknown event types", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_unknown", + namespace: ["task:abc"], + text: "ignored", + }); + assert.deepStrictEqual(next["task:abc"].segments, []); + assert.deepStrictEqual(next["task:abc"].toolCalls, []); + }); +}); + +describe("SubagentsPanel", () => { + it("renders nothing when not active", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { "task:abc": { namespace: "task:abc", segments: [], toolCalls: [] } }, + activeView: "conversation", + }), + ); + assert.strictEqual(result, ""); + }); + + it("renders an empty state when there are no subagents", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: {}, + activeView: "subagents", + }), + ); + assert.ok(result.includes("No subagent activity yet.")); + }); + + it("renders each subagent's message segments", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [{ type: "message", content: "subagent says hi" }], + toolCalls: [], + }, + }, + activeView: "subagents", + }), + ); + assert.ok(result.includes("subagent says hi")); + }); + + it("renders subagent tool calls", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [], + toolCalls: [{ name: "read_file", status: "done" }], + }, + }, + activeView: "subagents", + }), + ); + assert.ok(result.includes("read_file")); + }); +}); From b4bb8b163220b7062fc2aacc0d903031a95ce1d7 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sat, 10 Oct 2026 20:58:52 -0400 Subject: [PATCH 03/24] chore: archive tui-subagents-panel change Archives the OpenSpec change tui-subagents-panel and syncs the delta specs into openspec/specs/ (tui-streaming updated, tui-subagents-panel created). --- .../.openspec.yaml | 0 .../2026-10-11-tui-subagents-panel}/design.md | 0 .../proposal.md | 0 .../specs/tui-streaming/spec.md | 2 +- .../specs/tui-subagents-panel/spec.md | 0 .../2026-10-11-tui-subagents-panel}/tasks.md | 0 openspec/specs/tui-streaming/spec.md | 23 +++++ openspec/specs/tui-subagents-panel/spec.md | 87 +++++++++++++++++++ 8 files changed, 111 insertions(+), 1 deletion(-) rename openspec/changes/{tui-subagents-panel => archive/2026-10-11-tui-subagents-panel}/.openspec.yaml (100%) rename openspec/changes/{tui-subagents-panel => archive/2026-10-11-tui-subagents-panel}/design.md (100%) rename openspec/changes/{tui-subagents-panel => archive/2026-10-11-tui-subagents-panel}/proposal.md (100%) rename openspec/changes/{tui-subagents-panel => archive/2026-10-11-tui-subagents-panel}/specs/tui-streaming/spec.md (98%) rename openspec/changes/{tui-subagents-panel => archive/2026-10-11-tui-subagents-panel}/specs/tui-subagents-panel/spec.md (100%) rename openspec/changes/{tui-subagents-panel => archive/2026-10-11-tui-subagents-panel}/tasks.md (100%) create mode 100644 openspec/specs/tui-subagents-panel/spec.md diff --git a/openspec/changes/tui-subagents-panel/.openspec.yaml b/openspec/changes/archive/2026-10-11-tui-subagents-panel/.openspec.yaml similarity index 100% rename from openspec/changes/tui-subagents-panel/.openspec.yaml rename to openspec/changes/archive/2026-10-11-tui-subagents-panel/.openspec.yaml diff --git a/openspec/changes/tui-subagents-panel/design.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/design.md similarity index 100% rename from openspec/changes/tui-subagents-panel/design.md rename to openspec/changes/archive/2026-10-11-tui-subagents-panel/design.md diff --git a/openspec/changes/tui-subagents-panel/proposal.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/proposal.md similarity index 100% rename from openspec/changes/tui-subagents-panel/proposal.md rename to openspec/changes/archive/2026-10-11-tui-subagents-panel/proposal.md diff --git a/openspec/changes/tui-subagents-panel/specs/tui-streaming/spec.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-streaming/spec.md similarity index 98% rename from openspec/changes/tui-subagents-panel/specs/tui-streaming/spec.md rename to openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-streaming/spec.md index c79a654a..6b5aac2c 100644 --- a/openspec/changes/tui-subagents-panel/specs/tui-streaming/spec.md +++ b/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-streaming/spec.md @@ -1,4 +1,4 @@ -## MODIFIED Requirements +## ADDED Requirements ### Requirement: Streaming handler SHALL route subagent events to a subagent store The `createStreamingHandler` function SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, instead of updating `streamingMsgIdRef.current`. The orchestrator's assistant bubble SHALL continue to receive the existing `message`, `reasoning`, `on_tool_start`, `on_tool_end`, `tool`, and `on_tool_error` event types. diff --git a/openspec/changes/tui-subagents-panel/specs/tui-subagents-panel/spec.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-subagents-panel/spec.md similarity index 100% rename from openspec/changes/tui-subagents-panel/specs/tui-subagents-panel/spec.md rename to openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-subagents-panel/spec.md diff --git a/openspec/changes/tui-subagents-panel/tasks.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/tasks.md similarity index 100% rename from openspec/changes/tui-subagents-panel/tasks.md rename to openspec/changes/archive/2026-10-11-tui-subagents-panel/tasks.md diff --git a/openspec/specs/tui-streaming/spec.md b/openspec/specs/tui-streaming/spec.md index c8892a1c..d543c4fb 100644 --- a/openspec/specs/tui-streaming/spec.md +++ b/openspec/specs/tui-streaming/spec.md @@ -170,3 +170,26 @@ After the assistant response is persisted to session state, `updateContextSize` - **WHEN** `updateContextSize` is called after persistence - **THEN** the system prompt token count is included in the final context size +### Requirement: Streaming handler SHALL route subagent events to a subagent store +The `createStreamingHandler` function SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, instead of updating `streamingMsgIdRef.current`. The orchestrator's assistant bubble SHALL continue to receive the existing `message`, `reasoning`, `on_tool_start`, `on_tool_end`, `tool`, and `on_tool_error` event types. + +#### Scenario: Subagent message routed to subagent store +- **WHEN** a `subagent_message` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent reasoning routed to subagent store +- **WHEN** a `subagent_reasoning` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent tool start routed to subagent store +- **WHEN** a `subagent_tool_start` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent tool end routed to subagent store +- **WHEN** a `subagent_tool_end` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Root graph events continue to update assistant bubble +- **WHEN** a `message` or `reasoning` event is received without a subagent namespace +- **THEN** the event continues to update `streamingMsgIdRef.current` + diff --git a/openspec/specs/tui-subagents-panel/spec.md b/openspec/specs/tui-subagents-panel/spec.md new file mode 100644 index 00000000..6e6d4fb6 --- /dev/null +++ b/openspec/specs/tui-subagents-panel/spec.md @@ -0,0 +1,87 @@ +# tui-subagents-panel Specification + +## Purpose +TBD - created by archiving change tui-subagents-panel. Update Purpose after archive. +## Requirements +### Requirement: callProvider SHALL capture the stream namespace +`callProvider` in `index.js` SHALL capture the `_namespace` from `agent.stream()` when iterating with `subgraphs: true`. When `namespace.length > 0`, it SHALL emit subagent event types carrying the namespace instead of the flat `message`/`reasoning`/`on_tool_*` events. The root graph (empty namespace) SHALL continue emitting the existing event types. + +#### Scenario: Root graph emits existing event types +- **WHEN** `callProvider` iterates a stream event with an empty namespace (`[]`) +- **THEN** it emits the existing `message`, `reasoning`, `on_tool_start`, or `on_tool_end` event types + +#### Scenario: Subagent emits subagent_message +- **WHEN** `callProvider` iterates a stream event with a populated namespace (e.g. `["task:"]`) and mode `messages` with message text +- **THEN** it emits a `subagent_message` event carrying the namespace and text + +#### Scenario: Subagent emits subagent_reasoning +- **WHEN** `callProvider` iterates a stream event with a populated namespace and reasoning content +- **THEN** it emits a `subagent_reasoning` event carrying the namespace and reasoning text + +#### Scenario: Subagent emits subagent_tool_start +- **WHEN** `callProvider` iterates a stream event with a populated namespace and mode `tools` with `on_tool_start` +- **THEN** it emits a `subagent_tool_start` event carrying the namespace and tool name + +#### Scenario: Subagent emits subagent_tool_end +- **WHEN** `callProvider` iterates a stream event with a populated namespace and mode `tools` with `on_tool_end` +- **THEN** it emits a `subagent_tool_end` event carrying the namespace and tool name + +### Requirement: Subagent events SHALL carry the namespace +Each `subagent_*` event emitted by `callProvider` SHALL include the namespace that identifies the subgraph that produced it. + +#### Scenario: Event carries namespace +- **WHEN** a `subagent_message` event is emitted +- **THEN** the event includes a `namespace` field equal to the populated namespace array + +### Requirement: TUI streaming handler SHALL route subagent events to a subagent store +`createStreamingHandler` in `src/tui/conversationArea.js` SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, not to `streamingMsgIdRef.current`. + +#### Scenario: Subagent message routed to subagent store +- **WHEN** a `subagent_message` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent reasoning routed to subagent store +- **WHEN** a `subagent_reasoning` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent tool start routed to subagent store +- **WHEN** a `subagent_tool_start` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent tool end routed to subagent store +- **WHEN** a `subagent_tool_end` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +### Requirement: SUBAGENTS panel SHALL be registered in PANELS +`PANELS` in `src/tui/panels.js` SHALL include a `SUBAGENTS` entry, and `getPanelOrder()` SHALL include it in the tab navigation order. + +#### Scenario: PANELS includes SUBAGENTS +- **WHEN** `PANELS` is inspected +- **THEN** it includes a `SUBAGENTS` entry + +#### Scenario: getPanelOrder includes SUBAGENTS +- **WHEN** `getPanelOrder()` is called +- **THEN** the returned order array includes `PANELS.SUBAGENTS` + +### Requirement: SUBAGENTS panel SHALL render subagent activity +`src/tui/subagentsPanel.js` SHALL render each subagent's reasoning/message segments and tool calls, keyed by namespace. + +#### Scenario: Panel renders subagent message segments +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's message segments +- **THEN** the panel renders the subagent's message content + +#### Scenario: Panel renders subagent reasoning segments +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's reasoning segments +- **THEN** the panel renders the subagent's reasoning content + +#### Scenario: Panel renders subagent tool calls +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's tool calls +- **THEN** the panel renders the subagent's tool call activity + +### Requirement: App SHALL render the SUBAGENTS panel +The App render switch in `src/tui/app.js` SHALL include a `currentView === PANELS.SUBAGENTS` branch that renders the subagents panel, passing the subagent store. + +#### Scenario: App renders SUBAGENTS panel +- **WHEN** `currentView === PANELS.SUBAGENTS` +- **THEN** the App renders the subagents panel component + From 47f30c7115e46f03974d4603fb6999dcb8f60722 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 11 Oct 2026 15:10:17 -0400 Subject: [PATCH 04/24] fix: route orchestrator output to conversation, not subagent store The subagent discriminator used namespace.length > 0, but LangGraph's subgraphs: true yields the root graph's namespace as [""] (an empty checkpoint_ns split on "|" produces [""], length 1, not []). This misrouted the orchestrator's own messages, reasoning, and tool calls as subagent_* events, so they were folded into the SUBAGENTS store and never rendered in the conversation view. Discriminate on whether the namespace has a non-empty component instead. Add a regression test documenting the [""] root-graph namespace. --- coverage.txt | 14 +++++++------- index.js | 19 +++++++++++-------- tests/unit/tui/subagentsPanel.test.js | 12 ++++++++++++ 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/coverage.txt b/coverage.txt index df29ecc5..7a6859a7 100644 --- a/coverage.txt +++ b/coverage.txt @@ -30,7 +30,7 @@ ℹ tui.js | 100.00 | 100.00 | 100.00 | ℹ vector.js | 100.00 | 100.00 | 100.00 | ℹ memory | | | | -ℹ context.js | 95.73 | 69.70 | 100.00 | 112-116 +ℹ context.js | 97.44 | 78.79 | 100.00 | 114-116 ℹ expireEphemeralMemories.js | 93.15 | 73.68 | 100.00 | 25-27 68-69 ℹ gc.js | 100.00 | 100.00 | 100.00 | ℹ index.js | 100.00 | 100.00 | 100.00 | @@ -70,10 +70,10 @@ ℹ logger.js | 79.53 | 66.67 | 100.00 | 87-98 123-132 137 139-141 164-166 176-180 203-211 217-222 239 273-274 276-278 297-299 307-309 317-319 327-329 337-339 ℹ skills | | | | ℹ agentMapper.js | 83.33 | 62.50 | 100.00 | 12-13 23-25 -ℹ discoverer.js | 99.10 | 91.94 | 100.00 | 174-175 -ℹ registry.js | 87.77 | 61.29 | 77.78 | 104-105 153-162 173-175 201-207 215-219 227-231 264-265 +ℹ discoverer.js | 99.10 | 92.31 | 100.00 | 174-175 +ℹ registry.js | 87.77 | 72.97 | 77.78 | 104-105 153-162 173-175 201-207 215-219 227-231 264-265 ℹ types.js | 100.00 | 100.00 | 100.00 | -ℹ validator.js | 96.35 | 97.50 | 80.00 | 130-134 +ℹ validator.js | 100.00 | 100.00 | 80.00 | ℹ stream | | | | ℹ transformers | | | | ℹ index.js | 100.00 | 100.00 | 100.00 | @@ -159,7 +159,7 @@ ℹ reflection | | | | ℹ index.js | 95.18 | 82.00 | 91.67 | 58-62 127-128 151-152 206-207 ℹ sampling | | | | -ℹ index.js | 94.97 | 84.38 | 80.00 | 27 180-188 +ℹ index.js | 94.97 | 81.82 | 80.00 | 27 180-188 ℹ scanAgents | | | | ℹ index.js | 100.00 | 80.00 | 100.00 | ℹ session | | | | @@ -182,7 +182,7 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 44.17 | 55.81 | 21.21 | 121-127 139-143 153-163 171-175 183-186 207-232 239-248 257-282 298-314 324-325 332-333 340-385 389-508 521-525 528-538 541-554 570-575 577-581 583-587 589-594 596-602 604-609 616-626 633-661 686-687 +ℹ app.js | 43.98 | 55.81 | 21.21 | 121-127 139-146 156-166 174-178 186-189 210-235 242-251 260-285 301-317 327-328 335-336 343-388 392-511 524-528 531-541 544-557 573-578 580-584 586-590 592-597 599-605 607-612 619-629 636-664 689-690 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | ℹ commandParser.js | 96.12 | 90.54 | 90.00 | 89-96 118 @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 90.89 | 88.74 | 86.55 | +ℹ all files | 90.91 | 88.85 | 86.55 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/index.js b/index.js index 7dc29c76..8b0e710d 100644 --- a/index.js +++ b/index.js @@ -231,14 +231,17 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, streamMode: ["messages", "tools"], subgraphs: true, })) { - // With `subgraphs: true`, the root graph yields an empty namespace `[]` - // and each subagent subgraph yields a populated namespace (e.g. - // `["task:"]`). `namespace.length > 0` discriminates subagent - // activity from the orchestrator's own output. Subagent events are - // emitted with a `subagent_` prefix and carry the namespace so the TUI - // can route them to a dedicated SUBAGENTS store instead of folding them - // into the orchestrator's assistant bubble. - const isSubagent = namespace.length > 0; + // With `subgraphs: true`, the root graph yields a namespace of `[""]` + // (an empty checkpoint_ns split on "|") and each subagent subgraph + // yields a populated namespace (e.g. `["task:"]`). The root + // graph's namespace is `[""]` — length 1, not 0 — so `length > 0` + // alone would misroute the orchestrator's own output as subagent + // activity. Discriminate on whether the namespace has a NON-EMPTY + // component instead. Subagent events are emitted with a `subagent_` + // prefix and carry the namespace so the TUI can route them to a + // dedicated SUBAGENTS store instead of folding them into the + // orchestrator's assistant bubble. + const isSubagent = Array.isArray(namespace) && namespace.some((part) => part.length > 0); if (mode === "messages") { const [msg] = payload; diff --git a/tests/unit/tui/subagentsPanel.test.js b/tests/unit/tui/subagentsPanel.test.js index 963c6399..20396f46 100644 --- a/tests/unit/tui/subagentsPanel.test.js +++ b/tests/unit/tui/subagentsPanel.test.js @@ -39,6 +39,18 @@ describe("updateSubagentStore — namespace discrimination", () => { assert.deepStrictEqual(next, {}); }); + it('ignores events with a blank-component namespace (root graph yields [""])', () => { + // With `subgraphs: true`, the root graph's checkpoint_ns is "" which + // splits to `[""]` — length 1, not 0. The store must still ignore it. + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_message", + namespace: [""], + text: "root message", + }); + assert.deepStrictEqual(next, {}); + }); + it("routes a subagent message to the namespace entry", () => { const store = createSubagentStore(); const next = updateSubagentStore(store, { From 24a7741af21fd3428e11385fec5905763af6fbc5 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 11 Oct 2026 15:23:21 -0400 Subject: [PATCH 05/24] fix: use tools: namespace prefix to discriminate subagent events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used a non-empty-component check, but the orchestrator's own output is emitted with a populated namespace (["model_request:"]) under subgraphs: true. That still misrouted orchestrator messages as subagent_* events. The correct discriminator is a tools: prefix in the namespace — subagent subgraphs spawned by a tool call carry a tools: segment (e.g. ["tools:call_abc123", "model_request:"]), while the orchestrator's own output has none. This mirrors the deepagents library's own isSubagentNamespace helper. Verified empirically via a streaming probe: orchestrator events carry ["model_request:"] and are now classified as non-subagent. --- index.js | 21 +++++++++++---------- tests/unit/tui/subagentsPanel.test.js | 5 +++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/index.js b/index.js index 8b0e710d..41b0b63d 100644 --- a/index.js +++ b/index.js @@ -231,17 +231,18 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, streamMode: ["messages", "tools"], subgraphs: true, })) { - // With `subgraphs: true`, the root graph yields a namespace of `[""]` - // (an empty checkpoint_ns split on "|") and each subagent subgraph - // yields a populated namespace (e.g. `["task:"]`). The root - // graph's namespace is `[""]` — length 1, not 0 — so `length > 0` - // alone would misroute the orchestrator's own output as subagent - // activity. Discriminate on whether the namespace has a NON-EMPTY - // component instead. Subagent events are emitted with a `subagent_` - // prefix and carry the namespace so the TUI can route them to a - // dedicated SUBAGENTS store instead of folding them into the + // With `subgraphs: true`, the root graph yields a namespace like + // `["model_request:"]` and each subagent subgraph yields a + // namespace containing a `tools:` segment (e.g. + // `["tools:call_abc123", "model_request:"]`). The orchestrator's + // own output has NO `tools:` segment, so a `tools:` prefix check is the + // correct discriminator (mirrors the deepagents library's own + // `isSubagentNamespace`). Subagent events are emitted with a + // `subagent_` prefix and carry the namespace so the TUI can route them + // to a dedicated SUBAGENTS store instead of folding them into the // orchestrator's assistant bubble. - const isSubagent = Array.isArray(namespace) && namespace.some((part) => part.length > 0); + const isSubagent = + Array.isArray(namespace) && namespace.some((part) => part.startsWith("tools:")); if (mode === "messages") { const [msg] = payload; diff --git a/tests/unit/tui/subagentsPanel.test.js b/tests/unit/tui/subagentsPanel.test.js index 20396f46..115b12c9 100644 --- a/tests/unit/tui/subagentsPanel.test.js +++ b/tests/unit/tui/subagentsPanel.test.js @@ -40,8 +40,9 @@ describe("updateSubagentStore — namespace discrimination", () => { }); it('ignores events with a blank-component namespace (root graph yields [""])', () => { - // With `subgraphs: true`, the root graph's checkpoint_ns is "" which - // splits to `[""]` — length 1, not 0. The store must still ignore it. + // The store keys on the joined namespace; a blank component produces an + // empty key and is ignored. This guards against the root graph's + // `[""]` namespace leaking into the store. const store = createSubagentStore(); const next = updateSubagentStore(store, { type: "subagent_message", From 126bd3d1a411bc24f8ee9b0494413770561dda1c Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 11 Oct 2026 15:37:59 -0400 Subject: [PATCH 06/24] fix: keep orchestrator tool calls in conversation view The tools: prefix discriminator misrouted the orchestrator's own tool calls as subagent activity. The orchestrator's tool calls are emitted under a single-segment namespace (["tools:"], length 1), while a subagent subgraph always adds a second segment (a nested model_request: or tools:). A namespace is subagent-scoped only when it has a tools: segment AND more than one segment. Verified empirically against the orchestrator's ls/task tool calls and subagent message/tool-call namespaces. --- index.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 41b0b63d..02a1c19a 100644 --- a/index.js +++ b/index.js @@ -241,8 +241,18 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, // `subagent_` prefix and carry the namespace so the TUI can route them // to a dedicated SUBAGENTS store instead of folding them into the // orchestrator's assistant bubble. + // + // However, the orchestrator's OWN tool calls are also emitted under a + // `tools:` namespace (e.g. `["tools:"]` — length 1). A subagent + // subgraph always adds a second segment (a nested `model_request:` or + // `tools:`), so a namespace is subagent-scoped only when it has a + // `tools:` segment AND more than one segment. This keeps the + // orchestrator's tool calls in the conversation view while routing + // subagent activity to the SUBAGENTS store. const isSubagent = - Array.isArray(namespace) && namespace.some((part) => part.startsWith("tools:")); + Array.isArray(namespace) && + namespace.length > 1 && + namespace.some((part) => part.startsWith("tools:")); if (mode === "messages") { const [msg] = payload; From 02bd2c9b5d260c1327b89e45c333f48f82229a78 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 11 Oct 2026 16:09:57 -0400 Subject: [PATCH 07/24] feat: add /subagents slash command to reach the SUBAGENTS panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SUBAGENTS panel was registered in panels.js but unreachable — no slash command, no keyboard shortcut, and nextPanel/prevPanel were defined but never wired. Add /subagents to the view-switching commands and document it in the /help output, mirroring the /projects pattern. --- coverage.txt | 6 +++--- src/tui/commandHelp.js | 1 + src/tui/commandParser.js | 1 + src/tui/subagentsPanel.js | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/coverage.txt b/coverage.txt index 7a6859a7..8258cbf2 100644 --- a/coverage.txt +++ b/coverage.txt @@ -185,7 +185,7 @@ ℹ app.js | 43.98 | 55.81 | 21.21 | 121-127 139-146 156-166 174-178 186-189 210-235 242-251 260-285 301-317 327-328 335-336 343-388 392-511 524-528 531-541 544-557 573-578 580-584 586-590 592-597 599-605 607-612 619-629 636-664 689-690 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | -ℹ commandParser.js | 96.12 | 90.54 | 90.00 | 89-96 118 +ℹ commandParser.js | 96.14 | 90.54 | 90.00 | 89-96 119 ℹ commandPicker.js | 46.40 | 100.00 | 75.00 | 102-220 ℹ conversationPanel.js | 95.45 | 95.83 | 100.00 | 26-31 ℹ filePicker.js | 43.98 | 100.00 | 83.33 | 116-264 @@ -205,7 +205,7 @@ ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 -ℹ subagentsPanel.js | 100.00 | 75.00 | 100.00 | +ℹ subagentsPanel.js | 96.59 | 75.00 | 100.00 | 86-88 ℹ subagentStore.js | 100.00 | 70.37 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 90.91 | 88.85 | 86.55 | +ℹ all files | 90.90 | 88.85 | 86.55 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/commandHelp.js b/src/tui/commandHelp.js index f98e6c19..29cf6292 100644 --- a/src/tui/commandHelp.js +++ b/src/tui/commandHelp.js @@ -34,6 +34,7 @@ export const COMMAND_GROUPS = [ "/sessions - view sessions panel", "/settings - view settings panel", "/skills - view skills panel", + "/subagents - view subagents panel", ], }, ]; diff --git a/src/tui/commandParser.js b/src/tui/commandParser.js index b85860c9..0fc47f7a 100644 --- a/src/tui/commandParser.js +++ b/src/tui/commandParser.js @@ -113,6 +113,7 @@ export class CommandParser { ["memories", "memories"], ["skills", "skills"], ["settings", "settings"], + ["subagents", "subagents"], ]) { this.#register(cmd, (_args, _ctx) => { return { action: "view", value: view, message: `Switching to ${view} view.` }; diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index 9587b51b..1917113f 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { Box, Text, useWindowSize } from "ink"; +import { Box, Text, useInput, useWindowSize } from "ink"; import { MessageBubble } from "./messageBubble.js"; import { getRoleLabel } from "./messages.js"; From 350a07f6dd2c50ee7863af210d9b40901e2200c6 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 11 Oct 2026 16:13:03 -0400 Subject: [PATCH 08/24] fix: allow Escape to return to conversation from SUBAGENTS panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SUBAGENTS panel had no useInput handler, so Escape did nothing while in the subagent view — there was no way back to the orchestrator/conversation view. Add the same Escape-to-conversation pattern used by the skills panel. --- coverage.txt | 4 ++-- src/tui/subagentsPanel.js | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/coverage.txt b/coverage.txt index 8258cbf2..c56c0872 100644 --- a/coverage.txt +++ b/coverage.txt @@ -205,7 +205,7 @@ ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 -ℹ subagentsPanel.js | 96.59 | 75.00 | 100.00 | 86-88 +ℹ subagentsPanel.js | 96.94 | 75.00 | 85.71 | 33-35 ℹ subagentStore.js | 100.00 | 70.37 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 90.90 | 88.85 | 86.55 | +ℹ all files | 90.90 | 88.85 | 86.49 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index 1917113f..7ddef2a2 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -27,6 +27,16 @@ export function SubagentsPanel({ subagentStore = {}, activeView }) { return Object.values(subagentStore || {}); }, [subagentStore]); + // Escape returns to conversation view + useInput( + (_input, key) => { + if (key.escape) { + onViewChange?.("conversation"); + } + }, + { isActive }, + ); + if (!isActive) { return React.createElement(Box, null); } From 9496f007333fbb4ec721f19afc15cb386fdf7cfc Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 11 Oct 2026 16:15:35 -0400 Subject: [PATCH 09/24] fix: destructure onViewChange prop in SubagentsPanel The Escape handler referenced onViewChange but the component signature only destructured subagentStore and activeView, causing a ReferenceError when Escape was pressed in the subagents view. Add onViewChange to the destructuring and document it in the JSDoc. --- coverage.txt | 2 +- src/tui/subagentsPanel.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/coverage.txt b/coverage.txt index c56c0872..73f909d9 100644 --- a/coverage.txt +++ b/coverage.txt @@ -205,7 +205,7 @@ ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 -ℹ subagentsPanel.js | 96.94 | 75.00 | 85.71 | 33-35 +ℹ subagentsPanel.js | 96.97 | 75.00 | 85.71 | 34-36 ℹ subagentStore.js | 100.00 | 70.37 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index 7ddef2a2..f7eda380 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -17,8 +17,9 @@ import { getRoleLabel } from "./messages.js"; * subagentStore - Object keyed by namespace string, each value being * `{ namespace, segments, toolCalls }`. * activeView - The current active view name (from PANELS). + * onViewChange - Callback to switch back to conversation view. */ -export function SubagentsPanel({ subagentStore = {}, activeView }) { +export function SubagentsPanel({ subagentStore = {}, activeView, onViewChange }) { const isActive = activeView === "subagents"; const { rows } = useWindowSize(); const limit = Math.max(1, rows - 4); From 3683b346006b55336be24e92df28ea44b6108193 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 11 Oct 2026 16:29:02 -0400 Subject: [PATCH 10/24] fix: keep ConversationArea mounted across panel views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ConversationArea was unmounted whenever a panel view was active, so returning to the conversation view remounted it fresh and lost the session (message list, scroll position, streaming state). Pressing Escape from the subagents view appeared to start a new chat. Keep the ConversationArea mounted and overlay panels on top via absolute positioning. Remove the now-redundant loadConversation reload from handleViewChange. The app-level key handler gates on currentView, so Escape in a panel view only switches back to conversation — it never reaches the interrupt path while a panel is active. --- coverage.txt | 4 ++-- src/tui/app.js | 49 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/coverage.txt b/coverage.txt index 73f909d9..696d9241 100644 --- a/coverage.txt +++ b/coverage.txt @@ -182,7 +182,7 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 43.98 | 55.81 | 21.21 | 121-127 139-146 156-166 174-178 186-189 210-235 242-251 260-285 301-317 327-328 335-336 343-388 392-511 524-528 531-541 544-557 573-578 580-584 586-590 592-597 599-605 607-612 619-629 636-664 689-690 +ℹ app.js | 42.78 | 55.81 | 21.21 | 121 131-138 148-158 166-170 178-181 202-227 234-243 252-277 293-309 319-320 327-328 335-380 384-503 516-520 523-533 536-549 565-570 572-576 578-582 584-589 591-597 599-604 611-621 628-679 704-705 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | ℹ commandParser.js | 96.14 | 90.54 | 90.00 | 89-96 119 @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 90.90 | 88.85 | 86.49 | +ℹ all files | 90.85 | 88.85 | 86.49 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/app.js b/src/tui/app.js index 9c0470c0..314a82bb 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -112,22 +112,14 @@ function App({ /** * onViewChange — switch between conversation and panel views. - * When returning to conversation view, reload messages from session state - * (e.g., after session resume). + * The ConversationArea stays mounted across panel views, so the message + * list and session state survive the round-trip. No reload is needed on + * return — the conversation is already rendered. * @param {string} view - One of PANELS values */ - const handleViewChange = useCallback( - (view) => { - setCurrentView(view); - if (view === PANELS.CONVERSATION && conversationAreaRef.current) { - const conv = sessionState?.getConversation(); - if (conv && conv.length > 0) { - conversationAreaRef.current.loadConversation(conv); - } - } - }, - [sessionState], - ); + const handleViewChange = useCallback((view) => { + setCurrentView(view); + }, []); /** * handleSelectSkill — switch to the conversation view and pre-load the @@ -632,9 +624,17 @@ function App({ onDismiss: () => setShowBanner(false), version: appInfo ? appInfo.version : undefined, }) - : currentView !== PANELS.CONVERSATION - ? panelComponent - : React.createElement(ConversationArea, { + : React.createElement( + Box, + { flexDirection: "column", width: "100%", height: rows }, + // ConversationArea stays mounted across panel views so the + // session (message list, scroll position, streaming state) + // survives the round-trip. Panels overlay it via absolute + // positioning. The app-level key handler gates on + // `currentView`, so Escape in a panel view only switches + // back to conversation — it never reaches the interrupt + // path while a panel is active. + React.createElement(ConversationArea, { ref: conversationAreaRef, config, registry, @@ -663,6 +663,21 @@ function App({ reasoningCollapsed, toolCallCollapsed, }), + currentView !== PANELS.CONVERSATION + ? React.createElement( + Box, + { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: rows, + flexDirection: "column", + }, + panelComponent, + ) + : null, + ), // InputArea — hidden during panel views currentView === PANELS.CONVERSATION || showOnboarding ? React.createElement(InputArea, { From 304866dfcc8300f8aad1b1eeb22ccb9b126ee73c Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 06:30:09 -0400 Subject: [PATCH 11/24] fix: preserve conversation state across panel view round-trip --- coverage.txt | 18 +++--- src/tui/app.js | 26 +++----- src/tui/conversationArea.js | 13 +++- tests/unit/tui/repro-subagents-escape.test.js | 59 +++++++++++++++++++ 4 files changed, 87 insertions(+), 29 deletions(-) create mode 100644 tests/unit/tui/repro-subagents-escape.test.js diff --git a/coverage.txt b/coverage.txt index 696d9241..7631fa82 100644 --- a/coverage.txt +++ b/coverage.txt @@ -1,7 +1,7 @@ ℹ start of coverage report -ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ file | line % | branch % | funcs % | uncovered lines -ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ src | | | | ℹ agent | | | | ℹ agentDefinitions.js | 100.00 | 100.00 | 100.00 | @@ -182,7 +182,7 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 42.78 | 55.81 | 21.21 | 121 131-138 148-158 166-170 178-181 202-227 234-243 252-277 293-309 319-320 327-328 335-380 384-503 516-520 523-533 536-549 565-570 572-576 578-582 584-589 591-597 599-604 611-621 628-679 704-705 +ℹ app.js | 43.51 | 55.81 | 21.21 | 121 131-138 148-158 166-170 178-181 202-227 234-243 252-277 293-309 319-320 327-328 335-380 384-503 516-520 523-533 536-549 565-570 572-576 578-582 584-589 591-597 599-604 611-621 628-667 692-693 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | ℹ commandParser.js | 96.14 | 90.54 | 90.00 | 89-96 119 @@ -193,13 +193,13 @@ ℹ inputPanel.js | 100.00 | 100.00 | 100.00 | ℹ markdownText.js | 95.12 | 86.02 | 96.00 | 69 199-205 220-228 231-233 ℹ memoryPanel.js | 13.04 | 100.00 | 0.00 | 14-16 24-32 43-230 -ℹ messageBubble.js | 88.61 | 86.36 | 71.43 | 292-326 343-348 396-401 409-422 499-502 514-517 -ℹ messageList.js | 64.86 | 81.40 | 29.55 | 157-162 167-171 176-179 210-243 253-325 334 341-346 392 404-414 429-454 464-466 473-475 483 491 499 508-512 521-528 536 544 552 562 571-577 586 594-599 621-623 641-645 655-656 680-694 707-744 780-788 +ℹ messageBubble.js | 91.09 | 87.64 | 74.29 | 297 301-319 343-348 396-401 409-422 499-502 514-517 +ℹ messageList.js | 79.22 | 77.42 | 48.94 | 159 176-179 253-325 334 341-346 429-454 464-466 473-475 483 491 499 508-512 521-528 536 544 552 562 571-577 586 594-599 621-623 712-722 ℹ messages.js | 100.00 | 100.00 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | ℹ projectsPanel.js | 31.54 | 100.00 | 57.14 | 13-21 30-32 60-149 ℹ quotes.js | 100.00 | 100.00 | 100.00 | -ℹ scrollView.js | 88.75 | 85.71 | 72.73 | 139-142 149-153 167-184 +ℹ scrollView.js | 96.67 | 87.10 | 57.89 | 149-153 181-183 ℹ selectionLayout.js | 100.00 | 95.00 | 100.00 | ℹ sessionsPanel.js | 12.25 | 100.00 | 0.00 | 15-17 25-33 44-253 ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 @@ -216,7 +216,7 @@ ℹ store.js | 92.15 | 90.63 | 94.74 | 145-159 226-232 242 ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | -ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 90.85 | 88.85 | 86.49 | -ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ all files | 91.38 | 88.81 | 86.80 | +ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/app.js b/src/tui/app.js index 314a82bb..e89730f9 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -629,11 +629,11 @@ function App({ { flexDirection: "column", width: "100%", height: rows }, // ConversationArea stays mounted across panel views so the // session (message list, scroll position, streaming state) - // survives the round-trip. Panels overlay it via absolute - // positioning. The app-level key handler gates on - // `currentView`, so Escape in a panel view only switches - // back to conversation — it never reaches the interrupt - // path while a panel is active. + // survives the round-trip. When a panel is active it renders + // nothing (hidden), so the panel is shown in its place. The + // app-level key handler gates on `currentView`, so Escape in + // a panel view only switches back to conversation — it never + // reaches the interrupt path while a panel is active. React.createElement(ConversationArea, { ref: conversationAreaRef, config, @@ -662,21 +662,9 @@ function App({ selection, reasoningCollapsed, toolCallCollapsed, + hidden: currentView !== PANELS.CONVERSATION, }), - currentView !== PANELS.CONVERSATION - ? React.createElement( - Box, - { - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: rows, - flexDirection: "column", - }, - panelComponent, - ) - : null, + currentView !== PANELS.CONVERSATION ? panelComponent : null, ), // InputArea — hidden during panel views currentView === PANELS.CONVERSATION || showOnboarding diff --git a/src/tui/conversationArea.js b/src/tui/conversationArea.js index b945f26b..9953ce94 100644 --- a/src/tui/conversationArea.js +++ b/src/tui/conversationArea.js @@ -85,6 +85,7 @@ const ConversationArea = forwardRef(function ConversationArea( selection, reasoningCollapsed, toolCallCollapsed, + hidden = false, }, ref, ) { @@ -905,9 +906,19 @@ const ConversationArea = forwardRef(function ConversationArea( isStreaming: () => isStreamingRef.current, })); + // When hidden (a panel view is active), keep the full subtree mounted but + // hide it with `display: "none"` so the MessageList state (idsRef, dataRef, + // contentRef, scroll position) survives the round-trip. Returning an empty + // Box here would unmount ConversationPanel → MessageList and destroy that + // state, so returning to the conversation view would remount it empty. return React.createElement( Box, - { key: "conversation-wrapper", flexDirection: "column", flexGrow: 1 }, + { + key: "conversation-wrapper", + flexDirection: "column", + flexGrow: 1, + display: hidden ? "none" : "flex", + }, React.createElement(ConversationPanel, { assistantName: config?.tui?.name || "Assistant", showToolResults: config?.tui?.showToolResults, diff --git a/tests/unit/tui/repro-subagents-escape.test.js b/tests/unit/tui/repro-subagents-escape.test.js new file mode 100644 index 00000000..97f56260 --- /dev/null +++ b/tests/unit/tui/repro-subagents-escape.test.js @@ -0,0 +1,59 @@ +import React, { createRef } from "react"; +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert"; +import { render } from "ink"; +import ConversationArea from "../../../src/tui/conversationArea.js"; + +let unmount; +afterEach(() => { + if (unmount) unmount(); +}); + +function renderConversationArea(ref, hidden, messageCountRef) { + return React.createElement(ConversationArea, { + ref, + config: { tui: { name: "Assistant" } }, + registry: null, + sessionState: null, + dispatchProvider: null, + scheduleManager: null, + onSaveSession: () => {}, + gcManager: null, + gcTrigger: null, + compactContext: null, + getContextMessages: () => [], + model: null, + systemPrompt: "", + messageCountRef, + hidden, + }); +} + +describe("Repro: ConversationArea hidden round-trip preserves message state", () => { + it("keeps messages when toggling hidden (panel view) and back", async () => { + const ref = createRef(); + const messageCountRef = { current: 0 }; + const { unmount: um, rerender } = render(renderConversationArea(ref, false, messageCountRef)); + unmount = um; + + // Add a message while visible + ref.current.addMessage({ role: "user", content: "hello world" }); + assert.strictEqual(messageCountRef.current, 1, "message added while visible"); + assert.strictEqual(ref.current.getSelectionMessages().length, 1, "message list has 1 message"); + + // Toggle hidden (panel view active) — the regression path + rerender(renderConversationArea(ref, true, messageCountRef)); + + // Toggle back to visible (Escape returns to conversation) + rerender(renderConversationArea(ref, false, messageCountRef)); + + // The message must survive the round-trip — getSelectionMessages reads + // from the MessageList's internal dataRef/idsRef, which is destroyed if + // the subtree unmounts. + assert.strictEqual( + ref.current.getSelectionMessages().length, + 1, + "message survives hidden round-trip", + ); + }); +}); From 1e4d25abe13a304a50b7e68242edb4523d1e11b3 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 07:37:27 -0400 Subject: [PATCH 12/24] fix: skip interrupt for view-switching slash commands View commands (/skills, /subagents, /memories, /sessions, /settings, /projects) are pure panel switches and should not kill an in-flight stream. handleSubmit now skips the interrupt when the parsed command action is 'view'. State-mutating commands like /projects clear still interrupt, since they dispatch a message to the LLM. --- coverage.txt | 6 +++--- src/tui/app.js | 6 ++++-- src/tui/commandParser.js | 13 +++++++++++++ tests/unit/tui/commandParser.test.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/coverage.txt b/coverage.txt index 7631fa82..d6bbbcc1 100644 --- a/coverage.txt +++ b/coverage.txt @@ -182,10 +182,10 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 43.51 | 55.81 | 21.21 | 121 131-138 148-158 166-170 178-181 202-227 234-243 252-277 293-309 319-320 327-328 335-380 384-503 516-520 523-533 536-549 565-570 572-576 578-582 584-589 591-597 599-604 611-621 628-667 692-693 +ℹ app.js | 43.39 | 55.81 | 21.21 | 121 131-138 148-158 166-170 178-181 202-227 234-243 252-277 293-311 321-322 329-330 337-382 386-505 518-522 525-535 538-551 567-572 574-578 580-584 586-591 593-599 601-606 613-623 630-669 694-695 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | -ℹ commandParser.js | 96.14 | 90.54 | 90.00 | 89-96 119 +ℹ commandParser.js | 97.56 | 89.87 | 100.00 | 90-95 ℹ commandPicker.js | 46.40 | 100.00 | 75.00 | 102-220 ℹ conversationPanel.js | 95.45 | 95.83 | 100.00 | 26-31 ℹ filePicker.js | 43.98 | 100.00 | 83.33 | 116-264 @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 91.38 | 88.81 | 86.80 | +ℹ all files | 91.39 | 88.80 | 86.94 | ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/app.js b/src/tui/app.js index e89730f9..1a1618fe 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -296,8 +296,10 @@ function App({ const area = conversationAreaRef.current; if (!area) return; - // Interrupt if currently streaming - if (area.isStreaming?.()) { + // Interrupt if currently streaming — but skip for view-switching + // commands (/skills, /subagents, /memories, etc.). Those are pure + // panel switches and should not kill an in-flight stream. + if (area.isStreaming?.() && !parser.isViewCommand(trimmed)) { await area.interrupt(); } diff --git a/src/tui/commandParser.js b/src/tui/commandParser.js index 0fc47f7a..090159d0 100644 --- a/src/tui/commandParser.js +++ b/src/tui/commandParser.js @@ -214,6 +214,19 @@ export class CommandParser { return input && typeof input === "string" && input.trim().startsWith("/"); } + /** + * Check if a command opens a view (e.g. /skills, /subagents, /memories). + * View-switching commands are pure panel switches — they should not + * interrupt an in-flight stream. Determined by parsing the input and + * checking the resulting action, so it stays correct as commands are added. + * @param {string} input + * @returns {boolean} + */ + isViewCommand(input) { + const result = this.parse(input, {}); + return result?.action === "view"; + } + /** * Get a list of all registered commands (excludes internal/fallback commands). * @returns {string[]} diff --git a/tests/unit/tui/commandParser.test.js b/tests/unit/tui/commandParser.test.js index 1f60f19a..299b544e 100644 --- a/tests/unit/tui/commandParser.test.js +++ b/tests/unit/tui/commandParser.test.js @@ -331,6 +331,34 @@ describe("CommandParser", () => { }); }); + /* ------------------------------------------------------------------ */ + /* isViewCommand() */ + /* ------------------------------------------------------------------ */ + describe("isViewCommand()", () => { + it("returns true for view-switching commands", () => { + assert.strictEqual(parser.isViewCommand("/skills"), true); + assert.strictEqual(parser.isViewCommand("/subagents"), true); + assert.strictEqual(parser.isViewCommand("/memories"), true); + assert.strictEqual(parser.isViewCommand("/sessions"), true); + assert.strictEqual(parser.isViewCommand("/settings"), true); + assert.strictEqual(parser.isViewCommand("/projects"), true); + }); + + it("returns false for non-view commands", () => { + assert.strictEqual(parser.isViewCommand("/quit"), false); + assert.strictEqual(parser.isViewCommand("/help"), false); + assert.strictEqual(parser.isViewCommand("/compact"), false); + assert.strictEqual(parser.isViewCommand("/clear"), false); + assert.strictEqual(parser.isViewCommand("/config set telemetry.enabled true"), false); + }); + + it("returns false for plain text and non-command input", () => { + assert.strictEqual(parser.isViewCommand("hello"), false); + assert.strictEqual(parser.isViewCommand(""), false); + assert.strictEqual(parser.isViewCommand(null), false); + }); + }); + /* ------------------------------------------------------------------ */ /* listCommands() */ /* ------------------------------------------------------------------ */ From 80119bcdd97172d56a7a1b0f163bcce563022c4e Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 08:07:32 -0400 Subject: [PATCH 13/24] fix: coalesce subagent streaming segments into continuous blocks The subagent store pushed a new segment per stream chunk, so a subagent's message arrived as dozens of tiny fragments that MessageBubble rendered as a jumbled blend. Mirror the MessageList.updateMessage coalescing rules: message chunks append to the last message segment, reasoning coalesces (backwards-search, respecting grammatical sentence boundaries), and tool segments stay as their own blocks. --- coverage.txt | 4 +- src/tui/subagentStore.js | 70 ++++++++++++++++++-- tests/unit/tui/subagentsPanel.test.js | 95 +++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 6 deletions(-) diff --git a/coverage.txt b/coverage.txt index d6bbbcc1..66e50b0f 100644 --- a/coverage.txt +++ b/coverage.txt @@ -206,7 +206,7 @@ ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 ℹ subagentsPanel.js | 96.97 | 75.00 | 85.71 | 34-36 -ℹ subagentStore.js | 100.00 | 70.37 | 100.00 | +ℹ subagentStore.js | 100.00 | 82.61 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | ℹ chunker.js | 98.81 | 95.45 | 100.00 | 159-160 @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 91.39 | 88.80 | 86.94 | +ℹ all files | 91.41 | 88.84 | 86.96 | ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/subagentStore.js b/src/tui/subagentStore.js index f5091b10..74643624 100644 --- a/src/tui/subagentStore.js +++ b/src/tui/subagentStore.js @@ -43,6 +43,68 @@ function ensureEntry(store, key) { return store[key]; } +/** + * Coalesce a new segment into an existing segment list, mirroring the + * `MessageList.updateMessage` coalescing rules so subagent streaming output + * renders as continuous blocks rather than a fragment per chunk. + * + * - `message` segments append to the last segment when the type matches, + * otherwise they start a new block. + * - `reasoning` segments coalesce with the last reasoning segment (searching + * backwards, since a message may interleave), respecting a grammatical + * sentence boundary (period/question/exclamation followed by a capital). + * - `tool` segments always start a new block (each tool result is its own + * block with its own name header). + * + * @param {Array<{type: string, content: string, name?: string}>} segments - Existing segments. + * @param {{type: string, content: string, name?: string}} newSeg - The new segment to fold in. + * @returns {Array<{type: string, content: string, name?: string}>} The coalesced segments. + */ +function coalesceSegments(segments, newSeg) { + const merged = segments.map((s) => ({ ...s })); + + if (newSeg.type === "reasoning") { + // Search backwards for the last reasoning segment — continuous reasoning + // should coalesce even if a message interleaved between chunks. + let lastReasoning = null; + for (let i = merged.length - 1; i >= 0; i--) { + if (merged[i].type === "reasoning") { + lastReasoning = merged[i]; + break; + } + } + // Grammatical sentence boundary: a period followed by a capital letter + // starts a new block; otherwise coalesce. + if ( + lastReasoning && + (lastReasoning.content.endsWith(".") || + lastReasoning.content.endsWith("?") || + lastReasoning.content.endsWith("!")) && + /^[A-Z]/.test(newSeg.content) + ) { + merged.push({ ...newSeg }); + } else if (lastReasoning) { + lastReasoning.content += newSeg.content; + } else { + merged.push({ ...newSeg }); + } + } else if (newSeg.type === "tool") { + // Each tool message gets its own segment so it renders as its own block. + merged.push({ ...newSeg }); + } else { + // Message segments require a type match to append; a mismatch (e.g., + // message after reasoning) forces a new block. + const lastSeg = merged[merged.length - 1]; + if (lastSeg && lastSeg.type === newSeg.type) { + lastSeg.content += newSeg.content; + } else { + merged.push({ ...newSeg }); + } + } + + return merged; +} + /** * Fold a single subagent event into the store. Returns a new store object so * React state updates are immutable. @@ -56,21 +118,21 @@ export function updateSubagentStore(store, event) { const next = { ...store }; const entry = ensureEntry(next, key); - const segments = [...entry.segments]; + let segments = [...entry.segments]; const toolCalls = [...entry.toolCalls]; switch (event.type) { case "subagent_message": { const text = event.data?.text || event.text || ""; if (text) { - segments.push({ type: "message", content: text }); + segments = coalesceSegments(segments, { type: "message", content: text }); } break; } case "subagent_reasoning": { const text = event.data?.text || event.text || ""; if (text) { - segments.push({ type: "reasoning", content: text }); + segments = coalesceSegments(segments, { type: "reasoning", content: text }); } break; } @@ -78,7 +140,7 @@ export function updateSubagentStore(store, event) { const text = event.data?.text || event.text || ""; const name = event.data?.name || event.name || ""; if (text) { - segments.push({ type: "tool", content: text, name }); + segments = coalesceSegments(segments, { type: "tool", content: text, name }); } break; } diff --git a/tests/unit/tui/subagentsPanel.test.js b/tests/unit/tui/subagentsPanel.test.js index 115b12c9..f2133435 100644 --- a/tests/unit/tui/subagentsPanel.test.js +++ b/tests/unit/tui/subagentsPanel.test.js @@ -94,6 +94,101 @@ describe("updateSubagentStore — namespace discrimination", () => { }); }); +describe("updateSubagentStore — segment coalescing", () => { + it("coalesces consecutive message chunks into one segment", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "Hello ", + }); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "world", + }); + assert.deepStrictEqual(store["task:abc"].segments, [ + { type: "message", content: "Hello world" }, + ]); + }); + + it("coalesces consecutive reasoning chunks into one segment", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "thinking ", + }); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "hard", + }); + assert.deepStrictEqual(store["task:abc"].segments, [ + { type: "reasoning", content: "thinking hard" }, + ]); + }); + + it("starts a new message block when a reasoning segment interleaves", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "The answer is ", + }); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "calculating...", + }); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "42", + }); + // The second message chunk does not match the last segment (reasoning), + // so it starts a new message block rather than appending to the first. + const segs = store["task:abc"].segments; + assert.strictEqual(segs.length, 3); + assert.deepStrictEqual(segs[0], { type: "message", content: "The answer is " }); + assert.deepStrictEqual(segs[1], { type: "reasoning", content: "calculating..." }); + assert.deepStrictEqual(segs[2], { type: "message", content: "42" }); + }); + + it("starts a new reasoning block at a grammatical sentence boundary", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "First thought.", + }); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "Second thought", + }); + // A period followed by a capital letter starts a new block. + assert.strictEqual(store["task:abc"].segments.length, 2); + }); + + it("keeps each tool segment as its own block", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_tool", + namespace: ["task:abc"], + text: "output one", + name: "read_file", + }); + store = updateSubagentStore(store, { + type: "subagent_tool", + namespace: ["task:abc"], + text: "output two", + name: "read_file", + }); + assert.strictEqual(store["task:abc"].segments.length, 2); + }); +}); + describe("updateSubagentStore — tool-call redirection", () => { it("records a subagent tool start", () => { const store = createSubagentStore(); From 7fe4bff583524df38ad09036e0089571efbabf02 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 08:19:19 -0400 Subject: [PATCH 14/24] feat: make ctrl+r/ctrl+t collapse toggles work in the subagents view The ctrl+r (reasoning) and ctrl+t (tool-call) collapse toggles were gated behind the conversation-view early return, so they were dead in panel views. Move them to fire before that return so they work globally, and thread the collapse state through SubagentsPanel to MessageBubble so the subagents view respects the same toggles as the conversation view. --- coverage.txt | 8 ++-- src/tui/app.js | 34 +++++++++-------- src/tui/subagentsPanel.js | 22 ++++++++--- tests/unit/tui/subagentsPanel.test.js | 53 +++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 25 deletions(-) diff --git a/coverage.txt b/coverage.txt index 66e50b0f..a6caa915 100644 --- a/coverage.txt +++ b/coverage.txt @@ -182,7 +182,7 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 43.39 | 55.81 | 21.21 | 121 131-138 148-158 166-170 178-181 202-227 234-243 252-277 293-311 321-322 329-330 337-382 386-505 518-522 525-535 538-551 567-572 574-578 580-584 586-591 593-599 601-606 613-623 630-669 694-695 +ℹ app.js | 43.26 | 55.81 | 21.21 | 121 131-138 148-158 166-170 178-181 202-227 234-243 252-277 293-311 321-322 329-330 337-382 386-505 518-522 525-535 538-551 567-572 574-578 580-584 586-591 593-599 601-608 615-625 632-671 696-697 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | ℹ commandParser.js | 97.56 | 89.87 | 100.00 | 90-95 @@ -193,7 +193,7 @@ ℹ inputPanel.js | 100.00 | 100.00 | 100.00 | ℹ markdownText.js | 95.12 | 86.02 | 96.00 | 69 199-205 220-228 231-233 ℹ memoryPanel.js | 13.04 | 100.00 | 0.00 | 14-16 24-32 43-230 -ℹ messageBubble.js | 91.09 | 87.64 | 74.29 | 297 301-319 343-348 396-401 409-422 499-502 514-517 +ℹ messageBubble.js | 94.39 | 87.50 | 77.14 | 297 301-319 343-348 499-502 514-517 ℹ messageList.js | 79.22 | 77.42 | 48.94 | 159 176-179 253-325 334 341-346 429-454 464-466 473-475 483 491 499 508-512 521-528 536 544 552 562 571-577 586 594-599 621-623 712-722 ℹ messages.js | 100.00 | 100.00 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | @@ -205,7 +205,7 @@ ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 -ℹ subagentsPanel.js | 96.97 | 75.00 | 85.71 | 34-36 +ℹ subagentsPanel.js | 97.30 | 76.47 | 85.71 | 44-46 ℹ subagentStore.js | 100.00 | 82.61 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 91.41 | 88.84 | 86.96 | +ℹ all files | 91.47 | 88.84 | 87.03 | ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/app.js b/src/tui/app.js index 1a1618fe..14dcf57a 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -403,6 +403,22 @@ function App({ setShowBanner(false); } + // Ctrl+r / Ctrl+t toggle the global collapse state for reasoning and + // tool-call blocks. These are global — they work in the conversation + // view AND in panel views (e.g. SUBAGENTS), so they must be handled + // before the panel-view early return below. Return early so the key + // never reaches the input panel (which would render the letter). + if (key.ctrl && input === "r") { + setReasoningCollapsed((prev) => !prev); + setTimeout(() => conversationAreaRef.current?.scrollToBottom?.(), 50); + return; + } + if (key.ctrl && input === "t") { + setToolCallCollapsed((prev) => !prev); + setTimeout(() => conversationAreaRef.current?.scrollToBottom?.(), 50); + return; + } + // Panel view — defer all input to the active panel via its own useInput({ isActive }) // Each panel handles Escape to return to conversation when appropriate. if (currentView !== PANELS.CONVERSATION) { @@ -454,22 +470,6 @@ function App({ return; } - // Ctrl+r / Ctrl+t toggle the global collapse state for reasoning and - // tool-call blocks. Return early so the key never reaches the input - // panel (which would render the letter). Scroll to the bottom after the - // redraw so the user isn't left far from the content when blocks expand - // or collapse. - if (key.ctrl && input === "r") { - setReasoningCollapsed((prev) => !prev); - setTimeout(() => conversationAreaRef.current?.scrollToBottom?.(), 50); - return; - } - if (key.ctrl && input === "t") { - setToolCallCollapsed((prev) => !prev); - setTimeout(() => conversationAreaRef.current?.scrollToBottom?.(), 50); - return; - } - // In search mode, Enter jumps to the next match; Shift+Enter jumps to // the previous match. The search input's ink-text-input onSubmit is a // no-op, so these keys are handled here. @@ -602,6 +602,8 @@ function App({ subagentStore: conversationAreaRef.current?.getSubagentStore?.() || {}, onViewChange: handleViewChange, activeView: currentView, + reasoningCollapsed, + toolCallCollapsed, }); } diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index f7eda380..e6f05fc9 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -14,12 +14,22 @@ import { getRoleLabel } from "./messages.js"; * to render the reasoning/message segments and a compact tool-call list. * * Props: - * subagentStore - Object keyed by namespace string, each value being - * `{ namespace, segments, toolCalls }`. - * activeView - The current active view name (from PANELS). - * onViewChange - Callback to switch back to conversation view. + * subagentStore - Object keyed by namespace string, each value being + * `{ namespace, segments, toolCalls }`. + * activeView - The current active view name (from PANELS). + * onViewChange - Callback to switch back to conversation view. + * reasoningCollapsed - Whether reasoning segments render collapsed (global + * ctrl+r toggle, shared with the conversation view). + * toolCallCollapsed - Whether tool segments render collapsed (global + * ctrl+t toggle, shared with the conversation view). */ -export function SubagentsPanel({ subagentStore = {}, activeView, onViewChange }) { +export function SubagentsPanel({ + subagentStore = {}, + activeView, + onViewChange, + reasoningCollapsed = false, + toolCallCollapsed = true, +}) { const isActive = activeView === "subagents"; const { rows } = useWindowSize(); const limit = Math.max(1, rows - 4); @@ -69,6 +79,8 @@ export function SubagentsPanel({ subagentStore = {}, activeView, onViewChange }) segments: entry.segments, streaming: false, assistantName: entry.namespace, + reasoningCollapsed, + toolCallCollapsed, }), renderToolCalls(entry.toolCalls), ), diff --git a/tests/unit/tui/subagentsPanel.test.js b/tests/unit/tui/subagentsPanel.test.js index f2133435..c73d49a4 100644 --- a/tests/unit/tui/subagentsPanel.test.js +++ b/tests/unit/tui/subagentsPanel.test.js @@ -312,4 +312,57 @@ describe("SubagentsPanel", () => { ); assert.ok(result.includes("read_file")); }); + + it("renders reasoning collapsed when reasoningCollapsed is true", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [{ type: "reasoning", content: "secret thinking" }], + toolCalls: [], + }, + }, + activeView: "subagents", + reasoningCollapsed: true, + }), + ); + // Collapsed reasoning renders the "💭 Thinking…" placeholder, not the content. + assert.ok(result.includes("Thinking")); + assert.ok(!result.includes("secret thinking")); + }); + + it("renders reasoning expanded when reasoningCollapsed is false", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [{ type: "reasoning", content: "secret thinking" }], + toolCalls: [], + }, + }, + activeView: "subagents", + reasoningCollapsed: false, + }), + ); + assert.ok(result.includes("secret thinking")); + }); + + it("renders tool content expanded when toolCallCollapsed is false", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [{ type: "tool", content: "tool output", name: "read_file" }], + toolCalls: [], + }, + }, + activeView: "subagents", + toolCallCollapsed: false, + }), + ); + assert.ok(result.includes("tool output")); + }); }); From d6d88880cc386a16940d96cf7e186f1e019965d9 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 08:43:36 -0400 Subject: [PATCH 15/24] feat: add live autoscroll to the subagents view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps fixed: 1. The subagent store was a useRef, so subagent events never triggered a React re-render — the panel showed stale output until the stream ended. Convert it to state so each update re-renders the panel live. 2. The panel had no scroll container — it truncated to the terminal height with no way to track output. Wire it to VirtualScrollView with auto-scroll-to-bottom (respecting manual scroll-up) and route the app-level scroll keys (up/down/page) to the panel when the subagents view is active. --- coverage.txt | 6 +- src/tui/app.js | 37 +++++--- src/tui/conversationArea.js | 8 +- src/tui/subagentsPanel.js | 124 ++++++++++++++++++++------ tests/unit/tui/subagentsPanel.test.js | 25 +++++- 5 files changed, 153 insertions(+), 47 deletions(-) diff --git a/coverage.txt b/coverage.txt index a6caa915..a277ddf1 100644 --- a/coverage.txt +++ b/coverage.txt @@ -182,7 +182,7 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 43.26 | 55.81 | 21.21 | 121 131-138 148-158 166-170 178-181 202-227 234-243 252-277 293-311 321-322 329-330 337-382 386-505 518-522 525-535 538-551 567-572 574-578 580-584 586-591 593-599 601-608 615-625 632-671 696-697 +ℹ app.js | 42.62 | 55.81 | 21.21 | 122 132-139 149-159 167-171 179-182 203-228 235-244 253-278 294-312 322-323 330-331 338-383 387-517 530-534 537-547 550-563 579-584 586-590 592-596 598-603 605-611 613-621 628-638 645-684 709-710 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | ℹ commandParser.js | 97.56 | 89.87 | 100.00 | 90-95 @@ -205,7 +205,7 @@ ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 -ℹ subagentsPanel.js | 97.30 | 76.47 | 85.71 | 44-46 +ℹ subagentsPanel.js | 95.53 | 71.43 | 84.62 | 56-58 83-87 ℹ subagentStore.js | 100.00 | 82.61 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 91.47 | 88.84 | 87.03 | +ℹ all files | 91.44 | 88.79 | 87.01 | ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/app.js b/src/tui/app.js index 14dcf57a..8fc7283e 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -70,6 +70,7 @@ function App({ const conversationAreaRef = useRef(null); const inputAreaRef = useRef(null); + const subagentsPanelRef = useRef(null); const messageCountRef = useRef(0); // Pending silent message to dispatch once the conversation area is mounted. // The conversation area unmounts during panel views, so the ref is null when @@ -410,12 +411,24 @@ function App({ // never reaches the input panel (which would render the letter). if (key.ctrl && input === "r") { setReasoningCollapsed((prev) => !prev); - setTimeout(() => conversationAreaRef.current?.scrollToBottom?.(), 50); + setTimeout(() => { + const target = + currentView === PANELS.SUBAGENTS + ? subagentsPanelRef.current + : conversationAreaRef.current; + target?.scrollToBottom?.(); + }, 50); return; } if (key.ctrl && input === "t") { setToolCallCollapsed((prev) => !prev); - setTimeout(() => conversationAreaRef.current?.scrollToBottom?.(), 50); + setTimeout(() => { + const target = + currentView === PANELS.SUBAGENTS + ? subagentsPanelRef.current + : conversationAreaRef.current; + target?.scrollToBottom?.(); + }, 50); return; } @@ -492,16 +505,15 @@ function App({ inputAreaRef.current?.navigateHistory("down"); } } else { - if (key.upArrow) conversationAreaRef.current?.scrollBy(-1); - if (key.downArrow) conversationAreaRef.current?.scrollBy(1); - if (key.pageUp) - conversationAreaRef.current?.scrollBy( - -(conversationAreaRef.current?.getViewportHeight?.() || 1), - ); - if (key.pageDown) - conversationAreaRef.current?.scrollBy( - conversationAreaRef.current?.getViewportHeight?.() || 1, - ); + // In the SUBAGENTS view, route scroll keys to the subagents panel + // (which has its own virtualized scroll). Otherwise route to the + // conversation area. + const scrollTarget = + currentView === PANELS.SUBAGENTS ? subagentsPanelRef.current : conversationAreaRef.current; + if (key.upArrow) scrollTarget?.scrollBy(-1); + if (key.downArrow) scrollTarget?.scrollBy(1); + if (key.pageUp) scrollTarget?.scrollBy(-(scrollTarget?.getViewportHeight?.() || 1)); + if (key.pageDown) scrollTarget?.scrollBy(scrollTarget?.getViewportHeight?.() || 1); } }); @@ -599,6 +611,7 @@ function App({ }); } else if (currentView === PANELS.SUBAGENTS) { panelComponent = React.createElement(SubagentsPanel, { + ref: subagentsPanelRef, subagentStore: conversationAreaRef.current?.getSubagentStore?.() || {}, onViewChange: handleViewChange, activeView: currentView, diff --git a/src/tui/conversationArea.js b/src/tui/conversationArea.js index 9953ce94..7cd146bf 100644 --- a/src/tui/conversationArea.js +++ b/src/tui/conversationArea.js @@ -111,7 +111,9 @@ const ConversationArea = forwardRef(function ConversationArea( // Subagent store — accumulates subagent reasoning/message segments and tool // calls keyed by stream namespace. Populated by `createStreamingHandler` // when it receives `subagent_*` events, and rendered by the SUBAGENTS panel. - const subagentStoreRef = useRef(createSubagentStore()); + // Held as state (not a ref) so each update triggers a re-render, which the + // SUBAGENTS panel needs to show live output as subagents stream. + const [subagentStore, setSubagentStore] = useState(createSubagentStore); const skillList = registry ? registry.list() : []; const parser = new CommandParser(); @@ -665,7 +667,7 @@ const ConversationArea = forwardRef(function ConversationArea( event.type === "subagent_tool_start" || event.type === "subagent_tool_end" ) { - subagentStoreRef.current = updateSubagentStore(subagentStoreRef.current, event); + setSubagentStore((prev) => updateSubagentStore(prev, event)); return; } @@ -847,7 +849,7 @@ const ConversationArea = forwardRef(function ConversationArea( * render each subagent's reasoning/message segments and tool calls. * @returns {Object} The subagent store keyed by namespace. */ - getSubagentStore: () => subagentStoreRef.current, + getSubagentStore: () => subagentStore, /** * Bulk-load conversation exchanges into the message list. * Clears existing messages and repopulates from the array. diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index e6f05fc9..5c522bca 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -1,7 +1,8 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useRef, useCallback, forwardRef, useImperativeHandle } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import { MessageBubble } from "./messageBubble.js"; import { getRoleLabel } from "./messages.js"; +import { VirtualScrollView } from "./scrollView.js"; /** * Subagents panel — renders each subagent's reasoning/message segments and @@ -13,6 +14,11 @@ import { getRoleLabel } from "./messages.js"; * store and renders each subagent as a distinct block, reusing `MessageBubble` * to render the reasoning/message segments and a compact tool-call list. * + * The panel virtualizes the subagent list via `VirtualScrollView` and + * auto-scrolls to the bottom as new subagent output arrives, so the user can + * track live subagent activity. Manual scroll-up suppresses auto-scroll until + * the user returns to the bottom. + * * Props: * subagentStore - Object keyed by namespace string, each value being * `{ namespace, segments, toolCalls }`. @@ -23,16 +29,22 @@ import { getRoleLabel } from "./messages.js"; * toolCallCollapsed - Whether tool segments render collapsed (global * ctrl+t toggle, shared with the conversation view). */ -export function SubagentsPanel({ - subagentStore = {}, - activeView, - onViewChange, - reasoningCollapsed = false, - toolCallCollapsed = true, -}) { +export const SubagentsPanel = forwardRef(function SubagentsPanel( + { + subagentStore = {}, + activeView, + onViewChange, + reasoningCollapsed = false, + toolCallCollapsed = true, + }, + ref, +) { const isActive = activeView === "subagents"; const { rows } = useWindowSize(); - const limit = Math.max(1, rows - 4); + const scrollRef = useRef(null); + // Track manual scroll-up: when the user scrolls away from the bottom, + // suppress auto-scroll until they return to the bottom. + const isUserScrolledUpRef = useRef(false); const entries = useMemo(() => { return Object.values(subagentStore || {}); @@ -48,6 +60,35 @@ export function SubagentsPanel({ { isActive }, ); + // Auto-scroll to the bottom when content grows, unless the user has scrolled + // up to read earlier output. + const handleContentHeightChange = useCallback((height, previousHeight) => { + if (!scrollRef.current || height <= previousHeight) return; + if (isUserScrolledUpRef.current) return; + scrollRef.current.scrollToBottom?.(); + }, []); + + // Track manual scroll position; resume auto-scroll when the user returns + // to the bottom. + const handleScroll = useCallback((offset) => { + const bottom = scrollRef.current?.getBottomOffset?.() || 0; + isUserScrolledUpRef.current = offset < bottom; + }, []); + + // Expose imperative scroll API so the app-level scroll keys (up/down/page) + // and the ctrl+r/ctrl+t scroll-to-bottom can route to this panel. + useImperativeHandle( + ref, + () => ({ + scrollToBottom: () => scrollRef.current?.scrollToBottom?.(), + scrollBy: (delta) => scrollRef.current?.scrollBy?.(delta), + scrollToTop: () => scrollRef.current?.scrollToTop?.(), + getViewportHeight: () => scrollRef.current?.getViewportHeight?.(), + getBottomOffset: () => scrollRef.current?.getBottomOffset?.(), + }), + [], + ); + if (!isActive) { return React.createElement(Box, null); } @@ -60,32 +101,59 @@ export function SubagentsPanel({ ); } + const viewportHeight = Math.max(1, rows - 4); + return React.createElement( Box, { flexDirection: "column", padding: 1 }, React.createElement(Text, { bold: true }, `Subagents (${entries.length})`), - ...entries.slice(0, limit).map((entry) => - React.createElement( - Box, - { key: entry.namespace, flexDirection: "column", marginBottom: 1 }, + React.createElement(VirtualScrollView, { + ref: scrollRef, + items: entries, + height: viewportHeight, + overscan: 2, + estimateHeight: (entry) => estimateSubagentHeight(entry), + renderItem: (entry) => React.createElement( - Text, - { color: "cyan", bold: true }, - `▸ ${getRoleLabel("assistant", entry.namespace)}`, + Box, + { key: entry.namespace, flexDirection: "column", marginBottom: 1 }, + React.createElement( + Text, + { color: "cyan", bold: true }, + `▸ ${getRoleLabel("assistant", entry.namespace)}`, + ), + React.createElement(MessageBubble, { + role: "assistant", + content: (entry.segments || []).map((s) => s.content).join(""), + segments: entry.segments, + streaming: false, + assistantName: entry.namespace, + reasoningCollapsed, + toolCallCollapsed, + }), + renderToolCalls(entry.toolCalls), ), - React.createElement(MessageBubble, { - role: "assistant", - content: (entry.segments || []).map((s) => s.content).join(""), - segments: entry.segments, - streaming: false, - assistantName: entry.namespace, - reasoningCollapsed, - toolCallCollapsed, - }), - renderToolCalls(entry.toolCalls), - ), - ), + onContentHeightChange: handleContentHeightChange, + onScroll: handleScroll, + }), ); +}); + +/** + * Estimate the rendered height of a subagent entry in rows, used by the + * virtualized scroll view for off-window items. The measured height corrects + * lazily as items scroll into view. + * @param {{segments?: Array<{type: string, content: string}>, toolCalls?: Array}} entry + * @returns {number} Estimated height in rows. + */ +export function estimateSubagentHeight(entry) { + // Header row + message content lines + tool-call rows. + let height = 1; + const text = (entry.segments || []).map((s) => s.content).join(""); + if (text) height += Math.ceil(text.length / 80) || 1; + // Each tool call renders as a row in the compact list. + height += (entry.toolCalls || []).length; + return Math.max(1, height); } /** diff --git a/tests/unit/tui/subagentsPanel.test.js b/tests/unit/tui/subagentsPanel.test.js index c73d49a4..1821c668 100644 --- a/tests/unit/tui/subagentsPanel.test.js +++ b/tests/unit/tui/subagentsPanel.test.js @@ -7,7 +7,7 @@ import { updateSubagentStore, namespaceKey, } from "../../../src/tui/subagentStore.js"; -import { SubagentsPanel } from "../../../src/tui/subagentsPanel.js"; +import { SubagentsPanel, estimateSubagentHeight } from "../../../src/tui/subagentsPanel.js"; describe("namespaceKey", () => { it("joins a populated namespace into a stable key", () => { @@ -366,3 +366,26 @@ describe("SubagentsPanel", () => { assert.ok(result.includes("tool output")); }); }); + +describe("estimateSubagentHeight", () => { + it("returns at least 1 row for an empty entry", () => { + assert.ok(estimateSubagentHeight({ segments: [], toolCalls: [] }) >= 1); + }); + + it("accounts for message content and tool calls", () => { + const height = estimateSubagentHeight({ + segments: [{ type: "message", content: "hello world" }], + toolCalls: [{ name: "read_file", status: "done" }], + }); + // Header (1) + content lines + tool-call rows. + assert.ok(height >= 2); + }); + + it("grows with longer content", () => { + const short = estimateSubagentHeight({ segments: [{ type: "message", content: "hi" }] }); + const long = estimateSubagentHeight({ + segments: [{ type: "message", content: "x".repeat(200) }], + }); + assert.ok(long >= short); + }); +}); From cd719214529d3d416e78a2fb9db499c0a9948df3 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 08:58:47 -0400 Subject: [PATCH 16/24] fix: lift subagent store to App so the panel updates live The subagent store lived as state in ConversationArea, but the SUBAGENTS panel read it via getSubagentStore() during App's render. A child's state change doesn't re-render the parent, so the panel only refreshed when App re-rendered for another reason (e.g. ctrl+r). Lift the store to App state and have ConversationArea report each subagent event via onSubagentUpdate, so App re-renders and passes the fresh store to the panel. Also simplify the panel to mimic the conversation list: just message bubbles, no 'Subagents (N)' header and no extra wrapper. --- coverage.txt | 14 +++++++------- src/tui/app.js | 16 +++++++++++++++- src/tui/conversationArea.js | 20 ++++++-------------- src/tui/subagentsPanel.js | 7 ------- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/coverage.txt b/coverage.txt index a277ddf1..bb80f27d 100644 --- a/coverage.txt +++ b/coverage.txt @@ -1,7 +1,7 @@ ℹ start of coverage report -ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ file | line % | branch % | funcs % | uncovered lines -ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ src | | | | ℹ agent | | | | ℹ agentDefinitions.js | 100.00 | 100.00 | 100.00 | @@ -182,7 +182,7 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 42.62 | 55.81 | 21.21 | 122 132-139 149-159 167-171 179-182 203-228 235-244 253-278 294-312 322-323 330-331 338-383 387-517 530-534 537-547 550-563 579-584 586-590 592-596 598-603 605-611 613-621 628-638 645-684 709-710 +ℹ app.js | 43.44 | 55.81 | 20.59 | 124 135 145-152 162-172 180-184 192-195 216-241 248-257 266-291 307-325 335-336 343-344 351-396 400-530 543-547 550-560 563-576 592-597 599-603 605-609 611-616 618-624 626-634 641-651 658-698 723-724 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | ℹ commandParser.js | 97.56 | 89.87 | 100.00 | 90-95 @@ -205,7 +205,7 @@ ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 -ℹ subagentsPanel.js | 95.53 | 71.43 | 84.62 | 56-58 83-87 +ℹ subagentsPanel.js | 95.35 | 71.43 | 84.62 | 55-57 82-86 ℹ subagentStore.js | 100.00 | 82.61 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | @@ -216,7 +216,7 @@ ℹ store.js | 92.15 | 90.63 | 94.74 | 145-159 226-232 242 ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | -ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 91.44 | 88.79 | 87.01 | -ℹ ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +ℹ all files | 91.44 | 88.79 | 86.95 | +ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/app.js b/src/tui/app.js index 8fc7283e..80264192 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -12,6 +12,7 @@ import { SettingsPanel } from "./settingsPanel.js"; import { SessionsPanel } from "./sessionsPanel.js"; import { ProjectsPanel } from "./projectsPanel.js"; import { SubagentsPanel } from "./subagentsPanel.js"; +import { updateSubagentStore } from "./subagentStore.js"; import { getActiveProviderConfig } from "../provider/index.js"; import { useMouseScroll, scaleScrollDelta } from "./useMouseScroll.js"; import { buildLayout, extractSelection, mapCoordToChar } from "./selectionLayout.js"; @@ -63,6 +64,11 @@ function App({ // and jump-to-next. Search mode takes precedence over normal input focus. const [searchMode, setSearchMode] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + // Subagent store — accumulated subagent activity keyed by namespace. Lifted + // to App so the SUBAGENTS panel re-renders live as subagents stream. The + // ConversationArea's streaming handler reports each subagent event here via + // onSubagentUpdate, which updates this state and re-renders the panel. + const [subagentStore, setSubagentStore] = useState({}); const lastInterruptTimeRef = useRef(0); const { exit } = useApp(); const exitRef = useRef(exit); @@ -110,6 +116,13 @@ function App({ const onContextChange = useCallback((size) => inputAreaRef.current?.setContextSize(size), []); const onCompactingChange = useCallback((val) => inputAreaRef.current?.setIsCompacting(val), []); const onInterruptInput = useCallback(() => inputAreaRef.current?.clearInput(), []); + // Fold a subagent event into the App-level subagent store. Called by the + // ConversationArea's streaming handler for each subagent_* event. Updating + // this state re-renders App, which passes the fresh store to the SUBAGENTS + // panel so it updates live. + const onSubagentUpdate = useCallback((event) => { + setSubagentStore((prev) => updateSubagentStore(prev, event)); + }, []); /** * onViewChange — switch between conversation and panel views. @@ -612,7 +625,7 @@ function App({ } else if (currentView === PANELS.SUBAGENTS) { panelComponent = React.createElement(SubagentsPanel, { ref: subagentsPanelRef, - subagentStore: conversationAreaRef.current?.getSubagentStore?.() || {}, + subagentStore, onViewChange: handleViewChange, activeView: currentView, reasoningCollapsed, @@ -666,6 +679,7 @@ function App({ onContextChange, onCompactingChange, onInterruptInput, + onSubagentUpdate, onQuit: handleQuit, onNewSession: handleNewSession, onViewChange: handleViewChange, diff --git a/src/tui/conversationArea.js b/src/tui/conversationArea.js index 7cd146bf..d69a4982 100644 --- a/src/tui/conversationArea.js +++ b/src/tui/conversationArea.js @@ -14,7 +14,6 @@ import { createSession } from "../session/factory.js"; import { setConfigValue } from "../config/loader.js"; import { isAvailable, getGcCalls } from "../memory/gc.js"; import { logger } from "../shared/logger.js"; -import { createSubagentStore, updateSubagentStore } from "./subagentStore.js"; /** /** @@ -72,6 +71,7 @@ const ConversationArea = forwardRef(function ConversationArea( onContextChange, onCompactingChange, onInterruptInput, + onSubagentUpdate, onQuit, onNewSession, onViewChange, @@ -108,12 +108,10 @@ const ConversationArea = forwardRef(function ConversationArea( const toolMessageTokensRef = useRef(0); const contextUpdateTimerRef = useRef(null); const pendingContextRef = useRef({ content: "" }); - // Subagent store — accumulates subagent reasoning/message segments and tool - // calls keyed by stream namespace. Populated by `createStreamingHandler` - // when it receives `subagent_*` events, and rendered by the SUBAGENTS panel. - // Held as state (not a ref) so each update triggers a re-render, which the - // SUBAGENTS panel needs to show live output as subagents stream. - const [subagentStore, setSubagentStore] = useState(createSubagentStore); + // Subagent store is lifted to App state. The streaming handler reports each + // subagent_* event via onSubagentUpdate, which updates App's state and + // re-renders the SUBAGENTS panel live. This component no longer owns the + // store — it just forwards events upward. const skillList = registry ? registry.list() : []; const parser = new CommandParser(); @@ -667,7 +665,7 @@ const ConversationArea = forwardRef(function ConversationArea( event.type === "subagent_tool_start" || event.type === "subagent_tool_end" ) { - setSubagentStore((prev) => updateSubagentStore(prev, event)); + onSubagentUpdate?.(event); return; } @@ -844,12 +842,6 @@ const ConversationArea = forwardRef(function ConversationArea( newSession: handleNewSession, clear: () => messageListRef.current?.clear(), addMessage, - /** - * Get the current subagent store. The SUBAGENTS panel reads this to - * render each subagent's reasoning/message segments and tool calls. - * @returns {Object} The subagent store keyed by namespace. - */ - getSubagentStore: () => subagentStore, /** * Bulk-load conversation exchanges into the message list. * Clears existing messages and repopulates from the array. diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index 5c522bca..6bcec882 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -1,7 +1,6 @@ import React, { useMemo, useRef, useCallback, forwardRef, useImperativeHandle } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import { MessageBubble } from "./messageBubble.js"; -import { getRoleLabel } from "./messages.js"; import { VirtualScrollView } from "./scrollView.js"; /** @@ -106,7 +105,6 @@ export const SubagentsPanel = forwardRef(function SubagentsPanel( return React.createElement( Box, { flexDirection: "column", padding: 1 }, - React.createElement(Text, { bold: true }, `Subagents (${entries.length})`), React.createElement(VirtualScrollView, { ref: scrollRef, items: entries, @@ -117,11 +115,6 @@ export const SubagentsPanel = forwardRef(function SubagentsPanel( React.createElement( Box, { key: entry.namespace, flexDirection: "column", marginBottom: 1 }, - React.createElement( - Text, - { color: "cyan", bold: true }, - `▸ ${getRoleLabel("assistant", entry.namespace)}`, - ), React.createElement(MessageBubble, { role: "assistant", content: (entry.segments || []).map((s) => s.content).join(""), From fa7856cccffc11f97761346b198222d8187c7407 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 10:59:56 -0400 Subject: [PATCH 17/24] fix: give subagent entries an id so the virtualized panel renders VirtualScrollView keys items by item.id (used for the height map and React keys). The subagents panel passed raw store entries with no id field, so all items collided on undefined and the panel rendered blank. Map each entry to { id: namespace, ...entry } so the scroll view can key and measure them. --- coverage.txt | 4 ++-- src/tui/subagentsPanel.js | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/coverage.txt b/coverage.txt index bb80f27d..f6f7fee6 100644 --- a/coverage.txt +++ b/coverage.txt @@ -205,7 +205,7 @@ ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 -ℹ subagentsPanel.js | 95.35 | 71.43 | 84.62 | 55-57 82-86 +ℹ subagentsPanel.js | 95.48 | 72.41 | 85.71 | 60-62 87-91 ℹ subagentStore.js | 100.00 | 82.61 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 91.44 | 88.79 | 86.95 | +ℹ all files | 91.44 | 88.79 | 86.96 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index 6bcec882..b5c90e7a 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -45,8 +45,13 @@ export const SubagentsPanel = forwardRef(function SubagentsPanel( // suppress auto-scroll until they return to the bottom. const isUserScrolledUpRef = useRef(false); + // VirtualScrollView keys items by `id` (used for the height map and React + // keys). Map each store entry to an object carrying its namespace as the id. const entries = useMemo(() => { - return Object.values(subagentStore || {}); + return Object.values(subagentStore || {}).map((entry) => ({ + id: entry.namespace, + ...entry, + })); }, [subagentStore]); // Escape returns to conversation view @@ -114,7 +119,7 @@ export const SubagentsPanel = forwardRef(function SubagentsPanel( renderItem: (entry) => React.createElement( Box, - { key: entry.namespace, flexDirection: "column", marginBottom: 1 }, + { flexDirection: "column", marginBottom: 1 }, React.createElement(MessageBubble, { role: "assistant", content: (entry.segments || []).map((s) => s.content).join(""), From c68c09e324ed8ce8b98d243b863a3ef982ecc1f6 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 11:59:46 -0400 Subject: [PATCH 18/24] fix(tui): render subagent segments with collapse/expand parity The subagents panel now renders reasoning/message/tool segments exactly like the main message list, and the global ctrl+r / ctrl+t collapse toggles apply to it. The panel threads reasoningCollapsed and toolCallCollapsed through to MessageBubble, and uses a friendly 'Subagent' assistantName label instead of the raw namespace header. --- src/tui/subagentsPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index b5c90e7a..499adb93 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -125,7 +125,7 @@ export const SubagentsPanel = forwardRef(function SubagentsPanel( content: (entry.segments || []).map((s) => s.content).join(""), segments: entry.segments, streaming: false, - assistantName: entry.namespace, + assistantName: "Subagent", reasoningCollapsed, toolCallCollapsed, }), From b9d36f9177ec1c30e50b6449abe5f7aaf985b8bc Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 12 Oct 2026 12:07:32 -0400 Subject: [PATCH 19/24] fix(tui): render subagent tool calls with wrench glyph and lowercase name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool calls in the subagents panel now render with the 🔧 glyph (matching the orchestrator's tool display) instead of a bullet list, and the assistant name label is lowercase 'subagent'. --- coverage.txt | 6 +++--- src/tui/subagentsPanel.js | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/coverage.txt b/coverage.txt index f6f7fee6..2013ba29 100644 --- a/coverage.txt +++ b/coverage.txt @@ -198,14 +198,14 @@ ℹ messages.js | 100.00 | 100.00 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | ℹ projectsPanel.js | 31.54 | 100.00 | 57.14 | 13-21 30-32 60-149 -ℹ quotes.js | 100.00 | 100.00 | 100.00 | +ℹ quotes.js | 96.97 | 87.50 | 100.00 | 62-63 ℹ scrollView.js | 96.67 | 87.10 | 57.89 | 149-153 181-183 ℹ selectionLayout.js | 100.00 | 95.00 | 100.00 | ℹ sessionsPanel.js | 12.25 | 100.00 | 0.00 | 15-17 25-33 44-253 ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 -ℹ subagentsPanel.js | 95.48 | 72.41 | 85.71 | 60-62 87-91 +ℹ subagentsPanel.js | 95.45 | 72.41 | 85.71 | 60-62 87-91 ℹ subagentStore.js | 100.00 | 82.61 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | @@ -217,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 91.44 | 88.79 | 86.96 | +ℹ all files | 91.43 | 88.77 | 86.96 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index 499adb93..8ce6cb59 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -125,7 +125,7 @@ export const SubagentsPanel = forwardRef(function SubagentsPanel( content: (entry.segments || []).map((s) => s.content).join(""), segments: entry.segments, streaming: false, - assistantName: "Subagent", + assistantName: "subagent", reasoningCollapsed, toolCallCollapsed, }), @@ -165,12 +165,11 @@ function renderToolCalls(toolCalls) { return React.createElement( Box, { flexDirection: "column", marginTop: 1 }, - React.createElement(Text, { color: "yellow", bold: true }, "Tool calls:"), ...toolCalls.map((call, idx) => React.createElement( Text, { key: idx, color: call.status === "error" ? "red" : "gray" }, - ` • ${call.name}${call.status === "running" ? " (running)" : ""}`, + `🔧 ${call.name}${call.status === "running" ? " (running)" : ""}`, ), ), ); From cdccbf4f9ba26786e6fb367b73afebacb5c75c9c Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 13 Oct 2026 07:54:44 -0400 Subject: [PATCH 20/24] feat(tui): make subagents panel a read-only expanded view The SUBAGENTS panel no longer participates in the global ctrl+r/ctrl+t collapse toggles. It always renders message bubbles expanded (reasoning and tool content visible), so it serves as a read-only live view of subagent activity. Autoscroll is preserved: the panel still follows new output unless the user scrolls up to inspect earlier content. --- src/tui/app.js | 20 +++++--------------- src/tui/subagentsPanel.js | 16 ++++++++-------- tests/unit/tui/subagentsPanel.test.js | 27 ++++----------------------- 3 files changed, 17 insertions(+), 46 deletions(-) diff --git a/src/tui/app.js b/src/tui/app.js index 80264192..22b65a29 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -418,29 +418,21 @@ function App({ } // Ctrl+r / Ctrl+t toggle the global collapse state for reasoning and - // tool-call blocks. These are global — they work in the conversation - // view AND in panel views (e.g. SUBAGENTS), so they must be handled - // before the panel-view early return below. Return early so the key + // tool-call blocks. These apply only to the conversation view — the + // SUBAGENTS panel is a read-only view that always renders expanded, so + // it does not participate in these toggles. Return early so the key // never reaches the input panel (which would render the letter). if (key.ctrl && input === "r") { setReasoningCollapsed((prev) => !prev); setTimeout(() => { - const target = - currentView === PANELS.SUBAGENTS - ? subagentsPanelRef.current - : conversationAreaRef.current; - target?.scrollToBottom?.(); + conversationAreaRef.current?.scrollToBottom?.(); }, 50); return; } if (key.ctrl && input === "t") { setToolCallCollapsed((prev) => !prev); setTimeout(() => { - const target = - currentView === PANELS.SUBAGENTS - ? subagentsPanelRef.current - : conversationAreaRef.current; - target?.scrollToBottom?.(); + conversationAreaRef.current?.scrollToBottom?.(); }, 50); return; } @@ -628,8 +620,6 @@ function App({ subagentStore, onViewChange: handleViewChange, activeView: currentView, - reasoningCollapsed, - toolCallCollapsed, }); } diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index 8ce6cb59..173b7155 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -23,18 +23,16 @@ import { VirtualScrollView } from "./scrollView.js"; * `{ namespace, segments, toolCalls }`. * activeView - The current active view name (from PANELS). * onViewChange - Callback to switch back to conversation view. - * reasoningCollapsed - Whether reasoning segments render collapsed (global - * ctrl+r toggle, shared with the conversation view). - * toolCallCollapsed - Whether tool segments render collapsed (global - * ctrl+t toggle, shared with the conversation view). + * + * This is a read-only view: it always renders the message bubbles expanded + * (reasoning and tool content visible). It does not participate in the global + * ctrl+r / ctrl+t collapse toggles, which apply only to the conversation view. */ export const SubagentsPanel = forwardRef(function SubagentsPanel( { subagentStore = {}, activeView, onViewChange, - reasoningCollapsed = false, - toolCallCollapsed = true, }, ref, ) { @@ -126,8 +124,10 @@ export const SubagentsPanel = forwardRef(function SubagentsPanel( segments: entry.segments, streaming: false, assistantName: "subagent", - reasoningCollapsed, - toolCallCollapsed, + // Read-only view — always render expanded. The subagents + // panel does not participate in the global collapse toggles. + reasoningCollapsed: false, + toolCallCollapsed: false, }), renderToolCalls(entry.toolCalls), ), diff --git a/tests/unit/tui/subagentsPanel.test.js b/tests/unit/tui/subagentsPanel.test.js index 1821c668..f71dd619 100644 --- a/tests/unit/tui/subagentsPanel.test.js +++ b/tests/unit/tui/subagentsPanel.test.js @@ -313,7 +313,9 @@ describe("SubagentsPanel", () => { assert.ok(result.includes("read_file")); }); - it("renders reasoning collapsed when reasoningCollapsed is true", () => { + it("always renders reasoning expanded (read-only view)", () => { + // The subagents panel is read-only and does not participate in the global + // collapse toggles — reasoning content is always visible. const result = renderToString( React.createElement(SubagentsPanel, { subagentStore: { @@ -324,32 +326,12 @@ describe("SubagentsPanel", () => { }, }, activeView: "subagents", - reasoningCollapsed: true, - }), - ); - // Collapsed reasoning renders the "💭 Thinking…" placeholder, not the content. - assert.ok(result.includes("Thinking")); - assert.ok(!result.includes("secret thinking")); - }); - - it("renders reasoning expanded when reasoningCollapsed is false", () => { - const result = renderToString( - React.createElement(SubagentsPanel, { - subagentStore: { - "task:abc": { - namespace: "task:abc", - segments: [{ type: "reasoning", content: "secret thinking" }], - toolCalls: [], - }, - }, - activeView: "subagents", - reasoningCollapsed: false, }), ); assert.ok(result.includes("secret thinking")); }); - it("renders tool content expanded when toolCallCollapsed is false", () => { + it("always renders tool content expanded (read-only view)", () => { const result = renderToString( React.createElement(SubagentsPanel, { subagentStore: { @@ -360,7 +342,6 @@ describe("SubagentsPanel", () => { }, }, activeView: "subagents", - toolCallCollapsed: false, }), ); assert.ok(result.includes("tool output")); From 901812f01a3eab2c6d1b7ca4c25c297e969f1369 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 13 Oct 2026 07:59:37 -0400 Subject: [PATCH 21/24] style(tui): collapse subagents panel props to satisfy oxfmt --- src/tui/subagentsPanel.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js index 173b7155..321b35ae 100644 --- a/src/tui/subagentsPanel.js +++ b/src/tui/subagentsPanel.js @@ -29,11 +29,7 @@ import { VirtualScrollView } from "./scrollView.js"; * ctrl+r / ctrl+t collapse toggles, which apply only to the conversation view. */ export const SubagentsPanel = forwardRef(function SubagentsPanel( - { - subagentStore = {}, - activeView, - onViewChange, - }, + { subagentStore = {}, activeView, onViewChange }, ref, ) { const isActive = activeView === "subagents"; From f1c6f67e3f6ee8bb7963bf537268dea9d8c8d234 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 13 Oct 2026 08:05:34 -0400 Subject: [PATCH 22/24] style(tests): annotate console.error in mcp-server fixture The standalone MCP server process uses stderr as its only channel for surfacing a fatal startup error to the parent process. Add an eslint-disable-next-line no-console annotation to satisfy the lint rule. --- tests/fixtures/mcp-server.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/fixtures/mcp-server.mjs b/tests/fixtures/mcp-server.mjs index 95be5adc..80fcb5ad 100644 --- a/tests/fixtures/mcp-server.mjs +++ b/tests/fixtures/mcp-server.mjs @@ -52,6 +52,9 @@ async function main() { } main().catch((err) => { + // Standalone MCP server process entry point — stderr is the only channel + // for surfacing a fatal startup error to the parent process. + // eslint-disable-next-line no-console console.error(err); process.exit(1); }); From 798633de0a807fa702ad05bba2cd1ffb74bf0a2c Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 13 Oct 2026 21:09:23 -0400 Subject: [PATCH 23/24] fix(tui): sync subagent bubble segments from props when no topic The SUBAGENTS panel renders MessageBubble without a topic, so the no-topic sync path is its live-update path. It only synced the streaming/tool-call locals, leaving segments and content frozen on the first chunk. Add segments/content to the sync so subagent reasoning streams continuously instead of stopping after 1-4 chars. --- coverage.txt | 22 ++++++++++++---------- src/tui/messageBubble.js | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/coverage.txt b/coverage.txt index 1de5c721..5ce40af4 100644 --- a/coverage.txt +++ b/coverage.txt @@ -30,7 +30,7 @@ ℹ tui.js | 100.00 | 100.00 | 100.00 | ℹ vector.js | 100.00 | 100.00 | 100.00 | ℹ memory | | | | -ℹ context.js | 95.73 | 69.70 | 100.00 | 112-116 +ℹ context.js | 97.44 | 78.79 | 100.00 | 114-116 ℹ expireEphemeralMemories.js | 93.15 | 73.68 | 100.00 | 25-27 68-69 ℹ gc.js | 100.00 | 100.00 | 100.00 | ℹ index.js | 100.00 | 100.00 | 100.00 | @@ -70,10 +70,10 @@ ℹ logger.js | 79.53 | 66.67 | 100.00 | 87-98 123-132 137 139-141 164-166 176-180 203-211 217-222 239 273-274 276-278 297-299 307-309 317-319 327-329 337-339 ℹ skills | | | | ℹ agentMapper.js | 83.33 | 62.50 | 100.00 | 12-13 23-25 -ℹ discoverer.js | 99.10 | 91.94 | 100.00 | 174-175 -ℹ registry.js | 87.77 | 61.29 | 77.78 | 104-105 153-162 173-175 201-207 215-219 227-231 264-265 +ℹ discoverer.js | 99.10 | 92.31 | 100.00 | 174-175 +ℹ registry.js | 87.77 | 72.97 | 77.78 | 104-105 153-162 173-175 201-207 215-219 227-231 264-265 ℹ types.js | 100.00 | 100.00 | 100.00 | -ℹ validator.js | 96.35 | 97.50 | 80.00 | 130-134 +ℹ validator.js | 100.00 | 100.00 | 80.00 | ℹ stream | | | | ℹ transformers | | | | ℹ index.js | 100.00 | 100.00 | 100.00 | @@ -182,10 +182,10 @@ ℹ yaml | | | | ℹ index.js | 100.00 | 94.44 | 100.00 | ℹ tui | | | | -ℹ app.js | 43.78 | 57.14 | 21.21 | 123 133-140 150-160 168-172 180-183 204-229 236-245 254-279 295-313 323-324 331-332 339-384 388-507 520-524 527-537 540-553 569-574 576-580 582-586 588-593 595-602 609-619 626-665 690-691 +ℹ app.js | 44.28 | 55.81 | 20.59 | 127 138 148-155 165-175 183-187 195-198 219-244 251-260 269-294 310-328 338-339 346-347 354-399 403-525 538-542 545-555 558-571 587-592 594-598 600-604 606-611 613-619 621-627 634-644 651-691 716-717 ℹ banner.js | 87.30 | 100.00 | 85.71 | 28-35 ℹ commandHelp.js | 100.00 | 100.00 | 100.00 | -ℹ commandParser.js | 97.55 | 89.87 | 100.00 | 90-95 +ℹ commandParser.js | 97.56 | 89.87 | 100.00 | 90-95 ℹ commandPicker.js | 46.40 | 100.00 | 75.00 | 102-220 ℹ conversationPanel.js | 95.45 | 95.83 | 100.00 | 26-31 ℹ filePicker.js | 43.98 | 100.00 | 83.33 | 116-264 @@ -193,18 +193,20 @@ ℹ inputPanel.js | 100.00 | 100.00 | 100.00 | ℹ markdownText.js | 95.12 | 86.02 | 96.00 | 69 199-205 220-228 231-233 ℹ memoryPanel.js | 13.04 | 100.00 | 0.00 | 14-16 24-32 43-230 -ℹ messageBubble.js | 85.64 | 85.23 | 68.57 | 292-326 343-348 396-401 408-436 476-478 499-502 514-517 -ℹ messageList.js | 64.86 | 81.40 | 29.55 | 157-162 167-171 176-179 210-243 253-325 334 341-346 392 404-414 429-454 464-466 473-475 483 491 499 508-512 521-528 536 544 552 562 571-577 586 594-599 621-623 641-645 655-656 680-694 707-744 780-788 +ℹ messageBubble.js | 93.56 | 86.60 | 77.14 | 312 316-334 358-363 411-416 514-517 529-532 +ℹ messageList.js | 79.22 | 77.42 | 48.94 | 159 176-179 253-325 334 341-346 429-454 464-466 473-475 483 491 499 508-512 521-528 536 544 552 562 571-577 586 594-599 621-623 712-722 ℹ messages.js | 100.00 | 100.00 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | ℹ projectsPanel.js | 31.54 | 100.00 | 57.14 | 13-21 30-32 60-149 ℹ quotes.js | 100.00 | 100.00 | 100.00 | -ℹ scrollView.js | 88.75 | 85.71 | 72.73 | 139-142 149-153 167-184 +ℹ scrollView.js | 96.67 | 87.10 | 57.89 | 149-153 181-183 ℹ selectionLayout.js | 100.00 | 95.00 | 100.00 | ℹ sessionsPanel.js | 12.25 | 100.00 | 0.00 | 15-17 25-33 44-253 ℹ settingsPanel.js | 75.31 | 83.87 | 76.47 | 78-92 118-144 181-189 195-199 205-207 ℹ skillsPanel.js | 89.69 | 71.43 | 71.43 | 67-69 75-81 ℹ statusBar.js | 99.06 | 98.11 | 100.00 | 29-30 +ℹ subagentsPanel.js | 95.35 | 72.41 | 85.71 | 54-56 81-85 +ℹ subagentStore.js | 100.00 | 82.61 | 100.00 | ℹ useMouseScroll.js | 100.00 | 100.00 | 100.00 | ℹ vector | | | | ℹ chunker.js | 98.81 | 95.45 | 100.00 | 159-160 @@ -215,6 +217,6 @@ ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 90.73 | 88.78 | 86.46 | +ℹ all files | 91.43 | 88.72 | 86.89 | ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/messageBubble.js b/src/tui/messageBubble.js index 12a3f8d0..5075ee6a 100644 --- a/src/tui/messageBubble.js +++ b/src/tui/messageBubble.js @@ -276,7 +276,11 @@ export function MessageBubbleInner({ const [localActiveToolCall, setLocalActiveToolCall] = useState(activeToolCall); const [localContent, setLocalContent] = useState(content); - // Sync local state from props when not using pub/sub (session restore, initial render) + // Sync local state from props when not using pub/sub (session restore, initial render). + // The SUBAGENTS panel renders MessageBubble without a `topic`, so this path is + // also the live-update path there: `segments` and `content` must track the props + // as the subagent store grows, otherwise the bubble freezes on the first chunk + // (subagent_* events route to the store, not to a pub/sub topic). useEffect(() => { if (!topic) { setLocalStreaming(streaming); @@ -284,8 +288,19 @@ export function MessageBubbleInner({ setLocalCompletedToolCalls(completedToolCalls || []); setLocalToolCallDisplay(toolCallDisplay); setLocalActiveToolCall(activeToolCall); + setSegments(initialSegments || []); + setLocalContent(content); } - }, [topic, streaming, turnDuration, completedToolCalls, toolCallDisplay, activeToolCall]); + }, [ + topic, + streaming, + turnDuration, + completedToolCalls, + toolCallDisplay, + activeToolCall, + initialSegments, + content, + ]); useEffect(() => { if (!topic) return; From 0bff72102e4fcd2b03cbdc7ac4163ff2911457d2 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 13 Oct 2026 21:27:51 -0400 Subject: [PATCH 24/24] docs: document /subagents command in README and tutorial Add the /subagents command to the command tables in README.md and docs/TUTORIAL.md. The banner and /help already list it via COMMAND_GROUPS in src/tui/commandHelp.js. --- README.md | 1 + docs/TUTORIAL.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 0dcac828..4c40711c 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,7 @@ node index.js --mode interactive --session abc123 | `/sessions` | Open the sessions panel | | `/settings` | Open the settings panel | | `/skills` | Open the skills panel | +| `/subagents` | Open the subagents panel — view each subagent's reasoning, message segments, and tool calls live during a multi-agent turn | | `@` | Open the file picker — type `@` followed by a path fragment to autocomplete file paths from the active project directory | | `Ctrl+F` | Toggle in-conversation search | | `Ctrl+R` | Toggle reasoning block collapse/expand | diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index b349b4c1..73607bfd 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -359,6 +359,7 @@ Once inside the interactive terminal, use these commands: | `/sessions` | Open the sessions panel | | `/memories` | Open the memories panel | | `/skills` | Open the skills panel | +| `/subagents` | Open the subagents panel — view each subagent's reasoning, message segments, and tool calls live during a multi-agent turn | | `/settings` | Open the settings panel | | `/projects` | Open the projects panel — select a project directory to set the active project | | `/projects clear` | Clear the active project back to the default |