@tangle-network/ui 11.15.3 → 11.16.1
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 +14 -0
- package/dist/run/expanded-tool-detail.js +4 -1
- package/dist/tool-previews/command-preview.d.ts +14 -3
- package/dist/tool-previews/command-preview.js +169 -68
- package/dist/tool-previews/write-file-preview.js +1 -1
- package/package.json +1 -1
- package/src/run/expanded-tool-detail.tsx +1 -1
- package/src/tool-previews/command-preview.tsx +233 -92
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.16.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c59ed11: `CommandPreview` prints a failed command's text once when the producer persisted it as both the output and the error.
|
|
8
|
+
|
|
9
|
+
It also reads a JSON string as a `{ stdout, stderr, exitCode }` envelope only when every key is an envelope key, so a command whose own output is JSON with a `stdout` field is shown as printed, and an `error` status turns the exit badge red even when the retained exit code is 0.
|
|
10
|
+
|
|
11
|
+
## 11.16.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 14c9406: `CommandPreview` renders a shell command as a terminal block: a `$ command` prompt line with its exit status, and stdout and stderr in separate regions on a dark inset that keeps its contrast in both themes. It reads the plain-string output a workflow persists as well as `{ stdout, stderr, exitCode }`, shows an exit-code badge only when a code is known, and is collapsed until opened (`defaultExpanded` opts out; the expanded tool row passes it).
|
|
16
|
+
|
|
3
17
|
## 11.15.3
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
|
@@ -60,7 +60,10 @@ function formatOutput(value) {
|
|
|
60
60
|
const ExpandedToolDetail = memo(({ part }) => {
|
|
61
61
|
const meta = getToolDisplayMetadata(part);
|
|
62
62
|
const { status, input, output, error } = part.state;
|
|
63
|
-
if (meta.displayVariant === "command") return /* @__PURE__ */ jsx(CommandPreview, {
|
|
63
|
+
if (meta.displayVariant === "command") return /* @__PURE__ */ jsx(CommandPreview, {
|
|
64
|
+
part,
|
|
65
|
+
defaultExpanded: true
|
|
66
|
+
});
|
|
64
67
|
if (meta.displayVariant === "write-file") return /* @__PURE__ */ jsx(WriteFilePreview, { part });
|
|
65
68
|
if (meta.displayVariant === "grep") return /* @__PURE__ */ jsx(GrepResultsPreview, { part });
|
|
66
69
|
if (meta.displayVariant === "glob") return /* @__PURE__ */ jsx(GlobResultsPreview, { part });
|
|
@@ -2,10 +2,21 @@ import { ToolPart } from "../types/parts.js";
|
|
|
2
2
|
//#region src/tool-previews/command-preview.d.ts
|
|
3
3
|
export interface CommandPreviewProps {
|
|
4
4
|
part: ToolPart;
|
|
5
|
+
/**
|
|
6
|
+
* Show the output without a click. Off by default: a run is read as a list of
|
|
7
|
+
* commands, and the output of the one that matters is a click away. A surface
|
|
8
|
+
* that is itself an expansion (an opened tool row) turns it on.
|
|
9
|
+
*/
|
|
10
|
+
defaultExpanded?: boolean;
|
|
11
|
+
className?: string;
|
|
5
12
|
}
|
|
6
13
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
14
|
+
* One shell command as a terminal block: a `$ command` prompt line with its
|
|
15
|
+
* exit status, and stdout and stderr in separate regions below it.
|
|
16
|
+
*
|
|
17
|
+
* The block is always dark. It scopes the dark token set onto itself with
|
|
18
|
+
* `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
|
|
19
|
+
* contrast they were designed with on a dark ground, in a light console too.
|
|
9
20
|
*/
|
|
10
|
-
export declare const CommandPreview: import("react").MemoExoticComponent<({ part }: CommandPreviewProps) => import("react").JSX.Element>;
|
|
21
|
+
export declare const CommandPreview: import("react").MemoExoticComponent<({ part, defaultExpanded, className }: CommandPreviewProps) => import("react").JSX.Element>;
|
|
11
22
|
//#endregion
|
|
@@ -1,91 +1,192 @@
|
|
|
1
1
|
import { focusRing } from "../lib/focus.js";
|
|
2
2
|
import { cn } from "../lib/utils.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { ChevronDown, ChevronRight, Terminal } from "lucide-react";
|
|
3
|
+
import { memo, useId, useState } from "react";
|
|
4
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
|
7
6
|
//#region src/tool-previews/command-preview.tsx
|
|
8
|
-
/**
|
|
7
|
+
/** The input keys a shell tool names its command under. */
|
|
8
|
+
const COMMAND_KEYS = [
|
|
9
|
+
"command",
|
|
10
|
+
"cmd",
|
|
11
|
+
"script",
|
|
12
|
+
"commandLine"
|
|
13
|
+
];
|
|
14
|
+
/** The command a shell tool ran, from a structured input or a raw string. */
|
|
15
|
+
function commandOf(input) {
|
|
16
|
+
if (typeof input === "string") return input;
|
|
17
|
+
if (input && typeof input === "object") {
|
|
18
|
+
const obj = input;
|
|
19
|
+
for (const key of COMMAND_KEYS) {
|
|
20
|
+
const value = obj[key];
|
|
21
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
function finiteNumber(value) {
|
|
27
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
28
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Split a tool's output into stdout, stderr and an exit code.
|
|
35
|
+
*
|
|
36
|
+
* Accepts both shapes producers persist: a structured `{ stdout, stderr,
|
|
37
|
+
* exitCode }` (or `output` / `exit_code` / `code`), and the plain string a
|
|
38
|
+
* workflow stores. A string carries no exit code, so none is invented.
|
|
39
|
+
*/
|
|
9
40
|
function extractCommandOutput(output) {
|
|
10
|
-
if (
|
|
41
|
+
if (output === void 0 || output === null) return {
|
|
11
42
|
stdout: "",
|
|
12
43
|
stderr: "",
|
|
13
|
-
exitCode: 0
|
|
14
|
-
};
|
|
15
|
-
if (typeof output === "string") return {
|
|
16
|
-
stdout: output,
|
|
17
|
-
stderr: "",
|
|
18
|
-
exitCode: 0
|
|
44
|
+
exitCode: void 0
|
|
19
45
|
};
|
|
20
|
-
if (typeof output === "
|
|
21
|
-
const
|
|
46
|
+
if (typeof output === "string") {
|
|
47
|
+
const parsed = parseStructured(output);
|
|
48
|
+
if (parsed) return parsed;
|
|
22
49
|
return {
|
|
23
|
-
stdout:
|
|
24
|
-
stderr:
|
|
25
|
-
exitCode:
|
|
50
|
+
stdout: output,
|
|
51
|
+
stderr: "",
|
|
52
|
+
exitCode: void 0
|
|
26
53
|
};
|
|
27
54
|
}
|
|
55
|
+
if (typeof output === "object") return fromObject(output);
|
|
28
56
|
return {
|
|
29
57
|
stdout: String(output),
|
|
30
58
|
stderr: "",
|
|
31
|
-
exitCode: 0
|
|
59
|
+
exitCode: void 0
|
|
32
60
|
};
|
|
33
61
|
}
|
|
62
|
+
function fromObject(obj) {
|
|
63
|
+
const text = (value) => value === void 0 || value === null ? "" : String(value);
|
|
64
|
+
return {
|
|
65
|
+
stdout: text(obj.stdout ?? obj.output),
|
|
66
|
+
stderr: text(obj.stderr),
|
|
67
|
+
exitCode: finiteNumber(obj.exitCode ?? obj.exit_code ?? obj.code)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** The keys a serialized command result carries, and nothing else. */
|
|
71
|
+
const ENVELOPE_KEYS = /* @__PURE__ */ new Set([
|
|
72
|
+
"stdout",
|
|
73
|
+
"stderr",
|
|
74
|
+
"output",
|
|
75
|
+
"exitCode",
|
|
76
|
+
"exit_code",
|
|
77
|
+
"code",
|
|
78
|
+
"signal",
|
|
79
|
+
"durationMs",
|
|
80
|
+
"duration_ms",
|
|
81
|
+
"timedOut",
|
|
82
|
+
"timed_out"
|
|
83
|
+
]);
|
|
84
|
+
/** A string that is a serialized `{ stdout, stderr, exitCode }` record. */
|
|
85
|
+
function parseStructured(text) {
|
|
86
|
+
const trimmed = text.trimStart();
|
|
87
|
+
if (!trimmed.startsWith("{")) return void 0;
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(trimmed);
|
|
90
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
91
|
+
const obj = parsed;
|
|
92
|
+
if (!("stdout" in obj) && !("stderr" in obj)) return void 0;
|
|
93
|
+
if (!Object.keys(obj).every((key) => ENVELOPE_KEYS.has(key))) return;
|
|
94
|
+
return fromObject(obj);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Exit status as a badge: the code when one is known, else the tool's status.
|
|
100
|
+
* Red only for a nonzero exit or an error; a clean run stays quiet green. */
|
|
101
|
+
function ExitBadge({ exitCode, status }) {
|
|
102
|
+
if (status === "running" || status === "pending") return /* @__PURE__ */ jsxs("span", {
|
|
103
|
+
className: "inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground",
|
|
104
|
+
children: [/* @__PURE__ */ jsx(Loader2, {
|
|
105
|
+
className: "h-3 w-3 animate-spin",
|
|
106
|
+
"aria-hidden": "true"
|
|
107
|
+
}), "running"]
|
|
108
|
+
});
|
|
109
|
+
const failed = status === "error" || exitCode !== void 0 && exitCode !== 0;
|
|
110
|
+
const label = exitCode !== void 0 ? `exit ${exitCode}` : failed ? "error" : null;
|
|
111
|
+
if (label === null) return null;
|
|
112
|
+
return /* @__PURE__ */ jsx("span", {
|
|
113
|
+
className: cn("inline-flex shrink-0 items-center rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums", failed ? "bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]" : "bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]"),
|
|
114
|
+
children: label
|
|
115
|
+
});
|
|
116
|
+
}
|
|
34
117
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
118
|
+
* One shell command as a terminal block: a `$ command` prompt line with its
|
|
119
|
+
* exit status, and stdout and stderr in separate regions below it.
|
|
120
|
+
*
|
|
121
|
+
* The block is always dark. It scopes the dark token set onto itself with
|
|
122
|
+
* `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
|
|
123
|
+
* contrast they were designed with on a dark ground, in a light console too.
|
|
37
124
|
*/
|
|
38
|
-
const CommandPreview = memo(({ part }) => {
|
|
39
|
-
const [expanded, setExpanded] = useState(
|
|
40
|
-
const
|
|
41
|
-
const command =
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
125
|
+
const CommandPreview = memo(({ part, defaultExpanded = false, className }) => {
|
|
126
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
127
|
+
const bodyId = useId();
|
|
128
|
+
const command = commandOf(part.state.input);
|
|
129
|
+
const { status } = part.state;
|
|
130
|
+
const output = extractCommandOutput(part.state.output);
|
|
131
|
+
const normalize = (text) => text.replace(/\s+/g, " ").trim();
|
|
132
|
+
const errorText = part.state.error !== void 0 && output.stdout.length > 0 && normalize(part.state.error) === normalize(output.stdout) ? void 0 : part.state.error;
|
|
133
|
+
const hasBody = output.stdout.length > 0 || output.stderr.length > 0 || errorText !== void 0 && errorText.length > 0;
|
|
134
|
+
const promptLine = /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
135
|
+
/* @__PURE__ */ jsx("span", {
|
|
136
|
+
"aria-hidden": "true",
|
|
137
|
+
className: "shrink-0 select-none text-muted-foreground",
|
|
138
|
+
children: "$"
|
|
139
|
+
}),
|
|
140
|
+
/* @__PURE__ */ jsx("code", {
|
|
141
|
+
className: cn("min-w-0 flex-1 whitespace-pre-wrap break-all", expanded ? "" : "line-clamp-2"),
|
|
142
|
+
children: command
|
|
143
|
+
}),
|
|
144
|
+
/* @__PURE__ */ jsx(ExitBadge, {
|
|
145
|
+
exitCode: output.exitCode,
|
|
146
|
+
status
|
|
147
|
+
})
|
|
148
|
+
] });
|
|
149
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
150
|
+
"data-theme": "dark",
|
|
151
|
+
"data-testid": "command-preview",
|
|
152
|
+
className: cn("overflow-hidden rounded-[var(--radius-md)] bg-[var(--md3-surface-container-lowest)] font-mono text-xs text-foreground", className),
|
|
153
|
+
children: [hasBody ? /* @__PURE__ */ jsxs("button", {
|
|
154
|
+
type: "button",
|
|
155
|
+
onClick: () => setExpanded((value) => !value),
|
|
156
|
+
"aria-expanded": expanded,
|
|
157
|
+
"aria-controls": bodyId,
|
|
158
|
+
className: cn("flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-white/5", focusRing),
|
|
159
|
+
children: [promptLine, expanded ? /* @__PURE__ */ jsx(ChevronDown, {
|
|
160
|
+
className: "mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground",
|
|
161
|
+
"aria-hidden": "true"
|
|
162
|
+
}) : /* @__PURE__ */ jsx(ChevronRight, {
|
|
163
|
+
className: "mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground",
|
|
164
|
+
"aria-hidden": "true"
|
|
165
|
+
})]
|
|
166
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
167
|
+
className: "flex w-full items-start gap-2 px-3 py-2",
|
|
168
|
+
children: promptLine
|
|
169
|
+
}), expanded && hasBody ? /* @__PURE__ */ jsxs("div", {
|
|
170
|
+
id: bodyId,
|
|
171
|
+
className: "border-t border-white/10",
|
|
172
|
+
children: [
|
|
173
|
+
output.stdout ? /* @__PURE__ */ jsx("pre", {
|
|
174
|
+
className: "max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 leading-relaxed",
|
|
80
175
|
children: output.stdout
|
|
81
|
-
}) : null,
|
|
82
|
-
|
|
176
|
+
}) : null,
|
|
177
|
+
output.stderr ? /* @__PURE__ */ jsx("pre", {
|
|
178
|
+
"aria-label": "stderr",
|
|
179
|
+
className: "max-h-80 overflow-auto whitespace-pre-wrap break-all border-t border-white/10 px-3 py-2 leading-relaxed text-[var(--surface-danger-text)]",
|
|
83
180
|
children: output.stderr
|
|
84
|
-
}) : null
|
|
85
|
-
|
|
86
|
-
|
|
181
|
+
}) : null,
|
|
182
|
+
errorText ? /* @__PURE__ */ jsx("pre", {
|
|
183
|
+
className: "max-h-80 overflow-auto whitespace-pre-wrap break-all border-t border-white/10 px-3 py-2 leading-relaxed text-[var(--surface-danger-text)]",
|
|
184
|
+
children: errorText
|
|
185
|
+
}) : null
|
|
186
|
+
]
|
|
187
|
+
}) : null]
|
|
87
188
|
});
|
|
88
189
|
});
|
|
89
190
|
CommandPreview.displayName = "CommandPreview";
|
|
90
191
|
//#endregion
|
|
91
|
-
export { CommandPreview };
|
|
192
|
+
export { CommandPreview, commandOf, extractCommandOutput };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CodeBlock, CopyButton } from "../markdown/code-block.js";
|
|
2
|
-
import { PreviewCard, PreviewError, PreviewLoading } from "./preview-primitives.js";
|
|
3
2
|
import { getSyntaxLanguage } from "../files/file-format.js";
|
|
3
|
+
import { PreviewCard, PreviewError, PreviewLoading } from "./preview-primitives.js";
|
|
4
4
|
import { memo } from "react";
|
|
5
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { FileEdit } from "lucide-react";
|
package/package.json
CHANGED
|
@@ -60,7 +60,7 @@ export const ExpandedToolDetail = memo(({ part }: ExpandedToolDetailProps) => {
|
|
|
60
60
|
|
|
61
61
|
// Specialised previews
|
|
62
62
|
if (meta.displayVariant === "command") {
|
|
63
|
-
return <CommandPreview part={part} />;
|
|
63
|
+
return <CommandPreview part={part} defaultExpanded />;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
if (meta.displayVariant === "write-file") {
|
|
@@ -1,120 +1,261 @@
|
|
|
1
|
-
import { memo, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
Terminal,
|
|
4
|
-
ChevronDown,
|
|
5
|
-
ChevronRight,
|
|
6
|
-
Loader2,
|
|
7
|
-
} from "lucide-react";
|
|
1
|
+
import { memo, useId, useState } from "react";
|
|
2
|
+
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
|
8
3
|
import { focusRing } from "../lib/focus";
|
|
9
4
|
import { cn } from "../lib/utils";
|
|
10
5
|
import type { ToolPart } from "../types/parts";
|
|
11
|
-
import { PreviewCard, PreviewError, PreviewLoading } from "./preview-primitives";
|
|
12
6
|
|
|
13
7
|
export interface CommandPreviewProps {
|
|
14
8
|
part: ToolPart;
|
|
9
|
+
/**
|
|
10
|
+
* Show the output without a click. Off by default: a run is read as a list of
|
|
11
|
+
* commands, and the output of the one that matters is a click away. A surface
|
|
12
|
+
* that is itself an expansion (an opened tool row) turns it on.
|
|
13
|
+
*/
|
|
14
|
+
defaultExpanded?: boolean;
|
|
15
|
+
className?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/** The input keys a shell tool names its command under. */
|
|
19
|
+
const COMMAND_KEYS = ["command", "cmd", "script", "commandLine"] as const;
|
|
20
|
+
|
|
21
|
+
/** The command a shell tool ran, from a structured input or a raw string. */
|
|
22
|
+
export function commandOf(input: unknown): string {
|
|
23
|
+
if (typeof input === "string") return input;
|
|
24
|
+
if (input && typeof input === "object") {
|
|
25
|
+
const obj = input as Record<string, unknown>;
|
|
26
|
+
for (const key of COMMAND_KEYS) {
|
|
27
|
+
const value = obj[key];
|
|
28
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CommandOutput {
|
|
19
35
|
stdout: string;
|
|
20
36
|
stderr: string;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (typeof output === "string") return { stdout: output, stderr: "", exitCode: 0 };
|
|
37
|
+
/** Undefined when the producer persisted plain text and never reported one. */
|
|
38
|
+
exitCode: number | undefined;
|
|
39
|
+
}
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
exitCode: Number(obj.exitCode ?? obj.exit_code ?? obj.code ?? 0),
|
|
33
|
-
};
|
|
41
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
42
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
43
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
44
|
+
const parsed = Number(value);
|
|
45
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
34
46
|
}
|
|
35
|
-
|
|
36
|
-
return { stdout: String(output), stderr: "", exitCode: 0 };
|
|
47
|
+
return undefined;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
51
|
+
* Split a tool's output into stdout, stderr and an exit code.
|
|
52
|
+
*
|
|
53
|
+
* Accepts both shapes producers persist: a structured `{ stdout, stderr,
|
|
54
|
+
* exitCode }` (or `output` / `exit_code` / `code`), and the plain string a
|
|
55
|
+
* workflow stores. A string carries no exit code, so none is invented.
|
|
42
56
|
*/
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
export function extractCommandOutput(output: unknown): CommandOutput {
|
|
58
|
+
if (output === undefined || output === null) {
|
|
59
|
+
return { stdout: "", stderr: "", exitCode: undefined };
|
|
60
|
+
}
|
|
61
|
+
if (typeof output === "string") {
|
|
62
|
+
const parsed = parseStructured(output);
|
|
63
|
+
if (parsed) return parsed;
|
|
64
|
+
return { stdout: output, stderr: "", exitCode: undefined };
|
|
65
|
+
}
|
|
66
|
+
if (typeof output === "object") {
|
|
67
|
+
return fromObject(output as Record<string, unknown>);
|
|
68
|
+
}
|
|
69
|
+
return { stdout: String(output), stderr: "", exitCode: undefined };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fromObject(obj: Record<string, unknown>): CommandOutput {
|
|
73
|
+
const text = (value: unknown) =>
|
|
74
|
+
value === undefined || value === null ? "" : String(value);
|
|
75
|
+
return {
|
|
76
|
+
stdout: text(obj.stdout ?? obj.output),
|
|
77
|
+
stderr: text(obj.stderr),
|
|
78
|
+
exitCode: finiteNumber(obj.exitCode ?? obj.exit_code ?? obj.code),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
48
81
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
82
|
+
/** The keys a serialized command result carries, and nothing else. */
|
|
83
|
+
const ENVELOPE_KEYS = new Set([
|
|
84
|
+
"stdout",
|
|
85
|
+
"stderr",
|
|
86
|
+
"output",
|
|
87
|
+
"exitCode",
|
|
88
|
+
"exit_code",
|
|
89
|
+
"code",
|
|
90
|
+
"signal",
|
|
91
|
+
"durationMs",
|
|
92
|
+
"duration_ms",
|
|
93
|
+
"timedOut",
|
|
94
|
+
"timed_out",
|
|
95
|
+
]);
|
|
53
96
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
97
|
+
/** A string that is a serialized `{ stdout, stderr, exitCode }` record. */
|
|
98
|
+
function parseStructured(text: string): CommandOutput | undefined {
|
|
99
|
+
const trimmed = text.trimStart();
|
|
100
|
+
if (!trimmed.startsWith("{")) return undefined;
|
|
101
|
+
try {
|
|
102
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
103
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const obj = parsed as Record<string, unknown>;
|
|
107
|
+
// Only an unambiguous envelope: a stream key, and nothing but envelope keys.
|
|
108
|
+
// A command whose own stdout is JSON that happens to have a `stdout` field
|
|
109
|
+
// (`{"stdout":"v","result":"ok"}`) is shown as the text it printed.
|
|
110
|
+
if (!("stdout" in obj) && !("stderr" in obj)) return undefined;
|
|
111
|
+
if (!Object.keys(obj).every((key) => ENVELOPE_KEYS.has(key))) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
return fromObject(obj);
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
57
119
|
|
|
120
|
+
/** Exit status as a badge: the code when one is known, else the tool's status.
|
|
121
|
+
* Red only for a nonzero exit or an error; a clean run stays quiet green. */
|
|
122
|
+
function ExitBadge({
|
|
123
|
+
exitCode,
|
|
124
|
+
status,
|
|
125
|
+
}: {
|
|
126
|
+
exitCode: number | undefined;
|
|
127
|
+
status: ToolPart["state"]["status"];
|
|
128
|
+
}) {
|
|
129
|
+
if (status === "running" || status === "pending") {
|
|
130
|
+
return (
|
|
131
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
|
|
132
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
|
133
|
+
running
|
|
134
|
+
</span>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// The tool's own error status wins: a runner can fail after the shell exited
|
|
138
|
+
// 0, and a green "exit 0" beside that error would contradict it.
|
|
139
|
+
const failed = status === "error" || (exitCode !== undefined && exitCode !== 0);
|
|
140
|
+
const label =
|
|
141
|
+
exitCode !== undefined ? `exit ${exitCode}` : failed ? "error" : null;
|
|
142
|
+
if (label === null) return null;
|
|
58
143
|
return (
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
className={cn(
|
|
67
|
-
"inline-flex items-center rounded-full border px-2 py-0.5 font-mono",
|
|
68
|
-
isError
|
|
69
|
-
? "border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]"
|
|
70
|
-
: "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]",
|
|
71
|
-
)}
|
|
72
|
-
>
|
|
73
|
-
exit {output.exitCode}
|
|
74
|
-
</span>
|
|
75
|
-
) : null
|
|
76
|
-
}
|
|
144
|
+
<span
|
|
145
|
+
className={cn(
|
|
146
|
+
"inline-flex shrink-0 items-center rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums",
|
|
147
|
+
failed
|
|
148
|
+
? "bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]"
|
|
149
|
+
: "bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]",
|
|
150
|
+
)}
|
|
77
151
|
>
|
|
78
|
-
|
|
79
|
-
|
|
152
|
+
{label}
|
|
153
|
+
</span>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One shell command as a terminal block: a `$ command` prompt line with its
|
|
159
|
+
* exit status, and stdout and stderr in separate regions below it.
|
|
160
|
+
*
|
|
161
|
+
* The block is always dark. It scopes the dark token set onto itself with
|
|
162
|
+
* `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
|
|
163
|
+
* contrast they were designed with on a dark ground, in a light console too.
|
|
164
|
+
*/
|
|
165
|
+
export const CommandPreview = memo(
|
|
166
|
+
({ part, defaultExpanded = false, className }: CommandPreviewProps) => {
|
|
167
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
168
|
+
const bodyId = useId();
|
|
169
|
+
const command = commandOf(part.state.input);
|
|
170
|
+
const { status } = part.state;
|
|
171
|
+
const output = extractCommandOutput(part.state.output);
|
|
172
|
+
// A failed call often persists the same text twice — as its output and as
|
|
173
|
+
// its error. Print it once. Only an exact repeat (ignoring whitespace) is
|
|
174
|
+
// folded: an error that merely CONTAINS the output, or the reverse, is a
|
|
175
|
+
// distinct message and keeps its own region.
|
|
176
|
+
const normalize = (text: string) => text.replace(/\s+/g, " ").trim();
|
|
177
|
+
const errorRepeats =
|
|
178
|
+
part.state.error !== undefined &&
|
|
179
|
+
output.stdout.length > 0 &&
|
|
180
|
+
normalize(part.state.error) === normalize(output.stdout);
|
|
181
|
+
const errorText = errorRepeats ? undefined : part.state.error;
|
|
182
|
+
const hasBody =
|
|
183
|
+
output.stdout.length > 0 ||
|
|
184
|
+
output.stderr.length > 0 ||
|
|
185
|
+
(errorText !== undefined && errorText.length > 0);
|
|
186
|
+
|
|
187
|
+
const promptLine = (
|
|
188
|
+
<>
|
|
189
|
+
<span aria-hidden="true" className="shrink-0 select-none text-muted-foreground">
|
|
190
|
+
$
|
|
191
|
+
</span>
|
|
192
|
+
<code
|
|
193
|
+
className={cn(
|
|
194
|
+
"min-w-0 flex-1 whitespace-pre-wrap break-all",
|
|
195
|
+
expanded ? "" : "line-clamp-2",
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
{command}
|
|
199
|
+
</code>
|
|
200
|
+
<ExitBadge exitCode={output.exitCode} status={status} />
|
|
201
|
+
</>
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div
|
|
206
|
+
data-theme="dark"
|
|
207
|
+
data-testid="command-preview"
|
|
80
208
|
className={cn(
|
|
81
|
-
"
|
|
82
|
-
|
|
209
|
+
"overflow-hidden rounded-[var(--radius-md)] bg-[var(--md3-surface-container-lowest)] font-mono text-xs text-foreground",
|
|
210
|
+
className,
|
|
83
211
|
)}
|
|
84
212
|
>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
213
|
+
{hasBody ? (
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={() => setExpanded((value) => !value)}
|
|
217
|
+
aria-expanded={expanded}
|
|
218
|
+
aria-controls={bodyId}
|
|
219
|
+
className={cn(
|
|
220
|
+
"flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-white/5",
|
|
221
|
+
focusRing,
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{promptLine}
|
|
225
|
+
{expanded ? (
|
|
226
|
+
<ChevronDown className="mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
227
|
+
) : (
|
|
228
|
+
<ChevronRight className="mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
229
|
+
)}
|
|
230
|
+
</button>
|
|
95
231
|
) : (
|
|
96
|
-
<
|
|
232
|
+
<div className="flex w-full items-start gap-2 px-3 py-2">{promptLine}</div>
|
|
97
233
|
)}
|
|
98
|
-
</button>
|
|
99
|
-
|
|
100
|
-
{part.state.status === "running" ? <PreviewLoading /> : null}
|
|
101
|
-
{errorText ? <PreviewError error={errorText} /> : null}
|
|
102
234
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
235
|
+
{expanded && hasBody ? (
|
|
236
|
+
<div id={bodyId} className="border-t border-white/10">
|
|
237
|
+
{output.stdout ? (
|
|
238
|
+
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 leading-relaxed">
|
|
239
|
+
{output.stdout}
|
|
240
|
+
</pre>
|
|
241
|
+
) : null}
|
|
242
|
+
{output.stderr ? (
|
|
243
|
+
<pre
|
|
244
|
+
aria-label="stderr"
|
|
245
|
+
className="max-h-80 overflow-auto whitespace-pre-wrap break-all border-t border-white/10 px-3 py-2 leading-relaxed text-[var(--surface-danger-text)]"
|
|
246
|
+
>
|
|
247
|
+
{output.stderr}
|
|
248
|
+
</pre>
|
|
249
|
+
) : null}
|
|
250
|
+
{errorText ? (
|
|
251
|
+
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all border-t border-white/10 px-3 py-2 leading-relaxed text-[var(--surface-danger-text)]">
|
|
252
|
+
{errorText}
|
|
253
|
+
</pre>
|
|
254
|
+
) : null}
|
|
255
|
+
</div>
|
|
256
|
+
) : null}
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
);
|
|
120
261
|
CommandPreview.displayName = "CommandPreview";
|