@bismawy/pi-arnative 0.3.2 → 0.3.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.4] - 2026-10-04
4
+
5
+ ### Added
6
+ - `scripts/release-check.mjs`, a read-only release preflight with one stage per step of the release (`pre-tag`, `pre-publish`, `post-publish`): it verifies a clean tree on `main` in sync with `origin/main`, no open pull requests, the `CHANGELOG.md` entry, whether the tag exists **and is pushed**, and whether the registry already serves the version. It never writes, tags, pushes or publishes.
7
+ - `npm run release:check` (the `pre-tag` stage) and a `prepublishOnly` hook wired to the `pre-publish` stage. A missing or unpushed tag now aborts `npm publish` with `npm error code 1`, so a forgotten `git tag` can no longer ship a release.
8
+
9
+ ### Changed
10
+ - README documents the release stages and their exit codes (`0` pass, `1` check failed, `2` bad usage).
11
+
12
+ ## [0.3.3] - 2026-10-04
13
+
14
+ ### Changed
15
+ - `codemode` now renders in the arnative box like every other tool call: side borders, green background (`toolSuccessBg`), and the script's first line prefixed with the `󱞩` summary glyph. While the tool runs the box shows the shared spinner; the `\uf489` glyph appears with the result (`extensions/tools.ts`).
16
+ - Collapsed `codemode` is now two rows, the same footprint as `bash`/`grep`/`read`: title + the script's first line. Nested calls and output wait behind the standard `[ctrl+o to expand]` hint. A failing run still shows its output.
17
+ - pi's `Script completed / Wall time / Output:` header no longer shows in the box.
18
+ - Shared duration wording (`2ms` under a second, `1.4s` above) and one error glyph (`x`) across every tool; a failing `codemode` run shows its whole output and never a `[ctrl+o to expand]` hint.
19
+ - The file tools (`bash`, `read`, `write`, `grep`, `find`, `edit`) now show `[ctrl+o to expand]` when their collapsed summary hides more than it shows — a two-line `extra +1 / -1` box no longer looks complete while its diff sits behind it. Expanded and genuinely single-line results get no hint (`extensions/tools.ts`).
20
+ - The `chrome_devtools_*` tools (from `@narumitw/pi-chrome-devtools`) now render in the same box instead of their bare "Chrome DevTools: …" lines. Their own `renderCall`/`renderResult` are bypassed: the call box uses the Chrome glyph and the result box the usual summary + `[ctrl+o to expand]` (`extensions/tools.ts`).
21
+
3
22
  ## [0.3.2] - 2026-10-04
4
23
 
5
24
  ### Changed
package/README.md CHANGED
@@ -14,7 +14,7 @@ pi-arnative replaces pi’s default terminal chrome with a unified, cohesive vis
14
14
 
15
15
  - Warm Themes: Cohesive, contrast-checked color palettes generated without drift.
16
16
  - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, and Shortcut.
17
- - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call.
17
+ - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call. Built-in `codemode` is boxed and collapsed to two rows (title + first script line) like `bash`/`grep`/`read`; nested calls and output sit behind `[ctrl+o to expand]` (shown whenever the collapsed summary hides more, including `edit` diffs); a failed run always shows its full output. The `chrome_devtools_*` tools box too, replacing their bare "Chrome DevTools: …" lines.
18
18
  - Enhanced Footer: fixed 3-row grid — directory/git line, then extension status paired with token metrics.
19
19
  - Transcript Clock: Timestamps for messages with clean bubble backgrounds.
20
20
  - Usage Dashboard: /usage command to track tokens and session costs.
@@ -96,6 +96,15 @@ npm test # Run self-checks & theme generator check
96
96
  npm run themes # Rebuild all theme variants
