@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,91 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatMentionBlock } from "./mention-llm-format";
3
+ import type { AgentWidgetMentionLlmEntry } from "../types";
4
+
5
+ function entry(label: string, text: string): AgentWidgetMentionLlmEntry {
6
+ return {
7
+ label,
8
+ text,
9
+ ref: { sourceId: "files", itemId: label, label },
10
+ item: { id: label, label },
11
+ };
12
+ }
13
+
14
+ describe("formatMentionBlock", () => {
15
+ it("fenced (default): label in the info string, body inside a ``` fence", () => {
16
+ expect(formatMentionBlock(entry("App.tsx", "FILE BODY"), 0)).toBe(
17
+ "```App.tsx\nFILE BODY\n```"
18
+ );
19
+ // Passing "fenced" explicitly is identical to the default.
20
+ expect(formatMentionBlock(entry("App.tsx", "FILE BODY"), 0, "fenced")).toBe(
21
+ "```App.tsx\nFILE BODY\n```"
22
+ );
23
+ });
24
+
25
+ it("fenced: escalates to four backticks when the body contains a ``` fence", () => {
26
+ const body = "before\n```\ncode\n```\nafter";
27
+ expect(formatMentionBlock(entry("Doc", body), 0)).toBe(
28
+ "````Doc\n" + body + "\n````"
29
+ );
30
+ });
31
+
32
+ it("fenced: escalates past the longest run when the body contains a ```` fence", () => {
33
+ const body = "````\nnested\n````";
34
+ expect(formatMentionBlock(entry("Doc", body), 0)).toBe(
35
+ "`````Doc\n" + body + "\n`````"
36
+ );
37
+ });
38
+
39
+ it("fenced: escalates past fences indented up to three spaces (CommonMark)", () => {
40
+ // A closing fence indented by up to three spaces is valid CommonMark, so an
41
+ // indented ``` inside the body must still force a four-backtick wrapper.
42
+ const body = "- item\n ```\n code\n ```";
43
+ expect(formatMentionBlock(entry("Doc", body), 0)).toBe(
44
+ "````Doc\n" + body + "\n````"
45
+ );
46
+ // Four or more spaces is an indented code block, not a fence: no escalation.
47
+ expect(formatMentionBlock(entry("Doc", " ```"), 0)).toBe(
48
+ "```Doc\n ```\n```"
49
+ );
50
+ });
51
+
52
+ it("function form: a throwing template falls back to fenced instead of rejecting", () => {
53
+ const block = formatMentionBlock(entry("App.tsx", "FILE BODY"), 0, () => {
54
+ throw new Error("host template bug");
55
+ });
56
+ // One bad host template must not reject finalize() and drop every
57
+ // mention's context from the outgoing message.
58
+ expect(block).toBe("```App.tsx\nFILE BODY\n```");
59
+ });
60
+
61
+ it("document: Anthropic shape with a 1-based index", () => {
62
+ expect(formatMentionBlock(entry("App.tsx", "FILE BODY"), 0, "document")).toBe(
63
+ '<document index="1">\n' +
64
+ "<source>App.tsx</source>\n" +
65
+ "<document_content>\nFILE BODY\n</document_content>\n" +
66
+ "</document>"
67
+ );
68
+ // index is 0-based in, rendered 1-based out.
69
+ expect(formatMentionBlock(entry("Two", "B"), 1, "document")).toContain(
70
+ '<document index="2">'
71
+ );
72
+ });
73
+
74
+ it("document: falls back to the fenced block when the body carries the closing tag", () => {
75
+ const body = "spoofed </document_content> injection";
76
+ // Would break the XML boundary → fenced instead.
77
+ expect(formatMentionBlock(entry("Doc", body), 0, "document")).toBe(
78
+ "```Doc\n" + body + "\n```"
79
+ );
80
+ });
81
+
82
+ it("function form: receives {entry, index} and its return is used verbatim", () => {
83
+ const seen: { label: string; index: number }[] = [];
84
+ const out = formatMentionBlock(entry("App.tsx", "BODY"), 3, (e, i) => {
85
+ seen.push({ label: e.label, index: i });
86
+ return `CUSTOM(${e.item.id}):${e.text}`;
87
+ });
88
+ expect(out).toBe("CUSTOM(App.tsx):BODY");
89
+ expect(seen).toEqual([{ label: "App.tsx", index: 3 }]);
90
+ });
91
+ });
@@ -0,0 +1,79 @@
1
+ import type {
2
+ AgentWidgetContextMentionConfig,
3
+ AgentWidgetMentionLlmEntry,
4
+ } from "../types";
5
+
6
+ /**
7
+ * Longest run of leading backticks on any line of `text`. A fenced block whose
8
+ * fence is longer than this can safely wrap the body without the body's own
9
+ * fences closing it early. CommonMark treats a closing fence indented by up to
10
+ * three spaces as valid, so indented runs count too.
11
+ */
12
+ function longestLeadingBacktickRun(text: string): number {
13
+ let longest = 0;
14
+ for (const line of text.split("\n")) {
15
+ const match = /^ {0,3}(`+)/.exec(line);
16
+ if (match && match[1].length > longest) longest = match[1].length;
17
+ }
18
+ return longest;
19
+ }
20
+
21
+ /**
22
+ * Wrap `text` in a fenced code block carrying `label` in the info string. The
23
+ * fence escalates past any backtick run inside the body (three backticks by
24
+ * default, four/five/… when the body itself contains a fence) so a mention body
25
+ * can never terminate its own wrapper.
26
+ */
27
+ function fencedBlock(label: string, text: string): string {
28
+ const fence = "`".repeat(Math.max(3, longestLeadingBacktickRun(text) + 1));
29
+ return `${fence}${label}\n${text}\n${fence}`;
30
+ }
31
+
32
+ /**
33
+ * Anthropic's documented long-context document shape. `index` is 0-based here
34
+ * and rendered 1-based (`index + 1`) to match Anthropic's example numbering.
35
+ * A body containing the literal `</document_content>` closing tag would break
36
+ * the XML boundary, so that one entry falls back to the fenced block, which has
37
+ * no such collision.
38
+ */
39
+ function documentBlock(label: string, text: string, index: number): string {
40
+ if (text.includes("</document_content>")) return fencedBlock(label, text);
41
+ return (
42
+ `<document index="${index + 1}">\n` +
43
+ `<source>${label}</source>\n` +
44
+ `<document_content>\n${text}\n</document_content>\n` +
45
+ `</document>`
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Format one resolved mention into the ready-to-join LLM block string, per the
51
+ * widget's `contextMentions.llmFormat`. `index` is the 0-based position of this
52
+ * block among the message's contributed mention blocks (used by the `"document"`
53
+ * preset's 1-based numbering and passed through to the function form).
54
+ *
55
+ * A throwing function-form template falls back to the fenced preset for that
56
+ * entry instead of propagating: `finalize()` runs the whole assembly in one
57
+ * pass, so an uncaught throw here would reject the entire bundle and silently
58
+ * drop every mention's context (plus contentParts and structured context) from
59
+ * the outgoing message — far worse than one host-formatted block degrading.
60
+ */
61
+ export function formatMentionBlock(
62
+ entry: AgentWidgetMentionLlmEntry,
63
+ index: number,
64
+ format: AgentWidgetContextMentionConfig["llmFormat"] = "fenced"
65
+ ): string {
66
+ if (typeof format === "function") {
67
+ try {
68
+ return format(entry, index);
69
+ } catch (error) {
70
+ console.warn(
71
+ "[persona] contextMentions.llmFormat threw; falling back to the fenced format for this mention",
72
+ error
73
+ );
74
+ return fencedBlock(entry.label, entry.text);
75
+ }
76
+ }
77
+ if (format === "document") return documentBlock(entry.label, entry.text, index);
78
+ return fencedBlock(entry.label, entry.text);
79
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { defaultMentionFilter, createStaticMentionSource } from "./mention-matcher";
3
+ import type { AgentWidgetContextMentionItem } from "../types";
4
+
5
+ const item = (
6
+ label: string,
7
+ extra: Partial<AgentWidgetContextMentionItem> = {}
8
+ ): AgentWidgetContextMentionItem => ({ id: label, label, ...extra });
9
+
10
+ describe("defaultMentionFilter", () => {
11
+ it("returns all items recency-ordered for an empty query", () => {
12
+ const items = [
13
+ item("alpha", { recencyScore: 1 }),
14
+ item("beta", { recencyScore: 5 }),
15
+ item("gamma", { recencyScore: 3 }),
16
+ ];
17
+ expect(defaultMentionFilter(items, "").map((i) => i.label)).toEqual([
18
+ "beta",
19
+ "gamma",
20
+ "alpha",
21
+ ]);
22
+ });
23
+
24
+ it("ranks prefix > word-boundary > subsequence", () => {
25
+ const items = [
26
+ item("subsequence_app_x"), // 'app' as subsequence only? actually contains 'app' as substring
27
+ item("my-app-config"), // word boundary: 'app' starts the 'app' word
28
+ item("application"), // prefix
29
+ item("apricot pie"), // subsequence a-p-p? 'apricot pie' -> a,p,...,p (yes)
30
+ ];
31
+ const ranked = defaultMentionFilter(items, "app").map((i) => i.label);
32
+ expect(ranked[0]).toBe("application"); // prefix wins
33
+ expect(ranked.indexOf("my-app-config")).toBeLessThan(
34
+ ranked.indexOf("apricot pie")
35
+ );
36
+ });
37
+
38
+ it("drops non-matching items", () => {
39
+ const items = [item("dashboard"), item("settings")];
40
+ expect(defaultMentionFilter(items, "xyz")).toHaveLength(0);
41
+ });
42
+
43
+ it("is case-insensitive", () => {
44
+ const items = [item("README.md")];
45
+ expect(defaultMentionFilter(items, "readme")).toHaveLength(1);
46
+ });
47
+
48
+ it("treats camelCase boundaries as word starts", () => {
49
+ const items = [item("getUserName")];
50
+ const ranked = defaultMentionFilter(items, "user");
51
+ expect(ranked).toHaveLength(1);
52
+ });
53
+
54
+ it("breaks ties by recency then label", () => {
55
+ const items = [
56
+ item("appB", { recencyScore: 1 }),
57
+ item("appA", { recencyScore: 1 }),
58
+ item("appC", { recencyScore: 9 }),
59
+ ];
60
+ expect(defaultMentionFilter(items, "app").map((i) => i.label)).toEqual([
61
+ "appC",
62
+ "appA",
63
+ "appB",
64
+ ]);
65
+ });
66
+ });
67
+
68
+ describe("createStaticMentionSource", () => {
69
+ it("wires search() to defaultMentionFilter and passes resolve through", async () => {
70
+ const resolve = async () => ({ llmAppend: "x" });
71
+ const src = createStaticMentionSource({
72
+ id: "files",
73
+ label: "Files",
74
+ items: [item("App.tsx"), item("index.ts")],
75
+ resolve,
76
+ resolveOn: "submit",
77
+ });
78
+ expect(src.id).toBe("files");
79
+ expect(src.label).toBe("Files");
80
+ expect(src.resolveOn).toBe("submit");
81
+ const ctx = {} as never;
82
+ const results = await src.search("app", ctx);
83
+ expect(results.map((i) => i.label)).toEqual(["App.tsx"]);
84
+ expect(src.resolve).toBe(resolve);
85
+ });
86
+ });
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Default fuzzy matcher + static-source helper for context mentions.
3
+ *
4
+ * Pure and dependency-free so it can be exported for hosts (to filter
5
+ * client-side sources) and unit-tested in isolation. Ranking tiers, best first:
6
+ * 1. prefix — label starts with the query
7
+ * 2. word-boundary — a word inside the label starts with the query
8
+ * 3. subsequence — query chars appear in order anywhere in the label
9
+ * Ties break by `recencyScore` (desc) then label (asc). An empty query returns
10
+ * every item ordered by recency (recents/popular first).
11
+ */
12
+
13
+ import type {
14
+ AgentWidgetContextMentionCommandContext,
15
+ AgentWidgetContextMentionItem,
16
+ AgentWidgetContextMentionSource,
17
+ } from "../types";
18
+
19
+ // Match tiers, best (lowest) first. Plain numbers rather than a const enum so
20
+ // the module stays isolatedModules-safe.
21
+ const TIER_PREFIX = 0;
22
+ const TIER_WORD_BOUNDARY = 1;
23
+ const TIER_SUBSEQUENCE = 2;
24
+ const TIER_NONE = 3;
25
+
26
+ function isSubsequence(needle: string, haystack: string): boolean {
27
+ if (!needle) return true;
28
+ let n = 0;
29
+ for (let h = 0; h < haystack.length && n < needle.length; h++) {
30
+ if (haystack[h] === needle[n]) n++;
31
+ }
32
+ return n === needle.length;
33
+ }
34
+
35
+ function scoreItem(label: string, query: string): number {
36
+ const l = label.toLowerCase();
37
+ const q = query.toLowerCase();
38
+ if (l.startsWith(q)) return TIER_PREFIX;
39
+ // Word boundaries: whitespace, hyphen, underscore, slash, dot, camelCase.
40
+ // Split camelCase by inserting a space at lower→upper transitions first
41
+ // (a lookbehind-free rewrite — lookbehind is a parse error on Safari < 16.4,
42
+ // and this module ships in the core bundle, so a regex literal there would
43
+ // break the whole widget on those browsers even with mentions disabled).
44
+ const words = label.replace(/([a-z])([A-Z])/g, "$1 $2").split(/[\s\-_/.]+/);
45
+ if (words.some((w) => w.toLowerCase().startsWith(q))) return TIER_WORD_BOUNDARY;
46
+ if (isSubsequence(q, l)) return TIER_SUBSEQUENCE;
47
+ return TIER_NONE;
48
+ }
49
+
50
+ function compareWithinTier(
51
+ a: AgentWidgetContextMentionItem,
52
+ b: AgentWidgetContextMentionItem
53
+ ): number {
54
+ const ra = a.recencyScore ?? 0;
55
+ const rb = b.recencyScore ?? 0;
56
+ if (rb !== ra) return rb - ra;
57
+ return a.label.localeCompare(b.label);
58
+ }
59
+
60
+ /**
61
+ * Filter + rank items for the current `@query`. An empty query keeps all items,
62
+ * recency-ordered. Non-matching items are dropped.
63
+ */
64
+ export function defaultMentionFilter(
65
+ items: AgentWidgetContextMentionItem[],
66
+ query: string
67
+ ): AgentWidgetContextMentionItem[] {
68
+ const q = query.trim();
69
+ if (!q) {
70
+ return [...items].sort(compareWithinTier);
71
+ }
72
+ const scored: { item: AgentWidgetContextMentionItem; tier: number }[] = [];
73
+ for (const item of items) {
74
+ const tier = scoreItem(item.label, q);
75
+ if (tier !== TIER_NONE) scored.push({ item, tier });
76
+ }
77
+ scored.sort((a, b) => {
78
+ if (a.tier !== b.tier) return a.tier - b.tier;
79
+ return compareWithinTier(a.item, b.item);
80
+ });
81
+ return scored.map((s) => s.item);
82
+ }
83
+
84
+ /**
85
+ * Split a command-channel query into its command NAME (first token) and ARGS
86
+ * (everything after, trimmed). `"deploy staging"` → `{ name: "deploy", args:
87
+ * "staging" }`; `"deploy"` → `{ name: "deploy", args: "" }`. Shared by the
88
+ * slash-command source (matches on `name`) and the controller (captures `args`).
89
+ */
90
+ export function splitCommandQuery(query: string): { name: string; args: string } {
91
+ const trimmed = query.replace(/^\s+/, "");
92
+ const sp = trimmed.search(/\s/);
93
+ if (sp === -1) return { name: trimmed, args: "" };
94
+ return { name: trimmed.slice(0, sp), args: trimmed.slice(sp + 1).trim() };
95
+ }
96
+
97
+ /**
98
+ * The easy path: a static, in-memory list filtered client-side with
99
+ * {@link defaultMentionFilter}. Supply your own `resolve()` to turn a selected
100
+ * item into a dispatch payload (lead with `llmAppend`).
101
+ */
102
+ export function createStaticMentionSource(opts: {
103
+ id: string;
104
+ label: string;
105
+ items: AgentWidgetContextMentionItem[];
106
+ resolve: AgentWidgetContextMentionSource["resolve"];
107
+ resolveOn?: "select" | "submit";
108
+ }): AgentWidgetContextMentionSource {
109
+ return {
110
+ id: opts.id,
111
+ label: opts.label,
112
+ resolveOn: opts.resolveOn,
113
+ search: (query) => defaultMentionFilter(opts.items, query),
114
+ resolve: opts.resolve,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * A single slash-command definition for {@link createSlashCommandsSource}. Maps
120
+ * to a menu item plus its dispatch behavior (see `AgentWidgetContextMentionItem.command`):
121
+ * - `"prompt"` (default): `prompt` text is written into the composer (a macro).
122
+ * - `"action"`: `action()` runs in the browser (no message sent).
123
+ * - `"server"`: `data` is sent to the backend via request `context.mentions`.
124
+ */
125
+ export type SlashCommandDefinition = {
126
+ /** Command name shown after the trigger (e.g. "summarize" → `/summarize`). */
127
+ name: string;
128
+ /** Menu subtitle. */
129
+ description?: string;
130
+ /** Lucide icon name for the menu row / chip. */
131
+ iconName?: string;
132
+ /** Dispatch kind. @default "prompt" */
133
+ kind?: "prompt" | "action" | "server";
134
+ /** `kind:"prompt"` — composer text (static, or built from the typed args). */
135
+ prompt?: string | ((args: string) => string);
136
+ /** `kind:"prompt"` — insertion mode. @default "replace" */
137
+ insertMode?: "replace" | "insert-at-caret";
138
+ /** `kind:"prompt"` — submit the composer immediately after inserting. */
139
+ submitOnSelect?: boolean;
140
+ /**
141
+ * Free-text argument hint (e.g. "order id"). Shown as ghost text in the menu
142
+ * row (`lookup ‹order id›`) and switches the command to INLINE COMPLETION:
143
+ * selecting it fills `/name ` into the composer so you type the arg inline,
144
+ * and the command runs at SUBMIT with the typed args (Slack-style). Optional
145
+ * for `"prompt"`/`"action"`; `"server"` commands always complete inline.
146
+ */
147
+ argsPlaceholder?: string;
148
+ /** `kind:"action"` — the browser handler (receives parsed `args`). */
149
+ action?: (ctx: AgentWidgetContextMentionCommandContext) => void | Promise<void>;
150
+ /** `kind:"server"` — structured payload sent via `context.mentions.<sourceId>.<name>`. */
151
+ data?:
152
+ | Record<string, unknown>
153
+ | ((args: string) => Record<string, unknown>);
154
+ };
155
+
156
+ /**
157
+ * Build a `/`-slash-command SOURCE for `contextMentions.triggers`. Unlike a
158
+ * mention source, its items are COMMANDS (verbs): selecting one runs a prompt
159
+ * macro, a client action, or a server skill (see {@link SlashCommandDefinition}).
160
+ *
161
+ * Matching is on the command NAME only (the first token of the query), so
162
+ * `/deploy staging` still matches the `deploy` command while `staging` becomes
163
+ * the command's `args`. Pair it with a `"/"` channel, typically at line-start:
164
+ *
165
+ * ```ts
166
+ * contextMentions: {
167
+ * enabled: true,
168
+ * sources: [ ...mentionSources ], // @ context
169
+ * triggers: [{
170
+ * trigger: "/",
171
+ * triggerPosition: "line-start",
172
+ * sources: [createSlashCommandsSource({ id: "cmd", label: "Commands", commands })],
173
+ * }],
174
+ * }
175
+ * ```
176
+ */
177
+ export function createSlashCommandsSource(opts: {
178
+ id: string;
179
+ label: string;
180
+ commands: SlashCommandDefinition[];
181
+ }): AgentWidgetContextMentionSource {
182
+ const items: AgentWidgetContextMentionItem[] = opts.commands.map((c) => ({
183
+ id: c.name,
184
+ label: c.name,
185
+ description: c.description,
186
+ iconName: c.iconName,
187
+ command: c.kind ?? "prompt",
188
+ insertMode: c.insertMode,
189
+ submitOnSelect: c.submitOnSelect,
190
+ action: c.action,
191
+ commandArgsPlaceholder: c.argsPlaceholder,
192
+ }));
193
+ const byName = new Map(opts.commands.map((c) => [c.name, c]));
194
+ const itemByName = new Map(items.map((it) => [it.id, it]));
195
+
196
+ return {
197
+ id: opts.id,
198
+ label: opts.label,
199
+ // Exact-name lookup for submit-time inline-command dispatch (the query's
200
+ // first token) — not a fuzzy search.
201
+ matchCommand: (name) => itemByName.get(name),
202
+ // Server commands defer resolve to submit (args captured at select). Prompt
203
+ // commands call resolve synchronously from the controller; action commands
204
+ // never resolve. So "submit" is the correct source-level default here.
205
+ resolveOn: "submit",
206
+ search: (query) => defaultMentionFilter(items, splitCommandQuery(query).name),
207
+ resolve: (item, ctx) => {
208
+ const def = byName.get(item.id);
209
+ if (!def) return {};
210
+ if (def.kind === "server") {
211
+ const data =
212
+ typeof def.data === "function" ? def.data(ctx.args) : def.data;
213
+ return { context: data ?? {} };
214
+ }
215
+ // prompt (and the default): the composer-insert text.
216
+ const text =
217
+ typeof def.prompt === "function" ? def.prompt(ctx.args) : def.prompt ?? "";
218
+ return { insertText: text };
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared builder for the inline mention TOKEN — the atomic styled pill that sits
3
+ * inside the sentence (Slack/Linear/Cursor style), used by both the contenteditable
4
+ * composer (inline chunk) and the read-only sent bubble (`message-bubble`, core).
5
+ *
6
+ * The default is a tinted pill: icon + `@label`, colored by
7
+ * `--persona-mention-token-accent` (from `ref.color`, else the theme accent), with
8
+ * a `data-mention-source` attribute so hosts can theme per source/type in CSS
9
+ * (`.persona-mention-token[data-mention-source="files"] { … }`). A host can replace
10
+ * the whole element via `render` (the `renderMentionToken` config hook).
11
+ *
12
+ * Display-only: no document model, safe to bundle in both the core and the inline
13
+ * chunk (a small duplicate, like other shared helpers).
14
+ */
15
+
16
+ import { createElement, createNode, cx } from "./dom";
17
+ import { renderLucideIcon } from "./icons";
18
+ import type { AgentWidgetContextMentionRef } from "../types";
19
+
20
+ export interface AgentWidgetContextMentionTokenRenderContext {
21
+ ref: AgentWidgetContextMentionRef;
22
+ readonly: boolean;
23
+ }
24
+
25
+ export interface CreateMentionTokenOptions {
26
+ /** True in the sent bubble (adds `persona-mention-token-readonly`). */
27
+ readonly?: boolean;
28
+ /** Full-custom token DOM (the `renderMentionToken` config hook). */
29
+ render?: (ctx: AgentWidgetContextMentionTokenRenderContext) => HTMLElement;
30
+ }
31
+
32
+ export function createMentionTokenElement(
33
+ ref: AgentWidgetContextMentionRef,
34
+ opts: CreateMentionTokenOptions = {}
35
+ ): HTMLElement {
36
+ if (opts.render) return opts.render({ ref, readonly: !!opts.readonly });
37
+
38
+ const token = createNode("span", {
39
+ className: cx(
40
+ "persona-mention-token",
41
+ opts.readonly && "persona-mention-token-readonly"
42
+ ),
43
+ // role="img" + aria-label makes the icon + `@label` announce as one atomic
44
+ // unit ("App.tsx mention") instead of the icon and text being read
45
+ // separately; the resolve-status hook may later swap this label (see
46
+ // composer-contenteditable `setMentionStatus`).
47
+ attrs: {
48
+ "data-mention-source": ref.sourceId,
49
+ title: ref.label,
50
+ role: "img",
51
+ "aria-label": `${ref.label} mention`,
52
+ },
53
+ });
54
+ // Per-item / per-type accent recolors the whole pill (bg tint + icon + text).
55
+ if (ref.color) {
56
+ token.style.setProperty("--persona-mention-token-accent", ref.color);
57
+ }
58
+
59
+ const iconEl = renderLucideIcon(ref.iconName ?? "at-sign", 13, "currentColor", 2);
60
+ if (iconEl) {
61
+ const iconHost = createElement("span", "persona-mention-token-icon");
62
+ iconHost.appendChild(iconEl);
63
+ token.appendChild(iconHost);
64
+ }
65
+ token.appendChild(
66
+ createNode("span", {
67
+ className: "persona-mention-token-label",
68
+ text: `@${ref.label}`,
69
+ })
70
+ );
71
+ return token;
72
+ }