Skip to content

feat: add dedicated subagent view to TUI - #1371

Open
avoidwork wants to merge 20 commits into
mainfrom
feat/tui-subagents-panel
Open

avoidwork wants to merge 20 commits into
mainfrom
feat/tui-subagents-panel

Conversation

@avoidwork

@avoidwork avoidwork commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Description

Adds a dedicated SUBAGENTS panel to the TUI that captures and renders each subagent's reasoning, message segments, and tool calls separately from the orchestrator's conversation. callProvider now captures the stream namespace so subagent subgraphs emit distinct subagent_* event types, which are routed to a dedicated subagent store keyed by namespace instead of the orchestrator's assistant bubble.

Also fixes two related issues surfaced while building the panel: view-switching slash commands (/subagents, /skills, /memories, etc.) no longer interrupt an in-flight stream, and the ConversationArea stays mounted across panel views so message list and session state survive the round-trip.

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Bugfix (non-breaking change which fixes an issue)

Testing

Added tests/unit/tui/subagentsPanel.test.js (16 tests) covering namespace discrimination, event routing, and tool-call redirection. Added tests/unit/tui/repro-subagents-escape.test.js for the Escape-to-conversation behavior. Updated tests/unit/tui/panels.test.js, tests/unit/tui.test.js, and tests/unit/tui/commandParser.test.js for the new SUBAGENTS panel and isViewCommand helper. Verified with npm run test (4219 passing), npm run lint (0 errors), and npm run coverage (subagentStore.js at 100% line coverage).

Coverage

  • Line coverage maintained

Checklist

  • npm run lint passes
  • Tests pass with maintained line coverage
  • No forbidden patterns used
  • Conventional Commit style applied

Closes #1360

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
@avoidwork avoidwork self-assigned this Oct 11, 2026
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
Archives the OpenSpec change tui-subagents-panel and syncs the delta
specs into openspec/specs/ (tui-streaming updated, tui-subagents-panel
created).
@avoidwork

Copy link
Copy Markdown
Owner Author

Audit Results — tui-subagents-panel

Goal Fulfillment

Goal Status Notes
1. Capture namespace in callProvider ✅ Complete index.js captures namespace, computes isSubagent = namespace.length > 0, emits subagent_message/subagent_reasoning/subagent_tool/subagent_tool_start/subagent_tool_end for subagents; root graph events unchanged.
2. Route subagent events in TUI ✅ Complete createStreamingHandler routes subagent_* events to subagentStoreRef.current via updateSubagentStore, returning early before touching streamingMsgIdRef.current.
3. Add SUBAGENTS panel ✅ Complete PANELS.SUBAGENTS added and registered in getPanelOrder(); src/tui/subagentsPanel.js created reusing MessageBubble.
4. Wire panel into App ✅ Complete currentView === PANELS.SUBAGENTS branch added, passing subagentStore from conversationAreaRef.current?.getSubagentStore?.().
5. Write unit tests ✅ Complete tests/unit/tui/subagentsPanel.test.js covers namespace discrimination, event routing, and tool-call redirection.
6. Verify ✅ Complete npm run test (4214 pass), npm run lint (0 errors), npm run coverage (maintained; new files 100% line coverage).

Spec Compliance

  • tui-subagents-panel spec — All ADDED requirements satisfied: subagent store keyed by namespace, segments for message/reasoning/tool, tool-call tracking.
  • tui-streaming delta spec — MODIFIED requirement satisfied: createStreamingHandler routes subagent_message/subagent_reasoning/subagent_tool_start/subagent_tool_end to the subagent store, not streamingMsgIdRef.current; root graph events continue to update the assistant bubble.

Task Completion

All 8 tasks in tasks.md are marked [x]. openspec status reports isComplete: true.

Quality Check

  • No forbidden patterns (no console.log, no empty catch, no wildcard exports).
  • New modules (subagentStore.js, subagentsPanel.js) are pure/testable and follow project conventions (2-space indent, JSDoc, camelCase).
  • Existing panels.test.js and tui.test.js updated to reflect the new panel order (7 panels).
  • npm start reaches the Ink mount but fails on raw-mode stdin — an environment limitation of the non-TTY sandbox, not a code defect.

Iterations

  • Spec audit: 1 iteration (no errors found).
  • Implementation audit: 1 iteration (no errors found).

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.
The previous fix used a non-empty-component check, but the orchestrator's
own output is emitted with a populated namespace (["model_request:<uuid>"])
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:<uuid>"]), 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:<uuid>"] and are now classified as non-subagent.
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:<id>"], 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.
@avoidwork

avoidwork commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner Author

Subagent event routing — namespace discriminator

This PR routes subagent activity to a dedicated SUBAGENTS store. The discriminator that separates orchestrator events from subagent events lives in callProvider (index.js), and it took a couple of iterations to get right. Documenting the final logic here for future reference.

The namespace structure (with subgraphs: true)

Source Namespace Length
Orchestrator message ["model_request:<uuid>"] 1
Orchestrator tool call ["tools:<id>"] 1
Subagent message ["tools:<id>", "model_request:<uuid>"] 2
Subagent tool call / result ["tools:<id>", "tools:<subid>"] 2

The rule

const isSubagent =
  Array.isArray(namespace) &&
  namespace.length > 1 &&
  namespace.some((part) => part.startsWith("tools:"));

A namespace is subagent-scoped only when it has a tools: segment and more than one segment. The orchestrator's own tool calls are emitted under a single tools: segment (length 1), so they stay in the conversation view. A subagent subgraph always adds a second segment (a nested model_request: or tools:), so it routes to the SUBAGENTS store.

Why not a UUID match?

There is no stable programmatic orchestrator identifier. The ReactAgent exposes no id/uuid attribute — its own keys are options and compactContext, and its prototype exposes graph, checkpointer, store, invoke, stream, etc. The model_request:<uuid> in the namespace is a per-request model ID, not a persistent orchestrator identity. So the discriminator is structural (namespace shape), not a UUID match.

History of the fix

  1. Initial: namespace.length > 0 — wrong. The root graph yields ["model_request:<uuid>"] (length 1), so the orchestrator's own messages were misrouted as subagent activity.
  2. Second attempt: namespace.some(part => part.startsWith("tools:")) — still wrong. The orchestrator's own tool calls are emitted under ["tools:<id>"], so they were misrouted too.
  3. Final: namespace.length > 1 && namespace.some(part => part.startsWith("tools:")) — correct. Verified empirically against the orchestrator's ls/task tool calls and subagent message/tool-call namespaces.

All 4215 tests pass; lint is clean.

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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
…name

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'.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add dedicated subagent view to TUI

1 participant