@tangle-network/ui 11.15.3 → 11.16.0

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,11 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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).
8
+
3
9
  ## 11.15.3
4
10
 
5
11
  ### 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,176 @@
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
+ /** A string that is a serialized `{ stdout, stderr, exitCode }` record. */
71
+ function parseStructured(text) {
72
+ const trimmed = text.trimStart();
73
+ if (!trimmed.startsWith("{")) return void 0;
74
+ try {
75
+ const parsed = JSON.parse(trimmed);
76
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
77
+ const obj = parsed;
78
+ if (!("stdout" in obj) && !("stderr" in obj)) return void 0;
79
+ return fromObject(obj);
80
+ } catch {
81
+ return;
82
+ }
83
+ }
84
+ /** Exit status as a badge: the code when one is known, else the tool's status.
85
+ * Red only for a nonzero exit or an error; a clean run stays quiet green. */
86
+ function ExitBadge({ exitCode, status }) {
87
+ if (status === "running" || status === "pending") return /* @__PURE__ */ jsxs("span", {
88
+ className: "inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground",
89
+ children: [/* @__PURE__ */ jsx(Loader2, {
90
+ className: "h-3 w-3 animate-spin",
91
+ "aria-hidden": "true"
92
+ }), "running"]
93
+ });
94
+ const failed = exitCode !== void 0 ? exitCode !== 0 : status === "error";
95
+ const label = exitCode !== void 0 ? `exit ${exitCode}` : failed ? "error" : null;
96
+ if (label === null) return null;
97
+ return /* @__PURE__ */ jsx("span", {
98
+ 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)]"),
99
+ children: label
100
+ });
101
+ }
34
102
  /**
35
- * Terminal-style command output preview.
36
- * Shows the command, exit code, and stdout/stderr with expand/collapse.
103
+ * One shell command as a terminal block: a `$ command` prompt line with its
104
+ * exit status, and stdout and stderr in separate regions below it.
105
+ *
106
+ * The block is always dark. It scopes the dark token set onto itself with
107
+ * `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
108
+ * contrast they were designed with on a dark ground, in a light console too.
37
109
  */
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";
110
+ const CommandPreview = memo(({ part, defaultExpanded = false, className }) => {
111
+ const [expanded, setExpanded] = useState(defaultExpanded);
112
+ const bodyId = useId();
113
+ const command = commandOf(part.state.input);
114
+ const { status } = part.state;
115
+ const output = extractCommandOutput(part.state.output);
44
116
  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",
117
+ const hasBody = output.stdout.length > 0 || output.stderr.length > 0 || errorText !== void 0 && errorText.length > 0;
118
+ const promptLine = /* @__PURE__ */ jsxs(Fragment$1, { children: [
119
+ /* @__PURE__ */ jsx("span", {
120
+ "aria-hidden": "true",
121
+ className: "shrink-0 select-none text-muted-foreground",
122
+ children: "$"
123
+ }),
124
+ /* @__PURE__ */ jsx("code", {
125
+ className: cn("min-w-0 flex-1 whitespace-pre-wrap break-all", expanded ? "" : "line-clamp-2"),
126
+ children: command
127
+ }),
128
+ /* @__PURE__ */ jsx(ExitBadge, {
129
+ exitCode: output.exitCode,
130
+ status
131
+ })
132
+ ] });
133
+ return /* @__PURE__ */ jsxs("div", {
134
+ "data-theme": "dark",
135
+ "data-testid": "command-preview",
136
+ className: cn("overflow-hidden rounded-[var(--radius-md)] bg-[var(--md3-surface-container-lowest)] font-mono text-xs text-foreground", className),
137
+ children: [hasBody ? /* @__PURE__ */ jsxs("button", {
138
+ type: "button",
139
+ onClick: () => setExpanded((value) => !value),
140
+ "aria-expanded": expanded,
141
+ "aria-controls": bodyId,
142
+ className: cn("flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-white/5", focusRing),
143
+ children: [promptLine, expanded ? /* @__PURE__ */ jsx(ChevronDown, {
144
+ className: "mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground",
145
+ "aria-hidden": "true"
146
+ }) : /* @__PURE__ */ jsx(ChevronRight, {
147
+ className: "mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground",
148
+ "aria-hidden": "true"
149
+ })]
150
+ }) : /* @__PURE__ */ jsx("div", {
151
+ className: "flex w-full items-start gap-2 px-3 py-2",
152
+ children: promptLine
153
+ }), expanded && hasBody ? /* @__PURE__ */ jsxs("div", {
154
+ id: bodyId,
155
+ className: "border-t border-white/10",
156
+ children: [
157
+ output.stdout ? /* @__PURE__ */ jsx("pre", {
158
+ className: "max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 leading-relaxed",
80
159
  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",
160
+ }) : null,
161
+ output.stderr ? /* @__PURE__ */ jsx("pre", {
162
+ "aria-label": "stderr",
163
+ 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
164
  children: output.stderr
84
- }) : null]
85
- }) : null
86
- ]
165
+ }) : null,
166
+ errorText ? /* @__PURE__ */ jsx("pre", {
167
+ 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)]",
168
+ children: errorText
169
+ }) : null
170
+ ]
171
+ }) : null]
87
172
  });
88
173
  });
89
174
  CommandPreview.displayName = "CommandPreview";
