@artooi/ag-ui-web-component 0.10.0 → 0.11.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +89 -7
  3. package/dist/ag-ui-web-component.bundle.js +175 -54
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +29 -0
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +28 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +589 -72
  12. package/dist/index.js.map +4 -4
  13. package/dist/ui/approval_card.d.ts +51 -0
  14. package/dist/ui/approval_card.d.ts.map +1 -0
  15. package/dist/ui/attachment_chips.d.ts.map +1 -1
  16. package/dist/ui/attachment_tray.d.ts.map +1 -1
  17. package/dist/ui/question_card.d.ts +52 -0
  18. package/dist/ui/question_card.d.ts.map +1 -0
  19. package/dist/ui/skills_menu.d.ts.map +1 -1
  20. package/dist/ui/styles.d.ts +1 -1
  21. package/dist/ui/styles.d.ts.map +1 -1
  22. package/dist/ui/thoughts_block.d.ts.map +1 -1
  23. package/dist/ui/thread_drawer.d.ts.map +1 -1
  24. package/dist/ui/ui_strings.d.ts +16 -0
  25. package/dist/ui/ui_strings.d.ts.map +1 -1
  26. package/package.json +4 -4
  27. package/src/core/ag_ui_chat.ts +172 -3
  28. package/src/core/agui_client.ts +86 -11
  29. package/src/index.ts +14 -0
  30. package/src/ui/approval_card.ts +119 -0
  31. package/src/ui/attachment_chips.ts +5 -0
  32. package/src/ui/attachment_tray.ts +8 -0
  33. package/src/ui/question_card.ts +216 -0
  34. package/src/ui/skills_menu.ts +6 -0
  35. package/src/ui/styles.ts +121 -0
  36. package/src/ui/thoughts_block.ts +1 -0
  37. package/src/ui/thread_drawer.ts +11 -0
  38. package/src/ui/ui_strings.ts +30 -0
  39. package/src/version.ts +1 -1
@@ -1,5 +1,11 @@
1
- import { type AbstractAgent, type AgentSubscriber, randomUUID } from "@ag-ui/client";
2
- import type { Context, Message, Tool } from "@ag-ui/core";
1
+ import {
2
+ type AbstractAgent,
3
+ type AgentSubscriber,
4
+ buildResumeArray,
5
+ type RunAgentParameters,
6
+ randomUUID,
7
+ } from "@ag-ui/client";
8
+ import type { Context, Interrupt, Message, ResumeEntry, Tool } from "@ag-ui/core";
3
9
  import { MAX_TOOL_ROUNDS } from "../constants.js";
4
10
  import type { AttachmentRef } from "./attachment.js";
5
11
 
