@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.
@@ -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
- const children = { type: "array", minItems: 1, items: { $ref: "#/$defs/node" } } as const;
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") } });
@@ -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 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 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: 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,37 @@ 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
+ 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
- "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)]",
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
- * The block is always dark. It scopes the dark token set onto itself with
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 exitCode={output.exitCode} status={status} />
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
- "overflow-hidden rounded-[var(--radius-md)] bg-[var(--md3-surface-container-lowest)] font-mono text-xs text-foreground",
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 hover:bg-white/5",
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
  >