@djangocfg/widget-chat 0.1.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/package.json +95 -0
  4. package/src/composer/composer.tsx +100 -0
  5. package/src/composer/context.ts +33 -0
  6. package/src/composer/controls/button.tsx +104 -0
  7. package/src/composer/controls/language-menu.tsx +123 -0
  8. package/src/composer/controls/send.tsx +83 -0
  9. package/src/composer/controls/think.tsx +50 -0
  10. package/src/composer/controls/tools.tsx +130 -0
  11. package/src/composer/controls/tooltip.tsx +10 -0
  12. package/src/composer/controls/voice.tsx +266 -0
  13. package/src/composer/field/field.tsx +140 -0
  14. package/src/composer/field/input.tsx +214 -0
  15. package/src/composer/field/keys.ts +44 -0
  16. package/src/composer/field/use-autosize.ts +134 -0
  17. package/src/composer/focus-field.ts +27 -0
  18. package/src/composer/index.ts +86 -0
  19. package/src/composer/stories/field.tsx +112 -0
  20. package/src/composer/stories/harness.tsx +127 -0
  21. package/src/composer/tools/menu.tsx +129 -0
  22. package/src/composer/tools/rows.tsx +180 -0
  23. package/src/composer/tools/use-menu-nav.ts +66 -0
  24. package/src/composer/tools/use-recent-tools.ts +30 -0
  25. package/src/composer/tray/banner.tsx +157 -0
  26. package/src/composer/tray/command-progress.tsx +116 -0
  27. package/src/composer/tray/picked-tools.tsx +104 -0
  28. package/src/composer/tray/queue.tsx +202 -0
  29. package/src/composer/tray/use-replace-text.ts +58 -0
  30. package/src/composer/use-draft-persistence.ts +79 -0
  31. package/src/content/index.ts +16 -0
  32. package/src/content/media-content.tsx +38 -0
  33. package/src/content/response.tsx +221 -0
  34. package/src/core/adapter.ts +29 -0
  35. package/src/core/capabilities.ts +286 -0
  36. package/src/core/chunks.ts +66 -0
  37. package/src/core/content/incomplete-math.ts +118 -0
  38. package/src/core/content/index.ts +9 -0
  39. package/src/core/draft-storage.ts +62 -0
  40. package/src/core/events.ts +88 -0
  41. package/src/core/focus/index.ts +10 -0
  42. package/src/core/focus/roving.ts +56 -0
  43. package/src/core/group-parts.ts +80 -0
  44. package/src/core/index.ts +34 -0
  45. package/src/core/links/index.ts +33 -0
  46. package/src/core/links/prose.ts +191 -0
  47. package/src/core/links/scheme.ts +98 -0
  48. package/src/core/links/tool-id.ts +26 -0
  49. package/src/core/media/allowed-src.ts +138 -0
  50. package/src/core/media/block.ts +254 -0
  51. package/src/core/media/index.ts +22 -0
  52. package/src/core/media/kinds.ts +85 -0
  53. package/src/core/outbound.ts +86 -0
  54. package/src/core/parts.ts +169 -0
  55. package/src/core/queue/index.ts +15 -0
  56. package/src/core/queue/queue.ts +100 -0
  57. package/src/core/recall/boundary.ts +77 -0
  58. package/src/core/recall/from-transcript.ts +47 -0
  59. package/src/core/recall/index.ts +23 -0
  60. package/src/core/recall/ladder.ts +171 -0
  61. package/src/core/reload-decision.ts +36 -0
  62. package/src/core/scroll/index.ts +17 -0
  63. package/src/core/scroll/missed.ts +106 -0
  64. package/src/core/scroll/sent.ts +54 -0
  65. package/src/core/text-replace.ts +20 -0
  66. package/src/core/tool-name.ts +31 -0
  67. package/src/core/tool-picks-set.ts +48 -0
  68. package/src/core/tool-picks.ts +142 -0
  69. package/src/core/tool-prompt.ts +47 -0
  70. package/src/core/tools/capability.ts +34 -0
  71. package/src/core/tools/catalogue.ts +109 -0
  72. package/src/core/tools/index.ts +17 -0
  73. package/src/core/tools/layout.ts +44 -0
  74. package/src/core/tools/nav.ts +32 -0
  75. package/src/core/tools/rank.ts +60 -0
  76. package/src/core/tools/recent.ts +39 -0
  77. package/src/core/transcript.ts +301 -0
  78. package/src/core/voice/earcons.ts +58 -0
  79. package/src/core/voice/index.ts +26 -0
  80. package/src/core/voice/language-search.ts +62 -0
  81. package/src/core/voice/languages.ts +267 -0
  82. package/src/core/voice/resolve-language.ts +93 -0
  83. package/src/core/voice/session.ts +196 -0
  84. package/src/core/voice/web-speech.ts +170 -0
  85. package/src/i18n/index.ts +87 -0
  86. package/src/i18n/locales/ar.ts +97 -0
  87. package/src/i18n/locales/da.ts +90 -0
  88. package/src/i18n/locales/de.ts +90 -0
  89. package/src/i18n/locales/en.ts +90 -0
  90. package/src/i18n/locales/es.ts +90 -0
  91. package/src/i18n/locales/fr.ts +90 -0
  92. package/src/i18n/locales/it.ts +90 -0
  93. package/src/i18n/locales/ja.ts +83 -0
  94. package/src/i18n/locales/ko.ts +83 -0
  95. package/src/i18n/locales/nl.ts +90 -0
  96. package/src/i18n/locales/no.ts +90 -0
  97. package/src/i18n/locales/pl.ts +96 -0
  98. package/src/i18n/locales/ptBR.ts +90 -0
  99. package/src/i18n/locales/ru.ts +96 -0
  100. package/src/i18n/locales/sv.ts +90 -0
  101. package/src/i18n/locales/tr.ts +89 -0
  102. package/src/i18n/locales/zh.ts +83 -0
  103. package/src/i18n/plural.ts +62 -0
  104. package/src/i18n/types.ts +257 -0
  105. package/src/index.ts +18 -0
  106. package/src/primitives/index.ts +43 -0
  107. package/src/primitives/message.tsx +85 -0
  108. package/src/primitives/part-registry.tsx +44 -0
  109. package/src/primitives/stories-harness.tsx +118 -0
  110. package/src/primitives/suggestions.tsx +112 -0
  111. package/src/primitives/tool-call.tsx +145 -0
  112. package/src/primitives/tooltip.tsx +48 -0
  113. package/src/primitives/transcript.tsx +285 -0
  114. package/src/primitives/use-dismiss.ts +100 -0
  115. package/src/primitives/use-missed-count.ts +30 -0
  116. package/src/primitives/use-overflows.ts +69 -0
  117. package/src/primitives/use-return-on-send.ts +29 -0
  118. package/src/primitives/use-roving-focus.ts +69 -0
  119. package/src/primitives/use-select-all-transcript.ts +111 -0
  120. package/src/primitives/use-stick-to-bottom.ts +83 -0
  121. package/src/primitives/use-turn-activity.ts +25 -0
  122. package/src/store/context.ts +28 -0
  123. package/src/store/hooks.ts +215 -0
  124. package/src/store/index.ts +31 -0
  125. package/src/store/provider.tsx +97 -0
  126. package/src/store/store.ts +394 -0
  127. package/src/store/subscribers.ts +55 -0
  128. package/src/store/surfaces.ts +174 -0
  129. package/src/store/use-draft.ts +168 -0
  130. package/src/store/use-queue.ts +61 -0
  131. package/src/store/use-recall.ts +264 -0
  132. package/src/store/use-reload-on-revision.ts +67 -0
  133. package/src/store/use-speech-language.ts +84 -0
  134. package/src/store/use-voice.ts +116 -0
  135. package/src/styles/block.css +128 -0
  136. package/src/styles/composer.css +449 -0
  137. package/src/styles/index.css +47 -0
  138. package/src/styles/markdown.css +368 -0
  139. package/src/styles/surface.css +102 -0
  140. package/src/styles/tool.css +128 -0
  141. package/src/styles/turn-mark.css +97 -0
  142. package/src/testing/index.ts +15 -0
  143. package/src/testing/mock-adapter.ts +69 -0
  144. package/src/testing/mock-tools.ts +101 -0
  145. package/src/testing/mock-voice.ts +81 -0
  146. package/src/ui/chat-composer.tsx +286 -0
  147. package/src/ui/chat-reply-chip.tsx +42 -0
  148. package/src/ui/chat-suggestions.tsx +87 -0
  149. package/src/ui/chat-transcript.tsx +157 -0
  150. package/src/ui/chat.tsx +197 -0
  151. package/src/ui/collapsible-body.tsx +180 -0
  152. package/src/ui/collapsible-row.tsx +81 -0
  153. package/src/ui/day-divider.tsx +49 -0
  154. package/src/ui/index.ts +54 -0
  155. package/src/ui/jump-to-latest.tsx +60 -0
  156. package/src/ui/link-chip.tsx +73 -0
  157. package/src/ui/message-actions.tsx +163 -0
  158. package/src/ui/message-quote.tsx +47 -0
  159. package/src/ui/message.tsx +295 -0
  160. package/src/ui/pending.tsx +85 -0
  161. package/src/ui/renderers/data.tsx +63 -0
  162. package/src/ui/renderers/reasoning.tsx +73 -0
  163. package/src/ui/renderers/tool.tsx +176 -0
  164. package/src/ui/turn-mark.tsx +31 -0
  165. package/src/utils/day-heading.ts +46 -0
  166. package/src/utils/error-message.ts +10 -0
  167. package/src/utils/index.ts +12 -0
  168. package/src/utils/markdown-text.ts +175 -0
  169. package/src/widgets/index.ts +14 -0
  170. package/src/widgets/media.tsx +239 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Hide an UNCLOSED `$$` from the math parser.