@@ -33,6 +39,27 @@ export interface ToolExecution {
33
39
  */
34
40
  export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
35
41
 
42
+ /**
43
+ * One user decision for a server-side-tool approval interrupt. Structurally
44
+ * matches `@ag-ui/client`'s (non-exported) `ResumeResponse`, the payload
45
+ * {@link buildResumeArray} turns into a `ResumeEntry`: `resolved` approves (with
46
+ * an optional `payload`, e.g. `{ approved: true }`), `cancelled` denies.
47
+ */
48
+ export type InterruptResponse = { status: "resolved"; payload?: unknown } | { status: "cancelled" };
49
+
50
+ /**
51
+ * Resolves the approval interrupts a run finished on, keyed by interrupt id.
52
+ *
53
+ * When a gated **server-side** tool defers instead of executing, the run
54
+ * finishes on an AG-UI interrupt outcome; the host renders an approval card per
55
+ * interrupt and returns each decision here, and the loop resumes the run with
56
+ * the answers. Omit for agents that never gate server-side tools — an
57
+ * unresolved interrupt then simply ends the loop.
58
+ */
59
+ export type ResolveInterrupts = (
60
+ interrupts: readonly Interrupt[],
61
+ ) => Promise<Record<string, InterruptResponse>>;
62
+
36
63
  /**
37
64
  * Callbacks the {@link AgUiClient} invokes as a run progresses. The host
38
65
  * (the `<ag-ui-chat>` element) implements these to render streaming text and
@@ -93,6 +120,11 @@ export interface AgUiClientConfig extends AgUiRunInputs {
93
120
  handlers: AgUiClientHandlers;
94
121
  /** Executes frontend tool calls. Omit for server-only tool sets. */
95
122
  executeTool?: ExecuteTool;
123
+ /**
124
+ * Resolves server-side-tool approval interrupts. Omit when no server-side
125
+ * tool is gated for approval — an interrupt then ends the loop unanswered.
126
+ */
127
+ resolveInterrupts?: ResolveInterrupts;
96
128
  /**
97
129
  * Invoked with the latest history whenever it changes, so the host can
98
130
  * persist it for durability across page reloads. Omit to keep the
@@ -134,6 +166,7 @@ export class AgUiClient {
134
166
  readonly #getTools: () => Tool[];
135
167
  readonly #getContext: () => Context[];
136
168
  readonly #executeTool: ExecuteTool | null;
169
+ readonly #resolveInterrupts: ResolveInterrupts | null;
137
170
  readonly #onPersist: (messages: readonly Message[]) => void;
138
171
  readonly #connectionLostMessage: string;
139
172
  // Set by cancel(); reset at the top of each #run(). Checked by the loop so
@@ -146,6 +179,7 @@ export class AgUiClient {
146
179
  this.#getTools = config.getTools ?? (() => []);
147
180
  this.#getContext = config.getContext ?? (() => []);
148
181
  this.#executeTool = config.executeTool ?? null;
182
+ this.#resolveInterrupts = config.resolveInterrupts ?? null;
149
183
  this.#onPersist = config.onPersist ?? (() => {});
150
184
  this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
151
185
  }
@@ -241,6 +275,11 @@ export class AgUiClient {
241
275
  }
242
276
 
243
277
  async #runLoop(): Promise<void> {
278
+ // Carries the resolved approval answers into the *next* run when a round
279
+ // finished on a server-side-tool interrupt. Distinct from the public
280
+ // resume() navigation-reload path (which continues an unfinished
281
+ // frontend-tool round after a page load) — this stays inside one #run().
282
+ let resume: ResumeEntry[] | undefined;
244
283
  for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
245
284
  // A cancel during the previous round's frontend-tool execution lands
246
285
  // here: the running handler completed, but no further round starts.
@@ -248,11 +287,16 @@ export class AgUiClient {
248
287
  return;
249
288
  }
250
289
  const pending: AgUiToolCall[] = [];
251
- const runState = { terminal: false, errored: false };
252
- await this.#agent.runAgent(
253
- { tools: this.#getTools(), context: this.#getContext() },
254
- this.#buildSubscriber(pending, runState),
255
- );
290
+ const runState: RunState = { terminal: false, errored: false, interrupts: [] };
291
+ const params: RunAgentParameters = {
292
+ tools: this.#getTools(),
293
+ context: this.#getContext(),
294
+ };
295
+ if (resume !== undefined) {
296
+ params.resume = resume;
297
+ }
298
+ await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
299
+ resume = undefined;
256
300
  this.#onPersist(this.#agent.messages);
257
301
  // Cancelled mid-stream: the user said stop — don't execute the tool
258
302
  // calls collected before the abort.
@@ -272,6 +316,22 @@ export class AgUiClient {
272
316
  if (runState.errored) {
273
317
  return;
274
318
  }
319
+ // A gated server-side tool deferred instead of executing: the run finished
320
+ // on an interrupt outcome. Ask the host to resolve each interrupt, then
321
+ // re-enter the loop carrying the answers — the follow-up run runs or denies
322
+ // the tool (its result streams back as TOOL_CALL_RESULT). Takes precedence
323
+ // over the frontend-tool sweep below: a server-side tool isn't ours to run.
324
+ if (runState.interrupts.length > 0) {
325
+ if (this.#resolveInterrupts === null) {
326
+ return;
327
+ }
328
+ const responses = await this.#resolveInterrupts(runState.interrupts);
329
+ if (this.#cancelled) {
330
+ return;
331
+ }
332
+ resume = buildResumeArray(runState.interrupts, responses);
333
+ continue;
334
+ }
275
335
  if (this.#executeTool === null || pending.length === 0) {
276
336
  return;
277
337
  }
@@ -301,10 +361,7 @@ export class AgUiClient {
301
361
  }
302
362
  }
303
363
 
304
- #buildSubscriber(
305
- pending: AgUiToolCall[],
306
- runState: { terminal: boolean; errored: boolean },
307
- ): AgentSubscriber {
364
+ #buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
308
365
  const h = this.#handlers;
309
366
  return {
310
367
  onRunInitialized() {
@@ -340,6 +397,16 @@ export class AgUiClient {
340
397
  onReasoningEndEvent() {
341
398
  h.onReasoningEnd();
342
399
  },
400
+ onRunFinishedEvent(params) {
401
+ // RUN_FINISHED is terminal for both a normal finish and an interrupt.
402
+ // Capturing the interrupts here (rather than reading the agent's
403
+ // `pendingInterrupts` field afterwards) keeps the loop self-contained
404
+ // and independent of that field's cross-run clearing semantics.
405
+ runState.terminal = true;
406
+ if (params.outcome === "interrupt") {
407
+ runState.interrupts = params.interrupts;
408
+ }
409
+ },
343
410
  onRunErrorEvent({ event }) {
344
411
  runState.terminal = true;
345
412
  runState.errored = true;
@@ -353,6 +420,14 @@ export class AgUiClient {
353
420
  }
354
421
  }
355
422
 
423
+ /** Per-run mutable flags the subscriber writes and {@link AgUiClient} reads. */
424
+ interface RunState {
425
+ terminal: boolean;
426
+ errored: boolean;
427
+ /** Approval interrupts a run finished on (empty for a normal finish). */
428
+ interrupts: Interrupt[];
429
+ }
430
+
356
431
  /**
357
432
  * Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
358
433
  * with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ export {
27
27
  type AgUiToolCall,
28
28
  ConnectionLostError,
29
29
  type ExecuteTool,
30
+ type InterruptResponse,
31
+ type ResolveInterrupts,
30
32
  type ToolExecution,
31
33
  } from "./core/agui_client.js";
32
34
  export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
@@ -98,12 +100,24 @@ export {
98
100
  type RouteWithParams,
99
101
  } from "./tools/route_map.js";
100
102
  export { createStateHookTools, type StateHook } from "./tools/state_hook.js";
103
+ export {
104
+ type ApprovalOptions,
105
+ type ApprovalRenderer,
106
+ type ApprovalRequest,
107
+ requestApproval,
108
+ } from "./ui/approval_card.js";
101
109
  export {
102
110
  type ConfirmationOptions,
103
111
  type ConfirmationRequest,
104
112
  requestConfirmation,
105
113
  } from "./ui/confirmation_card.js";
106
114
  export { prettifyToolName } from "./ui/prettify_tool_name.js";
115
+ export {
116
+ type QuestionOptions,
117
+ type QuestionRenderer,
118
+ type QuestionRequest,
119
+ requestQuestion,
120
+ } from "./ui/question_card.js";
107
121
  export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
108
122
  export {
109
123
  type SettledStatus,
@@ -0,0 +1,119 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
3
+ /** What the inline approval card displays for one server-side-tool interrupt. */
4
+ export interface ApprovalRequest {
5
+ /**
6
+ * Human-readable prompt from the AG-UI interrupt (e.g.
7
+ * `Approve delete_thing({…})?`). Falls back to the generic
8
+ * {@link UiStrings.approvalPrompt} when the interrupt carries no message.
9
+ */
10
+ message?: string;
11
+ /** Tool name, surfaced as a `data-tool-name` attribute for styling/tests. */
12
+ toolName?: string;
13
+ }
14
+
15
+ /** Build a labelled action button. */
16
+ function actionButton(modifier: string, label: string): HTMLButtonElement {
17
+ const button = document.createElement("button");
18
+ button.type = "button";
19
+ button.className = `approval-btn approval-btn--${modifier}`;
20
+ button.setAttribute("part", `approval-button approval-${modifier}`);
21
+ button.textContent = label;
22
+ return button;
23
+ }
24
+
25
+ /** Options for {@link requestApproval}. */
26
+ export interface ApprovalOptions {
27
+ /**
28
+ * Aborting this signal resolves the card as **denied** (buttons disabled,
29
+ * `data-resolved="denied"`) — the hook a Stop control uses to dismiss a
30
+ * pending approval when the user cancels the whole run.
31
+ */
32
+ signal?: AbortSignal;
33
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
34
+ strings?: UiStrings;
35
+ }
36
+
37
+ /**
38
+ * A fully custom renderer for a server-side-tool approval, set via
39
+ * `AgUiChat.approvalRenderer`. Receives the {@link ApprovalRequest} (the
40
+ * interrupt's message + tool name) and an `AbortSignal` that fires when the run
41
+ * is stopped, and resolves `true` to approve or `false` to deny. When provided
42
+ * it **replaces** the built-in {@link requestApproval} card entirely — the host
43
+ * owns the DOM, so it can render a native modal, a framework component, or
44
+ * anything else. See the `strings` / `::part()` seams for styling the built-in
45
+ * card instead of replacing it.
46
+ */
47
+ export type ApprovalRenderer = (
48
+ request: ApprovalRequest,
49
+ options: { signal: AbortSignal },
50
+ ) => Promise<boolean>;
51
+
52
+ /**
53
+ * Append an inline **approval** card to ``host`` and resolve when the user
54
+ * decides whether a gated *server-side* tool may run.
55
+ *
56
+ * The server-side approval gate is distinct from the client-tool confirmation
57
+ * card ({@link requestConfirmation}): a destructive server tool defers instead
58
+ * of executing, and the run finishes on an AG-UI *interrupt* the client answers
59
+ * with `resume[]`. This card is the browser half of that loop — it reads
60
+ * naturally after the tool-call card whose execution it gates, and resolves
61
+ * ``true`` to approve (run the tool) or ``false`` to deny. The card stays in the
62
+ * transcript as a resolved record (buttons disabled, `data-resolved` set)
63
+ * rather than vanishing.
64
+ */
65
+ export function requestApproval(
66
+ host: Node & ParentNode,
67
+ request: ApprovalRequest,
68
+ options: ApprovalOptions = {},
69
+ ): Promise<boolean> {
70
+ const strings = options.strings ?? DEFAULT_UI_STRINGS;
71
+ return new Promise<boolean>((resolve) => {
72
+ const card = document.createElement("div");
73
+ card.className = "approval";
74
+ card.setAttribute("part", "approval");
75
+ if (request.toolName !== undefined) {
76
+ card.setAttribute("data-tool-name", request.toolName);
77
+ }
78
+ card.setAttribute("role", "group");
79
+ card.setAttribute("aria-label", strings.approveAction);
80
+
81
+ const body = document.createElement("div");
82
+ body.className = "approval-body";
83
+ body.setAttribute("part", "approval-body");
84
+ body.textContent = request.message ?? strings.approvalPrompt;
85
+
86
+ const actions = document.createElement("div");
87
+ actions.className = "approval-actions";
88
+ actions.setAttribute("part", "approval-actions");
89
+
90
+ const deny = actionButton("deny", strings.deny);
91
+ const approve = actionButton("approve", strings.approve);
92
+
93
+ let settled = false;
94
+ const close = (approved: boolean): void => {
95
+ if (settled) {
96
+ return;
97
+ }
98
+ settled = true;
99
+ deny.disabled = true;
100
+ approve.disabled = true;
101
+ card.setAttribute("data-resolved", approved ? "approved" : "denied");
102
+ resolve(approved);
103
+ };
104
+
105
+ deny.addEventListener("click", () => close(false));
106
+ approve.addEventListener("click", () => close(true));
107
+ options.signal?.addEventListener("abort", () => close(false), { once: true });
108
+
109
+ actions.append(deny, approve);
110
+ card.append(body, actions);
111
+ host.appendChild(card);
112
+ if (options.signal?.aborted === true) {
113
+ // The run was cancelled before the card could ask; record the denial.
114
+ close(false);
115
+ return;
116
+ }
117
+ approve.focus();
118
+ });
119
+ }
@@ -9,6 +9,7 @@ import type { AttachmentRef } from "../core/attachment.js";
9
9
  export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivElement {
10
10
  const list = document.createElement("div");
11
11
  list.className = "attachment-chips";
12
+ list.setAttribute("part", "attachment-chips");
12
13
  for (const ref of refs) {
13
14
  list.appendChild(renderChip(ref));
14
15
  }
@@ -18,19 +19,23 @@ export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivEl
18
19
  function renderChip(ref: AttachmentRef): HTMLDivElement {
19
20
  const chip = document.createElement("div");
20
21
  chip.className = "attachment-chip attachment-chip--ready";
22
+ chip.setAttribute("part", "attachment-chip");
21
23
 
22
24
  const icon = document.createElement("span");
23
25
  icon.className = "attachment-chip-icon";
26
+ icon.setAttribute("part", "attachment-chip-icon");
24
27
  icon.textContent = iconFor(ref.mime);
25
28
  icon.setAttribute("aria-hidden", "true");
26
29
 
27
30
  const name = document.createElement("span");
28
31
  name.className = "attachment-chip-name";
32
+ name.setAttribute("part", "attachment-chip-name");
29
33
  name.textContent = ref.name;
30
34
  name.title = ref.name;
31
35
 
32
36
  const size = document.createElement("span");
33
37
  size.className = "attachment-chip-size";
38
+ size.setAttribute("part", "attachment-chip-size");
34
39
  size.textContent = formatBytes(ref.size);
35
40
 
36
41
  chip.append(icon, name, size);
@@ -205,19 +205,23 @@ export class AttachmentTray {
205
205
  #renderChip(item: TrayItem): HTMLDivElement {
206
206
  const chip = document.createElement("div");
207
207
  chip.className = `attachment-chip attachment-chip--${item.status}`;
208
+ chip.setAttribute("part", "attachment-chip");
208
209
 
209
210
  const icon = document.createElement("span");
210
211
  icon.className = "attachment-chip-icon";
212
+ icon.setAttribute("part", "attachment-chip-icon");
211
213
  icon.textContent = iconFor(item.file.type);
212
214
  icon.setAttribute("aria-hidden", "true");
213
215
 
214
216
  const name = document.createElement("span");
215
217
  name.className = "attachment-chip-name";
218
+ name.setAttribute("part", "attachment-chip-name");
216
219
  name.textContent = item.file.name;
217
220
  name.title = item.file.name;
218
221
 
219
222
  const meta = document.createElement("span");
220
223
  meta.className = "attachment-chip-size";
224
+ meta.setAttribute("part", "attachment-chip-size");
221
225
  meta.textContent =
222
226
  item.status === ATTACHMENT_STATUS.ERROR ? item.error : formatBytes(item.file.size);
223
227
 
@@ -226,8 +230,10 @@ export class AttachmentTray {
226
230
  if (item.status === ATTACHMENT_STATUS.UPLOADING) {
227
231
  const bar = document.createElement("div");
228
232
  bar.className = "attachment-chip-bar";
233
+ bar.setAttribute("part", "attachment-chip-bar");
229
234
  const fill = document.createElement("div");
230
235
  fill.className = "attachment-chip-bar-fill";
236
+ fill.setAttribute("part", "attachment-chip-bar-fill");
231
237
  fill.style.width = `${Math.round(item.progress * 100)}%`;
232
238
  bar.appendChild(fill);
233
239
  chip.appendChild(bar);
@@ -237,6 +243,7 @@ export class AttachmentTray {
237
243
  const retry = document.createElement("button");
238
244
  retry.type = "button";
239
245
  retry.className = "attachment-chip-retry";
246
+ retry.setAttribute("part", "attachment-chip-retry");
240
247
  retry.title = this.#strings.retry;
241
248
  retry.setAttribute("aria-label", this.#strings.retryUpload);
242
249
  retry.textContent = "↻";
@@ -247,6 +254,7 @@ export class AttachmentTray {
247
254
  const remove = document.createElement("button");
248
255
  remove.type = "button";
249
256
  remove.className = "attachment-chip-remove";
257
+ remove.setAttribute("part", "attachment-chip-remove");
250
258
  remove.title = this.#strings.remove;
251
259
  remove.setAttribute("aria-label", this.#strings.removeAttachment);
252
260
  remove.textContent = "✕";
@@ -0,0 +1,216 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
3
+ /** What the inline question card asks (the `ask_user` frontend tool's args). */
4
+ export interface QuestionRequest {
5
+ /** The question shown to the user. */
6
+ question: string;
7
+ /**
8
+ * Preset choices rendered as radios. When empty/omitted the card is a plain
9
+ * free-text prompt.
10
+ */
11
+ options?: readonly string[];
12
+ /**
13
+ * Whether the user may type a custom answer. With `options`, adds an "other"
14
+ * radio revealing a text field; without options the card is free-text anyway.
15
+ */
16
+ allowCustom?: boolean;
17
+ }
18
+
19
+ /** Options for {@link requestQuestion}. */
20
+ export interface QuestionOptions {
21
+ /**
22
+ * Aborting this signal resolves the card with an empty answer (fields
23
+ * disabled) — the hook a Stop control uses to dismiss an open question when
24
+ * the user cancels the whole run.
25
+ */
26
+ signal?: AbortSignal;
27
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
28
+ strings?: UiStrings;
29
+ }
30
+
31
+ /**
32
+ * A fully custom renderer for the `ask_user` question, set via
33
+ * `AgUiChat.askUserRenderer`. Receives the parsed {@link QuestionRequest} and an
34
+ * `AbortSignal` that fires when the run is stopped, and resolves with the user's
35
+ * answer (an empty string signals "no answer", e.g. on abort). When provided it
36
+ * **replaces** the built-in {@link requestQuestion} card entirely — the host owns
37
+ * the DOM, so it can render a native modal, a framework component, or anything
38
+ * else. See the `strings` / `::part()` seams for styling the built-in card
39
+ * instead of replacing it.
40
+ */
41
+ export type QuestionRenderer = (
42
+ request: QuestionRequest,
43
+ options: { signal: AbortSignal },
44
+ ) => Promise<string>;
45
+
46
+ /** A single custom-answer text field. */
47
+ function answerInput(placeholder: string): HTMLInputElement {
48
+ const input = document.createElement("input");
49
+ input.type = "text";
50
+ input.className = "question-input";
51
+ input.setAttribute("part", "question-input");
52
+ input.placeholder = placeholder;
53
+ return input;
54
+ }
55
+
56
+ /**
57
+ * Append an inline **question** card to ``host`` and resolve with the user's
58
+ * answer — the browser half of the built-in `ask_user` frontend tool.
59
+ *
60
+ * Unlike the confirmation/approval cards (which resolve a yes/no), this collects
61
+ * a typed answer: a radio pick from ``options``, or free text (when
62
+ * ``allowCustom`` or no ``options`` are given). The card stays in the transcript
63
+ * as a resolved record (controls disabled, `data-resolved` set) rather than
64
+ * vanishing. A Stop while it is open resolves it with an empty string.
65
+ */
66
+ export function requestQuestion(
67
+ host: Node & ParentNode,
68
+ request: QuestionRequest,
69
+ options: QuestionOptions = {},
70
+ ): Promise<string> {
71
+ const strings = options.strings ?? DEFAULT_UI_STRINGS;
72
+ const choices = request.options ?? [];
73
+ const hasChoices = choices.length > 0;
74
+ // Free text is offered when there are no preset choices, or when custom
75
+ // answers are explicitly allowed alongside them (via an "other" radio).
76
+ const allowsText = !hasChoices || request.allowCustom === true;
77
+
78
+ return new Promise<string>((resolve) => {
79
+ const card = document.createElement("div");
80
+ card.className = "question";
81
+ card.setAttribute("part", "question");
82
+ card.setAttribute("role", "group");
83
+ card.setAttribute("aria-label", strings.askUserAction);
84
+
85
+ const body = document.createElement("div");
86
+ body.className = "question-body";
87
+ body.setAttribute("part", "question-body");
88
+ body.textContent = request.question;
89
+
90
+ const form = document.createElement("div");
91
+ form.className = "question-options";
92
+ form.setAttribute("part", "question-options");
93
+
94
+ // `name` scopes the radio group to this card so multiple open cards don't
95
+ // interfere; a per-card token keeps it unique without module state.
96
+ const group = `q-${choices.length}-${request.question.length}`;
97
+ const radios: HTMLInputElement[] = [];
98
+ for (const choice of choices) {
99
+ const label = document.createElement("label");
100
+ label.className = "question-choice";
101
+ label.setAttribute("part", "question-choice");
102
+ const radio = document.createElement("input");
103
+ radio.type = "radio";
104
+ radio.name = group;
105
+ radio.value = choice;
106
+ radio.setAttribute("part", "question-radio");
107
+ const text = document.createElement("span");
108
+ text.setAttribute("part", "question-choice-text");
109
+ text.textContent = choice;
110
+ label.append(radio, text);
111
+ form.appendChild(label);
112
+ radios.push(radio);
113
+ }
114
+
115
+ // The "other" radio (only alongside choices) toggles the free-text field.
116
+ let otherRadio: HTMLInputElement | null = null;
117
+ let input: HTMLInputElement | null = null;
118
+ if (allowsText) {
119
+ input = answerInput(strings.answerPlaceholder);
120
+ if (hasChoices) {
121
+ const label = document.createElement("label");
122
+ label.className = "question-choice";
123
+ label.setAttribute("part", "question-choice");
124
+ otherRadio = document.createElement("input");
125
+ otherRadio.type = "radio";
126
+ otherRadio.name = group;
127
+ otherRadio.value = "";
128
+ otherRadio.setAttribute("part", "question-radio");
129
+ const text = document.createElement("span");
130
+ text.setAttribute("part", "question-choice-text");
131
+ text.textContent = strings.otherOption;
132
+ label.append(otherRadio, text);
133
+ form.appendChild(label);
134
+ input.disabled = true;
135
+ }
136
+ form.appendChild(input);
137
+ }
138
+
139
+ const actions = document.createElement("div");
140
+ actions.className = "question-actions";
141
+ actions.setAttribute("part", "question-actions");
142
+ const submit = document.createElement("button");
143
+ submit.type = "button";
144
+ submit.className = "question-btn";
145
+ submit.setAttribute("part", "question-button");
146
+ submit.textContent = strings.submit;
147
+ actions.appendChild(submit);
148
+
149
+ let settled = false;
150
+ const answerFor = (): string | null => {
151
+ const picked = radios.find((r) => r.checked);
152
+ if (picked !== undefined) {
153
+ return picked.value;
154
+ }
155
+ if (input !== null && (otherRadio === null || otherRadio.checked)) {
156
+ const typed = input.value.trim();
157
+ return typed === "" ? null : typed;
158
+ }
159
+ return null;
160
+ };
161
+ const refresh = (): void => {
162
+ if (input !== null && otherRadio !== null) {
163
+ input.disabled = !otherRadio.checked;
164
+ }
165
+ submit.disabled = answerFor() === null;
166
+ };
167
+ const close = (answer: string): void => {
168
+ if (settled) {
169
+ return;
170
+ }
171
+ settled = true;
172
+ submit.disabled = true;
173
+ for (const radio of radios) {
174
+ radio.disabled = true;
175
+ }
176
+ if (otherRadio !== null) {
177
+ otherRadio.disabled = true;
178
+ }
179
+ if (input !== null) {
180
+ input.disabled = true;
181
+ }
182
+ card.setAttribute("data-resolved", answer === "" ? "cancelled" : "answered");
183
+ resolve(answer);
184
+ };
185
+
186
+ for (const radio of [...radios, ...(otherRadio !== null ? [otherRadio] : [])]) {
187
+ radio.addEventListener("change", refresh);
188
+ }
189
+ input?.addEventListener("input", refresh);
190
+ input?.addEventListener("keydown", (event) => {
191
+ if (event.key === "Enter") {
192
+ event.preventDefault();
193
+ const answer = answerFor();
194
+ if (answer !== null) {
195
+ close(answer);
196
+ }
197
+ }
198
+ });
199
+ submit.addEventListener("click", () => {
200
+ const answer = answerFor();
201
+ if (answer !== null) {
202
+ close(answer);
203
+ }
204
+ });
205
+ options.signal?.addEventListener("abort", () => close(""), { once: true });
206
+
207
+ card.append(body, form, actions);
208
+ host.appendChild(card);
209
+ if (options.signal?.aborted === true) {
210
+ close("");
211
+ return;
212
+ }
213
+ refresh();
214
+ (hasChoices ? radios[0] : input)?.focus();
215
+ });
216
+ }
@@ -24,9 +24,11 @@ export class SkillsMenu {
24
24
  this.#onPick = onPick;
25
25
  this.chips = document.createElement("div");
26
26
  this.chips.className = "skill-chips";
27
+ this.chips.setAttribute("part", "skill-chips");
27
28
  this.chips.hidden = true;
28
29
  this.palette = document.createElement("div");
29
30
  this.palette.className = "skill-palette";
31
+ this.palette.setAttribute("part", "skill-palette");
30
32
  this.palette.setAttribute("role", "listbox");
31
33
  this.palette.hidden = true;
32
34
  }
@@ -137,6 +139,7 @@ export class SkillsMenu {
137
139
  const button = document.createElement("button");
138
140
  button.type = "button";
139
141
  button.className = "skill-chip";
142
+ button.setAttribute("part", "skill-chip");
140
143
  button.textContent = skill.title;
141
144
  button.addEventListener("click", () => this.#pick(skill));
142
145
  this.chips.appendChild(button);
@@ -149,17 +152,20 @@ export class SkillsMenu {
149
152
  const item = document.createElement("button");
150
153
  item.type = "button";
151
154
  item.className = "skill-item";
155
+ item.setAttribute("part", "skill-item");
152
156
  item.setAttribute("role", "option");
153
157
  item.setAttribute("aria-selected", index === this.#activeIndex ? "true" : "false");
154
158
 
155
159
  const title = document.createElement("span");
156
160
  title.className = "skill-item-title";
161
+ title.setAttribute("part", "skill-item-title");
157
162
  title.textContent = skill.title;
158
163
  item.appendChild(title);
159
164
 
160
165
  if (skill.description !== undefined) {
161
166
  const desc = document.createElement("span");
162
167
  desc.className = "skill-item-desc";
168
+ desc.setAttribute("part", "skill-item-desc");
163
169
  desc.textContent = skill.description;
164
170
  item.appendChild(desc);
165
171
  }