@quandev104/pi-style 0.1.2 → 0.1.3

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -2
  3. package/dist/extensions/pi-style.js +5510 -4865
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -2
  6. package/extension-src/pi-style/app/index.ts +0 -1
  7. package/extension-src/pi-style/domain/config-authorization.ts +1 -2
  8. package/extension-src/pi-style/domain/config-normalization.ts +3 -3
  9. package/extension-src/pi-style/domain/config-types.ts +2 -2
  10. package/extension-src/pi-style/domain/theme.ts +3 -0
  11. package/extension-src/pi-style/features/messages/index.ts +2 -8
  12. package/extension-src/pi-style/features/tools/boxed/bash.ts +384 -0
  13. package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
  14. package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
  15. package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
  16. package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
  17. package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
  18. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
  19. package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
  20. package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
  21. package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
  22. package/extension-src/pi-style/features/tools/index.ts +14 -0
  23. package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
  24. package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
  25. package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
  26. package/extension-src/pi-style/pi/config-session.ts +0 -2
  27. package/extension-src/pi-style/pi/index.ts +35 -1
  28. package/extension-src/pi-style/pi/session-coordinator.ts +39 -3
  29. package/extension-src/pi-style/shared/box.ts +41 -7
  30. package/extension-src/pi-style/shared/theme-extras.ts +0 -2
  31. package/package.json +1 -1
