pi-compact-tools 0.13.1 → 0.14.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.
@@ -1,3 +1,4 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import type {
2
3
  AgentToolResult,
3
4
  ExtensionAPI,
@@ -15,11 +16,14 @@ import {
15
16
  createPowerShellToolDefinition,
16
17
  createReadToolDefinition,
17
18
  createWriteToolDefinition,
19
+ ToolExecutionComponent,
18
20
  } from "@earendil-works/pi-coding-agent";
19
21
  import type { Component } from "@earendil-works/pi-tui";
20
22
  import { classifyCallStatus, formatDurationMs, normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
21
23
  import { loadConfig } from "./compact-tools-config.ts";
22
- import { installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
24
+ import { paletteOf } from "./compact-tools-color.ts";
25
+ import { registerCompactToolsCommand } from "./compact-tools-command.ts";
26
+ import { clearRowResolver, installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
23
27
  import {
24
28
  formatResultLineSummary,
25
29
  countEditChanges,
@@ -40,7 +44,7 @@ import {
40
44
  claudeRows,
41
45
  LiveCallContainer,
42
46
  limitComponentLines,
43
- prefixedLines,
47
+ prefixedSentence,
44
48
  prefixedText,
45
49
  railComponent,
46
50
  renderArguments,
@@ -49,14 +53,17 @@ import {
49
53
  renderOutput,
50
54
  renderToolCall,
51
55
  styleMultiline,
56
+ WithinWidth,
52
57
  } from "./compact-tools-layout.ts";
53
- import { CLAUDE_DIFF_CONTEXT_LINES, CLAUDE_OUTPUT_ROWS, CLAUDE_WRITE_ROWS, claudeFailure, claudeOutcome, claudeTitle } from "./compact-tools-claude.ts";
58
+ import { CLAUDE_DIFF_CONTEXT_LINES, CLAUDE_INDENT, CLAUDE_OUTPUT_ROWS, CLAUDE_RESULT_PREFIX, CLAUDE_TITLE_INDENT, CLAUDE_WRITE_ROWS, claudeFailure, claudeOutcome, claudeTitle } from "./compact-tools-claude.ts";
59
+ import { CODEX_BULLET } from "./compact-tools-codex.ts";
60
+ import { renderCodexCall, renderCodexResult } from "./compact-tools-codex-rows.ts";
54
61
  import { ToolGroupController } from "./compact-tools-grouping.ts";
55
62
  import { chromePainter, paintIndicator } from "./compact-tools-palette.ts";
56
63
  import { ProgressController } from "./compact-tools-progress.ts";
57
64
  import { showReleaseNotice } from "./compact-tools-release.ts";
58
65
  import { ToolRuntime } from "./compact-tools-runtime.ts";
59
- import { SilentModeController } from "./compact-tools-silent.ts";
66
+ import { findChat, isKind, SilentModeController } from "./compact-tools-silent.ts";
60
67
  import { ThinkingCycleController } from "./compact-tools-thinking.ts";
61
68
  import { ViewportKeeper } from "./compact-tools-viewport.ts";
62
69
  import {
@@ -70,13 +77,28 @@ import {
70
77
 
71
78
  /** Context lines kept around each change while an edit result is collapsed. */
72
79
  const PREVIEW_DIFF_CONTEXT_LINES = 1;
73
- /** Claude Code sets what a call returned under the "⎿" rather than beside a rail. */
74
- const CLAUDE_INDENT = " ";
75
80
 
76
81
  const runtime = new ToolRuntime();
77
82
  const RENDER_WIDGET_KEY = "compact-tools-render";
78
83
  const registeredTools = new Set<CompactToolName>();
79
84
  let registeredConfiguration: string | undefined;
85
+ /**
86
+ * Whether the last session found its directory trusted. A /reload rebuilds the
87
+ * transcript before session_start says so again, so the tools a trusted project
88
+ * adds are registered from the start rather than after its rows were made. Pi
89
+ * evaluates this module afresh on every load, so the answer is kept on globalThis.
90
+ */
91
+ const TRUST_KEY = Symbol.for("pi-compact-tools.trust");
92
+ type TrustSlot = typeof globalThis & { [TRUST_KEY]?: { cwd: string; trusted: boolean } };
93
+
94
+ /** A directory as the file system names it, so `/var` and `/private/var` are one place. */
95
+ function realDirectory(path: string): string {
96
+ try {
97
+ return realpathSync(path);
98
+ } catch {
99
+ return path;
100
+ }
101
+ }
80
102
 
81
103
  /** How a tool's row is drawn: a file tool's result is code, a shell's is output, a custom tool's is its author's. */
82
104
  type RowKind = "file" | "shell" | "custom";
@@ -90,6 +112,10 @@ function isClaude(): boolean {
90
112
  return runtime.config.style === "claude";
91
113
  }
92
114
 
115
+ function isCodex(): boolean {
116
+ return runtime.config.style === "codex";
117
+ }
118
+
93
119
  function pathArgument(args: ToolArgs): string {
94
120
  return typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : "";
95
121
  }
@@ -107,13 +133,13 @@ function callStatus(ctx: RenderContext, state: RowState): RowStatus {
107
133
  * result, so a dot painted with the call would miss the result that arrived with
108
134
  * it; reading the row's state at draw time sees it. A settled dot never changes.
109
135
  */
110
- function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState): () => string {
136
+ function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState, glyph?: string): () => string {
111
137
  let settled: string | undefined;
112
138
  return () => {
113
139
  if (settled !== undefined) return settled;
114
140
  const status = callStatus(ctx, state);
115
141
  const indicator = paintIndicator(theme, status,
116
- runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()));
142
+ runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()), glyph);
117
143
  if (status === "success" || status === "error") settled = indicator;
118
144
  return indicator;
119
145
  };
@@ -128,6 +154,7 @@ function canReuseResult<TArgs>(
128
154
  state: RowState,
129
155
  result: AgentToolResult<unknown>,
130
156
  options: ToolRenderResultOptions,
157
+ theme: Theme,
131
158
  ctx: RenderContext<TArgs>,
132
159
  ): ctx is RenderContext<TArgs> & { lastComponent: Component } {
133
160
  return ctx.lastComponent !== undefined
@@ -137,10 +164,11 @@ function canReuseResult<TArgs>(
137
164
  && state.lastResultExpanded === state.expanded
138
165
  && state.lastResultPreview === state.preview
139
166
  && state.lastResultError === ctx.isError
140
- && state.lastResultConfigRevision === state.configRevision;
167
+ && state.lastResultConfigRevision === state.configRevision
168
+ && state.lastResultPalette === paletteOf(theme);
141
169
  }
142
170
 
143
- function rememberResult(state: RowState, result: AgentToolResult<unknown>, options: ToolRenderResultOptions, isError: boolean): void {
171
+ function rememberResult(state: RowState, result: AgentToolResult<unknown>, options: ToolRenderResultOptions, isError: boolean, theme: Theme): void {
144
172
  state.lastResultContent = result.content;
145
173
  state.lastResultDetails = result.details;
146
174
  state.lastResultPartial = options.isPartial;
@@ -148,6 +176,7 @@ function rememberResult(state: RowState, result: AgentToolResult<unknown>, optio
148
176
  state.lastResultPreview = state.preview;
149
177
  state.lastResultError = isError;
150
178
  state.lastResultConfigRevision = state.configRevision;
179
+ state.lastResultPalette = paletteOf(theme);
151
180
  }
152
181
 
153
182
  function renderControls(
@@ -188,19 +217,22 @@ function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderCo
188
217
  const state = runtime.syncRow(ctx);
189
218
  runtime.syncExpansion(state, ctx.expanded, name);
190
219
  const kind = rowKind(name);
191
- const indicator = liveIndicator(theme, ctx, state);
220
+ const indicator = liveIndicator(theme, ctx, state, isCodex() ? CODEX_BULLET : undefined);
192
221
  // Painted once now as well, so a row that starts running starts the animation clock.
193
222
  indicator();
223
+ // Codex's head reads the call's outcome, so it is worded each time it is drawn.
224
+ if (isCodex()) return renderCodexCall(name, kind, args, theme, state, () => callStatus(ctx, state), indicator, ctx.toolCallId);
194
225
  if (isClaude()) {
195
226
  // Claude Code's call line: `⦁ Bash(npm test)`, `⦁ Update(src/app.ts)`.
196
227
  const { label, argument } = claudeTitle(name, args, state.expanded === true);
197
228
  const title = ` ${theme.fg("toolTitle", theme.bold(label))}`
198
229
  + (argument ? styleMultiline(`(${argument})`, (line) => theme.fg("toolOutput", line)) : "");
199
- // The title wraps rather than being cut to the row; only a very long command is shortened.
200
- const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, undefined, theme));
230
+ // The title wraps under the tool's name rather than being cut to the row; only a
231
+ // very long command is shortened.
232
+ const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, undefined, theme, CLAUDE_TITLE_INDENT));
201
233
  // The title already lists plain arguments; only nested ones need the full view.
202
- if (kind === "custom" && state.expanded && hasNestedArguments(args)) container.addChild(renderArguments(args, theme));
203
- return container;
234
+ if (kind === "custom" && state.expanded && hasNestedArguments(args)) container.addChild(renderArguments(args, theme, CLAUDE_INDENT));
235
+ return new WithinWidth(container);
204
236
  }
205
237
  const title = ` ${theme.fg("toolTitle", theme.bold(name))}`;
206
238
  let details: string | undefined;
@@ -216,10 +248,13 @@ function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderCo
216
248
  const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, details, theme));
217
249
  const extra = kind === "file" ? getArgumentDetails(name, args) : kind === "custom" && state.expanded ? args : {};
218
250
  if (Object.keys(extra).length > 0) container.addChild(renderArguments(extra, theme));
219
- return container;
251
+ return new WithinWidth(container);
220
252
  }
221
253
 
222
- /** A result body as code where it is code: numbered file text, or an edit's diff. */
254
+ /**
255
+ * A result body as code where it is code: numbered file text, or an edit's diff.
256
+ * `rail` is what each row starts with; the compact result rail unless given.
257
+ */
223
258
  function renderFileBody(
224
259
  name: string,
225
260
  args: ToolArgs,
@@ -228,20 +263,22 @@ function renderFileBody(
228
263
  expanded: boolean,
229
264
  theme: Theme,
230
265
  isError: boolean,
266
+ rail?: string,
231
267
  ): Component | undefined {
232
- if (isError) return renderOutput(output, theme, isError);
268
+ const fallback = () => renderOutput(output, theme, isError, rail);
269
+ if (isError) return fallback();
233
270
  const path = pathArgument(args);
234
271
  if (name === "edit" && getEditDiff(result)) {
235
272
  return renderCodeDiff(getEditPatch(result), getEditDiff(result), path, theme,
236
- expanded ? {} : { contextLines: PREVIEW_DIFF_CONTEXT_LINES }) ?? renderOutput(output, theme, isError);
273
+ expanded ? { rail } : { contextLines: PREVIEW_DIFF_CONTEXT_LINES, rail }) ?? fallback();
237
274
  }
238
275
  if (name === "read" && isReadTextResult(result)) {
239
276
  const { body, footer } = splitReadFooter(output);
240
277
  const startLine = typeof args.offset === "number" ? args.offset : 1;
241
- return renderCodeView(body, path, theme, { startLine, footer }) ?? renderOutput(output, theme, isError);
278
+ return renderCodeView(body, path, theme, { startLine, footer, rail }) ?? fallback();
242
279
  }
243
- if (name === "write") return renderCodeView(output, path, theme) ?? renderOutput(output, theme, isError);
244
- return renderOutput(output, theme, isError);
280
+ if (name === "write") return renderCodeView(output, path, theme, { rail }) ?? fallback();
281
+ return fallback();
245
282
  }
246
283
 
247
284
  function renderRowBody(
@@ -253,17 +290,18 @@ function renderRowBody(
253
290
  theme: Theme,
254
291
  isError: boolean,
255
292
  author: AuthorResultRenderer | undefined,
293
+ rail?: string,
256
294
  ): Component | undefined {
257
- if (rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError);
295
+ if (rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError, rail);
258
296
  if (author) {
259
297
  try {
260
298
  const component = author(expanded);
261
- if (component) return railComponent(component, theme);
299
+ if (component) return railComponent(component, theme, rail);
262
300
  } catch {
263
301
  // A failing third-party renderer degrades to the plain text result.
264
302
  }
265
303
  }
266
- return renderOutput(output, theme, isError);
304
+ return renderOutput(output, theme, isError, rail);
267
305
  }
268
306
 
269
307
  function claudeMoreLines(theme: Theme, hidden: number): string {
@@ -309,12 +347,12 @@ function claudeBody(
309
347
  // A command's output is already the result line itself.
310
348
  const kind = rowKind(name);
311
349
  if (!expanded || kind === "shell") return undefined;
312
- if (kind === "custom") return author ? renderRowBody(name, args, result, output, true, theme, false, author) : undefined;
313
- return renderFileBody(name, args, result, output, true, theme, false);
350
+ if (kind === "custom") return author ? renderRowBody(name, args, result, output, true, theme, false, author, CLAUDE_INDENT) : undefined;
351
+ return renderFileBody(name, args, result, output, true, theme, false, CLAUDE_INDENT);
314
352
  }
315
353
 
316
354
  /**
317
- * Claude Code's result block: a "└" line saying what happened, or the output
355
+ * Claude Code's result block: a "⎿" line saying what happened, or the output
318
356
  * itself for commands and custom tools, then any diff or code beneath it.
319
357
  */
320
358
  function renderClaudeResult(
@@ -331,7 +369,7 @@ function renderClaudeResult(
331
369
  const chrome = chromePainter(theme);
332
370
  const container = new CachedContainer();
333
371
  // One line under the call, wrapped rather than cut to the row.
334
- const line = (text: string) => prefixedLines(text, chrome(" └ "), CLAUDE_INDENT, false);
372
+ const line = (text: string) => prefixedSentence(text, chrome(CLAUDE_RESULT_PREFIX), CLAUDE_INDENT);
335
373
  if (options.isPartial) {
336
374
  container.addChild(line(chrome("Running…")));
337
375
  return container;
@@ -344,7 +382,7 @@ function renderClaudeResult(
344
382
  container.addChild(line(theme.fg("error", failure.headline)));
345
383
  container.addChild(claudeOutput(failure.detail, expanded, theme, true, "", CLAUDE_INDENT));
346
384
  } else if (expanded && output.trim()) {
347
- container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(" └ ")));
385
+ container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(CLAUDE_RESULT_PREFIX)));
348
386
  } else {
349
387
  container.addChild(line(theme.fg("error", failure.headline)));
350
388
  }
@@ -356,7 +394,7 @@ function renderClaudeResult(
356
394
  ? formatResultLineSummary(name, args, result, output) ?? "Done"
357
395
  : claudeOutcome(name, args, result, output, (value) => theme.bold(value));
358
396
  if (outcome !== undefined) container.addChild(line(theme.fg("toolOutput", outcome)));
359
- else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(" └ ")));
397
+ else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(CLAUDE_RESULT_PREFIX)));
360
398
  if (body) container.addChild(body);
361
399
  return container;
362
400
  }
