@quandev104/pi-style 0.1.3 → 0.1.5
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 +30 -0
- package/README.md +8 -4
- package/dist/extensions/pi-style.js +3777 -958
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -0
- package/extension-src/pi-style/domain/config-normalization.ts +21 -5
- package/extension-src/pi-style/domain/config-presets.ts +1 -1
- package/extension-src/pi-style/domain/config-types.ts +7 -3
- package/extension-src/pi-style/domain/status.ts +1 -1
- package/extension-src/pi-style/domain/theme.ts +6 -1
- package/extension-src/pi-style/features/editor/index.ts +169 -27
- package/extension-src/pi-style/features/messages/index.ts +66 -0
- package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +430 -172
- package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
- package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +28 -2
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +47 -4
- package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
- package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
- package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +64 -4
- package/extension-src/pi-style/features/tools/boxed/shared.ts +41 -1
- package/extension-src/pi-style/features/tools/boxed/write.ts +19 -2
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +17 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +104 -10
- package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
- package/extension-src/pi-style/pi/index.ts +18 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +33 -1
- package/extension-src/pi-style/shared/box.ts +86 -25
- package/extension-src/pi-style/shared/split-diff.ts +9 -9
- package/package.json +1 -1
- package/themes/titanium-light.json +82 -0
- package/themes/titanium.json +79 -0
- package/themes/.gitkeep +0 -0
|
@@ -7,7 +7,8 @@ import { stripAnsi } from "../../../shared/ansi.js";
|
|
|
7
7
|
import type { BoxTheme } from "../../../shared/box.js";
|
|
8
8
|
import {
|
|
9
9
|
boxedToolWidthKey,
|
|
10
|
-
|
|
10
|
+
formatBoxedRunningStatus,
|
|
11
|
+
formatBoxedWords,
|
|
11
12
|
formatToolOutputLine,
|
|
12
13
|
getTextOutput,
|
|
13
14
|
renderBoxedToolCall,
|
|
@@ -16,6 +17,25 @@ import {
|
|
|
16
17
|
shortenPath,
|
|
17
18
|
} from "../../../shared/box.js";
|
|
18
19
|
import { safeTruncateToWidth, truncateAtCodePointBoundary } from "../../../shared/render-budget.js";
|
|
20
|
+
import { parseSimpleBashCommand } from "./command-shape.js";
|
|
21
|
+
import {
|
|
22
|
+
classifyGhCommand,
|
|
23
|
+
type GhParsedSemantic,
|
|
24
|
+
type GhRunJobParsed,
|
|
25
|
+
type GhSemanticClass,
|
|
26
|
+
parseGhOutput,
|
|
27
|
+
renderGhCardLines,
|
|
28
|
+
renderGhRunJobResult,
|
|
29
|
+
} from "./gh.js";
|
|
30
|
+
import {
|
|
31
|
+
classifyGitCommand,
|
|
32
|
+
type GitDiffParsed,
|
|
33
|
+
type GitParsedSemantic,
|
|
34
|
+
type GitSemanticClass,
|
|
35
|
+
parseGitOutput,
|
|
36
|
+
renderGitCardLines,
|
|
37
|
+
renderGitDiffResult,
|
|
38
|
+
} from "./git.js";
|
|
19
39
|
import {
|
|
20
40
|
type GrepMatch,
|
|
21
41
|
groupMatchesByFile,
|
|
@@ -30,8 +50,16 @@ import {
|
|
|
30
50
|
SEARCH_ICON,
|
|
31
51
|
TREE_INDENT,
|
|
32
52
|
} from "./output-tree.js";
|
|
33
|
-
import {
|
|
34
|
-
|
|
53
|
+
import {
|
|
54
|
+
getStateElapsedMs,
|
|
55
|
+
getToolsRenderConfig,
|
|
56
|
+
isResultSeen,
|
|
57
|
+
markResultSeen,
|
|
58
|
+
recordExecutionEnded,
|
|
59
|
+
startElapsedTicker,
|
|
60
|
+
stopElapsedTicker,
|
|
61
|
+
} from "./session-config.js";
|
|
62
|
+
import { type BoxedToolContext, type BoxedToolDefinition, noteBoxedCallState, noteExecutionStart } from "./shared.js";
|
|
35
63
|
|
|
36
64
|
const MAX_LINE_CHARS = 2000;
|
|
37
65
|
const ESC = "\x1b";
|
|
@@ -182,44 +210,271 @@ function renderBoxedBashCall(
|
|
|
182
210
|
if (commandLines.length > maxCommandLines + 1) {
|
|
183
211
|
detailLines.push(theme.fg("muted", `... ${commandLines.length - maxCommandLines - 1} more lines`));
|
|
184
212
|
}
|
|
185
|
-
|
|
213
|
+
const running = Boolean(context.executionStarted);
|
|
214
|
+
const resultSeen = isResultSeen(context.state);
|
|
215
|
+
const base = {
|
|
186
216
|
widthKey,
|
|
187
217
|
isError: Boolean(context.isError),
|
|
188
218
|
isPartial: Boolean(context.isPartial),
|
|
189
219
|
isPending: Boolean(context.isPartial),
|
|
190
|
-
|
|
220
|
+
running,
|
|
221
|
+
};
|
|
222
|
+
if (running && context.isPartial && !resultSeen) {
|
|
223
|
+
// Pre-result running card: the call closes the box with a live running
|
|
224
|
+
// footer and a `No output received yet` line. The first partial result
|
|
225
|
+
// renders nothing, so this card is never duplicated below.
|
|
226
|
+
detailLines.push(theme.fg("dim", "No output received yet"));
|
|
227
|
+
return renderBoxedToolCall(theme, "Bash", detailLines, {
|
|
228
|
+
...base,
|
|
229
|
+
pendingLabel: formatBoxedRunningStatus(theme, getStateElapsedMs(context.state)),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// Streaming (a result renderer already continues this box) and terminal
|
|
233
|
+
// (settled) passes leave the box open so the result closes it.
|
|
234
|
+
return renderBoxedToolCall(theme, "Bash", detailLines, { ...base, resultSeen });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Terminal status detection ────────────────────────────────────────────────
|
|
238
|
+
// The bash tool appends a `\n\n<status>` suffix to failed results (nonzero exit,
|
|
239
|
+
// timeout, abort). Parse it off the raw text so the footer can carry the real
|
|
240
|
+
// status instead of displaying the suffix as output.
|
|
241
|
+
|
|
242
|
+
type BashTerminalStatus =
|
|
243
|
+
| { kind: "exit"; exitCode: number }
|
|
244
|
+
| { kind: "timeout"; seconds: number }
|
|
245
|
+
| { kind: "cancelled" };
|
|
246
|
+
|
|
247
|
+
const BASH_STATUS_PATTERNS: ReadonlyArray<{
|
|
248
|
+
re: RegExp;
|
|
249
|
+
build: (match: RegExpMatchArray) => BashTerminalStatus;
|
|
250
|
+
}> = [
|
|
251
|
+
{
|
|
252
|
+
re: /(?:^|\n\n)Command timed out after ([\d.]+) seconds$/i,
|
|
253
|
+
build: (match) => ({ kind: "timeout", seconds: Number(match[1]) }),
|
|
254
|
+
},
|
|
255
|
+
{ re: /(?:^|\n\n)[^\n]*aborted$/i, build: () => ({ kind: "cancelled" }) },
|
|
256
|
+
{
|
|
257
|
+
re: /(?:^|\n\n)Command exited with code (\d+)$/i,
|
|
258
|
+
build: (match) => ({ kind: "exit", exitCode: Number(match[1]) }),
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
function parseBashTerminalStatus(text: string): { status: BashTerminalStatus | undefined; body: string } {
|
|
263
|
+
const clean = String(text ?? "").replace(/\r/g, "");
|
|
264
|
+
for (const { re, build } of BASH_STATUS_PATTERNS) {
|
|
265
|
+
const match = clean.match(re);
|
|
266
|
+
if (match && match.index !== undefined) {
|
|
267
|
+
return { status: build(match), body: clean.slice(0, match.index).trimEnd() };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Pi's message_end error path (agent aborted) sends a bare status text with
|
|
271
|
+
// no bash output shape; recognize it as a cancelled state.
|
|
272
|
+
if (/^(?:operation )?aborted(?: after \d+ retry attempts?)?$/i.test(clean.trim())) {
|
|
273
|
+
return { status: { kind: "cancelled" }, body: "" };
|
|
274
|
+
}
|
|
275
|
+
return { status: undefined, body: clean };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function bashErrorLabel(status: BashTerminalStatus | undefined): string | undefined {
|
|
279
|
+
if (status?.kind === "timeout") return "✗ Timed out";
|
|
280
|
+
if (status?.kind === "cancelled") return "✗ Cancelled";
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Body text shown when a terminal bash result produced no output. */
|
|
285
|
+
function bashEmptyBodyText(status: BashTerminalStatus | undefined, isError: boolean): string {
|
|
286
|
+
if (status?.kind === "timeout") return "No output was received before the timeout";
|
|
287
|
+
if (status?.kind === "cancelled") return "Command was cancelled without producing output";
|
|
288
|
+
if (isError) return "Command failed without producing output";
|
|
289
|
+
return "Command completed without producing output";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function bashFooter(
|
|
293
|
+
theme: BoxTheme,
|
|
294
|
+
status: BashTerminalStatus | undefined,
|
|
295
|
+
elapsedMs: number | undefined,
|
|
296
|
+
bodyText: string,
|
|
297
|
+
isError: boolean,
|
|
298
|
+
): string {
|
|
299
|
+
const elapsed = elapsedMs === undefined ? "--" : `${(elapsedMs / 1000).toFixed(2)}s`;
|
|
300
|
+
const words = bodyText.trim() ? formatBoxedWords(bodyText) : "";
|
|
301
|
+
|
|
302
|
+
if (status?.kind === "timeout") {
|
|
303
|
+
const seconds = Number.isFinite(status.seconds) && status.seconds > 0 ? status.seconds : Number.NaN;
|
|
304
|
+
return theme.fg(
|
|
305
|
+
"warning",
|
|
306
|
+
Number.isFinite(seconds) ? `Terminated after ${seconds.toFixed(1)}s` : "Terminated by timeout",
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
if (status?.kind === "cancelled") {
|
|
310
|
+
return [theme.fg("warning", "Cancelled"), theme.fg("text", elapsed)].join(theme.fg("dim", " · "));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const exitLabel = status?.kind === "exit" ? `Exit ${status.exitCode}` : isError ? "Failed" : "Exit 0";
|
|
314
|
+
const exitColor = status?.kind === "exit" && status.exitCode !== 0 ? "error" : "text";
|
|
315
|
+
const parts = [theme.fg(exitColor, exitLabel), theme.fg("text", elapsed)];
|
|
316
|
+
if (words) parts.push(theme.fg("dim", words));
|
|
317
|
+
return parts.join(theme.fg("dim", " · "));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Interactive command heuristics ───────────────────────────────────────────
|
|
321
|
+
// Terminal programs that read stdin or own the screen produce no pipe output;
|
|
322
|
+
// when one runs silently we hint that it may be waiting for terminal input.
|
|
323
|
+
|
|
324
|
+
const INTERACTIVE_COMMANDS = new Set([
|
|
325
|
+
"pi",
|
|
326
|
+
"vim",
|
|
327
|
+
"vi",
|
|
328
|
+
"nvim",
|
|
329
|
+
"nano",
|
|
330
|
+
"less",
|
|
331
|
+
"more",
|
|
332
|
+
"man",
|
|
333
|
+
"top",
|
|
334
|
+
"htop",
|
|
335
|
+
"btop",
|
|
336
|
+
"ssh",
|
|
337
|
+
"telnet",
|
|
338
|
+
"python",
|
|
339
|
+
"python3",
|
|
340
|
+
"node",
|
|
341
|
+
"sqlite3",
|
|
342
|
+
"mysql",
|
|
343
|
+
"psql",
|
|
344
|
+
"redis-cli",
|
|
345
|
+
"mongosh",
|
|
346
|
+
"bc",
|
|
347
|
+
"irssi",
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
function isInteractiveCommand(command: unknown): boolean {
|
|
351
|
+
const base =
|
|
352
|
+
(
|
|
353
|
+
String(command ?? "")
|
|
354
|
+
.trim()
|
|
355
|
+
.split(/\s+/)[0] ?? ""
|
|
356
|
+
)
|
|
357
|
+
.split("/")
|
|
358
|
+
.pop() ?? "";
|
|
359
|
+
return INTERACTIVE_COMMANDS.has(base);
|
|
191
360
|
}
|
|
192
361
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return
|
|
362
|
+
/** Wrap an output preview so an empty result renders state text instead of `∅`. */
|
|
363
|
+
function bashBodyComponent(preview: Component, emptyLines: string[] | undefined): Component {
|
|
364
|
+
if (!emptyLines) return preview;
|
|
365
|
+
return {
|
|
366
|
+
invalidate: () => preview.invalidate(),
|
|
367
|
+
render(width: number): string[] {
|
|
368
|
+
const lines = preview.render(width);
|
|
369
|
+
return lines.length > 0 ? lines : emptyLines;
|
|
370
|
+
},
|
|
371
|
+
};
|
|
196
372
|
}
|
|
197
373
|
|
|
198
|
-
|
|
374
|
+
/** Streaming continuation: streamed output (or `No output received yet`), a
|
|
375
|
+
* live running footer, and no `Response` divider until the tool settles. */
|
|
376
|
+
function renderBashStreamingResult(
|
|
199
377
|
theme: BoxTheme,
|
|
200
|
-
|
|
201
|
-
|
|
378
|
+
raw: string,
|
|
379
|
+
options: { expanded: boolean },
|
|
202
380
|
context: BoxedToolContext,
|
|
203
|
-
expandHint?: string,
|
|
204
381
|
): Component {
|
|
382
|
+
const body = stripBashToolNoticeLines(stripAnsi(raw));
|
|
383
|
+
const hasOutput = body.trim().length > 0;
|
|
384
|
+
const elapsed = getStateElapsedMs(context.state);
|
|
385
|
+
const emptyLines: string[] = [theme.fg("dim", "No output received yet")];
|
|
386
|
+
if (!hasOutput && isInteractiveCommand(context?.args?.command) && (elapsed ?? 0) >= 1000) {
|
|
387
|
+
emptyLines.push(theme.fg("dim", "The process may be waiting for terminal input"));
|
|
388
|
+
}
|
|
389
|
+
const preview = createBashResultPreview(theme, body, options, "toolOutput");
|
|
205
390
|
const rawCommand = String(context?.args?.command ?? "...");
|
|
206
|
-
|
|
207
|
-
return renderBoxedToolResult(theme, inner, {
|
|
391
|
+
return renderBoxedToolResult(theme, bashBodyComponent(preview, hasOutput ? undefined : emptyLines), {
|
|
208
392
|
widthKey: bashWidthKey(rawCommand, context?.args?.timeout),
|
|
209
|
-
referenceLines,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
],
|
|
213
|
-
|
|
214
|
-
isError: context.isError,
|
|
215
|
-
isPartial: Boolean(context.isPartial),
|
|
393
|
+
referenceLines: rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`),
|
|
394
|
+
dividerLabel: "Output",
|
|
395
|
+
showDivider: hasOutput,
|
|
396
|
+
footerLines: [formatBoxedRunningStatus(theme, elapsed)],
|
|
397
|
+
isPartial: true,
|
|
216
398
|
});
|
|
217
399
|
}
|
|
218
400
|
|
|
219
|
-
function
|
|
220
|
-
|
|
401
|
+
function renderBashFinalResult(
|
|
402
|
+
theme: BoxTheme,
|
|
403
|
+
raw: string,
|
|
404
|
+
options: { expanded: boolean },
|
|
405
|
+
context: BoxedToolContext,
|
|
406
|
+
): Component {
|
|
407
|
+
const isError = Boolean(context.isError);
|
|
408
|
+
const clean = stripAnsi(raw);
|
|
409
|
+
const { status, body: statusStripped } = parseBashTerminalStatus(clean);
|
|
410
|
+
const output = stripBashToolNoticeLines(statusStripped);
|
|
411
|
+
const elapsed = getStateElapsedMs(context.state);
|
|
412
|
+
const outputColor = isError ? "error" : "toolOutput";
|
|
413
|
+
const footer = bashFooter(theme, status, elapsed, output, isError);
|
|
414
|
+
const errorLabel = isError ? (bashErrorLabel(status) ?? "✗ Error") : undefined;
|
|
415
|
+
|
|
416
|
+
const rawCommand = String(context?.args?.command ?? "...");
|
|
417
|
+
const widthKey = bashWidthKey(rawCommand, context?.args?.timeout);
|
|
418
|
+
const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
|
|
419
|
+
|
|
420
|
+
if (!options.expanded) {
|
|
421
|
+
// Collapsed: only process the tail of the output (notices stripped per line).
|
|
422
|
+
const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
|
|
423
|
+
let nlCount = 0;
|
|
424
|
+
let tailStart = 0;
|
|
425
|
+
for (let i = statusStripped.length - 1; i >= 0; i--) {
|
|
426
|
+
if (statusStripped.charCodeAt(i) === 10) {
|
|
427
|
+
nlCount++;
|
|
428
|
+
if (nlCount >= scanLines) {
|
|
429
|
+
tailStart = i + 1;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const tail = stripBashToolNoticeLines(stripAnsi(statusStripped.slice(tailStart)));
|
|
435
|
+
const totalLinesBefore = tailStart > 0 ? countNewlines(statusStripped, 0, tailStart) : 0;
|
|
436
|
+
const preview = createBashResultPreview(theme, tail, options, outputColor);
|
|
437
|
+
return renderBoxedToolResult(
|
|
438
|
+
theme,
|
|
439
|
+
bashBodyComponent(
|
|
440
|
+
preview,
|
|
441
|
+
statusStripped.trim() ? undefined : [theme.fg("muted", bashEmptyBodyText(status, isError))],
|
|
442
|
+
),
|
|
443
|
+
{
|
|
444
|
+
widthKey,
|
|
445
|
+
referenceLines,
|
|
446
|
+
footerLines: [footer],
|
|
447
|
+
...(totalLinesBefore > 0 ? { expandHint: "Ctrl+O for more" } : {}),
|
|
448
|
+
isError,
|
|
449
|
+
isPartial: false,
|
|
450
|
+
...(errorLabel ? { errorLabel } : {}),
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const preview = createBashResultPreview(theme, output, options, outputColor);
|
|
456
|
+
return renderBoxedToolResult(
|
|
457
|
+
theme,
|
|
458
|
+
bashBodyComponent(preview, output.trim() ? undefined : [theme.fg("muted", bashEmptyBodyText(status, isError))]),
|
|
459
|
+
{
|
|
460
|
+
widthKey,
|
|
461
|
+
referenceLines,
|
|
462
|
+
footerLines: [footer],
|
|
463
|
+
isError,
|
|
464
|
+
isPartial: false,
|
|
465
|
+
...(errorLabel ? { errorLabel } : {}),
|
|
466
|
+
},
|
|
467
|
+
);
|
|
221
468
|
}
|
|
222
469
|
|
|
470
|
+
/** First-partial-pass result: the pending/running call card stands alone. */
|
|
471
|
+
const EMPTY_BASH_RESULT: Component = Object.freeze({
|
|
472
|
+
invalidate() {},
|
|
473
|
+
render() {
|
|
474
|
+
return [];
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
|
|
223
478
|
function createBashResultPreview(
|
|
224
479
|
theme: BoxTheme,
|
|
225
480
|
text: string,
|
|
@@ -337,59 +592,7 @@ interface BashTreeClass {
|
|
|
337
592
|
readonly singlePath?: string;
|
|
338
593
|
}
|
|
339
594
|
|
|
340
|
-
const BASH_PREFIX_COMMANDS = new Set(["sudo", "env", "time", "nice", "nohup", "command", "stdbuf", "ionice", "watch"]);
|
|
341
595
|
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
596
|
|
|
394
597
|
/** grep/rg flags that consume a separate value token (`--type ts`). */
|
|
395
598
|
const GREP_VALUE_FLAGS = new Set([
|
|
@@ -468,55 +671,11 @@ function classifyByArgs(kind: BashTreeKind, args: string[]): BashTreeClass {
|
|
|
468
671
|
};
|
|
469
672
|
}
|
|
470
673
|
|
|
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
674
|
/** Classify a bash command for tree rendering, or null to keep the boxed shell. */
|
|
485
675
|
export function classifyBashCommand(command: string): BashTreeClass | null {
|
|
486
|
-
const
|
|
487
|
-
if (!
|
|
488
|
-
const
|
|
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
|
-
|
|
676
|
+
const shape = parseSimpleBashCommand(command, { allowTrailingTruncationPipe: true });
|
|
677
|
+
if (!shape) return null;
|
|
678
|
+
const rest = shape.tokens;
|
|
520
679
|
const base = (rest[0] ?? "").split("/").pop() ?? "";
|
|
521
680
|
let kind: BashTreeKind | null = null;
|
|
522
681
|
if (base === "ls") kind = "ls";
|
|
@@ -525,11 +684,11 @@ export function classifyBashCommand(command: string): BashTreeClass | null {
|
|
|
525
684
|
if (!kind) return null;
|
|
526
685
|
|
|
527
686
|
const cls = classifyByArgs(kind, rest.slice(1));
|
|
528
|
-
if (cdDir && cls.pathLabel === "current directory") {
|
|
687
|
+
if (shape.cdDir && cls.pathLabel === "current directory") {
|
|
529
688
|
return {
|
|
530
689
|
kind,
|
|
531
690
|
...(cls.pattern !== undefined ? { pattern: cls.pattern } : {}),
|
|
532
|
-
pathLabel: shortenPath(cdDir),
|
|
691
|
+
pathLabel: shortenPath(shape.cdDir),
|
|
533
692
|
...(cls.singlePath !== undefined ? { singlePath: cls.singlePath } : {}),
|
|
534
693
|
};
|
|
535
694
|
}
|
|
@@ -537,7 +696,7 @@ export function classifyBashCommand(command: string): BashTreeClass | null {
|
|
|
537
696
|
}
|
|
538
697
|
|
|
539
698
|
function bashTreeHeader(theme: BoxTheme, cls: BashTreeClass, counts?: { files?: number; matches?: number }): string {
|
|
540
|
-
const label = cls.kind === "find" ? "
|
|
699
|
+
const label = cls.kind === "find" ? "Find" : cls.kind === "ls" ? "List" : "Grep";
|
|
541
700
|
const hasDetail = Boolean(cls.pattern) || Boolean(counts);
|
|
542
701
|
// ls/find/grep headers carry the magnifying-glass icon in Nerd Font mode.
|
|
543
702
|
const icon = getToolsRenderConfig().nerdFonts ? `${SEARCH_ICON} ` : "";
|
|
@@ -596,22 +755,86 @@ function parseBashTreeOutput(cls: BashTreeClass, output: string): ParsedBashTree
|
|
|
596
755
|
}
|
|
597
756
|
|
|
598
757
|
interface BashTreeState {
|
|
599
|
-
readonly cls:
|
|
758
|
+
readonly cls: BashSemanticClass;
|
|
600
759
|
/** Raw command, so the call panel can render the boxed bash call on fallback. */
|
|
601
760
|
readonly command: string;
|
|
602
761
|
/** `parsed` once the result arrives; `fallback` when the boxed shell takes over. */
|
|
603
|
-
parsed?:
|
|
762
|
+
parsed?: ParsedSemantic;
|
|
604
763
|
fallback?: boolean;
|
|
605
764
|
}
|
|
606
765
|
|
|
607
|
-
|
|
766
|
+
/** Classified semantic command: a bash tree (ls/find/grep), a git card, or a
|
|
767
|
+
* gh card (pr/issue/run). */
|
|
768
|
+
export type BashSemanticClass = BashTreeClass | GitSemanticClass | GhSemanticClass;
|
|
769
|
+
type ParsedSemantic = ParsedBashTree | GitParsedSemantic | GhParsedSemantic;
|
|
770
|
+
|
|
771
|
+
/** Classify a bash command for semantic rendering (tree, git card, or gh
|
|
772
|
+
* card), or null to keep the boxed command/response shell. */
|
|
773
|
+
export function classifyBashSemantic(command: string): BashSemanticClass | null {
|
|
774
|
+
return classifyBashCommand(command) ?? classifyGitCommand(command) ?? classifyGhCommand(command);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function isBashTreeClass(cls: BashSemanticClass): cls is BashTreeClass {
|
|
778
|
+
return cls.kind === "ls" || cls.kind === "find" || cls.kind === "grep";
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Type guard for the gh semantic classes (pr/issue/run list/view/checks/
|
|
782
|
+
* create/job). */
|
|
783
|
+
function isGhClass(cls: BashSemanticClass): cls is GhSemanticClass {
|
|
784
|
+
switch (cls.kind) {
|
|
785
|
+
case "pr-list":
|
|
786
|
+
case "pr-view":
|
|
787
|
+
case "pr-checks":
|
|
788
|
+
case "pr-create":
|
|
789
|
+
case "issue-list":
|
|
790
|
+
case "issue-view":
|
|
791
|
+
case "run-list":
|
|
792
|
+
case "run-view":
|
|
793
|
+
case "run-job":
|
|
794
|
+
return true;
|
|
795
|
+
default:
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** `gh run view --job=<id>` renders a boxed log result (Phase 8D); the other gh
|
|
801
|
+
* classes render their whole panel in the call card. */
|
|
802
|
+
function isGhRunJobClass(cls: BashSemanticClass): boolean {
|
|
803
|
+
return cls.kind === "run-job";
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** `git diff` / `git show` render a boxed adaptive-diff result (Phase 8B); the
|
|
807
|
+
* other semantic classes render their whole panel in the call card. */
|
|
808
|
+
function isGitDiffClass(cls: BashSemanticClass): boolean {
|
|
809
|
+
return !isBashTreeClass(cls) && (cls as GitSemanticClass).kind === "diff";
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** `git commit`/`push`/`pull`/`fetch` may produce informational exit-1 output
|
|
813
|
+
* (e.g. `git commit` with nothing staged) that still parses to a card. Their
|
|
814
|
+
* parsers are fail-closed, so genuine errors (push rejected, hook failure)
|
|
815
|
+
* return null and fall back to the raw boxed shell (ADR 0005). */
|
|
816
|
+
function isGitActionClass(cls: BashSemanticClass): boolean {
|
|
817
|
+
return !isBashTreeClass(cls) && (cls as GitSemanticClass).kind === "action";
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function parseSemanticOutput(cls: BashSemanticClass, output: string): ParsedSemantic | null {
|
|
821
|
+
if (isBashTreeClass(cls)) return parseBashTreeOutput(cls, output);
|
|
822
|
+
if (isGhClass(cls)) return parseGhOutput(cls, output);
|
|
823
|
+
return parseGitOutput(cls, output);
|
|
824
|
+
}
|
|
608
825
|
|
|
609
|
-
|
|
826
|
+
const semanticStates = new Map<string, BashTreeState>();
|
|
827
|
+
|
|
828
|
+
/** Reset all semantic bash state (session start/shutdown, new message). */
|
|
610
829
|
export function resetBashTreeRegistry(): void {
|
|
611
|
-
|
|
830
|
+
semanticStates.clear();
|
|
612
831
|
}
|
|
613
832
|
|
|
614
|
-
function renderBashTreeLines(
|
|
833
|
+
function renderBashTreeLines(
|
|
834
|
+
theme: BoxTheme,
|
|
835
|
+
state: { cls: BashTreeClass; parsed?: ParsedBashTree },
|
|
836
|
+
width: number,
|
|
837
|
+
): string[] {
|
|
615
838
|
const safeWidth = Math.max(1, width);
|
|
616
839
|
const cls = state.cls;
|
|
617
840
|
if (state.parsed && "entries" in state.parsed) {
|
|
@@ -645,13 +868,13 @@ const EMPTY_BASH_TREE_RESULT: Component = {
|
|
|
645
868
|
};
|
|
646
869
|
|
|
647
870
|
/** 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
|
|
649
|
-
* the boxed shell, the call renders the boxed bash call instead, so call and
|
|
871
|
+
* result arrives, then the parsed output tree/card. When the result falls back
|
|
872
|
+
* to the boxed shell, the call renders the boxed bash call instead, so call and
|
|
650
873
|
* result form one complete box and never duplicate. The state reference is
|
|
651
874
|
* captured at creation so a registry clear on session reset/resume does not
|
|
652
875
|
* blank already-rendered panels. */
|
|
653
|
-
function
|
|
654
|
-
const state =
|
|
876
|
+
function renderSemanticPanel(theme: BoxTheme, toolCallId: string, context: BoxedToolContext): Component {
|
|
877
|
+
const state = semanticStates.get(toolCallId);
|
|
655
878
|
return {
|
|
656
879
|
invalidate() {},
|
|
657
880
|
render(width: number): string[] {
|
|
@@ -664,7 +887,21 @@ function renderBashTreePanel(theme: BoxTheme, toolCallId: string, context: Boxed
|
|
|
664
887
|
bashWidthKey(state.command, context?.args?.timeout),
|
|
665
888
|
).render(width);
|
|
666
889
|
}
|
|
667
|
-
|
|
890
|
+
if (isBashTreeClass(state.cls)) {
|
|
891
|
+
const treeState: { cls: BashTreeClass; parsed?: ParsedBashTree } = { cls: state.cls };
|
|
892
|
+
if (state.parsed !== undefined) treeState.parsed = state.parsed as ParsedBashTree;
|
|
893
|
+
return renderBashTreeLines(theme, treeState, width);
|
|
894
|
+
}
|
|
895
|
+
// Git classes only ever carry git parsed values (parseSemanticOutput
|
|
896
|
+
// dispatches on the class), so the narrowed cast is exact.
|
|
897
|
+
if (isGhClass(state.cls)) {
|
|
898
|
+
const ghState: { cls: GhSemanticClass; parsed?: GhParsedSemantic } = { cls: state.cls };
|
|
899
|
+
if (state.parsed !== undefined) ghState.parsed = state.parsed as GhParsedSemantic;
|
|
900
|
+
return renderGhCardLines(theme, ghState, width);
|
|
901
|
+
}
|
|
902
|
+
const gitState: { cls: GitSemanticClass; parsed?: GitParsedSemantic } = { cls: state.cls };
|
|
903
|
+
if (state.parsed !== undefined) gitState.parsed = state.parsed as GitParsedSemantic;
|
|
904
|
+
return renderGitCardLines(theme, gitState, width);
|
|
668
905
|
},
|
|
669
906
|
};
|
|
670
907
|
}
|
|
@@ -672,52 +909,73 @@ function renderBashTreePanel(theme: BoxTheme, toolCallId: string, context: Boxed
|
|
|
672
909
|
export const bashTool: BoxedToolDefinition = {
|
|
673
910
|
call(args, theme, context) {
|
|
674
911
|
noteExecutionStart(context);
|
|
675
|
-
const cls =
|
|
912
|
+
const cls = classifyBashSemantic(String(args?.command ?? ""));
|
|
676
913
|
if (cls) {
|
|
677
|
-
|
|
678
|
-
return
|
|
914
|
+
semanticStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
|
|
915
|
+
return renderSemanticPanel(theme, context.toolCallId, context);
|
|
679
916
|
}
|
|
917
|
+
noteBoxedCallState(context);
|
|
680
918
|
const rawCommand = String(args?.command ?? "...");
|
|
681
919
|
return renderBoxedBashCall(theme, rawCommand.split("\n"), context, bashWidthKey(rawCommand, args?.timeout));
|
|
682
920
|
},
|
|
683
921
|
result(result, options, theme, context) {
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
922
|
+
const firstResultPass = !isResultSeen(context.state);
|
|
923
|
+
markResultSeen(context.state);
|
|
924
|
+
const cls = classifyBashSemantic(String(context?.args?.command ?? ""));
|
|
925
|
+
// Action classes (commit/push/pull/fetch) also attempt parsing on exit-1
|
|
926
|
+
// results so informational states like `git commit` with nothing staged
|
|
927
|
+
// render as a card; the fail-closed parser keeps genuine errors raw.
|
|
928
|
+
if (cls && (!context.isError || isGitActionClass(cls))) {
|
|
929
|
+
// Semantic-classified commands render in the call panel; the result adds
|
|
930
|
+
// nothing. Keep terminal state in sync without an elapsed ticker. Git
|
|
931
|
+
// parsers only run on the terminal result: streaming partial output may
|
|
932
|
+
// hold a truncated line that would fail parsing and wrongly fall back.
|
|
933
|
+
if (!options.isPartial) {
|
|
934
|
+
recordExecutionEnded(context.state);
|
|
935
|
+
stopElapsedTicker(context.state);
|
|
693
936
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
937
|
+
if (!options.isPartial || isBashTreeClass(cls)) {
|
|
938
|
+
const output = stripBashToolNoticeLines(stripAnsi(getTextOutput(result)));
|
|
939
|
+
const parsed = parseSemanticOutput(cls, output);
|
|
940
|
+
const state = semanticStates.get(context.toolCallId);
|
|
941
|
+
if (parsed) {
|
|
942
|
+
if (state) state.parsed = parsed;
|
|
943
|
+
else
|
|
944
|
+
semanticStates.set(context.toolCallId, {
|
|
945
|
+
cls,
|
|
946
|
+
command: String(context?.args?.command ?? ""),
|
|
947
|
+
parsed,
|
|
948
|
+
});
|
|
949
|
+
// `git diff` / `git show` render a boxed adaptive-diff result (one frame
|
|
950
|
+
// per file); `gh run view --job=<id>` renders a boxed log result. Every
|
|
951
|
+
// other semantic class renders its whole panel in the call card, so the
|
|
952
|
+
// result adds nothing.
|
|
953
|
+
if (isGitDiffClass(cls)) {
|
|
954
|
+
return renderGitDiffResult(theme, parsed as GitDiffParsed, options, context);
|
|
955
|
+
}
|
|
956
|
+
if (isGhRunJobClass(cls)) {
|
|
957
|
+
return renderGhRunJobResult(theme, parsed as GhRunJobParsed, options, context);
|
|
711
958
|
}
|
|
959
|
+
return EMPTY_BASH_TREE_RESULT;
|
|
712
960
|
}
|
|
961
|
+
// Unparseable output (ls -l, raw rg summary, non-git output): the boxed
|
|
962
|
+
// shell owns the result; flag the call panel to render nothing so the
|
|
963
|
+
// two don't duplicate.
|
|
964
|
+
if (state) state.fallback = true;
|
|
713
965
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
966
|
+
} else if (options.isPartial) {
|
|
967
|
+
startElapsedTicker(context.state, context.invalidate);
|
|
968
|
+
} else {
|
|
969
|
+
recordExecutionEnded(context.state);
|
|
970
|
+
stopElapsedTicker(context.state);
|
|
971
|
+
}
|
|
972
|
+
const raw = getTextOutput(result);
|
|
973
|
+
if (options.isPartial) {
|
|
974
|
+
// First partial pass: the running call card stands alone. Later passes
|
|
975
|
+
// stream output into the open continuation without a Response divider.
|
|
976
|
+
if (firstResultPass) return EMPTY_BASH_RESULT;
|
|
977
|
+
return renderBashStreamingResult(theme, raw, options, context);
|
|
718
978
|
}
|
|
719
|
-
|
|
720
|
-
const inner = createBashResultPreview(theme, output, options, outputColor);
|
|
721
|
-
return renderBoxedBashResult(theme, inner, result, context);
|
|
979
|
+
return renderBashFinalResult(theme, raw, options, context);
|
|
722
980
|
},
|
|
723
981
|
};
|