@quandev104/pi-style 0.1.2 → 0.1.4

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 (36) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -2
  3. package/dist/extensions/pi-style.js +5919 -4966
  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/status.ts +1 -1
  11. package/extension-src/pi-style/domain/theme.ts +3 -0
  12. package/extension-src/pi-style/features/messages/index.ts +2 -8
  13. package/extension-src/pi-style/features/tools/boxed/bash.ts +663 -45
  14. package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
  15. package/extension-src/pi-style/features/tools/boxed/edit.ts +28 -2
  16. package/extension-src/pi-style/features/tools/boxed/fallback.ts +47 -4
  17. package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
  18. package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
  19. package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
  20. package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
  21. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
  22. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
  23. package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
  24. package/extension-src/pi-style/features/tools/boxed/session-config.ts +70 -4
  25. package/extension-src/pi-style/features/tools/boxed/shared.ts +41 -1
  26. package/extension-src/pi-style/features/tools/boxed/write.ts +105 -47
  27. package/extension-src/pi-style/features/tools/index.ts +14 -0
  28. package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
  29. package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
  30. package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
  31. package/extension-src/pi-style/pi/config-session.ts +0 -2
  32. package/extension-src/pi-style/pi/index.ts +35 -1
  33. package/extension-src/pi-style/pi/session-coordinator.ts +47 -3
  34. package/extension-src/pi-style/shared/box.ts +117 -26
  35. package/extension-src/pi-style/shared/theme-extras.ts +0 -2
  36. package/package.json +1 -1
@@ -7,16 +7,40 @@ import { stripAnsi } from "../../../shared/ansi.js";
7
7
  import type { BoxTheme } from "../../../shared/box.js";
8
8
  import {
9
9
  boxedToolWidthKey,
10
- formatBoxedFooter,
10
+ formatBoxedRunningStatus,
11
+ formatBoxedWords,
11
12
  formatToolOutputLine,
12
13
  getTextOutput,
13
14
  renderBoxedToolCall,
14
15
  renderBoxedToolResult,
15
16
  replaceTabs,
17
+ shortenPath,
16
18
  } from "../../../shared/box.js";
17
19
  import { safeTruncateToWidth, truncateAtCodePointBoundary } from "../../../shared/render-budget.js";
