Skip to content

Commit 839b57b

Browse files
committed
feat(tui): add atomic tool output mode
1 parent a2632b7 commit 839b57b

30 files changed

Lines changed: 1909 additions & 189 deletions

packages/coding-agent/src/core/changes.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# changes
22

3+
## Bundled extension tools retain built-in provenance (2026-08-05)
4+
5+
### What changed
6+
7+
- `resource-loader.ts` marks bundled built-in extension entry paths as `source: "builtin"` when applying extension,
8+
command, and tool source information.
9+
10+
### Why
11+
12+
- Bundled codemode is loaded from its package path, but its Eval tool still needs the same trusted provenance as
13+
factory-backed built-ins. Treating it as a user-local extension disabled trusted atomic metadata at runtime.
14+
15+
### Why this cannot be expressed externally
16+
17+
- Source provenance is assigned while the core resource loader constructs the authoritative extension registry.
18+
19+
### Expected merge conflict zones
20+
21+
- LOW: `resource-loader.ts` extension source-info assignment.
22+
23+
## Tool output keybinding describes the three-state cycle (2026-08-05)
24+
25+
### What changed
26+
27+
- The `app.tools.expand` action description now names collapsed, expanded, and atomic output instead of describing a
28+
boolean expansion toggle.
29+
30+
### Why
31+
32+
- Ctrl+O now cycles three output modes, so help and keybinding discovery must describe the action users actually get.
33+
34+
### Why this cannot be expressed externally
35+
36+
- The action description is owned by the core keybinding registry consumed by built-in help surfaces.
37+
38+
### Expected merge conflict zones
39+
40+
- LOW: `keybindings.ts` `app.tools.expand` description.
41+
342
## Bound provider-timeout retry continuations (2026-08-05)
443

544
### What changed

packages/coding-agent/src/core/extensions/changes.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Core Extensions Changes
22

3+
## 2026-08-05 - Tool renderers can release suspended state
4+
5+
### What changed and why
6+
7+
- `ToolDefinition` gains an optional `disposeRenderState(state)` lifecycle hook.
8+
- The interactive tool shell calls the hook when a renderer is suspended or disposed, so extensions can release
9+
renderer-owned timers and resources instead of leaving invisible work active in atomic mode.
10+
11+
### Why this cannot be expressed externally
12+
13+
- Renderer state is created and retained by the built-in interactive shell; only the shared tool-definition contract
14+
can provide a lifecycle boundary for every extension renderer.
15+
16+
### Expected merge conflict zones
17+
18+
- LOW: `types.ts` `ToolDefinition` renderer members.
19+
- LOW: interactive `tool-execution-renderer.ts` suspension and disposal.
20+
321
## 2026-08-03 - ExtensionContext exposes the resolved agent dir
422

523
### What changed and why

packages/coding-agent/src/core/extensions/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,9 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
632632
theme: Theme,
633633
context: ToolRenderContext<TState, Static<TParams>>,
634634
) => Component;
635+
636+
/** Release renderer-owned resources when the tool view is suspended or disposed. */
637+
disposeRenderState?: (state: TState) => void;
635638
}
636639

637640
type AnyToolDefinition = ToolDefinition<any, any, any>;

