@runtypelabs/persona 4.8.0 → 4.10.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 (130) hide show
  1. package/README.md +14 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +72 -64
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +634 -3
  26. package/dist/index.d.ts +634 -3
  27. package/dist/index.global.js +59 -51
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +64 -56
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +236 -57
  42. package/dist/theme-editor-preview.d.cts +485 -1
  43. package/dist/theme-editor-preview.d.ts +485 -1
  44. package/dist/theme-editor-preview.js +53 -47
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/artifact-pane.test.ts +47 -0
  57. package/src/components/artifact-pane.ts +25 -2
  58. package/src/components/composer-parts.ts +3 -12
  59. package/src/components/context-mention-button.test.ts +70 -0
  60. package/src/components/context-mention-button.ts +82 -0
  61. package/src/components/context-mention-chip.ts +134 -0
  62. package/src/components/context-mention-menu.test.ts +508 -0
  63. package/src/components/context-mention-menu.ts +0 -0
  64. package/src/components/message-bubble.test.ts +175 -0
  65. package/src/components/message-bubble.ts +177 -19
  66. package/src/components/panel.ts +7 -10
  67. package/src/context-mentions-bundle.test.ts +163 -0
  68. package/src/context-mentions-entry.ts +185 -0
  69. package/src/context-mentions-inline-entry.test.ts +136 -0
  70. package/src/context-mentions-inline-entry.ts +226 -0
  71. package/src/context-mentions-inline-loader.test.ts +30 -0
  72. package/src/context-mentions-inline-loader.ts +36 -0
  73. package/src/context-mentions-inline.ts +15 -0
  74. package/src/context-mentions-loader.ts +32 -0
  75. package/src/context-mentions.ts +16 -0
  76. package/src/index-core.ts +27 -0
  77. package/src/index-global.ts +51 -0
  78. package/src/markdown-parsers-loader.ts +35 -44
  79. package/src/plugin-kit.test.ts +40 -0
  80. package/src/plugin-kit.ts +39 -5
  81. package/src/runtime/init-update-reset.test.ts +81 -0
  82. package/src/runtime/init.test.ts +62 -0
  83. package/src/runtime/init.ts +7 -14
  84. package/src/session.mentions.test.ts +175 -0
  85. package/src/session.test.ts +52 -4
  86. package/src/session.ts +121 -5
  87. package/src/smart-dom-reader.test.ts +129 -2
  88. package/src/smart-dom-reader.ts +127 -1
  89. package/src/styles/context-mention-menu-css.ts +176 -0
  90. package/src/styles/widget.css +243 -126
  91. package/src/theme-editor/sections.ts +3 -3
  92. package/src/types/theme.ts +2 -0
  93. package/src/types.ts +542 -0
  94. package/src/ui.artifact-pane-gating.test.ts +11 -1
  95. package/src/ui.attachments-drop.test.ts +90 -0
  96. package/src/ui.header-update-stability.test.ts +149 -0
  97. package/src/ui.launcher-update-merge.test.ts +83 -0
  98. package/src/ui.mention-submit.test.ts +235 -0
  99. package/src/ui.send-button-stream-update.test.ts +69 -0
  100. package/src/ui.ts +379 -84
  101. package/src/utils/chunk-loader.test.ts +97 -0
  102. package/src/utils/chunk-loader.ts +88 -0
  103. package/src/utils/composer-contenteditable.test.ts +507 -0
  104. package/src/utils/composer-contenteditable.ts +626 -0
  105. package/src/utils/composer-document.test.ts +280 -0
  106. package/src/utils/composer-document.ts +293 -0
  107. package/src/utils/composer-history.test.ts +35 -7
  108. package/src/utils/composer-history.ts +30 -20
  109. package/src/utils/composer-input.ts +159 -0
  110. package/src/utils/config-merge.test.ts +131 -0
  111. package/src/utils/config-merge.ts +61 -0
  112. package/src/utils/context-mention-controller.test.ts +1215 -0
  113. package/src/utils/context-mention-controller.ts +1186 -0
  114. package/src/utils/context-mention-manager.test.ts +422 -0
  115. package/src/utils/context-mention-manager.ts +410 -0
  116. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  117. package/src/utils/context-mention-orchestrator.ts +348 -0
  118. package/src/utils/live-region.test.ts +108 -0
  119. package/src/utils/live-region.ts +94 -0
  120. package/src/utils/mention-channels.ts +63 -0
  121. package/src/utils/mention-llm-format.test.ts +91 -0
  122. package/src/utils/mention-llm-format.ts +79 -0
  123. package/src/utils/mention-matcher.test.ts +86 -0
  124. package/src/utils/mention-matcher.ts +221 -0
  125. package/src/utils/mention-token.ts +72 -0
  126. package/src/utils/mention-trigger.test.ts +155 -0
  127. package/src/utils/mention-trigger.ts +156 -0
  128. package/src/utils/theme.test.ts +54 -4
  129. package/src/utils/theme.ts +6 -3
  130. package/src/utils/tokens.ts +27 -11
