@artooi/ag-ui-web-component 0.1.0 → 0.2.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 (109) hide show
  1. package/CHANGELOG.md +102 -2
  2. package/README.md +235 -47
  3. package/dist/ag-ui-web-component.bundle.js +451 -46
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +34 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/{ag_ui_chat.d.ts → core/ag_ui_chat.d.ts} +63 -6
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -0
  9. package/dist/core/agui_client.d.ts.map +1 -0
  10. package/dist/core/conversation_store.d.ts.map +1 -0
  11. package/dist/core/create_http_agent.d.ts.map +1 -0
  12. package/dist/core/define_ag_ui_chat.d.ts.map +1 -0
  13. package/dist/dom/animations.d.ts +71 -0
  14. package/dist/dom/animations.d.ts.map +1 -0
  15. package/dist/{dom_driver.d.ts → dom/dom_driver.d.ts} +7 -1
  16. package/dist/dom/dom_driver.d.ts.map +1 -0
  17. package/dist/dom/native_setter.d.ts +5 -0
  18. package/dist/dom/native_setter.d.ts.map +1 -0
  19. package/dist/index.d.ts +19 -16
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3886 -388
  22. package/dist/index.js.map +4 -4
  23. package/dist/skills/fill_template.d.ts +17 -0
  24. package/dist/skills/fill_template.d.ts.map +1 -0
  25. package/dist/skills/parse_skills.d.ts +8 -0
  26. package/dist/skills/parse_skills.d.ts.map +1 -0
  27. package/dist/skills/skill.d.ts +24 -0
  28. package/dist/skills/skill.d.ts.map +1 -0
  29. package/dist/{client_tool_registry.d.ts → tools/client_tool_registry.d.ts} +6 -1
  30. package/dist/tools/client_tool_registry.d.ts.map +1 -0
  31. package/dist/tools/is_destructive.d.ts.map +1 -0
  32. package/dist/tools/is_navigates.d.ts.map +1 -0
  33. package/dist/tools/page_map.d.ts.map +1 -0
  34. package/dist/tools/route_map.d.ts +40 -0
  35. package/dist/tools/route_map.d.ts.map +1 -0
  36. package/dist/tools/state_hook.d.ts.map +1 -0
  37. package/dist/ui/confirmation_card.d.ts +22 -0
  38. package/dist/ui/confirmation_card.d.ts.map +1 -0
  39. package/dist/ui/render_markdown.d.ts +13 -0
  40. package/dist/ui/render_markdown.d.ts.map +1 -0
  41. package/dist/ui/reveal_words.d.ts +8 -0
  42. package/dist/ui/reveal_words.d.ts.map +1 -0
  43. package/dist/ui/skills_menu.d.ts +35 -0
  44. package/dist/ui/skills_menu.d.ts.map +1 -0
  45. package/dist/ui/styles.d.ts +2 -0
  46. package/dist/ui/styles.d.ts.map +1 -0
  47. package/dist/ui/tool_call_card.d.ts +35 -0
  48. package/dist/ui/tool_call_card.d.ts.map +1 -0
  49. package/package.json +5 -3
  50. package/src/constants.ts +38 -0
  51. package/src/core/ag_ui_chat.ts +786 -0
  52. package/src/{agui_client.ts → core/agui_client.ts} +1 -1
  53. package/src/{define_ag_ui_chat.ts → core/define_ag_ui_chat.ts} +1 -1
  54. package/src/dom/animations.ts +201 -0
  55. package/src/{dom_driver.ts → dom/dom_driver.ts} +35 -2
  56. package/src/dom/native_setter.ts +42 -0
  57. package/src/index.ts +61 -32
  58. package/src/skills/fill_template.ts +32 -0
  59. package/src/skills/parse_skills.ts +26 -0
  60. package/src/skills/skill.ts +23 -0
  61. package/src/{client_tool_registry.ts → tools/client_tool_registry.ts} +6 -4
  62. package/src/{is_destructive.ts → tools/is_destructive.ts} +1 -1
  63. package/src/{is_navigates.ts → tools/is_navigates.ts} +1 -1
  64. package/src/tools/route_map.ts +140 -0
  65. package/src/{state_hook.ts → tools/state_hook.ts} +1 -1
  66. package/src/ui/confirmation_card.ts +71 -0
  67. package/src/ui/render_markdown.ts +68 -0
  68. package/src/ui/reveal_words.ts +41 -0
  69. package/src/ui/skills_menu.ts +171 -0
  70. package/src/ui/styles.ts +631 -0
  71. package/src/{tool_call_card.ts → ui/tool_call_card.ts} +50 -19
  72. package/src/version.ts +1 -1
  73. package/dist/ag_ui_chat.d.ts.map +0 -1
  74. package/dist/agui_client.d.ts.map +0 -1
  75. package/dist/animations.d.ts +0 -33
  76. package/dist/animations.d.ts.map +0 -1
  77. package/dist/client_tool_registry.d.ts.map +0 -1
  78. package/dist/confirmation_modal.d.ts +0 -14
  79. package/dist/confirmation_modal.d.ts.map +0 -1
  80. package/dist/conversation_store.d.ts.map +0 -1
  81. package/dist/create_http_agent.d.ts.map +0 -1
  82. package/dist/define_ag_ui_chat.d.ts.map +0 -1
  83. package/dist/dom_driver.d.ts.map +0 -1
  84. package/dist/is_destructive.d.ts.map +0 -1
  85. package/dist/is_navigates.d.ts.map +0 -1
  86. package/dist/page_map.d.ts.map +0 -1
  87. package/dist/route_map.d.ts +0 -27
  88. package/dist/route_map.d.ts.map +0 -1
  89. package/dist/state_hook.d.ts.map +0 -1
  90. package/dist/styles.d.ts +0 -2
  91. package/dist/styles.d.ts.map +0 -1
  92. package/dist/tool_call_card.d.ts +0 -29
  93. package/dist/tool_call_card.d.ts.map +0 -1
  94. package/src/ag_ui_chat.ts +0 -411
  95. package/src/animations.ts +0 -86
  96. package/src/confirmation_modal.ts +0 -69
  97. package/src/route_map.ts +0 -83
  98. package/src/styles.ts +0 -296
  99. /package/dist/{agui_client.d.ts → core/agui_client.d.ts} +0 -0
  100. /package/dist/{conversation_store.d.ts → core/conversation_store.d.ts} +0 -0
  101. /package/dist/{create_http_agent.d.ts → core/create_http_agent.d.ts} +0 -0
  102. /package/dist/{define_ag_ui_chat.d.ts → core/define_ag_ui_chat.d.ts} +0 -0
  103. /package/dist/{is_destructive.d.ts → tools/is_destructive.d.ts} +0 -0
  104. /package/dist/{is_navigates.d.ts → tools/is_navigates.d.ts} +0 -0
  105. /package/dist/{page_map.d.ts → tools/page_map.d.ts} +0 -0
  106. /package/dist/{state_hook.d.ts → tools/state_hook.d.ts} +0 -0
  107. /package/src/{conversation_store.ts → core/conversation_store.ts} +0 -0
  108. /package/src/{create_http_agent.ts → core/create_http_agent.ts} +0 -0
  109. /package/src/{page_map.ts → tools/page_map.ts} +0 -0
