@owlburtoe/pi-cc-tools 1.1.0 → 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
@@ -33,6 +34,7 @@ Set in `.pi/settings.json` or `~/.pi/settings.json`:
33
34
  "bashOutputMode": "opencode",
34
35
  "bashCollapsedLines": 10,
35
36
  "bashStackConsecutive": true,
37
+ "bashSemanticDisplay": true,
36
38
  "diffCollapsedLines": 24,
37
39
  "themeAdaptive": true,
38
40
  "diffTheme": "github-dark",
@@ -140,7 +142,7 @@ Color selections are persisted as `spinnerColor` / `spinnerStatusColor` in `~/.p
140
142
 
141
143
  | Value | Behavior |
142
144
  |-------|----------|
143
- | `opencode` | Compact status while collapsed, with `Ctrl+O` hint for output preview |
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. |
144
146
  | `summary` | Status only; no output preview or expansion hint |
145
147
  | `preview` | Show a small output preview even while collapsed |
146
148
 
@@ -149,6 +151,7 @@ Color selections are persisted as `spinnerColor` / `spinnerStatusColor` in `~/.p
149
151
  | Setting | Default | Description |
150
152
  |---------|---------|-------------|
151
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 |
152
155
 
153
156
  ### Numeric settings
154
157
 
@@ -8,6 +8,7 @@
8
8
  "bashOutputMode": "opencode",
9
9
  "bashCollapsedLines": 10,
10
10
  "bashStackConsecutive": true,
11
+ "bashSemanticDisplay": true,
11
12
  "diffCollapsedLines": 24,
12
13
  "showTruncationHints": false,
13
14
  "themeAdaptive": true,
@@ -86,6 +86,8 @@ interface SettingsFile {
86
86
  bashCollapsedLines?: number;
87
87
  /** When true (default), consecutive bash tool rows render without the extra inter-tool spacer. */
88
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;
89
91
  showTruncationHints?: boolean;
90
92
  diffCollapsedLines?: number;
91
93
  diffTheme?: string;
@@ -1373,6 +1375,191 @@ function bashOutputMode(): "opencode" | "summary" | "preview" {
1373
1375
  return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
1374
1376
  }
1375
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
+
1376
1563
  function diffCollapsedLimit(): number {
1377
1564
  const value = readSettings().diffCollapsedLines;
1378
1565
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 24;
@@ -4281,16 +4468,23 @@ export default function (pi: ExtensionAPI) {
4281
4468
  },
4282
4469
  renderCall(args, theme, ctx) {
4283
4470
  syncToolCallStatus(ctx);
4284
- const summary = stableCallSummary(ctx, "_callSummary", () => summarizeText(args.command, 72));
4285
- 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)));
4286
4478
  },
4287
4479
  renderResult(result, { expanded, isPartial }, theme, ctx) {
4288
4480
  const details = result.details as BashToolDetails | undefined;
4289
4481
  const output = result.content[0]?.type === "text" ? result.content[0].text : "";
4290
4482
  const nonEmpty = output.split("\n").filter((line) => line.trim().length > 0);
4483
+ const semantic = bashSemanticDisplayEnabled() ? classifyBashCommandForDisplay(ctx.args?.command ?? "") : null;
4291
4484
  if (isPartial) {
4292
4485
  setupBlinkTimer(ctx);
4293
- 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));
4294
4488
  }
4295
4489
  clearBlinkTimer(ctx);
4296
4490
  setToolStatus(ctx, ctx.isError ? "error" : "success");
@@ -4299,8 +4493,9 @@ export default function (pi: ExtensionAPI) {
4299
4493
  const isError = ctx.isError || (exitCode !== null && exitCode !== 0);
4300
4494
  let text = isError
4301
4495
  ? theme.fg("error", exitCode !== null ? `Exit ${exitCode}` : "Failed")
4302
- : theme.fg("success", "Done");
4303
- text += theme.fg("muted", ` (${nonEmpty.length} lines)`);
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)`)}`;
4304
4499
  if (details?.truncation?.truncated) text += theme.fg("warning", " [truncated]");
4305
4500
 
4306
4501
  const mode = bashOutputMode();
@@ -4311,7 +4506,10 @@ export default function (pi: ExtensionAPI) {
4311
4506
  return makeText(ctx.lastComponent, withBranch(`${text}\n${preview}`, theme));
4312
4507
  }
4313
4508
 
4314
- if (!expanded && nonEmpty.length > 0) return makeText(ctx.lastComponent, withBranch(`${text}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
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
+ }
4315
4513
  if (!expanded) return makeText(ctx.lastComponent, withBranch(text, theme));
4316
4514
  text += `\n${buildPreviewText(nonEmpty.map((line) => theme.fg(isError ? "error" : "dim", line)), true, theme, bashCollapsedLimit())}`;
4317
4515
  return makeText(ctx.lastComponent, withBranch(text, theme));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlburtoe/pi-cc-tools",
3
- "version": "1.1.0",
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": {