@owlburtoe/pi-cc-tools 1.0.1 → 1.1.1

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
@@ -5,6 +5,7 @@ Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots,
5
5
  ## Features
6
6
 
7
7
  - **Compact built-in tool rendering** for `read`, `bash`, `grep`, `find`, `ls`, `edit`, and `write`
8
+ - **Semantic bash display** that renders common read-only shell one-liners like `nl -ba file | sed -n '1,200p'` as Claude Code-style `Read file (lines 1-200)` rows
8
9
  - **Claude-style OpenAI tool rendering** for `apply_patch` plus common Pi/OpenAI-style tools like `webfetch`, `web_search`, `fetch_content`, task tools, and context tools
9
10
  - **`apply_patch` diff previews** that render parsed file patches in the call phase, similar to `edit`/`write`
10
11
  - **Adaptive edit/write diffs** with split or unified layouts, syntax highlighting, and inline word-level emphasis
@@ -32,6 +33,8 @@ Set in `.pi/settings.json` or `~/.pi/settings.json`:
32
33
  "previewLines": 8,
33
34
  "bashOutputMode": "opencode",
34
35
  "bashCollapsedLines": 10,
36
+ "bashStackConsecutive": true,
37
+ "bashSemanticDisplay": true,
35
38
  "diffCollapsedLines": 24,
36
39
  "themeAdaptive": true,
37
40
  "diffTheme": "github-dark",