@@ -376,14 +414,19 @@ function renderRowResult(
376
414
  ): Component {
377
415
  const state = runtime.syncRow(ctx, !options.isPartial);
378
416
  runtime.syncExpansion(state, ctx.expanded, name);
379
- if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
417
+ if (canReuseResult(state, result, options, theme, ctx)) return ctx.lastComponent;
380
418
  const kind = rowKind(name);
381
419
  const output = kind === "file" ? getFileOutput(name, ctx.args, result, ctx.isError) : getTextResult(result);
382
420
  const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
383
421
  runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0 || author !== undefined);
384
422
  let component: Component;
385
- if (isClaude()) {
386
- component = renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author);
423
+ if (isCodex()) {
424
+ component = renderCodexResult({
425
+ name, kind, args: ctx.args, result, output, theme, state, isError: ctx.isError,
426
+ isPartial: options.isPartial, expanded: state.expanded === true, toolCallId: ctx.toolCallId, author,
427
+ });
428
+ } else if (isClaude()) {
429
+ component = new WithinWidth(renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author));
387
430
  } else {
388
431
  if (!options.isPartial && !state.resultLineSummaryComputed) {
389
432
  // A custom tool with no text has nothing to count.
@@ -400,9 +443,9 @@ function renderRowResult(
400
443
  const failureReason = ctx.isError && !state.expanded && !state.preview ? summarizeFailure(name, output) : undefined;
401
444
  container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, failureReason,
402
445
  name === "edit" ? countEditChanges(result) : undefined));
403
- component = container;
446
+ component = new WithinWidth(container);
404
447
  }
