@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.
- package/README.md +14 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
- package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +8 -8
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +72 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +634 -3
- package/dist/index.d.ts +634 -3
- package/dist/index.global.js +59 -51
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +64 -56
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.cjs +17 -16
- package/dist/smart-dom-reader.d.cts +507 -1
- package/dist/smart-dom-reader.d.ts +507 -1
- package/dist/smart-dom-reader.js +17 -16
- package/dist/theme-editor-preview.cjs +236 -57
- package/dist/theme-editor-preview.d.cts +485 -1
- package/dist/theme-editor-preview.d.ts +485 -1
- package/dist/theme-editor-preview.js +53 -47
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +473 -0
- package/dist/theme-editor.d.ts +473 -0
- package/dist/theme-editor.js +5 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/widget.css +1 -1
- package/package.json +15 -3
- package/src/client.test.ts +69 -0
- package/src/client.ts +65 -51
- package/src/components/artifact-pane.test.ts +47 -0
- package/src/components/artifact-pane.ts +25 -2
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/index-core.ts +27 -0
- package/src/index-global.ts +51 -0
- package/src/markdown-parsers-loader.ts +35 -44
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/init-update-reset.test.ts +81 -0
- package/src/runtime/init.test.ts +62 -0
- package/src/runtime/init.ts +7 -14
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +121 -5
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +243 -126
- package/src/theme-editor/sections.ts +3 -3
- package/src/types/theme.ts +2 -0
- package/src/types.ts +542 -0
- package/src/ui.artifact-pane-gating.test.ts +11 -1
- package/src/ui.attachments-drop.test.ts +90 -0
- package/src/ui.header-update-stability.test.ts +149 -0
- package/src/ui.launcher-update-merge.test.ts +83 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.send-button-stream-update.test.ts +69 -0
- package/src/ui.ts +379 -84
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/config-merge.test.ts +131 -0
- package/src/utils/config-merge.ts +61 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/theme.test.ts +54 -4
- package/src/utils/theme.ts +6 -3
- 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<
|
|
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<
|
|
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).
|
|
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).
|
|
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
|
});
|