@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,176 @@
1
+ import { ChevronDown } from "lucide-react";
2
+ import { useEffect, useRef, useState } from "react";
3
+
4
+ import { useChatCopy } from "../../i18n";
5
+ import {
6
+ MessageContent,
7
+ SubagentThread,
8
+ ToolCall,
9
+ ToolCallHeader,
10
+ ToolCallInput,
11
+ ToolCallOutput,
12
+ type PartRenderProps,
13
+ } from "../../primitives";
14
+ import { ChatMessageFrame } from "../message";
15
+
16
+ // Form adapted from vercel/ai-elements `packages/elements/src/tool.tsx`
17
+ // (Apache-2.0, submodule @ 6a9d5b1). The header-as-trigger and the chevron are
18
+ // theirs; the state dictionary, the disclosure rule and the reader's override
19
+ // are ours — they collapse unconditionally, which renders a running call as a
20
+ // stalled row.
21
+
22
+ /**
23
+ * The four states of a tool call, as a person reads them.
24
+ *
25
+ * The protocol's own identifiers (`input-streaming`, `output-available`) are the
26
+ * reducer's vocabulary, not a label: a reader watching a turn sees
27
+ * "output-available" and has to translate it. This maps the four onto the
28
+ * dictionary's user-facing names.
29
+ */
30
+ const TOOL_STATE_FIELD = {
31
+ "input-streaming": "inputStreaming",
32
+ "input-available": "inputAvailable",
33
+ "output-available": "outputAvailable",
34
+ "output-error": "outputError",
35
+ } as const;
36
+
37
+ /**
38
+ * Whether a call in this state deserves the reader's screen.
39
+ *
40
+ * Open while it RUNS: the part renders at `tool-input-start` so the reader sees
41
+ * which tool is working, and folded shut that is indistinguishable from stalled.
42
+ * Shut once it succeeded — one `tool_search` result buries the answer below it.
43
+ * An error stays open: short, and the reason the turn went as it did.
44
+ *
45
+ * The reference collapses unconditionally; that is the line not copied.
46
+ */
47
+ export function opensItself(state: keyof typeof TOOL_STATE_FIELD): boolean {
48
+ return state !== "output-available";
49
+ }
50
+
51
+ /**
52
+ * Whether a reader's override still applies after the state moved.
53
+ *
54
+ * It survives everything except the transition that carries its own answer:
55
+ * arriving at a state auto-collapse governs. Without the exception a call opened
56
+ * mid-run would stay open forever; without the survival it would slam shut under
57
+ * the cursor the moment the result landed.
58
+ *
59
+ * A function rather than an effect body so the rule is testable without a DOM —
60
+ * this package ships no jsdom, and the rule is the fragile part, not the click.
61
+ */
62
+ export function keepsOverride(
63
+ previous: keyof typeof TOOL_STATE_FIELD,
64
+ next: keyof typeof TOOL_STATE_FIELD,
65
+ ): boolean {
66
+ return previous === next;
67
+ }
68
+
69
+ export function ToolRenderer({ part }: PartRenderProps) {
70
+ const copy = useChatCopy().tool;
71
+ // Hooks run before the type guard: a renderer that returns early above a hook
72
+ // changes the hook order between parts and React unmounts the tree.
73
+ const [override, setOverride] = useState<boolean | null>(null);
74
+ const state = part.type === "tool" ? part.toolState.state : "output-available";
75
+ const auto = opensItself(state);
76
+
77
+ /*
78
+ A reader's click OUTLIVES the state change that follows it. Without this the
79
+ box opened mid-run slams shut when the result arrives. The ref tracks the
80
+ previous state so the override clears on that transition, not every render.
81
+ */
82
+ const previous = useRef(state);
83
+ useEffect(() => {
84
+ if (!keepsOverride(previous.current, state)) {
85
+ previous.current = state;
86
+ setOverride(null);
87
+ }
88
+ }, [state]);
89
+
90
+ if (part.type !== "tool") return null;
91
+
92
+ const open = override ?? auto;
93
+ const stateLabel = copy[TOOL_STATE_FIELD[state]];
94
+
95
+ return (
96
+ <ToolCall
97
+ part={part}
98
+ open={open}
99
+ className="chat-tool grid gap-1 rounded-md border border-border bg-muted/40 p-3 text-sm"
100
+ >
101
+ <ToolCallHeader>
102
+ {(p) => {
103
+ // A call is IN FLIGHT until its output or its error arrives.
104
+ const running =
105
+ p.toolState.state === "input-streaming" ||
106
+ p.toolState.state === "input-available";
107
+ const failed = p.toolState.state === "output-error";
108
+ // Sized and reserved in every state, so the label does not shift
109
+ // sideways when the call completes. A running call TUMBLES rather than
110
+ // pulses — the app's one vocabulary for "still going", shared with
111
+ // the turn mark and the recording glyph; a pulse says only "busy".
112
+ const dotTone = failed
113
+ ? "bg-destructive"
114
+ : running
115
+ ? "bg-primary"
116
+ : "bg-muted-foreground/40";
117
+ const dotClass = `chat-tool-dot shrink-0 ${dotTone}`;
118
+ const labelClass = `chat-tool-status text-xs ${failed ? "text-destructive" : "text-muted-foreground"}`;
119
+ return (
120
+ /*
121
+ A real button: keyboard-reachable, and `aria-expanded` carries the
122
+ state the decorative chevron only shows. `min-w-0` + `truncate` on
123
+ the name — it is data, and a long one pushes the chevron off the row.
124
+ */
125
+ <button
126
+ type="button"
127
+ className="chat-tool-header"
128
+ aria-expanded={open}
129
+ aria-label={open ? copy.collapse : copy.expand}
130
+ onClick={() => setOverride(!open)}
131
+ >
132
+ <span className="flex min-w-0 items-center gap-2">
133
+ <span aria-hidden className={dotClass} data-running={running || undefined} />
134
+ <strong className="truncate font-medium">{p.toolName}</strong>
135
+ <span className={labelClass}>{stateLabel}</span>
136
+ </span>
137
+ <ChevronDown className="chat-tool-chevron size-4 text-muted-foreground" aria-hidden />
138
+ </button>
139
+ );
140
+ }}
141
+ </ToolCallHeader>
142
+ {/* Absent, not hidden: a `hidden` subtree keeps the tall result in the
143
+ DOM, which is the weight this rule exists to remove. */}
144
+ {open ? (
145
+ <>
146
+ <ToolCallInput className="chat-tool-input text-muted-foreground font-mono text-xs" />
147
+ <ToolCallOutput className="chat-tool-output text-muted-foreground font-mono text-xs" />
148
+ </>
149
+ ) : null}
150
+ {/*
151
+ Renders null for an ordinary tool, so it costs a plain call nothing.
152
+ The nested thread inherits THESE renderers through the registry
153
+ context — a subagent's text and tools draw the same as an outer one
154
+ without registering anything.
155
+
156
+ The frame is supplied HERE rather than inside the thread because
157
+ `SubagentThread` is a primitive and the frame is `ui`: the layer rule
158
+ points the other way, and a primitive reaching up for it would invert
159
+ the arrow. Without this the nested turns render through a bare
160
+ `MessageContent` — no bubble, no role asymmetry, and none of the
161
+ selection containment every outer message has, so a drag inside a
162
+ delegation fuses two speakers.
163
+
164
+ Outside the disclosure: a delegation is the turn's substance, not the
165
+ call's arguments — folding it away hides a conversation behind a chevron.
166
+ */}
167
+ <SubagentThread className="mt-2 grid gap-2 border-l-2 border-border pl-3">
168
+ {(message) => (
169
+ <MessageContent message={message}>
170
+ {(msg, parts) => <ChatMessageFrame message={msg}>{parts}</ChatMessageFrame>}
171
+ </MessageContent>
172
+ )}
173
+ </SubagentThread>
174
+ </ToolCall>
175
+ );
176
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `waiting` — the turn is in flight and has produced nothing yet.
3
+ * `working` — text is arriving; the mark trails the last line.
4
+ *
5
+ * There is deliberately no "done". A resting mark under a finished reply is true
6
+ * of every reply ever sent, so it stacks up one per message and stops carrying
7
+ * information — the mark's whole job is telling the live turn from the settled
8
+ * ones.
9
+ */
10
+ export type TurnMarkState = "waiting" | "working";
11
+
12
+ export interface TurnMarkProps {
13
+ readonly state: TurnMarkState;
14
+ readonly className?: string;
15
+ }
16
+
17
+ /**
18
+ * One shape for the whole life of a turn: waiting, working, done.
19
+ *
20
+ * The states differ only in pace and weight — never in shape — so the end of an
21
+ * answer reads as the mark settling rather than as a new icon appearing. The
22
+ * motion and the sizing are in `styles/turn-mark.css`; this is only the element
23
+ * and its state attribute.
24
+ *
25
+ * `aria-hidden`, always. The transcript's own live region announces a turn's
26
+ * start and end; a mark that also spoke would double every announcement, and it
27
+ * carries no information the region does not already give.
28
+ */
29
+ export function TurnMark({ state, className }: TurnMarkProps) {
30
+ return <span className={`turn-mark ${className ?? ""}`} data-state={state} aria-hidden />;
31
+ }
@@ -0,0 +1,46 @@
1
+ import { startsNewDay } from "@djangocfg/widget-kit";
2
+
3
+ /**
4
+ * Which day-label a transcript row opens, if it opens one at all.
5
+ *
6
+ * Only the CHOICE lives here. Calendar arithmetic and the absolute format are
7
+ * `lib-utils`, which owns display time for every surface — three surfaces had
8
+ * already spelled the clock three ways before that file existed, so a
9
+ * package-local date formatter is the one thing this must not become.
10
+ *
11
+ * "Today" and "yesterday" are product wording rather than a date format, which
12
+ * is why the choice is made here and the copy comes from this package. The
13
+ * `absolute` case carries no label: only the caller knows the reader's locale.
14
+ */
15
+ export type DayHeading = "today" | "yesterday" | "absolute";
16
+
17
+ /**
18
+ * The heading for `at`, or `undefined` when the row needs none.
19
+ *
20
+ * `previous` is the row immediately before; `undefined` means the first row.
21
+ *
22
+ * `now` is a parameter, not a clock read: it keeps the rule testable and lets
23
+ * one render pass label every row against a single instant, so a transcript
24
+ * cannot straddle midnight mid-list.
25
+ */
26
+ export function dayHeadingFor(
27
+ at: number,
28
+ previous: number | undefined,
29
+ now: number,
30
+ ): DayHeading | undefined {
31
+ if (!startsNewDay(at, previous)) return undefined;
32
+ if (!startsNewDay(at, now)) {
33
+ // A "Today" opening the FIRST row separates nothing and names the day the
34
+ // reader is already in. Every other first-row label — "Yesterday", a date —
35
+ // carries information they do not have, so only this one is dropped.
36
+ return previous === undefined ? undefined : "today";
37
+ }
38
+ // Shifted from NOW, and by the DATE field rather than by 24h: "yesterday" is
39
+ // a statement about the reader's today, and a day is 23 or 25 hours long
40
+ // across a daylight-saving boundary, where subtracting a fixed span lands on
41
+ // the wrong date.
42
+ const yesterday = new Date(now);
43
+ yesterday.setDate(yesterday.getDate() - 1);
44
+ if (!startsNewDay(at, yesterday.getTime())) return "yesterday";
45
+ return "absolute";
46
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The readable half of a thrown value.
3
+ *
4
+ * A `catch` binding is `unknown`, and a rejected fetch, a thrown string and a
5
+ * real `Error` all arrive through it. Rendering one directly prints
6
+ * `[object Object]` in the place a user reads to find out what went wrong.
7
+ */
8
+ export function messageOf(error: unknown): string {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
@@ -0,0 +1,12 @@
1
+ // Leaf mechanics: byte formatting, ids, clipboard, class merging, dates.
2
+ //
3
+ // May import: nothing local. This folder is a sink.
4
+ //
5
+ // Membership test, stated because this is where codebases dump everything: if
6
+ // the helper's signature mentions a message, a part, a tool or a block, it is
7
+ // NOT a util — it belongs in core.
8
+
9
+ export { messageOf } from "./error-message";
10
+ export { dayHeadingFor, type DayHeading } from "./day-heading";
11
+ export { markdownToText, markdownToLine } from "./markdown-text";
12
+ export type { MarkdownTextOptions, OpaqueBlock } from "./markdown-text";
@@ -0,0 +1,175 @@
1
+ import { toString } from "mdast-util-to-string";
2
+ import remarkGfm from "remark-gfm";
3
+ import remarkParse from "remark-parse";
4
+ import { unified } from "unified";
5
+
6
+ /**
7
+ * Markdown → one line of plain text.
8
+ *
9
+ * For surfaces that show a message as a LABEL rather than rendering it: the
10
+ * roster preview, a notification body, a search hit, a chip title. The reader
11
+ * there wants to know WHAT arrived, not to read it, and raw `**bold**` or a
12
+ * table's pipes are this product's internal format leaking out.
13
+ *
14
+ * Built on the parser this package already renders with — `unified` +
15
+ * `remark-parse` + `remark-gfm`, the same stack `streamdown` runs. Not a regex
16
+ * pipeline: those do not merely miss constructs, they EDIT the text, and
17
+ * `file_name_here.txt` losing its underscores is worse than an asterisk
18
+ * surviving.
19
+ */
20
+
21
+ /** A node's children are separate BLOCKS, so their text must not run together. */
22
+ const BLOCK_CONTAINERS = new Set([
23
+ "root",
24
+ "blockquote",
25
+ "list",
26
+ "listItem",
27
+ "table",
28
+ "tableRow",
29
+ "footnoteDefinition",
30
+ ]);
31
+
32
+ interface MdNode {
33
+ type: string;
34
+ children?: MdNode[];
35
+ checked?: boolean | null;
36
+ lang?: string | null;
37
+ alt?: string | null;
38
+ url?: string;
39
+ }
40
+
41
+ /**
42
+ * What a block becomes when its CONTENT is not worth reading in one line.
43
+ *
44
+ * A fenced block flattened to text reads as if the agent said it in prose:
45
+ * "const x = 1 const y = 2" is not something anyone wrote. A table is worse —
46
+ * headers and cells collapse into one stream and the structure that made it a
47
+ * table is gone. Both are frequent here, because this product is agents writing
48
+ * code.
49
+ *
50
+ * The glyph is a constant and never translated; the word beside it is supplied
51
+ * by the caller, which is what keeps this file free of an i18n dependency.
52
+ */
53
+ export type OpaqueBlock = "code" | "table" | "image" | "formula";
54
+
55
+ export interface MarkdownTextOptions {
56
+ /**
57
+ * Names for the blocks that collapse to a glyph. A key left out means that
58
+ * block flattens to its text as before — so a caller with no catalogue gets
59
+ * the old behaviour rather than a bare glyph with no word.
60
+ */
61
+ readonly blockLabels?: Partial<Record<OpaqueBlock, string>>;
62
+ /**
63
+ * Re-emit GFM task state as a glyph. A task item carries completion in
64
+ * `checked`, not in its text, so a preview that drops it says an unfinished
65
+ * item is done.
66
+ */
67
+ readonly taskMarks?: boolean;
68
+ }
69
+
70
+ const GLYPH: Record<OpaqueBlock, string> = {
71
+ code: "💻",
72
+ table: "📊",
73
+ image: "📷",
74
+ formula: "🔢",
75
+ };
76
+
77
+ /** `html` nodes are raw by design — mdast does not parse their inner markup.
78
+ * These strings are message text, so a tag is something a human or an agent
79
+ * typed: drop the tag, keep the words. NOT sanitisation — every consumer
80
+ * renders the result as a text node. */
81
+ const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g;
82
+
83
+ const collapse = (s: string): string =>
84
+ s.replace(HTML_TAG, "").replace(/\s+/g, " ").trim();
85
+
86
+ const TO_STRING_OPTS = { includeImageAlt: true, includeHtml: true } as const;
87
+
88
+ /** The opaque block this node is, or null when its text should be kept. */
89
+ function opaqueKind(node: MdNode): OpaqueBlock | null {
90
+ if (node.type === "code") return "code";
91
+ if (node.type === "table") return "table";
92
+ if (node.type === "image") return "image";
93
+ if (node.type === "math" || node.type === "inlineMath") return "formula";
94
+ return null;
95
+ }
96
+
97
+ function flatten(node: MdNode, options: MarkdownTextOptions): string {
98
+ const labels = options.blockLabels ?? {};
99
+ const kind = opaqueKind(node);
100
+ if (kind !== null) {
101
+ const word = labels[kind];
102
+ // No label supplied: fall through to the text. A bare glyph with no word
103
+ // is less informative than the content it replaced.
104
+ if (word !== undefined) return `${GLYPH[kind]} ${word}`;
105
+ }
106
+
107
+ if (!BLOCK_CONTAINERS.has(node.type) || !node.children?.length) {
108
+ return toString(node as Parameters<typeof toString>[0], TO_STRING_OPTS);
109
+ }
110
+
111
+ const mark =
112
+ options.taskMarks && node.type === "listItem" && typeof node.checked === "boolean"
113
+ ? node.checked
114
+ ? "✓ "
115
+ : "◻ "
116
+ : "";
117
+
118
+ const parts: string[] = [];
119
+ for (const child of node.children) {
120
+ const text = flatten(child, options).trim();
121
+ if (text) parts.push(text);
122
+ }
123
+ // `toString` on a container concatenates blocks bare — two paragraphs come
124
+ // back as "donenext" — so the boundary is reintroduced here. One space is all
125
+ // a single line can express of it.
126
+ return mark + parts.join(" ");
127
+ }
128
+
129
+ const processor = unified().use(remarkParse).use(remarkGfm);
130
+
131
+ function parse(markdown: string): MdNode | null {
132
+ try {
133
+ return processor.parse(markdown) as unknown as MdNode;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * All of the markdown's text on one collapsed line.
141
+ *
142
+ * A parser that throws must not blank the label: showing raw markdown is
143
+ * degraded, showing nothing loses the message entirely.
144
+ */
145
+ export function markdownToText(
146
+ markdown: string,
147
+ options: MarkdownTextOptions = {},
148
+ ): string {
149
+ if (!markdown) return "";
150
+ const tree = parse(markdown);
151
+ if (tree === null) return collapse(markdown);
152
+ return collapse(flatten(tree, { taskMarks: true, ...options }));
153
+ }
154
+
155
+ /**
156
+ * The FIRST block that carries words — for a NAME (row title, chip, search hit).
157
+ *
158
+ * Returns '' when nothing is renderable (a lone fence with no label, a rule, a
159
+ * bare marker), so a caller can fall back to its own placeholder rather than
160
+ * printing punctuation as a name.
161
+ */
162
+ export function markdownToLine(
163
+ markdown: string,
164
+ options: MarkdownTextOptions = {},
165
+ ): string {
166
+ if (!markdown) return "";
167
+ const tree = parse(markdown);
168
+ if (tree === null) return collapse(markdown.split("\n")[0] ?? "");
169
+ const opts = { taskMarks: false, ...options };
170
+ for (const block of tree.children ?? []) {
171
+ const line = collapse(flatten(block, opts));
172
+ if (line) return line;
173
+ }
174
+ return "";
175
+ }
@@ -0,0 +1,14 @@
1
+ // Block renderers. A widget is a PURE FUNCTION of its `data`: no store, no
2
+ // transcript, no streaming knowledge beyond an explicit prop. That is what
3
+ // makes one testable without mounting a provider — and why a dependency with a
4
+ // hidden context requirement (ui-core's Spinner needs i18n) cannot be used here.
5
+ //
6
+ // Each widget ships with a `read*` parser kept SEPARATE from the component, so
7
+ // a malformed payload degrades to "render nothing" instead of throwing inside a
8
+ // transcript row, and so the parsing is testable without a renderer.
9
+ //
10
+ // May import: core, utils, content.
11
+ // May NOT import: store, primitives, composer, ui.
12
+
13
+ export { MediaWidget } from "./media";
14
+ export type { MediaRendererProps } from "./media";