3
+ *
4
+ * ## The defect, measured
5
+ *
6
+ * streamdown completes half-arrived markdown so a bold run styles itself while
7
+ * the model is still typing it. Applied to math that behaviour is wrong rather
8
+ * than merely early: `complexity is $$O(n \log` renders a typeset `O(n log` —
9
+ * a DIFFERENT expression from the one being written, which then jumps to
10
+ * another as tokens land. A reader cannot tell an intermediate formula from a
11
+ * final one, so a plausible wrong formula is presented as an answer.
12
+ *
13
+ * ## Why not `parseIncompleteMarkdown: false`
14
+ *
15
+ * Because the flag is global, and completion is the whole reason this
16
+ * dependency was chosen. On this exact input set:
17
+ *
18
+ * | unclosed | flag on | flag off |
19
+ * |---|---|---|
20
+ * | `**important` | bold | plain |
21
+ * | `` `foo( `` | code | plain |
22
+ * | `[docs](` | link | plain |
23
+ * | `$$O(n \log` | KATEX ← the defect | plain |
24
+ *
25
+ * Turning it off fixes one row by breaking three. So completion stays on and
26
+ * the ONE construct where a premature guess is a lie gets neutralised here.
27
+ *
28
+ * ## How
29
+ *
30
+ * The last `$$` is escaped only when it opens a run nothing closes. Escaping
31
+ * (rather than deleting) is what keeps the literal `$$` visible: the user sees
32
+ * the characters the model has actually emitted, which is the honest state.
33
+ */
34
+
35
+ /** The delimiter. Inline math uses the same one — `singleDollarTextMath` is off. */
36
+ const DELIMITER = "$$";
37
+
38
+ /**
39
+ * Escape a trailing unclosed `$$`, leaving balanced math untouched.
40
+ *
41
+ * Fenced code is skipped: `` ```sh\necho $$ `` is a shell PID, not an opening
42
+ * delimiter, and rewriting inside a fence would corrupt code the user copies.
43
+ */
44
+ export function maskIncompleteMath(source: string): string {
45
+ const segments = splitOnFences(source);
46
+ let count = 0;
47
+
48
+ // Count across prose segments only — a `$$` inside a fence closes nothing.
49
+ for (const segment of segments) {
50
+ if (segment.fenced) continue;
51
+ count += occurrences(segment.text);
52
+ }
53
+
54
+ if (count % 2 === 0) return source;
55
+
56
+ // Odd ⇒ the LAST prose `$$` is the unclosed opener.
57
+ for (let i = segments.length - 1; i >= 0; i -= 1) {
58
+ const segment = segments[i];
59
+ if (!segment || segment.fenced) continue;
60
+ const at = segment.text.lastIndexOf(DELIMITER);
61
+ if (at === -1) continue;
62
+ segments[i] = {
63
+ fenced: false,
64
+ text: `${segment.text.slice(0, at)}\\$\\$${segment.text.slice(at + DELIMITER.length)}`,
65
+ };
66
+ break;
67
+ }
68
+
69
+ return segments.map((segment) => segment.text).join("");
70
+ }
71
+
72
+ interface Segment {
73
+ readonly fenced: boolean;
74
+ readonly text: string;
75
+ }
76
+
77
+ function occurrences(text: string): number {
78
+ let n = 0;
79
+ let from = 0;
80
+ for (;;) {
81
+ const at = text.indexOf(DELIMITER, from);
82
+ if (at === -1) return n;
83
+ n += 1;
84
+ from = at + DELIMITER.length;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Split into alternating prose and fenced-code segments.
90
+ *
91
+ * An UNCLOSED fence swallows the rest of the string — which is correct: while a
92
+ * fence is open, everything after it is code, including any `$$`.
93
+ */
94
+ function splitOnFences(source: string): Segment[] {
95
+ const segments: Segment[] = [];
96
+ const fence = /^[ \t]*(?:```|~~~)/gm;
97
+ let cursor = 0;
98
+ let open: number | null = null;
99
+
100
+ for (let match = fence.exec(source); match !== null; match = fence.exec(source)) {
101
+ const lineEnd = source.indexOf("\n", match.index);
102
+ const after = lineEnd === -1 ? source.length : lineEnd + 1;
103
+
104
+ if (open === null) {
105
+ segments.push({ fenced: false, text: source.slice(cursor, match.index) });
106
+ open = match.index;
107
+ cursor = match.index;
108
+ } else {
109
+ segments.push({ fenced: true, text: source.slice(open, after) });
110
+ open = null;
111
+ cursor = after;
112
+ }
113
+ }
114
+
115
+ // A fence left open owns everything to the end.
116
+ segments.push({ fenced: open !== null, text: source.slice(cursor) });
117
+ return segments;
118
+ }
@@ -0,0 +1,9 @@
1
+ // Pure decisions about a markdown string BEFORE it reaches a parser.
2
+ //
3
+ // Here rather than in content/ because none of it renders: these are string →
4
+ // string functions with no React and no dependency on the parser, so they run
5
+ // in microseconds and can be reasoned about without one.
6
+ //
7
+ // May import: utils.
8
+
9
+ export { maskIncompleteMath } from "./incomplete-math";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What a persisted draft is worth keeping, as a pure decision.
3
+ *
4
+ * The effects that read and write live in `composer/use-draft-persistence.ts`;
5
+ * the RULES live here so they can be tested without a DOM. This workspace has
6
+ * no jsdom, so a rule that stays inside a hook is a rule nothing can assert.
7
+ */
8
+
9
+ /** A draft larger than this is a paste, not typing, and belongs in a chip. */
10
+ export const DRAFT_MAX_LENGTH = 100_000;
11
+
12
+ const PREFIX = "djc.chat.draft:";
13
+
14
+ /**
15
+ * The storage key for a room. Spelled like `store/use-speech-language.ts`, so
16
+ * chat's keys share one convention.
17
+ *
18
+ * Unversioned: the value is a plain string, so the only migration a version
19
+ * could gate is "it became structured" — and that wants a new prefix, leaving
20
+ * the old entries to expire with their session.
21
+ */
22
+ export function draftStorageKey(room: string): string {
23
+ return PREFIX + room;
24
+ }
25
+
26
+ /**
27
+ * What to do with a value read back from storage.
28
+ *
29
+ * `null` for both "nothing stored" and "stored empty": an empty string would
30
+ * otherwise CLEAR a field the host had already seeded, which is the one
31
+ * outcome a restore must never produce. Over-long values are truncated rather
32
+ * than dropped — losing the tail of a long draft beats losing all of it.
33
+ */
34
+ export function restoredDraft(stored: string | null): string | null {
35
+ if (stored === null || stored === "") return null;
36
+ return stored.length > DRAFT_MAX_LENGTH ? stored.slice(0, DRAFT_MAX_LENGTH) : stored;
37
+ }
38
+
39
+ /**
40
+ * Whether writing this text means storing it or removing the entry.
41
+ *
42
+ * Empty means REMOVE, which is what makes a send clear the draft with no
43
+ * send-specific branch: `send` resets the draft, the save path sees `""`, and
44
+ * the entry goes. Keeping an empty string instead would leave every sent room
45
+ * holding a useless entry forever.
46
+ */
47
+ export function draftWrite(text: string): { readonly remove: true } | { readonly value: string } {
48
+ if (text === "") return { remove: true };
49
+ return { value: text.slice(0, DRAFT_MAX_LENGTH) };
50
+ }
51
+
52
+ /**
53
+ * Whether the save path may write yet.
54
+ *
55
+ * Before a room's restore has run, the draft still holds the PREVIOUS room's
56
+ * text (or the empty initial value). Writing then puts one room's draft under
57
+ * another's key, or erases a stored draft with the emptiness that precedes its
58
+ * own restore — the second is silent and destroys real work.
59
+ */
60
+ export function mayPersist(restoredRoom: string | null, room: string): boolean {
61
+ return restoredRoom === room;
62
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What happens inside a chat, as data.
3
+ *
4
+ * The alternative this replaces is a prop threaded from the component that
5
+ * KNOWS a thing happened to the component that must react — through every layer
6
+ * between them, none of which cares. `ComposerQueue` needed the composer's
7
+ * field; the ask card needs the composer's focus; a host wants to know a prompt
8
+ * was taken back. Each on its own is one more prop on `<Chat>`, and the
9
+ * predecessor reached 83 of them that way.
10
+ *
11
+ * Modelled on `a typed transport`'s `IEventBus` — one typed map, no untyped
12
+ * escape hatch — but declared here rather than imported: the chat package must
13
+ * not depend on the transport to talk to itself.
14
+ */
15
+
16
+ import type { Draft } from "./outbound";
17
+
18
+ /**
19
+ * Every event, with its payload. `void` means the event carries nothing.
20
+ *
21
+ * Names are `subject:verb`, past tense: an event states what HAPPENED. A name
22
+ * in the imperative ("focus:composer") is a command wearing an event's clothes,
23
+ * and a bus that carries commands becomes a second, untyped API for the thing
24
+ * it is commanding.
25
+ */
26
+ export interface ChatEventMap {
27
+ /** A queued prompt was taken back for editing; it has left the queue. */
28
+ "queue:taken": { readonly id: string; readonly draft: Draft };
29
+ /** A prompt was promoted past the running turn, which is now aborted. */
30
+ "queue:promoted": { readonly id: string };
31
+ /** A turn was sent. Carries what went out, not what comes back. */
32
+ "turn:sent": { readonly draft: Draft };
33
+ }
34
+
35
+ export type ChatEventName = keyof ChatEventMap;
36
+
37
+ export type ChatEventHandler<E extends ChatEventName> = (payload: ChatEventMap[E]) => void;
38
+
39
+ export type Unsubscribe = () => void;
40
+
41
+ /** Names whose payload is `void`: emitted with no second argument. */
42
+ export type VoidChatEvent = {
43
+ [E in ChatEventName]: ChatEventMap[E] extends void ? E : never;
44
+ }[ChatEventName];
45
+
46
+ export type DataChatEvent = Exclude<ChatEventName, VoidChatEvent>;
47
+
48
+ export interface ChatEvents {
49
+ on<E extends DataChatEvent>(event: E, handler: ChatEventHandler<E>): Unsubscribe;
50
+ on<E extends VoidChatEvent>(event: E, handler: () => void): Unsubscribe;
51
+ emit<E extends DataChatEvent>(event: E, payload: ChatEventMap[E]): void;
52
+ emit<E extends VoidChatEvent>(event: E): void;
53
+ }
54
+
55
+ /**
56
+ * An in-memory bus, one per chat.
57
+ *
58
+ * Per chat and not module-scoped: two conversations open side by side must not
59
+ * hear each other's events, which a singleton cannot express. Created beside
60
+ * the store, handed down the same provider.
61
+ *
62
+ * Handlers are copied before dispatch, which fixes the listener list at the
63
+ * moment of the emit. A `Set` tolerates deleting the CURRENT entry mid-loop, so
64
+ * a one-shot unsubscribing itself is safe either way — the case that is not is
65
+ * one handler unsubscribing ANOTHER. Without the copy that second handler is
66
+ * silently skipped for this emit, which reads exactly like an event that never
67
+ * fired. Everyone listening when the event happened hears it.
68
+ */
69
+ export function createChatEvents(): ChatEvents {
70
+ const handlers = new Map<ChatEventName, Set<(payload: never) => void>>();
71
+
72
+ function on(event: ChatEventName, handler: (payload: never) => void): Unsubscribe {
73
+ const set = handlers.get(event) ?? new Set();
74
+ handlers.set(event, set);
75
+ set.add(handler);
76
+ return () => {
77
+ set.delete(handler);
78
+ };
79
+ }
80
+
81
+ function emit(event: ChatEventName, payload?: unknown): void {
82
+ const set = handlers.get(event);
83
+ if (set === undefined) return;
84
+ for (const handler of [...set]) (handler as (p: unknown) => void)(payload);
85
+ }
86
+
87
+ return { on, emit } as ChatEvents;
88
+ }
@@ -0,0 +1,10 @@
1
+ // Which row is the transcript's single tab stop.
2
+ //
3
+ // The DOM part is three lines and lives in primitives/; the part that can be
4
+ // wrong — which index a key selects, and what happens when the list shrinks
5
+ // under a stale one — lives here where it is testable without a renderer.
6
+ //
7
+ // May import: utils.
8
+
9
+ export { CHAT_ITEM_ATTR, clampIndex, isRovingKey, nextIndex } from "./roving";
10
+ export type { RovingKey } from "./roving";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The transcript is ONE tab stop; arrows move within it.
3
+ *
4
+ * Without this, Tab walks every message, every tool card and every link before
5
+ * reaching the composer — so on a transcript of any length a keyboard user
6
+ * cannot reach the input at all. The APG calls it roving tabindex.
7
+ *
8
+ * The DOM part is three lines and lives in `primitives/`. What lives here is
9
+ * the part that can be wrong: which index a key selects.
10
+ */
11
+
12
+ export const CHAT_ITEM_ATTR = "data-chat-item";
13
+
14
+ /** Keys that move within the transcript, mapped to a target index. */
15
+ export type RovingKey = "ArrowDown" | "ArrowUp" | "Home" | "End";
16
+
17
+ export function isRovingKey(key: string): key is RovingKey {
18
+ return key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End";
19
+ }
20
+
21
+ /**
22
+ * Where a key moves the active row, or `null` when it moves nowhere.
23
+ *
24
+ * `null` — rather than "the same index" — is what lets the caller decide
25
+ * whether to call `preventDefault()`. Swallowing ArrowDown at the last row
26
+ * would stop the PAGE from scrolling, which is the browser behaviour a user at
27
+ * the end of a transcript is relying on.
28
+ */
29
+ export function nextIndex(key: RovingKey, active: number, count: number): number | null {
30
+ if (count === 0) return null;
31
+
32
+ const last = count - 1;
33
+ // Clamped, never wrapped. Wrapping from the newest message to the first is
34
+ // disorienting in a transcript, where position carries meaning — MUI X Chat
35
+ // clamps here too.
36
+ const target =
37
+ key === "Home"
38
+ ? 0
39
+ : key === "End"
40
+ ? last
41
+ : Math.min(last, Math.max(0, active + (key === "ArrowDown" ? 1 : -1)));
42
+
43
+ return target === active ? null : target;
44
+ }
45
+
46
+ /**
47
+ * Keep the active index valid as the transcript grows and shrinks.
48
+ *
49
+ * Messages arrive while the user reads. An index that outlives its row leaves
50
+ * EVERY row with `tabIndex={-1}` — the transcript silently stops being
51
+ * reachable by Tab at all, and nothing about it looks broken.
52
+ */
53
+ export function clampIndex(active: number, count: number): number {
54
+ if (count === 0) return 0;
55
+ return Math.min(count - 1, Math.max(0, active));
56
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Collapse runs of same-kind parts into groups.
3
+ *
4
+ * A turn that thinks, calls four tools, thinks again and answers renders as six
5
+ * separate cards without this — the answer ends up below a wall of tool calls.
6
+ * Grouping puts the run behind one "Thinking" or "4 tools" affordance, which is
7
+ * what assistant-ui does and what makes a tool-heavy transcript readable.
8
+ *
9
+ * Only ADJACENT parts coalesce. Reordering to gather all tool calls together
10
+ * would misrepresent the turn: the sequence is the agent's reasoning, and
11
+ * "searched, then read what it found" is different information from "searched
12
+ * and read".
13
+ */
14
+
15
+ import type { MessagePart } from "./parts";
16
+
17
+ export type PartGroup =
18
+ /** A single part, rendered on its own. */
19
+ | { readonly kind: "single"; readonly key: string; readonly part: MessagePart }
20
+ /** Two or more adjacent parts of one type. */
21
+ | {
22
+ readonly kind: "run";
23
+ readonly key: string;
24
+ readonly type: MessagePart["type"];
25
+ readonly parts: readonly MessagePart[];
26
+ };
27
+
28
+ /** Types worth collapsing. Text is never grouped — prose is the answer itself. */
29
+ const GROUPABLE = new Set<MessagePart["type"]>(["reasoning", "tool"]);
30
+
31
+ export function groupParts(parts: readonly MessagePart[]): readonly PartGroup[] {
32
+ const groups: PartGroup[] = [];
33
+ let run: MessagePart[] = [];
34
+
35
+ const flush = (): void => {
36
+ const first = run[0];
37
+ if (first === undefined) return;
38
+ groups.push(
39
+ run.length === 1
40
+ ? { kind: "single", key: first.id, part: first }
41
+ // Keyed on the FIRST member: a run that grows while streaming keeps its
42
+ // key, so the group does not remount on every new tool call.
43
+ : { kind: "run", key: first.id, type: first.type, parts: run },
44
+ );
45
+ run = [];
46
+ };
47
+
48
+ for (const part of parts) {
49
+ const previous = run[0];
50
+ const continues =
51
+ previous !== undefined && previous.type === part.type && GROUPABLE.has(part.type);
52
+ if (!continues) flush();
53
+ if (GROUPABLE.has(part.type)) {
54
+ run.push(part);
55
+ continue;
56
+ }
57
+ groups.push({ kind: "single", key: part.id, part });
58
+ }
59
+ flush();
60
+
61
+ return groups;
62
+ }
63
+
64
+ /**
65
+ * Should a group be open?
66
+ *
67
+ * Open while the run is still producing, collapsed once it settles — the user
68
+ * wants to watch a tool work and then get it out of the way. Derived rather
69
+ * than stored so it cannot disagree with the parts; a host that wants manual
70
+ * control layers its own state over this default.
71
+ */
72
+ export function isGroupActive(group: PartGroup): boolean {
73
+ const parts = group.kind === "run" ? group.parts : [group.part];
74
+ return parts.some(
75
+ (part) =>
76
+ part.type === "tool" &&
77
+ (part.toolState.state === "input-streaming" ||
78
+ part.toolState.state === "input-available"),
79
+ );
80
+ }
@@ -0,0 +1,34 @@
1
+ export * from "./parts";
2
+ export * from "./group-parts";
3
+ export * from "./adapter";
4
+ export * from "./capabilities";
5
+ export * from "./outbound";
6
+ export * from "./tool-picks";
7
+ // What a pick still wants. A rule about the DRAFT, so it ships with the model
8
+ // rather than being re-derived at the surface that renders the hint.
9
+ export * from "./tool-prompt";
10
+ export { addToolPick, addToolPicks, removeToolPick, toggleToolPick } from "./tool-picks-set";
11
+ export * from "./chunks";
12
+ // Every adapter that mints a tool part owes the reader the same name, so the
13
+ // rule ships with the model rather than being re-derived at each entry point.
14
+ export * from "./tool-name";
15
+ export * from "./transcript";
16
+ export * from "./media";
17
+ export * from "./tools";
18
+ export * from "./recall";
19
+ export * from "./voice";
20
+ export * from "./queue";
21
+ export * from "./focus";
22
+ export * from "./scroll";
23
+ export * from "./content";
24
+ export * from "./draft-storage";
25
+ export * from "./events";
26
+ export * from "./reload-decision";
27
+ // The host implements `AskReplace` and so must be able to name its return.
28
+ // `composer/index.ts` states this barrel already carries it and declines to
29
+ // re-export for that reason — the two must agree, or the type is reachable from
30
+ // nowhere and every host writes its own union.
31
+ export * from "./text-replace";
32
+ // The mention URL scheme. Exported because `configureMentionScheme` is a host
33
+ // call — the scheme is the host's product name, and nothing else can supply it.
34
+ export * from "./links";
@@ -0,0 +1,33 @@
1
+ // Links, both directions: the ones this package WRITES into a message, and the
2
+ // ones it READS back out of one.
3
+ //
4
+ // Together because they are one round trip. A picked tool is written as a
5
+ // markdown link and parsed back on render, and `scheme.ts` owns both halves so
6
+ // the shape can never disagree with itself. `prose.ts` handles the other kind —
7
+ // ordinary web links the model or the user wrote — which are only ever read.
8
+ //
9
+ // Pure functions of a string, no React and no DOM: the chip decision and the
10
+ // label are testable without rendering anything, which is what keeps them
11
+ // honest while the renderer changes.
12
+
13
+ export {
14
+ configureMentionScheme,
15
+ mentionScheme,
16
+ toolURL,
17
+ parse,
18
+ type ParsedMention,
19
+ type ParseResult,
20
+ } from "./scheme";
21
+
22
+ // Reading half only. A picked tool needs no preprocess step — the composer
23
+ // writes the link itself, so the id is never recovered from prose.
24
+ export { extractToolId } from "./tool-id";
25
+
26
+ export {
27
+ isBareUrlLink,
28
+ isExternalHref,
29
+ linkChipLabel,
30
+ normalizeHref,
31
+ splitUrl,
32
+ type SplitUrl,
33
+ } from "./prose";
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Which links in a message body read as a bare reference, and what a compact
3
+ * label for one looks like.
4
+ *
5
+ * A bare url and `[the docs](…)` are indistinguishable in the tree: both are an
6
+ * ordinary mdast `link`, and their hast properties are byte-identical. There is
7
+ * no autolink marker to read. The signal is the CHILD — an autolink's sole text
8
+ * child equals its url.
9
+ *
10
+ * Label shape adapted from the retired `@djangocfg/ui-tools`
11
+ * (`src/tools/forms/MarkdownEditor/url/detect.ts`) (`splitUrl`, `normalizeHref`,
12
+ * the Chrome-omnibox form). `truncateUrlLabel` is reworked here; `faviconUrl`
13
+ * does not come across — it would make a third-party request per link.
14
+ */
15
+
16
+ const ELLIPSIS = "…";
17
+
18
+ /** Default budget for a whole label. Wide enough for `domain/first/…/last`. */
19
+ const DEFAULT_MAX_CHARS = 40;
20
+
21
+ /** A url split into the parts a label is built from. */
22
+ export interface SplitUrl {
23
+ /** Host without a leading `www.`. Empty when unparseable. */
24
+ readonly domain: string;
25
+ /** Path segments, empty ones dropped. */
26
+ readonly segments: readonly string[];
27
+ /** `#fragment` including the hash, or empty. */
28
+ readonly hash: string;
29
+ /** `mailto:` address without the scheme, or empty. */
30
+ readonly mail: string;
31
+ }
32
+
33
+ /** Prepend a scheme to a schemeless `www.` host so `URL` can parse it. */
34
+ export function normalizeHref(raw: string): string {
35
+ if (/^www\./i.test(raw)) return `https://${raw}`;
36
+ return raw;
37
+ }
38
+
39
+ /**
40
+ * Split an href into the label's parts.
41
+ *
42
+ * The hash is its OWN field, not fused into the path: that is what lets a label
43
+ * shed a fragment before it sheds a path segment.
44
+ */
45
+ export function splitUrl(href: string): SplitUrl {
46
+ if (/^mailto:/i.test(href)) {
47
+ const mail = href.slice("mailto:".length);
48
+ const at = mail.lastIndexOf("@");
49
+ return {
50
+ domain: at >= 0 ? mail.slice(at + 1) : "",
51
+ segments: [],
52
+ hash: "",
53
+ mail,
54
+ };
55
+ }
56
+ try {
57
+ const u = new URL(normalizeHref(href));
58
+ return {
59
+ domain: u.hostname.replace(/^www\./i, ""),
60
+ segments: `${u.pathname}${u.search}`.split("/").filter((s) => s.length > 0),
61
+ hash: u.hash,
62
+ mail: "",
63
+ };
64
+ } catch {
65
+ // A label is presentation and must not throw on a token `URL` refuses.
66
+ const noScheme = normalizeHref(href).replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
67
+ const hashAt = noScheme.indexOf("#");
68
+ const body = hashAt >= 0 ? noScheme.slice(0, hashAt) : noScheme;
69
+ const slash = body.indexOf("/");
70
+ return {
71
+ domain: (slash >= 0 ? body.slice(0, slash) : body).replace(/^www\./i, ""),
72
+ segments: slash >= 0 ? body.slice(slash).split("/").filter((s) => s.length > 0) : [],
73
+ hash: hashAt >= 0 ? noScheme.slice(hashAt) : "",
74
+ mail: "",
75
+ };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Join a domain with the segments a candidate keeps.
81
+ *
82
+ * `…` claims something was dropped, so it is emitted only when `total` exceeds
83
+ * what was kept. An unconditional mark reads as correct while standing for
84
+ * nothing.
85
+ */
86
+ function join(domain: string, kept: readonly string[], total: number, hash: string): string {
87
+ // The mark sits where the gap is: between two survivors, or leading when only
88
+ // the last survives. Always after `kept[0]` would read as `last/…`.
89
+ const marked =
90
+ total <= kept.length
91
+ ? [...kept]
92
+ : kept.length <= 1
93
+ ? [ELLIPSIS, ...kept]
94
+ : [kept[0] as string, ELLIPSIS, ...kept.slice(1)];
95
+ const body = marked.join("/");
96
+ return `${domain}${body === "" ? "" : `/${body}`}${hash}`;
97
+ }
98
+
99
+ /**
100
+ * A compact, Chrome-omnibox-style label for a url: the domain in full, the
101
+ * path's middle collapsed, the last segment kept.
102
+ *
103
+ * A fragment is shed before a path segment: the last segment names the PAGE, a
104
+ * fragment only locates a spot inside it. Fusing them into one string puts a
105
+ * long fragment on the last segment and forces every candidate over budget.
106
+ *
107
+ * The last resort truncates the last segment and KEEPS it. Never a bare
108
+ * `domain/…`, which identifies nothing when every link shares the domain.
109
+ */
110
+ export function linkChipLabel(href: string, maxChars: number = DEFAULT_MAX_CHARS): string {
111
+ const { domain, segments, hash, mail } = splitUrl(href);
112
+
113
+ if (mail !== "") {
114
+ if (mail.length <= maxChars) return mail;
115
+ return `${ELLIPSIS}${mail.slice(mail.length - (maxChars - 1))}`;
116
+ }
117
+
118
+ const total = segments.length;
119
+ if (total === 0) {
120
+ const bare = `${domain}${hash}`;
121
+ return bare.length <= maxChars ? bare : domain;
122
+ }
123
+
124
+ const last = segments[total - 1] as string;
125
+ const first = segments[0] as string;
126
+ // Widest first, and at each width the fragment goes before a path segment
127
+ // does: `domain/cli/daemon/service` beats `domain/…/service#install-options`,
128
+ // because the segments name the page and the fragment only locates a spot
129
+ // inside it.
130
+ const kept: readonly (readonly string[])[] = [
131
+ segments,
132
+ total >= 3 ? [first, last] : segments,
133
+ [last],
134
+ ];
135
+ const candidates: readonly string[] = kept.flatMap((k) => [
136
+ join(domain, k, total, hash),
137
+ join(domain, k, total, ""),
138
+ ]);
139
+ for (const candidate of candidates) {
140
+ if (candidate.length <= maxChars) return candidate;
141
+ }
142
+
143
+ // Nothing fit, so the last segment is itself too long. Keep the page's own
144
+ // name, cut to what the domain leaves, so two links into the same domain
145
+ // still read differently. The leading mark is still conditional: a
146
+ // single-segment path dropped nothing, and claiming otherwise is the same
147
+ // lie `join` exists to prevent.
148
+ const lead = total > 1 ? `${ELLIPSIS}/` : "";
149
+ const room = Math.max(4, maxChars - domain.length - lead.length - 2);
150
+ return `${domain}/${lead}${last.slice(0, room)}${ELLIPSIS}`;
151
+ }
152
+
153
+ /**
154
+ * Whether an anchor is a BARE url — the reader faces the raw string, with no
155
+ * label the author chose.
156
+ *
157
+ * `text` is the anchor's sole text child, or undefined when the child is not a
158
+ * lone text node. A formatted label (`**…**`, inline code, several children)
159
+ * is never bare: the author who formatted a label meant it to read inline.
160
+ *
161
+ * The comparison runs on a normalised form so a schemeless `www.` host still
162
+ * matches: gfm rewrites the URL and leaves the text alone, so raw equality
163
+ * would fail on exactly the shape a model writes when it elides the scheme.
164
+ * One comparison rather than a second rule — one place to be wrong.
165
+ *
166
+ * A label that IS the url counts, though it is technically a markdown link.
167
+ * The rule is about what the READER faces, and that is the same either way.
168
+ */
169
+ export function isBareUrlLink(href: string | undefined, text: string | undefined): boolean {
170
+ if (href === undefined || text === undefined) return false;
171
+ const label = text.trim();
172
+ if (label === "") return false;
173
+ return stripScheme(href) === stripScheme(label);
174
+ }
175
+
176
+ /** Drop a leading `http://` or `https://` and any trailing slash. */
177
+ function stripScheme(value: string): string {
178
+ return value.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
179
+ }
180
+
181
+ /**
182
+ * Whether an href is a web target a new tab makes sense for.
183
+ *
184
+ * An in-app scheme opened with `target="_blank"` spawns a blank tab
185
+ * the browser cannot navigate. This is about the navigation target, not about
186
+ * safety — `rehype-harden` already decides which protocols survive at all.
187
+ */
188
+ export function isExternalHref(href: string | undefined): boolean {
189
+ if (href === undefined) return false;
190
+ return /^(https?|mailto|tel):/i.test(href);
191
+ }