@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +8 -4
  3. package/dist/extensions/pi-style.js +3777 -958
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -0
  6. package/extension-src/pi-style/domain/config-normalization.ts +21 -5
  7. package/extension-src/pi-style/domain/config-presets.ts +1 -1
  8. package/extension-src/pi-style/domain/config-types.ts +7 -3
  9. package/extension-src/pi-style/domain/status.ts +1 -1
  10. package/extension-src/pi-style/domain/theme.ts +6 -1
  11. package/extension-src/pi-style/features/editor/index.ts +169 -27
  12. package/extension-src/pi-style/features/messages/index.ts +66 -0
  13. package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
  14. package/extension-src/pi-style/features/tools/boxed/bash.ts +430 -172
  15. package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
  16. package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +28 -2
  18. package/extension-src/pi-style/features/tools/boxed/fallback.ts +47 -4
  19. package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
  20. package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
  21. package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
  22. package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
  23. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
  24. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
  25. package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
  26. package/extension-src/pi-style/features/tools/boxed/session-config.ts +64 -4
  27. package/extension-src/pi-style/features/tools/boxed/shared.ts +41 -1
  28. package/extension-src/pi-style/features/tools/boxed/write.ts +19 -2
  29. package/extension-src/pi-style/pi/compatibility-coordinator.ts +17 -0
  30. package/extension-src/pi-style/pi/compatibility-probe.ts +104 -10
  31. package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
  32. package/extension-src/pi-style/pi/index.ts +18 -1
  33. package/extension-src/pi-style/pi/session-coordinator.ts +33 -1
  34. package/extension-src/pi-style/shared/box.ts +86 -25
  35. package/extension-src/pi-style/shared/split-diff.ts +9 -9
  36. package/package.json +1 -1
  37. package/themes/titanium-light.json +82 -0
  38. package/themes/titanium.json +79 -0
  39. 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
- formatBoxedFooter,
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 { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
34
- import { type BoxedToolContext, type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
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
- return renderBoxedToolCall(theme, "Bash", detailLines, {
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
- function formatTimeout(context: BoxedToolContext): string {
194
- const timeout = context?.args?.timeout ?? 300;
195
- return `${timeout}s`;
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
- function renderBoxedBashResult(
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
- inner: Component,
201
- result: unknown,
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
- const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
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
- footerLines: [
211
- formatBoxedFooter(theme, result as never, [`timeout ${formatTimeout(context)}`], getElapsed(context)),
212
- ],
213
- ...(expandHint ? { expandHint } : {}),
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 getElapsed(context: BoxedToolContext): number | undefined {
220
- return getStateElapsedMs(context.state);
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 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
-
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" ? "Glob" : cls.kind === "ls" ? "List" : "Grep";
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: BashTreeClass;
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?: ParsedBashTree;
762
+ parsed?: ParsedSemantic;
604
763
  fallback?: boolean;
605
764
  }
606
765
 
607
- const bashTreeStates = new Map<string, BashTreeState>();
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
- /** Reset all bash tree state (session start/shutdown, new message). */
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
- bashTreeStates.clear();
830
+ semanticStates.clear();
612
831
  }
613
832
 
614
- function renderBashTreeLines(theme: BoxTheme, state: BashTreeState, width: number): string[] {
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 to
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 renderBashTreePanel(theme: BoxTheme, toolCallId: string, context: BoxedToolContext): Component {
654
- const state = bashTreeStates.get(toolCallId);
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
- return renderBashTreeLines(theme, state, width);
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 = classifyBashCommand(String(args?.command ?? ""));
912
+ const cls = classifyBashSemantic(String(args?.command ?? ""));
676
913
  if (cls) {
677
- bashTreeStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
678
- return renderBashTreePanel(theme, context.toolCallId, context);
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 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;
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
- // 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
- }
698
- const raw = getTextOutput(result);
699
- const outputColor = context.isError ? "error" : "toolOutput";
700
-
701
- if (!options.expanded) {
702
- const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
703
- let nlCount = 0;
704
- let tailStart = 0;
705
- for (let i = raw.length - 1; i >= 0; i--) {
706
- if (raw.charCodeAt(i) === 10) {
707
- nlCount++;
708
- if (nlCount >= scanLines) {
709
- tailStart = i + 1;
710
- break;
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
- const tail = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart)));
715
- const totalLinesBefore = tailStart > 0 ? countNewlines(raw, 0, tailStart) : 0;
716
- const inner = createBashResultPreview(theme, tail, options, outputColor);
717
- return renderBoxedBashResult(theme, inner, result, context, totalLinesBefore > 0 ? "Ctrl+O for more" : undefined);
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
- const output = stripBashToolNoticeLines(stripAnsi(raw));
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
  };