@owlburtoe/pi-cc-tools 1.1.0 → 1.1.2

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,8 @@ 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
9
+ - **Read-only inspection grouping** that collapses consecutive `read`/`grep`/`find`/`ls` rows into one Claude-style `Inspect` block, capped to five visible entries by default
8
10
  - **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
11
  - **`apply_patch` diff previews** that render parsed file patches in the call phase, similar to `edit`/`write`
10
12
  - **Adaptive edit/write diffs** with split or unified layouts, syntax highlighting, and inline word-level emphasis
@@ -33,6 +35,9 @@ Set in `.pi/settings.json` or `~/.pi/settings.json`:
33
35
  "bashOutputMode": "opencode",
34
36
  "bashCollapsedLines": 10,
35
37
  "bashStackConsecutive": true,
38
+ "bashSemanticDisplay": true,
39
+ "readOnlyToolGrouping": true,
40
+ "readOnlyToolGroupLimit": 5,
36
41
  "diffCollapsedLines": 24,
37
42
  "themeAdaptive": true,
38
43
  "diffTheme": "github-dark",
@@ -140,7 +145,7 @@ Color selections are persisted as `spinnerColor` / `spinnerStatusColor` in `~/.p
140
145
 
141
146
  | Value | Behavior |
142
147
  |-------|----------|
143
- | `opencode` | Compact status while collapsed, with `Ctrl+O` hint for output preview |
148
+ | `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
149
  | `summary` | Status only; no output preview or expansion hint |
145
150
  | `preview` | Show a small output preview even while collapsed |
146
151
 
@@ -149,6 +154,8 @@ Color selections are persisted as `spinnerColor` / `spinnerStatusColor` in `~/.p
149
154
  | Setting | Default | Description |
150
155
  |---------|---------|-------------|
151
156
  | `bashStackConsecutive` | `true` | Remove the extra synthetic spacer between adjacent bash tool rows so command bursts render as a tight stack |
157
+ | `bashSemanticDisplay` | `true` | Render common read-only shell file-inspection commands as semantic `Read` rows instead of raw `Bash` rows |
158
+ | `readOnlyToolGrouping` | `true` | Collapse adjacent read-only inspection tools into one `Inspect` block; mutating `write`/`edit`/`apply_patch` rows stay independent |
152
159
 
153
160
  ### Numeric settings
154
161
 
@@ -158,6 +165,7 @@ Color selections are persisted as `spinnerColor` / `spinnerStatusColor` in `~/.p
158
165
  | `expandedPreviewMaxLines` | `4000` | Max lines when fully expanded |
159
166
  | `bashCollapsedLines` | `10` | Lines for collapsed bash output |
160
167
  | `diffCollapsedLines` | `24` | Diff lines before collapsing |
168
+ | `readOnlyToolGroupLimit` | `5` | Max inspection entries shown inside a grouped read-only tool block |
161
169
 
162
170
  ## Notes
163
171
 
@@ -8,6 +8,9 @@
8
8
  "bashOutputMode": "opencode",
9
9
  "bashCollapsedLines": 10,
10
10
  "bashStackConsecutive": true,
11
+ "bashSemanticDisplay": true,
12
+ "readOnlyToolGrouping": true,
13
+ "readOnlyToolGroupLimit": 5,
11
14
  "diffCollapsedLines": 24,
12
15
  "showTruncationHints": false,
13
16
  "themeAdaptive": true,
@@ -86,6 +86,10 @@ 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;
91
+ readOnlyToolGrouping?: boolean;
92
+ readOnlyToolGroupLimit?: number;
89
93
  showTruncationHints?: boolean;
90
94
  diffCollapsedLines?: number;
91
95
  diffTheme?: string;
@@ -339,6 +343,253 @@ function shouldStackConsecutiveBash(): boolean {
339
343
  return readSettings().bashStackConsecutive !== false;
340
344
  }
341
345
 
