pi-compact-tools 0.6.1 → 0.7.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.
@@ -3,7 +3,6 @@ import type {
3
3
  BashToolDetails,
4
4
  BashToolInput,
5
5
  ExtensionAPI,
6
- ExtensionContext,
7
6
  Theme,
8
7
  ToolDefinition,
9
8
  ToolRenderResultOptions,
@@ -22,28 +21,34 @@ import type { Component, Container } from "@earendil-works/pi-tui";
22
21
  import {
23
22
  classifyCallStatus,
24
23
  formatDurationMs,
24
+ indicatorGlyph,
25
+ indicatorStrength,
26
+ indicatorTone,
25
27
  normalizeLineEndings,
26
28
  type RowStatus,
27
29
  } from "./compact-tools-core.ts";
28
- import { isFullscreenMode, loadConfig } from "./compact-tools-config.ts";
29
- import { classifyToggleInput } from "./compact-tools-input.ts";
30
+ import { colorizeRgb, interpolateRgb, themeColorRgb } from "./compact-tools-color.ts";
31
+ import { loadConfig } from "./compact-tools-config.ts";
30
32
  import {
31
- formatArgumentSummary,
32
- formatReadResultSummary,
33
+ formatResultLineSummary,
33
34
  getArgumentDetails,
34
35
  getCallDetails,
36
+ getEditChanges,
35
37
  getFileOutput,
36
38
  getTextResult,
39
+ summarizeShellCommand,
37
40
  } from "./compact-tools-invocation.ts";
38
41
  import {
39
42
  CachedContainer,
43
+ limitComponentLines,
40
44
  prefixedText,
41
45
  renderArguments,
46
+ renderEditChanges,
42
47
  renderOutput,
43
48
  renderToolCall,
44
49
  styleMultiline,
45
- wrapEditResult,
46
50
  } from "./compact-tools-layout.ts";
51
+ import { ProgressController } from "./compact-tools-progress.ts";
47
52
  import { ToolRuntime } from "./compact-tools-runtime.ts";
48
53
  import { ThinkingCycleController } from "./compact-tools-thinking.ts";
49
54
  import type {
@@ -57,37 +62,27 @@ import type {
57
62
 
58
63
  const runtime = new ToolRuntime();
59
64
  const registeredTools = new Set<CompactToolName>();
60
- let unsubscribeTerminalInput: (() => void) | undefined;
61
-
62
- function bindTerminalInput(ctx: ExtensionContext): void {
63
- unsubscribeTerminalInput?.();
64
- unsubscribeTerminalInput = ctx.ui.onTerminalInput((data) => {
65
- const input = classifyToggleInput(data);
66
- if (!input) return undefined;
67
- if (input === "release") return { consume: true };
68
- const result = runtime.toggleTrackedRows();
69
- if (result) ctx.ui.notify(`Compact tool rows: ${result}`, "info");
70
- return { consume: true };
71
- });
72
- }
73
-
74
- function spinnerTone(frameIndex: number, frameCount: number): "muted" | "dim" | "border" {
75
- if (frameCount < 3) return "muted";
76
- const distanceFromCenter = Math.abs(frameIndex / (frameCount - 1) - 0.5) * 2;
77
- if (distanceFromCenter < 0.34) return "border";
78
- return distanceFromCenter < 0.75 ? "dim" : "muted";
79
- }
65
+ let registeredConfiguration: string | undefined;
80
66
 
81
67
  function callStatus(ctx: RenderContext, state: RowState): RowStatus {
82
- return classifyCallStatus(ctx.isError, ctx.executionStarted, state.endedAt !== undefined);
68
+ // write/edit arguments can stream for much longer than their eventual filesystem
69
+ // operation. Treat that active tool-call phase as running so every built-in row
70
+ // animates consistently instead of waiting for execute() to begin.
71
+ const active = ctx.executionStarted || !ctx.argsComplete;
72
+ return classifyCallStatus(ctx.isError, active, state.endedAt !== undefined);
83
73
  }
84
74
 
85
- function renderIndicator(theme: Theme, state: RowState, status: RowStatus): string {
86
- if (status === "error") return theme.fg("error", "●");
87
- if (status === "success") return theme.fg("success", "●");
88
- const frames = runtime.config.spinner.frames;
89
- const frameIndex = state.frame ?? 0;
90
- return theme.fg(spinnerTone(frameIndex, frames.length), frames[frameIndex] ?? frames[0] ?? "◐");
75
+ function renderIndicator(theme: Theme, ctx: RenderContext, status: RowStatus): string {
76
+ const frame = runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate());
77
+ const glyph = indicatorGlyph(status, frame);
78
+ if (status === "running") {
79
+ const bright = themeColorRgb(theme, "borderAccent");
80
+ const dim = themeColorRgb(theme, "borderMuted");
81
+ if (bright && dim) {
82
+ return colorizeRgb(theme, interpolateRgb(dim, bright, indicatorStrength(status, frame)), glyph);
83
+ }
84
+ }
85
+ return theme.fg(indicatorTone(status, frame), glyph);
91
86
  }
92
87
 
93
88
  function formatDuration(state: RowState): string | undefined {
@@ -95,33 +90,55 @@ function formatDuration(state: RowState): string | undefined {
95
90
  return formatDurationMs((state.endedAt ?? Date.now()) - state.startedAt);
96
91
  }
97
92
 
93
+ function canReuseResult<TDetails, TArgs>(
94
+ state: RowState,
95
+ result: AgentToolResult<TDetails>,
96
+ options: ToolRenderResultOptions,
97
+ ctx: RenderContext<TArgs>,
98
+ ): ctx is RenderContext<TArgs> & { lastComponent: Component } {
99
+ return ctx.lastComponent !== undefined
100
+ && state.lastResultContent === result.content
101
+ && state.lastResultDetails === result.details
102
+ && state.lastResultPartial === options.isPartial
103
+ && state.lastResultExpanded === state.expanded
104
+ && state.lastResultPreview === state.preview
105
+ && state.lastResultError === ctx.isError
106
+ && state.lastResultConfigRevision === state.configRevision;
107
+ }
108
+
109
+ function rememberResult<TDetails, TArgs>(
110
+ state: RowState,
111
+ result: AgentToolResult<TDetails>,
112
+ options: ToolRenderResultOptions,
113
+ ctx: RenderContext<TArgs>,
114
+ ): void {
115
+ state.lastResultContent = result.content;
116
+ state.lastResultDetails = result.details;
117
+ state.lastResultPartial = options.isPartial;
118
+ state.lastResultExpanded = state.expanded;
119
+ state.lastResultPreview = state.preview;
120
+ state.lastResultError = ctx.isError;
121
+ state.lastResultConfigRevision = state.configRevision;
122
+ }
123
+
98
124
  function renderControls(
99
125
  theme: Theme,
100
126
  state: RowState,
101
127
  running: boolean,
102
128
  isError: boolean,
103
- summary?: string,
104
- argumentSummary?: string,
129
+ lineSummary?: string,
105
130
  ): Component {
106
- const duration = running && !runtime.animatesRows ? undefined : formatDuration(state);
131
+ const duration = running ? undefined : formatDuration(state);
107
132
  const status = running
108
133
  ? (duration ?? "Running")
109
134
  : `${isError ? "Failed" : "Done"}${duration ? ` in ${duration}` : ""}`;
110
135
  let details = theme.fg("borderAccent", status);
111
- if (argumentSummary && !running) details += theme.fg("borderAccent", ` (${argumentSummary})`);
112
- if (summary) details += theme.fg("borderAccent", ` • ${summary}`);
113
- if (state.hasResult) {
114
- const clickAction = state.expanded ? "to hide" : "for result";
115
- details += theme.fg(
116
- "borderAccent",
117
- `, ${theme.italic("ctrl+o")} toggle all • ${theme.italic("click")} ${clickAction}`,
118
- );
119
- }
136
+ if (lineSummary && !running) details += theme.fg("borderAccent", ` (${lineSummary})`);
120
137
  return prefixedText(details, theme.fg("border", " └─ "), " ");
121
138
  }
122
139
 
123
140
  function renderCallTitle(name: string, theme: Theme, ctx: RenderContext, state: RowState): string {
124
- return `${renderIndicator(theme, state, callStatus(ctx, state))} ${theme.fg("toolTitle", theme.bold(name))}`;
141
+ return `${renderIndicator(theme, ctx, callStatus(ctx, state))} ${theme.fg("toolTitle", theme.bold(name))}`;
125
142
  }
126
143
 
127
144
  function renderFileCall(
@@ -132,7 +149,6 @@ function renderFileCall(
132
149
  ): Container {
133
150
  const name = definition.name as CompactToolName;
134
151
  const state = runtime.syncRow(ctx, ctx.state.endedAt === undefined);
135
- runtime.track(ctx, name, state);
136
152
  runtime.syncExpansion(state, ctx.expanded, name);
137
153
  const callDetails = getCallDetails(name, args);
138
154
  const details = callDetails
@@ -145,38 +161,21 @@ function renderFileCall(
145
161
  return container;
146
162
  }
147
163
 
148
- function updateEditResult(
149
- definition: BuiltInDefinition,
150
- result: AgentToolResult<unknown>,
151
- options: ToolRenderResultOptions,
152
- theme: Theme,
153
- ctx: RenderContext,
154
- state: RowState,
155
- ): void {
156
- if (definition.name !== "edit" || !definition.renderResult) return;
157
- state.originalResultComponent = definition.renderResult(
158
- result,
159
- { ...options, expanded: state.expanded ?? false },
160
- theme,
161
- { ...ctx, state: {}, lastComponent: state.originalResultComponent },
162
- );
163
- }
164
-
165
164
  function appendFileResult(
166
165
  container: Container,
167
166
  definition: BuiltInDefinition,
168
167
  state: RowState,
169
168
  output: string,
169
+ editChanges: string,
170
170
  theme: Theme,
171
171
  isError: boolean,
172
172
  ): void {
173
- if (!state.expanded) return;
174
- if (definition.name === "edit") {
175
- if (state.originalResultComponent) container.addChild(wrapEditResult(state.originalResultComponent, theme));
176
- return;
177
- }
178
- const component = renderOutput(output, theme, isError);
179
- if (component) container.addChild(component);
173
+ if (!state.expanded && !state.preview) return;
174
+ const component = definition.name === "edit" && editChanges
175
+ ? renderEditChanges(editChanges, theme)
176
+ : renderOutput(output, theme, isError);
177
+ if (!component) return;
178
+ container.addChild(state.expanded ? component : limitComponentLines(component, runtime.config.previewLines, theme));
180
179
  }
181
180
 
182
181
  function renderFileResult(
@@ -185,18 +184,22 @@ function renderFileResult(
185
184
  options: ToolRenderResultOptions,
186
185
  theme: Theme,
187
186
  ctx: RenderContext,
188
- ): Container {
187
+ ): Component {
189
188
  const name = definition.name as CompactToolName;
190
189
  const state = runtime.syncRow(ctx, options.isPartial, !options.isPartial);
191
- runtime.track(ctx, name, state);
190
+ runtime.syncExpansion(state, ctx.expanded, name);
191
+ if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
192
192
  const output = getFileOutput(name, ctx.args, result, ctx.isError);
193
- runtime.setResultAvailable(state, name, name === "edit" || output.length > 0);
194
- updateEditResult(definition, result, options, theme, ctx, state);
193
+ const editChanges = name === "edit" ? getEditChanges(result) : "";
194
+ runtime.setResultAvailable(state, name, editChanges.length > 0 || output.length > 0);
195
195
  const container = new CachedContainer();
196
- appendFileResult(container, definition, state, output, theme, ctx.isError);
197
- const summary = name === "read" && !ctx.isError ? formatReadResultSummary(result) : undefined;
198
- const argumentSummary = formatArgumentSummary(name, ctx.args);
199
- container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, summary, argumentSummary));
196
+ appendFileResult(container, definition, state, output, editChanges, theme, ctx.isError);
197
+ if (!options.isPartial && !state.resultLineSummaryComputed) {
198
+ state.resultLineSummary = formatResultLineSummary(name, ctx.args, result, output);
199
+ state.resultLineSummaryComputed = true;
200
+ }
201
+ container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, state.resultLineSummary));
202
+ rememberResult(state, result, options, ctx);
200
203
  return container;
201
204
  }
202
205
 
@@ -207,10 +210,11 @@ function renderShellCall(
207
210
  ctx: RenderContext<BashToolInput>,
208
211
  ): Component {
209
212
  const state = runtime.syncRow(ctx, ctx.state.endedAt === undefined);
210
- runtime.track(ctx, name, state);
211
213
  runtime.syncExpansion(state, ctx.expanded, name);
212
- const command = normalizeLineEndings(args.command ?? "") || "…";
213
- const details = styleMultiline(command, (line) => theme.fg("toolOutput", line));
214
+ const command = normalizeLineEndings(args.command ?? "");
215
+ const details = state.expanded
216
+ ? styleMultiline(command || "…", (line) => theme.fg("toolOutput", line))
217
+ : theme.fg("toolOutput", summarizeShellCommand(name, command));
214
218
  return renderToolCall(renderCallTitle(name, theme, ctx, state), details, theme);
215
219
  }
216
220
 
@@ -222,14 +226,26 @@ function renderShellResult(
222
226
  ctx: RenderContext<BashToolInput>,
223
227
  ): Component {
224
228
  const state = runtime.syncRow(ctx, options.isPartial, !options.isPartial);
225
- runtime.track(ctx, name, state);
229
+ runtime.syncExpansion(state, ctx.expanded, name);
230
+ if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
226
231
  const output = getTextResult(result);
227
232
  runtime.setResultAvailable(state, name, output.length > 0);
228
- if (!state.expanded) return renderControls(theme, state, options.isPartial, ctx.isError);
233
+ if (!options.isPartial && !state.resultLineSummaryComputed) {
234
+ state.resultLineSummary = formatResultLineSummary(name, ctx.args, result, output);
235
+ state.resultLineSummaryComputed = true;
236
+ }
237
+ if (!state.expanded && !state.preview) {
238
+ const controls = renderControls(theme, state, options.isPartial, ctx.isError, state.resultLineSummary);
239
+ rememberResult(state, result, options, ctx);
240
+ return controls;
241
+ }
229
242
  const container = new CachedContainer();
230
243
  const component = renderOutput(output, theme, ctx.isError);
231
- if (component) container.addChild(component);
232
- container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
244
+ if (component) {
245
+ container.addChild(state.expanded ? component : limitComponentLines(component, runtime.config.previewLines, theme));
246
+ }
247
+ container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, state.resultLineSummary));
248
+ rememberResult(state, result, options, ctx);
233
249
  return container;
234
250
  }
235
251
 
@@ -283,6 +299,8 @@ function registerShellTool(pi: ExtensionAPI, definition: BuiltInDefinition): voi
283
299
  function registerTools(pi: ExtensionAPI, cwd: string): void {
284
300
  const enabledTools = new Set(runtime.config.tools);
285
301
  for (const name of registeredTools) {
302
+ // Pi exposes no unregister API, so restoring a disabled tool means re-registering
303
+ // the built-in definition to release this extension's renderers.
286
304
  if (!enabledTools.has(name)) pi.registerTool(createBuiltInDefinition(name, cwd));
287
305
  }
288
306
  registeredTools.clear();
@@ -295,24 +313,38 @@ function registerTools(pi: ExtensionAPI, cwd: string): void {
295
313
  }
296
314
 
297
315
  function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void {
298
- runtime.configure(loadConfig(cwd, projectTrusted), isFullscreenMode());
299
- registerTools(pi, cwd ?? process.cwd());
316
+ const resolvedCwd = cwd ?? process.cwd();
317
+ const config = loadConfig(resolvedCwd, projectTrusted);
318
+ const signature = JSON.stringify([resolvedCwd, config]);
319
+ if (signature === registeredConfiguration) return;
320
+ runtime.configure(config);
321
+ registerTools(pi, resolvedCwd);
322
+ registeredConfiguration = signature;
300
323
  }
301
324
 
302
325
  export default function compactTools(pi: ExtensionAPI): void {
303
326
  const thinkingCycle = new ThinkingCycleController(pi);
304
- configure(pi);
327
+ const progress = new ProgressController(pi);
328
+ // Register once while the extension runtime is being built. In particular, this
329
+ // makes the overrides available before Pi restores the active tool set on /reload.
330
+ configure(pi, process.cwd());
305
331
  pi.on("session_start", (event, ctx) => {
306
332
  if (event.reason !== "reload") runtime.clearTimings();
307
333
  configure(pi, ctx.cwd, ctx.isProjectTrusted());
308
- bindTerminalInput(ctx);
309
- thinkingCycle.bind(ctx);
334
+ if (ctx.mode === "tui") {
335
+ progress.bind(ctx);
336
+ thinkingCycle.bind(ctx);
337
+ } else {
338
+ progress.dispose();
339
+ thinkingCycle.dispose();
340
+ }
310
341
  });
311
- pi.on("resources_discover", (_event, ctx) => configure(pi, ctx.cwd, ctx.isProjectTrusted()));
312
342
  pi.on("session_shutdown", (event) => {
313
343
  thinkingCycle.dispose();
314
- unsubscribeTerminalInput?.();
315
- unsubscribeTerminalInput = undefined;
344
+ progress.dispose();
316
345
  runtime.reset(event.reason !== "reload");
346
+ // Some Pi hosts rebuild the active tool registry during reload while keeping
347
+ // this module instance. Force the next session to restore our renderers.
348
+ if (event.reason === "reload") registeredConfiguration = undefined;
317
349
  });
318
350
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.6.1",
3
+ "version": "0.7.7",
4
4
  "description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,8 +18,15 @@
18
18
  },
19
19
  "default": ["read", "write", "edit", "bash"]
20
20
  },
21
+ "previewLines": {
22
+ "description": "Maximum rendered lines shown initially when auto_compact is false. Explicit expansion always shows the complete result.",
23
+ "type": "integer",
24
+ "minimum": 1,
25
+ "maximum": 100,
26
+ "default": 10
27
+ },
21
28
  "auto_compact": {
22
- "description": "Whether each compact-rendered tool starts compact and participates in the global Ctrl+O expand/collapse action. False starts expanded and excludes the tool from that action; clicking remains available.",
29
+ "description": "Whether each compact-rendered tool starts hidden. False starts with a bounded preview; Pi's normal click and Ctrl+O flow can expand either state.",
23
30
  "type": "object",
24
31
  "additionalProperties": false,
25
32
  "properties": {
@@ -38,29 +45,6 @@
38
45
  "edit": false,
39
46
  "bash": true
40
47
  }
41
- },
42
- "spinner": {
43
- "type": "object",
44
- "additionalProperties": false,
45
- "properties": {
46
- "frames": {
47
- "description": "Animation frames. Use terminal-safe single-width characters for consistent alignment.",
48
- "type": "array",
49
- "minItems": 1,
50
- "items": {
51
- "type": "string",
52
- "minLength": 1
53
- },
54
- "default": ["◐", "◓", "◑", "◒"]
55
- },
56
- "intervalMs": {
57
- "description": "Milliseconds between spinner frames.",
58
- "type": "integer",
59
- "minimum": 40,
60
- "maximum": 5000,
61
- "default": 120
62
- }
63
- }
64
48
  }
65
49
  }