@@ -135,6 +138,21 @@ Color selections are persisted as `spinnerColor` / `spinnerStatusColor` in `~/.p
135
138
  | `mcpOutputMode` | `hidden`, `summary`, `preview` | `preview` |
136
139
  | `bashOutputMode` | `opencode`, `summary`, `preview` | `opencode` |
137
140
 
141
+ `bashOutputMode` behavior:
142
+
143
+ | Value | Behavior |
144
+ |-------|----------|
145
+ | `opencode` | Compact status while collapsed, with `Ctrl+O` hint for output preview on raw shell commands. Semantic read-only bash rows omit the repeated hint to stay closer to Claude Code. |
146
+ | `summary` | Status only; no output preview or expansion hint |
147
+ | `preview` | Show a small output preview even while collapsed |
148
+
149
+ ### Boolean settings
150
+
151
+ | Setting | Default | Description |
152
+ |---------|---------|-------------|
153
+ | `bashStackConsecutive` | `true` | Remove the extra synthetic spacer between adjacent bash tool rows so command bursts render as a tight stack |
154
+ | `bashSemanticDisplay` | `true` | Render common read-only shell file-inspection commands as semantic `Read` rows instead of raw `Bash` rows |
155
+
138
156
  ### Numeric settings
139
157
 
140
158
  | Setting | Default | Description |
@@ -7,6 +7,8 @@
7
7
  "expandedPreviewMaxLines": 4000,
8
8
  "bashOutputMode": "opencode",
9
9
  "bashCollapsedLines": 10,
10
+ "bashStackConsecutive": true,
11
+ "bashSemanticDisplay": true,
10
12
  "diffCollapsedLines": 24,
11
13
  "showTruncationHints": false,
12
14
  "themeAdaptive": true,
@@ -84,6 +84,10 @@ interface SettingsFile {
84
84
  expandedPreviewMaxLines?: number;
85
85
  bashOutputMode?: "opencode" | "summary" | "preview";
86
86
  bashCollapsedLines?: number;
87
+ /** When true (default), consecutive bash tool rows render without the extra inter-tool spacer. */
88
+ bashStackConsecutive?: boolean;
89
+ /** When true (default), read-only shell file-inspection one-liners render as semantic Read rows instead of raw Bash rows. */
90
+ bashSemanticDisplay?: boolean;
87
91
  showTruncationHints?: boolean;
88
92
  diffCollapsedLines?: number;
89
93
  diffTheme?: string;
@@ -329,6 +333,44 @@ function isToolExecutionLike(value: unknown): value is { toolName: string; toolC
329
333
  return typeof candidate.toolName === "string" && typeof candidate.toolCallId === "string";
330
334
  }
331
335
 
336
+ function isBashToolExecution(value: unknown): boolean {
337
+ return isToolExecutionLike(value) && (value as any).toolName === "bash";
338
+ }
339
+
340
+ function shouldStackConsecutiveBash(): boolean {
341
+ return readSettings().bashStackConsecutive !== false;
342
+ }
343
+
344
+ function hasConsecutiveBashToolChildren(children: unknown[]): boolean {
345
+ let previousWasBash = false;
346
+ for (const child of children) {
347
+ const currentIsBash = isBashToolExecution(child);
348
+ if (currentIsBash && previousWasBash) return true;
349
+ previousWasBash = currentIsBash;
350
+ }
351
+ return false;
352
+ }
353
+
354
+ function dropLeadingSpacerLine(lines: string[]): string[] {
355
+ return lines.length > 0 && isBlankLine(lines[0]) ? lines.slice(1) : lines;
356
+ }
357
+
358
+ function renderWithStackedConsecutiveBash(container: any, width: number): string[] | null {
359
+ if (!shouldStackConsecutiveBash()) return null;
360
+ const children = Array.isArray(container?.children) ? container.children : null;
361
+ if (!children || !hasConsecutiveBashToolChildren(children)) return null;
362
+
363
+ const rendered: string[] = [];
364
+ let previousWasBash = false;
365
+ for (const child of children) {
366
+ const currentIsBash = isBashToolExecution(child);
367
+ const childLines = typeof child?.render === "function" ? child.render(width) : [];
368
+ rendered.push(...(currentIsBash && previousWasBash ? dropLeadingSpacerLine(childLines) : childLines));
369
+ previousWasBash = currentIsBash;
370
+ }
371
+ return rendered;
372
+ }
373
+
332
374
  function isTerminalImageLine(line: string): boolean {
333
375
  return line.includes(KITTY_IMAGE_PREFIX) || line.includes(ITERM2_IMAGE_PREFIX);
334
376
  }
@@ -359,6 +401,11 @@ function patchGlobalToolBorders(): void {
359
401
 
360
402
  const originalRender = proto.render;
361
403
  proto.render = function patchedContainerRender(width: number): string[] {
404
+ if (!isToolExecutionLike(this)) {
405
+ const stacked = renderWithStackedConsecutiveBash(this, width);
406
+ if (stacked) return stacked;
407
+ }
408
+
362
409
  if (isToolExecutionLike(this)) {
363
410
  const cached = (this as any)[TOOL_RENDER_CACHE];
364
411
  if (cached?.width === width && cached?.mode === toolBackgroundMode) {
@@ -1324,6 +1371,195 @@ function bashCollapsedLimit(): number {
1324
1371
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10;
1325
1372
  }
1326
1373
 
1374
+ function bashOutputMode(): "opencode" | "summary" | "preview" {
1375
+ return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
1376
+ }
1377
+
1378
+ function bashSemanticDisplayEnabled(): boolean {
1379
+ return readSettings().bashSemanticDisplay !== false;
1380
+ }
1381
+
1382
+ export interface BashDisplayInfo {
1383
+ kind: "read";
1384
+ label: "Read";
1385
+ path: string;
1386
+ rangeLabel?: string;
1387
+ suppressCollapsedHint: boolean;
1388
+ }
1389
+
1390
+ function tokenizeShellCommand(command: string): string[] | null {
1391
+ const tokens: string[] = [];
1392
+ let current = "";
1393
+ let quote: "'" | '"' | null = null;
1394
+ let escaping = false;
1395
+
1396
+ const push = () => {
1397
+ if (current.length > 0) {
1398
+ tokens.push(current);
1399
+ current = "";
1400
+ }
1401
+ };
1402
+
1403
+ for (let i = 0; i < command.length; i++) {
1404
+ const char = command[i];
1405
+ if (escaping) {
1406
+ current += char;
1407
+ escaping = false;
1408
+ continue;
1409
+ }
1410
+ if (quote) {
1411
+ if (char === quote) {
1412
+ quote = null;
1413
+ continue;
1414
+ }
1415
+ if (quote === '"' && char === "\\") {
1416
+ escaping = true;
1417
+ continue;
1418
+ }
1419
+ current += char;
1420
+ continue;
1421
+ }
1422
+ if (char === "\\") {
1423
+ escaping = true;
1424
+ continue;
1425
+ }
1426
+ if (char === "'" || char === '"') {
1427
+ quote = char;
1428
+ continue;
1429
+ }
1430
+ if (/\s/.test(char)) {
1431
+ push();
1432
+ continue;
1433
+ }
1434
+ if (char === "|" || char === ";") {
1435
+ push();
1436
+ if (char === "|" && command[i + 1] === "|") {
1437
+ tokens.push("||");
1438
+ i++;
1439
+ } else {
1440
+ tokens.push(char);
1441
+ }
1442
+ continue;
1443
+ }
1444
+ if (char === "&" && command[i + 1] === "&") {
1445
+ push();
1446
+ tokens.push("&&");
1447
+ i++;
1448
+ continue;
1449
+ }
1450
+ current += char;
1451
+ }
1452
+ if (escaping) current += "\\";
1453
+ if (quote) return null;
1454
+ push();
1455
+ return tokens;
1456
+ }
1457
+
1458
+ function isControlToken(token: string): boolean {
1459
+ return token === "&&" || token === "||" || token === ";";
1460
+ }
1461
+
1462
+ function isOptionToken(token: string): boolean {
1463
+ return token.startsWith("-") && token !== "-" && token !== "--";
1464
+ }
1465
+
1466
+ function singlePathArg(tokens: readonly string[]): string | null {
1467
+ const paths = tokens.filter((token) => token !== "--" && token !== "-" && !isOptionToken(token));
1468
+ return paths.length === 1 ? paths[0] : null;
1469
+ }
1470
+
1471
+ function formatSedRangeLabel(script: string | undefined): string | undefined {
1472
+ if (!script) return undefined;
1473
+ const match = script.match(/^(\d+|\$)(?:,(\d+|\$))?p$/);
1474
+ if (!match) return undefined;
1475
+ const start = match[1];
1476
+ const end = match[2];
1477
+ if (!end || end === start) return `line ${start}`;
1478
+ return `lines ${start}-${end}`;
1479
+ }
1480
+
1481
+ function parseSedPrintRange(tokens: readonly string[]): string | undefined {
1482
+ for (let i = 0; i < tokens.length; i++) {
1483
+ if (tokens[i] === "-n" && tokens[i + 1]) {
1484
+ const label = formatSedRangeLabel(tokens[i + 1]);
1485
+ if (label) return label;
1486
+ }
1487
+ const direct = formatSedRangeLabel(tokens[i]);
1488
+ if (direct) return direct;
1489
+ }
1490
+ return undefined;
1491
+ }
1492
+
1493
+ function classifyNlRead(tokens: readonly string[]): BashDisplayInfo | null {
1494
+ const pipeIndex = tokens.indexOf("|");
1495
+ const left = pipeIndex === -1 ? tokens : tokens.slice(0, pipeIndex);
1496
+ if (left[0] !== "nl") return null;
1497
+ const path = singlePathArg(left.slice(1));
1498
+ if (!path) return null;
1499
+ let rangeLabel: string | undefined;
1500
+ if (pipeIndex !== -1) {
1501
+ const right = tokens.slice(pipeIndex + 1);
1502
+ if (right[0] !== "sed" || right.includes("|")) return null;
1503
+ rangeLabel = parseSedPrintRange(right);
1504
+ }
1505
+ return { kind: "read", label: "Read", path, rangeLabel, suppressCollapsedHint: true };
1506
+ }
1507
+
1508
+ function classifySedRead(tokens: readonly string[]): BashDisplayInfo | null {
1509
+ if (tokens[0] !== "sed") return null;
1510
+ const rangeLabel = parseSedPrintRange(tokens);
1511
+ if (!rangeLabel) return null;
1512
+ const scriptIndex = tokens.findIndex((token) => formatSedRangeLabel(token) !== undefined);
1513
+ const path = singlePathArg(tokens.slice(scriptIndex + 1));
1514
+ if (!path) return null;
1515
+ return { kind: "read", label: "Read", path, rangeLabel, suppressCollapsedHint: true };
1516
+ }
1517
+
1518
+ function classifyCatRead(tokens: readonly string[]): BashDisplayInfo | null {
1519
+ if (tokens[0] !== "cat") return null;
1520
+ const path = singlePathArg(tokens.slice(1));
1521
+ if (!path) return null;
1522
+ return { kind: "read", label: "Read", path, suppressCollapsedHint: true };
1523
+ }
1524
+
1525
+ function classifyHeadTailRead(tokens: readonly string[]): BashDisplayInfo | null {
1526
+ const command = tokens[0];
1527
+ if (command !== "head" && command !== "tail") return null;
1528
+ const args = tokens.slice(1);
1529
+ let count: string | undefined;
1530
+ const pathCandidates: string[] = [];
1531
+ for (let i = 0; i < args.length; i++) {
1532
+ const token = args[i];
1533
+ if (token === "--") continue;
1534
+ if (token === "-n" && args[i + 1]) {
1535
+ count = args[i + 1];
1536
+ i++;
1537
+ continue;
1538
+ }
1539
+ const compactCount = token.match(/^-([0-9]+)$/)?.[1];
1540
+ if (compactCount) {
1541
+ count = compactCount;
1542
+ continue;
1543
+ }
1544
+ if (isOptionToken(token)) continue;
1545
+ pathCandidates.push(token);
1546
+ }
1547
+ if (pathCandidates.length !== 1) return null;
1548
+ const amount = count && /^\d+$/.test(count) ? count : "10";
1549
+ const rangeLabel = `${command === "head" ? "first" : "last"} ${amount} lines`;
1550
+ return { kind: "read", label: "Read", path: pathCandidates[0], rangeLabel, suppressCollapsedHint: true };
1551
+ }
1552
+
1553
+ export function classifyBashCommandForDisplay(command: string): BashDisplayInfo | null {
1554
+ const tokens = tokenizeShellCommand(command.trim());
1555
+ if (!tokens || tokens.length === 0) return null;
1556
+ if (tokens.some(isControlToken)) return null;
1557
+ return classifyNlRead(tokens)
1558
+ ?? classifySedRead(tokens)
1559
+ ?? classifyCatRead(tokens)
1560
+ ?? classifyHeadTailRead(tokens);
1561
+ }
1562
+
1327
1563
  function diffCollapsedLimit(): number {
1328
1564
  const value = readSettings().diffCollapsedLines;
1329
1565
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 24;
@@ -4232,28 +4468,50 @@ export default function (pi: ExtensionAPI) {
4232
4468
  },
4233
4469
  renderCall(args, theme, ctx) {
4234
4470
  syncToolCallStatus(ctx);
4235
- const summary = stableCallSummary(ctx, "_callSummary", () => summarizeText(args.command, 72));
4236
- return makeText(ctx.lastComponent, toolHeader("Bash", summary, theme, toolStatusDot(ctx, theme)));
4471
+ const semantic = bashSemanticDisplayEnabled() ? classifyBashCommandForDisplay(args.command ?? "") : null;
4472
+ const summary = stableCallSummary(ctx, "_callSummary", () => {
4473
+ if (!semantic) return summarizeText(args.command, 72);
4474
+ const path = sp(semantic.path);
4475
+ return semantic.rangeLabel ? `${path} ${theme.fg("muted", `(${semantic.rangeLabel})`)}` : path;
4476
+ });
4477
+ return makeText(ctx.lastComponent, toolHeader(semantic?.label ?? "Bash", summary, theme, toolStatusDot(ctx, theme)));
4237
4478
  },
4238
4479
  renderResult(result, { expanded, isPartial }, theme, ctx) {
4239
4480
  const details = result.details as BashToolDetails | undefined;
4240
4481
  const output = result.content[0]?.type === "text" ? result.content[0].text : "";
4241
4482
  const nonEmpty = output.split("\n").filter((line) => line.trim().length > 0);
4483
+ const semantic = bashSemanticDisplayEnabled() ? classifyBashCommandForDisplay(ctx.args?.command ?? "") : null;
4242
4484
  if (isPartial) {
4243
4485
  setupBlinkTimer(ctx);
4244
- return makeText(ctx.lastComponent, withBranch(theme.fg("warning", `Running... (${nonEmpty.length} lines)`), theme));
4486
+ const running = semantic?.kind === "read" ? "Reading" : "Running";
4487
+ return makeText(ctx.lastComponent, withBranch(theme.fg("warning", `${running}... (${nonEmpty.length} lines)`), theme));
4245
4488
  }
4246
4489
  clearBlinkTimer(ctx);
4247
4490
  setToolStatus(ctx, ctx.isError ? "error" : "success");
4248
- const exitMatch = output.match(/exit code: (\d+)/);
4491
+ const exitMatch = output.match(/(?:exit code:|exited with code)\s+(\d+)/i);
4249
4492
  const exitCode = exitMatch ? Number.parseInt(exitMatch[1], 10) : null;
4250
- let text = exitCode === null || exitCode === 0 ? theme.fg("success", "Done") : theme.fg("error", `Exit ${exitCode}`);
4251
- text += theme.fg("muted", ` (${nonEmpty.length} lines)`);
4493
+ const isError = ctx.isError || (exitCode !== null && exitCode !== 0);
4494
+ let text = isError
4495
+ ? theme.fg("error", exitCode !== null ? `Exit ${exitCode}` : "Failed")
4496
+ : semantic?.kind === "read"
4497
+ ? `${theme.fg("success", "Read")} ${theme.fg("muted", `${nonEmpty.length} line${nonEmpty.length === 1 ? "" : "s"}`)}`
4498
+ : `${theme.fg("success", "Done")}${theme.fg("muted", ` (${nonEmpty.length} lines)`)}`;
4252
4499
  if (details?.truncation?.truncated) text += theme.fg("warning", " [truncated]");
4253
- if (!expanded && nonEmpty.length > 0) return makeText(ctx.lastComponent, withBranch(`${text}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
4500
+
4501
+ const mode = bashOutputMode();
4502
+ if (mode === "summary") return makeText(ctx.lastComponent, withBranch(text, theme));
4503
+ if (mode === "preview") {
4504
+ if (nonEmpty.length === 0) return makeText(ctx.lastComponent, withBranch(text, theme));
4505
+ const preview = buildPreviewText(nonEmpty.map((line) => theme.fg(isError ? "error" : "dim", line)), expanded, theme, bashCollapsedLimit());
4506
+ return makeText(ctx.lastComponent, withBranch(`${text}\n${preview}`, theme));
4507
+ }
4508
+
4509
+ if (!expanded && nonEmpty.length > 0) {
4510
+ const hint = semantic?.suppressCollapsedHint ? "" : theme.fg("muted", " • Ctrl+O to expand");
4511
+ return makeText(ctx.lastComponent, withBranch(`${text}${hint}`, theme));
4512
+ }
4254
4513
  if (!expanded) return makeText(ctx.lastComponent, withBranch(text, theme));
4255
- const collapsed = bashCollapsedLimit();
4256
- text += `\n${buildPreviewText(nonEmpty.map((line) => theme.fg("dim", line)), false, theme, collapsed)}`;
4514
+ text += `\n${buildPreviewText(nonEmpty.map((line) => theme.fg(isError ? "error" : "dim", line)), true, theme, bashCollapsedLimit())}`;
4257
4515
  return makeText(ctx.lastComponent, withBranch(text, theme));
4258
4516
  },
4259
4517
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlburtoe/pi-cc-tools",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Claude Code-style tool rows for pi with Ctrl+O image previews and consistent built-in, MCP, and custom tool rendering",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -32,6 +32,7 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "bench:tools": "bun scripts/benchmark-tools.ts",
35
+ "test:bash-display": "bun scripts/test-bash-display.ts",
35
36
  "test:message-chrome": "bun scripts/test-message-chrome.ts"
36
37
  },
37
38
  "dependencies": {