packages/coding-agent/src/core/keybindings.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ export const KEYBINDINGS = {
8686
},
8787
"app.model.select": { defaultKeys: "ctrl+l", description: "Open model selector" },
8888
"app.history.search": { defaultKeys: "ctrl+r", description: "Search prompt history across sessions" },
89-
"app.tools.expand": { defaultKeys: "ctrl+o", description: "Toggle tool output" },
89+
"app.tools.expand": {
90+
defaultKeys: "ctrl+o",
91+
description: "Cycle collapsed, expanded, and atomic tool output",
92+
},
9093
"app.thinking.toggle": {
9194
defaultKeys: "ctrl+t",
9295
description: "Toggle thinking blocks",

packages/coding-agent/src/core/resource-loader.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { loadPromptTemplates } from "./prompt-templates.ts";
3939
import { SettingsManager } from "./settings-manager.ts";
4040
import type { Skill } from "./skills.ts";
4141
import { loadSkills } from "./skills.ts";
42-
import { createSourceInfo, type SourceInfo } from "./source-info.ts";
42+
import { createSourceInfo, createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
4343
import { resetTimings, time } from "./timings.ts";
4444

4545
export interface ResourceExtensionPaths {
@@ -997,10 +997,15 @@ export class DefaultResourceLoader implements ResourceLoader {
997997
}
998998

999999
private applyExtensionSourceInfo(extensions: Extension[], metadataByPath: Map<string, PathMetadata>): void {
1000+
const bundledExtensionPaths = this.getBundledExtensionEntryPaths();
10001001
for (const extension of extensions) {
1001-
extension.sourceInfo =
1002-
this.findSourceInfoForPath(extension.path, undefined, metadataByPath) ??
1003-
this.getDefaultSourceInfoForPath(extension.path);
1002+
extension.sourceInfo = bundledExtensionPaths.has(extension.resolvedPath)
1003+
? createSyntheticSourceInfo(extension.path, {
1004+
source: "builtin",
1005+
baseDir: dirname(extension.resolvedPath),
1006+
})
1007+
: (this.findSourceInfoForPath(extension.path, undefined, metadataByPath) ??
1008+
this.getDefaultSourceInfoForPath(extension.path));
10041009
for (const command of extension.commands.values()) {
10051010
command.sourceInfo = extension.sourceInfo;
10061011
}

packages/coding-agent/src/core/tools/bash.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,10 @@ export function createBashToolDefinition(
533533
component.invalidate();
534534
return component;
535535
},
536+
disposeRenderState(state) {
537+
if (state.interval) clearInterval(state.interval);
538+
state.interval = undefined;
539+
},
536540
};
537541
}
538542

packages/coding-agent/src/core/tools/changes.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# core/tools changes
22

3+
## bash renderer cleanup on suspension (2026-08-05)
4+
5+
### What changed
6+
7+
- `bash.ts` implements the renderer-state disposal hook and clears its elapsed-time interval when the interactive
8+
shell suspends or disposes the renderer.
9+
10+
### Why
11+
12+
- Atomic mode replaces the classic Bash view. Its hidden elapsed timer must not continue requesting renders while the
13+
atomic row is active.
14+
15+
### Why extension system couldn't handle this
16+
17+
- The interval belongs to the built-in Bash renderer state and must be released by that definition's lifecycle hook.
18+
19+
### Expected merge conflict zones
20+
21+
- LOW: `bash.ts` renderer members in `createBashToolDefinition()`.
22+
323
## source-backed write result patches (2026-07-21)
424

525
### What changed

packages/coding-agent/src/modes/interactive/changes.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
# changes
22

3+
## Atomic one-line tool output mode (2026-08-05)
4+
5+
### What changed
6+
7+
- `app.tools.expand` now cycles collapsed, expanded, and atomic tool output.
8+
- Atomic mode renders ordinary tools as adjacent full-width one-line rows with zero horizontal container padding or hard-coded leading spaces, exact bold tool names, shallow known-tool metadata, and original spinner placement.
9+
- Bash places its active spinner immediately after the tool name; Eval follows its native reading order: `eval`, target, active spinner, then call count.
10+
- Ctrl+O reaches direct, nested, and still-pending tool components while preserving top-level expansion ownership for non-tool components.
11+
- Monitor, Todo, and Goal tools retain their dedicated renderers, and classic, Grok, and extension self-renderers restore when leaving atomic mode.
12+
- Metadata observation is bounded, strips terminal and bidirectional control characters, accepts only safe-integer
13+
counts, and separates Eval targets from trusted facts with the reserved delimiter.
14+
- Built-in metadata and passthrough behavior require registered built-in provenance, so same-name extension overrides remain untrusted.
15+
- Live tool trust is captured by session and tool-call ID; transcript rebuilds reuse captured execution-time provenance,
16+
while historical calls without it default to untrusted.
17+
- Entering atomic mode suspends hidden renderers and releases renderer-owned resources through an explicit lifecycle hook.
18+
- `components/tool-execution-controller.ts` owns mode selection, atomic eligibility, render caching, suspension/resume,
19+
and spinner policy so the high-frequency component remains below the repository size ceiling.
20+
21+
### Why
22+
23+
- Dense tool activity needs a compact scan mode without losing the command target, progress, or useful result counts.
24+
25+
### Why this cannot be expressed externally
26+
27+
- Atomic spacing, renderer restoration, and mode propagation depend on the built-in tool component tree and global interactive keybinding state.
28+
29+
### Integration boundary
30+
31+
- This change does not alter TUI scrollback replay.
32+
- Expected merge conflict zones for future upstream ports are `interactive-mode.ts`, `components/tool-execution.ts`,
33+
`components/tool-execution-controller.ts`, `components/tool-execution-renderer.ts`, `tool-call-provenance.ts`,
34+
keybinding/help copy, and this record.
35+
336
## Server fallback abort uses one TUI notice (2026-08-05)
437

538
### What changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { atomicTarget, bashFailureStatus, observedBashLines, sanitizeAtomicLabel } from "./atomic-tool-observation.ts";
2+
import type { ToolExecutionIdentity, ToolExecutionRenderState } from "./tool-execution-types.ts";
3+
4+
const PASSTHROUGH_TOOLS = new Set(["monitor", "todo", "create_goal", "get_goal", "update_goal"]);
5+
const STATUS_TOOLS = new Set([
6+
"task",
7+
"task_create",
8+
"task_get",
9+
"task_list",
10+
"task_update",
11+
"team_create",
12+
"team_delete",
13+
]);
14+
15+
function record(value: unknown): Record<string, unknown> | undefined {
16+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
17+
}
18+
19+
function count(value: unknown): number | undefined {
20+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
21+
}
22+
23+
function plural(value: number, noun: string): string {
24+
return `${value} ${noun}${value === 1 ? "" : "s"}`;
25+
}
26+
27+
function knownCount(toolName: string, args: unknown, details: unknown): string | undefined {
28+
const detailFields = record(details);
29+
const argFields = record(args);
30+
switch (toolName) {
31+
case "read": {
32+
const totalLines = count(record(detailFields?.truncation)?.totalLines);
33+
return totalLines === undefined ? undefined : plural(totalLines, "line");
34+
}
35+
case "lsp_diagnostics": {
36+
const diagnostics = count(detailFields?.totalDiagnostics);
37+
return diagnostics === undefined ? undefined : plural(diagnostics, "diagnostic");
38+
}
39+
case "lsp_find_references": {
40+
const references =
41+
count(detailFields?.totalReferences) ??
42+
(Array.isArray(detailFields?.references) ? detailFields.references.length : undefined);
43+
return references === undefined ? undefined : plural(references, "reference");
44+
}
45+
case "lsp_symbols": {
46+
const symbols =
47+
count(detailFields?.totalSymbols) ??
48+
(Array.isArray(detailFields?.symbols) ? detailFields.symbols.length : undefined);
49+
return symbols === undefined ? undefined : plural(symbols, "symbol");
50+
}
51+
case "web_search": {
52+
const results =
53+
count(detailFields?.totalResults) ??
54+
(Array.isArray(detailFields?.results) ? detailFields.results.length : undefined);
55+
return results === undefined ? undefined : plural(results, "result");
56+
}
57+
case "multi_tool_use.parallel":
58+
return Array.isArray(argFields?.tool_uses) ? plural(argFields.tool_uses.length, "call") : undefined;
59+
case "task":
60+
return Array.isArray(argFields?.tasks) ? plural(argFields.tasks.length, "task") : undefined;
61+
case "team_create": {
62+
const members = record(argFields?.inline_spec)?.members;
63+
return Array.isArray(members) ? plural(members.length, "member") : undefined;
64+
}
65+
default:
66+
return undefined;
67+
}
68+
}
69+
70+
function knownStatus(toolName: string, state: ToolExecutionRenderState): string | undefined {
71+
if (STATUS_TOOLS.has(toolName)) {
72+
const details = record(state.result?.details);
73+
const status = typeof details?.status === "string" ? sanitizeAtomicLabel(details.status) : undefined;
74+
if (status) return status;
75+
if (state.isPartial) {
76+
const activity = record(details?.progress)?.activity;
77+
const partial = details?.phase ?? activity;
78+
return typeof partial === "string" ? sanitizeAtomicLabel(partial) : undefined;
79+
}
80+
}
81+
return state.result?.isError ? "failed" : undefined;
82+
}
83+
84+
export function isAtomicToolPassthrough(identity: ToolExecutionIdentity): boolean {
85+
return identity.trustedBuiltIn && PASSTHROUGH_TOOLS.has(identity.toolName);
86+
}
87+
88+
export class AtomicToolMetadata {
89+
readonly name: string;
90+
readonly supportsProgressSpinner: boolean;
91+
target: string | undefined;
92+
facts: string | undefined;
93+
isError = false;
94+
private readonly identity: ToolExecutionIdentity;
95+
private retainedLines?: number;
96+
private retainedLinesTruncated = false;
97+
private retainedCalls?: number;
98+
99+
constructor(identity: ToolExecutionIdentity, state: ToolExecutionRenderState) {
100+
this.identity = identity;
101+
this.name = sanitizeAtomicLabel(identity.toolName);
102+
this.supportsProgressSpinner =
103+
identity.trustedBuiltIn && (identity.toolName === "bash" || identity.toolName === "eval");
104+
this.update(state);
105+
}
106+
107+
update(state: ToolExecutionRenderState): void {
108+
this.target = undefined;
109+
this.facts = undefined;
110+
this.isError = false;
111+
try {
112+
this.isError = state.result?.isError === true;
113+
this.target = atomicTarget(this.identity, state.args);
114+
if (this.identity.trustedBuiltIn && this.identity.toolName === "bash") {
115+
const observed = observedBashLines(state.result);
116+
if (observed) {
117+
if (observed.count > (this.retainedLines ?? 0)) {
118+
this.retainedLines = observed.count;
119+
this.retainedLinesTruncated = observed.truncated;
120+
} else if (observed.truncated) {
121+
this.retainedLinesTruncated = true;
122+
}
123+
}
124+
} else if (this.identity.trustedBuiltIn && this.identity.toolName === "eval") {
125+
const calls = record(state.result?.details)?.toolCalls;
126+
if (Array.isArray(calls)) this.retainedCalls = Math.max(this.retainedCalls ?? 0, calls.length);
127+
}
128+
this.facts = this.buildFacts(state);
129+
} catch {
130+
this.facts = undefined;
131+
}
132+
}
133+
134+
private buildFacts(state: ToolExecutionRenderState): string | undefined {
135+
if (this.identity.trustedBuiltIn && this.identity.toolName === "bash" && this.retainedLines !== undefined) {
136+
const suffix = this.retainedLinesTruncated ? "+" : "";
137+
const lines = `${this.retainedLines}${suffix} line${this.retainedLines === 1 ? "" : "s"}`;
138+
return [lines, bashFailureStatus(state.result)].filter(Boolean).join(" · ");
139+
}
140+
if (this.identity.trustedBuiltIn && this.identity.toolName === "eval" && this.retainedCalls !== undefined) {
141+
return plural(this.retainedCalls, "call");
142+
}
143+
if (!this.identity.trustedBuiltIn) return this.isError ? "failed" : undefined;
144+
return (
145+
[
146+
knownCount(this.identity.toolName, state.args, state.result?.details),
147+
knownStatus(this.identity.toolName, state),
148+
]
149+
.filter(Boolean)
150+
.join(" · ") || undefined
151+
);
152+
}
153+
}

0 commit comments

Comments
 (0)