@tangle-network/ui 11.16.4 → 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 +12 -0
- package/dist/openui/schema-compiled.js +108 -209
- package/dist/openui/schema-data.js +0 -1
- package/dist/openui-schema.js +4 -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/openui/schema-compiled.ts +1 -1
- package/src/openui/schema-data.ts +5 -1
- package/src/openui/schema.test.tsx +45 -0
- package/src/openui/schema.ts +5 -0
- 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
|
@@ -7,7 +7,11 @@ const string = { type: "string" } as const;
|
|
|
7
7
|
const boolean = { type: "boolean" } as const;
|
|
8
8
|
const gap = { enum: ["sm", "md", "lg"] } as const;
|
|
9
9
|
const primitive = { type: ["string", "number", "boolean", "null"] } as const;
|
|
10
|
-
|
|
10
|
+
// No minItems here: stack and grid require `children` and a child can itself be
|
|
11
|
+
// a stack, so a nonempty minimum makes a required recursive loop that model
|
|
12
|
+
// tool-schema converters reject (opencode refused render_ui outright). The
|
|
13
|
+
// persistence gate in schema.ts still rejects an empty container.
|
|
14
|
+
const children = { type: "array", items: { $ref: "#/$defs/node" } } as const;
|
|
11
15
|
const actions = { type: "array", minItems: 1, items: { $ref: "#/$defs/action" } } as const;
|
|
12
16
|
|
|
13
17
|
function node(type: string, fields: Record<string, unknown>, required: string[] = []) {
|
|
@@ -24,6 +24,45 @@ const validPage = {
|
|
|
24
24
|
],
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
type JsonSchema = Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Model tool-schema converters accept a recursive `$ref` only when the loop can
|
|
31
|
+
* terminate: it must pass through an optional property or an array that may be
|
|
32
|
+
* empty. Return the first `$defs` path that recurses through required,
|
|
33
|
+
* nonempty values only.
|
|
34
|
+
*/
|
|
35
|
+
function findRequiredRefLoop(defs: Record<string, JsonSchema>): string | null {
|
|
36
|
+
function visit(schema: JsonSchema, path: string, stack: string[]): string | null {
|
|
37
|
+
const ref = typeof schema.$ref === "string" ? schema.$ref.replace("#/$defs/", "") : null;
|
|
38
|
+
if (ref) {
|
|
39
|
+
if (stack.includes(ref)) return path;
|
|
40
|
+
return visit(defs[ref]!, `$defs.${ref}`, [...stack, ref]);
|
|
41
|
+
}
|
|
42
|
+
for (const key of ["oneOf", "anyOf"] as const) {
|
|
43
|
+
const options = schema[key];
|
|
44
|
+
if (Array.isArray(options)) {
|
|
45
|
+
for (const [index, option] of options.entries()) {
|
|
46
|
+
const loop = visit(option as JsonSchema, `${path}.${key}.${index}`, stack);
|
|
47
|
+
if (loop) return loop;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (schema.type === "array" && schema.items && Number(schema.minItems ?? 0) > 0) {
|
|
52
|
+
return visit(schema.items as JsonSchema, `${path}.items`, stack);
|
|
53
|
+
}
|
|
54
|
+
const required = Array.isArray(schema.required) ? schema.required as string[] : [];
|
|
55
|
+
const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
|
|
56
|
+
for (const name of required) {
|
|
57
|
+
if (!properties[name]) continue;
|
|
58
|
+
const loop = visit(properties[name], `${path}.properties.${name}`, stack);
|
|
59
|
+
if (loop) return loop;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return visit({ $ref: "#/$defs/node" }, "$", []);
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
describe("OpenUI JSON contract", () => {
|
|
28
67
|
it("advertises every renderer node and accepts nested JSON with primitive cells", () => {
|
|
29
68
|
expect(OPENUI_NODE_TYPES).toHaveLength(13);
|
|
@@ -32,6 +71,12 @@ describe("OpenUI JSON contract", () => {
|
|
|
32
71
|
expect(validateOpenUIJsonArtifact([validPage, { type: "text", text: "Afterword" }])).toMatchObject({ ok: true });
|
|
33
72
|
});
|
|
34
73
|
|
|
74
|
+
it("keeps every recursive node path terminable for model tool-schema converters", () => {
|
|
75
|
+
expect(findRequiredRefLoop(OPENUI_NODE_JSON_SCHEMA.$defs as unknown as Record<string, JsonSchema>)).toBeNull();
|
|
76
|
+
expect(validateOpenUIJsonNode({ type: "card", title: "Empty", children: [] }))
|
|
77
|
+
.toMatchObject({ ok: false, issue: { path: "$.children" } });
|
|
78
|
+
});
|
|
79
|
+
|
|
35
80
|
it("rejects an unsupported top-level or nested section before it can be dropped", () => {
|
|
36
81
|
expect(validateOpenUIJsonNode({ type: "section", children: [{ type: "text", text: "hidden" }] }))
|
|
37
82
|
.toMatchObject({ ok: false, issue: { path: "$", message: expect.stringContaining("section") } });
|
package/src/openui/schema.ts
CHANGED
|
@@ -40,6 +40,11 @@ export function findUnsupportedOpenUINode(value: unknown): OpenUISchemaIssue | n
|
|
|
40
40
|
return { path, message: `Unsupported OpenUI node type: ${String(record.type)}.` };
|
|
41
41
|
}
|
|
42
42
|
if (record.type === "stack" || record.type === "grid" || record.type === "card") {
|
|
43
|
+
// The model-facing schema cannot express this minimum without a required
|
|
44
|
+
// recursive loop (see schema-data.ts), so the gate enforces it here.
|
|
45
|
+
if (Array.isArray(record.children) && record.children.length === 0) {
|
|
46
|
+
return { path: `${path}.children`, message: "OpenUI containers need at least one child." };
|
|
47
|
+
}
|
|
43
48
|
if (Array.isArray(record.children)) {
|
|
44
49
|
for (const [index, child] of record.children.entries()) {
|
|
45
50
|
const issue = visit(child, `${path}.children[${index}]`, depth + 1);
|
|
@@ -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
|
>
|