405
- rememberResult(state, result, options, ctx.isError);
448
+ rememberResult(state, result, options, ctx.isError, theme);
406
449
  return component;
407
450
  }
408
451
 
@@ -491,6 +534,28 @@ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void
491
534
  registeredConfiguration = signature;
492
535
  }
493
536
 
537
+ /**
538
+ * Apply a style saved by /compact-tools without reloading Pi. The compact, Claude, and
539
+ * Codex styles share every tool registration and read the style as they draw, so the
540
+ * configuration is swapped and each tool row on screen is drawn again, a running one
541
+ * included. Anything else that changed, or a switch to or from off, needs a reload.
542
+ */
543
+ function applyStyle(cwd: string, trusted: boolean): boolean {
544
+ const config = loadConfig(cwd, trusted);
545
+ const current = runtime.config;
546
+ if (config.style === "off" || current.style === "off") return false;
547
+ if (JSON.stringify({ ...config, style: current.style }) !== JSON.stringify(current)) return false;
548
+ runtime.configure(config);
549
+ registeredConfiguration = JSON.stringify([cwd, config]);
550
+ const chat = boundTui ? findChat(boundTui) : undefined;
551
+ for (const child of chat?.children ?? []) {
552
+ // Pi asks a row's renderers again whenever it redraws the row; this is that redraw.
553
+ if (isKind(child, ToolExecutionComponent)) (child as { updateDisplay?(): void }).updateDisplay?.();
554
+ }
555
+ boundTui?.requestRender();
556
+ return true;
557
+ }
558
+
494
559
  /** Note the calls an assistant message makes, as Pi reads them to make their rows. */
