@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -2
- package/dist/extensions/pi-style.js +5510 -4865
- 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/index.ts +2 -8
- package/extension-src/pi-style/features/tools/boxed/bash.ts +384 -0
- package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
- 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/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 +39 -3
- package/extension-src/pi-style/shared/box.ts +41 -7
- package/extension-src/pi-style/shared/theme-extras.ts +0 -2
- 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" | "
|
|
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,
|
|
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;
|
|
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;
|
|
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
|
|
198
|
-
|
|
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
|
|