18
- import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
19
- import { type BoxedToolContext, type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
20
+ import {
21
+ type GrepMatch,
22
+ groupMatchesByFile,
23
+ parseFindOutput,
24
+ parseGrepBareOutput,
25
+ parseGrepOutput,
26
+ parseLsLongOutput,
27
+ parseLsOutput,
28
+ pluralForm,
29
+ renderGrepTree,
30
+ renderOutputTree,
31
+ SEARCH_ICON,
32
+ TREE_INDENT,
33
+ } from "./output-tree.js";
34
+ import {
35
+ getStateElapsedMs,
36
+ getToolsRenderConfig,
37
+ isResultSeen,
38
+ markResultSeen,
39
+ recordExecutionEnded,
40
+ startElapsedTicker,
41
+ stopElapsedTicker,
42
+ } from "./session-config.js";
43
+ import { type BoxedToolContext, type BoxedToolDefinition, noteBoxedCallState, noteExecutionStart } from "./shared.js";
20
44
 
21
45
  const MAX_LINE_CHARS = 2000;
22
46
  const ESC = "\x1b";
@@ -167,44 +191,271 @@ function renderBoxedBashCall(
167
191
  if (commandLines.length > maxCommandLines + 1) {
168
192
  detailLines.push(theme.fg("muted", `... ${commandLines.length - maxCommandLines - 1} more lines`));
169
193
  }
170
- return renderBoxedToolCall(theme, "Bash", detailLines, {
194
+ const running = Boolean(context.executionStarted);
195
+ const resultSeen = isResultSeen(context.state);
196
+ const base = {
171
197
  widthKey,
172
198
  isError: Boolean(context.isError),
173
199
  isPartial: Boolean(context.isPartial),
174
200
  isPending: Boolean(context.isPartial),
175
- });
201
+ running,
202
+ };
203
+ if (running && context.isPartial && !resultSeen) {
204
+ // Pre-result running card: the call closes the box with a live running
205
+ // footer and a `No output received yet` line. The first partial result
206
+ // renders nothing, so this card is never duplicated below.
207
+ detailLines.push(theme.fg("dim", "No output received yet"));
208
+ return renderBoxedToolCall(theme, "Bash", detailLines, {
209
+ ...base,
210
+ pendingLabel: formatBoxedRunningStatus(theme, getStateElapsedMs(context.state)),
211
+ });
212
+ }
213
+ // Streaming (a result renderer already continues this box) and terminal
214
+ // (settled) passes leave the box open so the result closes it.
215
+ return renderBoxedToolCall(theme, "Bash", detailLines, { ...base, resultSeen });
216
+ }
217
+
218
+ // ── Terminal status detection ────────────────────────────────────────────────
219
+ // The bash tool appends a `\n\n<status>` suffix to failed results (nonzero exit,
220
+ // timeout, abort). Parse it off the raw text so the footer can carry the real
221
+ // status instead of displaying the suffix as output.
222
+
223
+ type BashTerminalStatus =
224
+ | { kind: "exit"; exitCode: number }
225
+ | { kind: "timeout"; seconds: number }
226
+ | { kind: "cancelled" };
227
+
228
+ const BASH_STATUS_PATTERNS: ReadonlyArray<{
229
+ re: RegExp;
230
+ build: (match: RegExpMatchArray) => BashTerminalStatus;
231
+ }> = [
232
+ {
233
+ re: /(?:^|\n\n)Command timed out after ([\d.]+) seconds$/i,
234
+ build: (match) => ({ kind: "timeout", seconds: Number(match[1]) }),
235
+ },
236
+ { re: /(?:^|\n\n)[^\n]*aborted$/i, build: () => ({ kind: "cancelled" }) },
237
+ {
238
+ re: /(?:^|\n\n)Command exited with code (\d+)$/i,
239
+ build: (match) => ({ kind: "exit", exitCode: Number(match[1]) }),
240
+ },
241
+ ];
242
+
243
+ function parseBashTerminalStatus(text: string): { status: BashTerminalStatus | undefined; body: string } {
244
+ const clean = String(text ?? "").replace(/\r/g, "");
245
+ for (const { re, build } of BASH_STATUS_PATTERNS) {
246
+ const match = clean.match(re);
247
+ if (match && match.index !== undefined) {
248
+ return { status: build(match), body: clean.slice(0, match.index).trimEnd() };
249
+ }
250
+ }
251
+ // Pi's message_end error path (agent aborted) sends a bare status text with
252
+ // no bash output shape; recognize it as a cancelled state.
253
+ if (/^(?:operation )?aborted(?: after \d+ retry attempts?)?$/i.test(clean.trim())) {
254
+ return { status: { kind: "cancelled" }, body: "" };
255
+ }
256
+ return { status: undefined, body: clean };
257
+ }
258
+
259
+ function bashErrorLabel(status: BashTerminalStatus | undefined): string | undefined {
260
+ if (status?.kind === "timeout") return "✗ Timed out";
261
+ if (status?.kind === "cancelled") return "✗ Cancelled";
262
+ return undefined;
263
+ }
264
+
265
+ /** Body text shown when a terminal bash result produced no output. */
266
+ function bashEmptyBodyText(status: BashTerminalStatus | undefined, isError: boolean): string {
267
+ if (status?.kind === "timeout") return "No output was received before the timeout";
268
+ if (status?.kind === "cancelled") return "Command was cancelled without producing output";
269
+ if (isError) return "Command failed without producing output";
270
+ return "Command completed without producing output";
271
+ }
272
+
273
+ function bashFooter(
274
+ theme: BoxTheme,
275
+ status: BashTerminalStatus | undefined,
276
+ elapsedMs: number | undefined,
277
+ bodyText: string,
278
+ isError: boolean,
279
+ ): string {
280
+ const elapsed = elapsedMs === undefined ? "--" : `${(elapsedMs / 1000).toFixed(2)}s`;
281
+ const words = bodyText.trim() ? formatBoxedWords(bodyText) : "";
282
+
283
+ if (status?.kind === "timeout") {
284
+ const seconds = Number.isFinite(status.seconds) && status.seconds > 0 ? status.seconds : Number.NaN;
285
+ return theme.fg(
286
+ "warning",
287
+ Number.isFinite(seconds) ? `Terminated after ${seconds.toFixed(1)}s` : "Terminated by timeout",
288
+ );
289
+ }
290
+ if (status?.kind === "cancelled") {
291
+ return [theme.fg("warning", "Cancelled"), theme.fg("text", elapsed)].join(theme.fg("dim", " · "));
292
+ }
293
+
294
+ const exitLabel = status?.kind === "exit" ? `Exit ${status.exitCode}` : isError ? "Failed" : "Exit 0";
295
+ const exitColor = status?.kind === "exit" && status.exitCode !== 0 ? "error" : "text";
296
+ const parts = [theme.fg(exitColor, exitLabel), theme.fg("text", elapsed)];
297
+ if (words) parts.push(theme.fg("dim", words));
298
+ return parts.join(theme.fg("dim", " · "));
176
299
  }
177
300
 
178
- function formatTimeout(context: BoxedToolContext): string {
179
- const timeout = context?.args?.timeout ?? 300;
180
- return `${timeout}s`;
301
+ // ── Interactive command heuristics ───────────────────────────────────────────
302
+ // Terminal programs that read stdin or own the screen produce no pipe output;
303
+ // when one runs silently we hint that it may be waiting for terminal input.
304
+
305
+ const INTERACTIVE_COMMANDS = new Set([
306
+ "pi",
307
+ "vim",
308
+ "vi",
309
+ "nvim",
310
+ "nano",
311
+ "less",
312
+ "more",
313
+ "man",
314
+ "top",
315
+ "htop",
316
+ "btop",
317
+ "ssh",
318
+ "telnet",
319
+ "python",
320
+ "python3",
321
+ "node",
322
+ "sqlite3",
323
+ "mysql",
324
+ "psql",
325
+ "redis-cli",
326
+ "mongosh",
327
+ "bc",
328
+ "irssi",
329
+ ]);
330
+
331
+ function isInteractiveCommand(command: unknown): boolean {
332
+ const base =
333
+ (
334
+ String(command ?? "")
335
+ .trim()
336
+ .split(/\s+/)[0] ?? ""
337
+ )
338
+ .split("/")
339
+ .pop() ?? "";
340
+ return INTERACTIVE_COMMANDS.has(base);
181
341
  }
182
342
 
183
- function renderBoxedBashResult(
343
+ /** Wrap an output preview so an empty result renders state text instead of `∅`. */
344
+ function bashBodyComponent(preview: Component, emptyLines: string[] | undefined): Component {
345
+ if (!emptyLines) return preview;
346
+ return {
347
+ invalidate: () => preview.invalidate(),
348
+ render(width: number): string[] {
349
+ const lines = preview.render(width);
350
+ return lines.length > 0 ? lines : emptyLines;
351
+ },
352
+ };
353
+ }
354
+
355
+ /** Streaming continuation: streamed output (or `No output received yet`), a
356
+ * live running footer, and no `Response` divider until the tool settles. */
357
+ function renderBashStreamingResult(
184
358
  theme: BoxTheme,
185
- inner: Component,
186
- result: unknown,
359
+ raw: string,
360
+ options: { expanded: boolean },
187
361
  context: BoxedToolContext,
188
- expandHint?: string,
189
362
  ): Component {
363
+ const body = stripBashToolNoticeLines(stripAnsi(raw));
364
+ const hasOutput = body.trim().length > 0;
365
+ const elapsed = getStateElapsedMs(context.state);
366
+ const emptyLines: string[] = [theme.fg("dim", "No output received yet")];
367
+ if (!hasOutput && isInteractiveCommand(context?.args?.command) && (elapsed ?? 0) >= 1000) {
368
+ emptyLines.push(theme.fg("dim", "The process may be waiting for terminal input"));
369
+ }
370
+ const preview = createBashResultPreview(theme, body, options, "toolOutput");
190
371
  const rawCommand = String(context?.args?.command ?? "...");
191
- const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
192
- return renderBoxedToolResult(theme, inner, {
372
+ return renderBoxedToolResult(theme, bashBodyComponent(preview, hasOutput ? undefined : emptyLines), {
193
373
  widthKey: bashWidthKey(rawCommand, context?.args?.timeout),
194
- referenceLines,
195
- footerLines: [
196
- formatBoxedFooter(theme, result as never, [`timeout ${formatTimeout(context)}`], getElapsed(context)),
197
- ],
198
- ...(expandHint ? { expandHint } : {}),
199
- isError: context.isError,
200
- isPartial: Boolean(context.isPartial),
374
+ referenceLines: rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`),
375
+ dividerLabel: "Output",
376
+ showDivider: hasOutput,
377
+ footerLines: [formatBoxedRunningStatus(theme, elapsed)],
378
+ isPartial: true,
201
379
  });
202
380
  }
203
381
 
204
- function getElapsed(context: BoxedToolContext): number | undefined {
205
- return getStateElapsedMs(context.state);
382
+ function renderBashFinalResult(
383
+ theme: BoxTheme,
384
+ raw: string,
385
+ options: { expanded: boolean },
386
+ context: BoxedToolContext,
387
+ ): Component {
388
+ const isError = Boolean(context.isError);
389
+ const clean = stripAnsi(raw);
390
+ const { status, body: statusStripped } = parseBashTerminalStatus(clean);
391
+ const output = stripBashToolNoticeLines(statusStripped);
392
+ const elapsed = getStateElapsedMs(context.state);
393
+ const outputColor = isError ? "error" : "toolOutput";
394
+ const footer = bashFooter(theme, status, elapsed, output, isError);
395
+ const errorLabel = isError ? (bashErrorLabel(status) ?? "✗ Error") : undefined;
396
+
397
+ const rawCommand = String(context?.args?.command ?? "...");
398
+ const widthKey = bashWidthKey(rawCommand, context?.args?.timeout);
399
+ const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
400
+
401
+ if (!options.expanded) {
402
+ // Collapsed: only process the tail of the output (notices stripped per line).
403
+ const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
404
+ let nlCount = 0;
405
+ let tailStart = 0;
406
+ for (let i = statusStripped.length - 1; i >= 0; i--) {
407
+ if (statusStripped.charCodeAt(i) === 10) {
408
+ nlCount++;
409
+ if (nlCount >= scanLines) {
410
+ tailStart = i + 1;
411
+ break;
412
+ }
413
+ }
414
+ }
415
+ const tail = stripBashToolNoticeLines(stripAnsi(statusStripped.slice(tailStart)));
416
+ const totalLinesBefore = tailStart > 0 ? countNewlines(statusStripped, 0, tailStart) : 0;
417
+ const preview = createBashResultPreview(theme, tail, options, outputColor);
418
+ return renderBoxedToolResult(
419
+ theme,
420
+ bashBodyComponent(
421
+ preview,
422
+ statusStripped.trim() ? undefined : [theme.fg("muted", bashEmptyBodyText(status, isError))],
423
+ ),
424
+ {
425
+ widthKey,
426
+ referenceLines,
427
+ footerLines: [footer],
428
+ ...(totalLinesBefore > 0 ? { expandHint: "Ctrl+O for more" } : {}),
429
+ isError,
430
+ isPartial: false,
431
+ ...(errorLabel ? { errorLabel } : {}),
432
+ },
433
+ );
434
+ }
435
+
436
+ const preview = createBashResultPreview(theme, output, options, outputColor);
437
+ return renderBoxedToolResult(
438
+ theme,
439
+ bashBodyComponent(preview, output.trim() ? undefined : [theme.fg("muted", bashEmptyBodyText(status, isError))]),
440
+ {
441
+ widthKey,
442
+ referenceLines,
443
+ footerLines: [footer],
444
+ isError,
445
+ isPartial: false,
446
+ ...(errorLabel ? { errorLabel } : {}),
447
+ },
448
+ );
206
449
  }
207
450
 
451
+ /** First-partial-pass result: the pending/running call card stands alone. */
452
+ const EMPTY_BASH_RESULT: Component = Object.freeze({
453
+ invalidate() {},
454
+ render() {
455
+ return [];
456
+ },
457
+ });
458
+
208
459
  function createBashResultPreview(
209
460
  theme: BoxTheme,
210
461
  text: string,
@@ -304,36 +555,403 @@ function createBashResultPreview(
304
555
  };
305
556
  }
306
557
 
558
+ // ── ls/find/grep/rg command detection ───────────────────────────────────────
559
+ // A bash command whose real command is ls/find/grep/rg (after env assignments,
560
+ // sudo/env/time prefixes, and path stripping), with no shell metacharacters
561
+ // (pipes, redirects, `;`, `&&`, command substitution, subshells, newlines), is
562
+ // rendered as the same boxless output tree as the corresponding native tool.
563
+ // Everything else keeps the boxed command/response shell.
564
+
565
+ type BashTreeKind = "ls" | "find" | "grep";
566
+
567
+ interface BashTreeClass {
568
+ readonly kind: BashTreeKind;
569
+ readonly pattern?: string;
570
+ readonly pathLabel?: string;
571
+ /** grep: exactly one path positional — single-file output (`line: content`)
572
+ * is attributed to it. */
573
+ readonly singlePath?: string;
574
+ }
575
+
576
+ const BASH_PREFIX_COMMANDS = new Set(["sudo", "env", "time", "nice", "nohup", "command", "stdbuf", "ionice", "watch"]);
577
+ const BASH_GREP_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg"]);
578
+ // Pipes (`|`), `;`, and `&` are excluded here: the classifier validates them
579
+ // explicitly (allowing `cd X && cmd` chains and a trailing `| head/tail`).
580
+ const BASH_SHELL_META_CHARS = new Set(["<", ">", "(", ")", "`"]);
581
+
582
+ /** Tokenize a single command line, stripping quotes. Returns null on an
583
+ * unterminated quote. `hasMeta` is true if any shell metacharacter appears
584
+ * *outside* quotes (so `grep 'a|b' f` stays classifiable). */
585
+ function tokenizeCommandLine(line: string): { tokens: string[]; hasMeta: boolean } | null {
586
+ const tokens: string[] = [];
587
+ let current = "";
588
+ let inToken = false;
589
+ let quote: string | null = null;
590
+ let hasMeta = false;
591
+ for (let i = 0; i < line.length; i++) {
592
+ const char = line[i] ?? "";
593
+ if (quote) {
594
+ if (char === "\\" && quote === '"') {
595
+ current += line[++i] ?? "";
596
+ continue;
597
+ }
598
+ if (char === quote) {
599
+ quote = null;
600
+ continue;
601
+ }
602
+ current += char;
603
+ continue;
604
+ }
605
+ if (char === '"' || char === "'") {
606
+ quote = char;
607
+ inToken = true;
608
+ continue;
609
+ }
610
+ if (char === " " || char === "\t") {
611
+ if (inToken) {
612
+ tokens.push(current);
613
+ current = "";
614
+ inToken = false;
615
+ }
616
+ continue;
617
+ }
618
+ if (BASH_SHELL_META_CHARS.has(char) || (char === "$" && (line[i + 1] ?? "") === "(")) {
619
+ hasMeta = true;
620
+ continue;
621
+ }
622
+ current += char;
623
+ inToken = true;
624
+ }
625
+ if (quote) return null;
626
+ if (inToken) tokens.push(current);
627
+ return { tokens, hasMeta };
628
+ }
629
+
630
+ /** grep/rg flags that consume a separate value token (`--type ts`). */
631
+ const GREP_VALUE_FLAGS = new Set([
632
+ "-e",
633
+ "--regexp",
634
+ "-g",
635
+ "--glob",
636
+ "--type",
637
+ "-t",
638
+ "--include",
639
+ "--exclude",
640
+ "-C",
641
+ "-A",
642
+ "-B",
643
+ "--context",
644
+ "--after-context",
645
+ "--before-context",
646
+ "-m",
647
+ "--max-count",
648
+ "-M",
649
+ "--max-columns",
650
+ "--ignore-file",
651
+ ]);
652
+
653
+ /** find flags that consume a separate value token (`-type f`). */
654
+ const FIND_VALUE_FLAGS = new Set([
655
+ "-type",
656
+ "-mtime",
657
+ "-atime",
658
+ "-ctime",
659
+ "-size",
660
+ "-maxdepth",
661
+ "-mindepth",
662
+ "-perm",
663
+ "-group",
664
+ "-user",
665
+ "-newer",
666
+ ]);
667
+
668
+ function classifyByArgs(kind: BashTreeKind, args: string[]): BashTreeClass {
669
+ const positionals: string[] = [];
670
+ let pattern: string | undefined;
671
+ for (let i = 0; i < args.length; i++) {
672
+ const token = args[i] ?? "";
673
+ if (
674
+ (kind === "grep" && (token === "-e" || token === "--regexp")) ||
675
+ (kind === "find" && (token === "-name" || token === "-iname" || token === "-path" || token === "-ipath"))
676
+ ) {
677
+ pattern = args[++i];
678
+ continue;
679
+ }
680
+ if (kind === "grep" && GREP_VALUE_FLAGS.has(token)) {
681
+ i++; // skip the flag and its value
682
+ continue;
683
+ }
684
+ if (kind === "find" && FIND_VALUE_FLAGS.has(token)) {
685
+ i++; // skip the flag and its value
686
+ continue;
687
+ }
688
+ if (token.startsWith("-")) continue;
689
+ positionals.push(token);
690
+ }
691
+ const rawPath = positionals[0] ?? ".";
692
+ const pathLabel = rawPath === "." ? "current directory" : shortenPath(rawPath);
693
+ if (kind === "ls") return { kind, pathLabel };
694
+ if (kind === "find") return { kind, ...(pattern !== undefined ? { pattern } : {}), pathLabel };
695
+ const grepPattern = pattern ?? positionals[0];
696
+ const pathArgs = pattern !== undefined ? positionals : positionals.slice(1);
697
+ const grepPath = pathArgs.join(" ");
698
+ const grepPathLabel = !grepPath || grepPath === "." ? "current directory" : shortenPath(grepPath);
699
+ return {
700
+ kind,
701
+ ...(grepPattern !== undefined ? { pattern: grepPattern } : {}),
702
+ pathLabel: grepPathLabel,
703
+ ...(pathArgs.length === 1 ? { singlePath: pathArgs[0] ?? "" } : {}),
704
+ };
705
+ }
706
+
707
+ /** `head [-n N]` / `tail [-n N]` truncation pipe tail (allowed at the end). */
708
+ function isHeadOrTailTail(tokens: readonly string[]): boolean {
709
+ if (tokens.length === 0 || (tokens[0] !== "head" && tokens[0] !== "tail")) return false;
710
+ for (let i = 1; i < tokens.length; i++) {
711
+ const token = tokens[i] ?? "";
712
+ if (token === "-n") continue;
713
+ if (/^\d+$/.test(token)) continue;
714
+ if (/^-\d+$/.test(token)) continue;
715
+ return false;
716
+ }
717
+ return true;
718
+ }
719
+
720
+ /** Classify a bash command for tree rendering, or null to keep the boxed shell. */
721
+ export function classifyBashCommand(command: string): BashTreeClass | null {
722
+ const commandText = String(command ?? "").trim();
723
+ if (!commandText || commandText.includes("\n")) return null;
724
+ const tokenized = tokenizeCommandLine(commandText);
725
+ if (!tokenized || tokenized.hasMeta || tokenized.tokens.length === 0) return null;
726
+ let tokens = tokenized.tokens;
727
+
728
+ // Allow a single trailing truncation pipe: `cmd | head [-n] N` / `| tail …`.
729
+ const pipes = tokens.flatMap((token, i) => (token === "|" ? [i] : []));
730
+ if (pipes.length > 0) {
731
+ if (pipes.length > 1) return null;
732
+ const last = pipes[0] ?? -1;
733
+ if (!isHeadOrTailTail(tokens.slice(last + 1))) return null;
734
+ tokens = tokens.slice(0, last);
735
+ }
736
+
737
+ let index = 0;
738
+ // Skip leading environment assignments (FOO=bar ...) and prefix commands.
739
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? "")) index++;
740
+ while (index < tokens.length && BASH_PREFIX_COMMANDS.has(tokens[index] ?? "")) index++;
741
+ // `cd <dir> &&` / `cd <dir>;` chains: the last directory becomes the default
742
+ // path when the command itself carries none.
743
+ let cdDir: string | undefined;
744
+ while (
745
+ tokens[index] === "cd" &&
746
+ index + 2 < tokens.length &&
747
+ tokens[index + 1] !== undefined &&
748
+ (tokens[index + 2] === "&&" || tokens[index + 2] === ";")
749
+ ) {
750
+ cdDir = tokens[index + 1];
751
+ index += 3;
752
+ }
753
+ const rest = tokens.slice(index);
754
+ if (rest.length === 0 || rest.some((token) => token === "&&" || token === ";" || token === "&")) return null;
755
+
756
+ const base = (rest[0] ?? "").split("/").pop() ?? "";
757
+ let kind: BashTreeKind | null = null;
758
+ if (base === "ls") kind = "ls";
759
+ else if (base === "find") kind = "find";
760
+ else if (BASH_GREP_COMMANDS.has(base)) kind = "grep";
761
+ if (!kind) return null;
762
+
763
+ const cls = classifyByArgs(kind, rest.slice(1));
764
+ if (cdDir && cls.pathLabel === "current directory") {
765
+ return {
766
+ kind,
767
+ ...(cls.pattern !== undefined ? { pattern: cls.pattern } : {}),
768
+ pathLabel: shortenPath(cdDir),
769
+ ...(cls.singlePath !== undefined ? { singlePath: cls.singlePath } : {}),
770
+ };
771
+ }
772
+ return cls;
773
+ }
774
+
775
+ function bashTreeHeader(theme: BoxTheme, cls: BashTreeClass, counts?: { files?: number; matches?: number }): string {
776
+ const label = cls.kind === "find" ? "Glob" : cls.kind === "ls" ? "List" : "Grep";
777
+ const hasDetail = Boolean(cls.pattern) || Boolean(counts);
778
+ // ls/find/grep headers carry the magnifying-glass icon in Nerd Font mode.
779
+ const icon = getToolsRenderConfig().nerdFonts ? `${SEARCH_ICON} ` : "";
780
+ const prefix = icon + (hasDetail ? `${label}:` : label);
781
+ const patternPart = cls.pattern ? ` ${theme.fg("text", cls.pattern)}` : "";
782
+ let middle = "";
783
+ if (counts) {
784
+ if (cls.kind === "grep") {
785
+ const matches = counts.matches ?? 0;
786
+ const files = counts.files ?? 0;
787
+ middle = ` ${theme.fg("accent", `${matches} ${pluralForm("match", matches)}`)}${theme.fg("dim", ` · ${files} ${pluralForm("file", files)}`)}`;
788
+ } else {
789
+ const files = counts.files ?? 0;
790
+ middle = ` ${theme.fg("accent", `${files} ${pluralForm("file", files)}`)}`;
791
+ }
792
+ }
793
+ const pathPart =
794
+ cls.pathLabel && cls.pathLabel !== "current directory" ? theme.fg("dim", ` · in ${cls.pathLabel}`) : "";
795
+ return `${typeof theme?.bold === "function" ? theme.bold(prefix) : prefix}${patternPart}${middle}${pathPart}`;
796
+ }
797
+
798
+ /** `ls -l` long-format lines (permissions block) can't be parsed into names
799
+ * reliably; fall back to the boxed shell for those. A leading `total N`
800
+ * summary line is skipped before the check. */
801
+ function isLongFormatLs(text: string): boolean {
802
+ const first = text
803
+ .split("\n")
804
+ .map((line) => line.trim())
805
+ .find((line) => line.length > 0 && !/^total\s+\d+$/i.test(line));
806
+ return Boolean(first) && /^[bcdlsp-][rwxtsST-]{9}[\s@]/.test(first as string);
807
+ }
808
+
809
+ /** Parsed bash tree output, or null to fall back to the boxed shell
810
+ * (long-format ls, unparseable grep). */
811
+ type ParsedBashTree = { entries: string[] } | { matches: GrepMatch[] };
812
+
813
+ function parseBashTreeOutput(cls: BashTreeClass, output: string): ParsedBashTree | null {
814
+ if (cls.kind === "ls") {
815
+ // `ls -l`/`ls -la` long format is parsed into names (with `/` for dirs)
816
+ // so bash listings render like the List tool tree.
817
+ if (isLongFormatLs(output)) return { entries: parseLsLongOutput(output) };
818
+ return { entries: parseLsOutput(output) };
819
+ }
820
+ if (cls.kind === "find") return { entries: parseFindOutput(output) };
821
+ const matches = parseGrepOutput(output);
822
+ if (matches.length === 0 && output.trim().length > 0) {
823
+ // Single-file `rg`/`grep` output is `line: content` with no filename:
824
+ // attribute matches to the command's single path argument.
825
+ if (cls.singlePath) {
826
+ const bare = parseGrepBareOutput(output, cls.singlePath);
827
+ if (bare.length > 0) return { matches: bare };
828
+ }
829
+ return null;
830
+ }
831
+ return { matches };
832
+ }
833
+
834
+ interface BashTreeState {
835
+ readonly cls: BashTreeClass;
836
+ /** Raw command, so the call panel can render the boxed bash call on fallback. */
837
+ readonly command: string;
838
+ /** `parsed` once the result arrives; `fallback` when the boxed shell takes over. */
839
+ parsed?: ParsedBashTree;
840
+ fallback?: boolean;
841
+ }
842
+
843
+ const bashTreeStates = new Map<string, BashTreeState>();
844
+
845
+ /** Reset all bash tree state (session start/shutdown, new message). */
846
+ export function resetBashTreeRegistry(): void {
847
+ bashTreeStates.clear();
848
+ }
849
+
850
+ function renderBashTreeLines(theme: BoxTheme, state: BashTreeState, width: number): string[] {
851
+ const safeWidth = Math.max(1, width);
852
+ const cls = state.cls;
853
+ if (state.parsed && "entries" in state.parsed) {
854
+ const entries = state.parsed.entries;
855
+ return renderOutputTree(theme, bashTreeHeader(theme, cls, { files: entries.length }), entries, safeWidth, {
856
+ moreUnit: "file",
857
+ indent: TREE_INDENT,
858
+ withIcons: getToolsRenderConfig().nerdFonts,
859
+ });
860
+ }
861
+ if (state.parsed && "matches" in state.parsed) {
862
+ const matches = state.parsed.matches;
863
+ return renderGrepTree(
864
+ theme,
865
+ bashTreeHeader(theme, cls, { matches: matches.length, files: groupMatchesByFile(matches).length }),
866
+ matches,
867
+ safeWidth,
868
+ { indent: TREE_INDENT, withIcons: getToolsRenderConfig().nerdFonts },
869
+ );
870
+ }
871
+ return [safeTruncateToWidth(bashTreeHeader(theme, cls), safeWidth, "…")];
872
+ }
873
+
874
+ /** Empty result component — the tree lives in the call panel, which re-renders
875
+ * with the parsed output once the result arrives. */
876
+ const EMPTY_BASH_TREE_RESULT: Component = {
877
+ invalidate() {},
878
+ render() {
879
+ return [];
880
+ },
881
+ };
882
+
883
+ /** Live panel component for a classified bash command: pending header until the
884
+ * result arrives, then the parsed output tree. When the result falls back to
885
+ * the boxed shell, the call renders the boxed bash call instead, so call and
886
+ * result form one complete box and never duplicate. The state reference is
887
+ * captured at creation so a registry clear on session reset/resume does not
888
+ * blank already-rendered panels. */
889
+ function renderBashTreePanel(theme: BoxTheme, toolCallId: string, context: BoxedToolContext): Component {
890
+ const state = bashTreeStates.get(toolCallId);
891
+ return {
892
+ invalidate() {},
893
+ render(width: number): string[] {
894
+ if (!state) return [];
895
+ if (state.fallback) {
896
+ return renderBoxedBashCall(
897
+ theme,
898
+ state.command.split("\n"),
899
+ context,
900
+ bashWidthKey(state.command, context?.args?.timeout),
901
+ ).render(width);
902
+ }
903
+ return renderBashTreeLines(theme, state, width);
904
+ },
905
+ };
906
+ }
907
+
307
908
  export const bashTool: BoxedToolDefinition = {
308
909
  call(args, theme, context) {
309
910
  noteExecutionStart(context);
911
+ const cls = classifyBashCommand(String(args?.command ?? ""));
912
+ if (cls) {
913
+ bashTreeStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
914
+ return renderBashTreePanel(theme, context.toolCallId, context);
915
+ }
916
+ noteBoxedCallState(context);
310
917
  const rawCommand = String(args?.command ?? "...");
311
918
  return renderBoxedBashCall(theme, rawCommand.split("\n"), context, bashWidthKey(rawCommand, args?.timeout));
312
919
  },
313
920
  result(result, options, theme, context) {
314
- const raw = getTextOutput(result);
315
- const outputColor = context.isError ? "error" : "toolOutput";
316
-
317
- if (!options.expanded) {
318
- const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
319
- let nlCount = 0;
320
- let tailStart = 0;
321
- for (let i = raw.length - 1; i >= 0; i--) {
322
- if (raw.charCodeAt(i) === 10) {
323
- nlCount++;
324
- if (nlCount >= scanLines) {
325
- tailStart = i + 1;
326
- break;
327
- }
328
- }
921
+ const firstResultPass = !isResultSeen(context.state);
922
+ markResultSeen(context.state);
923
+ const cls = classifyBashCommand(String(context?.args?.command ?? ""));
924
+ if (cls && !context.isError) {
925
+ // Tree-classified commands render in the call panel; the result adds
926
+ // nothing. Keep terminal state in sync without an elapsed ticker.
927
+ if (!options.isPartial) {
928
+ recordExecutionEnded(context.state);
929
+ stopElapsedTicker(context.state);
930
+ }
931
+ const output = stripBashToolNoticeLines(stripAnsi(getTextOutput(result)));
932
+ const parsed = parseBashTreeOutput(cls, output);
933
+ const state = bashTreeStates.get(context.toolCallId);
934
+ if (parsed) {
935
+ if (state) state.parsed = parsed;
936
+ else bashTreeStates.set(context.toolCallId, { cls, command: String(context?.args?.command ?? ""), parsed });
937
+ return EMPTY_BASH_TREE_RESULT;
329
938
  }
330
- const tail = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart)));
331
- const totalLinesBefore = tailStart > 0 ? countNewlines(raw, 0, tailStart) : 0;
332
- const inner = createBashResultPreview(theme, tail, options, outputColor);
333
- return renderBoxedBashResult(theme, inner, result, context, totalLinesBefore > 0 ? "Ctrl+O for more" : undefined);
939
+ // Unparseable output (ls -l, raw rg summary): the boxed shell owns the
940
+ // result; flag the call panel to render nothing so the two don't duplicate.
941
+ if (state) state.fallback = true;
942
+ } else if (options.isPartial) {
943
+ startElapsedTicker(context.state, context.invalidate);
944
+ } else {
945
+ recordExecutionEnded(context.state);
946
+ stopElapsedTicker(context.state);
947
+ }
948
+ const raw = getTextOutput(result);
949
+ if (options.isPartial) {
950
+ // First partial pass: the running call card stands alone. Later passes
951
+ // stream output into the open continuation without a Response divider.
952
+ if (firstResultPass) return EMPTY_BASH_RESULT;
953
+ return renderBashStreamingResult(theme, raw, options, context);
334
954
  }
335
- const output = stripBashToolNoticeLines(stripAnsi(raw));
336
- const inner = createBashResultPreview(theme, output, options, outputColor);
337
- return renderBoxedBashResult(theme, inner, result, context);
955
+ return renderBashFinalResult(theme, raw, options, context);
338
956
  },
339
957
  };