@tangle-network/ui 11.16.5 → 11.16.7

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,17 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.16.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 083c632: A closed terminal command has no border, so it reads as the same kind of row as the other tool calls beside it. On a phone its exit badge takes its own line under the command, so the command keeps the row's width. A tool error with nothing recorded about the command reads "tool reported error", which states what is known.
8
+
9
+ ## 11.16.6
10
+
11
+ ### Patch Changes
12
+
13
+ - 74fbea5: A closed terminal command is a quiet bordered row on the surface it sits on, and turns into the dark terminal only when opened, so a list of commands no longer reads as a stack of black bars. Red is kept for evidence the command failed: a nonzero exit, a timeout or a signal. A tool that reported an error with nothing recorded about the command shows a neutral "tool error" badge. A command wraps between its tokens, so `--short` is no longer split at its hyphen; a token wider than the line still breaks, and the copied text is unchanged. The status pill marks a failed state with a cross instead of a slashed disc, which read as "disabled".
14
+
3
15
  ## 11.16.5
4
16
 
5
17
  ### Patch Changes
@@ -10,8 +10,8 @@ import * as React from "react";
10
10
  * the tone's COLOUR, and the state's own LABEL as text.
11
11
  *
12
12
  * The glyphs are chosen to survive at 8px and to differ in outline rather than
13
- * in fill: a ring reads as "still open", a solid disc as "settled", a slashed
14
- * disc as "stopped". Two states never share one silhouette.
13
+ * in fill: a ring reads as "still open", a solid disc as "settled", a cross as
14
+ * "failed". Two states never share one silhouette.
15
15
  *
16
16
  * Each tone draws its fill, border and text from ONE matched token triple.
17
17
  * That pairing is the whole point: a status colour is solved against its own
@@ -45,22 +45,16 @@ function ToneGlyph({ tone }) {
45
45
  r: "3.25"
46
46
  })
47
47
  });
