@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 +9 -1
- package/config/config.example.json +3 -0
- package/extensions/index.ts +455 -6
- package/package.json +2 -1
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,
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
4285
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
4303
|
-
|
|
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)
|
|
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.
|
|
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": {
|