@gusnips/react 0.9.25 → 0.9.27

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.
@@ -1 +1 @@
1
- {"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["../../src/ui/menu.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAavC,eAAO,MAAM,IAAI,sEAAiB,CAAC;AACnC,eAAO,MAAM,WAAW,mBAAoB,CAAC;AAC7C,eAAO,MAAM,UAAU,yJAAmB,CAAC;AAC3C,eAAO,MAAM,cAAc,sJAAuB,CAAC;AACnD,eAAO,MAAM,SAAS,wJAAkB,CAAC;AACzC,eAAO,MAAM,YAAY,2JAAqB,CAAC;AAC/C,eAAO,MAAM,SAAS,wJAAkB,CAAC;AACzC,eAAO,MAAM,YAAY,oJAAqB,CAAC;AAC/C,eAAO,MAAM,gBAAgB,4JAAyB,CAAC;AACvD,eAAO,MAAM,yBAAyB,yKAAkC,CAAC;AACzE,eAAO,MAAM,sBAAsB,sKAA+B,CAAC;AACnE,eAAO,MAAM,eAAe,8BAAwB,CAAC;AACrD,eAAO,MAAM,kBAAkB,8JAA2B,CAAC;AAE3D,MAAM,MAAM,gBAAgB,GAAG,SAAS,CAAC,KAAK,CAAC,KAAK,GAAG;IACrD;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,SAAS,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IAChD;;;;;OAKG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,KAAK,CAAC;CAC9C,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,EAC1B,SAAS,EACT,QAAQ,EACR,KAAK,EACL,SAAS,EACT,eAAe,EACf,GAAG,IAAI,EACR,EAAE,gBAAgB,+BA6BlB;AAcD,wBAAgB,QAAQ,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,IAAI,CAAC,KAAK,+BAEpE;AAED,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,SAAS,CAAC,KAAK,+BAI9E;AAED,oFAAoF;AACpF,KAAK,gBAAgB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAa3C;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,KAAK,CAAC,KAAK,GAAG,gBAAgB,+BAS/F;AAED,sGAAsG;AACtG,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,QAAQ,EACR,GAAG,IAAI,EACR,EAAE,SAAS,CAAC,UAAU,CAAC,KAAK,GAAG,gBAAgB,+BAS/C;AAED,MAAM,MAAM,kBAAkB,GAAG,SAAS,CAAC,SAAS,CAAC,KAAK,GAAG;IAC3D,gCAAgC;IAChC,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,kBAAkB,+BAY5F"}
1
+ {"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["../../src/ui/menu.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAavC,eAAO,MAAM,IAAI,uBAAiB,CAAC;AACnC,eAAO,MAAM,WAAW,mBAAoB,CAAC;AAC7C,eAAO,MAAM,UAAU,yJAAmB,CAAC;AAC3C,eAAO,MAAM,cAAc,sJAAuB,CAAC;AACnD,eAAO,MAAM,SAAS,wJAAkB,CAAC;AACzC,eAAO,MAAM,YAAY,2JAAqB,CAAC;AAC/C,eAAO,MAAM,SAAS,wJAAkB,CAAC;AACzC,eAAO,MAAM,YAAY,oJAAqB,CAAC;AAC/C,eAAO,MAAM,gBAAgB,4JAAyB,CAAC;AACvD,eAAO,MAAM,yBAAyB,yKAAkC,CAAC;AACzE,eAAO,MAAM,sBAAsB,sKAA+B,CAAC;AACnE,eAAO,MAAM,eAAe,8BAAwB,CAAC;AACrD,eAAO,MAAM,kBAAkB,8JAA2B,CAAC;AAE3D,MAAM,MAAM,gBAAgB,GAAG,SAAS,CAAC,KAAK,CAAC,KAAK,GAAG;IACrD;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,SAAS,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IAChD;;;;;OAKG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,KAAK,CAAC;CAC9C,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,EAC1B,SAAS,EACT,QAAQ,EACR,KAAK,EACL,SAAS,EACT,eAAe,EACf,GAAG,IAAI,EACR,EAAE,gBAAgB,+BA6BlB;AAcD,wBAAgB,QAAQ,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,IAAI,CAAC,KAAK,+BAEpE;AAED,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,SAAS,CAAC,KAAK,+BAI9E;AAED,oFAAoF;AACpF,KAAK,gBAAgB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAa3C;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAAE,SAAS,CAAC,KAAK,CAAC,KAAK,GAAG,gBAAgB,+BAS/F;AAED,sGAAsG;AACtG,wBAAgB,cAAc,CAAC,EAC7B,KAAK,EACL,QAAQ,EACR,GAAG,IAAI,EACR,EAAE,SAAS,CAAC,UAAU,CAAC,KAAK,GAAG,gBAAgB,+BAS/C;AAED,MAAM,MAAM,kBAAkB,GAAG,SAAS,CAAC,SAAS,CAAC,KAAK,GAAG;IAC3D,gCAAgC;IAChC,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,kBAAkB,+BAY5F"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.9.25",
3
+ "version": "0.9.27",
4
4
  "description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,6 +41,10 @@
41
41
  "./theme": {
42
42
  "types": "./dist/theme.d.ts",
43
43
  "default": "./dist/theme.js"
44
+ },
45
+ "./commands": {
46
+ "types": "./dist/commands.d.ts",
47
+ "default": "./dist/commands.js"
44
48
  }
45
49
  },
46
50
  "types": "./dist/index.d.ts",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The ring that runs round a busy control's edge. Put it inside the control while it is busy.
3
+ *
4
+ * It draws nothing by itself: the look lives in `@gusnips/tokens/busy-ring.css`, which you import
5
+ * once. The control needs `position: relative` and the radius it already has (the ring copies it),
6
+ * and its disabled style must not dim it, or the ring is drawn at half strength and nobody sees it.
7
+ *
8
+ * Why a component for one `span`: the class name and the markup are one contract. Changing how
9
+ * busy looks, a second element or another shape, is then one edit here and one release, not one
10
+ * edit in every product's button.
11
+ */
12
+ export function BusyRing() {
13
+ return <span aria-hidden="true" className="busy-ring" />;
14
+ }
@@ -0,0 +1,444 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement, useLayoutEffect, type ChangeEvent } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useSlashCommands, type SlashCommand } from "./commands.ts";
6
+
7
+ interface Action extends SlashCommand {
8
+ action: string;
9
+ }
10
+
11
+ const commands: readonly Action[] = [
12
+ {
13
+ name: "help",
14
+ description: "See commands",
15
+ aliases: ["commands"],
16
+ tags: ["keyboard"],
17
+ action: "help",
18
+ },
19
+ {
20
+ name: "new",
21
+ description: "Start a chat",
22
+ aliases: ["fresh"],
23
+ tags: ["conversation"],
24
+ action: "new",
25
+ },
26
+ {
27
+ name: "stop",
28
+ description: "Stop the reply",
29
+ aliases: ["cancel"],
30
+ tags: ["reply"],
31
+ action: "stop",
32
+ },
33
+ {
34
+ name: "model",
35
+ description: "Choose a model",
36
+ aliases: ["llm"],
37
+ tags: ["engine"],
38
+ takesArg: true,
39
+ action: "model",
40
+ },
41
+ ];
42
+
43
+ const roots = new Set<Root>();
44
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
45
+
46
+ afterEach(() => {
47
+ for (const root of roots) act(() => root.unmount());
48
+ roots.clear();
49
+ document.body.replaceChildren();
50
+ });
51
+
52
+ function composer(initialText = "/", initialCommands = commands) {
53
+ const element = document.createElement("div");
54
+ document.body.append(element);
55
+ const root = createRoot(element);
56
+ roots.add(root);
57
+ let text = initialText;
58
+ let registry = initialCommands;
59
+ let current: ReturnType<typeof useSlashCommands<Action>> | undefined;
60
+ const execute = vi.fn();
61
+ const error = vi.fn();
62
+ const send = vi.fn();
63
+ const changed = vi.fn((next: string) => {
64
+ text = next;
65
+ render();
66
+ });
67
+
68
+ function Harness() {
69
+ const slash = useSlashCommands({
70
+ text,
71
+ commands: registry,
72
+ onChange: changed,
73
+ onExecute: execute,
74
+ onError: error,
75
+ });
76
+ useLayoutEffect(() => {
77
+ current = slash;
78
+ }, [slash]);
79
+ return createElement(
80
+ "div",
81
+ null,
82
+ createElement("textarea", {
83
+ value: text,
84
+ onChange: (event: ChangeEvent<HTMLTextAreaElement>) => changed(event.currentTarget.value),
85
+ "aria-label": "Message",
86
+ role: "combobox",
87
+ "aria-autocomplete": "list",
88
+ "aria-expanded": slash.open,
89
+ "aria-controls": slash.open ? slash.listId : undefined,
90
+ "aria-activedescendant": slash.activeId,
91
+ onKeyDown: (event) => {
92
+ if (slash.handleKeyDown(event)) return;
93
+ if (event.key === "Enter" && !event.shiftKey) {
94
+ event.preventDefault();
95
+ send(text);
96
+ }
97
+ },
98
+ }),
99
+ createElement(
100
+ "button",
101
+ {
102
+ type: "button",
103
+ onClick: () => {
104
+ if (!slash.submit()) send(text);
105
+ },
106
+ },
107
+ "Send",
108
+ ),
109
+ slash.open
110
+ ? createElement(
111
+ "div",
112
+ { id: slash.listId, role: "listbox", "aria-label": "Commands" },
113
+ ...slash.items.map((command, index) =>
114
+ createElement(
115
+ "button",
116
+ {
117
+ key: command.name,
118
+ type: "button",
119
+ tabIndex: -1,
120
+ id: slash.itemId(command),
121
+ role: "option",
122
+ "aria-selected": slash.index === index,
123
+ onMouseEnter: () => slash.highlight(index),
124
+ onMouseDown: (event) => event.preventDefault(),
125
+ onClick: (event) => slash.pick(command, event.altKey),
126
+ },
127
+ `/${command.name}`,
128
+ ),
129
+ ),
130
+ )
131
+ : null,
132
+ );
133
+ }
134
+
135
+ function render() {
136
+ root.render(createElement(Harness));
137
+ }
138
+ act(render);
139
+ const input = element.querySelector("textarea");
140
+ const sendButton = element.querySelector("button");
141
+ if (!input || !sendButton) throw new Error("Composer did not render");
142
+ input.focus();
143
+
144
+ return {
145
+ execute,
146
+ error,
147
+ send,
148
+ changed,
149
+ input,
150
+ get text() {
151
+ return text;
152
+ },
153
+ get menu() {
154
+ if (!current) throw new Error("Hook did not render");
155
+ return current;
156
+ },
157
+ change(next: string) {
158
+ act(() => {
159
+ text = next;
160
+ render();
161
+ });
162
+ },
163
+ register(next: readonly Action[]) {
164
+ act(() => {
165
+ registry = next;
166
+ render();
167
+ });
168
+ },
169
+ press(key: string, options: KeyboardEventInit = {}) {
170
+ const event = new KeyboardEvent("keydown", {
171
+ key,
172
+ bubbles: true,
173
+ cancelable: true,
174
+ ...options,
175
+ });
176
+ act(() => input.dispatchEvent(event));
177
+ return event;
178
+ },
179
+ clickSend() {
180
+ act(() => sendButton.click());
181
+ },
182
+ clickOption(name: string, options: MouseEventInit = {}) {
183
+ const option = Array.from(element.querySelectorAll("button[role=option]")).find(
184
+ (button) => button.textContent === `/${name}`,
185
+ );
186
+ if (!option) throw new Error(`Command not rendered: ${name}`);
187
+ const down = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
188
+ act(() => {
189
+ option.dispatchEvent(down);
190
+ option.dispatchEvent(new MouseEvent("click", { bubbles: true, ...options }));
191
+ });
192
+ return down;
193
+ },
194
+ };
195
+ }
196
+
197
+ describe("useSlashCommands", () => {
198
+ it("wraps arrow selection and links the active row to the input", () => {
199
+ const chat = composer();
200
+ expect(chat.menu.index).toBe(0);
201
+ expect(chat.input.getAttribute("aria-activedescendant")).toBe(chat.menu.itemId(commands[0]!));
202
+ expect(chat.press("ArrowUp").defaultPrevented).toBe(true);
203
+ expect(chat.menu.index).toBe(3);
204
+ chat.press("ArrowDown");
205
+ expect(chat.menu.index).toBe(0);
206
+ expect(chat.input.getAttribute("aria-controls")).toBe(chat.menu.listId);
207
+ });
208
+
209
+ it("executes a selected local action once without sending or clearing the draft", () => {
210
+ const chat = composer();
211
+ chat.press("ArrowDown");
212
+ chat.press("Enter");
213
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[1], undefined, false);
214
+ expect(chat.send).not.toHaveBeenCalled();
215
+ expect(chat.changed).not.toHaveBeenCalled();
216
+ expect(chat.text).toBe("/");
217
+ });
218
+
219
+ it("Tab completes a name without running its action", () => {
220
+ const chat = composer("/he");
221
+ expect(chat.press("Tab").defaultPrevented).toBe(true);
222
+ expect(chat.text).toBe("/help");
223
+ expect(chat.execute).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("retains arguments when keyboard selection completes a partial name", () => {
227
+ const chat = composer("/mo large model");
228
+ chat.press("Enter");
229
+ expect(chat.text).toBe("/model large model");
230
+ expect(chat.execute).not.toHaveBeenCalled();
231
+ chat.press("Enter", { altKey: true });
232
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[3], "large model", true);
233
+ });
234
+
235
+ it("completes an argument-taking alias before keyboard or button submission", () => {
236
+ const keyboard = composer("/llm");
237
+ keyboard.press("Enter");
238
+ expect(keyboard.text).toBe("/model ");
239
+ expect(keyboard.execute).not.toHaveBeenCalled();
240
+ const button = composer("/llm");
241
+ button.clickSend();
242
+ expect(button.text).toBe("/model ");
243
+ expect(button.execute).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("button submission uses an exact alias without entering the message path", () => {
247
+ const chat = composer("/cancel");
248
+ chat.clickSend();
249
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, false);
250
+ expect(chat.send).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it("Escape stays closed until the query changes", () => {
254
+ const chat = composer("/he");
255
+ expect(chat.press("Escape").defaultPrevented).toBe(true);
256
+ expect(chat.menu.open).toBe(false);
257
+ chat.register([commands[1]!]);
258
+ expect(chat.menu.open).toBe(false);
259
+ expect(chat.menu.activeId).toBeUndefined();
260
+ chat.register(commands);
261
+ chat.change("/hel");
262
+ expect(chat.menu.open).toBe(true);
263
+ expect(chat.menu.index).toBe(0);
264
+ chat.change("/he");
265
+ expect(chat.menu.open).toBe(true);
266
+ });
267
+
268
+ it("reopens the full list even when Escape dismissed the same bare slash", () => {
269
+ const chat = composer();
270
+ chat.press("ArrowDown");
271
+ chat.press("Escape");
272
+ expect(chat.menu.open).toBe(false);
273
+ act(() => chat.menu.showAll());
274
+ expect(chat.menu.open).toBe(true);
275
+ expect(chat.menu.index).toBe(0);
276
+ expect(chat.menu.items).toEqual(commands);
277
+ expect(chat.text).toBe("/");
278
+ expect(chat.execute).not.toHaveBeenCalled();
279
+ expect(chat.send).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("returns an unknown draft to the full list for retry", () => {
283
+ const chat = composer("/missing arg");
284
+ chat.clickSend();
285
+ act(() => chat.menu.showAll());
286
+ expect(chat.text).toBe("/");
287
+ expect(chat.menu.open).toBe(true);
288
+ expect(chat.menu.items).toEqual(commands);
289
+ expect(chat.execute).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it("protects IME when given a native keyboard event directly", () => {
293
+ const chat = composer("/help");
294
+ const event = new KeyboardEvent("keydown", {
295
+ key: "Enter",
296
+ isComposing: true,
297
+ cancelable: true,
298
+ });
299
+ act(() => expect(chat.menu.handleKeyDown(event)).toBe(true));
300
+ expect(event.defaultPrevented).toBe(false);
301
+ expect(chat.execute).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it("leaves Shift+Enter and modified arrow/Tab keys to the text field", () => {
305
+ const chat = composer();
306
+ for (const [key, options] of [
307
+ ["Enter", { shiftKey: true }],
308
+ ["ArrowDown", { shiftKey: true }],
309
+ ["ArrowUp", { metaKey: true }],
310
+ ["Tab", { shiftKey: true }],
311
+ ] satisfies Array<[string, KeyboardEventInit]>) {
312
+ expect(chat.press(key, options).defaultPrevented).toBe(false);
313
+ }
314
+ expect(chat.execute).not.toHaveBeenCalled();
315
+ expect(chat.send).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it("lets IME accept text without executing a command or sending a message", () => {
319
+ const chat = composer("/help");
320
+ expect(chat.press("Enter", { isComposing: true }).defaultPrevented).toBe(false);
321
+ expect(chat.execute).not.toHaveBeenCalled();
322
+ expect(chat.send).not.toHaveBeenCalled();
323
+ chat.change("plain text");
324
+ chat.press("Enter", { isComposing: true });
325
+ expect(chat.send).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it("also protects the final IME key reported only as keyCode 229", () => {
329
+ const chat = composer("/help");
330
+ const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
331
+ Object.defineProperty(event, "keyCode", { value: 229 });
332
+ act(() => chat.input.dispatchEvent(event));
333
+ expect(event.defaultPrevented).toBe(false);
334
+ expect(chat.execute).not.toHaveBeenCalled();
335
+ expect(chat.send).not.toHaveBeenCalled();
336
+ });
337
+
338
+ it("exposes empty results and reports unknown commands without sending them", () => {
339
+ const chat = composer("/missing");
340
+ expect(chat.menu.open).toBe(true);
341
+ expect(chat.menu.items).toEqual([]);
342
+ expect(chat.menu.activeId).toBeUndefined();
343
+ chat.press("Enter");
344
+ expect(chat.error).toHaveBeenCalledExactlyOnceWith({ kind: "unknown", name: "missing" });
345
+ expect(chat.send).not.toHaveBeenCalled();
346
+ expect(chat.text).toBe("/missing");
347
+ });
348
+
349
+ it("tags find a row but cannot execute through direct submission", () => {
350
+ const chat = composer("/conversation");
351
+ expect(chat.menu.items.map((command) => command.name)).toEqual(["new"]);
352
+ chat.clickSend();
353
+ expect(chat.error).toHaveBeenCalledWith({ kind: "unknown", name: "conversation" });
354
+ expect(chat.execute).not.toHaveBeenCalled();
355
+ chat.press("Tab");
356
+ expect(chat.text).toBe("/new");
357
+ chat.clickSend();
358
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[1], undefined, false);
359
+ });
360
+
361
+ it("does not auto-dispatch an ambiguous alias on Enter or button submission", () => {
362
+ const registry = commands.slice(0, 2).map((command) => ({ ...command, aliases: ["shared"] }));
363
+ const chat = composer("/shared", registry);
364
+ chat.press("Enter");
365
+ chat.clickSend();
366
+ expect(chat.error).toHaveBeenCalledTimes(2);
367
+ expect(chat.error).toHaveBeenLastCalledWith({
368
+ kind: "ambiguous",
369
+ name: "shared",
370
+ commands: registry,
371
+ });
372
+ expect(chat.execute).not.toHaveBeenCalled();
373
+ expect(chat.send).not.toHaveBeenCalled();
374
+ });
375
+
376
+ it("lets an explicit arrow selection choose between ambiguous aliases", () => {
377
+ const registry = commands.slice(0, 2).map((command) => ({ ...command, aliases: ["shared"] }));
378
+ const chat = composer("/shared", registry);
379
+ chat.press("ArrowDown");
380
+ chat.press("Enter");
381
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(registry[1], undefined, false);
382
+ expect(chat.error).not.toHaveBeenCalled();
383
+ });
384
+
385
+ it("keeps focus on the text field when a row is clicked", () => {
386
+ const chat = composer();
387
+ expect(chat.clickOption("stop", { altKey: true }).defaultPrevented).toBe(true);
388
+ expect(document.activeElement).toBe(chat.input);
389
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, true);
390
+ expect(chat.send).not.toHaveBeenCalled();
391
+ });
392
+
393
+ it("refreshes matching and resets selection when capabilities change under a fixed draft", () => {
394
+ const chat = composer();
395
+ chat.press("ArrowDown");
396
+ expect(chat.menu.active?.name).toBe("new");
397
+ chat.register([commands[2]!]);
398
+ expect(chat.text).toBe("/");
399
+ expect(chat.menu.index).toBe(0);
400
+ expect(chat.menu.active?.name).toBe("stop");
401
+ chat.press("Enter");
402
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, false);
403
+ });
404
+
405
+ it("uses current translated search words even when the draft has not changed", () => {
406
+ const chat = composer("/comecar");
407
+ expect(chat.menu.items).toEqual([]);
408
+ const translated = commands.map((command) =>
409
+ command.name === "new"
410
+ ? { ...command, description: "Comece uma conversa", tags: ["começar", "conversa"] }
411
+ : command,
412
+ );
413
+ chat.register(translated);
414
+ expect(chat.menu.items.map((command) => command.name)).toEqual(["new"]);
415
+ expect(chat.text).toBe("/comecar");
416
+ });
417
+
418
+ it("re-resolves a retained row against current capabilities", () => {
419
+ const chat = composer();
420
+ const oldRow = chat.menu.items[2]!;
421
+ chat.register(commands.slice(0, 2));
422
+ act(() => chat.menu.pick(oldRow));
423
+ expect(chat.error).toHaveBeenCalledWith({ kind: "unknown", name: "stop" });
424
+ expect(chat.execute).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it("direct submission does not depend on menu state and forwards Alt", () => {
428
+ const chat = composer("/cancel");
429
+ chat.press("Escape");
430
+ act(() => expect(chat.menu.submit(true)).toBe(true));
431
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, true);
432
+ });
433
+
434
+ it("leaves ordinary and multiline messages to the existing sender", () => {
435
+ const chat = composer("hi");
436
+ expect(chat.menu.open).toBe(false);
437
+ chat.clickSend();
438
+ expect(chat.send).toHaveBeenLastCalledWith("hi");
439
+ chat.change("/help\nquoted text");
440
+ chat.clickSend();
441
+ expect(chat.send).toHaveBeenLastCalledWith("/help\nquoted text");
442
+ expect(chat.execute).not.toHaveBeenCalled();
443
+ });
444
+ });