diff --git a/README.md b/README.md index f237a321..fffdbb3e 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,7 @@ Footer 使用一套 Codicon 线性图标:`` 模型、`` context、`` - 终端变窄时按优先级隐藏次要指标,不机械截断尾部; - Subagent 与 Workflow 活动时自动出现,空闲时不占空间; - Bash、Write/Edit 与 Subagent 结果可独立选择 `full` 或 `compact`;普通 `read`、`grep`、`find`、`ls` 以及 compact Bash/Write/Edit 默认显示一行语义活动摘要,包含目标、状态与关键规模;Nerd Font 可为读取、终端、编辑、搜索和目录动作显示 Codex 风格线框图标,未安装时动词与全部信息仍保持可读; -- 折叠内容用 Pi 的 `app.tools.expand` 快捷键临时展开(默认 `Ctrl+O`),展开后直接恢复 Pi 原生参数、输出、错误、diff、耗时与 full-output 证据; +- 折叠内容用 Pi 的 `app.tools.expand` 快捷键临时展开(默认 `Ctrl+O`),展开后直接恢复 Pi 原生参数、输出、错误、diff、耗时与 full-output 证据;进入 Direct Subagent 或 Workflow child 详情页时会继承父会话的当前展开状态,详情页内切换只影响该页,不改变父会话; - Git 状态本地刷新;只有显式运行 `/pr` 才查询 GitHub PR。 `fd` 与 `rg` 是结构化模型工具,不拼接 Shell。它们默认遵守 `.gitignore`,支持 Glob、类型、Smart Case、固定字符串与上下文。`git_show`、`git_diff`、`git_log` 以结构化参数提供只读提交、差异和历史检查,并禁用仓库配置的 external diff/textconv。两类工具的输出均限制为 50 KiB / 2000 行,完整截断内容最多私有保存 10 MiB,并在 Session Shutdown 时清理。 diff --git a/docs/design/CHILD_TOOL_ACTIVITY.md b/docs/design/CHILD_TOOL_ACTIVITY.md new file mode 100644 index 00000000..55e263e8 --- /dev/null +++ b/docs/design/CHILD_TOOL_ACTIVITY.md @@ -0,0 +1,73 @@ +# Child Tool Activity Design + +- Status: validated +- Created: 2026-08-29 +- Verified: 2026-08-30 +- Source boundary: OpenPI implementation and tests in the pull request that closes issue #93 +- Related issue: https://github.com/openpi-dev/openpi/issues/93 +- Related pull request: https://github.com/openpi-dev/openpi/pull/292 +- Supersedes: none + +## Goal + +Issue #93 requires Direct Subagent and Workflow child transcript pages to use +the same compact, one-line Pi activity projection for `read`, `grep`, `find`, +`ls`, `bash`, `write`, and `edit`. The default stays compact; the configured +Pi expansion binding reveals Pi's native call and result evidence. + +## Scope and Boundaries + +This changes only the operator-facing child-session page. It does not change +child tool definitions, tool schemas, execution, permissions, model-visible +messages, transcript artifacts, replay data, or provider requests. + +The existing `AgentToolRenderLedger` remains ephemeral. A live Direct or +Workflow child has the original tool definition and result data needed by +Pi's `ToolExecutionComponent`. A persisted Workflow transcript deliberately +has only its bounded text projection, so reopening it without an in-memory +ledger continues to use the existing safe text fallback rather than inventing +or persisting reconstructed native evidence. + +## Selected Interaction Model + +The child page inherits the parent UI's `getToolsExpanded()` value when the +page opens. The `app.tools.expand` binding toggles a page-local value while +the child page is focused. It does not call `setToolsExpanded()` on the parent +UI, so inspecting a child cannot unexpectedly expand or collapse the parent +conversation after the overlay closes. The page footer displays the configured +binding and the current expand/collapse action. + +This uses the same effective compact/full setup semantics as the parent: +existing wrapped tool definitions determine the compact default, and the +standard Pi expansion binding reveals native evidence. No setup setting or +command is added. + +## Architecture + +`AgentSessionPage` owns only local interaction state. It passes `expanded` to +the shared `AgentTranscriptRenderer`, which forwards it for every tool block +to `AgentToolRenderer`. `AgentToolRenderLedger` applies the value to the +existing Pi `ToolExecutionComponent` before rendering. + +Both entry points supply the initial state through the shared page: + +- Direct Subagent takeover reads the parent UI state when it opens the + takeover overlay. +- Workflow dashboard captures that state when the dashboard overlay opens and + passes it to its transcript page. + +The Direct and Workflow document adapters remain projections into the same +renderer. There is no duplicate formatter or child-specific tool definition. + +## Validation + +Focused tests prove that a wrapped Bash child call is one compact row by +default, reveals its command and output through the native renderer when the +configured expansion binding is used, and returns to the compact row. Existing +activity-renderer tests cover pending, success, error, long output, narrow +width, and all supported tool kinds. Direct and Workflow parity is asserted +against the same renderer output. + +The repository gates are `bun run check` and `bun run test`. Manual Pi smoke +covered Direct Subagent and Workflow child pages, compact and expanded output, +parent-state inheritance, page-local toggling, and Nerd Font icons. diff --git a/docs/design/README.md b/docs/design/README.md index 7060fc38..b25c9349 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,3 +11,4 @@ These records predate [`Decision 0001`](../decisions/0001-documentation-and-evid - [`PI_COMMUNITY_PACKAGE_AUDIT_2026-08-09.md`](PI_COMMUNITY_PACKAGE_AUDIT_2026-08-09.md) — code-grounded comparison of leading community packages - [`OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md`](OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md) — Pi-native Workflow interface and lifecycle redesign with implementation evidence - [`WORKFLOW_INVOCATION_GRAPH.md`](WORKFLOW_INVOCATION_GRAPH.md) — durable invocation facts, same-run handoff refs, reusable operators, and derived graph semantics +- [`CHILD_TOOL_ACTIVITY.md`](CHILD_TOOL_ACTIVITY.md) — shared compact and Pi-native expanded evidence projection for Direct Subagent and Workflow child transcripts diff --git a/extensions/shared/agent-session-page.ts b/extensions/shared/agent-session-page.ts index 5f04e726..7fe7e007 100644 --- a/extensions/shared/agent-session-page.ts +++ b/extensions/shared/agent-session-page.ts @@ -38,6 +38,11 @@ export interface AgentSessionPageSource { abort?(): void; } +export interface AgentSessionPageOptions { + /** Inherited from the parent UI when the child page opens. */ + readonly toolsExpanded?: boolean; +} + function safeLine(value: string) { return sanitizeTerminalText(value).replace(/\s+/g, " ").trim(); } @@ -76,6 +81,7 @@ export class AgentSessionPage implements Component, Focusable { private viewport = new TranscriptViewport(); private rowCount = 0; private viewportSize = 1; + private toolsExpanded: boolean; private _focused = false; get focused() { @@ -90,15 +96,22 @@ export class AgentSessionPage implements Component, Focusable { theme: Theme, keybindings: KeybindingsManager, source: AgentSessionPageSource, + options?: AgentSessionPageOptions, ) { this.tui = tui; this.theme = theme; this.keybindings = keybindings; this.source = source; + this.toolsExpanded = options?.toolsExpanded === true; } handleInput(data: string) { const state = this.source.getState(); + if (this.keybindings.matches(data, "app.tools.expand")) { + this.toolsExpanded = !this.toolsExpanded; + this.tui.requestRender(); + return; + } if ( this.source.abort && state?.status === "running" && @@ -229,6 +242,7 @@ export class AgentSessionPage implements Component, Focusable { const transcriptCapacity = Math.max(1, bodyHeight - errorRows); const transcript = this.renderer.render(state.document, width, this.theme, { now, + expanded: this.toolsExpanded, }); this.rowCount = transcript.length; this.viewportSize = transcriptCapacity; @@ -282,6 +296,10 @@ export class AgentSessionPage implements Component, Focusable { const hints: ScreenHint[] = []; hints.push([keys("app.interrupt"), "back"]); if (this.source.abort) hints.push([keys("app.clear"), "abort run"]); + hints.push([ + keys("app.tools.expand"), + this.toolsExpanded ? "collapse tools" : "expand tools", + ]); hints.push( [ `${keys("tui.editor.cursorUp")}/${keys("tui.editor.cursorDown")}`, diff --git a/extensions/shared/agent-tool-renderer.ts b/extensions/shared/agent-tool-renderer.ts index 7e3e47d8..9b43e22c 100644 --- a/extensions/shared/agent-tool-renderer.ts +++ b/extensions/shared/agent-tool-renderer.ts @@ -10,6 +10,8 @@ export interface AgentToolRenderRequest { readonly toolId: string; readonly name: string; readonly cwd?: string; + /** The child page's local Pi-native evidence visibility. */ + readonly expanded?: boolean; } /** Operator-only projection. It is deliberately absent from persisted state. */ @@ -34,6 +36,7 @@ interface ToolExecutionRecord { isPartial: boolean; component?: ToolExecutionComponent; componentCwd?: string; + componentExpanded?: boolean; } const inertTui = { @@ -99,6 +102,7 @@ export class AgentToolRenderLedger implements AgentToolRenderer { current.definition = definition; current.component = undefined; current.componentCwd = undefined; + current.componentExpanded = undefined; } current.name = name; if (current.args !== args) { @@ -190,6 +194,7 @@ export class AgentToolRenderLedger implements AgentToolRenderer { cwd, ); execution.componentCwd = cwd; + execution.componentExpanded = false; if (execution.executionStarted) execution.component.markExecutionStarted(); if (execution.argsComplete) execution.component.setArgsComplete(); @@ -197,6 +202,11 @@ export class AgentToolRenderLedger implements AgentToolRenderer { execution.component.updateResult(execution.result, execution.isPartial); } } + const expanded = request.expanded === true; + if (execution.componentExpanded !== expanded) { + execution.component.setExpanded(expanded); + execution.componentExpanded = expanded; + } return execution.component.render(width); } diff --git a/extensions/shared/agent-transcript.ts b/extensions/shared/agent-transcript.ts index 86b546cc..5c6dccba 100644 --- a/extensions/shared/agent-transcript.ts +++ b/extensions/shared/agent-transcript.ts @@ -164,8 +164,12 @@ function renderToolBlock( now: number, cwd?: string, toolRenderer?: AgentToolRenderer, + expanded = false, ) { - const native = toolRenderer?.renderTool({ toolId, name, cwd }, width); + const native = toolRenderer?.renderTool( + { toolId, name, cwd, expanded }, + width, + ); if (native) return native; const { args, fallback } = parseToolArgsPreview(argsPreview); return [ @@ -194,6 +198,7 @@ function renderAssistantItem( now: number, cwd?: string, toolRenderer?: AgentToolRenderer, + expanded = false, ) { const out = renderAssistantParts(item.parts, width); for (const part of item.parts) { @@ -215,6 +220,7 @@ function renderAssistantItem( now, cwd, toolRenderer, + expanded, ), ); } @@ -230,6 +236,7 @@ function renderToolResultItem( now: number, cwd?: string, toolRenderer?: AgentToolRenderer, + expanded = false, ) { if (paired) return []; return renderToolBlock( @@ -243,6 +250,7 @@ function renderToolResultItem( now, cwd, toolRenderer, + expanded, ); } @@ -294,6 +302,7 @@ function renderTranscriptItem( now: number, cwd?: string, toolRenderer?: AgentToolRenderer, + expanded = false, ) { if (item.kind === "user") return renderUserText(item.text, width); if (item.kind === "assistant") { @@ -305,6 +314,7 @@ function renderTranscriptItem( now, cwd, toolRenderer, + expanded, ); } return renderToolResultItem( @@ -315,6 +325,7 @@ function renderTranscriptItem( now, cwd, toolRenderer, + expanded, ); } @@ -410,10 +421,11 @@ export class AgentTranscriptRenderer { document: AgentTranscriptDocument, width: number, theme: Theme, - options?: { readonly now?: number }, + options?: { readonly now?: number; readonly expanded?: boolean }, ) { const out: string[] = []; const now = options?.now ?? Date.now(); + const expanded = options?.expanded === true; const liveTools = document.liveTools ?? []; if (document.toolRenderer) this.toolRenderers.add(document.toolRenderer); const liveIds = new Set(liveTools.map((tool) => tool.toolId)); @@ -435,6 +447,7 @@ export class AgentTranscriptRenderer { now, document.cwd, document.toolRenderer, + expanded, ); if (!cached && cacheable) { const widths = this.itemCache.get(item) ?? new Map(); @@ -481,6 +494,7 @@ export class AgentTranscriptRenderer { now, document.cwd, document.toolRenderer, + expanded, ), ); } diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index 9f406197..94407209 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -66,6 +66,8 @@ function statusGlyph( export interface TakeoverOptions { readonly badge?: string; + /** Captured from the parent UI when this child page opens. */ + readonly toolsExpanded?: boolean; } export async function openSubagentTakeover( @@ -75,9 +77,21 @@ export async function openSubagentTakeover( options?: TakeoverOptions, ) { if (!view.get(id)) return; + const takeoverOptions: TakeoverOptions = { + ...options, + toolsExpanded: ctx.ui.getToolsExpanded(), + }; await ctx.ui.custom( (tui, theme, keybindings, done) => - new TakeoverView(tui, theme, keybindings, id, view, done, options), + new TakeoverView( + tui, + theme, + keybindings, + id, + view, + done, + takeoverOptions, + ), { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" }, @@ -407,30 +421,36 @@ export class TakeoverView implements Component, Focusable { done: (value: null) => void, options?: TakeoverOptions, ) { - this.page = new AgentSessionPage(tui, theme, keybindings, { - getState: () => { - const snap = view.get(id); - if (!snap) return undefined; - return { - id: snap.id, - title: snap.title, - status: snap.status, - document: subagentTranscriptDocument( - snap, - view.getToolRenderer?.(id), - ), - metadata: [ - options?.badge, - snap.meta.modelLabel, - formatContextUtilization(snap.usage), - formatElapsed(snap), - ], - errorText: snap.errorText, - }; + this.page = new AgentSessionPage( + tui, + theme, + keybindings, + { + getState: () => { + const snap = view.get(id); + if (!snap) return undefined; + return { + id: snap.id, + title: snap.title, + status: snap.status, + document: subagentTranscriptDocument( + snap, + view.getToolRenderer?.(id), + ), + metadata: [ + options?.badge, + snap.meta.modelLabel, + formatContextUtilization(snap.usage), + formatElapsed(snap), + ], + errorText: snap.errorText, + }; + }, + close: () => this.close(done), + abort: () => view.requestAbort(id), }, - close: () => this.close(done), - abort: () => view.requestAbort(id), - }); + { toolsExpanded: options?.toolsExpanded }, + ); this.unsubscribe = view.subscribeTo(id, () => { this.refreshTicker(view.get(id), tui); this.scheduleRender(tui); diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 0f97608f..b1f4ebec 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -721,6 +721,7 @@ export class WorkflowDashboard { private startedSince: number; private close: () => void; private onAbort?: (runId: string) => boolean; + private initialToolsExpanded: boolean; constructor( tui: TUI, @@ -733,6 +734,7 @@ export class WorkflowDashboard { close: () => void, initialRunId?: string, onAbort?: (runId: string) => boolean, + initialToolsExpanded = false, ) { this.tui = tui; this.theme = theme; @@ -743,6 +745,7 @@ export class WorkflowDashboard { this.startedSince = startedSince; this.close = close; this.onAbort = onAbort; + this.initialToolsExpanded = initialToolsExpanded; this.refresh(); if (initialRunId) { const resolution = resolveWorkflowRunTarget( @@ -1040,6 +1043,7 @@ export class WorkflowDashboard { this.tui.requestRender(); }, }, + { toolsExpanded: this.initialToolsExpanded }, ); this.tui.requestRender(); } @@ -1425,6 +1429,7 @@ export async function showWorkflowDashboard( }, initialRunId, onAbort, + ctx.ui.getToolsExpanded(), ); return dashboard; }, diff --git a/extensions/workflows/transcript.ts b/extensions/workflows/transcript.ts index 0d6b1847..7f15d114 100644 --- a/extensions/workflows/transcript.ts +++ b/extensions/workflows/transcript.ts @@ -133,7 +133,7 @@ export class WorkflowTranscriptRenderer { cwd: string | undefined, width: number, theme: Theme, - options?: { readonly now?: number }, + options?: { readonly now?: number; readonly expanded?: boolean }, ) { return this.renderer.render( this.adapter.document(transcript, cwd), diff --git a/tests/extensions/shared/agent-session-page.test.ts b/tests/extensions/shared/agent-session-page.test.ts index f0dfd9ef..f325d744 100644 --- a/tests/extensions/shared/agent-session-page.test.ts +++ b/tests/extensions/shared/agent-session-page.test.ts @@ -4,12 +4,19 @@ import type { KeybindingsManager, Theme, } from "@earendil-works/pi-coding-agent"; -import { initTheme } from "@earendil-works/pi-coding-agent"; +import { + createBashToolDefinition, + defineTool, + initTheme, +} from "@earendil-works/pi-coding-agent"; +import { stripVTControlCharacters } from "node:util"; import { visibleWidth, type TUI } from "@earendil-works/pi-tui"; +import { withActivityRenderer } from "../../../extensions/file-mutation-display/render.ts"; import { AgentSessionPage, type AgentSessionPageState, } from "../../../extensions/shared/agent-session-page.ts"; +import { AgentToolRenderLedger } from "../../../extensions/shared/agent-tool-renderer.ts"; initTheme("dark", false); @@ -47,6 +54,54 @@ function state(): AgentSessionPageState { }; } +function bashState() { + const toolRenderer = new AgentToolRenderLedger(); + const toolId = "bash-1"; + const command = "printf command-marker"; + const output = "output-marker\nsecond output line"; + toolRenderer.start( + toolId, + "bash", + { command }, + defineTool(withActivityRenderer(createBashToolDefinition("/workspace"))), + ); + toolRenderer.end( + toolId, + "bash", + { content: [{ type: "text", text: output }] }, + false, + ); + return { + id: "child-bash", + title: "bash evidence", + status: "done" as const, + document: { + toolRenderer, + cwd: "/workspace", + items: [ + { + kind: "assistant" as const, + parts: [ + { + type: "toolCall" as const, + toolId, + name: "bash", + argsPreview: JSON.stringify({ command }), + }, + ], + }, + { + kind: "toolResult" as const, + toolId, + name: "bash", + isError: false, + outputPreview: output, + }, + ], + }, + }; +} + test("Direct and Workflow children use one read-only full-terminal page", () => { const direct = new AgentSessionPage(tui(18), theme, keybindings, { getState: state, @@ -80,3 +135,52 @@ test("a read-only Workflow child returns left without stealing a parent session" page.handleInput("left"); assert.equal(closed, 1); }); + +test("a child page toggles shared compact tool rows into Pi-native evidence", () => { + let renders = 0; + const page = new AgentSessionPage( + { + terminal: { rows: 24 }, + requestRender() { + renders += 1; + }, + } as unknown as TUI, + theme, + keybindings, + { getState: bashState, close() {} }, + ); + const render = () => stripVTControlCharacters(page.render(100).join("\n")); + + const compact = render(); + assert.match(compact, /Ran\s+printf command-marker/); + assert.doesNotMatch(compact, /output-marker/); + assert.match(compact, /expand\s+expand tools/); + + page.handleInput("app.tools.expand"); + const expanded = render(); + assert.equal(renders, 1); + assert.match(expanded, /output-marker/); + assert.match(expanded, /second output line/); + assert.match(expanded, /expand\s+collapse tools/); + + page.handleInput("app.tools.expand"); + const collapsedAgain = render(); + assert.equal(renders, 2); + assert.match(collapsedAgain, /Ran\s+printf command-marker/); + assert.doesNotMatch(collapsedAgain, /output-marker/); +}); + +test("a child page inherits an expanded parent state on its first render", () => { + const page = new AgentSessionPage( + tui(24), + theme, + keybindings, + { getState: bashState, close() {} }, + { toolsExpanded: true }, + ); + + assert.match( + stripVTControlCharacters(page.render(100).join("\n")), + /output-marker/, + ); +}); diff --git a/tests/extensions/workflows/transcript.test.ts b/tests/extensions/workflows/transcript.test.ts index 4981ff04..517bbd05 100644 --- a/tests/extensions/workflows/transcript.test.ts +++ b/tests/extensions/workflows/transcript.test.ts @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + createBashToolDefinition, defineTool, initTheme, type Theme, } from "@earendil-works/pi-coding-agent"; +import { stripVTControlCharacters } from "node:util"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; +import { withActivityRenderer } from "../../../extensions/file-mutation-display/render.ts"; import { AgentToolRenderLedger } from "../../../extensions/shared/agent-tool-renderer.ts"; import { AgentTranscriptRenderer } from "../../../extensions/shared/agent-transcript.ts"; import type { SubagentSnapshot } from "../../../extensions/subagents/src/domain.ts"; @@ -191,6 +194,91 @@ test("Workflow children use the same native renderer as Direct children", () => assert.match(workflowLines.join("\n"), /native result/); }); +test("Direct and Workflow children share compact and expanded Pi tool evidence", () => { + const toolId = "bash-1"; + const command = "printf shared-command"; + const output = "shared-output\nsecond shared line"; + const renderer = new AgentToolRenderLedger(); + renderer.start( + toolId, + "bash", + { command }, + defineTool(withActivityRenderer(createBashToolDefinition("/workspace"))), + ); + renderer.end( + toolId, + "bash", + { content: [{ type: "text", text: output }] }, + false, + ); + const direct: SubagentSnapshot = { + ...directSnapshot(), + cwd: "/workspace", + transcript: [ + { + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId, + name: "bash", + argsPreview: JSON.stringify({ command }), + }, + ], + }, + { + kind: "toolResult", + toolId, + name: "bash", + isError: false, + outputPreview: output, + }, + ], + }; + const workflow = bindWorkflowToolRenderer( + [ + { + role: "tool" as const, + name: "bash", + toolCallId: toolId, + text: JSON.stringify({ command }), + }, + { + role: "toolResult" as const, + name: "bash", + toolCallId: toolId, + text: output, + }, + ], + renderer, + ); + + for (const expanded of [false, true]) { + const directLines = new AgentTranscriptRenderer().render( + subagentTranscriptDocument(direct, renderer), + 100, + theme, + { now: 0, expanded }, + ); + const workflowLines = new WorkflowTranscriptRenderer().render( + workflow, + direct.cwd, + 100, + theme, + { now: 0, expanded }, + ); + assert.deepEqual(workflowLines, directLines); + const rendered = stripVTControlCharacters(workflowLines.join("\n")); + assert.match(rendered, /printf shared-command/); + if (expanded) { + assert.match(rendered, /shared-output/); + assert.match(rendered, /second shared line/); + } else { + assert.doesNotMatch(rendered, /shared-output/); + } + } +}); + test("old Workflow transcript entries without call ids remain renderable", () => { const lines = new WorkflowTranscriptRenderer().render( [