pi-compact-tools 0.5.0 → 0.5.7

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