@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.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -2
- package/dist/extensions/pi-style.js +5881 -4929
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -2
- package/extension-src/pi-style/app/index.ts +0 -1
- package/extension-src/pi-style/domain/config-authorization.ts +1 -2
- package/extension-src/pi-style/domain/config-normalization.ts +3 -3
- package/extension-src/pi-style/domain/config-types.ts +2 -2
- package/extension-src/pi-style/domain/theme.ts +3 -0
- package/extension-src/pi-style/features/messages/boxed-block.ts +16 -20
- package/extension-src/pi-style/features/messages/index.ts +97 -40
- package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
- package/extension-src/pi-style/features/startup/index.ts +4 -4
- package/extension-src/pi-style/features/tools/boxed/bash.ts +395 -19
- package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
- package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
- package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
- package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
- package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
- package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
- package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
- package/extension-src/pi-style/features/tools/index.ts +14 -0
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
- package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
- package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
- package/extension-src/pi-style/pi/config-session.ts +0 -2
- package/extension-src/pi-style/pi/index.ts +35 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +48 -4
- package/extension-src/pi-style/shared/ansi.ts +3 -0
- package/extension-src/pi-style/shared/box.ts +210 -69
- package/extension-src/pi-style/shared/split-diff.ts +395 -86
- package/extension-src/pi-style/shared/theme-extras.ts +0 -2
- 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: [
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
720
|
+
const inner = createBashResultPreview(theme, output, options, outputColor);
|
|
345
721
|
return renderBoxedBashResult(theme, inner, result, context);
|
|
346
722
|
},
|
|
347
723
|
};
|