@quandev104/pi-style 0.1.4 → 0.1.6

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 (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +10 -6
  3. package/dist/extensions/pi-style.js +3939 -1431
  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-authorization.ts +6 -3
  7. package/extension-src/pi-style/domain/config-normalization.ts +21 -5
  8. package/extension-src/pi-style/domain/config-presets.ts +1 -1
  9. package/extension-src/pi-style/domain/config-types.ts +7 -3
  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 +154 -130
  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/find.ts +2 -2
  18. package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
  19. package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
  21. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
  22. package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
  23. package/extension-src/pi-style/features/tools/boxed/write.ts +2 -1
  24. package/extension-src/pi-style/pi/compatibility-coordinator.ts +32 -11
  25. package/extension-src/pi-style/pi/compatibility-probe.ts +341 -205
  26. package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
  27. package/extension-src/pi-style/pi/index.ts +21 -3
  28. package/extension-src/pi-style/pi/session-coordinator.ts +24 -0
  29. package/extension-src/pi-style/shared/box.ts +8 -4
  30. package/extension-src/pi-style/shared/split-diff.ts +9 -9
  31. package/package.json +9 -9
  32. package/themes/titanium-light.json +82 -0
  33. package/themes/titanium.json +79 -0
  34. package/themes/.gitkeep +0 -0
@@ -0,0 +1,112 @@
1
+ // Boxed presentation for Pi's native direct bash execution (`!command` /
2
+ // `!!command` prefix in the input box). The native BashExecutionComponent draws
3
+ // plain full-width top/bottom bars; a fingerprint-certified additive render
4
+ // patch (see pi/compatibility-probe.ts) installs an own `render` on the
5
+ // prototype that re-frames the output into the same rounded box used by the
6
+ // boxed tool presentation: `╭─ ➔ Bash ◌ ─╮`, boxed `$ command` + output body,
7
+ // `╰─ Exit 0 ─╯`.
8
+ //
9
+ // The renderer is a pure delegate: it falls back to the native (inherited
10
+ // Container) rendering whenever the theme cache is empty or the component
11
+ // shape is unsupported. Nothing about the native instance is mutated — the
12
+ // box is computed per render from the live component state.
13
+
14
+ import type { BoxTheme } from "../../shared/box.js";
15
+ import {
16
+ boxBlankLine,
17
+ boxInnerWidth,
18
+ boxLabeledBorder,
19
+ boxLine,
20
+ boxWidth,
21
+ formatBoxedRunningStatus,
22
+ } from "../../shared/box.js";
23
+ import { getThemeExtra } from "../../shared/theme-extras.js";
24
+
25
+ let cachedTheme: BoxTheme | undefined;
26
+
27
+ /** Provide the active theme for the boxed bash execution display (session start / theme change). */
28
+ export function setBashExecutionTheme(theme: BoxTheme | undefined): void {
29
+ cachedTheme = theme;
30
+ }
31
+
32
+ /** Structural view of the native BashExecutionComponent as used by the patch. */
33
+ interface BashExecutionInstance {
34
+ command: string;
35
+ status: "running" | "cancelled" | "error" | "complete";
36
+ exitCode?: number | null;
37
+ contentContainer: { render(width: number): string[] };
38
+ /** Wall-clock start captured on the first boxed render for the live `◌ Running · Ns` footer. */
39
+ piStyleStart?: number;
40
+ }
41
+
42
+ const TOP_LEFT = "╭";
43
+ const TOP_RIGHT = "╮";
44
+ const BOTTOM_LEFT = "╰";
45
+ const BOTTOM_RIGHT = "╯";
46
+
47
+ function bashTitleColor(theme: BoxTheme): string {
48
+ // Honors the `bashPromptColor` theme extra, falling back to the bashMode
49
+ // semantic color (the same source the boxed bash tool title uses).
50
+ const extra = getThemeExtra(theme, "bashPromptColor");
51
+ return extra || "bashMode";
52
+ }
53
+
54
+ function boldOf(theme: BoxTheme): (text: string) => string {
55
+ return typeof theme?.bold === "function" ? theme.bold : (text: string) => text;
56
+ }
57
+
58
+ /** `➔ Bash ◌/✓/✗` — the same title language as the boxed bash tool call. */
59
+ function bashBoxTitle(theme: BoxTheme, host: BashExecutionInstance): string {
60
+ const name = "Bash";
61
+ const bold = boldOf(theme);
62
+ const prefix = theme.fg(bashTitleColor(theme), `➔ ${name}`);
63
+ if (host.status === "running") return bold(`${prefix} ${theme.fg("text", "◌")}`);
64
+ if (host.status === "cancelled") return bold(theme.fg("warning", `➔ ${name} ✗`));
65
+ if (host.status === "error") return bold(theme.fg("error", `➔ ${name} ✗`));
66
+ return bold(`${prefix} ${theme.fg("success", "✓")}`);
67
+ }
68
+
69
+ /** Bottom-border label: live running status with elapsed, or the terminal state. */
70
+ function bashBoxFooter(theme: BoxTheme, host: BashExecutionInstance): string {
71
+ if (host.status === "running") {
72
+ const elapsed = typeof host.piStyleStart === "number" ? (Date.now() - host.piStyleStart) / 1000 : undefined;
73
+ return formatBoxedRunningStatus(theme, elapsed);
74
+ }
75
+ if (host.status === "cancelled") return theme.fg("warning", "Cancelled");
76
+ if (host.status === "error") return theme.fg("error", `Exit ${host.exitCode ?? "?"}`);
77
+ return theme.fg("text", "Exit 0");
78
+ }
79
+
80
+ /**
81
+ * Render a BashExecutionComponent as the rounded box. Returns undefined so the
82
+ * caller falls back to the native rendering whenever the theme is unavailable
83
+ * or the component shape is not the certified layout.
84
+ */
85
+ export function renderBashExecutionBox(instance: unknown, args: unknown[]): string[] | undefined {
86
+ const theme = cachedTheme;
87
+ const width = args[0];
88
+ if (!theme || typeof width !== "number" || !Number.isFinite(width) || width <= 0) return undefined;
89
+ const host = instance as BashExecutionInstance;
90
+ const content = host.contentContainer;
91
+ if (!content || typeof content.render !== "function") return undefined;
92
+ try {
93
+ if (host.piStyleStart === undefined) host.piStyleStart = Date.now();
94
+ const renderedWidth = boxWidth(width);
95
+ const inner = boxInnerWidth(renderedWidth);
96
+ // The native Text children render one leading padding space per line;
97
+ // drop it so boxLine's own side padding produces symmetric borders.
98
+ const wrapped = content
99
+ .render(inner)
100
+ .map((line) => boxLine(theme, line.startsWith(" ") ? line.slice(1) : line, renderedWidth));
101
+ return [
102
+ "",
103
+ boxLabeledBorder(theme, TOP_LEFT, TOP_RIGHT, bashBoxTitle(theme, host), undefined, renderedWidth),
104
+ boxBlankLine(theme, renderedWidth),
105
+ ...wrapped,
106
+ boxBlankLine(theme, renderedWidth),
107
+ boxLabeledBorder(theme, BOTTOM_LEFT, BOTTOM_RIGHT, bashBoxFooter(theme, host), undefined, renderedWidth),
108
+ ];
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
@@ -17,6 +17,25 @@ import {
17
17
  shortenPath,
18
18
  } from "../../../shared/box.js";
19
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";
20
39
  import {
21
40
  type GrepMatch,
22
41
  groupMatchesByFile,
@@ -573,59 +592,7 @@ interface BashTreeClass {
573
592
  readonly singlePath?: string;
574
593
  }
575
594
 
576
- const BASH_PREFIX_COMMANDS = new Set(["sudo", "env", "time", "nice", "nohup", "command", "stdbuf", "ionice", "watch"]);
577
595
  const BASH_GREP_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg"]);
578
- // Pipes (`|`), `;`, and `&` are excluded here: the classifier validates them
579
- // explicitly (allowing `cd X && cmd` chains and a trailing `| head/tail`).
580
- const BASH_SHELL_META_CHARS = new Set(["<", ">", "(", ")", "`"]);
581
-
582
- /** Tokenize a single command line, stripping quotes. Returns null on an
583
- * unterminated quote. `hasMeta` is true if any shell metacharacter appears
584
- * *outside* quotes (so `grep 'a|b' f` stays classifiable). */
585
- function tokenizeCommandLine(line: string): { tokens: string[]; hasMeta: boolean } | null {
586
- const tokens: string[] = [];
587
- let current = "";
588
- let inToken = false;
589
- let quote: string | null = null;
590
- let hasMeta = false;
591
- for (let i = 0; i < line.length; i++) {
592
- const char = line[i] ?? "";
593
- if (quote) {
594
- if (char === "\\" && quote === '"') {
595
- current += line[++i] ?? "";
596
- continue;
597
- }
598
- if (char === quote) {
599
- quote = null;
600
- continue;
601
- }
602
- current += char;
603
- continue;
604
- }
605
- if (char === '"' || char === "'") {
606
- quote = char;
607
- inToken = true;
608
- continue;
609
- }
610
- if (char === " " || char === "\t") {
611
- if (inToken) {
612
- tokens.push(current);
613
- current = "";
614
- inToken = false;
615
- }
616
- continue;
617
- }
618
- if (BASH_SHELL_META_CHARS.has(char) || (char === "$" && (line[i + 1] ?? "") === "(")) {
619
- hasMeta = true;
620
- continue;
621
- }
622
- current += char;
623
- inToken = true;
624
- }
625
- if (quote) return null;
626
- if (inToken) tokens.push(current);
627
- return { tokens, hasMeta };
628
- }
629
596
 
630
597
  /** grep/rg flags that consume a separate value token (`--type ts`). */
631
598
  const GREP_VALUE_FLAGS = new Set([
@@ -704,55 +671,11 @@ function classifyByArgs(kind: BashTreeKind, args: string[]): BashTreeClass {
704
671
  };
705
672
  }
706
673
 
707
- /** `head [-n N]` / `tail [-n N]` truncation pipe tail (allowed at the end). */
708
- function isHeadOrTailTail(tokens: readonly string[]): boolean {
709
- if (tokens.length === 0 || (tokens[0] !== "head" && tokens[0] !== "tail")) return false;
710
- for (let i = 1; i < tokens.length; i++) {
711
- const token = tokens[i] ?? "";
712
- if (token === "-n") continue;
713
- if (/^\d+$/.test(token)) continue;
714
- if (/^-\d+$/.test(token)) continue;
715
- return false;
716
- }
717
- return true;
718
- }
719
-
720
674
  /** Classify a bash command for tree rendering, or null to keep the boxed shell. */
721
675
  export function classifyBashCommand(command: string): BashTreeClass | null {
722
- const commandText = String(command ?? "").trim();
723
- if (!commandText || commandText.includes("\n")) return null;
724
- const tokenized = tokenizeCommandLine(commandText);
725
- if (!tokenized || tokenized.hasMeta || tokenized.tokens.length === 0) return null;
726
- let tokens = tokenized.tokens;
727
-
728
- // Allow a single trailing truncation pipe: `cmd | head [-n] N` / `| tail …`.
729
- const pipes = tokens.flatMap((token, i) => (token === "|" ? [i] : []));
730
- if (pipes.length > 0) {
731
- if (pipes.length > 1) return null;
732
- const last = pipes[0] ?? -1;
733
- if (!isHeadOrTailTail(tokens.slice(last + 1))) return null;
734
- tokens = tokens.slice(0, last);
735
- }
736
-
737
- let index = 0;
738
- // Skip leading environment assignments (FOO=bar ...) and prefix commands.
739
- while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? "")) index++;
740
- while (index < tokens.length && BASH_PREFIX_COMMANDS.has(tokens[index] ?? "")) index++;
741
- // `cd <dir> &&` / `cd <dir>;` chains: the last directory becomes the default
742
- // path when the command itself carries none.
743
- let cdDir: string | undefined;
744
- while (
745
- tokens[index] === "cd" &&
746
- index + 2 < tokens.length &&
747
- tokens[index + 1] !== undefined &&
748
- (tokens[index + 2] === "&&" || tokens[index + 2] === ";")
749
- ) {
750
- cdDir = tokens[index + 1];
751
- index += 3;
752
- }
753
- const rest = tokens.slice(index);
754
- if (rest.length === 0 || rest.some((token) => token === "&&" || token === ";" || token === "&")) return null;
755
-
676
+ const shape = parseSimpleBashCommand(command, { allowTrailingTruncationPipe: true });
677
+ if (!shape) return null;
678
+ const rest = shape.tokens;
756
679
  const base = (rest[0] ?? "").split("/").pop() ?? "";
757
680
  let kind: BashTreeKind | null = null;
758
681
  if (base === "ls") kind = "ls";
@@ -761,11 +684,11 @@ export function classifyBashCommand(command: string): BashTreeClass | null {
761
684
  if (!kind) return null;
762
685
 
763
686
  const cls = classifyByArgs(kind, rest.slice(1));
764
- if (cdDir && cls.pathLabel === "current directory") {
687
+ if (shape.cdDir && cls.pathLabel === "current directory") {
765
688
  return {
766
689
  kind,
767
690
  ...(cls.pattern !== undefined ? { pattern: cls.pattern } : {}),
768
- pathLabel: shortenPath(cdDir),
691
+ pathLabel: shortenPath(shape.cdDir),
769
692
  ...(cls.singlePath !== undefined ? { singlePath: cls.singlePath } : {}),
770
693
  };
771
694
  }
@@ -773,7 +696,7 @@ export function classifyBashCommand(command: string): BashTreeClass | null {
773
696
  }
774
697
 
775
698
  function bashTreeHeader(theme: BoxTheme, cls: BashTreeClass, counts?: { files?: number; matches?: number }): string {
776
- const label = cls.kind === "find" ? "Glob" : cls.kind === "ls" ? "List" : "Grep";
699
+ const label = cls.kind === "find" ? "Find" : cls.kind === "ls" ? "List" : "Grep";
777
700
  const hasDetail = Boolean(cls.pattern) || Boolean(counts);
778
701
  // ls/find/grep headers carry the magnifying-glass icon in Nerd Font mode.
779
702
  const icon = getToolsRenderConfig().nerdFonts ? `${SEARCH_ICON} ` : "";
@@ -832,22 +755,86 @@ function parseBashTreeOutput(cls: BashTreeClass, output: string): ParsedBashTree
832
755
  }
833
756
 
834
757
  interface BashTreeState {
835
- readonly cls: BashTreeClass;
758
+ readonly cls: BashSemanticClass;
836
759
  /** Raw command, so the call panel can render the boxed bash call on fallback. */
837
760
  readonly command: string;
838
761
  /** `parsed` once the result arrives; `fallback` when the boxed shell takes over. */
839
- parsed?: ParsedBashTree;
762
+ parsed?: ParsedSemantic;
840
763
  fallback?: boolean;
841
764
  }
842
765
 
843
- 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
+ }
825
+
826
+ const semanticStates = new Map<string, BashTreeState>();
844
827
 
845
- /** Reset all bash tree state (session start/shutdown, new message). */
828
+ /** Reset all semantic bash state (session start/shutdown, new message). */
846
829
  export function resetBashTreeRegistry(): void {
847
- bashTreeStates.clear();
830
+ semanticStates.clear();
848
831
  }
849
832
 
850
- 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[] {
851
838
  const safeWidth = Math.max(1, width);
852
839
  const cls = state.cls;
853
840
  if (state.parsed && "entries" in state.parsed) {
@@ -881,13 +868,13 @@ const EMPTY_BASH_TREE_RESULT: Component = {
881
868
  };
882
869
 
883
870
  /** Live panel component for a classified bash command: pending header until the
884
- * result arrives, then the parsed output tree. When the result falls back to
885
- * 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
886
873
  * result form one complete box and never duplicate. The state reference is
887
874
  * captured at creation so a registry clear on session reset/resume does not
888
875
  * blank already-rendered panels. */
889
- function renderBashTreePanel(theme: BoxTheme, toolCallId: string, context: BoxedToolContext): Component {
890
- const state = bashTreeStates.get(toolCallId);
876
+ function renderSemanticPanel(theme: BoxTheme, toolCallId: string, context: BoxedToolContext): Component {
877
+ const state = semanticStates.get(toolCallId);
891
878
  return {
892
879
  invalidate() {},
893
880
  render(width: number): string[] {
@@ -900,7 +887,21 @@ function renderBashTreePanel(theme: BoxTheme, toolCallId: string, context: Boxed
900
887
  bashWidthKey(state.command, context?.args?.timeout),
901
888
  ).render(width);
902
889
  }
903
- 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);
904
905
  },
905
906
  };
906
907
  }
@@ -908,10 +909,10 @@ function renderBashTreePanel(theme: BoxTheme, toolCallId: string, context: Boxed
908
909
  export const bashTool: BoxedToolDefinition = {
909
910
  call(args, theme, context) {
910
911
  noteExecutionStart(context);
911
- const cls = classifyBashCommand(String(args?.command ?? ""));
912
+ const cls = classifyBashSemantic(String(args?.command ?? ""));
912
913
  if (cls) {
913
- bashTreeStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
914
- return renderBashTreePanel(theme, context.toolCallId, context);
914
+ semanticStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
915
+ return renderSemanticPanel(theme, context.toolCallId, context);
915
916
  }
916
917
  noteBoxedCallState(context);
917
918
  const rawCommand = String(args?.command ?? "...");
@@ -920,25 +921,48 @@ export const bashTool: BoxedToolDefinition = {
920
921
  result(result, options, theme, context) {
921
922
  const firstResultPass = !isResultSeen(context.state);
922
923
  markResultSeen(context.state);
923
- const cls = classifyBashCommand(String(context?.args?.command ?? ""));
924
- if (cls && !context.isError) {
925
- // Tree-classified commands render in the call panel; the result adds
926
- // nothing. Keep terminal state in sync without an elapsed ticker.
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.
927
933
  if (!options.isPartial) {
928
934
  recordExecutionEnded(context.state);
929
935
  stopElapsedTicker(context.state);
930
936
  }
931
- const output = stripBashToolNoticeLines(stripAnsi(getTextOutput(result)));
932
- const parsed = parseBashTreeOutput(cls, output);
933
- const state = bashTreeStates.get(context.toolCallId);
934
- if (parsed) {
935
- if (state) state.parsed = parsed;
936
- else bashTreeStates.set(context.toolCallId, { cls, command: String(context?.args?.command ?? ""), parsed });
937
- return EMPTY_BASH_TREE_RESULT;
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);
958
+ }
959
+ return EMPTY_BASH_TREE_RESULT;
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;
938
965
  }
939
- // Unparseable output (ls -l, raw rg summary): the boxed shell owns the
940
- // result; flag the call panel to render nothing so the two don't duplicate.
941
- if (state) state.fallback = true;
942
966
  } else if (options.isPartial) {
943
967
  startElapsedTicker(context.state, context.invalidate);
944
968
  } else {
@@ -15,20 +15,23 @@
15
15
  // - Batch boundaries: a new batch starts when the active batch is closed. The
16
16
  // active batch closes when a non-batchable tool call is dispatched
17
17
  // (boxed/index.ts), when a new message starts (pi/index.ts), and on session
18
- // reset (session-coordinator.ts). Lone calls render the same boxless tree
19
- // (a batch of one) there is no boxed single-call special case.
18
+ // reset (session-coordinator.ts). A lone `read` call renders as a single
19
+ // inline line (`➔ Read <path>`); lone ls/find calls render a flat output
20
+ // tree (a batch of one).
20
21
  // - No surrounding box: indentation and tree glyphs (├─/└─) carry the
21
- // hierarchy; the header line is the summary (` Read (N) · 0.08s`).
22
+ // hierarchy; the header line of a batched panel is the summary
23
+ // (` Read (N) · 0.08s`).
22
24
  // - Errors stay visible: failed members are always rendered inline (even in the
23
25
  // collapsed state), with their error text indented beneath the path.
24
- // - read members render a single path row. ls/find members render their parsed
25
- // output as a file subtree (flat for a lone call, nested per member when
26
- // batched) see renderOutputBatchPanel. Pending/failed members without output
27
- // fall back to the path row.
26
+ // - read members render a single path row (a lone read collapses to one inline
27
+ // line). ls/find members render their parsed output as a file subtree (flat
28
+ // for a lone call, nested per member when batched) — see
29
+ // renderOutputBatchPanel. Pending/failed members without output fall back to
30
+ // the path row.
28
31
 
29
32
  import type { Component } from "@earendil-works/pi-tui";
30
33
  import { stripAnsi } from "../../../shared/ansi.js";
31
- import { type BoxTheme, formatToolTitlePrefix } from "../../../shared/box.js";
34
+ import { type BoxTheme, dimLine, formatToolTitlePrefix } from "../../../shared/box.js";
32
35
  import { safeTruncateToWidth } from "../../../shared/render-budget.js";
33
36
  import {
34
37
  fileIcon,
@@ -53,7 +56,7 @@ export interface BatchToolMeta {
53
56
  readonly toolName: string;
54
57
  /** Human label shown in the batch header (e.g. "Read", "List", "Find"). */
55
58
  readonly label: string;
56
- /** Header label for output-tree panels: "Glob" for find, "List" for ls. */
59
+ /** Header label for output-tree panels: "Find" for find, "List" for ls. */
57
60
  readonly headerLabel?: string;
58
61
  }
59
62
 
@@ -283,7 +286,7 @@ function renderErrorLines(theme: BoxTheme, errorText: string, width: number): st
283
286
  .map((line) => line.trim())
284
287
  .filter((line) => line.length > 0);
285
288
  if (raw.length === 0) return [];
286
- const prefix = `${theme.fg("borderMuted", " │ ")}`;
289
+ const prefix = `${dimLine(" │ ")}`;
287
290
  const out = raw
288
291
  .slice(0, BATCH_ERROR_LINES)
289
292
  .map((line) => safeTruncateToWidth(`${prefix}${theme.fg("error", line)}`, Math.max(1, width), "…"));
@@ -305,14 +308,14 @@ function renderBatchTree(theme: BoxTheme, batch: BatchState, status: BatchStatus
305
308
  const glyph = memberGlyph(theme, member, showGlyphs);
306
309
  // Primary color for files read successfully, error red for failures.
307
310
  const pathColor = member.isError ? "error" : member.status === "done" ? "accent" : "text";
308
- const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", branch)}${glyph ? ` ${glyph}` : ""} ${theme.fg(pathColor, member.detail)}`;
311
+ const line = `${BATCH_TREE_INDENT}${dimLine(branch)}${glyph ? ` ${glyph}` : ""} ${theme.fg(pathColor, member.detail)}`;
309
312
  out.push(safeTruncateToWidth(line, Math.max(1, width), "…"));
310
313
  if (member.isError && member.errorText) out.push(...renderErrorLines(theme, member.errorText, width));
311
314
  }
312
315
  if (more > 0) {
313
316
  out.push(
314
317
  safeTruncateToWidth(
315
- `${BATCH_TREE_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `${more} more`)}`,
318
+ `${BATCH_TREE_INDENT}${dimLine("└─")} ${theme.fg("dim", `${more} more`)}`,
316
319
  Math.max(1, width),
317
320
  "…",
318
321
  ),
@@ -321,7 +324,7 @@ function renderBatchTree(theme: BoxTheme, batch: BatchState, status: BatchStatus
321
324
  return out;
322
325
  }
323
326
 
324
- /** Header for a lone (batch-of-one) ls/find output panel: `Glob: <pattern> <N> files · in <path>`. */
327
+ /** Header for a lone (batch-of-one) ls/find output panel: `Find: <pattern> <N> files · in <path>`. */
325
328
  function formatLoneOutputHeader(theme: BoxTheme, meta: BatchToolMeta, member: BatchMember): string {
326
329
  const label = meta.headerLabel ?? meta.label;
327
330
  const count = member.outputEntries?.length ?? 0;
@@ -337,26 +340,26 @@ function formatLoneOutputHeader(theme: BoxTheme, meta: BatchToolMeta, member: Ba
337
340
  /** Nested file subtree for one member inside a batched (2+) output panel. */
338
341
  function renderMemberSubtree(theme: BoxTheme, member: BatchMember, isLastMember: boolean, width: number): string[] {
339
342
  const safeWidth = Math.max(1, width);
340
- const trunk = isLastMember ? " " : theme.fg("borderMuted", "│");
343
+ const trunk = isLastMember ? " " : dimLine("│");
341
344
  const out: string[] = [];
342
345
 
343
346
  // Member header row: path + file count (or status glyph when not done).
344
347
  const entries = member.outputEntries ?? [];
345
348
  if (member.isError) {
346
- const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${theme.fg("error", "✗")} ${theme.fg("error", member.pathLabel ?? member.detail)}`;
349
+ const line = `${BATCH_TREE_INDENT}${dimLine(isLastMember ? "└─" : "├─")} ${theme.fg("error", "✗")} ${theme.fg("error", member.pathLabel ?? member.detail)}`;
347
350
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
348
351
  if (member.errorText) out.push(...renderErrorLines(theme, member.errorText, width));
349
352
  return out;
350
353
  }
351
354
  if (member.status !== "done" || member.outputEntries === undefined) {
352
355
  const glyph = member.status === "done" ? theme.fg("success", "✓") : theme.fg("text", "◌");
353
- const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${glyph} ${theme.fg("text", member.pathLabel ?? member.detail)}`;
356
+ const line = `${BATCH_TREE_INDENT}${dimLine(isLastMember ? "└─" : "├─")} ${glyph} ${theme.fg("text", member.pathLabel ?? member.detail)}`;
354
357
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
355
358
  return out;
356
359
  }
357
360
 
358
361
  const countLabel = theme.fg("dim", ` · ${entries.length} ${pluralForm("file", entries.length)}`);
359
- const headerLine = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${theme.fg("accent", member.pathLabel ?? member.detail)}${countLabel}`;
362
+ const headerLine = `${BATCH_TREE_INDENT}${dimLine(isLastMember ? "└─" : "├─")} ${theme.fg("accent", member.pathLabel ?? member.detail)}${countLabel}`;
360
363
  out.push(safeTruncateToWidth(headerLine, safeWidth, "…"));
361
364
 
362
365
  const visible = entries.slice(0, BATCH_MEMBER_FILE_HEAD_LIMIT);
@@ -367,11 +370,11 @@ function renderMemberSubtree(theme: BoxTheme, member: BatchMember, isLastMember:
367
370
  const entry = visible[i] ?? "";
368
371
  const label = icons && entry ? `${fileIcon(entry)} ${entry}` : entry;
369
372
  const branch = i < lastIndex || more > 0 ? "├─" : "└─";
370
- const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", branch)} ${theme.fg("toolOutput", label)}`;
373
+ const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${dimLine(branch)} ${theme.fg("toolOutput", label)}`;
371
374
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
372
375
  }
373
376
  if (more > 0) {
374
- const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${more} more ${pluralForm("file", more)}`)}`;
377
+ const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${dimLine("└─")} ${theme.fg("dim", `… ${more} more ${pluralForm("file", more)}`)}`;
375
378
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
376
379
  }
377
380
  return out;
@@ -381,7 +384,7 @@ function renderMemberSubtree(theme: BoxTheme, member: BatchMember, isLastMember:
381
384
  function renderOutputBatchPanel(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
382
385
  const safeWidth = Math.max(1, width);
383
386
 
384
- // Lone successful call with output: flat tree under a `Glob:/List:` header.
387
+ // Lone successful call with output: flat tree under a `Find:/List:` header.
385
388
  if (batch.members.length === 1) {
386
389
  const member = batch.members[0];
387
390
  if (member && member.outputEntries !== undefined && !member.isError) {
@@ -408,19 +411,39 @@ function renderOutputBatchPanel(theme: BoxTheme, batch: BatchState, status: Batc
408
411
  });
409
412
  if (more > 0) {
410
413
  out.push(
411
- safeTruncateToWidth(
412
- `${BATCH_TREE_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `${more} more`)}`,
413
- safeWidth,
414
- "…",
415
- ),
414
+ safeTruncateToWidth(`${BATCH_TREE_INDENT}${dimLine("└─")} ${theme.fg("dim", `${more} more`)}`, safeWidth, "…"),
416
415
  );
417
416
  }
418
417
  return out;
419
418
  }
420
419
 
420
+ /** Lone `read` call: single inline line `➔ Read <path>` — no count, no tree. */
421
+ function isLoneRead(batch: BatchState): boolean {
422
+ return batch.meta.toolName === "read" && batch.members.length === 1;
423
+ }
424
+
425
+ /** Lone read renders `➔ Read <path>` on one line; errors keep their error text. */
426
+ function renderLoneReadPanel(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
427
+ const member = batch.members[0];
428
+ if (!member) return [];
429
+ const prefix = bold(theme, formatToolTitlePrefix(theme, batch.meta.label));
430
+ const glyph = memberGlyph(theme, member, !status.allDone || status.failed > 0);
431
+ const pathColor = member.isError ? "error" : member.status === "done" ? "accent" : "text";
432
+ const out = [
433
+ safeTruncateToWidth(
434
+ `${prefix}${glyph ? ` ${glyph}` : ""} ${theme.fg(pathColor, member.detail)}`,
435
+ Math.max(1, width),
436
+ "…",
437
+ ),
438
+ ];
439
+ if (member.isError && member.errorText) out.push(...renderErrorLines(theme, member.errorText, width));
440
+ return out;
441
+ }
442
+
421
443
  function renderBatchPanelLines(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
422
- // The tree stays open in every state, including for a lone call: no boxed
423
- // single-call special case, no collapsed single-line summary.
444
+ // Lone read collapses to a single inline line; batched reads and lone
445
+ // ls/find calls keep their tree panels.
446
+ if (isLoneRead(batch)) return renderLoneReadPanel(theme, batch, status, width);
424
447
  if (isOutputTool(batch.meta) && batch.members.some((member) => member.outputEntries !== undefined)) {
425
448
  return renderOutputBatchPanel(theme, batch, status, width);
426
449
  }