@@ -0,0 +1,140 @@
1
+ import { X_NAVIGATES_KEY } from "../constants.js";
2
+ import type { ClientTool } from "./client_tool_registry.js";
3
+
4
+ /** A single navigable route the host declares for the agent. */
5
+ export interface Route {
6
+ /** Stable id the agent uses with `navigate_to_route`. */
7
+ readonly id: string;
8
+ /**
9
+ * The URL path to navigate to. May contain `:name` placeholders for dynamic
10
+ * segments (e.g. `/admin/shop/book/:pk/change/`); the agent fills them via
11
+ * the `navigate_to_route` `params` argument. Real apps are mostly
12
+ * parameterised — few pages have a truly static path.
13
+ */
14
+ readonly path: string;
15
+ /** Human label shown to the agent. */
16
+ readonly title?: string;
17
+ /** Optional grouping (e.g. an app or section). */
18
+ readonly group?: string;
19
+ /** Optional longer description of when to use the route. */
20
+ readonly description?: string;
21
+ }
22
+
23
+ /** The host-declared catalog of navigable routes. */
24
+ export type RouteMap = readonly Route[];
25
+
26
+ /** A route enriched with its derived dynamic-segment parameter names. */
27
+ export interface RouteWithParams extends Route {
28
+ /** Names of the `:name` placeholders in {@link Route.path} (may be empty). */
29
+ readonly pathParams: readonly string[];
30
+ }
31
+
32
+ const PATH_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g;
33
+
34
+ /** The `:name` path-parameter names declared in a path template, in order. */
35
+ function pathParamNames(path: string): string[] {
36
+ const names: string[] = [];
37
+ for (const match of path.matchAll(PATH_PARAM_RE)) {
38
+ const name = match[1];
39
+ if (name !== undefined) {
40
+ names.push(name);
41
+ }
42
+ }
43
+ return names;
44
+ }
45
+
46
+ /**
47
+ * Substitute `:name` placeholders in ``path`` from ``params``.
48
+ *
49
+ * Returns the concrete path plus the params *not* consumed by a placeholder, so
50
+ * the caller can append those as a query string. Throws (referencing
51
+ * ``routeId``) when a declared path param is missing or empty — a half-filled
52
+ * path must never be navigated to.
53
+ */
54
+ function fillPath(
55
+ routeId: string,
56
+ path: string,
57
+ params: Record<string, unknown>,
58
+ ): { path: string; leftover: Record<string, unknown> } {
59
+ const leftover: Record<string, unknown> = { ...params };
60
+ const filled = path.replace(PATH_PARAM_RE, (_match, name: string) => {
61
+ const value = params[name];
62
+ if (value === undefined || value === null || String(value) === "") {
63
+ throw new Error(`route "${routeId}" requires path param "${name}"`);
64
+ }
65
+ delete leftover[name];
66
+ return encodeURIComponent(String(value));
67
+ });
68
+ return { path: filled, leftover };
69
+ }
70
+
71
+ /** Append `params` to `path` as a query string (no-op when empty). */
72
+ function withQuery(path: string, params: Record<string, unknown>): string {
73
+ const usp = new URLSearchParams();
74
+ for (const [key, value] of Object.entries(params)) {
75
+ usp.set(key, String(value));
76
+ }
77
+ const query = usp.toString();
78
+ return query === "" ? path : `${path}?${query}`;
79
+ }
80
+
81
+ /**
82
+ * The built-in `route.*` tools, bound to live getters so a host can set
83
+ * `routeMap` / `navigate` before or after mount.
84
+ *
85
+ * `list_routes` is read-only and advertises each route's dynamic
86
+ * `pathParams` so the model knows what to supply. `navigate_to_route` is marked
87
+ * `x-navigates` so an MPA reload checkpoints + resumes; it substitutes path
88
+ * params into the template and appends any remaining params as a query string.
89
+ * When the host supplies a `navigate(path)` callback (an SPA), the element
90
+ * routes client-side instead and the run loop simply continues — see
91
+ * `AgUiChat`'s execute path.
92
+ */
93
+ export function createRouteTools(
94
+ getRouteMap: () => RouteMap,
95
+ getNavigate: () => ((path: string) => void) | null,
96
+ ): ClientTool[] {
97
+ return [
98
+ {
99
+ name: "list_routes",
100
+ description:
101
+ "List the routes the app can navigate to. Each route's `pathParams` " +
102
+ "names the dynamic segments to pass as `params` to `navigate_to_route`.",
103
+ parameters: { type: "object", properties: {}, required: [] },
104
+ handler: (): RouteWithParams[] =>
105
+ getRouteMap().map((route) => ({ ...route, pathParams: pathParamNames(route.path) })),
106
+ },
107
+ {
108
+ name: "navigate_to_route",
109
+ description:
110
+ "Navigate to one of the app's routes by its id, filling any dynamic " +
111
+ "`:name` path segments (and extra query params) from `params`.",
112
+ parameters: {
113
+ type: "object",
114
+ properties: {
115
+ route_id: { type: "string" },
116
+ params: { type: "object" },
117
+ },
118
+ required: ["route_id"],
119
+ [X_NAVIGATES_KEY]: true,
120
+ },
121
+ handler: (args) => {
122
+ const routeId = args["route_id"];
123
+ const route = getRouteMap().find((r) => r.id === routeId);
124
+ if (route === undefined) {
125
+ throw new Error(`unknown route "${String(routeId)}"`);
126
+ }
127
+ const provided = (args["params"] as Record<string, unknown> | undefined) ?? {};
128
+ const { path: filledPath, leftover } = fillPath(route.id, route.path, provided);
129
+ const path = withQuery(filledPath, leftover);
130
+ const navigate = getNavigate();
131
+ if (navigate !== null) {
132
+ navigate(path);
133
+ } else {
134
+ window.location.assign(path);
135
+ }
136
+ return { navigated: true, path };
137
+ },
138
+ },
139
+ ];
140
+ }
@@ -1,5 +1,5 @@
1
+ import { X_DESTRUCTIVE_KEY } from "../constants.js";
1
2
  import type { ClientTool } from "./client_tool_registry.js";
