@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 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, { part });
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
- * Terminal-style command output preview.
8
- * Shows the command, exit code, and stdout/stderr with expand/collapse.
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 { PreviewCard, PreviewError, PreviewLoading } from "./preview-primitives.js";
4
- import { memo, useState } from "react";
5
- import { jsx, jsxs } from "react/jsx-runtime";
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
- /** Extract stdout/stderr/exitCode from tool output. */
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 (!output) return {
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 === "object" && output !== null) {
21
- const obj = output;
46
+ if (typeof output === "string") {
47
+ const parsed = parseStructured(output);
48
+ if (parsed) return parsed;
22
49
  return {
23
- stdout: String(obj.stdout ?? obj.output ?? ""),
24
- stderr: String(obj.stderr ?? ""),
25
- exitCode: Number(obj.exitCode ?? obj.exit_code ?? obj.code ?? 0)
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
- * Terminal-style command output preview.
36
- * Shows the command, exit code, and stdout/stderr with expand/collapse.
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(true);
40
- const input = part.state.input;
41
- const command = typeof input?.command === "string" ? input.command : String(input ?? "");
42
- const output = part.state.status === "completed" ? extractCommandOutput(part.state.output) : null;
43
- const isError = output ? output.exitCode !== 0 : part.state.status === "error";
44
- const errorText = part.state.error;
45
- const lineCount = output?.stdout ? output.stdout.split("\n").length : 0;
46
- return /* @__PURE__ */ jsxs(PreviewCard, {
47
- icon: /* @__PURE__ */ jsx(Terminal, { className: "h-4 w-4" }),
48
- title: "Command",
49
- description: command,
50
- meta: output ? /* @__PURE__ */ jsxs("span", {
51
- className: cn("inline-flex items-center rounded-full border px-2 py-0.5 font-mono", isError ? "border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]" : "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]"),
52
- children: ["exit ", output.exitCode]
53
- }) : null,
54
- children: [
55
- /* @__PURE__ */ jsxs("button", {
56
- onClick: () => setExpanded((value) => !value),
57
- className: cn("flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-border bg-muted px-3 py-2 text-left transition-colors hover:border-[var(--border-strong)] hover:bg-accent/45", focusRing),
58
- children: [
59
- /* @__PURE__ */ jsx("code", {
60
- className: "min-w-0 flex-1 truncate text-xs font-mono text-foreground",
61
- children: command
62
- }),
63
- lineCount > 0 ? /* @__PURE__ */ jsxs("span", {
64
- className: "shrink-0 text-xs text-muted-foreground",
65
- children: [
66
- lineCount,
67
- " line",
68
- lineCount === 1 ? "" : "s"
69
- ]
70
- }) : null,
71
- expanded ? /* @__PURE__ */ jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" }) : /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5 text-muted-foreground" })
72
- ]
73
- }),
74
- part.state.status === "running" ? /* @__PURE__ */ jsx(PreviewLoading, {}) : null,
75
- errorText ? /* @__PURE__ */ jsx(PreviewError, { error: errorText }) : null,
76
- expanded && output ? /* @__PURE__ */ jsxs("div", {
77
- className: "overflow-hidden rounded-[var(--radius-md)] border border-border bg-muted",
78
- children: [output.stdout ? /* @__PURE__ */ jsx("pre", {
79
- className: "max-h-80 overflow-auto px-3 py-3 text-xs font-mono whitespace-pre-wrap break-all text-foreground",
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, output.stderr ? /* @__PURE__ */ jsx("pre", {
82
- className: "max-h-80 overflow-auto border-t border-border px-3 py-3 text-xs font-mono whitespace-pre-wrap break-all text-red-200",
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
- }) : null
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.15.3",
3
+ "version": "11.16.1",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- /** Extract stdout/stderr/exitCode from tool output. */
18
- function extractCommandOutput(output: unknown): {
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
- exitCode: number;
22
- } {
23
- if (!output) return { stdout: "", stderr: "", exitCode: 0 };
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
- if (typeof output === "object" && output !== null) {
28
- const obj = output as Record<string, unknown>;
29
- return {
30
- stdout: String(obj.stdout ?? obj.output ?? ""),
31
- stderr: String(obj.stderr ?? ""),
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
- * Terminal-style command output preview.
41
- * Shows the command, exit code, and stdout/stderr with expand/collapse.
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 const CommandPreview = memo(({ part }: CommandPreviewProps) => {
44
- const [expanded, setExpanded] = useState(true);
45
- const input = part.state.input as Record<string, unknown> | undefined;
46
- const command =
47
- typeof input?.command === "string" ? input.command : String(input ?? "");
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
- const output =
50
- part.state.status === "completed"
51
- ? extractCommandOutput(part.state.output)
52
- : null;
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
- const isError = output ? output.exitCode !== 0 : part.state.status === "error";
55
- const errorText = part.state.error;
56
- const lineCount = output?.stdout ? output.stdout.split("\n").length : 0;
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
- <PreviewCard
60
- icon={<Terminal className="h-4 w-4" />}
61
- title="Command"
62
- description={command}
63
- meta={
64
- output ? (
65
- <span
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
- <button
79
- onClick={() => setExpanded((value) => !value)}
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
- "flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-border bg-muted px-3 py-2 text-left transition-colors hover:border-[var(--border-strong)] hover:bg-accent/45",
82
- focusRing,
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
- <code className="min-w-0 flex-1 truncate text-xs font-mono text-foreground">
86
- {command}
87
- </code>
88
- {lineCount > 0 ? (
89
- <span className="shrink-0 text-xs text-muted-foreground">
90
- {lineCount} line{lineCount === 1 ? "" : "s"}
91
- </span>
92
- ) : null}
93
- {expanded ? (
94
- <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
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
- <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
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
- {expanded && output ? (
104
- <div className="overflow-hidden rounded-[var(--radius-md)] border border-border bg-muted">
105
- {output.stdout ? (
106
- <pre className="max-h-80 overflow-auto px-3 py-3 text-xs font-mono whitespace-pre-wrap break-all text-foreground">
107
- {output.stdout}
108
- </pre>
109
- ) : null}
110
- {output.stderr ? (
111
- <pre className="max-h-80 overflow-auto border-t border-border px-3 py-3 text-xs font-mono whitespace-pre-wrap break-all text-red-200">
112
- {output.stderr}
113
- </pre>
114
- ) : null}
115
- </div>
116
- ) : null}
117
- </PreviewCard>
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";