495
560
  function noteCalls(message: unknown): void {
496
561
  // Only a run's own updates announce calls; the check is cheap enough for every update.
@@ -511,15 +576,20 @@ function madeByTool(event: object): boolean {
511
576
  return typeof (event as { parentToolCallId?: unknown }).parentToolCallId === "string";
512
577
  }
513
578
 
579
+ /** Pi's TUI while one is bound: where a style switched in place finds the rows to draw again. */
580
+ let boundTui: { requestRender(): void } | undefined;
581
+
514
582
  /** Hand the animation Pi's frame request, reached through an invisible widget as the extension API allows. */
515
583
  function bindRenderer(ctx: ExtensionContext): void {
516
584
  ctx.ui.setWidget(RENDER_WIDGET_KEY, (tui) => {
585
+ boundTui = tui;
517
586
  runtime.bindRenderer(() => tui.requestRender());
518
587
  return { render: () => [], invalidate() {} };
519
588
  }, { placement: "belowEditor" });
520
589
  }
521
590
 
522
591
  function unbindRenderer(ctx: ExtensionContext | undefined): void {
592
+ boundTui = undefined;
523
593
  runtime.bindRenderer(undefined);
524
594
  ctx?.ui.setWidget(RENDER_WIDGET_KEY, undefined, { placement: "belowEditor" });
525
595
  }
@@ -533,6 +603,7 @@ export default function compactTools(pi: ExtensionAPI): void {
533
603
  registeredConfiguration = undefined;
534
604
  registeredTools.clear();
535
605
 
606
+ registerCompactToolsCommand(pi, () => runtime.config, applyStyle);
536
607
  const thinkingCycle = new ThinkingCycleController(pi);
537
608
  const progress = new ProgressController(pi);
538
609
  const viewport = new ViewportKeeper();
@@ -544,12 +615,16 @@ export default function compactTools(pi: ExtensionAPI): void {
544
615
  let renderContext: ExtensionContext | undefined;
545
616
  // Register once while the extension runtime is being built. In particular, this
546
617
  // makes the overrides available before Pi restores the active tool set on /reload.
547
- configure(pi, process.cwd());
618
+ const cwd = process.cwd();
619
+ const lastTrust = (globalThis as TrustSlot)[TRUST_KEY];
620
+ configure(pi, cwd, lastTrust?.cwd === realDirectory(cwd) && lastTrust.trusted);
548
621
  pi.on("session_start", (event, ctx) => {
549
622
  if (event.reason !== "reload") runtime.clearTimings();
550
623
  // Rows restored from the session are drawn next; while idle none of them is running.
551
624
  if (typeof ctx.isIdle === "function") runtime.setBusy(!ctx.isIdle());
552
- configure(pi, ctx.cwd, ctx.isProjectTrusted());
625
+ const trusted = ctx.isProjectTrusted();
626
+ (globalThis as TrustSlot)[TRUST_KEY] = { cwd: realDirectory(ctx.cwd), trusted };
627
+ configure(pi, ctx.cwd, trusted);
553
628
  unbindRenderer(renderContext);
554
629
  renderContext = undefined;
555
630
  if (ctx.mode === "tui" && runtime.config.style !== "off") {
@@ -597,5 +672,7 @@ export default function compactTools(pi: ExtensionAPI): void {
597
672
  unbindRenderer(renderContext);
598
673
  renderContext = undefined;
599
674
  runtime.reset(event.reason !== "reload");
675
+ // A reload that keeps the extension sets it again before Pi rebuilds the transcript.
676
+ clearRowResolver(resolveCustomRow);
600
677
  });
601
678
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.13.1",
4
- "description": "Compact, expandable tool rows for Pi in its own compact style or Claude Code's, with a silent mode and a GitHub Dark theme.",
3
+ "version": "0.14.0",
4
+ "description": "Tool rows for Pi in three styles: Claude, Codex, and Compact. Switch with /compact-tools; includes silent mode and a GitHub Dark theme.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Dong Hyeong Lee",
@@ -17,6 +17,8 @@
17
17
  "pi-package",
18
18
  "pi-coding-agent",
19
19
  "pi-extension",
20
+ "claude-code",
21
+ "codex",
20
22
  "terminal-ui",
21
23
  "theme"
22
24
  ],
@@ -24,14 +26,13 @@
24
26
  "extensions",
25
27
  "themes",
26
28
  "examples",
27
- "scripts",
28
29
  "schemas",
29
30
  "release-notes.json",
30
31
  "README.md",
31
32
  "LICENSE"
32
33
  ],
33
34
  "scripts": {
34
- "test": "tsx --test tests/*.test.ts",
35
+ "test": "tsx --test tests/*.test.ts && tsx --test tests/resources.validation.ts",
35
36
  "check": "tsc --noEmit && npm test && npm run check:resources",
36
37
  "check:resources": "node scripts/check-resources.mjs",
37
38
  "prepublishOnly": "npm run check"
@@ -50,9 +51,12 @@
50
51
  "@earendil-works/pi-tui": ">=0.85.1"
51
52
  },
52
53
  "devDependencies": {
53
- "@earendil-works/pi-coding-agent": "0.99.1",
54
- "@earendil-works/pi-tui": "0.99.1",
54
+ "@earendil-works/pi-coding-agent": "1.0.0",
55
+ "@earendil-works/pi-tui": "1.0.0",
56
+ "@sinonjs/fake-timers": "15.0.0",
55
57
  "@types/node": "^24.0.0",
58
+ "@types/sinonjs__fake-timers": "15.0.1",
59
+ "ajv": "8.20.0",
56
60
  "tsx": "^4.23.13",
57
61
  "typescript": "^5.9.0"
58
62
  },
@@ -1,4 +1,27 @@
1
1
  {
2
+ "0.14.0": {
3
+ "New": [
4
+ "Codex style added.",
5
+ "`/compact-tools` command added. Try `/compact-tools codex`."
6
+ ],
7
+ "Fix": [
8
+ "Commands that write or run something, such as `tee`, `xargs`, or `find -delete`, keep their own row instead of folding into a group",
9
+ "`/compact-tools codex` without a scope saves where the style is decided, so a switch always takes effect",
10
+ "`/compact-tools` and `/silent` run on the first Enter",
11
+ "A trusted project's tools apply right after a style switch",
12
+ "Fixes for narrow panes, long error messages, and pipelines in Compact summaries"
13
+ ],
14
+ "Deprecated": []
15
+ },
16
+ "0.13.2": [
17
+ "Verified on Pi 1.0.0 while keeping compatibility with Pi 0.85.1 and later",
18
+ "Silent activity keeps animating through retries and follow-up turns until final settlement",
19
+ "Hidden file results defer syntax highlighting until their code is actually shown",
20
+ "Completed tool results repaint correctly when the theme changes",
21
+ "Large file views render without exceeding JavaScript argument limits",
22
+ "Fullscreen viewport anchoring and code-row layout allocate less work",
23
+ "Execution timing history stays bounded even when tool rows are not drawn"
24
+ ],
2
25
  "0.13.1": [],
3
26
  "0.13.0": [
4
27
  "Follows Pi 0.99's system theme and light terminals: rails, dots, and the working label take their colors from the theme itself",
@@ -9,13 +9,13 @@
9
9
  "type": "string"
10
10
  },
11
11
  "style": {
12
- "description": "How tool rows are drawn. compact draws each call as a compact, expandable row. claude draws them the way Claude Code does: runs of reads, searches, listings, and commands that only look around fold into one summary line, while other commands, edits, writes, and custom tools keep rows of their own with Bash(…), Update(…), and similar titles; click a group or press Ctrl+O to open it. off leaves every tool with Pi's own renderer.",
12
+ "description": "How tool rows are drawn. compact draws each call as a compact, expandable row. claude draws them the way Claude Code does: runs of reads, searches, listings, and commands that only look around fold into one summary line, while other commands, edits, writes, and custom tools keep rows of their own with Bash(…), Update(…), and similar titles; click a group or press Ctrl+O to open it. codex draws them the way Codex CLI does: calls that only look around fold into a • Explored group, commands read Ran or Failed (exit N) with their last three output lines, edits preview their changed lines, and custom tools read • Called; Ctrl+O or a click opens each one. off leaves every tool with Pi's own renderer.",
13
13
  "type": "string",
14
- "enum": ["compact", "claude", "off"],
14
+ "enum": ["compact", "claude", "codex", "off"],
15
15
  "default": "compact"
16
16
  },
17
17
  "mode": {
18
- "description": "normal shows every tool row. silent leaves only your prompts and the final answers: tool rows, in-between assistant turns, thinking, error and abort notices, extension notices, and Pi's own status lines are hidden while a light animates beneath the prompt. Ctrl+' or /silent toggles it for the current session, in either style.",
18
+ "description": "normal shows every tool row. silent leaves only your prompts and the final answers: tool rows, in-between assistant turns, thinking, error and abort notices, extension notices, and Pi's own status lines are hidden while a light animates beneath the prompt. Ctrl+' or /silent toggles it for the current session, in any style.",
19
19
  "type": "string",
20
20
  "enum": ["normal", "silent"],
21
21
  "default": "normal"
@@ -1,82 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
-
3
- const resources = [
4
- "themes/github-dark-pro.json",
5
- "examples/compact-tools.json",
6
- "examples/compact-tools-2.json",
7
- "schemas/compact-tools.schema.json",
8
- "release-notes.json",
9
- ];
10
-
11
- for (const path of resources) {
12
- const value = JSON.parse(await readFile(new URL(`../${path}`, import.meta.url), "utf8"));
13
- if (!value || typeof value !== "object" || Array.isArray(value)) {
14
- throw new Error(`${path} must contain a JSON object`);
15
- }
16
- }
17
-
18
- const theme = JSON.parse(
19
- await readFile(new URL("../themes/github-dark-pro.json", import.meta.url), "utf8"),
20
- );
21
- if (theme.name !== "github-dark-pro" || !theme.colors || typeof theme.colors !== "object") {
22
- throw new Error("themes/github-dark-pro.json is not a valid pi theme document");
23
- }
24
- if (!theme.colors.border || !theme.colors.dim || !theme.colors.muted) {
25
- throw new Error("github-dark-pro must define separator colors");
26
- }
27
- if (!theme.vars?.thinkingText || !theme.colors.thinkingMax) {
28
- throw new Error("github-dark-pro must define thinking text and summary colors");
29
- }
30
- if (!theme.colors.mdQuote || !theme.colors.mdCodeBlockBorder) {
31
- throw new Error("github-dark-pro must define thinking detail and code fence colors");
32
- }
33
-
34
- const example = JSON.parse(
35
- await readFile(new URL("../examples/compact-tools.json", import.meta.url), "utf8"),
36
- );
37
- if (!example.auto_compact || example.auto_compact.edit !== false) {
38
- throw new Error("example auto_compact policy must keep edit diffs visible by default");
39
- }
40
- if (Object.values(example.auto_compact).some((enabled) => typeof enabled !== "boolean")) {
41
- throw new Error("example auto_compact values must be booleans");
42
- }
43
- if (!Number.isInteger(example.previewLines) || example.previewLines < 1 || example.previewLines > 100) {
44
- throw new Error("example previewLines must be an integer from 1 to 100");
45
- }
46
- const alternateExample = JSON.parse(
47
- await readFile(new URL("../examples/compact-tools-2.json", import.meta.url), "utf8"),
48
- );
49
- for (const [name, config] of [["compact-tools", example], ["compact-tools-2", alternateExample]]) {
50
- if ("durationIndicators" in config) {
51
- throw new Error(`${name} must not contain the removed durationIndicators setting`);
52
- }
53
- if ("spinner" in config) {
54
- throw new Error(`${name} must not contain the removed spinner setting`);
55
- }
56
- }
57
-
58
- const schema = JSON.parse(
59
- await readFile(new URL("../schemas/compact-tools.schema.json", import.meta.url), "utf8"),
60
- );
61
- if (schema.properties?.durationIndicators) {
62
- throw new Error("schema must not expose the removed durationIndicators setting");
63
- }
64
- if (schema.properties?.spinner) {
65
- throw new Error("schema must not expose the removed spinner setting");
66
- }
67
- if (!schema.properties?.auto_compact?.properties?.edit) {
68
- throw new Error("schema must describe per-tool auto_compact policies");
69
- }
70
- if (schema.properties?.previewLines?.default !== 10) {
71
- throw new Error("schema must expose the bounded previewLines setting");
72
- }
73
-
74
- const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
75
- const releaseNotes = JSON.parse(await readFile(new URL("../release-notes.json", import.meta.url), "utf8"));
76
- // Every published version needs an entry. An empty list is a quiet release that shows no notice.
77
- if (!Array.isArray(releaseNotes[manifest.version]) ||
78
- releaseNotes[manifest.version].some((note) => typeof note !== "string" || !note.trim())) {
79
- throw new Error(`release-notes.json must have an entry for v${manifest.version} before publishing; use [] for no notice`);
80
- }
81
-
82
- console.log("Resource checks passed");