90
175
  //#endregion
91
- export { CommandPreview };
176
+ 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.0",
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,229 @@
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
+ }
48
71
 
49
- const output =
50
- part.state.status === "completed"
51
- ? extractCommandOutput(part.state.output)
52
- : null;
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
+ }
53
81
 
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;
82
+ /** A string that is a serialized `{ stdout, stderr, exitCode }` record. */
83
+ function parseStructured(text: string): CommandOutput | undefined {
84
+ const trimmed = text.trimStart();
85
+ if (!trimmed.startsWith("{")) return undefined;
86
+ try {
87
+ const parsed: unknown = JSON.parse(trimmed);
88
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
89
+ return undefined;
90
+ }
91
+ const obj = parsed as Record<string, unknown>;
92
+ if (!("stdout" in obj) && !("stderr" in obj)) return undefined;
93
+ return fromObject(obj);
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
57
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({
102
+ exitCode,
103
+ status,
104
+ }: {
105
+ exitCode: number | undefined;
106
+ status: ToolPart["state"]["status"];
107
+ }) {
108
+ if (status === "running" || status === "pending") {
109
+ return (
110
+ <span className="inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
111
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
112
+ running
113
+ </span>
114
+ );
115
+ }
116
+ const failed = exitCode !== undefined ? exitCode !== 0 : status === "error";
117
+ const label =
118
+ exitCode !== undefined ? `exit ${exitCode}` : failed ? "error" : null;
119
+ if (label === null) return null;
58
120
  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
- }
121
+ <span
122
+ className={cn(
123
+ "inline-flex shrink-0 items-center rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums",
124
+ failed
125
+ ? "bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]"
126
+ : "bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]",
127
+ )}
77
128
  >
78
- <button
79
- onClick={() => setExpanded((value) => !value)}
129
+ {label}
130
+ </span>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * One shell command as a terminal block: a `$ command` prompt line with its
136
+ * exit status, and stdout and stderr in separate regions below it.
137
+ *
138
+ * The block is always dark. It scopes the dark token set onto itself with
139
+ * `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
140
+ * contrast they were designed with on a dark ground, in a light console too.
141
+ */
142
+ export const CommandPreview = memo(
143
+ ({ part, defaultExpanded = false, className }: CommandPreviewProps) => {
144
+ const [expanded, setExpanded] = useState(defaultExpanded);
145
+ const bodyId = useId();
146
+ const command = commandOf(part.state.input);
147
+ const { status } = part.state;
148
+ const output = extractCommandOutput(part.state.output);
149
+ const errorText = part.state.error;
150
+ const hasBody =
151
+ output.stdout.length > 0 ||
152
+ output.stderr.length > 0 ||
153
+ (errorText !== undefined && errorText.length > 0);
154
+
155
+ const promptLine = (
156
+ <>
157
+ <span aria-hidden="true" className="shrink-0 select-none text-muted-foreground">
158
+ $
159
+ </span>
160
+ <code
161
+ className={cn(
162
+ "min-w-0 flex-1 whitespace-pre-wrap break-all",
163
+ expanded ? "" : "line-clamp-2",
164
+ )}
165
+ >
166
+ {command}
167
+ </code>
168
+ <ExitBadge exitCode={output.exitCode} status={status} />
169
+ </>
170
+ );
171
+
172
+ return (
173
+ <div
174
+ data-theme="dark"
175
+ data-testid="command-preview"
80
176
  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,
177
+ "overflow-hidden rounded-[var(--radius-md)] bg-[var(--md3-surface-container-lowest)] font-mono text-xs text-foreground",
178
+ className,
83
179
  )}
84
180
  >
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" />
181
+ {hasBody ? (
182
+ <button
183
+ type="button"
184
+ onClick={() => setExpanded((value) => !value)}
185
+ aria-expanded={expanded}
186
+ aria-controls={bodyId}
187
+ className={cn(
188
+ "flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-white/5",
189
+ focusRing,
190
+ )}
191
+ >
192
+ {promptLine}
193
+ {expanded ? (
194
+ <ChevronDown className="mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
195
+ ) : (
196
+ <ChevronRight className="mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
197
+ )}
198
+ </button>
95
199
  ) : (
96
- <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
200
+ <div className="flex w-full items-start gap-2 px-3 py-2">{promptLine}</div>
97
201
  )}
98
- </button>
99
-
100
- {part.state.status === "running" ? <PreviewLoading /> : null}
101
- {errorText ? <PreviewError error={errorText} /> : null}
102
202
 
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
- });
203
+ {expanded && hasBody ? (
204
+ <div id={bodyId} className="border-t border-white/10">
205
+ {output.stdout ? (
206
+ <pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 leading-relaxed">
207
+ {output.stdout}
208
+ </pre>
209
+ ) : null}
210
+ {output.stderr ? (
211
+ <pre
212
+ aria-label="stderr"
213
+ 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)]"
214
+ >
215
+ {output.stderr}
216
+ </pre>
217
+ ) : null}
218
+ {errorText ? (
219
+ <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)]">
220
+ {errorText}
221
+ </pre>
222
+ ) : null}
223
+ </div>
224
+ ) : null}
225
+ </div>
226
+ );
227
+ },
228
+ );
120
229
  CommandPreview.displayName = "CommandPreview";