48
- case "danger": return /* @__PURE__ */ jsxs("svg", {
48
+ case "danger": return /* @__PURE__ */ jsx("svg", {
49
49
  ...common,
50
50
  "aria-hidden": "true",
51
51
  fill: "none",
52
52
  stroke: "currentColor",
53
- strokeWidth: "1.5",
54
- children: [/* @__PURE__ */ jsx("circle", {
55
- cx: "4",
56
- cy: "4",
57
- r: "3.1",
58
- fill: "currentColor",
59
- opacity: "0.35"
60
- }), /* @__PURE__ */ jsx("path", {
61
- d: "M1.9 6.1 6.1 1.9",
53
+ strokeWidth: "1.75",
54
+ children: /* @__PURE__ */ jsx("path", {
55
+ d: "M1.5 1.5 6.5 6.5M6.5 1.5 1.5 6.5",
62
56
  strokeLinecap: "round"
63
- })]
57
+ })
64
58
  });
65
59
  case "warning": return /* @__PURE__ */ jsx("svg", {
66
60
  ...common,
@@ -14,7 +14,9 @@ export interface CommandPreviewProps {
14
14
  * One shell command as a terminal block: a `$ command` prompt line with its
15
15
  * exit status, and stdout and stderr in separate regions below it.
16
16
  *
17
- * The block is always dark. It scopes the dark token set onto itself with
17
+ * Closed, a command is a quiet row on the surface it sits on: a list of
18
+ * commands should read as a list, not a stack of black bars. Opened, it is a
19
+ * dark terminal. It scopes the dark token set onto itself with
18
20
  * `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
19
21
  * contrast they were designed with on a dark ground, in a light console too.
20
22
  */
@@ -61,10 +61,14 @@ function extractCommandOutput(output) {
61
61
  }
62
62
  function fromObject(obj) {
63
63
  const text = (value) => value === void 0 || value === null ? "" : String(value);
64
+ const timedOut = obj.timedOut ?? obj.timed_out;
65
+ const signal = obj.signal;
64
66
  return {
65
67
  stdout: text(obj.stdout ?? obj.output),
66
68
  stderr: text(obj.stderr),
67
- exitCode: finiteNumber(obj.exitCode ?? obj.exit_code ?? obj.code)
69
+ exitCode: finiteNumber(obj.exitCode ?? obj.exit_code ?? obj.code),
70
+ ...timedOut === true ? { timedOut: true } : {},
71
+ ...typeof signal === "string" && signal.length > 0 ? { signal } : {}
68
72
  };
69
73
  }
70
74
  /** The keys a serialized command result carries, and nothing else. */
@@ -96,9 +100,12 @@ function parseStructured(text) {
96
100
  return;
97
101
  }
98
102
  }
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 }) {
103
+ /** Exit status as a badge. Red only where the command itself is known to have
104
+ * failed: a nonzero exit, a timeout, or a signal. A tool that reported an
105
+ * error with nothing recorded about the command (no exit code, or exit 0
106
+ * from a runner that failed afterwards) gets a neutral badge; its error text
107
+ * prints below. A clean run stays quiet green. */
108
+ function ExitBadge({ output, status }) {
102
109
  if (status === "running" || status === "pending") return /* @__PURE__ */ jsxs("span", {
103
110
  className: "inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground",
104
111
  children: [/* @__PURE__ */ jsx(Loader2, {
@@ -106,19 +113,53 @@ function ExitBadge({ exitCode, status }) {
106
113
  "aria-hidden": "true"
107
114
  }), "running"]
108
115
  });
109
- const failed = status === "error" || exitCode !== void 0 && exitCode !== 0;
110
- const label = exitCode !== void 0 ? `exit ${exitCode}` : failed ? "error" : null;
116
+ const { exitCode, timedOut, signal } = output;
117
+ let label;
118
+ let tone;
119
+ if (timedOut) {
120
+ label = "timed out";
121
+ tone = "danger";
122
+ } else if (signal) {
123
+ label = signal;
124
+ tone = "danger";
125
+ } else if (exitCode !== void 0 && exitCode !== 0) {
126
+ label = `exit ${exitCode}`;
127
+ tone = "danger";
128
+ } else if (status === "error") {
129
+ label = exitCode === 0 ? "exit 0 · tool reported error" : "tool reported error";
130
+ tone = "neutral";
131
+ } else {
132
+ label = exitCode === 0 ? "exit 0" : null;
133
+ tone = "success";
134
+ }
111
135
  if (label === null) return null;
112
136
  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)]"),
137
+ className: cn("inline-flex shrink-0 items-center rounded-full border px-1.5 py-px text-[11px] font-medium tabular-nums", tone === "danger" && "border-transparent bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]", tone === "success" && "border-transparent bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]", tone === "neutral" && "border-[var(--surface-neutral-border)] bg-[var(--surface-neutral-bg)] text-[var(--surface-neutral-text)]"),
114
138
  children: label
115
139
  });
116
140
  }
117
141
  /**
142
+ * A command split at its whitespace, each token in an inline box. Browsers
143
+ * break after a hyphen, which split `--short` into `--` and `short` on a
144
+ * phone. A box moves to the next line whole, and breaks inside only when it
145
+ * is wider than the line (a full SHA). The text is unchanged, so a copy still
146
+ * pastes the exact command.
147
+ */
148
+ function CommandText({ command, all }) {
149
+ const tokens = command.split(/(\s+)/);
150
+ const limit = all ? tokens.length : 128;
151
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [tokens.slice(0, limit).map((token, i) => token.length > 0 && !/\s/.test(token) ? /* @__PURE__ */ jsx("span", {
152
+ className: "inline-block max-w-full [overflow-wrap:anywhere]",
153
+ children: token
154
+ }, i) : token), limit < tokens.length ? tokens.slice(limit).join("") : null] });
155
+ }
156
+ /**
118
157
  * One shell command as a terminal block: a `$ command` prompt line with its
119
158
  * exit status, and stdout and stderr in separate regions below it.
120
159
  *
121
- * The block is always dark. It scopes the dark token set onto itself with
160
+ * Closed, a command is a quiet row on the surface it sits on: a list of
161
+ * commands should read as a list, not a stack of black bars. Opened, it is a
162
+ * dark terminal. It scopes the dark token set onto itself with
122
163
  * `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
123
164
  * contrast they were designed with on a dark ground, in a light console too.
124
165
  */
@@ -139,23 +180,30 @@ const CommandPreview = memo(({ part, defaultExpanded = false, className }) => {
139
180
  }),
140
181
  /* @__PURE__ */ jsx("code", {
141
182
  className: cn("min-w-0 flex-1 whitespace-pre-wrap [overflow-wrap:anywhere]", expanded ? "" : "line-clamp-2"),
142
- children: command
183
+ children: /* @__PURE__ */ jsx(CommandText, {
184
+ command,
185
+ all: expanded
186
+ })
143
187
  }),
144
- /* @__PURE__ */ jsx(ExitBadge, {
145
- exitCode: output.exitCode,
146
- status
188
+ /* @__PURE__ */ jsx("span", {
189
+ className: "flex shrink-0 max-sm:order-last max-sm:basis-full max-sm:pl-4",
190
+ children: /* @__PURE__ */ jsx(ExitBadge, {
191
+ output,
192
+ status
193
+ })
147
194
  })
148
195
  ] });
149
196
  return /* @__PURE__ */ jsxs("div", {
150
- "data-theme": "dark",
197
+ "data-theme": expanded ? "dark" : void 0,
151
198
  "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),
199
+ "data-expanded": expanded,
200
+ className: cn("overflow-hidden rounded-[var(--radius-md)] border font-mono text-xs text-foreground", expanded ? "border-[var(--md3-surface-container-lowest)] bg-[var(--md3-surface-container-lowest)]" : "border-transparent bg-transparent", className),
153
201
  children: [hasBody ? /* @__PURE__ */ jsxs("button", {
154
202
  type: "button",
155
203
  onClick: () => setExpanded((value) => !value),
156
204
  "aria-expanded": expanded,
157
205
  "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),
206
+ className: cn("flex w-full flex-wrap items-start gap-x-2 gap-y-1 px-3 py-2 text-left transition-colors sm:flex-nowrap", expanded ? "hover:bg-white/5" : "hover:bg-muted", focusRing),
159
207
  children: [promptLine, expanded ? /* @__PURE__ */ jsx(ChevronDown, {
160
208
  className: "mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground",
161
209
  "aria-hidden": "true"
@@ -164,7 +212,7 @@ const CommandPreview = memo(({ part, defaultExpanded = false, className }) => {
164
212
  "aria-hidden": "true"
165
213
  })]
166
214
  }) : /* @__PURE__ */ jsx("div", {
167
- className: "flex w-full items-start gap-2 px-3 py-2",
215
+ className: "flex w-full flex-wrap items-start gap-x-2 gap-y-1 px-3 py-2 sm:flex-nowrap",
168
216
  children: promptLine
169
217
  }), expanded && hasBody ? /* @__PURE__ */ jsxs("div", {
170
218
  id: bodyId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.16.5",
3
+ "version": "11.16.7",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -142,7 +142,7 @@
142
142
  "peerDependencies": {
143
143
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
144
144
  "@nanostores/react": ">=0.8.4 <3.0.0",
145
- "@tangle-network/brand": "^1.10.1",
145
+ "@tangle-network/brand": "^1.10.2",
146
146
  "@tiptap/core": ">=3.20.4 <4.0.0",
147
147
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
148
148
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
@@ -11,8 +11,8 @@ import { cn } from "../lib/utils";
11
11
  * the tone's COLOUR, and the state's own LABEL as text.
12
12
  *
13
13
  * The glyphs are chosen to survive at 8px and to differ in outline rather than
14
- * in fill: a ring reads as "still open", a solid disc as "settled", a slashed
15
- * disc as "stopped". Two states never share one silhouette.
14
+ * in fill: a ring reads as "still open", a solid disc as "settled", a cross as
15
+ * "failed". Two states never share one silhouette.
16
16
  *
17
17
  * Each tone draws its fill, border and text from ONE matched token triple.
18
18
  * That pairing is the whole point: a status colour is solved against its own
@@ -107,8 +107,8 @@ function ToneGlyph({ tone }: { tone: StatusTone }) {
107
107
  <circle cx="4" cy="4" r="3.25" />
108
108
  </svg>
109
109
  );
110
- // Settled and bad: a disc with a bar through it, so it differs from
111
- // success in OUTLINE and not only in hue.
110
+ // Settled and bad: a cross, so it differs from success in OUTLINE and not
111
+ // only in hue. A slashed disc read as "disabled" beside a failed run.
112
112
  case "danger":
113
113
  return (
114
114
  <svg
@@ -116,10 +116,9 @@ function ToneGlyph({ tone }: { tone: StatusTone }) {
116
116
  aria-hidden="true"
117
117
  fill="none"
118
118
  stroke="currentColor"
119
- strokeWidth="1.5"
119
+ strokeWidth="1.75"
120
120
  >
121
- <circle cx="4" cy="4" r="3.1" fill="currentColor" opacity="0.35" />
122
- <path d="M1.9 6.1 6.1 1.9" strokeLinecap="round" />
121
+ <path d="M1.5 1.5 6.5 6.5M6.5 1.5 1.5 6.5" strokeLinecap="round" />
123
122
  </svg>
124
123
  );
125
124
  // Needs a person: a triangle.
@@ -0,0 +1,73 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { ToolPart } from "../types/parts";
4
+ import { CommandPreview } from "./command-preview";
5
+
6
+ function part(state: Partial<ToolPart["state"]>): ToolPart {
7
+ return {
8
+ type: "tool",
9
+ id: "t1",
10
+ tool: "bash",
11
+ state: { status: "completed", input: { command: "git status --short" }, ...state },
12
+ } as ToolPart;
13
+ }
14
+
15
+ describe("CommandPreview", () => {
16
+ it("shows a neutral badge, not a red exit, when a tool errored without an exit code", () => {
17
+ render(<CommandPreview part={part({ status: "error", output: "warning: x" })} />);
18
+ const badge = screen.getByText("tool reported error");
19
+ expect(badge.className).toContain("surface-neutral");
20
+ expect(badge.className).not.toContain("surface-danger");
21
+ expect(screen.queryByText("error")).toBeNull();
22
+ });
23
+
24
+ it("keeps a recorded nonzero exit red", () => {
25
+ render(
26
+ <CommandPreview
27
+ part={part({ status: "error", output: { stdout: "", stderr: "no", exitCode: 2 } })}
28
+ />,
29
+ );
30
+ expect(screen.getByText("exit 2").className).toContain("surface-danger");
31
+ });
32
+
33
+ it("boxes each token so a phone wraps between flags", () => {
34
+ render(<CommandPreview part={part({ output: "M a" })} />);
35
+ const flag = screen.getByText("--short");
36
+ expect(flag.tagName).toBe("SPAN");
37
+ expect(flag.className).toContain("inline-block");
38
+ // A box wider than the line still breaks, so no token overflows.
39
+ expect(flag.className).toContain("[overflow-wrap:anywhere]");
40
+ // The text is unchanged, so a copy pastes the exact command.
41
+ expect(screen.getByTestId("command-preview").querySelector("code")?.textContent).toBe(
42
+ "git status --short",
43
+ );
44
+ });
45
+
46
+ it("treats a timeout or a signal as a failure even without an exit code", () => {
47
+ render(
48
+ <CommandPreview
49
+ part={part({ status: "error", output: { stdout: "", stderr: "", timedOut: true } })}
50
+ />,
51
+ );
52
+ expect(screen.getByText("timed out").className).toContain("surface-danger");
53
+ });
54
+
55
+ it("keeps a runner error after a clean exit neutral", () => {
56
+ render(
57
+ <CommandPreview
58
+ part={part({ status: "error", output: { stdout: "ok", stderr: "", exitCode: 0 } })}
59
+ />,
60
+ );
61
+ expect(screen.getByText("exit 0 · tool reported error").className).toContain("surface-neutral");
62
+ });
63
+
64
+ it("is a quiet row until opened, then a dark terminal", () => {
65
+ render(<CommandPreview part={part({ output: "M a" })} />);
66
+ const block = screen.getByTestId("command-preview");
67
+ expect(block).not.toHaveAttribute("data-theme");
68
+ expect(block.className).toContain("bg-transparent");
69
+ fireEvent.click(screen.getByRole("button"));
70
+ expect(block).toHaveAttribute("data-theme", "dark");
71
+ expect(screen.getByRole("region", { name: "stdout" })).toHaveTextContent("M a");
72
+ });
73
+ });
@@ -36,6 +36,10 @@ export interface CommandOutput {
36
36
  stderr: string;
37
37
  /** Undefined when the producer persisted plain text and never reported one. */
38
38
  exitCode: number | undefined;
39
+ /** The command ran past its time limit. */
40
+ timedOut?: boolean;
41
+ /** The signal that ended the command, such as `SIGKILL`. */
42
+ signal?: string;
39
43
  }
40
44
 
41
45
  function finiteNumber(value: unknown): number | undefined {
@@ -72,10 +76,14 @@ export function extractCommandOutput(output: unknown): CommandOutput {
72
76
  function fromObject(obj: Record<string, unknown>): CommandOutput {
73
77
  const text = (value: unknown) =>
74
78
  value === undefined || value === null ? "" : String(value);
79
+ const timedOut = obj.timedOut ?? obj.timed_out;
80
+ const signal = obj.signal;
75
81
  return {
76
82
  stdout: text(obj.stdout ?? obj.output),
77
83
  stderr: text(obj.stderr),
78
84
  exitCode: finiteNumber(obj.exitCode ?? obj.exit_code ?? obj.code),
85
+ ...(timedOut === true ? { timedOut: true } : {}),
86
+ ...(typeof signal === "string" && signal.length > 0 ? { signal } : {}),
79
87
  };
80
88
  }
81
89
 
@@ -117,13 +125,16 @@ function parseStructured(text: string): CommandOutput | undefined {
117
125
  }
118
126
  }
119
127
 
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. */
128
+ /** Exit status as a badge. Red only where the command itself is known to have
129
+ * failed: a nonzero exit, a timeout, or a signal. A tool that reported an
130
+ * error with nothing recorded about the command (no exit code, or exit 0
131
+ * from a runner that failed afterwards) gets a neutral badge; its error text
132
+ * prints below. A clean run stays quiet green. */
122
133
  function ExitBadge({
123
- exitCode,
134
+ output,
124
135
  status,
125
136
  }: {
126
- exitCode: number | undefined;
137
+ output: CommandOutput;
127
138
  status: ToolPart["state"]["status"];
128
139
  }) {
129
140
  if (status === "running" || status === "pending") {
@@ -134,19 +145,39 @@ function ExitBadge({
134
145
  </span>
135
146
  );
136
147
  }
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;
148
+ const { exitCode, timedOut, signal } = output;
149
+ let label: string | null;
150
+ let tone: "danger" | "success" | "neutral";
151
+ if (timedOut) {
152
+ label = "timed out";
153
+ tone = "danger";
154
+ } else if (signal) {
155
+ label = signal;
156
+ tone = "danger";
157
+ } else if (exitCode !== undefined && exitCode !== 0) {
158
+ label = `exit ${exitCode}`;
159
+ tone = "danger";
160
+ } else if (status === "error") {
161
+ // Say what is known: the tool reported an error. Nothing recorded says
162
+ // the command itself failed.
163
+ label = exitCode === 0 ? "exit 0 · tool reported error" : "tool reported error";
164
+ tone = "neutral";
165
+ } else {
166
+ label = exitCode === 0 ? "exit 0" : null;
167
+ tone = "success";
168
+ }
142
169
  if (label === null) return null;
143
170
  return (
144
171
  <span
145
172
  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)]",
173
+ // Every badge has a border, so mixed rows keep one height.
174
+ "inline-flex shrink-0 items-center rounded-full border px-1.5 py-px text-[11px] font-medium tabular-nums",
175
+ tone === "danger" &&
176
+ "border-transparent bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]",
177
+ tone === "success" &&
178
+ "border-transparent bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]",
179
+ tone === "neutral" &&
180
+ "border-[var(--surface-neutral-border)] bg-[var(--surface-neutral-bg)] text-[var(--surface-neutral-text)]",
150
181
  )}
151
182
  >
152
183
  {label}
@@ -154,11 +185,44 @@ function ExitBadge({
154
185
  );
155
186
  }
156
187
 
188
+ /** A closed row shows two lines, so only its first tokens need their own
189
+ * box; a long script renders the rest as plain text. */
190
+ const BOXED_TOKENS = 64;
191
+
192
+ /**
193
+ * A command split at its whitespace, each token in an inline box. Browsers
194
+ * break after a hyphen, which split `--short` into `--` and `short` on a
195
+ * phone. A box moves to the next line whole, and breaks inside only when it
196
+ * is wider than the line (a full SHA). The text is unchanged, so a copy still
197
+ * pastes the exact command.
198
+ */
199
+ function CommandText({ command, all }: { command: string; all: boolean }) {
200
+ const tokens = command.split(/(\s+)/);
201
+ const limit = all ? tokens.length : BOXED_TOKENS * 2;
202
+ return (
203
+ <>
204
+ {tokens.slice(0, limit).map((token, i) =>
205
+ token.length > 0 && !/\s/.test(token) ? (
206
+ // Tokens repeat (two spaces, two "&&"), so position is the key.
207
+ <span key={i} className="inline-block max-w-full [overflow-wrap:anywhere]">
208
+ {token}
209
+ </span>
210
+ ) : (
211
+ token
212
+ ),
213
+ )}
214
+ {limit < tokens.length ? tokens.slice(limit).join("") : null}
215
+ </>
216
+ );
217
+ }
218
+
157
219
  /**
158
220
  * One shell command as a terminal block: a `$ command` prompt line with its
159
221
  * exit status, and stdout and stderr in separate regions below it.
160
222
  *
161
- * The block is always dark. It scopes the dark token set onto itself with
223
+ * Closed, a command is a quiet row on the surface it sits on: a list of
224
+ * commands should read as a list, not a stack of black bars. Opened, it is a
225
+ * dark terminal. It scopes the dark token set onto itself with
162
226
  * `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
163
227
  * contrast they were designed with on a dark ground, in a light console too.
164
228
  */
@@ -195,18 +259,28 @@ export const CommandPreview = memo(
195
259
  expanded ? "" : "line-clamp-2",
196
260
  )}
197
261
  >
198
- {command}
262
+ <CommandText command={command} all={expanded} />
199
263
  </code>
200
- <ExitBadge exitCode={output.exitCode} status={status} />
264
+ {/* On a phone the badge takes its own line under the command, so the
265
+ command keeps the row's width. */}
266
+ <span className="flex shrink-0 max-sm:order-last max-sm:basis-full max-sm:pl-4">
267
+ <ExitBadge output={output} status={status} />
268
+ </span>
201
269
  </>
202
270
  );
203
271
 
204
272
  return (
205
273
  <div
206
- data-theme="dark"
274
+ data-theme={expanded ? "dark" : undefined}
207
275
  data-testid="command-preview"
276
+ data-expanded={expanded}
208
277
  className={cn(
209
- "overflow-hidden rounded-[var(--radius-md)] bg-[var(--md3-surface-container-lowest)] font-mono text-xs text-foreground",
278
+ // One border in both states, so opening a row does not shift the
279
+ // list; the terminal's is its own fill colour.
280
+ "overflow-hidden rounded-[var(--radius-md)] border font-mono text-xs text-foreground",
281
+ expanded
282
+ ? "border-[var(--md3-surface-container-lowest)] bg-[var(--md3-surface-container-lowest)]"
283
+ : "border-transparent bg-transparent",
210
284
  className,
211
285
  )}
212
286
  >
@@ -217,7 +291,8 @@ export const CommandPreview = memo(
217
291
  aria-expanded={expanded}
218
292
  aria-controls={bodyId}
219
293
  className={cn(
220
- "flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-white/5",
294
+ "flex w-full flex-wrap items-start gap-x-2 gap-y-1 px-3 py-2 text-left transition-colors sm:flex-nowrap",
295
+ expanded ? "hover:bg-white/5" : "hover:bg-muted",
221
296
  focusRing,
222
297
  )}
223
298
  >
@@ -229,7 +304,9 @@ export const CommandPreview = memo(
229
304
  )}
230
305
  </button>
231
306
  ) : (
232
- <div className="flex w-full items-start gap-2 px-3 py-2">{promptLine}</div>
307
+ <div className="flex w-full flex-wrap items-start gap-x-2 gap-y-1 px-3 py-2 sm:flex-nowrap">
308
+ {promptLine}
309
+ </div>
233
310
  )}
234
311
 
235
312
  {expanded && hasBody ? (