akanjs 3.0.0-beta.13 → 3.0.0-beta.15

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 (74) hide show
  1. package/common/CodeAgentClient.ts +102 -0
  2. package/common/CodeTranscript.ts +337 -0
  3. package/common/codeAgentProfile.ts +175 -0
  4. package/common/codeAgentWire.ts +409 -0
  5. package/common/index.ts +51 -0
  6. package/common/markdownSpans.ts +57 -0
  7. package/dictionary/agent.dictionary.ts +4 -11
  8. package/dictionary/base.dictionary.ts +1 -0
  9. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  10. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  11. package/package.json +3 -1
  12. package/server/akanOption.ts +9 -2
  13. package/server/di/predefinedAdaptor.ts +2 -2
  14. package/service/predefinedAdaptor/anthropicLlm.ts +6 -4
  15. package/service/predefinedAdaptor/index.ts +0 -1
  16. package/service/predefinedAdaptor/llm.adaptor.ts +21 -1
  17. package/service/predefinedAdaptor/openaiLlm.ts +37 -18
  18. package/store/agentic/StToolBuilder.ts +54 -0
  19. package/store/agentic/attachAgentic.ts +2 -1
  20. package/store/agentic/useFormTools.ts +1 -1
  21. package/types/common/CodeAgentClient.d.ts +26 -0
  22. package/types/common/CodeTranscript.d.ts +99 -0
  23. package/types/common/codeAgentProfile.d.ts +111 -0
  24. package/types/common/codeAgentWire.d.ts +388 -0
  25. package/types/common/index.d.ts +7 -0
  26. package/types/common/markdownSpans.d.ts +40 -0
  27. package/types/dictionary/agent.dictionary.d.ts +1 -1
  28. package/types/dictionary/base.dictionary.d.ts +1 -1
  29. package/types/dictionary/dictionary.d.ts +9 -9
  30. package/types/server/akanOption.d.ts +9 -2
  31. package/types/server/di/predefinedAdaptor.d.ts +2 -2
  32. package/types/service/predefinedAdaptor/anthropicLlm.d.ts +1 -1
  33. package/types/service/predefinedAdaptor/index.d.ts +0 -1
  34. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +14 -1
  35. package/types/service/predefinedAdaptor/openaiLlm.d.ts +20 -11
  36. package/types/store/agentic/StToolBuilder.d.ts +22 -0
  37. package/types/store/agentic/attachAgentic.d.ts +2 -1
  38. package/types/store/baseSt.d.ts +2 -2
  39. package/types/ui/Agent/Chat.d.ts +7 -1
  40. package/types/ui/Agent/Composer.d.ts +17 -1
  41. package/types/ui/Agent/MentionNode.d.ts +26 -0
  42. package/types/ui/Agent/RichInput.d.ts +22 -0
  43. package/types/ui/Agent/ToolCard.d.ts +16 -0
  44. package/types/ui/Agent/markdownSpans.d.ts +1 -0
  45. package/types/ui/Agent/mentionDraft.d.ts +19 -0
  46. package/types/ui/Agent/useChatReferences.d.ts +3 -2
  47. package/types/ui/UiOverride/context.d.ts +2 -0
  48. package/types/ui/index.d.ts +2 -1
  49. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  50. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  51. package/types/vendor/use-agentic/AgentSession.d.ts +15 -1
  52. package/types/vendor/use-agentic/ToolRunner.d.ts +21 -1
  53. package/types/vendor/use-agentic/types.d.ts +31 -1
  54. package/ui/Agent/Chat.tsx +24 -7
  55. package/ui/Agent/Composer.tsx +73 -21
  56. package/ui/Agent/Markdown.tsx +2 -2
  57. package/ui/Agent/MentionNode.ts +62 -0
  58. package/ui/Agent/RichInput.tsx +154 -0
  59. package/ui/Agent/ToolCard.tsx +39 -0
  60. package/ui/Agent/markdownSpans.tsx +26 -36
  61. package/ui/Agent/mentionDraft.ts +101 -0
  62. package/ui/Agent/useChatReferences.ts +5 -8
  63. package/ui/UiOverride/context.ts +2 -0
  64. package/ui/index.ts +2 -1
  65. package/vendor/use-agentic/AgentSession.ts +45 -1
  66. package/vendor/use-agentic/AgenticSurface.ts +2 -0
  67. package/vendor/use-agentic/ToolRunner.ts +55 -1
  68. package/vendor/use-agentic/types.ts +36 -1
  69. package/service/predefinedAdaptor/deepseekLlm.ts +0 -82
  70. package/types/service/predefinedAdaptor/deepseekLlm.d.ts +0 -19
  71. /package/{ui/Agent → common}/markdownBlocks.ts +0 -0
  72. /package/{ui/Agent → common}/markdownTable.ts +0 -0
  73. /package/types/{ui/Agent → common}/markdownBlocks.d.ts +0 -0
  74. /package/types/{ui/Agent → common}/markdownTable.d.ts +0 -0
