pi-compact-tools 0.4.2 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,14 @@
1
- /** Compact rendering and shared status indicators for built-in tools. */
2
-
3
- import { existsSync, readFileSync } from "node:fs";
4
- import { join } from "node:path";
5
1
  import type {
6
2
  AgentToolResult,
7
3
  BashToolDetails,
8
4
  BashToolInput,
9
5
  ExtensionAPI,
6
+ ExtensionContext,
10
7
  Theme,
11
8
  ToolDefinition,
12
9
  ToolRenderResultOptions,
13
10
  } from "@earendil-works/pi-coding-agent";
14
11
  import {
15
- CONFIG_DIR_NAME,
16
12
  createBashToolDefinition,
17
13
  createEditToolDefinition,
18
14
  createFindToolDefinition,
@@ -21,362 +17,79 @@ import {
21
17
  createPowerShellToolDefinition,
22
18
  createReadToolDefinition,
23
19
  createWriteToolDefinition,
24
- getAgentDir,
25
20
  } from "@earendil-works/pi-coding-agent";
26
21
  import type { Component } from "@earendil-works/pi-tui";
27
- import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
22
+ import { Container } from "@earendil-works/pi-tui";
28
23
  import {
29
24
  classifyCallStatus,
30
25
  formatDurationMs,
31
26
  HEX_COLOR_PATTERN,
32
27
  normalizeLineEndings,
33
- parseDurationIndicators,
34
28
  rgbToAnsi256,
35
29
  selectDurationIndicator,
36
30
  type DurationIndicatorConfig,
37
31
  type RowStatus,
38
32
  type ThemeDurationIndicatorColor,
39
33
  } from "./compact-tools-core.ts";
40
-
41
- const MAX_LEVEL = 3;
42
- const CONFIG_FILE = "compact-tools.json";
43
- const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"] as const;
44
- const SUPPORTED_TOOL_SET = new Set<string>(SUPPORTED_TOOLS);
45
-
46
- type CompactToolName = (typeof SUPPORTED_TOOLS)[number];
47
-
48
- export interface CompactToolsConfig {
49
- tools: CompactToolName[];
50
- auto_compact: Record<CompactToolName, boolean>;
51
- previewLines: number;
52
- spinner: {
53
- frames: string[];
54
- intervalMs: number;
55
- };
56
- durationIndicators: DurationIndicatorConfig[];
57
- }
58
-
59
- export const DEFAULT_CONFIG: CompactToolsConfig = {
60
- tools: ["read", "write", "edit", "bash"],
61
- auto_compact: {
62
- read: true,
63
- write: true,
64
- edit: false,
65
- bash: true,
66
- powershell: true,
67
- grep: true,
68
- find: true,
69
- ls: true,
70
- },
71
- previewLines: 10,
72
- spinner: {
73
- frames: ["◐", "◓", "◑", "◒"],
74
- intervalMs: 120,
75
- },
76
- durationIndicators: [
77
- { underMs: 1_000, icon: "⚡️", color: "warning" },
78
- { underMs: 10_000, icon: "🔥", color: "#D95C3F" },
79
- { underMs: 30_000, icon: "○", color: "#79C0FF" },
80
- { icon: "⏳", color: "#D2A8FF" },
81
- ],
82
- };
83
-
84
- let config = DEFAULT_CONFIG;
85
- let animateRows = false;
86
- const registeredCompactTools = new Set<CompactToolName>();
87
-
88
- interface RowState {
89
- level?: number;
90
- availableLevels?: number[];
91
- lastExpanded?: boolean;
92
- frame?: number;
93
- startedAt?: number;
94
- endedAt?: number;
95
- timer?: ReturnType<typeof setInterval>;
96
- originalResultComponent?: Component;
97
- }
98
-
99
- type ToolArgs = Record<string, unknown>;
100
- type BuiltInDefinition = ToolDefinition<any, any, any>;
101
- type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
102
- type RenderContext<TArgs = ToolArgs> = Omit<BaseRenderContext, "args" | "state"> & {
103
- args: TArgs;
104
- state: RowState;
105
- };
106
-
107
- type JsonObject = Record<string, unknown>;
108
-
109
- function isObject(value: unknown): value is JsonObject {
110
- return typeof value === "object" && value !== null && !Array.isArray(value);
111
- }
112
-
113
- function warnConfig(path: string, message: string): void {
114
- console.error(`[compact-tools] ${path}: ${message}`);
115
- }
116
-
117
- function isIntegerInRange(value: unknown, minimum: number, maximum: number): value is number {
118
- return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
119
- }
120
-
121
- function isNonEmptyStringArray(value: unknown): value is string[] {
122
- return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0);
123
- }
124
-
125
- function parseTools(value: unknown, path: string): CompactToolName[] | undefined {
126
- if (!Array.isArray(value)) {
127
- warnConfig(path, "tools must be an array; using previous values");
128
- return undefined;
129
- }
130
- const valid = value.filter((item): item is CompactToolName => typeof item === "string" && SUPPORTED_TOOL_SET.has(item));
131
- const invalid = value.filter((item) => typeof item !== "string" || !SUPPORTED_TOOL_SET.has(item));
132
- if (invalid.length > 0) warnConfig(path, `ignoring unsupported tools: ${invalid.map(String).join(", ")}`);
133
- return [...new Set(valid)];
134
- }
135
-
136
- function parseAutoCompact(
137
- base: CompactToolsConfig["auto_compact"],
138
- value: unknown,
139
- path: string,
140
- ): CompactToolsConfig["auto_compact"] {
141
- if (value === undefined) return base;
142
- if (!isObject(value)) {
143
- warnConfig(path, "auto_compact must be an object; using previous values");
144
- return base;
145
- }
146
- const autoCompact = { ...base };
147
- for (const [name, enabled] of Object.entries(value)) {
148
- if (!SUPPORTED_TOOL_SET.has(name)) {
149
- warnConfig(path, `ignoring unsupported auto_compact tool: ${name}`);
150
- continue;
151
- }
152
- if (typeof enabled !== "boolean") {
153
- warnConfig(path, `auto_compact.${name} must be true or false; using previous value`);
154
- continue;
155
- }
156
- autoCompact[name as CompactToolName] = enabled;
157
- }
158
- return autoCompact;
159
- }
160
-
161
- function mergeConfig(base: CompactToolsConfig, value: unknown, path: string): CompactToolsConfig {
162
- if (value === undefined) return base;
163
- if (!isObject(value)) {
164
- warnConfig(path, "expected a JSON object; using previous values");
165
- return base;
166
- }
167
- const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
168
- const auto_compact = parseAutoCompact(base.auto_compact, value.auto_compact, path);
169
- const previewValue = value.previewLines;
170
- const validPreviewLines = isIntegerInRange(previewValue, 1, 1_000);
171
- const previewLines = validPreviewLines ? previewValue : base.previewLines;
172
- if (previewValue !== undefined && !validPreviewLines) {
173
- warnConfig(path, "previewLines must be an integer from 1 to 1000; using previous value");
174
- }
175
- const spinner = parseSpinnerConfig(base.spinner, value.spinner, path);
176
- const durationIndicators = parseDurationIndicators(value.durationIndicators) ?? base.durationIndicators;
177
- if (value.durationIndicators !== undefined && durationIndicators === base.durationIndicators) {
178
- warnConfig(path, "invalid durationIndicators; using previous values");
179
- }
180
- return { tools, auto_compact, previewLines, spinner, durationIndicators };
181
- }
182
-
183
- function parseSpinnerConfig(
184
- base: CompactToolsConfig["spinner"],
185
- value: unknown,
186
- path: string,
187
- ): CompactToolsConfig["spinner"] {
188
- if (value === undefined) return base;
189
- if (!isObject(value)) {
190
- warnConfig(path, "spinner must be an object; using previous values");
191
- return base;
192
- }
193
- const framesValue = value.frames;
194
- const intervalValue = value.intervalMs;
195
- const validFrames = isNonEmptyStringArray(framesValue);
196
- const validInterval = isIntegerInRange(intervalValue, 40, 5_000);
197
- if (framesValue !== undefined && !validFrames) warnConfig(path, "spinner.frames must be a non-empty string array");
198
- if (intervalValue !== undefined && !validInterval) warnConfig(path, "spinner.intervalMs must be 40–5000");
199
- return {
200
- frames: validFrames ? framesValue : base.frames,
201
- intervalMs: validInterval ? intervalValue : base.intervalMs,
202
- };
203
- }
204
-
205
- function readConfig(path: string): unknown {
206
- if (!existsSync(path)) return undefined;
207
- try {
208
- return JSON.parse(readFileSync(path, "utf8"));
209
- } catch (error) {
210
- warnConfig(path, error instanceof Error ? error.message : String(error));
211
- return undefined;
212
- }
213
- }
214
-
215
- function getConfiguredTuiMode(): "regular" | "fullscreen" {
216
- for (let index = 0; index < process.argv.length; index++) {
217
- const argument = process.argv[index];
218
- if (argument === "--tui-mode") {
219
- const value = process.argv[index + 1];
220
- if (value === "regular" || value === "fullscreen") return value;
221
- }
222
- if (argument?.startsWith("--tui-mode=")) {
223
- const value = argument.slice("--tui-mode=".length);
224
- if (value === "regular" || value === "fullscreen") return value;
225
- }
226
- }
227
-
228
- const settings = readConfig(join(getAgentDir(), "settings.json"));
229
- return isObject(settings) && settings.tuiMode === "fullscreen" ? "fullscreen" : "regular";
230
- }
231
-
232
- export function loadConfig(cwd?: string, projectTrusted = false): CompactToolsConfig {
233
- const globalPath = join(getAgentDir(), CONFIG_FILE);
234
- let loaded = mergeConfig(DEFAULT_CONFIG, readConfig(globalPath), globalPath);
235
- if (!cwd || !projectTrusted) return loaded;
236
- const projectPath = join(cwd, CONFIG_DIR_NAME, CONFIG_FILE);
237
- loaded = mergeConfig(loaded, readConfig(projectPath), projectPath);
238
- return loaded;
239
- }
240
-
241
- const SPINNING_ROWS_KEY = Symbol.for("pi.compact-tools.spinning-rows");
242
- const EXECUTION_TIMINGS_KEY = Symbol.for("pi.compact-tools.execution-timings");
243
- type ExecutionTiming = { startedAt: number; endedAt?: number };
244
- const globalState = globalThis as typeof globalThis & {
245
- [SPINNING_ROWS_KEY]?: Set<RowState>;
246
- [EXECUTION_TIMINGS_KEY]?: Map<string, ExecutionTiming>;
247
- };
248
- for (const staleState of globalState[SPINNING_ROWS_KEY] ?? []) {
249
- if (staleState.timer) clearInterval(staleState.timer);
250
- }
251
- const spinningRows = new Set<RowState>();
252
- const executionTimings = globalState[EXECUTION_TIMINGS_KEY] ?? new Map<string, ExecutionTiming>();
253
- globalState[SPINNING_ROWS_KEY] = spinningRows;
254
- globalState[EXECUTION_TIMINGS_KEY] = executionTimings;
255
-
256
- function advanceLevel(state: RowState, expanded: boolean, name: CompactToolName): number {
257
- state.level ??= config.auto_compact[name] ? 0 : (state.availableLevels?.at(-1) ?? MAX_LEVEL);
258
- if (state.lastExpanded === undefined) {
259
- state.lastExpanded = expanded;
260
- return state.level;
261
- }
262
- if (state.lastExpanded !== expanded) {
263
- state.lastExpanded = expanded;
264
- const levels = state.availableLevels ?? [0, 1];
265
- const index = levels.indexOf(state.level);
266
- state.level = levels[(index + 1) % levels.length] ?? 0;
267
- }
268
- return state.level;
269
- }
270
-
271
- function setAvailableLevels(state: RowState, levels: number[], name: CompactToolName): void {
272
- state.availableLevels = levels;
273
- if (state.level === undefined) {
274
- state.level = config.auto_compact[name] ? 0 : (levels.at(-1) ?? 0);
275
- } else if (!levels.includes(state.level)) {
276
- state.level = !config.auto_compact[name] && state.level === MAX_LEVEL ? (levels.at(-1) ?? 0) : 0;
277
- }
278
- }
279
-
280
- function getOutputLevels(output: string): number[] {
281
- const normalized = normalizeLineEndings(output).trimEnd();
282
- if (!normalized) return [];
283
- return normalized.split("\n").length > config.previewLines ? [2, 3] : [2];
284
- }
285
-
286
- function stopSpinner(state: RowState): void {
287
- if (state.timer) clearInterval(state.timer);
288
- state.timer = undefined;
289
- spinningRows.delete(state);
290
- }
291
-
292
- function syncSpinner(state: RowState, running: boolean, invalidate: () => void): void {
293
- // Regular mode cannot safely redraw a transcript row after it has scrolled
294
- // above the viewport. Keep active rows static there to avoid full transcript
295
- // redraws and duplicated-looking terminal output.
296
- if (!running || !animateRows || config.spinner.frames.length < 2) {
297
- stopSpinner(state);
298
- return;
299
- }
300
- if (state.timer) return;
301
-
302
- state.frame ??= 0;
303
- state.timer = setInterval(() => {
304
- state.frame = ((state.frame ?? 0) + 1) % config.spinner.frames.length;
305
- try {
306
- invalidate();
307
- } catch {
308
- stopSpinner(state);
309
- }
310
- }, config.spinner.intervalMs);
311
- state.timer.unref?.();
312
- spinningRows.add(state);
313
- }
314
-
315
- function syncTiming(state: RowState, callStarted: boolean, finished: boolean): void {
316
- if (callStarted && state.startedAt === undefined) state.startedAt = Date.now();
317
- if (finished && state.startedAt !== undefined && state.endedAt === undefined) {
318
- state.endedAt = Date.now();
319
- }
320
- }
321
-
322
- function restoreExecutionTiming(state: RowState, toolCallId: string): boolean {
323
- const timing = executionTimings.get(toolCallId);
324
- if (!timing) return false;
325
- state.startedAt = timing.startedAt;
326
- state.endedAt = timing.endedAt;
327
- return timing.endedAt !== undefined;
328
- }
329
-
330
- function persistExecutionTiming(state: RowState, toolCallId: string): void {
331
- if (state.startedAt === undefined) return;
332
- const timing = executionTimings.get(toolCallId) ?? { startedAt: state.startedAt };
333
- timing.startedAt = state.startedAt;
334
- if (state.endedAt !== undefined) timing.endedAt = state.endedAt;
335
- executionTimings.set(toolCallId, timing);
336
- if (executionTimings.size <= 2_000) return;
337
- const oldestToolCallId = executionTimings.keys().next().value;
338
- if (oldestToolCallId !== undefined) executionTimings.delete(oldestToolCallId);
339
- }
340
-
341
- function syncRow(
342
- ctx: RenderContext,
343
- running = ctx.executionStarted && ctx.state.endedAt === undefined,
344
- finished = false,
345
- ): RowState {
346
- const state = ctx.state;
347
- if (restoreExecutionTiming(state, ctx.toolCallId)) running = false;
348
- const callStarted = !ctx.argsComplete || ctx.executionStarted || running;
349
- syncTiming(state, callStarted, finished);
350
- persistExecutionTiming(state, ctx.toolCallId);
351
- syncSpinner(state, running, ctx.invalidate);
352
- return state;
353
- }
354
-
355
- function resetUiState(clearTimings = false): void {
356
- for (const state of [...spinningRows]) stopSpinner(state);
357
- if (clearTimings) executionTimings.clear();
34
+ import { isFullscreenMode, loadConfig } from "./compact-tools-config.ts";
35
+ import { classifyToggleInput } from "./compact-tools-input.ts";
36
+ import {
37
+ getArgumentDetails,
38
+ getCallDetails,
39
+ getFileOutput,
40
+ getTextResult,
41
+ } from "./compact-tools-invocation.ts";
42
+ import {
43
+ prefixedText,
44
+ renderArguments,
45
+ renderOutput,
46
+ renderToolCall,
47
+ styleMultiline,
48
+ wrapEditResult,
49
+ } from "./compact-tools-layout.ts";
50
+ import { ToolRuntime } from "./compact-tools-runtime.ts";
51
+ import type {
52
+ BuiltInDefinition,
53
+ CompactToolName,
54
+ RenderContext,
55
+ RowState,
56
+ ShellToolName,
57
+ ToolArgs,
58
+ } from "./compact-tools-types.ts";
59
+
60
+ const runtime = new ToolRuntime();
61
+ const registeredTools = new Set<CompactToolName>();
62
+ let unsubscribeTerminalInput: (() => void) | undefined;
63
+
64
+ function bindTerminalInput(ctx: ExtensionContext): void {
65
+ unsubscribeTerminalInput?.();
66
+ unsubscribeTerminalInput = ctx.ui.onTerminalInput((data) => {
67
+ const input = classifyToggleInput(data);
68
+ if (!input) return undefined;
69
+ if (input === "release") return { consume: true };
70
+ const result = runtime.toggleTrackedRows();
71
+ if (result) ctx.ui.notify(`Compact tool rows: ${result}`, "info");
72
+ return { consume: true };
73
+ });
358
74
  }
359
75
 
360
76
  function spinnerTone(frameIndex: number, frameCount: number): "muted" | "dim" | "border" {
361
77
  if (frameCount < 3) return "muted";
362
- const position = frameIndex / (frameCount - 1);
363
- const distanceFromCenter = Math.abs(position - 0.5) * 2;
78
+ const distanceFromCenter = Math.abs(frameIndex / (frameCount - 1) - 0.5) * 2;
364
79
  if (distanceFromCenter < 0.34) return "border";
365
- if (distanceFromCenter < 0.75) return "dim";
366
- return "muted";
80
+ return distanceFromCenter < 0.75 ? "dim" : "muted";
367
81
  }
368
82
 
369
- function resolveCallStatus(ctx: RenderContext, state: RowState): RowStatus {
83
+ function callStatus(ctx: RenderContext, state: RowState): RowStatus {
370
84
  return classifyCallStatus(ctx.isError, ctx.executionStarted, state.endedAt !== undefined);
371
85
  }
372
86
 
373
87
  function renderIndicator(theme: Theme, state: RowState, status: RowStatus): string {
374
88
  if (status === "error") return theme.fg("error", "⊗");
375
89
  if (status === "success") return theme.fg("success", "●");
376
- // A new pending or running row starts at frame zero, then shares the same animation.
90
+ const frames = runtime.config.spinner.frames;
377
91
  const frameIndex = state.frame ?? 0;
378
- const frame = config.spinner.frames[frameIndex] ?? config.spinner.frames[0] ?? "◐";
379
- return theme.fg(spinnerTone(frameIndex, config.spinner.frames.length), frame);
92
+ return theme.fg(spinnerTone(frameIndex, frames.length), frames[frameIndex] ?? frames[0] ?? "◐");
380
93
  }
381
94
 
382
95
  function formatDuration(state: RowState): string | undefined {
@@ -385,20 +98,10 @@ function formatDuration(state: RowState): string | undefined {
385
98
  }
386
99
 
387
100
  function durationIndicator(state: RowState): DurationIndicatorConfig | undefined {
388
- // Reloading extensions recreates row state, so already-completed rows no longer
389
- // have timing data. Keep their configured indicator visible using the first rule.
390
- const elapsedMs = state.startedAt !== undefined && state.endedAt !== undefined
101
+ const elapsed = state.startedAt !== undefined && state.endedAt !== undefined
391
102
  ? state.endedAt - state.startedAt
392
103
  : undefined;
393
- return selectDurationIndicator(config.durationIndicators, elapsedMs);
394
- }
395
-
396
- function parseHexColor(color: string): [number, number, number] {
397
- return [
398
- Number.parseInt(color.slice(1, 3), 16),
399
- Number.parseInt(color.slice(3, 5), 16),
400
- Number.parseInt(color.slice(5, 7), 16),
401
- ];
104
+ return selectDurationIndicator(runtime.config.durationIndicators, elapsed);
402
105
  }
403
106
 
404
107
  function styleDurationIcon(theme: Theme, indicator: DurationIndicatorConfig): string {
@@ -406,21 +109,17 @@ function styleDurationIcon(theme: Theme, indicator: DurationIndicatorConfig): st
406
109
  if (!HEX_COLOR_PATTERN.test(color)) {
407
110
  return theme.fg(color as ThemeDurationIndicatorColor, indicator.icon);
408
111
  }
409
- const [red, green, blue] = parseHexColor(color);
112
+ const red = Number.parseInt(color.slice(1, 3), 16);
113
+ const green = Number.parseInt(color.slice(3, 5), 16);
114
+ const blue = Number.parseInt(color.slice(5, 7), 16);
410
115
  const ansi = theme.getColorMode() === "truecolor"
411
116
  ? `\x1b[38;2;${red};${green};${blue}m`
412
117
  : `\x1b[38;5;${rgbToAnsi256(red, green, blue)}m`;
413
118
  return `${ansi}${indicator.icon}\x1b[39m`;
414
119
  }
415
120
 
416
- function renderControls(theme: Theme, state: RowState, running: boolean, isError: boolean): Text {
417
- const duration = running && !animateRows ? undefined : formatDuration(state);
418
- const levels = state.availableLevels ?? [0, 1];
419
- const expandable = levels.length > 1;
420
- const atLastLevel = state.level === levels.at(-1);
421
- const action = expandable
422
- ? `${theme.italic("ctrl+o")} ${atLastLevel ? "to collapse" : "for more"}`
423
- : undefined;
121
+ function renderControls(theme: Theme, state: RowState, running: boolean, isError: boolean): Component {
122
+ const duration = running && !runtime.animatesRows ? undefined : formatDuration(state);
424
123
  const indicator = !running && !isError ? durationIndicator(state) : undefined;
425
124
  const status = running
426
125
  ? (duration ?? "Running")
@@ -428,161 +127,73 @@ function renderControls(theme: Theme, state: RowState, running: boolean, isError
428
127
  let details = indicator
429
128
  ? `${styleDurationIcon(theme, indicator)} ${theme.fg("borderAccent", status)}`
430
129
  : theme.fg("borderAccent", status);
431
- if (action) details += theme.fg("borderAccent", `, ${action}`);
432
- return new Text(`${theme.fg("border", " └─ ")}${details}`, 0, 0);
433
- }
434
-
435
- function firstLine(value: string, maxLength = 100): string {
436
- const line = normalizeLineEndings(value).split("\n")[0] ?? "";
437
- return line.length > maxLength ? `${line.slice(0, maxLength - 1)}…` : line;
438
- }
439
-
440
- function getTextResult(result: AgentToolResult<unknown>): string {
441
- const text = result.content
442
- .filter((item) => item.type === "text")
443
- .map((item) => item.text)
444
- .join("\n");
445
- return normalizeLineEndings(text).trimEnd();
446
- }
447
-
448
- function withoutLeadingBlankLines(component: Component, theme: Theme): Component {
449
- return {
450
- render(width: number) {
451
- const lines = component.render(Math.max(1, width - 4));
452
- const firstContentLine = lines.findIndex((line) => visibleWidth(line.trim()) > 0);
453
- const prefix = theme.fg("border", " │ ");
454
- return firstContentLine < 0 ? [] : lines.slice(firstContentLine).map((line) => `${prefix}${line}`);
455
- },
456
- invalidate() {
457
- component.invalidate?.();
458
- },
459
- };
460
- }
461
-
462
- function styleToolOutput(text: string, theme: Theme, isError: boolean): string {
463
- const color = isError ? "error" : "toolOutput";
464
- return text
465
- .split(/(⚡️?)/u)
466
- .map((part) => theme.fg(!isError && part.startsWith("⚡") ? "warning" : color, part))
467
- .join("");
468
- }
469
-
470
- function renderPreview(output: string, level: number, theme: Theme, isError: boolean): Text | undefined {
471
- const normalized = normalizeLineEndings(output).trimEnd();
472
- if (!normalized) return undefined;
473
-
474
- const lines = normalized.split("\n");
475
- const shown = level >= MAX_LEVEL ? lines : lines.slice(0, config.previewLines);
476
- const prefix = theme.fg("border", " │ ");
477
- let text = shown.map((line) => `${prefix}${styleToolOutput(line, theme, isError)}`).join("\n");
478
- if (shown.length < lines.length) {
479
- text += `\n${prefix}${theme.fg("dim", `… ${lines.length - shown.length} more lines`)}`;
130
+ if (state.hasResult) {
131
+ const clickAction = state.expanded ? "to hide" : "for result";
132
+ details += theme.fg(
133
+ "borderAccent",
134
+ `, ${theme.italic("ctrl+o")} toggle all • ${theme.italic("click")} ${clickAction}`,
135
+ );
480
136
  }
481
- return new Text(text, 0, 0);
137
+ return prefixedText(details, theme.fg("border", " └─ "), " ");
482
138
  }
483
139
 
484
- function getPathArg(args: ToolArgs): string {
485
- const value = args.path ?? args.file_path;
486
- return typeof value === "string" ? firstLine(value) : "";
140
+ function renderCallTitle(name: string, theme: Theme, ctx: RenderContext, state: RowState): string {
141
+ return `${renderIndicator(theme, state, callStatus(ctx, state))} ${theme.fg("toolTitle", theme.bold(name))}`;
487
142
  }
488
143
 
489
- function getCallDetails(name: string, args: ToolArgs): string {
490
- const path = getPathArg(args) || (name === "grep" || name === "find" || name === "ls" ? "." : "");
491
- const pattern = typeof args.pattern === "string" ? firstLine(args.pattern) : "…";
492
- if (name === "grep") return `/${pattern}/ in ${path}`;
493
- if (name === "find") return `${pattern} in ${path}`;
494
- return path;
495
- }
496
-
497
- function getFileArgumentDetails(name: string, args: ToolArgs): ToolArgs {
498
- if (name === "edit") return {};
499
- const omitted = new Set(["path", "file_path"]);
500
- if (name === "write") omitted.add("content");
501
- if (name === "grep" || name === "find") omitted.add("pattern");
502
- return Object.fromEntries(Object.entries(args).filter(([key, value]) => !omitted.has(key) && value !== undefined));
503
- }
504
-
505
- function renderArguments(args: ToolArgs, theme: Theme): Text {
506
- const json = JSON.stringify(args, null, 2) ?? "{}";
507
- const prefix = theme.fg("border", " │ ");
508
- const text = json
509
- .split("\n")
510
- .map((line) => `${prefix}${theme.fg("toolOutput", line)}`)
511
- .join("\n");
512
- return new Text(text, 0, 0);
144
+ function renderFileCall(
145
+ definition: BuiltInDefinition,
146
+ args: ToolArgs,
147
+ theme: Theme,
148
+ ctx: RenderContext,
149
+ ): Container {
150
+ const name = definition.name as CompactToolName;
151
+ const state = runtime.syncRow(ctx, ctx.state.endedAt === undefined);
152
+ runtime.track(ctx, name, state);
153
+ runtime.syncExpansion(state, ctx.expanded, name);
154
+ const callDetails = getCallDetails(name, args);
155
+ const details = callDetails
156
+ ? styleMultiline(callDetails, (line) => theme.fg("toolOutput", line))
157
+ : undefined;
158
+ const container = new Container();
159
+ container.addChild(renderToolCall(renderCallTitle(name, theme, ctx, state), details, theme));
160
+ const arguments_ = getArgumentDetails(name, args);
161
+ if (Object.keys(arguments_).length > 0) container.addChild(renderArguments(arguments_, theme));
162
+ return container;
513
163
  }
514
164
 
515
- function callOriginalEditResult(
165
+ function updateEditResult(
516
166
  definition: BuiltInDefinition,
517
167
  result: AgentToolResult<unknown>,
518
168
  options: ToolRenderResultOptions,
519
169
  theme: Theme,
520
170
  ctx: RenderContext,
521
171
  state: RowState,
522
- level: number,
523
172
  ): void {
524
173
  if (definition.name !== "edit" || !definition.renderResult) return;
525
174
  state.originalResultComponent = definition.renderResult(
526
175
  result,
527
- { ...options, expanded: level >= MAX_LEVEL },
176
+ { ...options, expanded: state.expanded ?? false },
528
177
  theme,
529
178
  { ...ctx, state: {}, lastComponent: state.originalResultComponent },
530
179
  );
531
180
  }
532
181
 
533
- function getFileOutput(name: string, args: ToolArgs, result: AgentToolResult<unknown>, isError: boolean): string {
534
- if (isError || name === "read" || name === "grep" || name === "find" || name === "ls") {
535
- return getTextResult(result);
536
- }
537
- if (name === "write") return String(args.content ?? "");
538
- return "";
539
- }
540
-
541
- function renderFileCall(
542
- definition: BuiltInDefinition,
543
- args: ToolArgs,
544
- theme: Theme,
545
- ctx: RenderContext,
546
- ): Container {
547
- // Animate and time from the first rendered call so large write/edit calls
548
- // represent the full lifecycle, including streamed arguments and filesystem work.
549
- const state = syncRow(ctx, ctx.state.endedAt === undefined);
550
- // syncRow may restore a completed timing after /reload. Recompute from the
551
- // synchronized state so a stale local value cannot leave the spinner visible.
552
- const status = resolveCallStatus(ctx, state);
553
- const level = advanceLevel(state, ctx.expanded, definition.name as CompactToolName);
554
- const callDetails = getCallDetails(definition.name, args);
555
- const argumentDetails = getFileArgumentDetails(definition.name, args);
556
- let text = `${renderIndicator(theme, state, status)} `;
557
- text += theme.fg("toolTitle", theme.bold(definition.name));
558
- if (callDetails) text += ` ${theme.fg("toolOutput", callDetails)}`;
559
-
560
- const container = new Container();
561
- container.addChild(new Text(text, 1, 0));
562
- if (level >= 1 && Object.keys(argumentDetails).length > 0) {
563
- container.addChild(renderArguments(argumentDetails, theme));
564
- }
565
- return container;
566
- }
567
-
568
- function addFileResultPreview(
182
+ function appendFileResult(
569
183
  container: Container,
570
184
  definition: BuiltInDefinition,
571
185
  state: RowState,
572
186
  output: string,
573
- level: number,
574
187
  theme: Theme,
575
188
  isError: boolean,
576
189
  ): void {
577
- if (level < 2) return;
190
+ if (!state.expanded) return;
578
191
  if (definition.name === "edit") {
579
- if (state.originalResultComponent) {
580
- container.addChild(withoutLeadingBlankLines(state.originalResultComponent, theme));
581
- }
192
+ if (state.originalResultComponent) container.addChild(wrapEditResult(state.originalResultComponent, theme));
582
193
  return;
583
194
  }
584
- const preview = renderPreview(output, level, theme, isError);
585
- if (preview) container.addChild(preview);
195
+ const component = renderOutput(output, theme, isError);
196
+ if (component) container.addChild(component);
586
197
  }
587
198
 
588
199
  function renderFileResult(
@@ -592,160 +203,128 @@ function renderFileResult(
592
203
  theme: Theme,
593
204
  ctx: RenderContext,
594
205
  ): Container {
595
- const state = syncRow(ctx, options.isPartial, !options.isPartial);
596
- const output = getFileOutput(definition.name, ctx.args, result, ctx.isError);
597
- const hasArguments = Object.keys(getFileArgumentDetails(definition.name, ctx.args)).length > 0;
598
- const levels = definition.name === "edit" ? [0, MAX_LEVEL] : [0, ...(hasArguments ? [1] : []), ...getOutputLevels(output)];
599
- setAvailableLevels(state, levels, definition.name as CompactToolName);
600
-
601
- const level = state.level ?? 0;
602
- callOriginalEditResult(definition, result, options, theme, ctx, state, level);
206
+ const name = definition.name as CompactToolName;
207
+ const state = runtime.syncRow(ctx, options.isPartial, !options.isPartial);
208
+ runtime.track(ctx, name, state);
209
+ const output = getFileOutput(name, ctx.args, result, ctx.isError);
210
+ runtime.setResultAvailable(state, name, name === "edit" || output.length > 0);
211
+ updateEditResult(definition, result, options, theme, ctx, state);
603
212
  const container = new Container();
604
- addFileResultPreview(container, definition, state, output, level, theme, ctx.isError);
213
+ appendFileResult(container, definition, state, output, theme, ctx.isError);
605
214
  container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
606
215
  return container;
607
216
  }
608
217
 
609
- function createFileCallRenderer(definition: BuiltInDefinition) {
610
- return (args: ToolArgs, theme: Theme, ctx: RenderContext) => renderFileCall(definition, args, theme, ctx);
611
- }
612
-
613
- function createFileResultRenderer(definition: BuiltInDefinition) {
614
- return (result: AgentToolResult<unknown>, options: ToolRenderResultOptions, theme: Theme, ctx: RenderContext) =>
615
- renderFileResult(definition, result, options, theme, ctx);
616
- }
617
-
618
- type TimedExecute = (...args: any[]) => Promise<AgentToolResult<unknown>>;
619
-
620
- function createTimedExecute(definition: BuiltInDefinition): TimedExecute {
621
- const execute = definition.execute as TimedExecute;
622
- return async (...args: any[]) => {
623
- const toolCallId = typeof args[0] === "string" ? args[0] : undefined;
624
- const startedAt = (toolCallId ? executionTimings.get(toolCallId)?.startedAt : undefined) ?? Date.now();
625
- if (toolCallId && !executionTimings.has(toolCallId)) executionTimings.set(toolCallId, { startedAt });
626
- try {
627
- return await execute.apply(definition, args);
628
- } finally {
629
- const endedAt = Date.now();
630
- const timing = toolCallId ? executionTimings.get(toolCallId) : undefined;
631
- if (timing) timing.endedAt = endedAt;
632
- }
633
- };
634
- }
635
-
636
- function registerFileTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
637
- const tool = {
638
- ...definition,
639
- execute: createTimedExecute(definition),
640
- renderShell: "self" as const,
641
- renderCall: createFileCallRenderer(definition),
642
- renderResult: createFileResultRenderer(definition),
643
- };
644
- // Built-in file schemas are intentionally erased at this shared renderer boundary.
645
- pi.registerTool(tool as ToolDefinition<any, any, RowState>);
646
- }
647
-
648
218
  function renderShellCall(
649
- name: "bash" | "powershell",
219
+ name: ShellToolName,
650
220
  args: BashToolInput,
651
221
  theme: Theme,
652
222
  ctx: RenderContext<BashToolInput>,
653
- ): Text {
654
- const state = syncRow(ctx, ctx.state.endedAt === undefined);
655
- // A completed timing can be restored inside syncRow. Derive the rendered
656
- // status afterward so completion immediately replaces the spinner.
657
- const status = resolveCallStatus(ctx, state);
658
- const level = advanceLevel(state, ctx.expanded, name);
659
- const command = normalizeLineEndings(args.command ?? "");
660
- const displayedCommand = (level >= 1 ? command : firstLine(command)) || "…";
661
- let text = `${renderIndicator(theme, state, status)} `;
662
- text += `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("toolOutput", displayedCommand)}`;
663
- return new Text(text, 1, 0);
223
+ ): Component {
224
+ const state = runtime.syncRow(ctx, ctx.state.endedAt === undefined);
225
+ runtime.track(ctx, name, state);
226
+ runtime.syncExpansion(state, ctx.expanded, name);
227
+ const command = normalizeLineEndings(args.command ?? "") || "…";
228
+ const details = styleMultiline(command, (line) => theme.fg("toolOutput", line));
229
+ return renderToolCall(renderCallTitle(name, theme, ctx, state), details, theme);
664
230
  }
665
231
 
666
232
  function renderShellResult(
667
- name: "bash" | "powershell",
233
+ name: ShellToolName,
668
234
  result: AgentToolResult<BashToolDetails | undefined>,
669
235
  options: ToolRenderResultOptions,
670
236
  theme: Theme,
671
237
  ctx: RenderContext<BashToolInput>,
672
238
  ): Component {
673
- const state = syncRow(ctx, options.isPartial, !options.isPartial);
239
+ const state = runtime.syncRow(ctx, options.isPartial, !options.isPartial);
240
+ runtime.track(ctx, name, state);
674
241
  const output = getTextResult(result);
675
- const hasDetailedCall = /\r\n?|\n/.test(ctx.args.command ?? "");
676
- setAvailableLevels(state, [0, ...(hasDetailedCall ? [1] : []), ...getOutputLevels(output)], name);
677
- const level = state.level ?? 0;
678
- if (level < 2) return renderControls(theme, state, options.isPartial, ctx.isError);
679
-
242
+ runtime.setResultAvailable(state, name, output.length > 0);
243
+ if (!state.expanded) return renderControls(theme, state, options.isPartial, ctx.isError);
680
244
  const container = new Container();
681
- const preview = renderPreview(output, level, theme, ctx.isError);
682
- if (preview) container.addChild(preview);
683
- else {
684
- const prefix = theme.fg("border", " │ ");
685
- const message = theme.fg("dim", options.isPartial ? "…" : "(no output)");
686
- container.addChild(new Text(`${prefix}${message}`, 0, 0));
687
- }
245
+ const component = renderOutput(output, theme, ctx.isError);
246
+ if (component) container.addChild(component);
688
247
  container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
689
248
  return container;
690
249
  }
691
250
 
251
+ const toolFactories: Record<CompactToolName, (cwd: string) => BuiltInDefinition> = {
252
+ read: createReadToolDefinition,
253
+ write: createWriteToolDefinition,
254
+ edit: createEditToolDefinition,
255
+ bash: createBashToolDefinition,
256
+ powershell: createPowerShellToolDefinition,
257
+ grep: createGrepToolDefinition,
258
+ find: createFindToolDefinition,
259
+ ls: createLsToolDefinition,
260
+ };
261
+
692
262
  function createBuiltInDefinition(name: CompactToolName, cwd: string): BuiltInDefinition {
693
- switch (name) {
694
- case "read": return createReadToolDefinition(cwd);
695
- case "write": return createWriteToolDefinition(cwd);
696
- case "edit": return createEditToolDefinition(cwd);
697
- case "bash": return createBashToolDefinition(cwd);
698
- case "powershell": return createPowerShellToolDefinition(cwd);
699
- case "grep": return createGrepToolDefinition(cwd);
700
- case "find": return createFindToolDefinition(cwd);
701
- case "ls": return createLsToolDefinition(cwd);
702
- }
263
+ return toolFactories[name](cwd);
264
+ }
265
+
266
+ function registerFileTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
267
+ pi.registerTool({
268
+ ...definition,
269
+ execute: runtime.createTimedExecute(definition),
270
+ renderShell: "self",
271
+ renderCall: (args: ToolArgs, theme: Theme, ctx: RenderContext) => renderFileCall(definition, args, theme, ctx),
272
+ renderResult: (
273
+ result: AgentToolResult<unknown>,
274
+ options: ToolRenderResultOptions,
275
+ theme: Theme,
276
+ ctx: RenderContext,
277
+ ) => renderFileResult(definition, result, options, theme, ctx),
278
+ } as ToolDefinition<any, any, RowState>);
703
279
  }
704
280
 
705
281
  function registerShellTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
282
+ const name = definition.name as ShellToolName;
706
283
  pi.registerTool({
707
284
  ...definition,
708
- execute: createTimedExecute(definition),
285
+ execute: runtime.createTimedExecute(definition),
709
286
  renderShell: "self",
710
287
  renderCall: (args: BashToolInput, theme: Theme, ctx: RenderContext<BashToolInput>) =>
711
- renderShellCall(definition.name as "bash" | "powershell", args, theme, ctx),
288
+ renderShellCall(name, args, theme, ctx),
712
289
  renderResult: (
713
290
  result: AgentToolResult<BashToolDetails | undefined>,
714
291
  options: ToolRenderResultOptions,
715
292
  theme: Theme,
716
293
  ctx: RenderContext<BashToolInput>,
717
- ) => renderShellResult(definition.name as "bash" | "powershell", result, options, theme, ctx),
294
+ ) => renderShellResult(name, result, options, theme, ctx),
718
295
  } as ToolDefinition<any, BashToolDetails | undefined, RowState>);
719
296
  }
720
297
 
721
- function registerBuiltInTools(pi: ExtensionAPI, cwd: string): void {
722
- for (const name of registeredCompactTools) {
723
- if (!config.tools.includes(name)) pi.registerTool(createBuiltInDefinition(name, cwd));
298
+ function registerTools(pi: ExtensionAPI, cwd: string): void {
299
+ const enabledTools = new Set(runtime.config.tools);
300
+ for (const name of registeredTools) {
301
+ if (!enabledTools.has(name)) pi.registerTool(createBuiltInDefinition(name, cwd));
724
302
  }
725
- registeredCompactTools.clear();
726
- for (const name of config.tools) {
303
+ registeredTools.clear();
304
+ for (const name of runtime.config.tools) {
727
305
  const definition = createBuiltInDefinition(name, cwd);
728
306
  if (name === "bash" || name === "powershell") registerShellTool(pi, definition);
729
307
  else registerFileTool(pi, definition);
730
- registeredCompactTools.add(name);
308
+ registeredTools.add(name);
731
309
  }
732
310
  }
733
311
 
734
- function applyConfig(pi: ExtensionAPI, cwd: string, projectTrusted: boolean): void {
735
- resetUiState();
736
- animateRows = getConfiguredTuiMode() === "fullscreen";
737
- config = loadConfig(cwd, projectTrusted);
738
- registerBuiltInTools(pi, cwd);
312
+ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void {
313
+ runtime.configure(loadConfig(cwd, projectTrusted), isFullscreenMode());
314
+ registerTools(pi, cwd ?? process.cwd());
739
315
  }
740
316
 
741
- export default function (pi: ExtensionAPI): void {
742
- animateRows = getConfiguredTuiMode() === "fullscreen";
743
- config = loadConfig();
744
- registerBuiltInTools(pi, process.cwd());
317
+ export default function compactTools(pi: ExtensionAPI): void {
318
+ configure(pi);
745
319
  pi.on("session_start", (event, ctx) => {
746
- if (event.reason !== "reload") executionTimings.clear();
747
- applyConfig(pi, ctx.cwd, ctx.isProjectTrusted());
320
+ if (event.reason !== "reload") runtime.clearTimings();
321
+ configure(pi, ctx.cwd, ctx.isProjectTrusted());
322
+ bindTerminalInput(ctx);
323
+ });
324
+ pi.on("resources_discover", (_event, ctx) => configure(pi, ctx.cwd, ctx.isProjectTrusted()));
325
+ pi.on("session_shutdown", (event) => {
326
+ unsubscribeTerminalInput?.();
327
+ unsubscribeTerminalInput = undefined;
328
+ runtime.reset(event.reason !== "reload");
748
329
  });
749
- pi.on("resources_discover", (_event, ctx) => applyConfig(pi, ctx.cwd, ctx.isProjectTrusted()));
750
- pi.on("session_shutdown", (event) => resetUiState(event.reason !== "reload"));
751
330
  }