@tangle-network/ui 11.16.5 → 11.16.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/primitives/status-pill.d.ts +2 -2
- package/dist/primitives/status-pill.js +5 -11
- package/dist/tool-previews/command-preview.d.ts +3 -1
- package/dist/tool-previews/command-preview.js +58 -13
- package/package.json +2 -2
- package/src/primitives/status-pill.tsx +6 -7
- package/src/tool-previews/command-preview.test.tsx +73 -0
- package/src/tool-previews/command-preview.tsx +88 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.16.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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".
|
|
8
|
+
|
|
3
9
|
## 11.16.5
|
|
4
10
|
|
|
5
11
|
### 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
|
|
14
|
-
*
|
|
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__ */
|
|
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.
|
|
54
|
-
children:
|
|
55
|
-
|
|
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
|
-
*
|
|
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
|
|
100
|
-
*
|
|
101
|
-
|
|
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
|
|
110
|
-
|
|
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 error" : "tool 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",
|
|
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
|
-
*
|
|
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,27 @@ 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:
|
|
183
|
+
children: /* @__PURE__ */ jsx(CommandText, {
|
|
184
|
+
command,
|
|
185
|
+
all: expanded
|
|
186
|
+
})
|
|
143
187
|
}),
|
|
144
188
|
/* @__PURE__ */ jsx(ExitBadge, {
|
|
145
|
-
|
|
189
|
+
output,
|
|
146
190
|
status
|
|
147
191
|
})
|
|
148
192
|
] });
|
|
149
193
|
return /* @__PURE__ */ jsxs("div", {
|
|
150
|
-
"data-theme": "dark",
|
|
194
|
+
"data-theme": expanded ? "dark" : void 0,
|
|
151
195
|
"data-testid": "command-preview",
|
|
152
|
-
|
|
196
|
+
"data-expanded": expanded,
|
|
197
|
+
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-border bg-transparent", className),
|
|
153
198
|
children: [hasBody ? /* @__PURE__ */ jsxs("button", {
|
|
154
199
|
type: "button",
|
|
155
200
|
onClick: () => setExpanded((value) => !value),
|
|
156
201
|
"aria-expanded": expanded,
|
|
157
202
|
"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),
|
|
203
|
+
className: cn("flex w-full items-start gap-2 px-3 py-2 text-left transition-colors", expanded ? "hover:bg-white/5" : "hover:bg-muted", focusRing),
|
|
159
204
|
children: [promptLine, expanded ? /* @__PURE__ */ jsx(ChevronDown, {
|
|
160
205
|
className: "mt-px h-3.5 w-3.5 shrink-0 text-muted-foreground",
|
|
161
206
|
"aria-hidden": "true"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.16.
|
|
3
|
+
"version": "11.16.6",
|
|
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.
|
|
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
|
|
15
|
-
*
|
|
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
|
|
111
|
-
//
|
|
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.
|
|
119
|
+
strokeWidth="1.75"
|
|
120
120
|
>
|
|
121
|
-
<
|
|
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 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 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
|
|
121
|
-
*
|
|
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
|
-
|
|
134
|
+
output,
|
|
124
135
|
status,
|
|
125
136
|
}: {
|
|
126
|
-
|
|
137
|
+
output: CommandOutput;
|
|
127
138
|
status: ToolPart["state"]["status"];
|
|
128
139
|
}) {
|
|
129
140
|
if (status === "running" || status === "pending") {
|
|
@@ -134,19 +145,37 @@ function ExitBadge({
|
|
|
134
145
|
</span>
|
|
135
146
|
);
|
|
136
147
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
label = exitCode === 0 ? "exit 0 · tool error" : "tool error";
|
|
162
|
+
tone = "neutral";
|
|
163
|
+
} else {
|
|
164
|
+
label = exitCode === 0 ? "exit 0" : null;
|
|
165
|
+
tone = "success";
|
|
166
|
+
}
|
|
142
167
|
if (label === null) return null;
|
|
143
168
|
return (
|
|
144
169
|
<span
|
|
145
170
|
className={cn(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
171
|
+
// Every badge has a border, so mixed rows keep one height.
|
|
172
|
+
"inline-flex shrink-0 items-center rounded-full border px-1.5 py-px text-[11px] font-medium tabular-nums",
|
|
173
|
+
tone === "danger" &&
|
|
174
|
+
"border-transparent bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]",
|
|
175
|
+
tone === "success" &&
|
|
176
|
+
"border-transparent bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]",
|
|
177
|
+
tone === "neutral" &&
|
|
178
|
+
"border-[var(--surface-neutral-border)] bg-[var(--surface-neutral-bg)] text-[var(--surface-neutral-text)]",
|
|
150
179
|
)}
|
|
151
180
|
>
|
|
152
181
|
{label}
|
|
@@ -154,11 +183,44 @@ function ExitBadge({
|
|
|
154
183
|
);
|
|
155
184
|
}
|
|
156
185
|
|
|
186
|
+
/** A closed row shows two lines, so only its first tokens need their own
|
|
187
|
+
* box; a long script renders the rest as plain text. */
|
|
188
|
+
const BOXED_TOKENS = 64;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A command split at its whitespace, each token in an inline box. Browsers
|
|
192
|
+
* break after a hyphen, which split `--short` into `--` and `short` on a
|
|
193
|
+
* phone. A box moves to the next line whole, and breaks inside only when it
|
|
194
|
+
* is wider than the line (a full SHA). The text is unchanged, so a copy still
|
|
195
|
+
* pastes the exact command.
|
|
196
|
+
*/
|
|
197
|
+
function CommandText({ command, all }: { command: string; all: boolean }) {
|
|
198
|
+
const tokens = command.split(/(\s+)/);
|
|
199
|
+
const limit = all ? tokens.length : BOXED_TOKENS * 2;
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
{tokens.slice(0, limit).map((token, i) =>
|
|
203
|
+
token.length > 0 && !/\s/.test(token) ? (
|
|
204
|
+
// Tokens repeat (two spaces, two "&&"), so position is the key.
|
|
205
|
+
<span key={i} className="inline-block max-w-full [overflow-wrap:anywhere]">
|
|
206
|
+
{token}
|
|
207
|
+
</span>
|
|
208
|
+
) : (
|
|
209
|
+
token
|
|
210
|
+
),
|
|
211
|
+
)}
|
|
212
|
+
{limit < tokens.length ? tokens.slice(limit).join("") : null}
|
|
213
|
+
</>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
157
217
|
/**
|
|
158
218
|
* One shell command as a terminal block: a `$ command` prompt line with its
|
|
159
219
|
* exit status, and stdout and stderr in separate regions below it.
|
|
160
220
|
*
|
|
161
|
-
*
|
|
221
|
+
* Closed, a command is a quiet row on the surface it sits on: a list of
|
|
222
|
+
* commands should read as a list, not a stack of black bars. Opened, it is a
|
|
223
|
+
* dark terminal. It scopes the dark token set onto itself with
|
|
162
224
|
* `data-theme="dark"`, so stderr, the badges and the muted prompt keep the
|
|
163
225
|
* contrast they were designed with on a dark ground, in a light console too.
|
|
164
226
|
*/
|
|
@@ -195,18 +257,24 @@ export const CommandPreview = memo(
|
|
|
195
257
|
expanded ? "" : "line-clamp-2",
|
|
196
258
|
)}
|
|
197
259
|
>
|
|
198
|
-
{command}
|
|
260
|
+
<CommandText command={command} all={expanded} />
|
|
199
261
|
</code>
|
|
200
|
-
<ExitBadge
|
|
262
|
+
<ExitBadge output={output} status={status} />
|
|
201
263
|
</>
|
|
202
264
|
);
|
|
203
265
|
|
|
204
266
|
return (
|
|
205
267
|
<div
|
|
206
|
-
data-theme="dark"
|
|
268
|
+
data-theme={expanded ? "dark" : undefined}
|
|
207
269
|
data-testid="command-preview"
|
|
270
|
+
data-expanded={expanded}
|
|
208
271
|
className={cn(
|
|
209
|
-
|
|
272
|
+
// One border in both states, so opening a row does not shift the
|
|
273
|
+
// list; the terminal's is its own fill colour.
|
|
274
|
+
"overflow-hidden rounded-[var(--radius-md)] border font-mono text-xs text-foreground",
|
|
275
|
+
expanded
|
|
276
|
+
? "border-[var(--md3-surface-container-lowest)] bg-[var(--md3-surface-container-lowest)]"
|
|
277
|
+
: "border-border bg-transparent",
|
|
210
278
|
className,
|
|
211
279
|
)}
|
|
212
280
|
>
|
|
@@ -217,7 +285,8 @@ export const CommandPreview = memo(
|
|
|
217
285
|
aria-expanded={expanded}
|
|
218
286
|
aria-controls={bodyId}
|
|
219
287
|
className={cn(
|
|
220
|
-
"flex w-full items-start gap-2 px-3 py-2 text-left transition-colors
|
|
288
|
+
"flex w-full items-start gap-2 px-3 py-2 text-left transition-colors",
|
|
289
|
+
expanded ? "hover:bg-white/5" : "hover:bg-muted",
|
|
221
290
|
focusRing,
|
|
222
291
|
)}
|
|
223
292
|
>
|