@@ -45,9 +45,9 @@ const allowedPaths = new Set([
45
45
  "editor.frame",
46
46
  "editor.showMetadata",
47
47
  "messages.enabled",
48
- "messages.userPrefix",
49
48
  "messages.assistantPrefix",
50
49
  "messages.specialBlocks",
50
+ "messages.hideThinkingLabel",
51
51
  "tools.enabled",
52
52
  "tools.style",
53
53
  "tools.maxCollapsedLines",
@@ -74,9 +74,9 @@ function validatePathValue(path: string, value: unknown): boolean {
74
74
  "editor.enabled",
75
75
  "editor.showMetadata",
76
76
  "messages.enabled",
77
- "messages.userPrefix",
78
77
  "messages.assistantPrefix",
79
78
  "messages.specialBlocks",
79
+ "messages.hideThinkingLabel",
80
80
  "tools.enabled",
81
81
  "tools.showElapsed",
82
82
  "tools.dimOutput",
@@ -311,7 +311,6 @@ export function createPiStyleApp(
311
311
  status: operational.installations?.status ?? "unknown",
312
312
  editor: operational.installations?.editor ?? "unknown",
313
313
  startup: operational.installations?.startup ?? "unknown",
314
- userMessage: operational.compatibility?.userMessage ?? "unknown",
315
314
  assistantMessage: operational.compatibility?.assistantMessage ?? "unknown",
316
315
  specialBlocks: operational.compatibility?.specialBlocks ?? "unknown",
317
316
  tools: operational.compatibility?.tools ?? "unknown",
@@ -4,7 +4,7 @@ export interface TierCAuthorizationInput {
4
4
  readonly certifiedHost: boolean;
5
5
  readonly coreFlag: boolean;
6
6
  readonly surfaceFlag: boolean;
7
- readonly surface: "messages" | "tools" | "userMessage" | "assistantMessage" | "specialBlocks";
7
+ readonly surface: "messages" | "tools" | "assistantMessage" | "specialBlocks";
8
8
  readonly config: NormalizedPiStyleConfig;
9
9
  }
10
10
 
@@ -12,7 +12,6 @@ export interface TierCAuthorizationInput {
12
12
  export function isTierCAuthorized(input: TierCAuthorizationInput): boolean {
13
13
  if (!input.certifiedHost || !input.coreFlag || !input.surfaceFlag || !input.config.enabled) return false;
14
14
  if (input.surface === "tools") return input.config.tools.enabled;
15
- if (input.surface === "userMessage") return input.config.messages.enabled && input.config.messages.userPrefix;
16
15
  if (input.surface === "assistantMessage")
17
16
  return input.config.messages.enabled && input.config.messages.assistantPrefix;
18
17
  if (input.surface === "specialBlocks") return input.config.messages.enabled && input.config.messages.specialBlocks;
@@ -28,7 +28,7 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
28
28
  contextBarWidth: 10,
29
29
  }),
30
30
  editor: Object.freeze({ enabled: true, style: "compact", frame: "auto", showMetadata: false }),
31
- messages: Object.freeze({ enabled: true, userPrefix: true, assistantPrefix: true, specialBlocks: true }),
31
+ messages: Object.freeze({ enabled: true, assistantPrefix: true, specialBlocks: true, hideThinkingLabel: true }),
32
32
  tools: Object.freeze({
33
33
  enabled: true,
34
34
  style: "compact-box",
@@ -159,9 +159,9 @@ export function normalizeConfig(
159
159
  }),
160
160
  messages: Object.freeze({
161
161
  enabled: bool(messages.enabled, defaults.messages.enabled),
162
- userPrefix: bool(messages.userPrefix, defaults.messages.userPrefix),
163
162
  assistantPrefix: bool(messages.assistantPrefix, defaults.messages.assistantPrefix),
164
163
  specialBlocks: bool(messages.specialBlocks, defaults.messages.specialBlocks),
164
+ hideThinkingLabel: bool(messages.hideThinkingLabel, defaults.messages.hideThinkingLabel),
165
165
  }),
166
166
  tools: Object.freeze({
167
167
  enabled: bool(tools.enabled, defaults.tools.enabled),
@@ -213,9 +213,9 @@ const BOOL_PATHS = new Set([
213
213
  "editor.enabled",
214
214
  "editor.showMetadata",
215
215
  "messages.enabled",
216
- "messages.userPrefix",
217
216
  "messages.assistantPrefix",
218
217
  "messages.specialBlocks",
218
+ "messages.hideThinkingLabel",
219
219
  "tools.enabled",
220
220
  "tools.showElapsed",
221
221
  "tools.dimOutput",
@@ -32,7 +32,7 @@ export interface PiStyleConfig {
32
32
  contextBarWidth?: number;
33
33
  };
34
34
  editor?: { enabled?: boolean; style?: string; frame?: string; showMetadata?: boolean };
35
- messages?: { enabled?: boolean; userPrefix?: boolean; assistantPrefix?: boolean; specialBlocks?: boolean };
35
+ messages?: { enabled?: boolean; assistantPrefix?: boolean; specialBlocks?: boolean; hideThinkingLabel?: boolean };
36
36
  tools?: {
37
37
  enabled?: boolean;
38
38
  style?: string;
@@ -79,7 +79,7 @@ export interface NormalizedPiStyleConfig {
79
79
  contextBarWidth: number;
80
80
  };
81
81
  readonly editor: { enabled: boolean; style: EditorStyle; frame: EditorFrame; showMetadata: boolean };
82
- readonly messages: { enabled: boolean; userPrefix: boolean; assistantPrefix: boolean; specialBlocks: boolean };
82
+ readonly messages: { enabled: boolean; assistantPrefix: boolean; specialBlocks: boolean; hideThinkingLabel: boolean };
83
83
  readonly tools: {
84
84
  enabled: boolean;
85
85
  style: string;
@@ -107,6 +107,7 @@ const GLYPHS = {
107
107
  powerlineRight: "\uE0B2",
108
108
  powerlineThinLeft: "\uE0B1",
109
109
  powerlineThinRight: "\uE0B3",
110
+ batchOpen: "\u{F111}",
110
111
  },
111
112
  unicode: {
112
113
  pi: "π",
@@ -118,6 +119,7 @@ const GLYPHS = {
118
119
  powerlineRight: "‹",
119
120
  powerlineThinLeft: "│",
120
121
  powerlineThinRight: "│",
122
+ batchOpen: "●",
121
123
  },
122
124
  ascii: {
123
125
  pi: "pi",
@@ -129,6 +131,7 @@ const GLYPHS = {
129
131
  powerlineRight: "<",
130
132
  powerlineThinLeft: "|",
131
133
  powerlineThinRight: "|",
134
+ batchOpen: "v",
132
135
  },
133
136
  } as const;
134
137
 
@@ -174,29 +174,23 @@ function prefixNative(lines: unknown, width: number, prefix: string): string[] |
174
174
  }
175
175
 
176
176
  export type MessageDecorationSnapshot = Readonly<{
177
- userPrefix: string;
178
177
  assistantPrefix: string;
179
- userEnabled: boolean;
180
178
  assistantEnabled: boolean;
181
179
  }>;
182
180
 
183
181
  export function decorateMessageRender(
184
- subtype: "native-user-message" | "native-assistant-message",
185
182
  original: unknown,
186
183
  instance: object,
187
184
  args: unknown[],
188
185
  snapshot: MessageDecorationSnapshot = {
189
- userPrefix: "❯ ",
190
186
  assistantPrefix: "│ ",
191
- userEnabled: true,
192
187
  assistantEnabled: true,
193
188
  },
194
189
  ): unknown {
195
190
  if (typeof original !== "function") return undefined;
196
191
  const width = typeof args[0] === "number" ? args[0] : 0;
197
- const enabled = subtype === "native-user-message" ? snapshot.userEnabled : snapshot.assistantEnabled;
198
- const prefix = subtype === "native-user-message" ? snapshot.userPrefix : snapshot.assistantPrefix;
199
- if (!enabled) return Reflect.apply(original, instance, args);
192
+ const prefix = snapshot.assistantPrefix;
193
+ if (!snapshot.assistantEnabled) return Reflect.apply(original, instance, args);
200
194
  if (width <= visibleWidth(prefix)) return Reflect.apply(original, instance, args);
201
195
  // Exactly one native invocation. If the reduced render cannot be certified, the
202
196
  // already-obtained result is the only safe fallback; retrying can mutate state.
@@ -13,8 +13,23 @@ import {
13
13
  renderBoxedToolCall,
14
14
  renderBoxedToolResult,
15
15
  replaceTabs,
16
+ shortenPath,
16
17
  } from "../../../shared/box.js";
17
18
  import { safeTruncateToWidth, truncateAtCodePointBoundary } from "../../../shared/render-budget.js";
19
+ import {
20
+ type GrepMatch,
21
+ groupMatchesByFile,
22
+ parseFindOutput,
23
+ parseGrepBareOutput,
24
+ parseGrepOutput,
25
+ parseLsLongOutput,
26
+ parseLsOutput,
27
+ pluralForm,
28
+ renderGrepTree,
29
+ renderOutputTree,
30
+ SEARCH_ICON,
31
+ TREE_INDENT,
32
+ } from "./output-tree.js";
18
33
  import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
19
34
  import { type BoxedToolContext, type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
20
35
 
@@ -304,13 +319,382 @@ function createBashResultPreview(
304
319
  };
305
320
  }
306
321
 
322
+ // ── ls/find/grep/rg command detection ───────────────────────────────────────
323
+ // A bash command whose real command is ls/find/grep/rg (after env assignments,
324
+ // sudo/env/time prefixes, and path stripping), with no shell metacharacters
325
+ // (pipes, redirects, `;`, `&&`, command substitution, subshells, newlines), is
326
+ // rendered as the same boxless output tree as the corresponding native tool.
327
+ // Everything else keeps the boxed command/response shell.
328
+
329
+ type BashTreeKind = "ls" | "find" | "grep";
330
+
331
+ interface BashTreeClass {
332
+ readonly kind: BashTreeKind;
333
+ readonly pattern?: string;
334
+ readonly pathLabel?: string;
335
+ /** grep: exactly one path positional — single-file output (`line: content`)
336
+ * is attributed to it. */
337
+ readonly singlePath?: string;
338
+ }
339
+
340
+ const BASH_PREFIX_COMMANDS = new Set(["sudo", "env", "time", "nice", "nohup", "command", "stdbuf", "ionice", "watch"]);
341
+ const BASH_GREP_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg"]);
342
+ // Pipes (`|`), `;`, and `&` are excluded here: the classifier validates them
343
+ // explicitly (allowing `cd X && cmd` chains and a trailing `| head/tail`).
344
+ const BASH_SHELL_META_CHARS = new Set(["<", ">", "(", ")", "`"]);
345
+
346
+ /** Tokenize a single command line, stripping quotes. Returns null on an
347
+ * unterminated quote. `hasMeta` is true if any shell metacharacter appears
348
+ * *outside* quotes (so `grep 'a|b' f` stays classifiable). */
349
+ function tokenizeCommandLine(line: string): { tokens: string[]; hasMeta: boolean } | null {
350
+ const tokens: string[] = [];
351
+ let current = "";
352
+ let inToken = false;
353
+ let quote: string | null = null;
354
+ let hasMeta = false;
355
+ for (let i = 0; i < line.length; i++) {
356
+ const char = line[i] ?? "";
357
+ if (quote) {
358
+ if (char === "\\" && quote === '"') {
359
+ current += line[++i] ?? "";
360
+ continue;
361
+ }
362
+ if (char === quote) {
363
+ quote = null;
364
+ continue;
365
+ }
366
+ current += char;
367
+ continue;
368
+ }
369
+ if (char === '"' || char === "'") {
370
+ quote = char;
371
+ inToken = true;
372
+ continue;
373
+ }
374
+ if (char === " " || char === "\t") {
375
+ if (inToken) {
376
+ tokens.push(current);
377
+ current = "";
378
+ inToken = false;
379
+ }
380
+ continue;
381
+ }
382
+ if (BASH_SHELL_META_CHARS.has(char) || (char === "$" && (line[i + 1] ?? "") === "(")) {
383
+ hasMeta = true;
384
+ continue;
385
+ }
386
+ current += char;
387
+ inToken = true;
388
+ }
389
+ if (quote) return null;
390
+ if (inToken) tokens.push(current);
391
+ return { tokens, hasMeta };
392
+ }
393
+
394
+ /** grep/rg flags that consume a separate value token (`--type ts`). */
395
+ const GREP_VALUE_FLAGS = new Set([
396
+ "-e",
397
+ "--regexp",
398
+ "-g",
399
+ "--glob",
400
+ "--type",
401
+ "-t",
402
+ "--include",
403
+ "--exclude",
404
+ "-C",
405
+ "-A",
406
+ "-B",
407
+ "--context",
408
+ "--after-context",
409
+ "--before-context",
410
+ "-m",
411
+ "--max-count",
412
+ "-M",
413
+ "--max-columns",
414
+ "--ignore-file",
415
+ ]);
416
+
417
+ /** find flags that consume a separate value token (`-type f`). */
418
+ const FIND_VALUE_FLAGS = new Set([
419
+ "-type",
420
+ "-mtime",
421
+ "-atime",
422
+ "-ctime",
423
+ "-size",
424
+ "-maxdepth",
425
+ "-mindepth",
426
+ "-perm",
427
+ "-group",
428
+ "-user",
429
+ "-newer",
430
+ ]);
431
+
432
+ function classifyByArgs(kind: BashTreeKind, args: string[]): BashTreeClass {
433
+ const positionals: string[] = [];
434
+ let pattern: string | undefined;
435
+ for (let i = 0; i < args.length; i++) {
436
+ const token = args[i] ?? "";
437
+ if (
438
+ (kind === "grep" && (token === "-e" || token === "--regexp")) ||
439
+ (kind === "find" && (token === "-name" || token === "-iname" || token === "-path" || token === "-ipath"))
440
+ ) {
441
+ pattern = args[++i];
442
+ continue;
443
+ }
444
+ if (kind === "grep" && GREP_VALUE_FLAGS.has(token)) {
445
+ i++; // skip the flag and its value
446
+ continue;
447
+ }
448
+ if (kind === "find" && FIND_VALUE_FLAGS.has(token)) {
449
+ i++; // skip the flag and its value
450
+ continue;
451
+ }
452
+ if (token.startsWith("-")) continue;
453
+ positionals.push(token);
454
+ }
455
+ const rawPath = positionals[0] ?? ".";
456
+ const pathLabel = rawPath === "." ? "current directory" : shortenPath(rawPath);
457
+ if (kind === "ls") return { kind, pathLabel };
458
+ if (kind === "find") return { kind, ...(pattern !== undefined ? { pattern } : {}), pathLabel };
459
+ const grepPattern = pattern ?? positionals[0];
460
+ const pathArgs = pattern !== undefined ? positionals : positionals.slice(1);
461
+ const grepPath = pathArgs.join(" ");
462
+ const grepPathLabel = !grepPath || grepPath === "." ? "current directory" : shortenPath(grepPath);
463
+ return {
464
+ kind,
465
+ ...(grepPattern !== undefined ? { pattern: grepPattern } : {}),
466
+ pathLabel: grepPathLabel,
467
+ ...(pathArgs.length === 1 ? { singlePath: pathArgs[0] ?? "" } : {}),
468
+ };
469
+ }
470
+
471
+ /** `head [-n N]` / `tail [-n N]` truncation pipe tail (allowed at the end). */
472
+ function isHeadOrTailTail(tokens: readonly string[]): boolean {
473
+ if (tokens.length === 0 || (tokens[0] !== "head" && tokens[0] !== "tail")) return false;
474
+ for (let i = 1; i < tokens.length; i++) {
475
+ const token = tokens[i] ?? "";
476
+ if (token === "-n") continue;
477
+ if (/^\d+$/.test(token)) continue;
478
+ if (/^-\d+$/.test(token)) continue;
479
+ return false;
480
+ }
481
+ return true;
482
+ }
483
+
484
+ /** Classify a bash command for tree rendering, or null to keep the boxed shell. */
485
+ export function classifyBashCommand(command: string): BashTreeClass | null {
486
+ const commandText = String(command ?? "").trim();
487
+ if (!commandText || commandText.includes("\n")) return null;
488
+ const tokenized = tokenizeCommandLine(commandText);
489
+ if (!tokenized || tokenized.hasMeta || tokenized.tokens.length === 0) return null;
490
+ let tokens = tokenized.tokens;
491
+
492
+ // Allow a single trailing truncation pipe: `cmd | head [-n] N` / `| tail …`.
493
+ const pipes = tokens.flatMap((token, i) => (token === "|" ? [i] : []));
494
+ if (pipes.length > 0) {
495
+ if (pipes.length > 1) return null;
496
+ const last = pipes[0] ?? -1;
497
+ if (!isHeadOrTailTail(tokens.slice(last + 1))) return null;
498
+ tokens = tokens.slice(0, last);
499
+ }
500
+
501
+ let index = 0;
502
+ // Skip leading environment assignments (FOO=bar ...) and prefix commands.
503
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? "")) index++;
504
+ while (index < tokens.length && BASH_PREFIX_COMMANDS.has(tokens[index] ?? "")) index++;
505
+ // `cd <dir> &&` / `cd <dir>;` chains: the last directory becomes the default
506
+ // path when the command itself carries none.
507
+ let cdDir: string | undefined;
508
+ while (
509
+ tokens[index] === "cd" &&
510
+ index + 2 < tokens.length &&
511
+ tokens[index + 1] !== undefined &&
512
+ (tokens[index + 2] === "&&" || tokens[index + 2] === ";")
513
+ ) {
514
+ cdDir = tokens[index + 1];
515
+ index += 3;
516
+ }
517
+ const rest = tokens.slice(index);
518
+ if (rest.length === 0 || rest.some((token) => token === "&&" || token === ";" || token === "&")) return null;
519
+
520
+ const base = (rest[0] ?? "").split("/").pop() ?? "";
521
+ let kind: BashTreeKind | null = null;
522
+ if (base === "ls") kind = "ls";
523
+ else if (base === "find") kind = "find";
524
+ else if (BASH_GREP_COMMANDS.has(base)) kind = "grep";
525
+ if (!kind) return null;
526
+
527
+ const cls = classifyByArgs(kind, rest.slice(1));
528
+ if (cdDir && cls.pathLabel === "current directory") {
529
+ return {
530
+ kind,
531
+ ...(cls.pattern !== undefined ? { pattern: cls.pattern } : {}),
532
+ pathLabel: shortenPath(cdDir),
533
+ ...(cls.singlePath !== undefined ? { singlePath: cls.singlePath } : {}),
534
+ };
535
+ }
536
+ return cls;
537
+ }
538
+
539
+ function bashTreeHeader(theme: BoxTheme, cls: BashTreeClass, counts?: { files?: number; matches?: number }): string {
540
+ const label = cls.kind === "find" ? "Glob" : cls.kind === "ls" ? "List" : "Grep";
541
+ const hasDetail = Boolean(cls.pattern) || Boolean(counts);
542
+ // ls/find/grep headers carry the magnifying-glass icon in Nerd Font mode.
543
+ const icon = getToolsRenderConfig().nerdFonts ? `${SEARCH_ICON} ` : "";
544
+ const prefix = icon + (hasDetail ? `${label}:` : label);
545
+ const patternPart = cls.pattern ? ` ${theme.fg("text", cls.pattern)}` : "";
546
+ let middle = "";
547
+ if (counts) {
548
+ if (cls.kind === "grep") {
549
+ const matches = counts.matches ?? 0;
550
+ const files = counts.files ?? 0;
551
+ middle = ` ${theme.fg("accent", `${matches} ${pluralForm("match", matches)}`)}${theme.fg("dim", ` · ${files} ${pluralForm("file", files)}`)}`;
552
+ } else {
553
+ const files = counts.files ?? 0;
554
+ middle = ` ${theme.fg("accent", `${files} ${pluralForm("file", files)}`)}`;
555
+ }
556
+ }
557
+ const pathPart =
558
+ cls.pathLabel && cls.pathLabel !== "current directory" ? theme.fg("dim", ` · in ${cls.pathLabel}`) : "";
559
+ return `${typeof theme?.bold === "function" ? theme.bold(prefix) : prefix}${patternPart}${middle}${pathPart}`;
560
+ }
561
+
562
+ /** `ls -l` long-format lines (permissions block) can't be parsed into names
563
+ * reliably; fall back to the boxed shell for those. A leading `total N`
564
+ * summary line is skipped before the check. */
565
+ function isLongFormatLs(text: string): boolean {
566
+ const first = text
567
+ .split("\n")
568
+ .map((line) => line.trim())
569
+ .find((line) => line.length > 0 && !/^total\s+\d+$/i.test(line));
570
+ return Boolean(first) && /^[bcdlsp-][rwxtsST-]{9}[\s@]/.test(first as string);
571
+ }
572
+
573
+ /** Parsed bash tree output, or null to fall back to the boxed shell
574
+ * (long-format ls, unparseable grep). */
575
+ type ParsedBashTree = { entries: string[] } | { matches: GrepMatch[] };
576
+
577
+ function parseBashTreeOutput(cls: BashTreeClass, output: string): ParsedBashTree | null {
578
+ if (cls.kind === "ls") {
579
+ // `ls -l`/`ls -la` long format is parsed into names (with `/` for dirs)
580
+ // so bash listings render like the List tool tree.
581
+ if (isLongFormatLs(output)) return { entries: parseLsLongOutput(output) };
582
+ return { entries: parseLsOutput(output) };
583
+ }
584
+ if (cls.kind === "find") return { entries: parseFindOutput(output) };
585
+ const matches = parseGrepOutput(output);
586
+ if (matches.length === 0 && output.trim().length > 0) {
587
+ // Single-file `rg`/`grep` output is `line: content` with no filename:
588
+ // attribute matches to the command's single path argument.
589
+ if (cls.singlePath) {
590
+ const bare = parseGrepBareOutput(output, cls.singlePath);
591
+ if (bare.length > 0) return { matches: bare };
592
+ }
593
+ return null;
594
+ }
595
+ return { matches };
596
+ }
597
+
598
+ interface BashTreeState {
599
+ readonly cls: BashTreeClass;
600
+ /** Raw command, so the call panel can render the boxed bash call on fallback. */
601
+ readonly command: string;
602
+ /** `parsed` once the result arrives; `fallback` when the boxed shell takes over. */
603
+ parsed?: ParsedBashTree;
604
+ fallback?: boolean;
605
+ }
606
+
607
+ const bashTreeStates = new Map<string, BashTreeState>();
608
+
609
+ /** Reset all bash tree state (session start/shutdown, new message). */
610
+ export function resetBashTreeRegistry(): void {
611
+ bashTreeStates.clear();
612
+ }
613
+
614
+ function renderBashTreeLines(theme: BoxTheme, state: BashTreeState, width: number): string[] {
615
+ const safeWidth = Math.max(1, width);
616
+ const cls = state.cls;
617
+ if (state.parsed && "entries" in state.parsed) {
618
+ const entries = state.parsed.entries;
619
+ return renderOutputTree(theme, bashTreeHeader(theme, cls, { files: entries.length }), entries, safeWidth, {
620
+ moreUnit: "file",
621
+ indent: TREE_INDENT,
622
+ withIcons: getToolsRenderConfig().nerdFonts,
623
+ });
624
+ }
625
+ if (state.parsed && "matches" in state.parsed) {
626
+ const matches = state.parsed.matches;
627
+ return renderGrepTree(
628
+ theme,
629
+ bashTreeHeader(theme, cls, { matches: matches.length, files: groupMatchesByFile(matches).length }),
630
+ matches,
631
+ safeWidth,
632
+ { indent: TREE_INDENT, withIcons: getToolsRenderConfig().nerdFonts },
633
+ );
634
+ }
635
+ return [safeTruncateToWidth(bashTreeHeader(theme, cls), safeWidth, "…")];
636
+ }
637
+
638
+ /** Empty result component — the tree lives in the call panel, which re-renders
639
+ * with the parsed output once the result arrives. */
640
+ const EMPTY_BASH_TREE_RESULT: Component = {
641
+ invalidate() {},
642
+ render() {
643
+ return [];
644
+ },
645
+ };
646
+
647
+ /** Live panel component for a classified bash command: pending header until the
648
+ * result arrives, then the parsed output tree. When the result falls back to
649
+ * the boxed shell, the call renders the boxed bash call instead, so call and
650
+ * result form one complete box and never duplicate. The state reference is
651
+ * captured at creation so a registry clear on session reset/resume does not
652
+ * blank already-rendered panels. */
653
+ function renderBashTreePanel(theme: BoxTheme, toolCallId: string, context: BoxedToolContext): Component {
654
+ const state = bashTreeStates.get(toolCallId);
655
+ return {
656
+ invalidate() {},
657
+ render(width: number): string[] {
658
+ if (!state) return [];
659
+ if (state.fallback) {
660
+ return renderBoxedBashCall(
661
+ theme,
662
+ state.command.split("\n"),
663
+ context,
664
+ bashWidthKey(state.command, context?.args?.timeout),
665
+ ).render(width);
666
+ }
667
+ return renderBashTreeLines(theme, state, width);
668
+ },
669
+ };
670
+ }
671
+
307
672
  export const bashTool: BoxedToolDefinition = {
308
673
  call(args, theme, context) {
309
674
  noteExecutionStart(context);
675
+ const cls = classifyBashCommand(String(args?.command ?? ""));
676
+ if (cls) {
677
+ bashTreeStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
678
+ return renderBashTreePanel(theme, context.toolCallId, context);
679
+ }
310
680
  const rawCommand = String(args?.command ?? "...");
311
681
  return renderBoxedBashCall(theme, rawCommand.split("\n"), context, bashWidthKey(rawCommand, args?.timeout));
312
682
  },
313
683
  result(result, options, theme, context) {
684
+ const cls = classifyBashCommand(String(context?.args?.command ?? ""));
685
+ if (cls && !context.isError) {
686
+ const output = stripBashToolNoticeLines(stripAnsi(getTextOutput(result)));
687
+ const parsed = parseBashTreeOutput(cls, output);
688
+ const state = bashTreeStates.get(context.toolCallId);
689
+ if (parsed) {
690
+ if (state) state.parsed = parsed;
691
+ else bashTreeStates.set(context.toolCallId, { cls, command: String(context?.args?.command ?? ""), parsed });
692
+ return EMPTY_BASH_TREE_RESULT;
693
+ }
694
+ // Unparseable output (ls -l, raw rg summary): the boxed shell owns the
695
+ // result; flag the call panel to render nothing so the two don't duplicate.
696
+ if (state) state.fallback = true;
697
+ }
314
698
  const raw = getTextOutput(result);
315
699
  const outputColor = context.isError ? "error" : "toolOutput";
316
700