Skip to content

feat(tui): subagent operation surface — notification visibility, list, human input (#37 #38 #39) - #43

Open
yixiao5428 wants to merge 31 commits into
devfrom
agentmgmt-1-tui-subagent-surface
Open

yixiao5428 wants to merge 31 commits into
devfrom
agentmgmt-1-tui-subagent-surface

Conversation

@yixiao5428

@yixiao5428 yixiao5428 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Subplan agentmgmt-1-tui-subagent-surface: the three TUI gaps after #35, one branch, three commits. Closes #37, closes #38, closes #39 on merge.

Commits

  1. TUI:Agent 通知渲染三不一致——完成通知完全不可见,取消与 agent_send 却是全文;应统一为一行提示 #39 — delegation outcomes visible (15feed6): completion/failure notices were synthetic-only text parts, so the UserMessage renderer filtered them into invisibility. inject() now attaches {kind: "agent_notification", summary} metadata; the TUI renders one muted line and never parses the model-facing envelope.
  2. TUI:主界面按 down 在历史翻完后展开 subagent 列表(当前只能盲循环) #38 — subagent list on exhausted history (418c19a): history.atLive() + a route callback — down moves the cursor, walks history, lands on the live input, and one more down opens the list (subtree minus root/current, direct children first, grandchildren indented). Unconsumed, the keystroke reaches the same no-op move() always produced.
  3. TUI:subagent 会话不可输入,人无法直接给子 Agent 发消息 #37 — human → subagent input (760c095): AgentInbox.deliver takes a tagged InboxMessage; both kinds share one identity resolution (the target's own agent/model/variant), so the rule that a delivered message never rewrites the target keeps exactly one owner. New endpoint POST /session/{id}/agent-message accepts parts and nothing else — a pick, not an omit, closing the noReply sink hole. TUI takeover reroutes submission ahead of shell/slash/prompt and hides the agent/model readout.

Process artifacts

  • Design: docs/design/agent-management/subplans/agentmgmt-1-tui-subagent-surface.md
  • Contract audit (§6 v2): docs/audits/agentmgmt-1-tui-subagent-surface/ — expectations, path B independent audit (0 critical / 0 unresolved), decisions
  • Devlogs with metrics ×3; manual verification checklist (20 items) — final behavioral closure pending

Verification

  • opencode agent-management suite 103/103; TUI 217 pass / 4 skip / 0 fail; both packages typecheck clean
  • Path B audit: 41 items, 39 ✅ / 2 ⚠️ (all dispositioned, see decisions.md)
  • Keystroke-level down-chain tests exist but are skipped pending a more faithful session-view projection (blockers + recipe documented in place)

🤖 Generated with Claude Code

yixiao5428 and others added 10 commits September 22, 2026 16:37
 #38 #39)

Subplan agentmgmt-1-tui-subagent-surface: three commits on one branch,
ordered #39 (notification visibility) -> #38 (subagent list via down-chain)
-> #37 (human -> subagent input). TUI only.

Key decisions: notification identified via 2-field part metadata
(kind/summary, I2); history-exhausted state via history.atLive() + callback
(I3); identity resolution stays single-owner in AgentInbox.deliver via
sender-kind union, enforced at the schema layer by a parts-only HTTP payload
(I1). Baseline for all line refs: dev @ bf8ec3d.

Co-Authored-By: Claude <noreply@anthropic.com>
Step 0 of workflow §6.2: 10-section expectations extracted from the
subplan contract doc, before any implementation. Notes the no-framework
substitution for property-based invariants (example-based, INV-1..5) and
marks the dedicated contract_audit script N/A pending a second subplan.

Co-Authored-By: Claude <noreply@anthropic.com>
…ice (#39)

Completion/failure notifications were synthetic-only text parts, so the
UserMessage renderer filtered them into invisibility — the one subagent
message users most need to see was the only one they could not.

inject() now attaches {kind: "agent_notification", summary} metadata to
the notice part; the TUI renders one muted line from it and never parses
the model-facing envelope text (INV-2). Parts without the metadata —
every pre-feature transcript — render exactly as before (Step P13).
The four existing synthetic filters are untouched: a notice is not user
input, so exclusion from undo aggregation and history recall stands.

Co-Authored-By: Claude <noreply@anthropic.com>
Subagent navigation was a blind cycle: one key jumped to the first child,
left/right cycled siblings with no overview. The chain now falls through
naturally — down moves the cursor, then walks prompt history, then lands on
the live input, and one more down opens the list.

history.move() cannot express "history exhausted" (at the bottom it returns
the live item, truthy, so its !item branch is dead going down), so the store
gains atLive(). prompt.history.next hands the keystroke to the new
onHistoryNextAtBottom callback only when the line is empty and the cursor is
on the live input; unconsumed, it reaches the same no-op move() always
produced there. With no subagents the keystroke stays with the editor.

The list is the view's subtree minus the root and the current session,
direct children first, grandchildren indented; rows show the instance name
(or agent type, or the title's @type suffix) with type and live status.
Membership is a pure exported function tested without rendering; the dialog
is a thin DialogSelect wrapper whose pick reuses enterChild.

Co-Authored-By: Claude <noreply@anthropic.com>
…idelity

Three real-app tests drive the actual keymap and headless renderer through
the down chain (list opens at exhausted history; stays with the editor
without subagents or with text in the input). They boot the full app and
navigate into the session route, but the stubbed projection is not yet
faithful enough for the view to paint, so they are skipped with the exact
blocker and a verified seeding recipe (real server + bun:sqlite child rows)
documented in place — flip the skips once the view paints.

Also records the compensating verification: the real-server projection was
confirmed field-for-field against what subagentListMembers consumes.

Co-Authored-By: Claude <noreply@anthropic.com>
…mt-1

Behavioral closure for what automation cannot reach (live keystrokes,
visual layout): per-commit checklists — notification visibility (#39),
down-chain and list contents (#38), human-to-subagent input and identity
non-rewrite (#37, post-implementation) — each with expected outcomes and a
result table to backfill. Referenced from the audit Step 5 record.

Co-Authored-By: Claude <noreply@anthropic.com>
…e items

Visual truncation of the notice line (#39), in-dialog key handling and
existing navigation-key regressions (#38), selector hiding, mid-run
message delivery, file parts through the new payload, and the
permission-hoisting known limitation (#37). Also scopes the checklist
away from #35's known outstanding issues.

Co-Authored-By: Claude <noreply@anthropic.com>
2.6: the cursor-end guard only jumps to the end when already on the last
visual line; on a multi-line input elsewhere, down moves a line down.
3.3: the list shows type and status but not model — model identity is
observed on the assistant message meta line instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Subagent sessions were read-only in the TUI: visible() demanded a root
session. Opening the input naively would route the globally selected
agent/model into createUserMessage, whose setAgentModel write-back would
persist them over the subagent's own identity — the same P0 class #35
fixed for agent_send.

The inbox now takes a tagged InboxMessage: "agent" carries agent_send and
stop traffic through the existing render(); "user" carries the human's
parts behind a fixed [Message from user] header. Both flow through one
identity resolution — the target's own agent/model/variant, "default"
folded away — so the rule that a delivered message never rewrites the
target keeps exactly one owner (I1). The new endpoint accepts parts and
nothing else: a pick, not an omit, because omitting from PromptPayload
would still admit noReply, which persists a message without ever running
the loop.

The TUI wires the takeover through a Prompt prop that reroutes
submission ahead of the shell/slash/prompt branches and hides the
agent/model readout; the route injects it only for child sessions and
calls the regenerated sdk.client.session.agentMessage. Old transcripts
and the root-session path are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Independent subagent audit over the full range diff: 0 critical,
0 unresolved; I1/I2/I3 verified line-by-line. Dispositions applied:
design doc amended where the contract lagged the implementation (404
channel and routing query on the new endpoint, the dialog-stack guard,
the list row fallback and description format, takeover delegating to the
route with the editor-selection consequence); handler ops comment
corrected (inline construction, die branch unreachable); user-path test
gains the missing sessionID assertion. Test gaps that stay open (atLive,
trigger conditions, render branch, HTTP layer) are recorded in
decisions.md with their tracking path.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

Copy link
Copy Markdown

The following comment was made by an LLM, it may be inaccurate:

yixiao5428 and others added 17 commits September 23, 2026 10:05
…p wording

Smoke of the published linux-x64 asset: sha256 matches, version string
correct, embedded rg runs, /project and /agent 200, agent-message
end-to-end 204 with the message persisted as a non-synthetic
[Message from user] part. One correction the smoke surfaced: excess
payload keys are stripped by Effect Schema defaults, not rejected —
noReply cannot reach the inbox, but the mechanism is stripping, so
D14 records the distinction (missing parts still 400s). Also logged
the stale-server pitfall: first probe hit a Sep 17 zombie opencode
holding the port, which masqueraded as the new build.

Co-Authored-By: Claude <noreply@anthropic.com>
…erequisites

Co-Authored-By: Claude <noreply@anthropic.com>
…onsolidation)

Co-Authored-By: Claude <noreply@anthropic.com>
…l Task -> Agent)

Co-Authored-By: Claude <noreply@anthropic.com>
Verification findings P1+P2 from the beta's manual pass.

P1: the list becomes the single navigation surface. Members are the whole
tree — Main (the root), every subagent, and the session being viewed, with
the current one preselected via DialogSelect's `current` and tagged in its
description; the root renders as "Main". The SubagentFooter loses its
Parent/Prev/Next buttons (info line stays) and the up/left/right/
<leader>down bindings go back to the editor, along with the hidden
commands and their handlers. The transcript hint now reads
"<down> view agents".

P2: subagent tool rows no longer label themselves "Task" — the tool was
removed in #35 and the word must not resurface; the agent type leads the
title, background keeps its marker.

Commit 2's exclusion of the root and the "no subagents, no UI" behavior
are overturned by verified usage feedback; design doc, expectations
(INV-4) and the manual checklist §2 are amended accordingly.

Co-Authored-By: Claude <noreply@anthropic.com>
From a grandchild, `parentID ?? id` yields the middle node, so the list
shrank to "current + parent" and the parent wore the Main label — the
two-level idiom rode along when the list went whole-tree. treeRoot() walks
parentID to the parentless ancestor (cycle-safe, falls back to the seat
itself when the chain is missing from the projection) and the dialog
receives that as its root. Regression tests cover every seat, the broken
chain, and unknown sessions.

Co-Authored-By: Claude <noreply@anthropic.com>
The Agents list's only entry point was a gesture with no visible sign:
down at the exhausted history. A muted one-line hint now sits above the
textarea in the input box — key display follows the prompt.history.next
binding — so the gesture is discoverable before it is needed.

Co-Authored-By: Claude <noreply@anthropic.com>
The hint now gates on exactly the trigger condition — empty input at the
live history position — so it hides while typing and while browsing
history. The row keeps its height, so text toggling never shifts the
layout.

Co-Authored-By: Claude <noreply@anthropic.com>
The key renders bright against the muted rest (staying muted through a
leader chord), matching the transcript hint's emphasis pattern.

Co-Authored-By: Claude <noreply@anthropic.com>
…ters

Verification finding P3. Each subagent row now carries its delegation
blurb inline after the name (parsed from the session title's
"(@type subagent)" suffix — the description the model gave when
delegating, no new data), and at the far right a footer slot with token
spend and elapsed time: live from creation while the agent runs, frozen
to its work duration once done, ticking once per second while the dialog
is open. The Main row shows its meters too. Three pure helpers
(subagentDescription, formatListTokens, formatListElapsed) are exported
and tested without rendering — the regex caught its own missing
paren on the normal case.

Co-Authored-By: Claude <noreply@anthropic.com>
…the dialog

Verification finding P4. A panel now sits below the input with the same
row anatomy as the down dialog — Main, blurbs, type/status/current, live
token and elapsed meters — through one shared formatAgentRow owner. Rows
hover-highlight and click-to-jump; the meters tick once per second while
it is on screen. The panel appears from two tree members up and truncates
past five rows with a tail pointing at the dialog, which stays the
keyboard and filter surface.

Verification finding P3 rode along earlier in this round: delegation
blurbs parsed from session titles plus the same meters, all behind three
tested pure helpers.

Co-Authored-By: Claude <noreply@anthropic.com>
Verification findings P5, four items:

The panel container aligns to the transcript text column on the left and
the input's inner right edge on the right. The session being viewed is
highlighted — a primary-colored title behind a leading dot, matching the
dialog's selection — and the "current" text tag is gone from the row
builder, highlighting belonging to each surface. The input's top-right
"down view agents" hint is deleted; the persistent panel replaced it.

The token readout converges on one convention: a shared contextUsage
helper (last scoring assistant message, percent of the model's context
limit) is now the single owner for the footer, the dialog and the panel —
the footer refactored onto it with its display unchanged, and the panel's
session-aggregate path with its "tok" unit is deleted. Two numbers that
look alike but meant different things was the whole bug.

Co-Authored-By: Claude <noreply@anthropic.com>
Both regressions came from eyeballing instead of reading the frame: the
panel guessed 5/3 padding where the input is actually left-border plus
2/2 inner padding, so the panel now mirrors that frame exactly and the
two edges coincide. And the current marker's dot sat in front of the
indented title, poking the label two columns out of line — the indent
moves out of formatAgentRow's title into its own field, and both surfaces
render it in a fixed-width gutter the dot shares, so no row ever shifts.

Co-Authored-By: Claude <noreply@anthropic.com>
Third alignment regression, same root cause each time: the panel hand-rolled
its row layout while the dialog shipped a second one, and hand-built padding
never matches by construction. The dialog's actual structure — scrollbox
padding 1/1, rows with conditional padding 1/3, gap 1, and an Option whose
title carries its own paddingLeft — is now the only row renderer:
DialogSelect.Option is exported and the panel renders it inside the same row
container, with the indent and the current dot sharing the gutter. The
panel's indentation is the dialog's by construction.

Co-Authored-By: Claude <noreply@anthropic.com>
The route container already insets the bottom stack by 2 on each side, and
the input's bottom hints line ends exactly at that container edge — but the
panel stacked paddingRight 1 + 3 on top of it, leaving the meters four
columns short of the "commands" hint. Right padding goes to zero at both
panel levels; the left keeps the dialog's 1 + 1 so the gutter column is
untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Three refinements from verification: the dot is gone (the primary title
already marks the current session), the leftmost glyphs align with the
directory path on the hints line above (both at container+1 — Option's
built-in paddingLeft of 3 cannot reach that column, so the panel stops
reusing the dialog's row renderer and becomes a deliberately compact
layout of its own), and a line of air separates the panel from the hints
line. The design doc's single-renderer rule narrows accordingly: identical
look must not have two renderers; a deliberately different look may.

Co-Authored-By: Claude <noreply@anthropic.com>
…ecklist

Co-Authored-By: Claude <noreply@anthropic.com>
yixiao5428 and others added 4 commits September 25, 2026 13:15
Verification finding P6. The current-row dot is gone — the primary-colored
title carries the highlight — and the rows lose their gutter, which lands
the labels on the Filter "F" column (container 4) and the meters on the
esc-tail column (width - 4). Option gains two additive options (dot, pl)
so the built-in marker and the row's left padding are overridable per
dialog; every other dialog keeps its behavior by default.

Co-Authored-By: Claude <noreply@anthropic.com>
… measured test

The measurement test located the real cause of three alignment rounds:
opentui ignores paddingLeft on <text> elements, so every padding-based
calculation landed three columns off. The dialog's rows now carry the
alignment as literal content spacing (3 spaces + tree indent + label),
which always renders, and a new test renders the real DialogSelect and
asserts the label column equals the Filter "F" column and the meters
share one right edge — alignment is pinned by measurement, not arithmetic.

Co-Authored-By: Claude <noreply@anthropic.com>
@lihaokun

Copy link
Copy Markdown
Owner

审过了。实现和流程都很扎实,有两处需要先处理才能合。

必须处理

1. CI 确定性失败 —— unit (linux 1/3) 与 unit (windows 1/3) 同时挂

test/cli/run/footer.view.test.tsx 两条断言期望底栏出现 ctrl+x down subagents,实际渲染里没有(同一帧里 ctrl+x q 3 queued、ctrl+p cmd 都在)。

根因是本分支删掉了四个键位绑定且未新增任何绑定:

- session_child_first          <leader>down
- session_child_cycle          right
- session_child_cycle_reverse  left
- session_parent               up

那句底栏提示是从 session_child_first 的绑定推导出来的,而它和它的测试都来自上游(FooterSubagentState 出自 34e5809059 feat(tui): show idle session directory (#36457))。绑定删了,提示随之消失,上游测试因此失败。

两个平台的同一个分片同时失败,说明是确定性的,不是 Windows 抖动。

需要决定:让提示指向新的入口(历史翻完处的 down),还是更新那两条上游断言。

2. 与 dev 冲突 —— 抱歉,是我造成的

刚合的 #45(空仓库 unborn HEAD)动了同一批文件。git merge-tree 显示:

CONFLICT (content): packages/opencode/src/agent-management/workdir.ts
Auto-merging            packages/opencode/src/tool/agent.ts
Auto-merging            packages/opencode/test/agent-management/workdir.test.ts

只有 workdir.ts 需要手工解。#45 在那里加了 emptyWorkspace() 提取、rev-parse --verify -q 的三路判定,以及 AgentWorkdir.note 字段——和本 PR 的改动位置相邻。

实现上做得好的几处

#37 的身份陷阱不但避开了,还避得比 issue 里建议的更彻底。 issue 里指出的风险是:TUI 的提交路径带的是 local.agent.current() / local.model.current()(全局 UI 选择,且其候选集合 filter(a => a.mode !== "subagent") 明确排除 subagent),直接放开输入框会把 explore 子会话改写成 build 并持久化。

这里的做法是新加服务端端点复用 AgentInbox.deliver——和 agent_send 同一条路,身份从目标会话解析——并且:

identity resolved from the target session (the payload structurally cannot carry it, I1)

载荷类型里根本没有 agent/model 字段,不变量由类型强制而非靠约束,这比"记得别传"可靠得多。顺带把输入框上方的 agent/model 显示也隐藏了,理由("它不再描述提交会发生什么")是对的。消息扩成 { kind: "user", … } 标签联合,表头能如实标明来自用户而不是伪装成某个 agent。

#38 的 hook 正是 issue 里指出的那个"目前不存在的状态"。 onHistoryNextAtBottom 的触发条件 input.plainText.length === 0 && history.atLive()——输入为空且已在实时输入处——和 issue 里分析的一致(原来的 if (!item) return false 在向后方向是死路,因为 move() 在 index === 0 时返回 {input:"",parts:[]} 这个真值)。

#39 保留 synthetic 另附 metadata、TUI 只渲一行,正是 issue 里建议的形状:既不把通知伪装成用户输入(synthetic 在 index.tsx 里还管着"找最近一条用户消息"等四处),又让来源可见。

流程完整:§6 v2 子计划文档 + docs/audits/ 五份 + 五篇 devlog。

一处不阻塞的观察

四个绑定的删除是有意的(注释追溯到验证发现 P1:导航收敛到 Agents 列表)。收敛入口本身合理;顺带记一笔:「按 up 回父会话」这个键没了,从子会话返回现在要开列表再选。不影响合并。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment