pi-compact-tools 0.4.2 → 0.5.0

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.
package/README.md CHANGED
@@ -31,8 +31,8 @@ Completed `edit` rows show their full diff initially by default. The per-tool `a
31
31
  - Compact rendering for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`
32
32
  - Animated configurable spinner in fullscreen TUI mode
33
33
  - Millisecond-precision duration and configurable completion icons
34
- - Per-tool initial compaction with `auto_compact: true` or `false`
35
- - Click or `Ctrl+O` always cycles through meaningful detail levels
34
+ - Per-tool initial compaction and global-toggle participation with `auto_compact: true` or `false`
35
+ - Click cycles one row through meaningful detail levels; `Ctrl+O` expands or collapses all participating rows
36
36
  - Short previews, full output, and initially visible edit diffs by default
37
37
  - Dark `│` and `└─` visual grouping
38
38
  - Distinct cool-blue thinking text
@@ -104,13 +104,13 @@ These defaults use emoji and standard Unicode, so they work without a Nerd Font.
104
104
 
105
105
  For a minimal blinking spinner that reuses the successful-tool circle, copy [`examples/compact-tools-2.json`](examples/compact-tools-2.json). Its frames use `● ● (blank) ● ●` at 120 ms intervals. The renderer's built-in frame dimming turns this into a fade-out/fade-in animation without Nerd Font glyphs. Duration indicators use a small `•` whose color changes by elapsed time; the fast tier uses the theme's warning color value `#E0A052` directly. Rename it to `compact-tools.json` or copy its contents to the active configuration path.
106
106
 
107
- `auto_compact` controls each tool's initial state independently:
107
+ `auto_compact` controls each tool's initial state and whether it participates in the global `Ctrl+O` toggle:
108
108
 
109
- - `true` starts at the compact summary.
110
- - `false` starts at the most detailed available level.
111
- - Click or `Ctrl+O` continues to cycle through available levels in either case.
109
+ - `true` starts at the compact summary and participates in `Ctrl+O`.
110
+ - `false` starts at the most detailed available level and is unaffected by `Ctrl+O`.
111
+ - Clicking any compact-rendered row still cycles that row through its available levels.
112
112
 
113
- Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start compact, while `edit` starts expanded so code diffs are immediately visible.
113
+ Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start compact and participate in the global toggle, while `edit` starts expanded and remains unaffected so code diffs stay visible.
114
114
 
115
115
  `durationIndicators` must have ascending `underMs` values, with a final fallback entry that omits `underMs`. `color` is optional and accepts a supported theme color or six-digit hex value. Existing icon-only configurations remain compatible.
116
116
 
@@ -150,13 +150,19 @@ The escaped code points remain readable on GitHub and are decoded to Nerd Font i
150
150
 
151
151
  ## Controls
152
152
 
153
- `Ctrl+O` or a mouse click cycles only through available content:
153
+ A mouse click cycles one row through its available content:
154
154
 
155
155
  ```text
156
156
  summary → arguments → output preview → full output → summary
157
157
  ```
158
158
 
159
- The `auto_compact` boolean only chooses where that cycle starts. With the default `edit: false`, the diff starts visible and the next click collapses it.
159
+ `Ctrl+O` operates on all rows whose tool has `auto_compact: true`:
160
+
161
+ - If any participating row is not fully expanded, it expands all participating rows.
162
+ - If every participating row is fully expanded, it collapses all participating rows.
163
+ - Rows whose tool has `auto_compact: false` are left unchanged.
164
+
165
+ The extension handles and consumes `Ctrl+O` directly, so no Pi keybinding changes are required. With the default `edit: false`, edit diffs start visible and are excluded from the global toggle, while clicking an edit row still collapses or expands it.
160
166
 
161
167
  ## Notes
162
168
 
@@ -13,6 +13,14 @@ export type DurationIndicatorConfig = {
13
13
  color?: DurationIndicatorColor;
14
14
  };
15
15
  export type RowStatus = "pending" | "running" | "success" | "error";
16
+ export type ExpandableLevelState = {
17
+ level?: number;
18
+ levels: readonly number[];
19
+ };
20
+
21
+ export function shouldExpandAll(rows: readonly ExpandableLevelState[]): boolean {
22
+ return rows.some(({ level, levels }) => level !== levels.at(-1));
23
+ }
16
24
 
17
25
  const DURATION_INDICATOR_COLOR_SET = new Set<string>(DURATION_INDICATOR_COLORS);
18
26
  export const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
@@ -7,6 +7,7 @@ import type {
7
7
  BashToolDetails,
8
8
  BashToolInput,
9
9
  ExtensionAPI,
10
+ ExtensionContext,
10
11
  Theme,
11
12
  ToolDefinition,
12
13
  ToolRenderResultOptions,
@@ -24,7 +25,7 @@ import {
24
25
  getAgentDir,
25
26
  } from "@earendil-works/pi-coding-agent";
26
27
  import type { Component } from "@earendil-works/pi-tui";
27
- import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
28
+ import { Container, Key, matchesKey, Text, visibleWidth } from "@earendil-works/pi-tui";
28
29
  import {
29
30
  classifyCallStatus,
30
31
  formatDurationMs,
@@ -33,6 +34,7 @@ import {
33
34
  parseDurationIndicators,
34
35
  rgbToAnsi256,
35
36
  selectDurationIndicator,
37
+ shouldExpandAll,
36
38
  type DurationIndicatorConfig,
37
39
  type RowStatus,
38
40
  type ThemeDurationIndicatorColor,
@@ -82,13 +84,16 @@ export const DEFAULT_CONFIG: CompactToolsConfig = {
82
84
  };
83
85
 
84
86
  let config = DEFAULT_CONFIG;
87
+ let configRevision: object = {};
85
88
  let animateRows = false;
89
+ let unsubscribeTerminalInput: (() => void) | undefined;
86
90
  const registeredCompactTools = new Set<CompactToolName>();
87
91
 
88
92
  interface RowState {
89
93
  level?: number;
90
94
  availableLevels?: number[];
91
95
  lastExpanded?: boolean;
96
+ configRevision?: object;
92
97
  frame?: number;
93
98
  startedAt?: number;
94
99
  endedAt?: number;
@@ -240,7 +245,13 @@ export function loadConfig(cwd?: string, projectTrusted = false): CompactToolsCo
240
245
 
241
246
  const SPINNING_ROWS_KEY = Symbol.for("pi.compact-tools.spinning-rows");
242
247
  const EXECUTION_TIMINGS_KEY = Symbol.for("pi.compact-tools.execution-timings");
248
+ const MAX_TRACKED_ROWS = 2_000;
243
249
  type ExecutionTiming = { startedAt: number; endedAt?: number };
250
+ type TrackedRow = {
251
+ state: RowState;
252
+ name: CompactToolName;
253
+ invalidate: () => void;
254
+ };
244
255
  const globalState = globalThis as typeof globalThis & {
245
256
  [SPINNING_ROWS_KEY]?: Set<RowState>;
246
257
  [EXECUTION_TIMINGS_KEY]?: Map<string, ExecutionTiming>;
@@ -250,12 +261,48 @@ for (const staleState of globalState[SPINNING_ROWS_KEY] ?? []) {
250
261
  }
251
262
  const spinningRows = new Set<RowState>();
252
263
  const executionTimings = globalState[EXECUTION_TIMINGS_KEY] ?? new Map<string, ExecutionTiming>();
264
+ const trackedRows = new Map<string, TrackedRow>();
253
265
  globalState[SPINNING_ROWS_KEY] = spinningRows;
254
266
  globalState[EXECUTION_TIMINGS_KEY] = executionTimings;
255
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
+
256
302
  function advanceLevel(state: RowState, expanded: boolean, name: CompactToolName): number {
303
+ const initialized = initializeRowLevel(state, name);
257
304
  state.level ??= config.auto_compact[name] ? 0 : (state.availableLevels?.at(-1) ?? MAX_LEVEL);
258
- if (state.lastExpanded === undefined) {
305
+ if (initialized || state.lastExpanded === undefined) {
259
306
  state.lastExpanded = expanded;
260
307
  return state.level;
261
308
  }
@@ -270,6 +317,7 @@ function advanceLevel(state: RowState, expanded: boolean, name: CompactToolName)
270
317
 
271
318
  function setAvailableLevels(state: RowState, levels: number[], name: CompactToolName): void {
272
319
  state.availableLevels = levels;
320
+ if (initializeRowLevel(state, name, levels)) return;
273
321
  if (state.level === undefined) {
274
322
  state.level = config.auto_compact[name] ? 0 : (levels.at(-1) ?? 0);
275
323
  } else if (!levels.includes(state.level)) {
@@ -354,9 +402,20 @@ function syncRow(
354
402
 
355
403
  function resetUiState(clearTimings = false): void {
356
404
  for (const state of [...spinningRows]) stopSpinner(state);
405
+ trackedRows.clear();
357
406
  if (clearTimings) executionTimings.clear();
358
407
  }
359
408
 
409
+ function bindTerminalInput(ctx: ExtensionContext): void {
410
+ unsubscribeTerminalInput?.();
411
+ unsubscribeTerminalInput = ctx.ui.onTerminalInput((data) => {
412
+ if (!matchesKey(data, Key.ctrl("o"))) return undefined;
413
+ const result = toggleTrackedRows();
414
+ if (result) ctx.ui.notify(`Compact tool rows: ${result}`, "info");
415
+ return { consume: true };
416
+ });
417
+ }
418
+
360
419
  function spinnerTone(frameIndex: number, frameCount: number): "muted" | "dim" | "border" {
361
420
  if (frameCount < 3) return "muted";
362
421
  const position = frameIndex / (frameCount - 1);
@@ -419,7 +478,7 @@ function renderControls(theme: Theme, state: RowState, running: boolean, isError
419
478
  const expandable = levels.length > 1;
420
479
  const atLastLevel = state.level === levels.at(-1);
421
480
  const action = expandable
422
- ? `${theme.italic("ctrl+o")} ${atLastLevel ? "to collapse" : "for more"}`
481
+ ? `${theme.italic("ctrl+o")} toggle all • ${theme.italic("click")} ${atLastLevel ? "to collapse" : "for more"}`
423
482
  : undefined;
424
483
  const indicator = !running && !isError ? durationIndicator(state) : undefined;
425
484
  const status = running
@@ -547,6 +606,7 @@ function renderFileCall(
547
606
  // Animate and time from the first rendered call so large write/edit calls
548
607
  // represent the full lifecycle, including streamed arguments and filesystem work.
549
608
  const state = syncRow(ctx, ctx.state.endedAt === undefined);
609
+ trackRow(ctx, definition.name as CompactToolName, state);
550
610
  // syncRow may restore a completed timing after /reload. Recompute from the
551
611
  // synchronized state so a stale local value cannot leave the spinner visible.
552
612
  const status = resolveCallStatus(ctx, state);
@@ -593,6 +653,7 @@ function renderFileResult(
593
653
  ctx: RenderContext,
594
654
  ): Container {
595
655
  const state = syncRow(ctx, options.isPartial, !options.isPartial);
656
+ trackRow(ctx, definition.name as CompactToolName, state);
596
657
  const output = getFileOutput(definition.name, ctx.args, result, ctx.isError);
597
658
  const hasArguments = Object.keys(getFileArgumentDetails(definition.name, ctx.args)).length > 0;
598
659
  const levels = definition.name === "edit" ? [0, MAX_LEVEL] : [0, ...(hasArguments ? [1] : []), ...getOutputLevels(output)];
@@ -652,6 +713,7 @@ function renderShellCall(
652
713
  ctx: RenderContext<BashToolInput>,
653
714
  ): Text {
654
715
  const state = syncRow(ctx, ctx.state.endedAt === undefined);
716
+ trackRow(ctx, name, state);
655
717
  // A completed timing can be restored inside syncRow. Derive the rendered
656
718
  // status afterward so completion immediately replaces the spinner.
657
719
  const status = resolveCallStatus(ctx, state);
@@ -671,6 +733,7 @@ function renderShellResult(
671
733
  ctx: RenderContext<BashToolInput>,
672
734
  ): Component {
673
735
  const state = syncRow(ctx, options.isPartial, !options.isPartial);
736
+ trackRow(ctx, name, state);
674
737
  const output = getTextResult(result);
675
738
  const hasDetailedCall = /\r\n?|\n/.test(ctx.args.command ?? "");
676
739
  setAvailableLevels(state, [0, ...(hasDetailedCall ? [1] : []), ...getOutputLevels(output)], name);
@@ -735,6 +798,7 @@ function applyConfig(pi: ExtensionAPI, cwd: string, projectTrusted: boolean): vo
735
798
  resetUiState();
736
799
  animateRows = getConfiguredTuiMode() === "fullscreen";
737
800
  config = loadConfig(cwd, projectTrusted);
801
+ configRevision = {};
738
802
  registerBuiltInTools(pi, cwd);
739
803
  }
740
804
 
@@ -745,7 +809,12 @@ export default function (pi: ExtensionAPI): void {
745
809
  pi.on("session_start", (event, ctx) => {
746
810
  if (event.reason !== "reload") executionTimings.clear();
747
811
  applyConfig(pi, ctx.cwd, ctx.isProjectTrusted());
812
+ bindTerminalInput(ctx);
748
813
  });
749
814
  pi.on("resources_discover", (_event, ctx) => applyConfig(pi, ctx.cwd, ctx.isProjectTrusted()));
750
- pi.on("session_shutdown", (event) => resetUiState(event.reason !== "reload"));
815
+ pi.on("session_shutdown", (event) => {
816
+ unsubscribeTerminalInput?.();
817
+ unsubscribeTerminalInput = undefined;
818
+ resetUiState(event.reason !== "reload");
819
+ });
751
820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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",
@@ -19,7 +19,7 @@
19
19
  "default": ["read", "write", "edit", "bash"]
20
20
  },
21
21
  "auto_compact": {
22
- "description": "Whether each compact-rendered tool starts in its compact summary state. Click/Ctrl+O remains available regardless of this value.",
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.",
23
23
  "type": "object",
24
24
  "additionalProperties": false,
25
25
  "properties": {