97
97
  ```
98
98
 
99
+ **Releasing:** `scripts/release-check.mjs` is a read-only preflight (never writes, tags, pushes or publishes) with one stage per step of the release:
100
+
101
+ | Stage | Run before | Checks |
102
+ | :--- | :--- | :--- |
103
+ | `pre-tag` | `git tag` | clean tree on `main`, in sync, no open PRs, `CHANGELOG.md` entry, tag free, version unpublished |
104
+ | `pre-publish` | `npm publish` | the above, plus the tag exists **and is pushed** |
105
+ | `post-publish` | done | the above, plus the version is live on the registry |
106
+
107
+ `pre-publish` runs automatically through npm's `prepublishOnly`, so a missing or unpushed tag aborts the publish (`npm error code 1`) instead of shipping a version whose tag was forgotten. `npm run release:check` is the `pre-tag` shorthand. Exit codes: `0` pass, `1` a check failed, `2` bad usage.
99
108
  </details>
100
109
 
101
110
  ## License
@@ -27,6 +27,8 @@ import {
27
27
  createReadTool,
28
28
  createWriteTool,
29
29
  getMarkdownTheme,
30
+ highlightCode,
31
+ initTheme,
30
32
  ToolExecutionComponent,
31
33
  } from "@earendil-works/pi-coding-agent";
32
34
  import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
@@ -64,6 +66,12 @@ export function spinIcon(): string {
64
66
  return FRAMES[Math.floor(Date.now() / 500) % FRAMES.length];
65
67
  }
66
68
 
69
+ /** Tool duration: `2ms` under a second, `1.4s` above — the wording pi itself uses. */
70
+ function fmtMs(ms: number | undefined): string {
71
+ if (ms === undefined || !Number.isFinite(ms)) return "";
72
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
73
+ }
74
+
67
75
  export class Lines {
68
76
  private get: (width: number) => string[];
69
77
  constructor(get: (width: number) => string[]) {
@@ -258,7 +266,26 @@ const BOXED_TOOLS = new Map([
258
266
  ["get_search_content", "get_content"],
259
267
  ["todo", "todo"],
260
268
  ]);
261
- const displayName = (name: string): string => BOXED_TOOLS.get(name) ?? name;
269
+
270
+ // @narumitw/pi-chrome-devtools ships bare-text renderers ("Chrome DevTools: navigate",
271
+ // collapsed output is empty) so they are ignored and these tools take our default box
272
+ // path. CDP_ICON marks them in the call box (no hook for the generic tool icon).
273
+ const CDP_ICON = "\uf268";
274
+ const CDP_TOOLS: [string, string][] = [
275
+ ["chrome_devtools_load", "load"],
276
+ ["chrome_devtools_list_pages", "list pages"],
277
+ ["chrome_devtools_select_page", "select page"],
278
+ ["chrome_devtools_navigate", "navigate"],
279
+ ["chrome_devtools_evaluate", "evaluate"],
280
+ ["chrome_devtools_screenshot", "screenshot"],
281
+ ["chrome_devtools_webmcp_list_tools", "list WebMCP tools"],
282
+ ["chrome_devtools_webmcp_call_tool", "call WebMCP tool"],
283
+ ];
284
+ const CDP_NAME = new Map(CDP_TOOLS);
285
+ for (const [n] of CDP_TOOLS) BOXED_TOOLS.set(n, n);
286
+
287
+ const displayName = (name: string): string =>
288
+ CDP_NAME.has(name) ? `Chrome DevTools: ${CDP_NAME.get(name)}` : (BOXED_TOOLS.get(name) ?? name);
262
289
  for (const n of BOXED_TOOLS.keys()) OWN_BOX.add(n);
263
290
 
264
291
  const MCP_TOOL = (name: string): boolean => name === "mcp" || name === "mcpScript" || name.startsWith("mcp__");
@@ -347,11 +374,12 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
347
374
  const name: string = this.toolName;
348
375
  const mine = usesTheirCallRenderer(this);
349
376
  const own = origCall.call(this);
350
- if (own && !mine) return own;
377
+ // Chrome DevTools' own renderCall is a bare line; ours replaces it (icon + name).
378
+ if (own && !mine && !CDP_NAME.has(name)) return own;
351
379
  return (args: any, th: Theme, ctx: TCtx) =>
352
380
  new Lines((width) => {
353
381
  if ((ctx.state as Record<string, unknown> | undefined)?.hasResult) return [];
354
- const icon = th.fg("warning", spinIcon());
382
+ const icon = CDP_NAME.has(name) ? th.fg("accent", CDP_ICON) : th.fg("warning", spinIcon());
355
383
  const theirs = own ? componentLines(safeCall(() => own.call(this, args, th, ctx)), width - 6) : [];
356
384
  if (name === "todo" && theirs.length)
357
385
  return box(th, width, formatTodoRows(theirs, th), "toolPendingBg");
@@ -370,6 +398,9 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
370
398
  // MCP: their result renderer is deliberately skipped (unbounded error dump).
371
399
  if (own && !usesTheirCallRenderer(this)) return own;
372
400
  const boxed = BOXED_TOOLS.has(name);
401
+ // Chrome DevTools are in BOXED_TOOLS for the title/icon only: their renderResult
402
+ // (raw text, empty when collapsed) would drop our title row, so it is ignored.
403
+ const cdp = CDP_NAME.has(name);
373
404
  return (result: TResult, opts: { expanded: boolean; isPartial?: boolean }, th: Theme, ctx: TCtx) => {
374
405
  if (opts.isPartial) return EMPTY;
375
406
  ((ctx.state ??= {}) as Record<string, unknown>).hasResult = true;
@@ -380,7 +411,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
380
411
  // Errors use our own path: pi-web-access' own error box is a box inside a
381
412
  // box. Their renderer content is only used on success.
382
413
  const theirs =
383
- (boxed || fff) && !isErr
414
+ (boxed || fff) && !isErr && !cdp
384
415
  ? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
385
416
  : null;
386
417
  return new Lines((width) => {
@@ -399,7 +430,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
399
430
  const bgType = isErr ? "toolErrorBg" : "toolSuccessBg";
400
431
  return box(th, width, formatTodoRows(combined, th), bgType);
401
432
  }
402
- if (boxed2.length) return box(th, width, boxedRows(icon, boxed2));
433
+ if (boxed2.length && !cdp) return box(th, width, boxedRows(icon, boxed2));
403
434
  const rows = titleRow(
404
435
  th,
405
436
  icon,
@@ -441,6 +472,78 @@ const formatTodoRows = (lines: string[], th: Theme): string[] => {
441
472
  return out;
442
473
  };
443
474
 
475
+ // codemode: the script is highlighted, the calls and output come from pi's own details.
476
+ function codeLines(code: string): string[] {
477
+ const js = code.replace(/\r/g, "").trimEnd().replace(/\t/g, " ");
478
+ if (!js) return [];
479
+ try {
480
+ return highlightCode(js, "javascript");
481
+ } catch {
482
+ return js.split("\n");
483
+ }
484
+ }
485
+
486
+ /** `✓ todo {"action":"list"} 2ms` — status icon, tool name, muted args, dim duration. */
487
+ function formatCodemodeCall(call: any, th: Theme): string {
488
+ const [color, glyph] = STATUS_ICON[String(call?.status ?? "ok")] ?? ["error", "x"];
489
+ const dur = fmtMs(Number(call?.durationMs));
490
+ let line = ` ${th.fg(color, glyph)} ${th.fg("accent", String(call?.name ?? ""))}`;
491
+ if (call?.args) line += ` ${th.fg("muted", String(call.args))}`;
492
+ if (dur) line += ` ${th.fg("dim", dur)}`;
493
+ return line;
494
+ }
495
+
496
+ // codemode call status -> [theme colour, glyph], same mapping pi's own renderer uses.
497
+ const STATUS_ICON: Record<string, [string, string]> = {
498
+ running: ["warning", "…"],
499
+ ok: ["success", "✓"],
500
+ cancelled: ["muted", "⊘"],
501
+ error: ["error", "x"],
502
+ };
503
+
504
+ /** Drop pi's "Script completed\nWall time …\nOutput:" header (a rejected input has none). */
505
+ function codemodeOutput(content: unknown): string {
506
+ const blocks = (Array.isArray(content) ? content : []).filter((c: any) => c?.type === "text") as Array<{ text?: string }>;
507
+ const header = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n/;
508
+ return blocks
509
+ .map((c, i) => (i === 0 ? String(c.text ?? "").replace(header, "") : String(c.text ?? "")))
510
+ .join("\n")
511
+ .trim();
512
+ }
513
+
514
+ /** Script lines; collapsed keeps only the first, like the other tools' one-line call. */
515
+ function codemodeScript(code: string, expanded: boolean): { lines: string[]; hidden: number } {
516
+ const lines = codeLines(code);
517
+ if (expanded || lines.length <= 1) return { lines, hidden: 0 };
518
+ return { lines: lines.slice(0, 1), hidden: lines.length - 1 };
519
+ }
520
+
521
+ /** Every box row: title, script, nested calls, output. Collapsed is the title plus the
522
+ * script's first line, with the rest behind the expand hint. Expanding — or failing —
523
+ * shows everything: a truncated error would read as a short, successful run. */
524
+ function codemodeBoxRows(
525
+ args: any,
526
+ result: { content?: unknown; details?: any } | undefined,
527
+ th: Theme,
528
+ expanded: boolean,
529
+ isErr: boolean,
530
+ icon: string,
531
+ ): string[] {
532
+ const rows = [`${icon} ${th.fg("accent", "codemode")}`];
533
+ const script = codemodeScript(typeof args?.code === "string" ? args.code : "", expanded);
534
+ script.lines.forEach((line, i) => rows.push(i === 0 ? `${resHead(th)} ${line}` : ` ${line}`));
535
+ const calls = (result?.details?.calls as any[] | undefined) ?? [];
536
+ const output = codemodeOutput(result?.content);
537
+ const outLines = output ? output.split("\n") : [];
538
+ const shown = expanded || isErr;
539
+ if (shown) {
540
+ if (calls.length || outLines.length) rows.push("");
541
+ rows.push(...calls.map((c) => formatCodemodeCall(c, th)), ...outLines.map((l) => th.fg("toolOutput", ` ${l}`)));
542
+ }
543
+ const hidden = script.hidden + (shown ? 0 : calls.length + outLines.length);
544
+ if (!expanded && hidden > 0) rows[rows.length - 1] += ` ${expandHint(th)}`;
545
+ return rows;
546
+ }
444
547
 
445
548
  // One render path for all tools (no per-tool duplication).
446
549
  // renderCall = running box (󰔟); renderResult = final box (✓/x + right-aligned
@@ -499,8 +602,7 @@ function minimal(
499
602
  ((context.state ??= {}) as Record<string, unknown>).hasResult = true;
500
603
  const text = textOf(result);
501
604
  const isErr = Boolean(context.isError || result.isError || text.startsWith("Error"));
502
- const ms = context.toolCallId ? TIMINGS.get(context.toolCallId) : undefined;
503
- const dur = ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : "";
605
+ const dur = fmtMs(context.toolCallId ? TIMINGS.get(context.toolCallId) : undefined);
504
606
  const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
505
607
  return new Lines((width) => {
506
608
  const soft = accentSoftOf(theme);
@@ -514,9 +616,15 @@ function minimal(
514
616
  dur,
515
617
  expanded,
516
618
  );
619
+ // Anything the collapsed summary hides gets the standard hint; without it a
620
+ // two-line box (edit: `+1 / -1`) looks complete while the diff sits behind it.
621
+ const hasDetail = countLines(text) > 1 || Boolean((result.details as { diff?: string } | undefined)?.diff);
517
622
  const resLine = res(result, theme, soft, isErr, expanded);
518
- if (resLine) rows.push(resLine);
519
623
  if (expanded) rows.push(...full(result, theme));
624
+ else if (resLine) {
625
+ const hint = hasDetail ? ` ${expandHint(theme)}` : "";
626
+ rows.push(hint ? `${truncateToWidth(resLine, Math.max(8, inner - visibleWidth(hint)), "…")}${hint}` : resLine);
627
+ }
520
628
  return box(theme, width, rows);
521
629
  });
522
630
  },
@@ -524,6 +632,12 @@ function minimal(
524
632
  }
525
633
 
526
634
  function arnativeTools(pi: ExtensionAPI) {
635
+ // codemode keeps pi's own data (script + nested calls + output) wrapped in the arnative
636
+ // box, bg like every other tool. A resolver runs first and returns our renderers for
637
+ // that name; `next()` would give pi's unboxed ones.
638
+ OWN_BOX.add("codemode");
639
+ pi.registerToolRenderer((toolName, next) => (toolName === "codemode" ? codemodeRenderers : next()));
640
+
527
641
  // Re-render source for the spinner + ticker cleanup when the session closes
528
642
  pi.on("session_start", async (_event, ctx) => {
529
643
  requestRenderFn = () => {
@@ -626,6 +740,33 @@ function arnativeTools(pi: ExtensionAPI) {
626
740
  );
627
741
  }
628
742
 
743
+ // codemode: pi's renderer wrapped in our box. renderShell "self" keeps the
744
+ // execution defaults; renderCall/renderResult draw the box. state.hasResult
745
+ // guarantees one box per call, also after reload/restore.
746
+ const codemodeRenderers = {
747
+ renderShell: "self" as const,
748
+ renderCall(args: any, th: Theme, ctx: TCtx) {
749
+ return new Lines((width) => {
750
+ if (ctx.state?.hasResult) return [];
751
+ const isErr = Boolean(ctx.isError);
752
+ const bg = isErr ? "toolErrorBg" : "toolPendingBg";
753
+ // Same spinner as every other box while the tool runs; the codemode glyph
754
+ // only appears once the result is in (renderResult).
755
+ const icon = th.fg("warning", spinIcon());
756
+ return box(th, width, codemodeBoxRows(args, undefined, th, Boolean(ctx.expanded), isErr, icon), bg);
757
+ });
758
+ },
759
+ renderResult(result: TResult & { details?: any }, opts: { expanded: boolean; isPartial?: boolean }, th: Theme, ctx: TCtx) {
760
+ if (opts.isPartial) return EMPTY;
761
+ ((ctx.state ??= {}) as Record<string, unknown>).hasResult = true;
762
+ const isErr = Boolean(ctx.isError || result.isError);
763
+ const icon = isErr ? th.fg("error", "x") : th.fg("success", "\uf489");
764
+ return new Lines((width) =>
765
+ box(th, width, codemodeBoxRows(ctx.args, result, th, Boolean(opts.expanded), isErr, icon), isErr ? "toolErrorBg" : "toolSuccessBg"),
766
+ );
767
+ },
768
+ };
769
+
629
770
  export default arnativeTools;
630
771
 
631
772
  // Self-check: `node extensions/tools.ts`.
@@ -914,6 +1055,7 @@ if (isMain(import.meta.url)) {
914
1055
  // The default export registers the tools (filling OWN_BOX); a pi stub suffices.
915
1056
  arnativeTools({
916
1057
  registerTool: () => {},
1058
+ registerToolRenderer: () => {},
917
1059
  on: () => {},
918
1060
  registerShortcut: () => {},
919
1061
  registerMessageRenderer: () => {},
@@ -1011,5 +1153,105 @@ if (isMain(import.meta.url)) {
1011
1153
  fullText({ content: [{ type: "text", text: "a\nb" }] }, th, 0).join("|") === th.fg("dim", "a") + "|" + th.fg("dim", "b"),
1012
1154
  "no width (self-check) still dumps dim",
1013
1155
  );
1156
+
1157
+ // codemode: one box per call, driven through the real component (what pi uses).
1158
+ const cmArgs = { code: 'const l = await tools.todo({ action: "list" });\nconsole.log(l);' };
1159
+ const cmRes = {
1160
+ content: [
1161
+ { type: "text", text: "Script completed\nWall time 0.01 seconds\nOutput:\n" },
1162
+ { type: "text", text: "alpha: ok" },
1163
+ ],
1164
+ details: { calls: [{ status: "ok", name: "todo", args: '{"action":"list"}', durationMs: 2 }] },
1165
+ };
1166
+ initTheme(); // ToolExecutionComponent resolves a real theme, not the fg-passthrough stub
1167
+ const cmRender = (args: any, result: any, expanded = false) => {
1168
+ const c: any = new ToolExecutionComponent("codemode", "1", args, {}, codemodeRenderers as any, { requestRender() {} }, cwd);
1169
+ if (result) c.updateResult(result, false);
1170
+ c.setExpanded(expanded);
1171
+ // pi prefixes the box with a spacer blank line; drop blanks so the rows are predictable.
1172
+ return (c.render(100) as string[]).map(stripAnsi).filter((l) => l !== "");
1173
+ };
1174
+ const cmCollapsed = cmRender(cmArgs, cmRes);
1175
+ assert(cmCollapsed.length === 4 && cmCollapsed[0]!.startsWith("╭") && cmCollapsed[3]!.startsWith("╰"), "codemode: collapsed box is title + one script line inside the border");
1176
+ assert(cmCollapsed[2]!.includes("const l = await tools.todo") && cmCollapsed[2]!.includes("[ctrl+o to expand]"), "codemode: collapsed shows the first script line + hint");
1177
+ const cmExpanded = cmRender(cmArgs, cmRes, true);
1178
+ assert(cmExpanded.some((l) => l.includes("✓ todo") && l.includes("2ms")) && cmExpanded.some((l) => l.includes("alpha: ok")), "codemode: expanded shows the nested calls and the output");
1179
+ // A failing run is never truncated: the whole output is shown without a hint.
1180
+ const longErr = { content: [{ type: "text", text: "Script failed\nWall time 0.1 seconds\nOutput:\n" + Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n") }], isError: true };
1181
+ const cmErr = cmRender({ code: "boom()" }, longErr);
1182
+ assert(cmErr.some((l) => l.includes("line 12")) && !cmErr.some((l) => l.includes("to expand")), "codemode: a failed run shows all its output, no hint");
1183
+ // Running: the shared spinner, not the codemode glyph, and no hint while nothing is hidden.
1184
+ const cmRunning = cmRender({ code: "console.log(1);" }, undefined);
1185
+ assert(FRAMES.some((f) => cmRunning.join("\n").includes(f)) && !cmRunning.join("\n").includes("\uf489"), "codemode: running box uses the shared spinner, not the codemode glyph");
1186
+ assert(!cmRunning.some((l) => l.includes("to expand")), "codemode: nothing hidden on a single-line script");
1187
+ assert([...cmCollapsed, ...cmExpanded, ...cmErr].every((l) => visibleWidth(l) === 100), "codemode: box fills the given width");
1188
+ assert(fmtMs(2) === "2ms" && fmtMs(1400) === "1.4s" && fmtMs(undefined) === "", "duration wording: ms under a second, s above");
1189
+ assert(codemodeOutput(cmRes.content) === "alpha: ok", "codemode: Script completed header dropped (own block)");
1190
+ assert(codemodeOutput([{ type: "text", text: "Script completed\nWall time 0.01 seconds\nOutput:\n2" }]) === "2", "codemode: header dropped when joined with the output");
1191
+ // minimal(): a collapsed summary hides more than it shows -> hint; expanded shows all.
1192
+ {
1193
+ const reg2: Record<string, any> = {};
1194
+ const fakePi: any = { registerTool: (t: any) => { reg2[t.name] = t; }, registerToolRenderer: () => {}, on: () => {} };
1195
+ arnativeTools(fakePi);
1196
+ const minRender = (name: string, args: any, result: any, expanded = false) => {
1197
+ const c: any = new ToolExecutionComponent(name, "1", args, {}, reg2[name], { requestRender() {} }, cwd);
1198
+ c.updateResult(result, false);
1199
+ c.setExpanded(expanded);
1200
+ return (c.render(74) as string[]).map(stripAnsi).filter((l) => l !== "");
1201
+ };
1202
+ const diffRes = { content: [{ type: "text", text: "ok" }], details: { diff: "@@\n-old\n+new" } };
1203
+ const ed = minRender("edit", { path: "README.md" }, diffRes);
1204
+ assert(ed.some((l) => l.includes("+1 / -1") && l.includes("to expand")), "minimal(): collapsed edit summary carries the expand hint");
1205
+ assert(!minRender("edit", { path: "README.md" }, diffRes, true).some((l) => l.includes("to expand")), "minimal(): expanded edit drops the hint");
1206
+ assert(
1207
+ minRender("bash", { command: "true" }, { content: [{ type: "text", text: "done" }], details: {} }).every((l) => !l.includes("to expand")),
1208
+ "minimal(): a single-line output has nothing hidden, so no hint",
1209
+ );
1210
+ assert(
1211
+ minRender("edit", { path: "README.md" }, diffRes).every((l) => visibleWidth(l) === 74),
1212
+ "minimal(): hint row still fills the box width",
1213
+ );
1214
+ }
1215
+
1216
+ // Chrome DevTools: their bare renderers are replaced by our box (title + summary).
1217
+ const textComp = (text: string, color?: string) => ({
1218
+ render: (w: number) => (text ? text.split("\n").map((l) => (color ? th.fg(color, l) : l).slice(0, Math.max(0, w))) : []),
1219
+ invalidate() {},
1220
+ });
1221
+ const cdpRes = {
1222
+ toolName: "chrome_devtools_navigate",
1223
+ toolDefinition: {
1224
+ renderCall: () => textComp("Chrome DevTools: navigate"),
1225
+ renderResult: () => textComp(""),
1226
+ },
1227
+ };
1228
+ // The call renderer is not theirs (their bare line would swallow our icon/title).
1229
+ assert(
1230
+ ToolExecutionComponent.prototype.getCallRenderer.call(cdpRes) !== (cdpRes.toolDefinition.renderCall as any)(),
1231
+ "chrome-devtools: their bare call renderer is replaced by our box",
1232
+ );
1233
+ const cdpResult = { content: [{ type: "text", text: 'navigated\n{\n "pageId": "p1",\n "url": "https://example.com"\n}' }], details: {} };
1234
+ const cdpComp = (result: any, expanded = false) => {
1235
+ const c: any = new ToolExecutionComponent("chrome_devtools_navigate", "1", { url: "https://example.com" }, {}, cdpRes.toolDefinition as any, { requestRender() {} }, cwd);
1236
+ if (result) c.updateResult(result, false);
1237
+ c.setExpanded(expanded);
1238
+ return (c.render(74) as string[]).map(stripAnsi).filter((l) => l !== "");
1239
+ };
1240
+ const cdpCollapsed = cdpComp(cdpResult);
1241
+ assert(
1242
+ cdpCollapsed[0]!.startsWith("╭") && cdpCollapsed[cdpCollapsed.length - 1]!.startsWith("╰") && cdpCollapsed.some((l) => l.includes("navigate")),
1243
+ "chrome-devtools: result renders in our box with the tool name",
1244
+ );
1245
+ const cdpCall = ToolExecutionComponent.prototype.getCallRenderer.call(cdpRes) as any;
1246
+ const cdpCallOut: string[] = cdpCall({ url: "https://example.com" }, th, { args: {}, state: {} }).render(74);
1247
+ assert(
1248
+ cdpCallOut[0]!.startsWith("╭") && cdpCallOut.some((l) => stripAnsi(l).includes("Chrome DevTools: navigate")),
1249
+ "chrome-devtools: call box keeps the tool title and the CDP glyph",
1250
+ );
1251
+ assert(stripAnsi(cdpCallOut[1]!).includes(CDP_ICON), "chrome-devtools: call box uses the Chrome glyph, not the spinner");
1252
+ const cdpExpanded = cdpComp(cdpResult, true);
1253
+ assert(cdpExpanded.some((l) => l.includes('"pageId": "p1"')), "chrome-devtools: expanded shows the full tool output");
1254
+ assert(cdpCollapsed.some((l) => l.includes("navigated") && l.includes("to expand")), "chrome-devtools: collapsed shows the first line + hint");
1255
+ assert([...cdpCollapsed, ...cdpExpanded].every((l) => visibleWidth(l) === 74), "chrome-devtools: box fills the given width");
1014
1256
  console.log("OK");
1015
1257
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Refined aesthetics. Cohesive tools. Built for Pi. Warm themes, rounded tool boxes, tabbed header, compact footer and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -42,6 +42,8 @@
42
42
  "scripts": {
43
43
  "dev": "pi --extension ./extensions/tools.ts --extension ./extensions/section-headers.ts --extension ./extensions/footer.ts --extension ./extensions/timestamps.ts --extension ./extensions/ui-render-tweaks.ts --extension ./extensions/usage.ts --extension ./extensions/arnative.ts",
44
44
  "test": "node lib/box.ts && node lib/ansi.ts && node lib/shortcuts.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/section-headers.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts && node extensions/arnative.ts",
45
- "themes": "node themes/gen-themes.mjs"
45
+ "themes": "node themes/gen-themes.mjs",
46
+ "release:check": "node scripts/release-check.mjs pre-tag",
47
+ "prepublishOnly": "node scripts/release-check.mjs pre-publish"
46
48
  }
47
49
  }