@quandev104/pi-style 0.1.1 → 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 (39) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -2
  3. package/dist/extensions/pi-style.js +5881 -4929
  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/boxed-block.ts +16 -20
  12. package/extension-src/pi-style/features/messages/index.ts +97 -40
  13. package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
  14. package/extension-src/pi-style/features/startup/index.ts +4 -4
  15. package/extension-src/pi-style/features/tools/boxed/bash.ts +395 -19
  16. package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
  18. package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
  19. package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
  21. package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
  22. package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
  23. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
  24. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
  25. package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
  26. package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
  27. package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
  28. package/extension-src/pi-style/features/tools/index.ts +14 -0
  29. package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
  30. package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
  31. package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
  32. package/extension-src/pi-style/pi/config-session.ts +0 -2
  33. package/extension-src/pi-style/pi/index.ts +35 -1
  34. package/extension-src/pi-style/pi/session-coordinator.ts +48 -4
  35. package/extension-src/pi-style/shared/ansi.ts +3 -0
  36. package/extension-src/pi-style/shared/box.ts +210 -69
  37. package/extension-src/pi-style/shared/split-diff.ts +395 -86
  38. package/extension-src/pi-style/shared/theme-extras.ts +0 -2
  39. package/package.json +1 -1
@@ -13,12 +13,26 @@ 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
 
21
- const MAX_BASH_PREVIEW_LINES = 5;
22
36
  const MAX_LINE_CHARS = 2000;
23
37
  const ESC = "\x1b";
24
38
  const BASH_TOOL_NOTICE_PATTERN = /^\[Showing (?:last|lines)\b.*\. Full output: .+\]$/;
@@ -186,13 +200,17 @@ function renderBoxedBashResult(
186
200
  inner: Component,
187
201
  result: unknown,
188
202
  context: BoxedToolContext,
203
+ expandHint?: string,
189
204
  ): Component {
190
205
  const rawCommand = String(context?.args?.command ?? "...");
191
206
  const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
192
207
  return renderBoxedToolResult(theme, inner, {
193
208
  widthKey: bashWidthKey(rawCommand, context?.args?.timeout),
194
209
  referenceLines,
195
- footerLines: [formatBoxedFooter(theme, result as never, [`⏹ ${formatTimeout(context)}`], getElapsed(context))],
210
+ footerLines: [
211
+ formatBoxedFooter(theme, result as never, [`timeout ${formatTimeout(context)}`], getElapsed(context)),
212
+ ],
213
+ ...(expandHint ? { expandHint } : {}),
196
214
  isError: context.isError,
197
215
  isPartial: Boolean(context.isPartial),
198
216
  });
@@ -207,7 +225,6 @@ function createBashResultPreview(
207
225
  text: string,
208
226
  options: { expanded: boolean },
209
227
  color: "toolOutput" | "error",
210
- extraLinesBefore: number = 0,
211
228
  ): Component {
212
229
  let cacheKey = "";
213
230
  let cacheLines: string[] | null = null;
@@ -226,7 +243,7 @@ function createBashResultPreview(
226
243
 
227
244
  if (!expanded) {
228
245
  // Collapsed: only process the tail of the output
229
- const needed = MAX_BASH_PREVIEW_LINES;
246
+ const needed = cfg.maxCollapsedLines;
230
247
  let totalNewlines = 0;
231
248
  let scanFrom = 0; // default: take full text if fewer than needed newlines
232
249
  for (let i = text.length - 1; i >= 0; i--) {
@@ -262,18 +279,8 @@ function createBashResultPreview(
262
279
  : formatToolOutputLine(theme, truncated, "text");
263
280
  });
264
281
 
265
- // Count remaining lines (lines before scanFrom)
266
- const remaining = extraLinesBefore + (scanFrom > 0 ? countNewlines(text, 0, scanFrom) : 0);
267
-
268
- if (remaining <= 0) {
269
- cacheKey = cacheId;
270
- cacheLines = truncatedShown;
271
- return cacheLines;
272
- }
273
-
274
- const hint = safeTruncateToWidth(`... ${remaining} more lines, press Ctrl+o to expand`, bodyWidth, "…");
275
282
  cacheKey = cacheId;
276
- cacheLines = [...truncatedShown, "", theme.fg("muted", hint)];
283
+ cacheLines = truncatedShown;
277
284
  return cacheLines;
278
285
  }
279
286
 
@@ -312,18 +319,387 @@ function createBashResultPreview(
312
319
  };
313
320
  }
314
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
+
315
672
  export const bashTool: BoxedToolDefinition = {
316
673
  call(args, theme, context) {
317
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
+ }
318
680
  const rawCommand = String(args?.command ?? "...");
319
681
  return renderBoxedBashCall(theme, rawCommand.split("\n"), context, bashWidthKey(rawCommand, args?.timeout));
320
682
  },
321
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
+ }
322
698
  const raw = getTextOutput(result);
323
699
  const outputColor = context.isError ? "error" : "toolOutput";
324
700
 
325
701
  if (!options.expanded) {
326
- const scanLines = MAX_BASH_PREVIEW_LINES + 10;
702
+ const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
327
703
  let nlCount = 0;
328
704
  let tailStart = 0;
329
705
  for (let i = raw.length - 1; i >= 0; i--) {
@@ -337,11 +713,11 @@ export const bashTool: BoxedToolDefinition = {
337
713
  }
338
714
  const tail = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart)));
339
715
  const totalLinesBefore = tailStart > 0 ? countNewlines(raw, 0, tailStart) : 0;
340
- const inner = createBashResultPreview(theme, tail, options, outputColor, totalLinesBefore);
341
- return renderBoxedBashResult(theme, inner, result, context);
716
+ const inner = createBashResultPreview(theme, tail, options, outputColor);
717
+ return renderBoxedBashResult(theme, inner, result, context, totalLinesBefore > 0 ? "Ctrl+O for more" : undefined);
342
718
  }
343
719
  const output = stripBashToolNoticeLines(stripAnsi(raw));
344
- const inner = createBashResultPreview(theme, output, options, outputColor, 0);
720
+ const inner = createBashResultPreview(theme, output, options, outputColor);
345
721
  return renderBoxedBashResult(theme, inner, result, context);
346
722
  },
347
723
  };