2
- import { X_DESTRUCTIVE_KEY } from "./constants.js";
3
3
 
4
4
  /**
5
5
  * A binding from a named piece of host application state to agent tools.
@@ -0,0 +1,71 @@
1
+ /** What the inline confirmation card displays. */
2
+ export interface ConfirmationRequest {
3
+ toolName: string;
4
+ args: Record<string, unknown>;
5
+ /**
6
+ * Human-readable prompt (from the tool's `x-confirm` metadata), e.g.
7
+ * "Activate this project?". Falls back to a generic `Run "<tool>"?`.
8
+ */
9
+ message?: string;
10
+ }
11
+
12
+ /** Build a labelled action button. */
13
+ function actionButton(modifier: string, label: string): HTMLButtonElement {
14
+ const button = document.createElement("button");
15
+ button.type = "button";
16
+ button.className = `confirm-btn confirm-btn--${modifier}`;
17
+ button.textContent = label;
18
+ return button;
19
+ }
20
+
21
+ /**
22
+ * Append an inline confirmation card to ``host`` (the chat message list) and
23
+ * resolve when the user decides.
24
+ *
25
+ * Unlike a modal overlay, the card lives in the transcript right where the
26
+ * action is — it reads naturally after the assistant's explanation and never
27
+ * steals focus from the page. Resolves ``true`` on confirm, ``false`` on
28
+ * cancel. The card stays in the transcript as a resolved record (buttons
29
+ * disabled, `data-resolved` set) rather than vanishing.
30
+ */
31
+ export function requestConfirmation(
32
+ host: Node & ParentNode,
33
+ request: ConfirmationRequest,
34
+ ): Promise<boolean> {
35
+ return new Promise<boolean>((resolve) => {
36
+ const card = document.createElement("div");
37
+ card.className = "confirm";
38
+ card.setAttribute("data-tool-name", request.toolName);
39
+ card.setAttribute("role", "group");
40
+ card.setAttribute("aria-label", "Confirm action");
41
+
42
+ const body = document.createElement("div");
43
+ body.className = "confirm-body";
44
+ body.textContent = request.message ?? `Run “${request.toolName}”?`;
45
+
46
+ const args = document.createElement("pre");
47
+ args.className = "confirm-args";
48
+ args.textContent = JSON.stringify(request.args, null, 2);
49
+
50
+ const actions = document.createElement("div");
51
+ actions.className = "confirm-actions";
52
+
53
+ const cancel = actionButton("cancel", "Cancel");
54
+ const confirm = actionButton("confirm", "Confirm");
55
+
56
+ const close = (accepted: boolean): void => {
57
+ cancel.disabled = true;
58
+ confirm.disabled = true;
59
+ card.setAttribute("data-resolved", accepted ? "confirmed" : "declined");
60
+ resolve(accepted);
61
+ };
62
+
63
+ cancel.addEventListener("click", () => close(false));
64
+ confirm.addEventListener("click", () => close(true));
65
+
66
+ actions.append(cancel, confirm);
67
+ card.append(body, args, actions);
68
+ host.appendChild(card);
69
+ confirm.focus();
70
+ });
71
+ }
@@ -0,0 +1,68 @@
1
+ import DOMPurify from "dompurify";
2
+ import { marked } from "marked";
3
+
4
+ // GitHub-flavoured markdown with single-newline line breaks (chat-like).
5
+ marked.setOptions({ gfm: true, breaks: true });
6
+
7
+ // Conservative allowlist for assistant chat content: inline emphasis, code,
8
+ // lists, quotes, headings, links, tables, and images. Deliberately excludes
9
+ // `iframe`, `style`, and any scripting — rendering untrusted model/tool output
10
+ // as HTML is an XSS surface, so the sanitiser is the load-bearing safety net.
11
+ const ALLOWED_TAGS = [
12
+ "a",
13
+ "p",
14
+ "br",
15
+ "strong",
16
+ "em",
17
+ "b",
18
+ "i",
19
+ "u",
20
+ "s",
21
+ "del",
22
+ "code",
23
+ "pre",
24
+ "ul",
25
+ "ol",
26
+ "li",
27
+ "blockquote",
28
+ "h1",
29
+ "h2",
30
+ "h3",
31
+ "h4",
32
+ "h5",
33
+ "h6",
34
+ "hr",
35
+ "span",
36
+ "table",
37
+ "thead",
38
+ "tbody",
39
+ "tr",
40
+ "th",
41
+ "td",
42
+ "img",
43
+ ];
44
+
45
+ const ALLOWED_ATTR = ["href", "title", "class", "src", "alt", "width", "height"];
46
+
47
+ /**
48
+ * Render markdown (and any embedded raw HTML) to a sanitised HTML string.
49
+ *
50
+ * Both markdown syntax and literal HTML flow through one path: `marked` emits
51
+ * HTML, then DOMPurify strips everything outside {@link ALLOWED_TAGS} /
52
+ * {@link ALLOWED_ATTR} (scripts, event handlers, `javascript:` URLs, etc.).
53
+ * Links are hardened with `target="_blank"` + `rel="noopener noreferrer"`.
54
+ *
55
+ * The result is trimmed so a single-paragraph message round-trips to clean
56
+ * `textContent` (no trailing newline from the wrapping `<p>`).
57
+ */
58
+ export function renderMarkdown(text: string): string {
59
+ const rendered = marked.parse(text, { async: false });
60
+ const clean = DOMPurify.sanitize(rendered, { ALLOWED_TAGS, ALLOWED_ATTR });
61
+ const template = document.createElement("template");
62
+ template.innerHTML = clean;
63
+ for (const anchor of template.content.querySelectorAll("a[href]")) {
64
+ anchor.setAttribute("target", "_blank");
65
+ anchor.setAttribute("rel", "noopener noreferrer");
66
+ }
67
+ return template.innerHTML.trim();
68
+ }
@@ -0,0 +1,41 @@
1
+ /** Collect the text nodes under ``root`` with their parents (depth-first). */
2
+ function collectTextNodes(root: Node, out: { node: Text; parent: Node }[]): void {
3
+ for (const child of Array.from(root.childNodes)) {
4
+ if (child.nodeType === Node.TEXT_NODE) {
5
+ out.push({ node: child as Text, parent: root });
6
+ } else {
7
+ collectTextNodes(child, out);
8
+ }
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Wrap each word under ``root`` in `<span class="word" style="--ag-ui-word-index: N">`
14
+ * so CSS can reveal them one-by-one (staggered by the index). Whitespace is
15
+ * preserved as plain text. Used for the `word` text-animation mode on a
16
+ * completed assistant message; pure DOM, idempotent enough to re-run.
17
+ */
18
+ export function wrapWords(root: HTMLElement): void {
19
+ const texts: { node: Text; parent: Node }[] = [];
20
+ collectTextNodes(root, texts);
21
+ let index = 0;
22
+ for (const { node, parent } of texts) {
23
+ const fragment = document.createDocumentFragment();
24
+ for (const part of node.data.split(/(\s+)/)) {
25
+ if (part === "") {
26
+ continue;
27
+ }
28
+ if (/\s/.test(part)) {
29
+ fragment.appendChild(document.createTextNode(part));
30
+ continue;
31
+ }
32
+ const span = document.createElement("span");
33
+ span.className = "word";
34
+ span.style.setProperty("--ag-ui-word-index", String(index));
35
+ span.textContent = part;
36
+ fragment.appendChild(span);
37
+ index += 1;
38
+ }
39
+ parent.replaceChild(fragment, node);
40
+ }
41
+ }
@@ -0,0 +1,171 @@
1
+ import type { Skill } from "../skills/skill.js";
2
+
3
+ /**
4
+ * The two skill surfaces over a single catalog: a **chips** row (the
5
+ * `chip: true` subset) and a **`/`-command palette** (all skills, filtered).
6
+ * Both are opt-in. Pure DOM; the host ({@link AgUiChat}) appends
7
+ * {@link chips} + {@link palette}, feeds input via {@link onInput} /
8
+ * {@link onKeydown}, and acts on the {@link onPick} callback.
9
+ */
10
+ export class SkillsMenu {
11
+ /** Chips row — append above the input. Hidden unless chips are enabled and present. */
12
+ readonly chips: HTMLDivElement;
13
+ /** The `/`-command dropdown — append in the input area. Hidden until opened. */
14
+ readonly palette: HTMLDivElement;
15
+
16
+ readonly #onPick: (skill: Skill) => void;
17
+ #skills: readonly Skill[] = [];
18
+ #chipsEnabled = false;
19
+ #slashEnabled = false;
20
+ #filtered: Skill[] = [];
21
+ #activeIndex = 0;
22
+
23
+ constructor(onPick: (skill: Skill) => void) {
24
+ this.#onPick = onPick;
25
+ this.chips = document.createElement("div");
26
+ this.chips.className = "skill-chips";
27
+ this.chips.hidden = true;
28
+ this.palette = document.createElement("div");
29
+ this.palette.className = "skill-palette";
30
+ this.palette.setAttribute("role", "listbox");
31
+ this.palette.hidden = true;
32
+ }
33
+
34
+ /** Replace the catalog (both surfaces re-derive from it). */
35
+ setSkills(skills: readonly Skill[]): void {
36
+ this.#skills = skills;
37
+ this.#renderChips();
38
+ }
39
+
40
+ enableChips(enabled: boolean): void {
41
+ this.#chipsEnabled = enabled;
42
+ this.#renderChips();
43
+ }
44
+
45
+ enableSlash(enabled: boolean): void {
46
+ this.#slashEnabled = enabled;
47
+ }
48
+
49
+ /** Whether the palette is currently open. */
50
+ isOpen(): boolean {
51
+ return !this.palette.hidden;
52
+ }
53
+
54
+ /**
55
+ * React to an input value change: open + filter the palette when slash mode
56
+ * is on and the value starts with `/`, otherwise close it.
57
+ */
58
+ onInput(value: string): void {
59
+ if (this.#slashEnabled && value.startsWith("/")) {
60
+ this.#open(value.slice(1));
61
+ } else {
62
+ this.close();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Handle a keydown while the palette is open. Returns ``true`` when consumed
68
+ * (the host should `preventDefault` and skip its own handling).
69
+ */
70
+ onKeydown(event: KeyboardEvent): boolean {
71
+ if (!this.isOpen()) {
72
+ return false;
73
+ }
74
+ if (event.key === "ArrowDown") {
75
+ this.#move(1);
76
+ return true;
77
+ }
78
+ if (event.key === "ArrowUp") {
79
+ this.#move(-1);
80
+ return true;
81
+ }
82
+ if (event.key === "Escape") {
83
+ this.close();
84
+ return true;
85
+ }
86
+ if (event.key === "Enter") {
87
+ // `slice(i, i+1)` yields a 0-or-1 element array; forEach gives a
88
+ // definite Skill (no indexed-access `| undefined`).
89
+ this.#filtered.slice(this.#activeIndex, this.#activeIndex + 1).forEach((skill) => {
90
+ this.#pick(skill);
91
+ });
92
+ return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ /** Close the palette. */
98
+ close(): void {
99
+ this.palette.hidden = true;
100
+ this.palette.replaceChildren();
101
+ }
102
+
103
+ #open(query: string): void {
104
+ const needle = query.trim().toLowerCase();
105
+ const matches = this.#skills.filter(
106
+ (skill) =>
107
+ skill.name.toLowerCase().includes(needle) || skill.title.toLowerCase().includes(needle),
108
+ );
109
+ if (matches.length === 0) {
110
+ this.close();
111
+ return;
112
+ }
113
+ this.#filtered = matches;
114
+ this.#activeIndex = 0;
115
+ this.#renderPalette();
116
+ this.palette.hidden = false;
117
+ }
118
+
119
+ #move(delta: number): void {
120
+ const count = this.#filtered.length;
121
+ this.#activeIndex = (this.#activeIndex + delta + count) % count;
122
+ this.#renderPalette();
123
+ }
124
+
125
+ #pick(skill: Skill): void {
126
+ this.close();
127
+ this.#onPick(skill);
128
+ }
129
+
130
+ #renderChips(): void {
131
+ this.chips.replaceChildren();
132
+ const chipSkills = this.#chipsEnabled
133
+ ? this.#skills.filter((skill) => skill.chip === true)
134
+ : [];
135
+ this.chips.hidden = chipSkills.length === 0;
136
+ for (const skill of chipSkills) {
137
+ const button = document.createElement("button");
138
+ button.type = "button";
139
+ button.className = "skill-chip";
140
+ button.textContent = skill.title;
141
+ button.addEventListener("click", () => this.#pick(skill));
142
+ this.chips.appendChild(button);
143
+ }
144
+ }
145
+
146
+ #renderPalette(): void {
147
+ this.palette.replaceChildren();
148
+ this.#filtered.forEach((skill, index) => {
149
+ const item = document.createElement("button");
150
+ item.type = "button";
151
+ item.className = "skill-item";
152
+ item.setAttribute("role", "option");
153
+ item.setAttribute("aria-selected", index === this.#activeIndex ? "true" : "false");
154
+
155
+ const title = document.createElement("span");
156
+ title.className = "skill-item-title";
157
+ title.textContent = skill.title;
158
+ item.appendChild(title);
159
+
160
+ if (skill.description !== undefined) {
161
+ const desc = document.createElement("span");
162
+ desc.className = "skill-item-desc";
163
+ desc.textContent = skill.description;
164
+ item.appendChild(desc);
165
+ }
166
+
167
+ item.addEventListener("click", () => this.#pick(skill));
168
+ this.palette.appendChild(item);
169
+ });
170
+ }
171
+ }