@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.
- package/CHANGELOG.md +30 -0
- package/README.md +10 -6
- package/dist/extensions/pi-style.js +3939 -1431
- 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-authorization.ts +6 -3
- 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/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 +154 -130
- 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/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/read.ts +3 -3
- package/extension-src/pi-style/features/tools/boxed/write.ts +2 -1
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +32 -11
- package/extension-src/pi-style/pi/compatibility-probe.ts +341 -205
- package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
- package/extension-src/pi-style/pi/index.ts +21 -3
- package/extension-src/pi-style/pi/session-coordinator.ts +24 -0
- package/extension-src/pi-style/shared/box.ts +8 -4
- package/extension-src/pi-style/shared/split-diff.ts +9 -9
- package/package.json +9 -9
- package/themes/titanium-light.json +82 -0
- package/themes/titanium.json +79 -0
- 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
|
|
723
|
-
if (!
|
|
724
|
-
const
|
|
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" ? "
|
|
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:
|
|
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?:
|
|
762
|
+
parsed?: ParsedSemantic;
|
|
840
763
|
fallback?: boolean;
|
|
841
764
|
}
|
|
842
765
|
|
|
843
|
-
|
|
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
|
|
828
|
+
/** Reset all semantic bash state (session start/shutdown, new message). */
|
|
846
829
|
export function resetBashTreeRegistry(): void {
|
|
847
|
-
|
|
830
|
+
semanticStates.clear();
|
|
848
831
|
}
|
|
849
832
|
|
|
850
|
-
function renderBashTreeLines(
|
|
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
|
|
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
|
|
890
|
-
const state =
|
|
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
|
-
|
|
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 =
|
|
912
|
+
const cls = classifyBashSemantic(String(args?.command ?? ""));
|
|
912
913
|
if (cls) {
|
|
913
|
-
|
|
914
|
-
return
|
|
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 =
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
if (
|
|
936
|
-
|
|
937
|
-
|
|
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).
|
|
19
|
-
// (
|
|
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
|
|
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
|
|
25
|
-
// output as a file subtree (flat
|
|
26
|
-
//
|
|
27
|
-
// fall back to
|
|
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: "
|
|
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 = `${
|
|
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}${
|
|
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}${
|
|
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: `
|
|
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 ? " " :
|
|
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}${
|
|
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}${
|
|
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}${
|
|
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}${
|
|
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}${
|
|
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 `
|
|
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
|
-
//
|
|
423
|
-
//
|
|
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
|
}
|