@@ -0,0 +1,280 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ emptyDocument,
4
+ documentFromTextarea,
5
+ toDisplayText,
6
+ toLogicalText,
7
+ logicalLength,
8
+ mentionBlocksInOrder,
9
+ insertMention,
10
+ removeMention,
11
+ spliceDocument,
12
+ documentToMessageFields,
13
+ MENTION_PLACEHOLDER,
14
+ type ComposerDocument
15
+ } from "./composer-document";
16
+ import { parseMentionTrigger } from "./mention-trigger";
17
+ import type { AgentWidgetContextMentionRef } from "../types";
18
+
19
+ const appRef: AgentWidgetContextMentionRef = {
20
+ sourceId: "files",
21
+ itemId: "app",
22
+ label: "App.tsx"
23
+ };
24
+ const utilRef: AgentWidgetContextMentionRef = {
25
+ sourceId: "files",
26
+ itemId: "util",
27
+ label: "util.ts"
28
+ };
29
+
30
+ /** Build a document quickly and normalize via a no-op insert-free path. */
31
+ function docOf(...blocks: ComposerDocument["blocks"]): ComposerDocument {
32
+ return { blocks };
33
+ }
34
+
35
+ describe("composer-document projections", () => {
36
+ it("emptyDocument is a single empty text block", () => {
37
+ expect(emptyDocument()).toEqual({ blocks: [{ kind: "text", value: "" }] });
38
+ expect(toLogicalText(emptyDocument())).toBe("");
39
+ });
40
+
41
+ it("documentFromTextarea wraps the value as one text block", () => {
42
+ expect(documentFromTextarea("hi there")).toEqual({
43
+ blocks: [{ kind: "text", value: "hi there" }]
44
+ });
45
+ });
46
+
47
+ it("projects text/display/logical distinctly around a token", () => {
48
+ const doc = docOf(
49
+ { kind: "text", value: "Check " },
50
+ { kind: "mention", id: "m1", ref: appRef },
51
+ { kind: "text", value: " for errors" }
52
+ );
53
+ expect(toDisplayText(doc)).toBe("Check @App.tsx for errors");
54
+ expect(toLogicalText(doc)).toBe("Check  for errors");
55
+ expect(logicalLength(doc)).toBe("Check  for errors".length);
56
+ });
57
+ });
58
+
59
+ describe("insertMention", () => {
60
+ it("replaces the @query range with a token and returns the trailing caret", () => {
61
+ // Start with "Check @App for errors"; caret after "@App" (index 10).
62
+ const start = documentFromTextarea("Check @App for errors");
63
+ const match = parseMentionTrigger(toLogicalText(start), 10);
64
+ expect(match).toEqual({ triggerIndex: 6, query: "App" });
65
+
66
+ const { doc, caret } = insertMention(
67
+ start,
68
+ { start: match!.triggerIndex, end: 10 },
69
+ appRef,
70
+ "m1"
71
+ );
72
+ // The pre-existing space separates; no second one is inserted.
73
+ expect(toDisplayText(doc)).toBe("Check @App.tsx for errors");
74
+ // caret hops the existing separator, landing after ` `.
75
+ expect(caret).toBe(8);
76
+ expect(toLogicalText(doc)[caret - 2]).toBe("");
77
+ expect(toLogicalText(doc)[caret - 1]).toBe(" ");
78
+ });
79
+
80
+ it("inserts at the very end of the document with a separating space", () => {
81
+ const start = documentFromTextarea("Look at @ap");
82
+ const { doc, caret } = insertMention(start, { start: 8, end: 11 }, appRef, "m1");
83
+ expect(toDisplayText(doc)).toBe("Look at @App.tsx ");
84
+ const mentions = mentionBlocksInOrder(doc);
85
+ expect(mentions).toHaveLength(1);
86
+ expect(mentions[0].ref).toEqual(appRef);
87
+ // Caret after the inserted space: typing or a fresh `@` chains directly.
88
+ expect(caret).toBe(toLogicalText(doc).length);
89
+ });
90
+
91
+ it("chains: a fresh trigger typed right after completion parses immediately", () => {
92
+ const start = documentFromTextarea("@ap");
93
+ const { doc, caret } = insertMention(start, { start: 0, end: 3 }, appRef, "m1");
94
+ // The auto-space satisfies the parser's preceding-whitespace requirement,
95
+ // so `@` chains without a manual spacebar press.
96
+ const typed = { blocks: [...doc.blocks, { kind: "text" as const, value: "@u" }] };
97
+ const logical = toLogicalText(typed);
98
+ expect(parseMentionTrigger(logical, logical.length)).toEqual({
99
+ triggerIndex: caret,
100
+ query: "u",
101
+ });
102
+ });
103
+
104
+ it("keeps a text slot between two adjacent inserted tokens (no adjacency)", () => {
105
+ let doc = documentFromTextarea("@a");
106
+ ({ doc } = insertMention(doc, { start: 0, end: 2 }, appRef, "m1"));
107
+ // Now type another trigger right after the token and select.
108
+ const logical = toLogicalText(doc); // ""
109
+ doc = { blocks: [...doc.blocks, { kind: "text", value: "@u" }] };
110
+ ({ doc } = insertMention(
111
+ doc,
112
+ { start: logical.length, end: logical.length + 2 },
113
+ utilRef,
114
+ "m2"
115
+ ));
116
+ // Two mentions, never directly adjacent (a text block separates them).
117
+ for (let i = 0; i < doc.blocks.length - 1; i++) {
118
+ const a = doc.blocks[i];
119
+ const b = doc.blocks[i + 1];
120
+ expect(a.kind === "mention" && b.kind === "mention").toBe(false);
121
+ }
122
+ expect(mentionBlocksInOrder(doc).map((m) => m.id)).toEqual(["m1", "m2"]);
123
+ });
124
+ });
125
+
126
+ describe("removeMention", () => {
127
+ it("drops a token and merges the surrounding text", () => {
128
+ const doc = docOf(
129
+ { kind: "text", value: "Check " },
130
+ { kind: "mention", id: "m1", ref: appRef },
131
+ { kind: "text", value: " now" }
132
+ );
133
+ const { doc: next, caret } = removeMention(doc, "m1");
134
+ expect(toDisplayText(next)).toBe("Check now");
135
+ // A single merged text block remains.
136
+ expect(next.blocks).toEqual([{ kind: "text", value: "Check now" }]);
137
+ // Caret lands where the token used to be.
138
+ expect(caret).toBe("Check ".length);
139
+ });
140
+
141
+ it("is a no-op for an unknown id", () => {
142
+ const doc = documentFromTextarea("nothing here");
143
+ const { doc: next } = removeMention(doc, "missing");
144
+ expect(next).toBe(doc);
145
+ });
146
+ });
147
+
148
+ describe("MENTION_PLACEHOLDER", () => {
149
+ it("is the U+FFFC object-replacement char, single-sourced from mention-trigger", () => {
150
+ expect(MENTION_PLACEHOLDER).toBe("");
151
+ expect(toLogicalText(
152
+ docOf(
153
+ { kind: "text", value: "a" },
154
+ { kind: "mention", id: "m1", ref: appRef }
155
+ )
156
+ )).toBe(`a${MENTION_PLACEHOLDER}`);
157
+ });
158
+ });
159
+
160
+ describe("spliceDocument", () => {
161
+ const withToken = (): ComposerDocument =>
162
+ docOf(
163
+ { kind: "text", value: "a " },
164
+ { kind: "mention", id: "m1", ref: appRef },
165
+ { kind: "text", value: " b" }
166
+ );
167
+
168
+ it("replaces a range OUTSIDE a token, preserving the token", () => {
169
+ // logical "a  b" (0='a' 1=' ' 2= 3=' ' 4='b'); replace the leading "a ".
170
+ const { doc, caret } = spliceDocument(withToken(), 0, 2, "X");
171
+ expect(toDisplayText(doc)).toBe("X@App.tsx b");
172
+ expect(mentionBlocksInOrder(doc)).toHaveLength(1);
173
+ expect(caret).toBe(1);
174
+ });
175
+
176
+ it("drops a token when the replaced range spans it (multi-block selection)", () => {
177
+ const { doc } = spliceDocument(withToken(), 1, 4, "");
178
+ expect(toDisplayText(doc)).toBe("ab");
179
+ expect(mentionBlocksInOrder(doc)).toHaveLength(0);
180
+ });
181
+
182
+ it("orders and clamps start/end defensively", () => {
183
+ // Reversed args → treated as [1, 4).
184
+ const reversed = spliceDocument(withToken(), 4, 1, "-");
185
+ expect(toDisplayText(reversed.doc)).toBe("a-b");
186
+ expect(reversed.caret).toBe(2);
187
+ // Out-of-range args → clamped to the whole document.
188
+ const whole = spliceDocument(withToken(), -5, 99, "Z");
189
+ expect(toDisplayText(whole.doc)).toBe("Z");
190
+ expect(whole.caret).toBe(1);
191
+ });
192
+ });
193
+
194
+ describe("parseMentionTrigger over logical text", () => {
195
+ it("does not report a query spanning an existing token", () => {
196
+ // "@a" typed, then token, then "b": logical "@ab". No active trigger at end.
197
+ const doc = docOf(
198
+ { kind: "text", value: "@a" },
199
+ { kind: "mention", id: "m1", ref: appRef },
200
+ { kind: "text", value: "b" }
201
+ );
202
+ const logical = toLogicalText(doc);
203
+ expect(logical).toBe("@ab");
204
+ expect(parseMentionTrigger(logical, logical.length)).toBeNull();
205
+ });
206
+
207
+ it("activates a fresh @ typed after a token", () => {
208
+ const doc = docOf(
209
+ { kind: "mention", id: "m1", ref: appRef },
210
+ { kind: "text", value: " @fo" }
211
+ );
212
+ const logical = toLogicalText(doc); // " @fo"
213
+ const match = parseMentionTrigger(logical, logical.length);
214
+ expect(match).toEqual({ triggerIndex: 2, query: "fo" });
215
+ });
216
+ });
217
+
218
+ describe("duplicate mentions", () => {
219
+ it("allows the same ref twice with distinct ids", () => {
220
+ let doc = documentFromTextarea("@a and @a");
221
+ ({ doc } = insertMention(doc, { start: 0, end: 2 }, appRef, "m1"));
222
+ const logical = toLogicalText(doc); // " and @a"
223
+ ({ doc } = insertMention(
224
+ doc,
225
+ { start: logical.indexOf("@"), end: logical.length },
226
+ appRef,
227
+ "m2"
228
+ ));
229
+ const mentions = mentionBlocksInOrder(doc);
230
+ expect(mentions.map((m) => m.id)).toEqual(["m1", "m2"]);
231
+ expect(mentions.every((m) => m.ref === appRef)).toBe(true);
232
+ });
233
+ });
234
+
235
+ describe("documentToMessageFields", () => {
236
+ it("emits content, contextMentions, and contentSegments in document order", () => {
237
+ const doc = docOf(
238
+ { kind: "text", value: "Check " },
239
+ { kind: "mention", id: "m1", ref: appRef },
240
+ { kind: "text", value: " for errors" }
241
+ );
242
+ const fields = documentToMessageFields(doc);
243
+ expect(fields.content).toBe("Check @App.tsx for errors");
244
+ expect(fields.contextMentions).toEqual([appRef]);
245
+ expect(fields.contentSegments).toEqual([
246
+ { kind: "text", text: "Check " },
247
+ { kind: "mention", ref: appRef },
248
+ { kind: "text", text: " for errors" }
249
+ ]);
250
+ });
251
+
252
+ it("preserves order and duplicates across multiple tokens", () => {
253
+ const doc = docOf(
254
+ { kind: "mention", id: "m1", ref: appRef },
255
+ { kind: "text", value: " vs " },
256
+ { kind: "mention", id: "m2", ref: utilRef }
257
+ );
258
+ const fields = documentToMessageFields(doc);
259
+ expect(fields.contextMentions).toEqual([appRef, utilRef]);
260
+ expect(fields.contentSegments).toEqual([
261
+ { kind: "mention", ref: appRef },
262
+ { kind: "text", text: " vs " },
263
+ { kind: "mention", ref: utilRef }
264
+ ]);
265
+ });
266
+
267
+ it("drops empty text runs from contentSegments", () => {
268
+ // A leading empty text block (structural) must not leak into the transcript.
269
+ const doc = docOf(
270
+ { kind: "text", value: "" },
271
+ { kind: "mention", id: "m1", ref: appRef },
272
+ { kind: "text", value: "" }
273
+ );
274
+ const fields = documentToMessageFields(doc);
275
+ expect(fields.contentSegments).toEqual([
276
+ { kind: "mention", ref: appRef }
277
+ ]);
278
+ expect(fields.content).toBe("@App.tsx");
279
+ });
280
+ });
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Pure, DOM-free document model for the inline-mention composer
3
+ * (`contextMentions.display: "inline"`).
4
+ *
5
+ * The canonical composer state is a flat, ordered list of blocks — text runs and
6
+ * atomic mention tokens — rather than a single string with range markers. Blocks
7
+ * make insert/remove, duplicate detection, and history round-trip explicit
8
+ * without parsing HTML. The DOM adapter (`composer-contenteditable.ts`) renders a
9
+ * document to a contenteditable surface and re-parses it back on input; this
10
+ * module owns everything that does not touch the DOM, so it is unit-testable in
11
+ * the Node env exactly like `mention-trigger.ts` and `composer-history.ts`.
12
+ *
13
+ * Two text projections exist, each for a distinct purpose:
14
+ * - `toDisplayText` — text + `@label` per mention (the human-readable, sent
15
+ * bubble / model-visible prose).
16
+ * - `toLogicalText` — text + `` (OBJECT REPLACEMENT CHAR) per mention. An
17
+ * INTERNAL index space only — never shown to the user, never
18
+ * sent to the model — that lets the existing
19
+ * `parseMentionTrigger` run over `(logicalText, caret)` with
20
+ * tokens standing in as single, query-terminating chars.
21
+ */
22
+
23
+ import { MENTION_PLACEHOLDER } from "./mention-trigger";
24
+ import type {
25
+ AgentWidgetContentSegment,
26
+ AgentWidgetContextMentionRef
27
+ } from "../types";
28
+
29
+ // Single-sourced from the dependency-free trigger module (see its definition for
30
+ // why it lives there); re-exported here so existing importers keep working.
31
+ export { MENTION_PLACEHOLDER };
32
+
33
+ /** Stable id for one inline mention instance (DOM `data-mention-id`, history key). */
34
+ export type ComposerMentionId = string;
35
+
36
+ /** One block in the composer document. Order is significant. */
37
+ export type ComposerBlock =
38
+ | { kind: "text"; value: string }
39
+ | {
40
+ kind: "mention";
41
+ id: ComposerMentionId;
42
+ ref: AgentWidgetContextMentionRef;
43
+ };
44
+
45
+ /** Canonical inline-composer state. */
46
+ export type ComposerDocument = {
47
+ blocks: ComposerBlock[];
48
+ };
49
+
50
+ /** A logical-coordinate range `[start, end)` into `toLogicalText(doc)`. */
51
+ export type LogicalRange = { start: number; end: number };
52
+
53
+ /** Prefix shown before a mention's label in display/model-visible prose. */
54
+ const MENTION_DISPLAY_PREFIX = "@";
55
+
56
+ /** Logical length of a single block (`` counts as one char). */
57
+ function blockLength(block: ComposerBlock): number {
58
+ return block.kind === "text" ? block.value.length : 1;
59
+ }
60
+
61
+ /**
62
+ * Enforce the structural invariant that keeps caret placement stable: the
63
+ * document begins and ends with a text block and no two mention blocks are
64
+ * adjacent (an empty text block always separates them). Adjacent text runs are
65
+ * merged. This never changes any projection's output (empty text contributes the
66
+ * empty string) — it only guarantees a caret slot exists on every side of a token.
67
+ */
68
+ function normalizeBlocks(blocks: ComposerBlock[]): ComposerBlock[] {
69
+ const out: ComposerBlock[] = [];
70
+ const pushText = (value: string): void => {
71
+ const last = out[out.length - 1];
72
+ if (last && last.kind === "text") last.value += value;
73
+ else out.push({ kind: "text", value });
74
+ };
75
+
76
+ // Ensure a leading text slot.
77
+ if (blocks.length === 0 || blocks[0].kind !== "text") {
78
+ out.push({ kind: "text", value: "" });
79
+ }
80
+
81
+ for (const block of blocks) {
82
+ if (block.kind === "text") {
83
+ pushText(block.value);
84
+ continue;
85
+ }
86
+ // Guarantee a text block precedes this mention (it does by construction, but
87
+ // stay defensive), then always trail one so the next block — text or another
88
+ // mention — keeps the alternation.
89
+ const last = out[out.length - 1];
90
+ if (!last || last.kind !== "text") out.push({ kind: "text", value: "" });
91
+ out.push({ kind: "mention", id: block.id, ref: block.ref });
92
+ out.push({ kind: "text", value: "" });
93
+ }
94
+
95
+ const last = out[out.length - 1];
96
+ if (!last || last.kind !== "text") out.push({ kind: "text", value: "" });
97
+ return out;
98
+ }
99
+
100
+ /** `{ blocks: [{ kind: "text", value: "" }] }`. */
101
+ export function emptyDocument(): ComposerDocument {
102
+ return { blocks: [{ kind: "text", value: "" }] };
103
+ }
104
+
105
+ /** Chip-mode shim: wrap a raw textarea value as a single text block. */
106
+ export function documentFromTextarea(value: string): ComposerDocument {
107
+ return { blocks: [{ kind: "text", value }] };
108
+ }
109
+
110
+ /** Text blocks + `@label` per mention — human-readable / model-visible prose. */
111
+ export function toDisplayText(doc: ComposerDocument): string {
112
+ return doc.blocks
113
+ .map((b) =>
114
+ b.kind === "text" ? b.value : `${MENTION_DISPLAY_PREFIX}${b.ref.label}`
115
+ )
116
+ .join("");
117
+ }
118
+
119
+ /**
120
+ * Text blocks + `` per mention — the internal caret/trigger index space for
121
+ * reusing `parseMentionTrigger`. Never shown to the user, never sent to the model.
122
+ */
123
+ export function toLogicalText(doc: ComposerDocument): string {
124
+ return doc.blocks
125
+ .map((b) => (b.kind === "text" ? b.value : MENTION_PLACEHOLDER))
126
+ .join("");
127
+ }
128
+
129
+ /** Total logical length of the document. */
130
+ export function logicalLength(doc: ComposerDocument): number {
131
+ return doc.blocks.reduce((sum, b) => sum + blockLength(b), 0);
132
+ }
133
+
134
+ /** Mention blocks in document order (source of submit `contextMentions`). */
135
+ export function mentionBlocksInOrder(
136
+ doc: ComposerDocument
137
+ ): Array<{ id: ComposerMentionId; ref: AgentWidgetContextMentionRef }> {
138
+ const out: Array<{ id: ComposerMentionId; ref: AgentWidgetContextMentionRef }> =
139
+ [];
140
+ for (const b of doc.blocks) {
141
+ if (b.kind === "mention") out.push({ id: b.id, ref: b.ref });
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Split `blocks` at a single logical offset, returning the blocks fully to the
148
+ * left of `cut` and the blocks fully to the right (text blocks that straddle the
149
+ * cut are sliced). Mention blocks (one logical char) never straddle in practice;
150
+ * a defensive cut inside one keeps the whole token on the right.
151
+ */
152
+ function splitBlocksAt(
153
+ blocks: ComposerBlock[],
154
+ cut: number
155
+ ): { left: ComposerBlock[]; right: ComposerBlock[] } {
156
+ const left: ComposerBlock[] = [];
157
+ const right: ComposerBlock[] = [];
158
+ let offset = 0;
159
+ for (const block of blocks) {
160
+ const len = blockLength(block);
161
+ const end = offset + len;
162
+ if (end <= cut) {
163
+ left.push(block);
164
+ } else if (offset >= cut) {
165
+ right.push(block);
166
+ } else if (block.kind === "text") {
167
+ const at = cut - offset;
168
+ if (at > 0) left.push({ kind: "text", value: block.value.slice(0, at) });
169
+ if (at < len) right.push({ kind: "text", value: block.value.slice(at) });
170
+ } else {
171
+ right.push(block);
172
+ }
173
+ offset = end;
174
+ }
175
+ return { left, right };
176
+ }
177
+
178
+ /**
179
+ * Replace the logical range `[range.start, range.end)` (the active `@query`,
180
+ * trigger char through caret) with an atomic mention block for `ref`, keyed by
181
+ * the caller-supplied `id`. A separating space follows the token (Slack-style)
182
+ * unless the next char already is one, so typing and `@`-chaining continue
183
+ * without a manual spacebar press; the returned caret sits after that space.
184
+ * The caller owns id creation so this stays pure.
185
+ */
186
+ export function insertMention(
187
+ doc: ComposerDocument,
188
+ range: LogicalRange,
189
+ ref: AgentWidgetContextMentionRef,
190
+ id: ComposerMentionId
191
+ ): { doc: ComposerDocument; caret: number } {
192
+ const before = splitBlocksAt(doc.blocks, range.start).left;
193
+ const after = splitBlocksAt(doc.blocks, range.end).right;
194
+ const next = after[0];
195
+ const nextChar = next?.kind === "text" ? next.value.charAt(0) : "";
196
+ // A following newline already separates; keep the caret on the token's line.
197
+ const needsSpace = nextChar !== " " && nextChar !== "\t" && nextChar !== "\n";
198
+ const blocks = normalizeBlocks([
199
+ ...before,
200
+ { kind: "mention", id, ref },
201
+ ...(needsSpace ? [{ kind: "text", value: " " } as const] : []),
202
+ ...after
203
+ ]);
204
+ // `before` spans exactly logical `[0, range.start)`; the token adds one char,
205
+ // and the caret hops the separator (inserted or pre-existing space/tab).
206
+ const caret = range.start + 1 + (nextChar === "\n" ? 0 : 1);
207
+ return { doc: { blocks }, caret };
208
+ }
209
+
210
+ /**
211
+ * Drop the mention block with `id` and merge the surrounding text. Returns the
212
+ * new document and the caret offset where the token used to be. No-op (same doc)
213
+ * when the id is absent — the caret then points at end of document.
214
+ */
215
+ export function removeMention(
216
+ doc: ComposerDocument,
217
+ id: ComposerMentionId
218
+ ): { doc: ComposerDocument; caret: number } {
219
+ let caret = 0;
220
+ let offset = 0;
221
+ let found = false;
222
+ for (const block of doc.blocks) {
223
+ if (block.kind === "mention" && block.id === id) {
224
+ caret = offset;
225
+ found = true;
226
+ }
227
+ offset += blockLength(block);
228
+ }
229
+ if (!found) return { doc, caret: offset };
230
+ const blocks = normalizeBlocks(
231
+ doc.blocks.filter((b) => !(b.kind === "mention" && b.id === id))
232
+ );
233
+ return { doc: { blocks }, caret };
234
+ }
235
+
236
+ /**
237
+ * Replace the logical range `[start, end)` with plain `text`, preserving every
238
+ * mention token that lies OUTSIDE the range and dropping any that fall inside it.
239
+ * Returns the new document and the caret offset just after the inserted text.
240
+ *
241
+ * This is the general logical-range edit the DOM adapter needs whenever a native
242
+ * edit can't be trusted to keep tokens atomic — paste over a selection, and
243
+ * slash-command completion that rewrites a `/query` span while other tokens sit
244
+ * elsewhere in the line. `start`/`end` are clamped and ordered defensively.
245
+ */
246
+ export function spliceDocument(
247
+ doc: ComposerDocument,
248
+ start: number,
249
+ end: number,
250
+ text: string
251
+ ): { doc: ComposerDocument; caret: number } {
252
+ const total = logicalLength(doc);
253
+ const lo = Math.max(0, Math.min(start, end, total));
254
+ const hi = Math.min(total, Math.max(start, end, 0));
255
+ const before = splitBlocksAt(doc.blocks, lo).left;
256
+ const after = splitBlocksAt(doc.blocks, hi).right;
257
+ const blocks = normalizeBlocks([
258
+ ...before,
259
+ { kind: "text", value: text },
260
+ ...after
261
+ ]);
262
+ return { doc: { blocks }, caret: lo + text.length };
263
+ }
264
+
265
+ /**
266
+ * Produce the display/transcript fields a sent inline-mode message carries.
267
+ * `content` is the human-readable prose (`@label` tokens inline); `contextMentions`
268
+ * are the refs in document order; `contentSegments` is the full block list with
269
+ * empty text runs dropped for a clean transcript. The model-visible channel
270
+ * (`llmContent`/`contentParts`) is assembled separately by the manager bundle and
271
+ * `session.applyMentionBundle` — this function does not touch it.
272
+ */
273
+ export function documentToMessageFields(doc: ComposerDocument): {
274
+ content: string;
275
+ contextMentions: AgentWidgetContextMentionRef[];
276
+ contentSegments: AgentWidgetContentSegment[];
277
+ } {
278
+ const contentSegments: AgentWidgetContentSegment[] = [];
279
+ for (const block of doc.blocks) {
280
+ if (block.kind === "text") {
281
+ if (block.value.length > 0) {
282
+ contentSegments.push({ kind: "text", text: block.value });
283
+ }
284
+ } else {
285
+ contentSegments.push({ kind: "mention", ref: block.ref });
286
+ }
287
+ }
288
+ return {
289
+ content: toDisplayText(doc),
290
+ contextMentions: mentionBlocksInOrder(doc).map((m) => m.ref),
291
+ contentSegments
292
+ };
293
+ }
@@ -2,16 +2,17 @@ import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  navigateComposerHistory,
4
4
  INITIAL_HISTORY_STATE,
5
- type ComposerHistoryState
5
+ type ComposerHistoryState,
6
+ type ComposerHistoryInput
6
7
  } from "./composer-history";
7
8
 
8
9
  const history = ["first", "second", "third"]; // oldest -> newest
9
10
 
10
11
  const up = (
11
12
  state: ComposerHistoryState,
12
- overrides: Partial<Parameters<typeof navigateComposerHistory>[0]> = {}
13
+ overrides: Partial<ComposerHistoryInput<string>> = {}
13
14
  ) =>
14
- navigateComposerHistory({
15
+ navigateComposerHistory<string>({
15
16
  direction: "up",
16
17
  history,
17
18
  currentValue: "",
@@ -22,9 +23,9 @@ const up = (
22
23
 
23
24
  const down = (
24
25
  state: ComposerHistoryState,
25
- overrides: Partial<Parameters<typeof navigateComposerHistory>[0]> = {}
26
+ overrides: Partial<ComposerHistoryInput<string>> = {}
26
27
  ) =>
27
- navigateComposerHistory({
28
+ navigateComposerHistory<string>({
28
29
  direction: "down",
29
30
  history,
30
31
  currentValue: "",
@@ -93,7 +94,7 @@ describe("navigateComposerHistory", () => {
93
94
  const result = down(state);
94
95
  expect(result.handled).toBe(true);
95
96
  expect(result.value).toBe("saved draft");
96
- expect(result.state).toEqual(INITIAL_HISTORY_STATE);
97
+ expect(result.state.index).toBe(-1); // not navigating
97
98
  });
98
99
 
99
100
  it("Down does nothing when not navigating history", () => {
@@ -123,6 +124,33 @@ describe("navigateComposerHistory", () => {
123
124
 
124
125
  const d1 = down(state); // back past newest -> restore draft
125
126
  expect(d1.value).toBe(draft);
126
- expect(d1.state).toEqual(INITIAL_HISTORY_STATE);
127
+ expect(d1.state.index).toBe(-1);
128
+ });
129
+
130
+ it("is generic over the entry type (documents, not just strings)", () => {
131
+ // Inline mode recalls ComposerDocument-shaped entries; the machine only
132
+ // indexes and swaps, so any entry type round-trips.
133
+ type Doc = { blocks: string[] };
134
+ const docs: Doc[] = [{ blocks: ["a"] }, { blocks: ["b"] }];
135
+ const draft: Doc = { blocks: ["draft"] };
136
+ const u = navigateComposerHistory<Doc>({
137
+ direction: "up",
138
+ history: docs,
139
+ currentValue: draft,
140
+ atStart: true,
141
+ state: { index: -1, draft }
142
+ });
143
+ expect(u.value).toEqual({ blocks: ["b"] }); // newest
144
+ expect(u.state).toEqual({ index: 1, draft });
145
+
146
+ const d = navigateComposerHistory<Doc>({
147
+ direction: "down",
148
+ history: docs,
149
+ currentValue: draft,
150
+ atStart: true,
151
+ state: u.state
152
+ });
153
+ expect(d.value).toEqual(draft); // restored draft
154
+ expect(d.state.index).toBe(-1);
127
155
  });
128
156
  });