package/ui/Agent/Chat.tsx CHANGED
@@ -28,7 +28,7 @@ import { agentSessionOf } from "./agentSessionOf";
28
28
  import type { AttachLimits, AttachReader } from "./attachment";
29
29
  import Bubble from "./Bubble";
30
30
  import { type ChatCommand, ChatCommands } from "./ChatCommands";
31
- import { Composer } from "./Composer";
31
+ import { Composer, type ComposerHandle } from "./Composer";
32
32
  import { Launcher } from "./Launcher";
33
33
  import Menu from "./Menu";
34
34
  import Question from "./Question";
@@ -36,6 +36,7 @@ import Queued from "./Queued";
36
36
  import Steps from "./Steps";
37
37
  import type { PersistOption } from "./sessionHistory";
38
38
  import type { BuiltinOption } from "./sessionView";
39
+ import ToolCard from "./ToolCard";
39
40
  import { tokenCount } from "./tokenCount";
40
41
  import { useChatAttachments } from "./useChatAttachments";
41
42
  import { type QueuedMessage, useChatQueue } from "./useChatQueue";
@@ -138,6 +139,12 @@ export interface ChatProps {
138
139
  * field reads as a paragraph rather than as the editor document it is stored as.
139
140
  */
140
141
  reference?: readonly ReferenceSource[];
142
+ /**
143
+ * Draws each pointer in the composer as the name it points at rather than as the `@[…](mention:…)` token that
144
+ * carries it. On wherever `reference` sources are declared, and `false` keeps the plain textarea — for an app
145
+ * that overrides the composer, or one that would rather see the tokens it is sending.
146
+ */
147
+ mentions?: boolean;
141
148
  /**
142
149
  * Raises or lowers what the composer accepts — per file, per message, and how many. The defaults are what one
143
150
  * turn's JSON safely carries to a conservative provider; an app pointed at a larger request limit, or one whose
@@ -199,6 +206,7 @@ export const DefaultChat = ({
199
206
  attach,
200
207
  attachLimits,
201
208
  reference,
209
+ mentions,
202
210
  voice,
203
211
  }: ChatProps) => {
204
212
  const { l } = usePage();
@@ -249,7 +257,13 @@ export const DefaultChat = ({
249
257
  const listRef = useRef<HTMLDivElement>(null);
250
258
  const inputRef = useRef<HTMLTextAreaElement>(null);
251
259
  const launcherRef = useRef<HTMLButtonElement>(null);
252
- const refs = useChatReferences({ session, draft, version, inputRef, onDraft: setDraft });
260
+ const handleRef = useRef<ComposerHandle | null>(null);
261
+
262
+ const focusComposer = () => {
263
+ if (handleRef.current) handleRef.current.focus();
264
+ else inputRef.current?.focus();
265
+ };
266
+ const refs = useChatReferences({ session, draft, version, handleRef, onDraft: setDraft });
253
267
 
254
268
  const sticky = useRef(true);
255
269
  const returning = useRef(false);
@@ -274,7 +288,7 @@ export const DefaultChat = ({
274
288
  event.preventDefault();
275
289
  sticky.current = true;
276
290
  setOpen(true);
277
- inputRef.current?.focus();
291
+ focusComposer();
278
292
  };
279
293
 
280
294
  window.addEventListener("keydown", onKeyDown, true);
@@ -287,7 +301,7 @@ export const DefaultChat = ({
287
301
  returning.current = false;
288
302
  return;
289
303
  }
290
- inputRef.current?.focus();
304
+ focusComposer();
291
305
  }, [open, session.pendingQuestion?.callId]);
292
306
  useEffect(
293
307
  () => () => {
@@ -299,7 +313,7 @@ export const DefaultChat = ({
299
313
  const write = (text: string) => {
300
314
  setDraft(text);
301
315
  menu.reopen();
302
- mentions.reopen();
316
+ mentionMenu.reopen();
303
317
  };
304
318
 
305
319
  const closable = openProp === undefined || !!onOpenChange;
@@ -349,9 +363,9 @@ export const DefaultChat = ({
349
363
  speech.hold(message.byVoice);
350
364
  };
351
365
  const menu = useSlashMenu({ draft, l, onCommand: runCommand });
352
- const mentions = useReferenceMenu({ draft, sources: reference ?? [], session, l, onWrite: write });
366
+ const mentionMenu = useReferenceMenu({ draft, sources: reference ?? [], session, l, onWrite: write });
353
367
 
354
- const list = menu.at() ? menu : mentions;
368
+ const list = menu.at() ? menu : mentionMenu;
355
369
  const send = () => {
356
370
  const text = draft.trim();
357
371
  if (!text && !files.attached.length) return;
@@ -558,6 +572,7 @@ export const DefaultChat = ({
558
572
  ) : null}
559
573
  </div>
560
574
  {session.pendingApproval ? <Approval approval={session.pendingApproval} /> : null}
575
+ {session.pendingCard ? <ToolCard card={session.pendingCard} key={session.pendingCard.callId} /> : null}
561
576
  {session.pendingQuestion ? (
562
577
  <Question key={session.pendingQuestion.callId} question={session.pendingQuestion} />
563
578
  ) : null}
@@ -567,7 +582,9 @@ export const DefaultChat = ({
567
582
  attached={files.attached}
568
583
  pending={files.pending}
569
584
  draft={draft}
585
+ handleRef={handleRef}
570
586
  inputRef={inputRef}
587
+ mentions={mentions ?? !!reference?.length}
571
588
  {...(speech.canListen ? { mic: { listening: speech.listening, onToggle: speech.toggle } } : {})}
572
589
  onDraft={write}
573
590
  onFiles={(picked) => void files.add(picked)}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { cn, usePage } from "akanjs/client";
3
+ import { lazy } from "akanjs/webkit";
3
4
  import { type KeyboardEvent, type RefObject, useEffect } from "react";
4
5
  import type { AgentSession, MessageAttachment, MessageReference } from "../../vendor/use-agentic";
5
6
  import { Button } from "../Button";
@@ -9,6 +10,16 @@ import { Attach, Chips } from "./Attach";
9
10
  import { Mic } from "./Mic";
10
11
  import { ReferenceChips } from "./Refer";
11
12
 
13
+ /**
14
+ * What the chat needs of whatever the composer draws into — a textarea, or the mention editor. Every offset is an
15
+ * offset into the draft string, tokens included, because that is the text the chat reasons about.
16
+ */
17
+ export interface ComposerHandle {
18
+ focus: () => void;
19
+ caret: () => number | null;
20
+ setCaret: (at: number) => void;
21
+ }
22
+
12
23
  export interface ComposerProps {
13
24
  className?: string;
14
25
  session: AgentSession;
@@ -32,8 +43,21 @@ export interface ComposerProps {
32
43
  onSend: () => void;
33
44
  onStop: () => void;
34
45
  inputRef?: RefObject<HTMLTextAreaElement | null>;
46
+ /**
47
+ * Draws each `@` pointer as the name it points at instead of as its token. On only where the chat was given
48
+ * `reference` sources, so an app that declared none never fetches the editor's chunk.
49
+ */
50
+ mentions?: boolean;
51
+ /** Filled by whichever input is drawn. An override that draws its own textarea leaves it null and `inputRef` answers. */
52
+ handleRef?: RefObject<ComposerHandle | null>;
35
53
  }
36
54
 
55
+ const RichInput = lazy(() => import("./RichInput"), {
56
+ ssr: false,
57
+ suspense: true,
58
+ loading: () => <div className="flex-1" />,
59
+ });
60
+
37
61
  /** Where the box stops growing and starts scrolling: a chat composer is a paragraph at most. */
38
62
  const maxComposerHeight = 128;
39
63
 
@@ -54,9 +78,31 @@ export const DefaultComposer = ({
54
78
  onSend,
55
79
  onStop,
56
80
  inputRef,
81
+ mentions = false,
82
+ handleRef,
57
83
  }: ComposerProps) => {
58
84
  const { l } = usePage();
59
85
  const surface = useUiRecipe("input") ?? inputRecipe;
86
+ const field = surface({ kind: "area", size: "sm" }, "max-h-32 flex-1 resize-none py-1.5");
87
+ const placeholder = session.pendingQuestion
88
+ ? l("base.agentAnswer")
89
+ : session.isRunning
90
+ ? l("base.agentQueuePlaceholder")
91
+ : l("base.agentPlaceholder");
92
+ useEffect(() => {
93
+ if (!handleRef || mentions) return;
94
+ handleRef.current = {
95
+ focus: () => inputRef?.current?.focus(),
96
+ caret: () => inputRef?.current?.selectionStart ?? null,
97
+ setCaret: (at) => {
98
+ inputRef?.current?.focus();
99
+ inputRef?.current?.setSelectionRange(at, at);
100
+ },
101
+ };
102
+ return () => {
103
+ handleRef.current = null;
104
+ };
105
+ }, [handleRef, inputRef, mentions]);
60
106
  useEffect(() => {
61
107
  const area = inputRef?.current;
62
108
  if (!area) return;
@@ -87,27 +133,33 @@ export const DefaultComposer = ({
87
133
  <Mic className="pb-2" label={l("base.agentListen")} listening={mic.listening} onToggle={mic.onToggle} />
88
134
  ) : null}
89
135
  <Attach className="pb-2" label={l("base.agentAttach")} onPick={onFiles} />
90
- <textarea
91
- className={surface({ kind: "area", size: "sm" }, "max-h-32 flex-1 resize-none py-1.5")}
92
- onChange={(event) => onDraft(event.target.value)}
93
- onKeyDown={onKeyDown}
94
- onPaste={(event) => {
95
- const pasted = [...event.clipboardData.files];
96
- if (!pasted.length) return;
97
- event.preventDefault();
98
- onFiles(pasted);
99
- }}
100
- placeholder={
101
- session.pendingQuestion
102
- ? l("base.agentAnswer")
103
- : session.isRunning
104
- ? l("base.agentQueuePlaceholder")
105
- : l("base.agentPlaceholder")
106
- }
107
- ref={inputRef}
108
- rows={1}
109
- value={draft}
110
- />
136
+ {mentions ? (
137
+ <RichInput
138
+ className={field}
139
+ draft={draft}
140
+ handleRef={handleRef}
141
+ onDraft={onDraft}
142
+ onFiles={onFiles}
143
+ onKeyDown={onKeyDown}
144
+ placeholder={placeholder}
145
+ />
146
+ ) : (
147
+ <textarea
148
+ className={field}
149
+ onChange={(event) => onDraft(event.target.value)}
150
+ onKeyDown={onKeyDown}
151
+ onPaste={(event) => {
152
+ const pasted = [...event.clipboardData.files];
153
+ if (!pasted.length) return;
154
+ event.preventDefault();
155
+ onFiles(pasted);
156
+ }}
157
+ placeholder={placeholder}
158
+ ref={inputRef}
159
+ rows={1}
160
+ value={draft}
161
+ />
162
+ )}
111
163
  {/* A parked question is not a turn to stop: the loop is waiting on the card, and the card has its own out. */}
112
164
  {session.isRunning && !session.pendingQuestion ? (
113
165
  <>
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
  import { cn } from "akanjs/client";
3
+ import type { Align, MarkdownBlock, MarkdownItem, TableBlock } from "akanjs/common";
4
+ import { MarkdownBlocks } from "akanjs/common";
3
5
  import type { ReactNode } from "react";
4
6
  import { createOverridable } from "../UiOverride";
5
- import { type MarkdownBlock, MarkdownBlocks, type MarkdownItem } from "./markdownBlocks";
6
7
  import { spans } from "./markdownSpans";
7
- import type { Align, TableBlock } from "./markdownTable";
8
8
 
9
9
  const alignClass: { [key in Align]: string } = { left: "text-left", center: "text-center", right: "text-right" };
10
10
 
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { $applyNodeReplacement, type LexicalNode, type NodeKey, type SerializedTextNode, TextNode } from "lexical";
3
+ import { type MessageReference, Reference } from "../../vendor/use-agentic";
4
+
5
+ export interface SerializedMentionNode extends SerializedTextNode {
6
+ reference: MessageReference;
7
+ }
8
+
9
+ /**
10
+ * One `@` pointer inside the composer: the label is what the node draws, and `getTextContent()` is the
11
+ * `@[label](mention:…)` token — so the editor's own text is the draft string the rest of the chat already reads,
12
+ * pointers and all, while the person sees a name.
13
+ *
14
+ * `token` mode is what makes it one thing: the caret never lands inside it, a backspace takes the whole pointer
15
+ * rather than a character of a label that would then name nothing, and a paste over it replaces it entirely.
16
+ */
17
+ export class MentionNode extends TextNode {
18
+ #reference: MessageReference;
19
+
20
+ static override getType() {
21
+ return "mention";
22
+ }
23
+
24
+ static override clone(node: MentionNode): MentionNode {
25
+ return new MentionNode(node.#reference, node.__key);
26
+ }
27
+
28
+ static override importJSON(json: Parameters<typeof TextNode.importJSON>[0]): MentionNode {
29
+ return new MentionNode((json as Partial<SerializedMentionNode>).reference);
30
+ }
31
+
32
+ constructor(reference: MessageReference = { refName: "", refId: "", label: "" }, key?: NodeKey) {
33
+ super(reference.label || Reference.keyOf(reference), key);
34
+ this.#reference = reference;
35
+ this.setMode("token");
36
+ }
37
+
38
+ get reference(): MessageReference {
39
+ return this.#reference;
40
+ }
41
+
42
+ override getTextContent(): string {
43
+ return Reference.token(this.#reference);
44
+ }
45
+
46
+ override createDOM(...args: Parameters<TextNode["createDOM"]>): HTMLElement {
47
+ const dom = super.createDOM(...args);
48
+ dom.className = "rounded-field bg-primary/10 px-1 text-primary";
49
+ dom.title = Reference.keyOf(this.#reference);
50
+ return dom;
51
+ }
52
+
53
+ override exportJSON(): SerializedMentionNode {
54
+ return { ...super.exportJSON(), reference: this.#reference };
55
+ }
56
+ }
57
+
58
+ export const $createMentionNode = (reference: MessageReference): MentionNode =>
59
+ $applyNodeReplacement(new MentionNode(reference));
60
+
61
+ export const $isMentionNode = (node: LexicalNode | null | undefined): node is MentionNode =>
62
+ node instanceof MentionNode;
@@ -0,0 +1,154 @@
1
+ "use client";
2
+ import { registerPlainText } from "@lexical/plain-text";
3
+ import { cn } from "akanjs/client";
4
+ import {
5
+ COMMAND_PRIORITY_HIGH,
6
+ createEditor,
7
+ KEY_ARROW_DOWN_COMMAND,
8
+ KEY_ARROW_UP_COMMAND,
9
+ KEY_ENTER_COMMAND,
10
+ KEY_ESCAPE_COMMAND,
11
+ KEY_TAB_COMMAND,
12
+ type LexicalCommand,
13
+ PASTE_COMMAND,
14
+ } from "lexical";
15
+ import { type KeyboardEvent as ReactKeyboardEvent, type RefObject, useEffect, useRef, useState } from "react";
16
+ import type { ComposerHandle } from "./Composer";
17
+ import { MentionNode } from "./MentionNode";
18
+ import { MentionDraft } from "./mentionDraft";
19
+
20
+ interface RichInputProps {
21
+ className?: string;
22
+ draft: string;
23
+ placeholder: string;
24
+ onDraft: (text: string) => void;
25
+ /** The textarea's own handler, unchanged: the chat reads a key off the draft and the caret, not off the DOM. */
26
+ onKeyDown: (event: ReactKeyboardEvent<HTMLTextAreaElement>) => void;
27
+ onFiles: (files: File[]) => void;
28
+ handleRef?: RefObject<ComposerHandle | null>;
29
+ }
30
+
31
+ /**
32
+ * The composer's input when the chat has `@` sources: the same draft string, drawn with each pointer as the name
33
+ * it points at instead of as the token that carries it.
34
+ *
35
+ * Lexical rather than a contenteditable of our own, for one reason — a Korean or Japanese IME composing into a
36
+ * contenteditable React also re-renders is the bug class the library exists to own, and it is not one an app can
37
+ * work around from outside.
38
+ */
39
+ export const RichInput = ({
40
+ className,
41
+ draft,
42
+ placeholder,
43
+ onDraft,
44
+ onKeyDown,
45
+ onFiles,
46
+ handleRef,
47
+ }: RichInputProps) => {
48
+ const [editor] = useState(() =>
49
+ createEditor({
50
+ namespace: "akanAgentComposer",
51
+ nodes: [MentionNode],
52
+ onError: (error: Error) => console.error(error),
53
+ }),
54
+ );
55
+ const rootRef = useRef<HTMLDivElement>(null);
56
+ const live = useRef({ draft, onDraft, onKeyDown, onFiles });
57
+ live.current = { draft, onDraft, onKeyDown, onFiles };
58
+
59
+ const settled = useRef(draft);
60
+ useEffect(() => {
61
+ const root = rootRef.current;
62
+ if (!root) return;
63
+ editor.setRootElement(root);
64
+ const forward = (key: string, event: KeyboardEvent | null) => {
65
+ const value = MentionDraft.read();
66
+ const caret = MentionDraft.caret() ?? value.length;
67
+ let prevented = false;
68
+ live.current.onKeyDown({
69
+ key,
70
+ shiftKey: !!event?.shiftKey,
71
+ nativeEvent: { isComposing: !!event?.isComposing },
72
+ currentTarget: { value, selectionStart: caret, selectionEnd: caret },
73
+ preventDefault: () => {
74
+ prevented = true;
75
+ event?.preventDefault();
76
+ },
77
+ } as unknown as ReactKeyboardEvent<HTMLTextAreaElement>);
78
+ return prevented;
79
+ };
80
+
81
+ const onKey = <T extends KeyboardEvent | null>(command: LexicalCommand<T>, key: string) =>
82
+ editor.registerCommand<T>(command, (event) => forward(key, event), COMMAND_PRIORITY_HIGH);
83
+ const teardowns = [
84
+ registerPlainText(editor),
85
+ editor.registerUpdateListener(({ editorState }) => {
86
+ const text = editorState.read(() => MentionDraft.read());
87
+ if (text === settled.current) return;
88
+
89
+ settled.current = text;
90
+ live.current.onDraft(text);
91
+ }),
92
+ onKey(KEY_ENTER_COMMAND, "Enter"),
93
+ onKey(KEY_TAB_COMMAND, "Tab"),
94
+ onKey(KEY_ARROW_UP_COMMAND, "ArrowUp"),
95
+ onKey(KEY_ARROW_DOWN_COMMAND, "ArrowDown"),
96
+ onKey(KEY_ESCAPE_COMMAND, "Escape"),
97
+ editor.registerCommand(
98
+ PASTE_COMMAND,
99
+ (event) => {
100
+ const files = [...(("clipboardData" in event ? event.clipboardData?.files : null) ?? [])];
101
+ if (!files.length) return false;
102
+ event.preventDefault();
103
+ live.current.onFiles(files);
104
+ return true;
105
+ },
106
+ COMMAND_PRIORITY_HIGH,
107
+ ),
108
+ ];
109
+ if (live.current.draft) editor.update(() => MentionDraft.write(live.current.draft), { discrete: true });
110
+ return () => {
111
+ for (const teardown of teardowns) teardown();
112
+ editor.setRootElement(null);
113
+ };
114
+ }, [editor]);
115
+ useEffect(() => {
116
+ if (draft === settled.current) return;
117
+ settled.current = draft;
118
+ editor.update(() => MentionDraft.write(draft), { discrete: true });
119
+ }, [draft, editor]);
120
+ useEffect(() => {
121
+ if (!handleRef) return;
122
+ handleRef.current = {
123
+ focus: () => editor.focus(),
124
+ caret: () => editor.getEditorState().read(() => MentionDraft.caret()),
125
+ setCaret: (at) => {
126
+ editor.update(() => MentionDraft.place(at), { discrete: true });
127
+ editor.focus();
128
+ },
129
+ };
130
+ return () => {
131
+ handleRef.current = null;
132
+ };
133
+ }, [editor, handleRef]);
134
+ return (
135
+ <div className="relative flex-1">
136
+ <div
137
+ aria-label={placeholder}
138
+ aria-multiline
139
+ className={cn("max-h-32 overflow-y-auto whitespace-pre-wrap break-words outline-none", className)}
140
+ contentEditable
141
+ ref={rootRef}
142
+ role="textbox"
143
+ suppressContentEditableWarning
144
+ />
145
+ {draft ? null : (
146
+ <span className="pointer-events-none absolute inset-0 select-none px-3 py-1.5 text-foreground/40 text-sm">
147
+ {placeholder}
148
+ </span>
149
+ )}
150
+ </div>
151
+ );
152
+ };
153
+
154
+ export default RichInput;
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { cn, usePage } from "akanjs/client";
3
+ import type { PendingCard } from "../../vendor/use-agentic";
4
+ import { createOverridable } from "../UiOverride";
5
+
6
+ export interface ToolCardProps {
7
+ className?: string;
8
+ card: PendingCard;
9
+ }
10
+
11
+ /**
12
+ * A call the user answers, parked above the composer where the approval and the question cards sit. What it draws
13
+ * is the app's own — the declaration that published the tool also said what filling it in looks like — so this
14
+ * frame owns only the placement and the way out of it.
15
+ *
16
+ * The way out is not the app's to forget: a card that renders no cancel of its own would otherwise park the turn
17
+ * on a component the user cannot dismiss, so the frame always draws one.
18
+ */
19
+ export const DefaultToolCard = ({ className, card }: ToolCardProps) => {
20
+ const { l } = usePage();
21
+ return (
22
+ <div
23
+ aria-label={l("base.agentToolCard")}
24
+ className={cn("flex flex-col gap-2 border-primary/30 border-t bg-primary/5 px-4 py-3", className)}
25
+ role="group"
26
+ >
27
+ {card.render({ args: card.args, submit: card.submit, cancel: card.dismiss })}
28
+ <button
29
+ className="self-end text-foreground/50 text-xs hover:text-foreground"
30
+ onClick={() => card.dismiss()}
31
+ type="button"
32
+ >
33
+ {l("base.skip")}
34
+ </button>
35
+ </div>
36
+ );
37
+ };
38
+
39
+ export default createOverridable("AgentToolCard", DefaultToolCard);
@@ -1,49 +1,39 @@
1
1
  "use client";
2
+ import { type MarkdownSpan, MarkdownSpans } from "akanjs/common";
2
3
  import type { ReactNode } from "react";
3
4
 
4
- const inline =
5
- /(!?)\[([^\]]*)\]\(((?:[^\s()]|\([^\s()]*\))+)\)|`([^`]+)`|\*\*([\s\S]+?)\*\*|\*([^*\n]+?)\*|~~([\s\S]+?)~~/g;
5
+ /** The browser rendering of {@link MarkdownSpans}; the scanner, and which hrefs are refused, live there. */
6
+ export const spans = (text: string): ReactNode[] => MarkdownSpans.of(text).map((span, at) => node(span, at));
6
7
 
7
- const isSafeHref = (href: string) => !/^[a-z][a-z0-9+.-]*:/i.test(href) || /^(?:https?|mailto|tel):/i.test(href);
8
-
9
- export const spans = (text: string): ReactNode[] => {
10
- const nodes: ReactNode[] = [];
11
- let cut = 0;
12
- for (const match of text.matchAll(inline)) {
13
- const at = match.index;
14
- const [, image, label, href, code, strong, em, del] = match;
15
- if (at > cut) nodes.push(text.slice(cut, at));
16
- cut = at + match[0].length;
17
- if (href)
18
- nodes.push(
19
- image || !isSafeHref(href) ? (
20
- label
21
- ) : (
22
- <a className="underline" href={href} key={at} rel="noreferrer" target="_blank">
23
- {spans(label)}
24
- </a>
25
- ),
8
+ const node = (span: MarkdownSpan, at: number): ReactNode => {
9
+ switch (span.kind) {
10
+ case "link":
11
+ return (
12
+ <a className="underline" href={span.href} key={at} rel="noreferrer" target="_blank">
13
+ {spans(span.text)}
14
+ </a>
26
15
  );
27
- else if (code)
28
- nodes.push(
16
+ case "code":
17
+ return (
29
18
  <code className="rounded-field bg-muted px-1 font-mono text-[11px]" key={at}>
30
- {code}
31
- </code>,
19
+ {span.text}
20
+ </code>
32
21
  );
33
- else if (strong)
34
- nodes.push(
22
+ case "strong":
23
+ return (
35
24
  <strong className="font-semibold" key={at}>
36
- {spans(strong)}
37
- </strong>,
25
+ {spans(span.text)}
26
+ </strong>
38
27
  );
39
- else if (em) nodes.push(<em key={at}>{spans(em)}</em>);
40
- else if (del)
41
- nodes.push(
28
+ case "em":
29
+ return <em key={at}>{spans(span.text)}</em>;
30
+ case "del":
31
+ return (
42
32
  <del className="opacity-60" key={at}>
43
- {spans(del)}
44
- </del>,
33
+ {span.text}
34
+ </del>
45
35
  );
36
+ default:
37
+ return span.text;
46
38
  }
47
- if (cut < text.length) nodes.push(text.slice(cut));
48
- return nodes;
49
39
  };