akanjs 3.0.0-alpha.26 → 3.0.0-alpha.28

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.26",
3
+ "version": "3.0.0-alpha.28",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -39,7 +39,7 @@ export const SYSTEM_PAGE_STATUS_COPY = {
39
39
  } as const;
40
40
 
41
41
  export const SYSTEM_PAGE_STYLE = `
42
- :root { color-scheme: dark; --akan-primary: #ff493b; --akan-secondary: #2b2e33; --akan-accent: #d1a23b; --akan-foreground: #ffffff; --akan-background: #1a1a1a; --akan-base-200: #2a2a2a; --akan-error: #f02020; }
42
+ :root { color-scheme: dark; --akan-primary: #ff493b; --akan-secondary: #2b2e33; --akan-accent: #d1a23b; --akan-foreground: #ffffff; --akan-background: #1a1a1a; --akan-error: #f02020; }
43
43
  body { margin: 0; min-height: 100vh; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--akan-background); color: var(--akan-foreground); }
44
44
  a { color: inherit; }
45
45
  .akan-system-page { min-height: 100vh; display: grid; place-items: center; padding: 32px 18px; box-sizing: border-box; background: radial-gradient(circle at top left, rgba(255, 73, 59, 0.16), transparent 34%), linear-gradient(135deg, var(--akan-background), #111); }
@@ -32,7 +32,7 @@ export declare const SYSTEM_PAGE_STATUS_COPY: {
32
32
  readonly actionLabel: "Back to safety";
33
33
  };
34
34
  };
35
- export declare const SYSTEM_PAGE_STYLE = "\n:root { color-scheme: dark; --akan-primary: #ff493b; --akan-secondary: #2b2e33; --akan-accent: #d1a23b; --akan-foreground: #ffffff; --akan-background: #1a1a1a; --akan-base-200: #2a2a2a; --akan-error: #f02020; }\nbody { margin: 0; min-height: 100vh; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; background: var(--akan-background); color: var(--akan-foreground); }\na { color: inherit; }\n.akan-system-page { min-height: 100vh; display: grid; place-items: center; padding: 32px 18px; box-sizing: border-box; background: radial-gradient(circle at top left, rgba(255, 73, 59, 0.16), transparent 34%), linear-gradient(135deg, var(--akan-background), #111); }\n.akan-system-card { width: min(720px, 100%); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 32px; background: linear-gradient(145deg, rgba(42, 42, 42, 0.96), rgba(43, 46, 51, 0.78)); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.36); padding: clamp(28px, 6vw, 56px); }\n.akan-system-status { margin: 0 0 18px; font-weight: 800; font-size: clamp(4rem, 18vw, 8rem); line-height: 0.85; letter-spacing: -0.08em; color: var(--akan-primary); }\n.akan-system-eyebrow { margin: 0 0 10px; color: var(--akan-accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; }\n.akan-system-title { margin: 0; max-width: 12ch; font-size: clamp(2rem, 7vw, 4rem); line-height: 0.95; letter-spacing: -0.055em; color: var(--akan-foreground); }\n.akan-system-description { margin: 22px 0 0; max-width: 56ch; color: rgba(255, 255, 255, 0.76); font-size: 1.05rem; line-height: 1.75; }\n.akan-system-path { margin: 22px 0 0; overflow-wrap: anywhere; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; background: rgba(26, 26, 26, 0.72); padding: 12px 14px; color: rgba(255, 255, 255, 0.72); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.88rem; }\n.akan-system-actions { margin-top: 30px; display: flex; flex-wrap: wrap; gap: 12px; }\n.akan-system-action { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; border: 1px solid var(--akan-primary); border-radius: 999px; background: var(--akan-primary); box-shadow: 0 12px 34px rgba(255, 73, 59, 0.26); color: var(--akan-foreground); padding: 0 18px; font-weight: 800; text-decoration: none; }\n.akan-system-secondary { border: 1px solid rgba(255, 255, 255, 0.12); background: var(--akan-secondary); color: var(--akan-foreground); }\n.akan-system-details { margin-top: 28px; max-height: 260px; overflow: auto; border-radius: 18px; background: rgba(26, 26, 26, 0.82); border: 1px solid rgba(240, 32, 32, 0.22); padding: 16px; color: rgba(255, 255, 255, 0.78); font-size: 0.82rem; line-height: 1.55; white-space: pre-wrap; }\n";
35
+ export declare const SYSTEM_PAGE_STYLE = "\n:root { color-scheme: dark; --akan-primary: #ff493b; --akan-secondary: #2b2e33; --akan-accent: #d1a23b; --akan-foreground: #ffffff; --akan-background: #1a1a1a; --akan-error: #f02020; }\nbody { margin: 0; min-height: 100vh; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; background: var(--akan-background); color: var(--akan-foreground); }\na { color: inherit; }\n.akan-system-page { min-height: 100vh; display: grid; place-items: center; padding: 32px 18px; box-sizing: border-box; background: radial-gradient(circle at top left, rgba(255, 73, 59, 0.16), transparent 34%), linear-gradient(135deg, var(--akan-background), #111); }\n.akan-system-card { width: min(720px, 100%); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 32px; background: linear-gradient(145deg, rgba(42, 42, 42, 0.96), rgba(43, 46, 51, 0.78)); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.36); padding: clamp(28px, 6vw, 56px); }\n.akan-system-status { margin: 0 0 18px; font-weight: 800; font-size: clamp(4rem, 18vw, 8rem); line-height: 0.85; letter-spacing: -0.08em; color: var(--akan-primary); }\n.akan-system-eyebrow { margin: 0 0 10px; color: var(--akan-accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; }\n.akan-system-title { margin: 0; max-width: 12ch; font-size: clamp(2rem, 7vw, 4rem); line-height: 0.95; letter-spacing: -0.055em; color: var(--akan-foreground); }\n.akan-system-description { margin: 22px 0 0; max-width: 56ch; color: rgba(255, 255, 255, 0.76); font-size: 1.05rem; line-height: 1.75; }\n.akan-system-path { margin: 22px 0 0; overflow-wrap: anywhere; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; background: rgba(26, 26, 26, 0.72); padding: 12px 14px; color: rgba(255, 255, 255, 0.72); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.88rem; }\n.akan-system-actions { margin-top: 30px; display: flex; flex-wrap: wrap; gap: 12px; }\n.akan-system-action { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; border: 1px solid var(--akan-primary); border-radius: 999px; background: var(--akan-primary); box-shadow: 0 12px 34px rgba(255, 73, 59, 0.26); color: var(--akan-foreground); padding: 0 18px; font-weight: 800; text-decoration: none; }\n.akan-system-secondary { border: 1px solid rgba(255, 255, 255, 0.12); background: var(--akan-secondary); color: var(--akan-foreground); }\n.akan-system-details { margin-top: 28px; max-height: 260px; overflow: auto; border-radius: 18px; background: rgba(26, 26, 26, 0.82); border: 1px solid rgba(240, 32, 32, 0.22); padding: 16px; color: rgba(255, 255, 255, 0.78); font-size: 0.82rem; line-height: 1.55; white-space: pre-wrap; }\n";
36
36
  export declare function createSystemPageDocument(options: SystemPageOptions): ReactNode;
37
37
  export declare function getSystemPageErrorDetails(error: unknown): string;
38
38
  export declare function getSystemPageHomeHref({ pathname, i18n, basePaths, headerBasePath, }: SystemPageHomeHrefOptions): string;
@@ -0,0 +1,11 @@
1
+ interface MarkdownProps {
2
+ className?: string;
3
+ children: string;
4
+ }
5
+ /**
6
+ * The chat's own markdown renderer: React elements, never `dangerouslySetInnerHTML`, and no parser dependency.
7
+ * `Bun.markdown` is the obvious candidate and cannot serve this — it is a runtime API the bundler passes through
8
+ * verbatim, so it is undefined in the browser, and assistant text arrives here as SSE deltas the client accrues.
9
+ */
10
+ export default function Markdown({ className, children }: MarkdownProps): import("react/jsx-runtime").JSX.Element;
11
+ export {};
@@ -0,0 +1,34 @@
1
+ import { type TableBlock } from "./markdownTable.d.ts";
2
+ export interface MarkdownItem {
3
+ depth: number;
4
+ ordered: boolean;
5
+ num: number;
6
+ text: string;
7
+ }
8
+ export type MarkdownBlock = {
9
+ kind: "code";
10
+ text: string;
11
+ } | {
12
+ kind: "heading";
13
+ level: number;
14
+ text: string;
15
+ } | {
16
+ kind: "list";
17
+ items: MarkdownItem[];
18
+ } | {
19
+ kind: "quote";
20
+ text: string;
21
+ } | {
22
+ kind: "rule";
23
+ } | {
24
+ kind: "para";
25
+ text: string;
26
+ } | TableBlock;
27
+ /**
28
+ * Block scanner for assistant chat text — the CommonMark subset a model actually emits over this wire.
29
+ */
30
+ export declare class MarkdownBlocks {
31
+ #private;
32
+ static of(source: string): MarkdownBlock[];
33
+ constructor(source: string);
34
+ }
@@ -0,0 +1,2 @@
1
+ import type { ReactNode } from "react";
2
+ export declare const spans: (text: string) => ReactNode[];
@@ -0,0 +1,16 @@
1
+ export type Align = "left" | "center" | "right";
2
+ export interface TableBlock {
3
+ kind: "table";
4
+ aligns: (Align | null)[];
5
+ head: string[];
6
+ rows: string[][];
7
+ }
8
+ /** GFM table scan, kept out of the block scanner because the delimiter row makes it the one shape that has to
9
+ * read the line after the one it starts on. */
10
+ export declare class MarkdownTable {
11
+ #private;
12
+ static at(lines: string[], at: number): {
13
+ block: TableBlock;
14
+ next: number;
15
+ } | null;
16
+ }
@@ -18,6 +18,9 @@ interface AdminPanelProps<T extends string, State, Input, Full extends {
18
18
  [key: string]: any;
19
19
  };
20
20
  };
21
+ template?: any;
22
+ unit?: any;
23
+ view?: any;
21
24
  /** Summary column to query descriptor. Only a column this map names becomes a link to its filtered listing. */
22
25
  queryMap?: {
23
26
  [key: string]: unknown;
@@ -31,5 +34,5 @@ export default function AdminPanel<RefName extends string, State, Input, Full ex
31
34
  id: string;
32
35
  }, Light extends {
33
36
  id: string;
34
- }>({ slice, components, queryMap, summaryColumns, insightColumns, renderInsight, renderDashboard, ...props }: AdminPanelProps<RefName, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
37
+ }>({ slice, components, queryMap, template, unit, view, summaryColumns, insightColumns, renderInsight, renderDashboard, ...props }: AdminPanelProps<RefName, State, Input, Full, Light>): import("react/jsx-runtime").JSX.Element;
35
38
  export {};
@@ -13,7 +13,7 @@ export default function Approval({ className, approval }: ApprovalProps) {
13
13
  return (
14
14
  <div className={cn("flex flex-col gap-2 border-warning/30 border-t bg-warning/10 px-4 py-3", className)}>
15
15
  <p className="text-sm">{approval.message}</p>
16
- <pre className="max-h-24 overflow-auto rounded-field bg-base-100/60 p-2 font-mono text-[10px]">
16
+ <pre className="max-h-24 overflow-auto rounded-field bg-background/60 p-2 font-mono text-[10px]">
17
17
  {JSON.stringify(approval.args, null, 2)}
18
18
  </pre>
19
19
  <div className="flex justify-end gap-2">
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { cn } from "akanjs/client";
3
3
  import type { ChatMessage, ToolCallResult } from "../../vendor/use-agentic";
4
+ import Markdown from "./Markdown";
4
5
 
5
6
  interface BubbleProps {
6
7
  className?: string;
@@ -63,7 +64,7 @@ export default function Bubble({ className, message, results }: BubbleProps) {
63
64
  const isDrafting = !message.text && !message.toolCalls?.length && !message.error;
64
65
  return (
65
66
  <div className={cn("flex max-w-[85%] flex-col gap-1 self-start", className)}>
66
- {message.text ? <p className="whitespace-pre-wrap text-sm">{message.text}</p> : null}
67
+ {message.text ? <Markdown className="text-sm">{message.text}</Markdown> : null}
67
68
  {(message.toolCalls ?? []).map((call) => (
68
69
  <Row key={call.id} args={call.args} name={call.name} result={results?.get(call.id)} />
69
70
  ))}
package/ui/Agent/Chat.tsx CHANGED
@@ -3,7 +3,7 @@ import { cn, fetch, usePage } from "akanjs/client";
3
3
  import type { PromptResult } from "akanjs/signal";
4
4
  import { AgentContext, type AgentPrompt, AgentPrompts, ensureStoreSurface } from "akanjs/store";
5
5
  import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
6
- import { AiOutlineClear, AiOutlineClose, AiOutlineRobot } from "react-icons/ai";
6
+ import { AiOutlineClear, AiOutlineClose } from "react-icons/ai";
7
7
  import { type AgentRunner, AgentSession, SessionContext } from "../../vendor/use-agentic";
8
8
  import { Button } from "../Button";
9
9
  import { inputRecipe } from "../recipe";
@@ -26,6 +26,8 @@ export interface ChatProps {
26
26
  persist?: PersistOption;
27
27
  }
28
28
 
29
+ const isApplePlatform = () => /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
30
+
29
31
  /**
30
32
  * The user-facing half of the in-page agent: one floating chat wired to the same surface the dock inspects.
31
33
  * The conversation loop runs in this browser session — every tool call executes here, gated by the approval
@@ -63,10 +65,31 @@ export const DefaultChat = ({
63
65
  );
64
66
  const [open, setOpen] = useState(defaultOpen);
65
67
  const [draft, setDraft] = useState("");
68
+ const [hotkey, setHotkey] = useState<{ label: string; keys: string } | null>(null);
66
69
  const listRef = useRef<HTMLDivElement>(null);
70
+ const inputRef = useRef<HTMLInputElement>(null);
67
71
  useEffect(() => {
68
72
  listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
69
73
  }, [version, open]);
74
+ useEffect(() => {
75
+ const apple = isApplePlatform();
76
+ setHotkey(apple ? { label: "⌘ L", keys: "Meta+L" } : { label: "Ctrl+L", keys: "Control+L" });
77
+ const onKeyDown = (event: KeyboardEvent) => {
78
+ if (event.repeat || event.key.toLowerCase() !== "l" || event.shiftKey || event.altKey) return;
79
+ const chord = apple ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
80
+ if (!chord) return;
81
+ event.preventDefault();
82
+ setOpen(true);
83
+ inputRef.current?.focus();
84
+ };
85
+
86
+ window.addEventListener("keydown", onKeyDown, true);
87
+ return () => window.removeEventListener("keydown", onKeyDown, true);
88
+ }, []);
89
+ useEffect(() => {
90
+ if (!open) return;
91
+ inputRef.current?.focus();
92
+ }, [open]);
70
93
  const runPrompt = async (prompt: AgentPrompt, args: string[]) => {
71
94
  const usage = `/${prompt.name} ${prompt.args.map((arg) => `<${arg.name}>`).join(" ")}`.trim();
72
95
  if (args.length < prompt.args.filter((arg) => arg.required).length) {
@@ -121,16 +144,33 @@ export const DefaultChat = ({
121
144
  if (!open)
122
145
  return (
123
146
  <button
147
+ aria-keyshortcuts={hotkey?.keys}
124
148
  aria-label={l("base.agent")}
125
149
  data-agent-ui=""
126
150
  className={cn(
127
- "fixed right-4 bottom-4 z-50 flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105",
151
+ "group/agent fixed right-4 bottom-4 z-50 flex size-12 items-center justify-center rounded-full border border-primary/20 bg-primary/90 text-primary-foreground shadow-lg shadow-primary/30 transition-transform hover:scale-105",
128
152
  className,
129
153
  )}
130
154
  onClick={() => setOpen(true)}
131
155
  type="button"
132
156
  >
133
- <AiOutlineRobot className="text-2xl" />
157
+ {hotkey ? (
158
+ <kbd className="pointer-events-none absolute right-full mr-3 hidden rounded-lg border border-border bg-background px-2 py-0.5 font-mono text-foreground/50 text-xs opacity-0 shadow-sm group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100 md:block">
159
+ {hotkey.label}
160
+ </kbd>
161
+ ) : null}
162
+ <svg className="size-7" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
163
+ <path
164
+ fill="currentColor"
165
+ d="M40 18 C43.6 43.6 54.4 54.4 80 58 C54.4 61.6 43.6 72.4 40 98
166
+ C36.4 72.4 25.6 61.6 0 58 C25.6 54.4 36.4 43.6 40 18 Z"
167
+ />
168
+ <path
169
+ fill="currentColor"
170
+ d="M80 2 C81.8 14.8 87.2 20.2 100 22 C87.2 23.8 81.8 29.2 80 42
171
+ C78.2 29.2 72.8 23.8 60 22 C72.8 20.2 78.2 14.8 80 2 Z"
172
+ />
173
+ </svg>
134
174
  </button>
135
175
  );
136
176
  return (
@@ -142,7 +182,7 @@ export const DefaultChat = ({
142
182
  className,
143
183
  )}
144
184
  >
145
- <header className="flex items-center gap-2 border-base-content/5 border-b px-4 py-3">
185
+ <header className="flex items-center gap-2 border-foreground/5 border-b px-4 py-3">
146
186
  <span className="font-semibold text-sm">{title ?? l("base.agent")}</span>
147
187
  {session.isRunning ? <span className="size-2 animate-pulse rounded-full bg-primary" /> : null}
148
188
  <span className="ml-auto flex items-center gap-2">
@@ -175,7 +215,7 @@ export const DefaultChat = ({
175
215
  </div>
176
216
  {session.pendingApproval ? <Approval approval={session.pendingApproval} /> : null}
177
217
  {menu.length ? (
178
- <div className="flex max-h-40 flex-col overflow-y-auto border-base-content/5 border-t py-1">
218
+ <div className="flex max-h-40 flex-col overflow-y-auto border-foreground/5 border-t py-1">
179
219
  {menu.map((prompt) => (
180
220
  <button
181
221
  className="flex items-baseline gap-2 px-4 py-1.5 text-left hover:bg-muted"
@@ -196,10 +236,11 @@ export const DefaultChat = ({
196
236
  ))}
197
237
  </div>
198
238
  ) : null}
199
- <div className="flex items-center gap-2 border-base-content/5 border-t p-3">
239
+ <div className="flex items-center gap-2 border-foreground/5 border-t p-3">
200
240
  <input
201
241
  className={inputRecipe({ size: "sm" }, "flex-1")}
202
242
  onChange={(event) => setDraft(event.target.value)}
243
+ ref={inputRef}
203
244
  onKeyDown={(event) => {
204
245
  if (event.key !== "Enter" || event.nativeEvent.isComposing) return;
205
246
  event.preventDefault();
@@ -29,7 +29,7 @@ export default function Context({ className }: ContextProps) {
29
29
  Assemble
30
30
  </button>
31
31
  {shown ? (
32
- <pre className="mt-2 max-h-60 overflow-auto rounded-field bg-base-100/60 p-2 text-[10px] leading-tight">
32
+ <pre className="mt-2 max-h-60 overflow-auto rounded-field bg-background/60 p-2 text-[10px] leading-tight">
33
33
  {shown}
34
34
  </pre>
35
35
  ) : null}
package/ui/Agent/Dock.tsx CHANGED
@@ -41,7 +41,7 @@ export const Dock = ({ className, bridge, surface, open = false }: DockProps) =>
41
41
  <aside
42
42
  data-agent-ui=""
43
43
  className={cn(
44
- "fixed right-4 bottom-4 z-50 flex max-h-[70vh] w-80 flex-col gap-2 overflow-y-auto rounded-box border border-base-content/10 bg-base-100/95 p-3 shadow-lg",
44
+ "fixed right-4 bottom-4 z-50 flex max-h-[70vh] w-80 flex-col gap-2 overflow-y-auto rounded-box border border-foreground/10 bg-background/95 p-3 shadow-lg",
45
45
  className,
46
46
  )}
47
47
  >
@@ -0,0 +1,125 @@
1
+ "use client";
2
+ import { cn } from "akanjs/client";
3
+ import type { ReactNode } from "react";
4
+ import { type MarkdownBlock, MarkdownBlocks, type MarkdownItem } from "./markdownBlocks";
5
+ import { spans } from "./markdownSpans";
6
+ import type { Align, TableBlock } from "./markdownTable";
7
+
8
+ const alignClass: { [key in Align]: string } = { left: "text-left", center: "text-center", right: "text-right" };
9
+
10
+ interface ListProps {
11
+ className?: string;
12
+ items: MarkdownItem[];
13
+ }
14
+
15
+ /** Folds the scanner's flat depth-scored items back into real nested lists, so a nested run carries its own
16
+ * marker and leaves the outer numbering alone — a hidden `li` still advances an `ol`'s counter. */
17
+ const List = ({ className, items }: ListProps) => {
18
+ const rows: ReactNode[] = [];
19
+ for (let at = 0; at < items.length; ) {
20
+ const item = items[at];
21
+ const nested: MarkdownItem[] = [];
22
+ for (at += 1; at < items.length && items[at].depth > item.depth; at += 1) nested.push(items[at]);
23
+ rows.push(
24
+ <li key={at}>
25
+ {spans(item.text)}
26
+ {nested.length ? <List className="pl-4" items={nested} /> : null}
27
+ </li>,
28
+ );
29
+ }
30
+ const [first] = items;
31
+ return first.ordered ? (
32
+ <ol className={cn("list-inside list-decimal", className)} start={first.num}>
33
+ {rows}
34
+ </ol>
35
+ ) : (
36
+ <ul className={cn("list-inside list-disc", className)}>{rows}</ul>
37
+ );
38
+ };
39
+
40
+ interface TableProps {
41
+ block: TableBlock;
42
+ }
43
+
44
+ const Table = ({ block }: TableProps) => {
45
+ const cellClass = block.head.map((_, idx) => {
46
+ const align = block.aligns[idx];
47
+ return align ? alignClass[align] : "";
48
+ });
49
+ return (
50
+
51
+ <div className="overflow-x-auto">
52
+ <table className="min-w-full border-collapse">
53
+ <thead>
54
+ <tr className="bg-muted/40">
55
+ {block.head.map((cell, idx) => (
56
+ <th className={cn("border-base-content/10 border-b px-2 py-1 text-left", cellClass[idx])} key={idx}>
57
+ {spans(cell)}
58
+ </th>
59
+ ))}
60
+ </tr>
61
+ </thead>
62
+ <tbody>
63
+ {block.rows.map((row, rowIdx) => (
64
+ <tr key={rowIdx}>
65
+ {row.map((cell, idx) => (
66
+ <td className={cn("border-base-content/5 border-b px-2 py-1 text-left", cellClass[idx])} key={idx}>
67
+ {spans(cell)}
68
+ </td>
69
+ ))}
70
+ </tr>
71
+ ))}
72
+ </tbody>
73
+ </table>
74
+ </div>
75
+ );
76
+ };
77
+
78
+ interface BlockProps {
79
+ block: MarkdownBlock;
80
+ }
81
+
82
+ const Block = ({ block }: BlockProps) => {
83
+ switch (block.kind) {
84
+ case "code":
85
+ return (
86
+ <pre className="overflow-x-auto rounded-field bg-muted p-2 font-mono text-[11px] leading-tight">
87
+ {block.text}
88
+ </pre>
89
+ );
90
+ case "heading":
91
+ return <p className={cn("font-semibold", block.level <= 2 && "text-base")}>{spans(block.text)}</p>;
92
+ case "list":
93
+ return <List items={block.items} />;
94
+ case "table":
95
+ return <Table block={block} />;
96
+ case "quote":
97
+ return (
98
+ <blockquote className="border-foreground/20 border-l-2 pl-2 text-foreground/70">{spans(block.text)}</blockquote>
99
+ );
100
+ case "rule":
101
+ return <hr className="border-foreground/10" />;
102
+ default:
103
+ return <p>{spans(block.text)}</p>;
104
+ }
105
+ };
106
+
107
+ interface MarkdownProps {
108
+ className?: string;
109
+ children: string;
110
+ }
111
+
112
+ /**
113
+ * The chat's own markdown renderer: React elements, never `dangerouslySetInnerHTML`, and no parser dependency.
114
+ * `Bun.markdown` is the obvious candidate and cannot serve this — it is a runtime API the bundler passes through
115
+ * verbatim, so it is undefined in the browser, and assistant text arrives here as SSE deltas the client accrues.
116
+ */
117
+ export default function Markdown({ className, children }: MarkdownProps) {
118
+ return (
119
+ <div className={cn("flex flex-col gap-2 break-words", className)}>
120
+ {MarkdownBlocks.of(children).map((block, idx) => (
121
+ <Block block={block} key={idx} />
122
+ ))}
123
+ </div>
124
+ );
125
+ }
package/ui/Agent/Tool.tsx CHANGED
@@ -37,7 +37,7 @@ export default function Tool({ className, surface, tool, onRun }: ToolProps) {
37
37
  onRun();
38
38
  };
39
39
  return (
40
- <details className={cn("rounded-field bg-base-100/60 px-2 py-1", className)}>
40
+ <details className={cn("rounded-field bg-background/60 px-2 py-1", className)}>
41
41
  <summary className="flex cursor-pointer list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
42
42
  <span
43
43
  className={cn(
@@ -17,7 +17,7 @@ export default function Transcript({ className, calls }: TranscriptProps) {
17
17
  return (
18
18
  <div className={cn("flex flex-col gap-1", className)}>
19
19
  {calls.map((call, idx) => (
20
- <div className="flex flex-col rounded-field bg-base-100/60 px-2 py-1" key={idx}>
20
+ <div className="flex flex-col rounded-field bg-background/60 px-2 py-1" key={idx}>
21
21
  <div className="flex items-baseline gap-2">
22
22
  <span className="truncate font-mono text-xs">{call.name}</span>
23
23
  <span className="ml-auto shrink-0 text-[10px] text-foreground/40">
@@ -0,0 +1,135 @@
1
+ import { MarkdownTable, type TableBlock } from "./markdownTable";
2
+
3
+ export interface MarkdownItem {
4
+ depth: number;
5
+ ordered: boolean;
6
+ num: number;
7
+ text: string;
8
+ }
9
+
10
+ export type MarkdownBlock =
11
+ | { kind: "code"; text: string }
12
+ | { kind: "heading"; level: number; text: string }
13
+ | { kind: "list"; items: MarkdownItem[] }
14
+ | { kind: "quote"; text: string }
15
+ | { kind: "rule" }
16
+ | { kind: "para"; text: string }
17
+ | TableBlock;
18
+
19
+ const fence = /^ {0,3}(```|~~~)/;
20
+ const heading = /^ {0,3}(#{1,6})\s+(.*)$/;
21
+ const rule = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
22
+ const quote = /^ {0,3}> ?(.*)$/;
23
+ const bullet = /^(\s*)[-*+]\s+(.*)$/;
24
+ const ordered = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
25
+ const lazyLine = /^\s+\S/;
26
+
27
+ /**
28
+ * Block scanner for assistant chat text — the CommonMark subset a model actually emits over this wire.
29
+ */
30
+ export class MarkdownBlocks {
31
+ static of(source: string): MarkdownBlock[] {
32
+ return new MarkdownBlocks(source).#run();
33
+ }
34
+
35
+ readonly #lines: string[];
36
+ readonly #blocks: MarkdownBlock[] = [];
37
+ #at = 0;
38
+
39
+ constructor(source: string) {
40
+ this.#lines = source.replace(/\r\n?/g, "\n").split("\n");
41
+ }
42
+
43
+ #run() {
44
+ while (this.#at < this.#lines.length) {
45
+ const line = this.#lines[this.#at];
46
+ const head = heading.exec(line);
47
+ const fenced = fence.exec(line);
48
+ const table = MarkdownTable.at(this.#lines, this.#at);
49
+ if (!line.trim()) this.#at += 1;
50
+ else if (fenced) this.#code(fenced[1]);
51
+ else if (rule.test(line)) this.#take({ kind: "rule" });
52
+ else if (head) this.#take({ kind: "heading", level: head[1].length, text: head[2] });
53
+ else if (quote.test(line)) this.#quote();
54
+ else if (ordered.test(line)) this.#list(true);
55
+ else if (bullet.test(line)) this.#list(false);
56
+ else if (table) {
57
+ this.#blocks.push(table.block);
58
+ this.#at = table.next;
59
+ } else this.#para();
60
+ }
61
+ return this.#blocks;
62
+ }
63
+
64
+ #take(block: MarkdownBlock) {
65
+ this.#blocks.push(block);
66
+ this.#at += 1;
67
+ }
68
+
69
+ #code(marker: string) {
70
+ this.#at += 1;
71
+ const body: string[] = [];
72
+ while (this.#at < this.#lines.length) {
73
+ const line = this.#lines[this.#at];
74
+ this.#at += 1;
75
+ if (line.trimStart().startsWith(marker)) break;
76
+ body.push(line);
77
+ }
78
+ this.#blocks.push({ kind: "code", text: body.join("\n") });
79
+ }
80
+
81
+ #quote() {
82
+ const lines: string[] = [];
83
+ while (this.#at < this.#lines.length) {
84
+ const match = quote.exec(this.#lines[this.#at]);
85
+ if (!match) break;
86
+ lines.push(match[1]);
87
+ this.#at += 1;
88
+ }
89
+ this.#blocks.push({ kind: "quote", text: lines.join(" ").trim() });
90
+ }
91
+
92
+ #list(isOrdered: boolean) {
93
+ const items: MarkdownItem[] = [];
94
+ while (this.#at < this.#lines.length) {
95
+ const line = this.#lines[this.#at];
96
+ const item = MarkdownBlocks.#item(line, isOrdered);
97
+ const open = items.at(-1);
98
+ if (item) items.push(item);
99
+ else if (open && lazyLine.test(line)) open.text += ` ${line.trim()}`;
100
+ else break;
101
+ this.#at += 1;
102
+ }
103
+ this.#blocks.push({ kind: "list", items });
104
+ }
105
+
106
+ static #item(line: string, isOrdered: boolean): MarkdownItem | null {
107
+ const numbered = ordered.exec(line);
108
+ const bulleted = bullet.exec(line);
109
+ const other = isOrdered ? bulleted : numbered;
110
+ const match = (isOrdered ? numbered : bulleted) ?? (other?.[1].length ? other : null);
111
+ if (!match) return null;
112
+ const carriesNum = match === numbered;
113
+ return {
114
+ depth: Math.floor(match[1].length / 2),
115
+ ordered: carriesNum,
116
+ num: carriesNum ? Number(match[2]) : 0,
117
+ text: carriesNum ? match[3] : match[2],
118
+ };
119
+ }
120
+
121
+ #para() {
122
+ const lines: string[] = [];
123
+ while (this.#at < this.#lines.length) {
124
+ const line = this.#lines[this.#at];
125
+ if (!line.trim() || this.#opensBlock(line) || MarkdownTable.at(this.#lines, this.#at)) break;
126
+ lines.push(line.trim());
127
+ this.#at += 1;
128
+ }
129
+ this.#blocks.push({ kind: "para", text: lines.join(" ") });
130
+ }
131
+
132
+ #opensBlock(line: string) {
133
+ return [fence, heading, rule, quote, bullet, ordered].some((pattern) => pattern.test(line));
134
+ }
135
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import type { ReactNode } from "react";
3
+
4
+ const inline =
5
+ /(!?)\[([^\]]*)\]\(((?:[^\s()]|\([^\s()]*\))+)\)|`([^`]+)`|\*\*([\s\S]+?)\*\*|\*([^*\n]+?)\*|~~([\s\S]+?)~~/g;
6
+
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
+ ),
26
+ );
27
+ else if (code)
28
+ nodes.push(
29
+ <code className="rounded-field bg-muted px-1 font-mono text-[11px]" key={at}>
30
+ {code}
31
+ </code>,
32
+ );
33
+ else if (strong)
34
+ nodes.push(
35
+ <strong className="font-semibold" key={at}>
36
+ {spans(strong)}
37
+ </strong>,
38
+ );
39
+ else if (em) nodes.push(<em key={at}>{spans(em)}</em>);
40
+ else if (del)
41
+ nodes.push(
42
+ <del className="opacity-60" key={at}>
43
+ {spans(del)}
44
+ </del>,
45
+ );
46
+ }
47
+ if (cut < text.length) nodes.push(text.slice(cut));
48
+ return nodes;
49
+ };
@@ -0,0 +1,54 @@
1
+ export type Align = "left" | "center" | "right";
2
+
3
+ export interface TableBlock {
4
+ kind: "table";
5
+ aligns: (Align | null)[];
6
+ head: string[];
7
+ rows: string[][];
8
+ }
9
+
10
+ const cellSplit = /(?<!\\)\|/;
11
+ const delimiter = /^:?-+:?$/;
12
+
13
+ /** GFM table scan, kept out of the block scanner because the delimiter row makes it the one shape that has to
14
+ * read the line after the one it starts on. */
15
+ export class MarkdownTable {
16
+ static at(lines: string[], at: number): { block: TableBlock; next: number } | null {
17
+ const header = lines[at];
18
+ const aligns = MarkdownTable.#aligns(lines[at + 1]);
19
+ if (!header?.includes("|") || !aligns) return null;
20
+ const head = MarkdownTable.#cells(header);
21
+ const rows: string[][] = [];
22
+ let next = at + 2;
23
+ for (; next < lines.length; next += 1) {
24
+ const line = lines[next];
25
+ if (!line.trim() || !line.includes("|")) break;
26
+ const cells = MarkdownTable.#cells(line);
27
+
28
+ rows.push(head.map((_, idx) => cells[idx] ?? ""));
29
+ }
30
+ return { block: { kind: "table", aligns, head, rows }, next };
31
+ }
32
+
33
+ static #cells(line: string): string[] {
34
+ return line
35
+ .trim()
36
+ .replace(/^\|/, "")
37
+ .replace(/(?<!\\)\|$/, "")
38
+ .split(cellSplit)
39
+ .map((cell) => cell.trim().replace(/\\\|/g, "|"));
40
+ }
41
+
42
+ /** The delimiter row is what separates a table from a paragraph that merely contains a pipe, and from the
43
+ * `---` of a rule — which is why the pipe is required of it rather than of the header. */
44
+ static #aligns(line: string | undefined): (Align | null)[] | null {
45
+ if (!line?.includes("|")) return null;
46
+ const cells = MarkdownTable.#cells(line);
47
+ if (!cells.every((cell) => delimiter.test(cell))) return null;
48
+ return cells.map((cell) => {
49
+ const closed = cell.endsWith(":");
50
+ if (cell.startsWith(":")) return closed ? "center" : "left";
51
+ return closed ? "right" : null;
52
+ });
53
+ }
54
+ }
@@ -14,6 +14,9 @@ interface AdminPanelProps<T extends string, State, Input, Full extends { id: str
14
14
  Unit: { [key: string]: any };
15
15
  View: { [key: string]: any };
16
16
  };
17
+ template?: any;
18
+ unit?: any;
19
+ view?: any;
17
20
  /** Summary column to query descriptor. Only a column this map names becomes a link to its filtered listing. */
18
21
  queryMap?: { [key: string]: unknown };
19
22
  /** Keys of the app's `summary` state shown as dashboard tiles above the list. */
@@ -32,6 +35,9 @@ export default function AdminPanel<
32
35
  slice,
33
36
  components,
34
37
  queryMap,
38
+ template,
39
+ unit,
40
+ view,
35
41
  summaryColumns,
36
42
  insightColumns = [],
37
43
  renderInsight = ({ insight }) => (
@@ -52,9 +58,15 @@ export default function AdminPanel<
52
58
  ...props
53
59
  }: AdminPanelProps<RefName, State, Input, Full, Light>) {
54
60
  const { sliceName } = slice;
55
- const Unit = components.Unit.General as ((props: ModelProps<any, any>) => ReactNode) | undefined;
56
- const Template = components.Template.General as ((props: { [key in RefName]: Full }) => ReactNode) | undefined;
57
- const View = components.View.General as ComponentType<{ [key in RefName]: Full }> | undefined;
61
+ const Unit = (unit ?? components.Unit.General ?? components.Unit.Card ?? Object.values(components.Unit).at(0)) as
62
+ | ((props: ModelProps<any, any>) => ReactNode)
63
+ | undefined;
64
+ const Template = (template ?? components.Template.General ?? Object.values(components.Template).at(0)) as
65
+ | ((props: { [key in RefName]: Full }) => ReactNode)
66
+ | undefined;
67
+ const View = (view ?? components.View.General ?? Object.values(components.View).at(0)) as
68
+ | ComponentType<{ [key in RefName]: Full }>
69
+ | undefined;
58
70
  return (
59
71
  <Data.ListContainer
60
72
  slice={slice}