346
+ function readOnlyToolGroupingEnabled(): boolean {
347
+ return readSettings().readOnlyToolGrouping !== false;
348
+ }
349
+
350
+ function readOnlyToolGroupLimit(): number {
351
+ const value = readSettings().readOnlyToolGroupLimit;
352
+ return typeof value === "number" && Number.isFinite(value) && value > 0
353
+ ? Math.max(1, Math.min(20, Math.floor(value)))
354
+ : 5;
355
+ }
356
+
357
+ function toolComponentRecord(value: unknown): Record<string, any> {
358
+ return value as Record<string, any>;
359
+ }
360
+
361
+ function componentCwd(value: unknown): string {
362
+ const cwd = toolComponentRecord(value).cwd;
363
+ return typeof cwd === "string" && cwd ? cwd : process.cwd();
364
+ }
365
+
366
+ function componentTextContent(value: unknown): string {
367
+ return getTextContent(toolComponentRecord(value).result);
368
+ }
369
+
370
+ function componentHasImageResult(value: unknown): boolean {
371
+ return !!getFirstImageBlock(toolComponentRecord(value).result);
372
+ }
373
+
374
+ function bashReadDisplayInfo(value: unknown): BashDisplayInfo | null {
375
+ const rec = toolComponentRecord(value);
376
+ if (rec.toolName !== "bash" || !bashSemanticDisplayEnabled()) return null;
377
+ return classifyBashCommandForDisplay(rec.args?.command ?? "");
378
+ }
379
+
380
+ function isReadOnlyInspectionToolExecution(value: unknown): boolean {
381
+ if (!readOnlyToolGroupingEnabled() || !isToolExecutionLike(value)) return false;
382
+ const rec = toolComponentRecord(value);
383
+ if (rec.expanded === true || componentHasImageResult(value)) return false;
384
+ if (rec.toolName === "read" || rec.toolName === "grep" || rec.toolName === "find" || rec.toolName === "ls") return true;
385
+ return !!bashReadDisplayInfo(value);
386
+ }
387
+
388
+ function hasConsecutiveReadOnlyInspectionToolChildren(children: unknown[]): boolean {
389
+ let previousWasInspect = false;
390
+ for (const child of children) {
391
+ const currentIsInspect = isReadOnlyInspectionToolExecution(child);
392
+ if (currentIsInspect && previousWasInspect) return true;
393
+ previousWasInspect = currentIsInspect;
394
+ }
395
+ return false;
396
+ }
397
+
398
+ function plural(count: number, noun: string): string {
399
+ if (count === 1) return `1 ${noun}`;
400
+ const suffix = /(?:s|x|z|ch|sh)$/i.test(noun) ? "es" : "s";
401
+ return `${count} ${noun}${suffix}`;
402
+ }
403
+
404
+ function textDataLines(text: string): string[] {
405
+ return text.split("\n").filter((line) => line.trim().length > 0 && !line.trimStart().startsWith("["));
406
+ }
407
+
408
+ function compactList(values: string[], limit: number): string {
409
+ if (values.length === 0) return "";
410
+ const shown = values.slice(0, limit);
411
+ const suffix = values.length > shown.length ? `, … +${values.length - shown.length}` : "";
412
+ return `${shown.join(", ")}${suffix}`;
413
+ }
414
+
415
+ function uniqueLeadingGrepFiles(text: string, limit: number): string[] {
416
+ const seen = new Set<string>();
417
+ const files: string[] = [];
418
+ for (const rawLine of text.split("\n")) {
419
+ const line = rawLine.trim();
420
+ if (!line || line.startsWith("[")) continue;
421
+ const match = /^(.+?)(?::\d+:|-\d+-)/.exec(line);
422
+ if (!match) continue;
423
+ const file = match[1];
424
+ if (seen.has(file)) continue;
425
+ seen.add(file);
426
+ files.push(file);
427
+ if (files.length >= limit + 1) break;
428
+ }
429
+ return files;
430
+ }
431
+
432
+ function formatOffsetLimit(args: any): string {
433
+ const parts: string[] = [];
434
+ if (args?.offset !== undefined && args?.offset !== null) parts.push(`offset=${args.offset}`);
435
+ if (args?.limit !== undefined && args?.limit !== null) parts.push(`limit=${args.limit}`);
436
+ return parts.length > 0 ? ` (${parts.join(", ")})` : "";
437
+ }
438
+
439
+ function readInspectionTarget(value: unknown): string {
440
+ const rec = toolComponentRecord(value);
441
+ return `${shortPath(componentCwd(value), rec.args?.path ?? "")}${formatOffsetLimit(rec.args)}`;
442
+ }
443
+
444
+ function grepInspectionTarget(value: unknown): string {
445
+ const rec = toolComponentRecord(value);
446
+ const pattern = summarizeText(rec.args?.pattern ?? "", 40);
447
+ const path = rec.args?.path ? ` in ${shortPath(componentCwd(value), rec.args.path)}` : "";
448
+ return `"${pattern}"${path}`;
449
+ }
450
+
451
+ function findInspectionTarget(value: unknown): string {
452
+ const rec = toolComponentRecord(value);
453
+ const pattern = summarizeText(rec.args?.pattern ?? "", 40);
454
+ const path = rec.args?.path ? ` in ${shortPath(componentCwd(value), rec.args.path)}` : "";
455
+ return `"${pattern}"${path}`;
456
+ }
457
+
458
+ function listInspectionTarget(value: unknown): string {
459
+ const rec = toolComponentRecord(value);
460
+ return shortPath(componentCwd(value), rec.args?.path ?? ".");
461
+ }
462
+
463
+ function readInspectionStatus(value: unknown): string {
464
+ const rec = toolComponentRecord(value);
465
+ if (!rec.result) return "Reading...";
466
+ if (rec.result?.isError) return "failed";
467
+ const text = componentTextContent(value);
468
+ const lines = text.length === 0 ? 0 : text.split("\n").length;
469
+ const suffix = rec.result?.details?.truncation?.truncated ? " (truncated)" : "";
470
+ return `${plural(lines, "line")} loaded${suffix}`;
471
+ }
472
+
473
+ function grepInspectionStatus(value: unknown): string {
474
+ const rec = toolComponentRecord(value);
475
+ if (!rec.result) return "Searching...";
476
+ if (rec.result?.isError) return "failed";
477
+ const text = componentTextContent(value).trim();
478
+ if (!text || /^No matches found\b/i.test(text)) return "no matches";
479
+ const lines = textDataLines(text);
480
+ let status = plural(lines.length, "match");
481
+ const files = uniqueLeadingGrepFiles(text, readOnlyToolGroupLimit());
482
+ if (files.length > 1) status += ` in ${compactList(files, readOnlyToolGroupLimit())}`;
483
+ if (rec.result?.details?.truncation?.truncated) status += " (truncated)";
484
+ return status;
485
+ }
486
+
487
+ function findInspectionStatus(value: unknown): string {
488
+ const rec = toolComponentRecord(value);
489
+ if (!rec.result) return "Finding...";
490
+ if (rec.result?.isError) return "failed";
491
+ const text = componentTextContent(value).trim();
492
+ if (!text || /^No files found\b/i.test(text)) return "no files";
493
+ const items = textDataLines(text);
494
+ let status = plural(items.length, "file");
495
+ if (items.length > 0) status += `: ${compactList(items, readOnlyToolGroupLimit())}`;
496
+ if (rec.result?.details?.truncation?.truncated) status += " (truncated)";
497
+ return status;
498
+ }
499
+
500
+ function listInspectionStatus(value: unknown): string {
501
+ const rec = toolComponentRecord(value);
502
+ if (!rec.result) return "Listing...";
503
+ if (rec.result?.isError) return "failed";
504
+ const text = componentTextContent(value).trim();
505
+ if (!text || /^empty directory\b/i.test(text)) return "empty directory";
506
+ const items = textDataLines(text);
507
+ let status = plural(items.length, "entry");
508
+ if (items.length > 0) status += `: ${compactList(items, readOnlyToolGroupLimit())}`;
509
+ return status;
510
+ }
511
+
512
+ function bashReadInspectionTarget(value: unknown): string {
513
+ const info = bashReadDisplayInfo(value);
514
+ if (!info) return "";
515
+ const range = info.rangeLabel ? ` (${info.rangeLabel})` : "";
516
+ return `${shortPath(componentCwd(value), info.path)}${range}`;
517
+ }
518
+
519
+ function bashReadInspectionStatus(value: unknown): string {
520
+ const rec = toolComponentRecord(value);
521
+ if (!rec.result) return "Reading...";
522
+ if (rec.result?.isError) return "failed";
523
+ const count = componentTextContent(value).split("\n").filter((line) => line.trim().length > 0).length;
524
+ return `${plural(count, "line")} read`;
525
+ }
526
+
527
+ function summarizeReadOnlyInspectionTool(value: unknown): string {
528
+ const rec = toolComponentRecord(value);
529
+ if (rec.toolName === "read") return `Read ${readInspectionTarget(value)} — ${readInspectionStatus(value)}`;
530
+ if (rec.toolName === "grep") return `Grep ${grepInspectionTarget(value)} — ${grepInspectionStatus(value)}`;
531
+ if (rec.toolName === "find") return `Find ${findInspectionTarget(value)} — ${findInspectionStatus(value)}`;
532
+ if (rec.toolName === "ls") return `List ${listInspectionTarget(value)} — ${listInspectionStatus(value)}`;
533
+ return `Read ${bashReadInspectionTarget(value)} — ${bashReadInspectionStatus(value)}`;
534
+ }
535
+
536
+ function renderInspectionGroup(group: unknown[], width: number): string[] {
537
+ syncToolBackgroundMode();
538
+ const limit = readOnlyToolGroupLimit();
539
+ const shown = group.slice(0, limit);
540
+ const remaining = group.length - shown.length;
541
+ const core: string[] = [` ${WRAP_MARK}● Inspect ${plural(group.length, "tool use")}`];
542
+ for (let index = 0; index < shown.length; index++) {
543
+ const isLast = index === shown.length - 1 && remaining === 0;
544
+ const branch = isLast ? "└─" : "├─";
545
+ core.push(` ${TOOL_RULE}${branch}${TRANSPARENT_RESET} ${WRAP_MARK}${summarizeReadOnlyInspectionTool(shown[index])}`);
546
+ }
547
+ if (remaining > 0) {
548
+ core.push(` ${TOOL_RULE}└─${TRANSPARENT_RESET} ${WRAP_MARK}… ${remaining} more inspection${remaining === 1 ? "" : "s"}`);
549
+ }
550
+ const renderedCore = core.flatMap((line) => wrapMarkedLine(line, width)).map((line) => padToWidth(line, width));
551
+ if (toolBackgroundMode === "outlines") return [" ".repeat(width), borderLine(width), ...renderedCore, borderLine(width)];
552
+ if (toolBackgroundMode === "transparent") return [" ".repeat(width), ...renderedCore];
553
+ return renderedCore;
554
+ }
555
+
556
+ function renderWithGroupedReadOnlyInspectionTools(container: any, width: number): string[] | null {
557
+ const children = Array.isArray(container?.children) ? container.children : null;
558
+ if (!children || !hasConsecutiveReadOnlyInspectionToolChildren(children)) return null;
559
+
560
+ const rendered: string[] = [];
561
+ let group: unknown[] = [];
562
+ let previousWasBash = false;
563
+ const flushGroup = () => {
564
+ if (group.length === 0) return;
565
+ if (group.length === 1) {
566
+ const child = group[0] as any;
567
+ const currentIsBash = isBashToolExecution(child);
568
+ const childLines = typeof child?.render === "function" ? child.render(width) : [];
569
+ rendered.push(...(currentIsBash && previousWasBash ? dropLeadingSpacerLine(childLines) : childLines));
570
+ previousWasBash = currentIsBash;
571
+ } else {
572
+ rendered.push(...renderInspectionGroup(group, width));
573
+ previousWasBash = false;
574
+ }
575
+ group = [];
576
+ };
577
+
578
+ for (const child of children) {
579
+ if (isReadOnlyInspectionToolExecution(child)) {
580
+ group.push(child);
581
+ continue;
582
+ }
583
+ flushGroup();
584
+ const currentIsBash = isBashToolExecution(child);
585
+ const childLines = typeof child?.render === "function" ? child.render(width) : [];
586
+ rendered.push(...(currentIsBash && previousWasBash ? dropLeadingSpacerLine(childLines) : childLines));
587
+ previousWasBash = currentIsBash;
588
+ }
589
+ flushGroup();
590
+ return rendered;
591
+ }
592
+
342
593
  function hasConsecutiveBashToolChildren(children: unknown[]): boolean {
343
594
  let previousWasBash = false;
344
595
  for (const child of children) {
@@ -400,6 +651,8 @@ function patchGlobalToolBorders(): void {
400
651
  const originalRender = proto.render;
401
652
  proto.render = function patchedContainerRender(width: number): string[] {
402
653
  if (!isToolExecutionLike(this)) {
654
+ const grouped = renderWithGroupedReadOnlyInspectionTools(this, width);
655
+ if (grouped) return grouped;
403
656
  const stacked = renderWithStackedConsecutiveBash(this, width);
404
657
  if (stacked) return stacked;
405
658
  }
@@ -1373,6 +1626,191 @@ function bashOutputMode(): "opencode" | "summary" | "preview" {
1373
1626
  return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
1374
1627
  }
1375
1628
 
1629
+ function bashSemanticDisplayEnabled(): boolean {
1630
+ return readSettings().bashSemanticDisplay !== false;
1631
+ }
1632
+
1633
+ export interface BashDisplayInfo {
1634
+ kind: "read";
1635
+ label: "Read";
1636
+ path: string;
1637
+ rangeLabel?: string;
1638
+ suppressCollapsedHint: boolean;
1639
+ }
1640
+
1641
+ function tokenizeShellCommand(command: string): string[] | null {
1642
+ const tokens: string[] = [];
1643
+ let current = "";
1644
+ let quote: "'" | '"' | null = null;
1645
+ let escaping = false;
1646
+
1647
+ const push = () => {
1648
+ if (current.length > 0) {
1649
+ tokens.push(current);
1650
+ current = "";
1651
+ }
1652
+ };
1653
+
1654
+ for (let i = 0; i < command.length; i++) {
1655
+ const char = command[i];
1656
+ if (escaping) {
1657
+ current += char;
1658
+ escaping = false;
1659
+ continue;
1660
+ }
1661
+ if (quote) {
1662
+ if (char === quote) {
1663
+ quote = null;
1664
+ continue;
1665
+ }
1666
+ if (quote === '"' && char === "\\") {
1667
+ escaping = true;
1668
+ continue;
1669
+ }
1670
+ current += char;
1671
+ continue;
1672
+ }
1673
+ if (char === "\\") {
1674
+ escaping = true;
1675
+ continue;
1676
+ }
1677
+ if (char === "'" || char === '"') {
1678
+ quote = char;
1679
+ continue;
1680
+ }
1681
+ if (/\s/.test(char)) {
1682
+ push();
1683
+ continue;
1684
+ }
1685
+ if (char === "|" || char === ";") {
1686
+ push();
1687
+ if (char === "|" && command[i + 1] === "|") {
1688
+ tokens.push("||");
1689
+ i++;
1690
+ } else {
1691
+ tokens.push(char);
1692
+ }
1693
+ continue;
1694
+ }
1695
+ if (char === "&" && command[i + 1] === "&") {
1696
+ push();
1697
+ tokens.push("&&");
1698
+ i++;
1699
+ continue;
1700
+ }
1701
+ current += char;
1702
+ }
1703
+ if (escaping) current += "\\";
1704
+ if (quote) return null;
1705
+ push();
1706
+ return tokens;
1707
+ }
1708
+
1709
+ function isControlToken(token: string): boolean {
1710
+ return token === "&&" || token === "||" || token === ";";
1711
+ }
1712
+
1713
+ function isOptionToken(token: string): boolean {
1714
+ return token.startsWith("-") && token !== "-" && token !== "--";
1715
+ }
1716
+
1717
+ function singlePathArg(tokens: readonly string[]): string | null {
1718
+ const paths = tokens.filter((token) => token !== "--" && token !== "-" && !isOptionToken(token));
1719
+ return paths.length === 1 ? paths[0] : null;
1720
+ }
1721
+
1722
+ function formatSedRangeLabel(script: string | undefined): string | undefined {
1723
+ if (!script) return undefined;
1724
+ const match = script.match(/^(\d+|\$)(?:,(\d+|\$))?p$/);
1725
+ if (!match) return undefined;
1726
+ const start = match[1];
1727
+ const end = match[2];
1728
+ if (!end || end === start) return `line ${start}`;
1729
+ return `lines ${start}-${end}`;
1730
+ }
1731
+
1732
+ function parseSedPrintRange(tokens: readonly string[]): string | undefined {
1733
+ for (let i = 0; i < tokens.length; i++) {
1734
+ if (tokens[i] === "-n" && tokens[i + 1]) {
1735
+ const label = formatSedRangeLabel(tokens[i + 1]);
1736
+ if (label) return label;
1737
+ }
1738
+ const direct = formatSedRangeLabel(tokens[i]);
1739
+ if (direct) return direct;
1740
+ }
1741
+ return undefined;
1742
+ }
1743
+
1744
+ function classifyNlRead(tokens: readonly string[]): BashDisplayInfo | null {
1745
+ const pipeIndex = tokens.indexOf("|");
1746
+ const left = pipeIndex === -1 ? tokens : tokens.slice(0, pipeIndex);
1747
+ if (left[0] !== "nl") return null;
1748
+ const path = singlePathArg(left.slice(1));
1749
+ if (!path) return null;
1750
+ let rangeLabel: string | undefined;
1751
+ if (pipeIndex !== -1) {
1752
+ const right = tokens.slice(pipeIndex + 1);
1753
+ if (right[0] !== "sed" || right.includes("|")) return null;
1754
+ rangeLabel = parseSedPrintRange(right);
1755
+ }
1756
+ return { kind: "read", label: "Read", path, rangeLabel, suppressCollapsedHint: true };
1757
+ }
1758
+
1759
+ function classifySedRead(tokens: readonly string[]): BashDisplayInfo | null {
1760
+ if (tokens[0] !== "sed") return null;
1761
+ const rangeLabel = parseSedPrintRange(tokens);
1762
+ if (!rangeLabel) return null;
1763
+ const scriptIndex = tokens.findIndex((token) => formatSedRangeLabel(token) !== undefined);
1764
+ const path = singlePathArg(tokens.slice(scriptIndex + 1));
1765
+ if (!path) return null;
1766
+ return { kind: "read", label: "Read", path, rangeLabel, suppressCollapsedHint: true };
1767
+ }
1768
+
1769
+ function classifyCatRead(tokens: readonly string[]): BashDisplayInfo | null {
1770
+ if (tokens[0] !== "cat") return null;
1771
+ const path = singlePathArg(tokens.slice(1));
1772
+ if (!path) return null;
1773
+ return { kind: "read", label: "Read", path, suppressCollapsedHint: true };
1774
+ }
1775
+
1776
+ function classifyHeadTailRead(tokens: readonly string[]): BashDisplayInfo | null {
1777
+ const command = tokens[0];
1778
+ if (command !== "head" && command !== "tail") return null;
1779
+ const args = tokens.slice(1);
1780
+ let count: string | undefined;
1781
+ const pathCandidates: string[] = [];
1782
+ for (let i = 0; i < args.length; i++) {
1783
+ const token = args[i];
1784
+ if (token === "--") continue;
1785
+ if (token === "-n" && args[i + 1]) {
1786
+ count = args[i + 1];
1787
+ i++;
1788
+ continue;
1789
+ }
1790
+ const compactCount = token.match(/^-([0-9]+)$/)?.[1];
1791
+ if (compactCount) {
1792
+ count = compactCount;
1793
+ continue;
1794
+ }
1795
+ if (isOptionToken(token)) continue;
1796
+ pathCandidates.push(token);
1797
+ }
1798
+ if (pathCandidates.length !== 1) return null;
1799
+ const amount = count && /^\d+$/.test(count) ? count : "10";
1800
+ const rangeLabel = `${command === "head" ? "first" : "last"} ${amount} lines`;
1801
+ return { kind: "read", label: "Read", path: pathCandidates[0], rangeLabel, suppressCollapsedHint: true };
1802
+ }
1803
+
1804
+ export function classifyBashCommandForDisplay(command: string): BashDisplayInfo | null {
1805
+ const tokens = tokenizeShellCommand(command.trim());
1806
+ if (!tokens || tokens.length === 0) return null;
1807
+ if (tokens.some(isControlToken)) return null;
1808
+ return classifyNlRead(tokens)
1809
+ ?? classifySedRead(tokens)
1810
+ ?? classifyCatRead(tokens)
1811
+ ?? classifyHeadTailRead(tokens);
1812
+ }
1813
+
1376
1814
  function diffCollapsedLimit(): number {
1377
1815
  const value = readSettings().diffCollapsedLines;
1378
1816
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 24;
@@ -4281,16 +4719,23 @@ export default function (pi: ExtensionAPI) {
4281
4719
  },
4282
4720
  renderCall(args, theme, ctx) {
4283
4721
  syncToolCallStatus(ctx);
4284
- const summary = stableCallSummary(ctx, "_callSummary", () => summarizeText(args.command, 72));
4285
- return makeText(ctx.lastComponent, toolHeader("Bash", summary, theme, toolStatusDot(ctx, theme)));
4722
+ const semantic = bashSemanticDisplayEnabled() ? classifyBashCommandForDisplay(args.command ?? "") : null;
4723
+ const summary = stableCallSummary(ctx, "_callSummary", () => {
4724
+ if (!semantic) return summarizeText(args.command, 72);
4725
+ const path = sp(semantic.path);
4726
+ return semantic.rangeLabel ? `${path} ${theme.fg("muted", `(${semantic.rangeLabel})`)}` : path;
4727
+ });
4728
+ return makeText(ctx.lastComponent, toolHeader(semantic?.label ?? "Bash", summary, theme, toolStatusDot(ctx, theme)));
4286
4729
  },
4287
4730
  renderResult(result, { expanded, isPartial }, theme, ctx) {
4288
4731
  const details = result.details as BashToolDetails | undefined;
4289
4732
  const output = result.content[0]?.type === "text" ? result.content[0].text : "";
4290
4733
  const nonEmpty = output.split("\n").filter((line) => line.trim().length > 0);
4734
+ const semantic = bashSemanticDisplayEnabled() ? classifyBashCommandForDisplay(ctx.args?.command ?? "") : null;
4291
4735
  if (isPartial) {
4292
4736
  setupBlinkTimer(ctx);
4293
- return makeText(ctx.lastComponent, withBranch(theme.fg("warning", `Running... (${nonEmpty.length} lines)`), theme));
4737
+ const running = semantic?.kind === "read" ? "Reading" : "Running";
4738
+ return makeText(ctx.lastComponent, withBranch(theme.fg("warning", `${running}... (${nonEmpty.length} lines)`), theme));
4294
4739
  }
4295
4740
  clearBlinkTimer(ctx);
4296
4741
  setToolStatus(ctx, ctx.isError ? "error" : "success");
@@ -4299,8 +4744,9 @@ export default function (pi: ExtensionAPI) {
4299
4744
  const isError = ctx.isError || (exitCode !== null && exitCode !== 0);
4300
4745
  let text = isError
4301
4746
  ? theme.fg("error", exitCode !== null ? `Exit ${exitCode}` : "Failed")
4302
- : theme.fg("success", "Done");
4303
- text += theme.fg("muted", ` (${nonEmpty.length} lines)`);
4747
+ : semantic?.kind === "read"
4748
+ ? `${theme.fg("success", "Read")} ${theme.fg("muted", `${nonEmpty.length} line${nonEmpty.length === 1 ? "" : "s"}`)}`
4749
+ : `${theme.fg("success", "Done")}${theme.fg("muted", ` (${nonEmpty.length} lines)`)}`;
4304
4750
  if (details?.truncation?.truncated) text += theme.fg("warning", " [truncated]");
4305
4751
 
4306
4752
  const mode = bashOutputMode();
@@ -4311,7 +4757,10 @@ export default function (pi: ExtensionAPI) {
4311
4757
  return makeText(ctx.lastComponent, withBranch(`${text}\n${preview}`, theme));
4312
4758
  }
4313
4759
 
4314
- if (!expanded && nonEmpty.length > 0) return makeText(ctx.lastComponent, withBranch(`${text}${theme.fg("muted", " • Ctrl+O to expand")}`, theme));
4760
+ if (!expanded && nonEmpty.length > 0) {
4761
+ const hint = semantic?.suppressCollapsedHint ? "" : theme.fg("muted", " • Ctrl+O to expand");
4762
+ return makeText(ctx.lastComponent, withBranch(`${text}${hint}`, theme));
4763
+ }
4315
4764
  if (!expanded) return makeText(ctx.lastComponent, withBranch(text, theme));
4316
4765
  text += `\n${buildPreviewText(nonEmpty.map((line) => theme.fg(isError ? "error" : "dim", line)), true, theme, bashCollapsedLimit())}`;
4317
4766
  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.2",
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": {