66
50
  }
@@ -21,7 +21,7 @@ if (theme.name !== "github-dark-pro" || !theme.colors || typeof theme.colors !==
21
21
  throw new Error("themes/github-dark-pro.json is not a valid pi theme document");
22
22
  }
23
23
  if (!theme.colors.border || !theme.colors.dim || !theme.colors.muted) {
24
- throw new Error("github-dark-pro must define spinner and separator colors");
24
+ throw new Error("github-dark-pro must define separator colors");
25
25
  }
26
26
  if (!theme.vars?.thinkingText || !theme.colors.thinkingMax) {
27
27
  throw new Error("github-dark-pro must define thinking text and summary colors");
@@ -33,31 +33,25 @@ if (!theme.colors.mdQuote || !theme.colors.mdCodeBlockBorder) {
33
33
  const example = JSON.parse(
34
34
  await readFile(new URL("../examples/compact-tools.json", import.meta.url), "utf8"),
35
35
  );
36
- if (!Array.isArray(example.spinner?.frames) || example.spinner.frames.length === 0) {
37
- throw new Error("example spinner.frames must be a non-empty array");
38
- }
39
- if (!Number.isInteger(example.spinner.intervalMs)) {
40
- throw new Error("example spinner.intervalMs must be an integer");
41
- }
42
36
  if (!example.auto_compact || example.auto_compact.edit !== false) {
43
37
  throw new Error("example auto_compact policy must keep edit diffs visible by default");
44
38
  }
45
39
  if (Object.values(example.auto_compact).some((enabled) => typeof enabled !== "boolean")) {
46
40
  throw new Error("example auto_compact values must be booleans");
47
41
  }
48
- if ("previewLines" in example) {
49
- throw new Error("example must not contain the removed previewLines setting");
42
+ if (!Number.isInteger(example.previewLines) || example.previewLines < 1 || example.previewLines > 100) {
43
+ throw new Error("example previewLines must be an integer from 1 to 100");
50
44
  }
51
- const blinkExample = JSON.parse(
45
+ const alternateExample = JSON.parse(
52
46
  await readFile(new URL("../examples/compact-tools-2.json", import.meta.url), "utf8"),
53
47
  );
54
- if (JSON.stringify(blinkExample.spinner?.frames) !== JSON.stringify(["●", "●", " ", "●", "●"])) {
55
- throw new Error("compact-tools-2 spinner must fade through repeated circles and a blank frame");
56
- }
57
- for (const [name, config] of [["compact-tools", example], ["compact-tools-2", blinkExample]]) {
48
+ for (const [name, config] of [["compact-tools", example], ["compact-tools-2", alternateExample]]) {
58
49
  if ("durationIndicators" in config) {
59
50
  throw new Error(`${name} must not contain the removed durationIndicators setting`);
60
51
  }
52
+ if ("spinner" in config) {
53
+ throw new Error(`${name} must not contain the removed spinner setting`);
54
+ }
61
55
  }
62
56
 
63
57
  const schema = JSON.parse(
@@ -66,11 +60,14 @@ const schema = JSON.parse(
66
60
  if (schema.properties?.durationIndicators) {
67
61
  throw new Error("schema must not expose the removed durationIndicators setting");
68
62
  }
63
+ if (schema.properties?.spinner) {
64
+ throw new Error("schema must not expose the removed spinner setting");
65
+ }
69
66
  if (!schema.properties?.auto_compact?.properties?.edit) {
70
67
  throw new Error("schema must describe per-tool auto_compact policies");
71
68
  }
72
- if (schema.properties?.previewLines) {
73
- throw new Error("schema must not expose the removed previewLines setting");
69
+ if (schema.properties?.previewLines?.default !== 10) {
70
+ throw new Error("schema must expose the bounded previewLines setting");
74
71
  }
75
72
 
76
73
  console.log("Resource checks passed");
@@ -1,8 +0,0 @@
1
- import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
2
-
3
- export type ToggleInput = "toggle" | "release";
4
-
5
- export function classifyToggleInput(data: string): ToggleInput | undefined {
6
- if (!matchesKey(data, Key.ctrl("o"))) return undefined;
7
- return isKeyRelease(data) ? "release" : "toggle";
8
- }