@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,171 @@
1
+ /**
2
+ * The prompt ladder: what ArrowUp and ArrowDown land on, as pure state.
3
+ *
4
+ * `boundary.ts` answers "may this press navigate"; this answers "to what". Split
5
+ * because they fail differently — a boundary bug moves the caret when it should
6
+ * not, a ladder bug loses the user's draft — and because the boundary rules were
7
+ * ported verbatim while this is where the queue rung is new.
8
+ *
9
+ * ## The ladder, from the bottom
10
+ *
11
+ * draft ← what the user was typing, stashed on the first ArrowUp
12
+ * queued (last) ← the newest prompt waiting to be sent, if any
13
+ * sent[n-1] ← the most recent sent prompt
14
+ * …
15
+ * sent[0] ← the oldest kept
16
+ *
17
+ * ArrowUp climbs, ArrowDown descends, and the bottom is always the draft the
18
+ * user had before they started walking. Losing that draft is the failure this
19
+ * design exists to prevent: a user presses Up once out of curiosity and their
20
+ * half-written message must come back on Down.
21
+ *
22
+ * ## Why the queue is a rung, and only its LAST entry
23
+ *
24
+ * A queued prompt was written but not answered, so it is the nearest thing in
25
+ * time to the draft — closer than anything already sent. Only the last one is a
26
+ * rung: it is the one a user reaching backwards means, and it is the one they
27
+ * would edit. Walking the whole queue would put entries the user can already
28
+ * see, and remove, into a ladder they cannot see.
29
+ *
30
+ * ## Why `index` is not enough on its own
31
+ *
32
+ * The pending rung cannot be an index into `sent` — it sits between the draft
33
+ * and `sent[n-1]` and belongs to neither array. Encoding it as `index === -1`
34
+ * would make it indistinguishable from "not walking at all", and a second
35
+ * ArrowDown would then jump to the draft, skipping every entry just walked.
36
+ * Hence an explicit position type.
37
+ */
38
+
39
+ /** Where the ladder currently is. `draft` is the bottom and the resting state. */
40
+ export type RecallPosition =
41
+ | { readonly kind: "draft" }
42
+ | { readonly kind: "queued" }
43
+ | { readonly kind: "sent"; readonly index: number };
44
+
45
+ export interface RecallState {
46
+ readonly position: RecallPosition;
47
+ /**
48
+ * The user's own text, held while they walk.
49
+ *
50
+ * `null` means nothing is stashed — i.e. the ladder is at rest. Stashed ONCE,
51
+ * on the first step off the draft, never on subsequent steps: re-stashing
52
+ * would overwrite the user's text with a recalled entry and the draft would be
53
+ * gone with no way back.
54
+ */
55
+ readonly stashedDraft: string | null;
56
+ /**
57
+ * What recall last wrote into the buffer.
58
+ *
59
+ * `bufferIsRecallOwned` reads this to tell "the user typed this" from "recall
60
+ * put this here". Cleared when the ladder returns to rest, so a stale value
61
+ * cannot make a fresh draft look recall-owned.
62
+ */
63
+ readonly lastRecalled: string | null;
64
+ }
65
+
66
+ export interface RecallLadder {
67
+ /** Sent prompts, OLDEST first — the order ArrowUp walks backwards through. */
68
+ readonly sent: readonly string[];
69
+ /** Prompts queued behind an in-flight turn, oldest first. Only the last is a rung. */
70
+ readonly queued: readonly string[];
71
+ }
72
+
73
+ /** A move's outcome: the new state, and the text to put in the buffer. */
74
+ export interface RecallMove {
75
+ readonly state: RecallState;
76
+ /** `null` ⇒ nothing moved; the caller must NOT write to the buffer. */
77
+ readonly text: string | null;
78
+ }
79
+
80
+ export const REST: RecallState = {
81
+ position: { kind: "draft" },
82
+ stashedDraft: null,
83
+ lastRecalled: null,
84
+ };
85
+
86
+ /** The queue rung exists only when the newest queued prompt has content. */
87
+ function queuedRung(ladder: RecallLadder): string | null {
88
+ const last = ladder.queued[ladder.queued.length - 1];
89
+ return last !== undefined && last !== "" ? last : null;
90
+ }
91
+
92
+ /**
93
+ * Step UP one rung.
94
+ *
95
+ * `draft` is the current buffer, stashed on the first step so ArrowDown can
96
+ * return it. Returns `text: null` when there is nowhere to go, which is how the
97
+ * caller knows to leave the key to the textarea rather than swallowing it.
98
+ */
99
+ export function recallPrevious(
100
+ state: RecallState,
101
+ ladder: RecallLadder,
102
+ draft: string,
103
+ ): RecallMove {
104
+ // Stash before anything else, and only at rest: `stashedDraft` is the user's
105
+ // text, and overwriting it mid-walk with a recalled entry loses it silently.
106
+ const stashedDraft = state.position.kind === "draft" ? draft : state.stashedDraft;
107
+
108
+ const step = (position: RecallPosition, text: string): RecallMove => ({
109
+ state: { position, stashedDraft, lastRecalled: text },
110
+ text,
111
+ });
112
+
113
+ if (state.position.kind === "draft") {
114
+ const pending = queuedRung(ladder);
115
+ if (pending !== null) return step({ kind: "queued" }, pending);
116
+ const newest = ladder.sent.length - 1;
117
+ if (newest < 0) return { state, text: null };
118
+ return step({ kind: "sent", index: newest }, ladder.sent[newest] ?? "");
119
+ }
120
+
121
+ if (state.position.kind === "queued") {
122
+ const newest = ladder.sent.length - 1;
123
+ if (newest < 0) return { state, text: null };
124
+ return step({ kind: "sent", index: newest }, ladder.sent[newest] ?? "");
125
+ }
126
+
127
+ const next = state.position.index - 1;
128
+ // At the oldest entry: stay. Wrapping to the newest would make a long ladder
129
+ // an infinite loop with no way to tell you had reached the end.
130
+ if (next < 0) return { state, text: null };
131
+ return step({ kind: "sent", index: next }, ladder.sent[next] ?? "");
132
+ }
133
+
134
+ /**
135
+ * Step DOWN one rung, ending at the stashed draft.
136
+ *
137
+ * At rest this does nothing and returns `text: null` — ArrowDown in an untouched
138
+ * composer belongs to the textarea.
139
+ */
140
+ export function recallNext(state: RecallState, ladder: RecallLadder): RecallMove {
141
+ if (state.position.kind === "draft") return { state, text: null };
142
+
143
+ const land = (): RecallMove => {
144
+ const text = state.stashedDraft ?? "";
145
+ // Back at rest: `lastRecalled` is cleared with the same write, so the
146
+ // restored draft is the USER's again and a further arrow will not navigate
147
+ // unless the buffer is empty.
148
+ return { state: REST, text };
149
+ };
150
+
151
+ if (state.position.kind === "queued") return land();
152
+
153
+ const next = state.position.index + 1;
154
+ if (next < ladder.sent.length) {
155
+ const text = ladder.sent[next] ?? "";
156
+ return {
157
+ state: { ...state, position: { kind: "sent", index: next }, lastRecalled: text },
158
+ text,
159
+ };
160
+ }
161
+
162
+ // Past the newest sent entry: the queued rung, then the draft.
163
+ const pending = queuedRung(ladder);
164
+ if (pending !== null) {
165
+ return {
166
+ state: { ...state, position: { kind: "queued" }, lastRecalled: pending },
167
+ text: pending,
168
+ };
169
+ }
170
+ return land();
171
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What to do when a room's revision moves.
3
+ *
4
+ * Split from the hook so the rule is testable without a renderer: every way
5
+ * live-append can misbehave — reloading over a live stream, dropping a message
6
+ * that arrived mid-turn, re-fetching the rows just read at mount — is decided
7
+ * here.
8
+ */
9
+ export type ReloadDecision = "reload" | "defer" | "ignore";
10
+
11
+ export interface ReloadInputs {
12
+ /** The room's current revision. */
13
+ readonly revision: number;
14
+ /** The last revision this reader acted on (or mounted with). */
15
+ readonly seen: number;
16
+ /** True while a turn is streaming into the transcript. */
17
+ readonly streaming: boolean;
18
+ /** True when an earlier change was deferred and has not yet been served. */
19
+ readonly deferred: boolean;
20
+ }
21
+
22
+ export function reloadDecision(input: ReloadInputs): ReloadDecision {
23
+ const changed = input.revision !== input.seen;
24
+
25
+ // Mid-stream, a change waits. Seeding replaces the transcript wholesale and
26
+ // clears the streaming id, so reloading now would delete the reply as it is
27
+ // being written.
28
+ if (input.streaming) return changed || input.deferred ? "defer" : "ignore";
29
+
30
+ // The stream ended with a change owed — serve it now. Without this branch a
31
+ // message that landed mid-turn stays invisible until the room is reopened,
32
+ // which is the original defect wearing a narrower window.
33
+ if (input.deferred) return "reload";
34
+
35
+ return changed ? "reload" : "ignore";
36
+ }
@@ -0,0 +1,17 @@
1
+ // How many messages arrived while the reader was away.
2
+ //
3
+ // In core/ because none of it is DOM. HOW to follow the bottom is not here and
4
+ // must not come back: it needs the engine's own scroll settle to blend against,
5
+ // which a pure model cannot see, and a hand-written version shook on every
6
+ // fling. `use-stick-to-bottom` owns the motion, wired in primitives/.
7
+ //
8
+ // WHETHER an arrival was the reader's own send is a different question, and one
9
+ // the library cannot answer at all — `isOwnSend` decides it from the transcript
10
+ // alone, which is why it is testable here and the animation is not.
11
+ //
12
+ // May import: utils.
13
+
14
+ export { MissedCount, missedRows } from "./missed";
15
+ export type { MissedRole } from "./missed";
16
+ export { isOwnSend } from "./sent";
17
+ export type { SentRole } from "./sent";
@@ -0,0 +1,106 @@
1
+ /**
2
+ * How many replies arrived while the reader was scrolled away.
3
+ *
4
+ * A plain class for the same reason as `BottomPin`: nothing here is DOM. What
5
+ * is hard is deciding when the count starts, when it resets, and what counts —
6
+ * and all three are wrong in ways a rendered badge cannot show you.
7
+ *
8
+ * No store: the transcript already knows the message list and the pin state, so
9
+ * a second owner for "how far behind is the reader" could disagree with the
10
+ * scroll position it is describing.
11
+ */
12
+
13
+ /**
14
+ * The reader's own messages never count.
15
+ *
16
+ * Sending scrolls them to the bottom anyway, so a `user` row can only enter the
17
+ * count in a race — and a badge that says "1 new" pointing at your own sentence
18
+ * is worse than no badge. Mirrors the server's unread rule, which excludes
19
+ * `author_kind = 'human'` for exactly this reason.
20
+ */
21
+ export type MissedRole = "user" | "assistant" | "system";
22
+
23
+ export class MissedCount {
24
+ /**
25
+ * The last row present when the reader left the bottom. Null while pinned.
26
+ *
27
+ * An id, not a length and not a set of every id seen. A length breaks when
28
+ * history loads ABOVE — the list grows without anything arriving. A set
29
+ * breaks the same way for the opposite reason: prepended rows are absent from
30
+ * it and read as new. New messages append, so the last row is the frontier,
31
+ * and everything after it is what the reader missed.
32
+ */
33
+ private frontier: string | null = null;
34
+ /** Distinguishes "pinned, no frontier" from "unpinned on an empty list". */
35
+ private armed = false;
36
+ private count = 0;
37
+
38
+ /**
39
+ * Feed the current state after every render.
40
+ *
41
+ * @param pinned whether the viewport is following the bottom
42
+ * @param rows every message currently in the transcript, in order
43
+ */
44
+ observe(pinned: boolean, rows: readonly { id: string; role: MissedRole }[]): void {
45
+ if (pinned) {
46
+ this.frontier = null;
47
+ this.armed = false;
48
+ this.count = 0;
49
+ return;
50
+ }
51
+ // The FIRST unpinned observation sets the frontier. An empty transcript
52
+ // leaves it null, which correctly means every later row is new.
53
+ if (!this.armed) {
54
+ this.frontier = rows.at(-1)?.id ?? null;
55
+ this.armed = true;
56
+ }
57
+ const at = this.frontier === null ? -1 : rows.findIndex((row) => row.id === this.frontier);
58
+ // A frontier that vanished (a cleared transcript, a room switch) must not
59
+ // count the whole list: -1 there would make every row new. Re-arm instead.
60
+ if (this.frontier !== null && at === -1) {
61
+ this.frontier = rows.at(-1)?.id ?? null;
62
+ this.count = 0;
63
+ return;
64
+ }
65
+ this.count = rows.slice(at + 1).filter((row) => row.role !== "user").length;
66
+ }
67
+
68
+ /**
69
+ * The badge's number, or null when there is nothing honest to show.
70
+ *
71
+ * Null rather than 0 so a caller cannot render an empty badge: "the button is
72
+ * showing but nothing arrived" and "1 arrived" are different states, and only
73
+ * the second deserves a number.
74
+ */
75
+ value(): number | null {
76
+ return this.count > 0 ? this.count : null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The transcript's id list paired with the roles the store holds for it.
82
+ *
83
+ * Separate from the React binding so the one decision it makes is testable
84
+ * without a DOM: what to do with an id the store has no message for. It DROPS
85
+ * that row. The alternative — defaulting a role — has to pick one, and every
86
+ * choice is wrong in a way the badge shows: `assistant` counts a phantom and
87
+ * sends the reader down to a row that is not there, `user` silently suppresses
88
+ * a real reply if the guess is ever wrong.
89
+ *
90
+ * `ids` and the message map are written together in every store path, so the
91
+ * case cannot arise today. It is written down because the two consumers of that
92
+ * invariant had drifted apart: `selectMessages` already drops the missing id,
93
+ * while this side used to substitute `assistant`.
94
+ *
95
+ * Structural parameter rather than the store's own types: `core` may import
96
+ * only `utils`.
97
+ */
98
+ export function missedRows(
99
+ ids: readonly string[],
100
+ byId: Readonly<Record<string, { readonly role: MissedRole } | undefined>>,
101
+ ): readonly { id: string; role: MissedRole }[] {
102
+ return ids.flatMap((id) => {
103
+ const message = byId[id];
104
+ return message === undefined ? [] : [{ id, role: message.role }];
105
+ });
106
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Did the reader just SEND, as opposed to receive?
3
+ *
4
+ * `use-stick-to-bottom` only follows growth while the view is already pinned, so
5
+ * a reader scrolled into history stayed there when their own message landed —
6
+ * the room looked as if nothing had been sent. Returning them is a different
7
+ * question from "follow a streaming reply", and nothing answered it.
8
+ *
9
+ * The signal is the transcript itself rather than an event, because every entry
10
+ * point ends there — Enter, the send button, a host calling `store.send`, a
11
+ * queued prompt draining. `turn:sent` fires only on the keyboard path, so a
12
+ * listener would have been right about a quarter of the ways to send.
13
+ *
14
+ * Pure, and here rather than in the hook, for the same reason as `MissedCount`:
15
+ * the three cases it must tell apart are not visible in a rendered scroll
16
+ * position, and getting any of them wrong is silent.
17
+ */
18
+
19
+ /** Only the reader's own turn asks to be shown the bottom. */
20
+ export type SentRole = "user" | "assistant" | "system";
21
+
22
+ /**
23
+ * @param previous the id list as last observed, or null before the first one
24
+ * @param ids the id list now
25
+ * @param roleOf the role the store holds for an id, or undefined if it has none
26
+ */
27
+ export function isOwnSend(
28
+ previous: readonly string[] | null,
29
+ ids: readonly string[],
30
+ roleOf: (id: string) => SentRole | undefined,
31
+ ): boolean {
32
+ // Nothing to compare against: the first observation is the room's existing
33
+ // history, not something that just happened.
34
+ if (previous === null) return false;
35
+
36
+ // A send APPENDS exactly one id and leaves the rest identical.
37
+ //
38
+ // Compared as a WHOLE list, not by its tail, because the store outlives a room
39
+ // switch: `load` replaces the transcript wholesale, and a room whose history
40
+ // happens to end on a user message would otherwise read as a send and animate
41
+ // a scroll every time it is opened. History paged in ABOVE fails this too,
42
+ // which is correct — the reader asked for those rows where they are.
43
+ if (ids.length !== previous.length + 1) return false;
44
+ for (let index = 0; index < previous.length; index += 1) {
45
+ if (ids[index] !== previous[index]) return false;
46
+ }
47
+
48
+ const last = ids[ids.length - 1];
49
+ if (last === undefined) return false;
50
+ // The assistant opening its reply also appends exactly one id. Following that
51
+ // would drag a reader out of the history they scrolled up to read, which is
52
+ // what the jump-to-latest control exists to offer instead of forcing.
53
+ return roleOf(last) === "user";
54
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Putting text into a composer that may already hold some.
3
+ *
4
+ * Three callers need this and each wrote its own: recall's Tab commit, a slash
5
+ * pick, and taking a queued prompt back for editing. They disagreed on the one
6
+ * question that matters — what happens to what the user already typed — so the
7
+ * rule lives here once.
8
+ */
9
+
10
+ /**
11
+ * Would this write destroy something? `false` ⇒ write, and do NOT prompt.
12
+ *
13
+ * An empty field has nothing to lose, and re-inserting text the field already
14
+ * holds is a no-op the user would read as a pointless question.
15
+ */
16
+ export function needsChoice(current: string, incoming: string): boolean {
17
+ const held = current.trim();
18
+ if (held === "") return false;
19
+ return held !== incoming.trim();
20
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The qualifier an MCP client prepends to every tool it borrows from a server.
3
+ *
4
+ * Tools are declared BARE on the server — it registers `ask_weather`, not
5
+ * `mcp__server__ask_weather`. The prefix is the client's
6
+ * namespacing, so stripping it restores the name we chose rather than inventing
7
+ * a nickname for a name we did not.
8
+ *
9
+ * Lazy on purpose: it must close at the FIRST separator, because `__` is legal
10
+ * inside a tool's own name and a greedy match would eat into it.
11
+ */
12
+ const QUALIFIED = /^mcp__.+?__/;
13
+
14
+ /**
15
+ * The tool name as a reader should see it.
16
+ *
17
+ * Exactly two segments come off — the marker and the server — because `__` is
18
+ * legal INSIDE a tool's own name. Taking the text after the last separator
19
+ * instead would silently shorten any such tool, and nothing would fail: the row
20
+ * still renders, just under half a name.
21
+ *
22
+ * A name that is not qualified passes through untouched, which is what keeps
23
+ * one engine's rows (`ToolSearch`) sitting level with another's in the same
24
+ * transcript.
25
+ */
26
+ export function readableToolName(toolName: string): string {
27
+ const bare = toolName.replace(QUALIFIED, "");
28
+ // A name that is ONLY its qualifier leaves nothing to render; the raw string
29
+ // is a worse label than the original, so the original stands.
30
+ return bare === "" ? toolName : bare;
31
+ }
@@ -0,0 +1,48 @@
1
+ // The picked-tool list as a SET, keyed by id.
2
+ //
3
+ // May import: ./tool-picks.
4
+
5
+ import type { ToolPick } from "./tool-picks";
6
+
7
+ /**
8
+ * Add a pick unless its id is already staged.
9
+ *
10
+ * Uniqueness belongs to the list, not to each caller: three paths write it —
11
+ * the menu, a paste carrying a marker, a restored draft — and a rule enforced
12
+ * at three call sites is one an unrelated fourth breaks. A duplicate is not
13
+ * cosmetic either: `appendToolMarkers` would write the marker twice and the
14
+ * model would read two requests.
15
+ *
16
+ * Returns the SAME array when nothing changes, so a no-op cannot re-render.
17
+ */
18
+ export function addToolPick(
19
+ picks: readonly ToolPick[],
20
+ pick: ToolPick,
21
+ ): readonly ToolPick[] {
22
+ return picks.some((staged) => staged.id === pick.id) ? picks : [...picks, pick];
23
+ }
24
+
25
+ /** Add several, in order, each subject to the same rule. */
26
+ export function addToolPicks(
27
+ picks: readonly ToolPick[],
28
+ incoming: readonly ToolPick[],
29
+ ): readonly ToolPick[] {
30
+ return incoming.reduce(addToolPick, picks);
31
+ }
32
+
33
+ export function removeToolPick(picks: readonly ToolPick[], id: string): readonly ToolPick[] {
34
+ return picks.filter((pick) => pick.id !== id);
35
+ }
36
+
37
+ /**
38
+ * The menu's verb: staged ⇒ remove, absent ⇒ add.
39
+ *
40
+ * A toggle rather than an add because the menu row is the same control either
41
+ * way — a second press has to mean "undo", and silently ignoring it reads as a
42
+ * dead row.
43
+ */
44
+ export function toggleToolPick(picks: readonly ToolPick[], pick: ToolPick): readonly ToolPick[] {
45
+ return picks.some((staged) => staged.id === pick.id)
46
+ ? removeToolPick(picks, pick.id)
47
+ : [...picks, pick];
48
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * A tool the user picked from the menu, and the marker that carries it.
3
+ *
4
+ * The marker reaches the MODEL, not the transcript. It says "the user chose
5
+ * this from the menu" in a form that survives a sentence around it, which a
6
+ * bare name cannot: `upload_file` in prose is indistinguishable from a word
7
+ * that was typed, and that ambiguity is the whole reason the menu exists.
8
+ */
9
+
10
+ import { toolURL } from "./links";
11
+
12
+ /** A picked tool: the registry's id, and the label the user actually saw. */
13
+ export interface ToolPick {
14
+ readonly id: string;
15
+ /** What the menu row said. Shown on the chip; never sent. */
16
+ readonly label: string;
17
+ /**
18
+ * The entry's icon KEY, carried so the chip can draw the same glyph the menu
19
+ * row did. Absent ⇒ no glyph; the id is not a fallback, because a host maps
20
+ * these keys and an id it never mapped would resolve to nothing anyway.
21
+ */
22
+ readonly icon?: string;
23
+ /**
24
+ * Carried from the catalogue row so the composer can say what is still
25
+ * missing without holding the catalogue itself — the draft is what the
26
+ * placeholder reads, and a pick is the only thing on it that knows.
27
+ */
28
+ readonly needs?: "file" | "skill";
29
+ }
30
+
31
+ /**
32
+ * The marker a picked tool becomes in the outbound text: a markdown link
33
+ * `[<label>](app://tool/<id>)`.
34
+ *
35
+ * A link rather than a bare token because the same string has two readers. The
36
+ * MODEL reads the label, which is the human name of the tool and says more than
37
+ * an id does; the RENDERER reads the href, which is the exact registry id and
38
+ * is what a chip must resolve. A bare `[tool:<id>]` served only the second, and
39
+ * printed as noise to the first.
40
+ *
41
+ * An empty label falls back to the id — a link with no text renders as nothing
42
+ * at all, which would hide the pick from the model entirely.
43
+ */
44
+ export function toolMarker(id: string, label: string): string {
45
+ return `[${escapeLinkText(label || id)}](${toolURL(id)})`;
46
+ }
47
+
48
+ /**
49
+ * Escape the characters that would end a markdown link's text early.
50
+ *
51
+ * Only `[` and `]` matter: either one unbalances the bracket pair, so the
52
+ * parser stops reading the label there and the href becomes visible prose.
53
+ * Labels rarely contain them, and a label that does must still round-trip.
54
+ */
55
+ function escapeLinkText(label: string): string {
56
+ return label.replace(/([[\]])/g, "\\$1");
57
+ }
58
+
59
+ /**
60
+ * Ids named by markers in a text, in the order they appear.
61
+ *
62
+ * For a host that must reconcile what a user typed against what they picked.
63
+ * Matched on the HREF, not the label: the id is the half that identifies the
64
+ * tool, and a label may be renamed, translated, or typed by the user.
65
+ *
66
+ * The label is skipped with an escape-aware scan so a `\]` inside it cannot end
67
+ * the pair early — the other half of what `escapeLinkText` writes.
68
+ */
69
+ export function readToolMarkers(text: string): readonly string[] {
70
+ const found: string[] = [];
71
+ for (const match of text.matchAll(/\[(?:\\.|[^\\[\]])*\]\(app:\/\/tool\/([^)\s]+)\)/g)) {
72
+ // The group is guaranteed by the pattern, but not by the type: an indexed
73
+ // read is `string | undefined` under the compiler's checked-access rule.
74
+ const raw = match[1];
75
+ if (raw === undefined) continue;
76
+ // The writer percent-encodes the id, so the reader must decode it or an id
77
+ // with a reserved character never matches the pick it came from.
78
+ try {
79
+ found.push(decodeURIComponent(raw));
80
+ } catch {
81
+ found.push(raw);
82
+ }
83
+ }
84
+ return found;
85
+ }
86
+
87
+ /**
88
+ * Append each pick's marker to the text the model receives.
89
+ *
90
+ * Appended at the END, never at the caret: the markers describe the whole turn,
91
+ * and splicing one into the middle of a sentence would change what the prose
92
+ * around it means.
93
+ *
94
+ * A pick already named in the text is not repeated — a user who typed the
95
+ * marker themselves, or a draft restored from storage, would otherwise send it
96
+ * twice and read as two requests.
97
+ */
98
+ export function appendToolMarkers(text: string, picks: readonly ToolPick[] | undefined): string {
99
+ // Tolerates a draft built before this field existed. `Draft` crosses the
100
+ // package boundary, so a host or a stored draft can still carry the old
101
+ // shape, and a missing list means "picked nothing" rather than a crash on
102
+ // the send path.
103
+ if (picks === undefined || picks.length === 0) return text;
104
+
105
+ const already = new Set(readToolMarkers(text));
106
+ const markers = picks
107
+ .filter((pick) => !already.has(pick.id))
108
+ .map((pick) => toolMarker(pick.id, pick.label));
109
+ if (markers.length === 0) return text;
110
+
111
+ const body = text.trim();
112
+ return body === "" ? markers.join(" ") : `${body} ${markers.join(" ")}`;
113
+ }
114
+
115
+ /** A text with its tool markers removed, and the ids they named. */
116
+ export interface StrippedToolMarkers {
117
+ readonly text: string;
118
+ readonly ids: readonly string[];
119
+ }
120
+
121
+ /**
122
+ * Split pasted text into prose and the tools it names.
123
+ *
124
+ * The marker is what `buildOutbound` WRITES, so copying a sent turn and pasting
125
+ * it back returns raw markdown to the box. The user sees a URL they did not
126
+ * type and a tool nothing staged — the round trip loses exactly the fact the
127
+ * marker exists to carry.
128
+ *
129
+ * Ids only. Whether a tool is offered here is the catalogue's answer, and a
130
+ * label lifted from the text would name a tool this host may not register.
131
+ */
132
+ export function stripToolMarkers(text: string): StrippedToolMarkers {
133
+ const ids = readToolMarkers(text);
134
+ if (ids.length === 0) return { text, ids };
135
+
136
+ // Collapse the space the marker was joined with, so removing a trailing one
137
+ // does not leave the prose ending in a blank.
138
+ const stripped = text
139
+ .replace(/\s*\[(?:\\.|[^\\[\]])*\]\(app:\/\/tool\/[^)\s]+\)/g, "")
140
+ .trim();
141
+ return { text: stripped, ids };
142
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What the composer should say once a tool is picked.
3
+ *
4
+ * Picking states an intent, and for most tools the object arrives as the
5
+ * sentence typed next — which is why the caret lands in the field and why this
6
+ * returns nothing for them. A few tools want something a sentence cannot carry,
7
+ * and today the interface stays silent: the turn sends, and the agent asks a
8
+ * question the composer could have prevented.
9
+ *
10
+ * A HINT, never a gate. The turn still sends without the file; the agent still
11
+ * asks. This only removes the round-trip when the user would have known.
12
+ */
13
+
14
+ import type { Draft } from "./outbound";
15
+
16
+ /**
17
+ * What a picked tool still wants. Mirrors the generated `ChatTool.needs`, which
18
+ * is derived in Go from the tool's own required arguments.
19
+ */
20
+ export type ToolNeed = "file" | "skill";
21
+
22
+ /**
23
+ * The unmet need of the picks on this draft, or null when there is nothing to
24
+ * say.
25
+ *
26
+ * Reads the DRAFT alone. The need travels on the pick, so this needs no
27
+ * catalogue and no resolver — a second lookup here would be a second place that
28
+ * can disagree with the row the user actually clicked.
29
+ *
30
+ * A `file` need is ALWAYS unmet: this surface is text-only, so nothing on the
31
+ * draft can answer it. The hint is what tells the user to name the file in
32
+ * words rather than reach for an affordance that is not there.
33
+ *
34
+ * The FIRST unmet need wins. Two picks wanting two different things is a
35
+ * sentence, not a placeholder, and stacking them would produce a line nobody
36
+ * can read — the second returns as soon as the first is met.
37
+ */
38
+ export function unmetNeed(draft: Draft): ToolNeed | null {
39
+ const picks = draft.pickedTools ?? [];
40
+ if (picks.length === 0) return null;
41
+
42
+ for (const pick of picks) {
43
+ if (pick.needs === undefined) continue;
44
+ return pick.needs;
45
+ }
46
+ return null;
47
+ }