@kywk/open-slide-core 2.3.1 → 2.3.2

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 (50) hide show
  1. package/dist/{build-CZ6Qtrng.js → build-46dO14h1.js} +1 -1
  2. package/dist/cli/bin.js +4 -4
  3. package/dist/{config-CpmGqpQi.d.ts → config-CDrYgaEj.d.ts} +6 -2
  4. package/dist/{config-B5LArqpp.js → config-KyYvWqq-.js} +2061 -355
  5. package/dist/{dev-CCgdakbZ.js → dev-C0n-o0-c.js} +2 -2
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +73 -32
  8. package/dist/locale/index.d.ts +1 -1
  9. package/dist/locale/index.js +1 -1
  10. package/dist/{preview-AwAqvG17.js → preview-CDWAGSfB.js} +1 -1
  11. package/dist/{types-CvAqFqsY.d.ts → types-DFK02XVv.d.ts} +110 -0
  12. package/dist/{update-C_RlnKu4.js → update-CqZqQRQk.js} +128 -7
  13. package/dist/vite/index.d.ts +1 -1
  14. package/dist/vite/index.js +1 -1
  15. package/dist/{zh-tw-B7_Oqv2q.js → zh-tw-B1pkKQsc.js} +535 -0
  16. package/package.json +9 -2
  17. package/src/agent/prompt.ts +141 -0
  18. package/src/agent/provider.ts +123 -0
  19. package/src/agent/types.ts +151 -0
  20. package/src/app/components/agent/agent-dock.tsx +35 -0
  21. package/src/app/components/agent/agent-markdown.tsx +45 -0
  22. package/src/app/components/agent/agent-panel.tsx +692 -0
  23. package/src/app/components/agent/agent-presence.tsx +74 -0
  24. package/src/app/components/agent/composer.tsx +120 -0
  25. package/src/app/components/agent/connect-view.tsx +184 -0
  26. package/src/app/components/agent/history-view.tsx +135 -0
  27. package/src/app/components/agent/provider-logo.tsx +34 -0
  28. package/src/app/components/agent/transcript.tsx +609 -0
  29. package/src/app/components/command/slide-command-menu.tsx +10 -0
  30. package/src/app/components/inline-svg.tsx +52 -21
  31. package/src/app/components/inspector/comment-layer.tsx +20 -1
  32. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  33. package/src/app/components/inspector/inspector-panel.tsx +1 -1
  34. package/src/app/components/mermaid.tsx +42 -11
  35. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  36. package/src/app/components/panel/panel-shell.tsx +2 -2
  37. package/src/app/components/panel/resize-handle.tsx +129 -0
  38. package/src/app/lib/agent/use-agent.ts +543 -0
  39. package/src/app/lib/use-auto-grow.ts +15 -0
  40. package/src/app/lib/utils.ts +4 -0
  41. package/src/app/routes/home.tsx +43 -7
  42. package/src/app/routes/slide.tsx +103 -104
  43. package/src/app/styles.css +170 -0
  44. package/src/editing/placeholder-id.ts +1 -0
  45. package/src/locale/en.ts +106 -0
  46. package/src/locale/es.ts +108 -0
  47. package/src/locale/ja.ts +99 -0
  48. package/src/locale/types.ts +109 -0
  49. package/src/locale/zh-cn.ts +96 -0
  50. package/src/locale/zh-tw.ts +96 -0
@@ -0,0 +1,141 @@
1
+ import { UNTITLED_SLIDE_ID_RE } from '../editing/placeholder-id.ts';
2
+ import { parseQuestions } from './provider.ts';
3
+ import type { AgentQuestion, AgentTurnContext } from './types.ts';
4
+
5
+ const PAGE_BY_PAGE =
6
+ 'Write a new deck page by page so the user can follow along on the canvas: first replace the file with the imports, meta (keeping its existing createdAt), design and the cover page only, then add each remaining page with its own separate edit, keeping the file compiling after every write.';
7
+
8
+ const DECK_SLUG =
9
+ "This deck's folder still has a placeholder name. Never rename or move the folder yourself: once the deck has content, end your final reply with a slug for it on a line of its own as <deck-slug>short-kebab-case-english-slug</deck-slug>, and open-slide renames the folder from it.";
10
+
11
+ export function systemPromptAppend(askInstruction: string): string {
12
+ return [
13
+ 'You are running inside the open-slide dev server, driven from its in-app agent panel in the browser rather than a terminal.',
14
+ 'The workspace is an open-slide project: decks live under slides/<id>/index.tsx and the dev server hot-reloads every edit, so the user sees changes immediately.',
15
+ 'Use the project skills: create-slide to draft a new deck, apply-comments to resolve @slide-comment markers left by the inspector, create-theme for themes, and slide-authoring as the technical reference before writing a page.',
16
+ PAGE_BY_PAGE,
17
+ 'Keep replies short and concrete: the panel is narrow. Prefer doing the work over describing it, and finish with a one-line summary of what changed.',
18
+ askInstruction,
19
+ ].join(' ');
20
+ }
21
+
22
+ export function buildTurnPrompt(text: string, context: AgentTurnContext | undefined): string {
23
+ const lines = contextLines(context);
24
+ if (lines.length === 0) return text;
25
+ return `${text}\n\n<open-slide-context>\n${lines.join('\n')}\n</open-slide-context>`;
26
+ }
27
+
28
+ // Slide titles and selected text come from deck content, so they sit in their
29
+ // own tags with `<` escaped and can't close the context block.
30
+ const escapeTag = (s: string) => s.replace(/</g, '&lt;');
31
+
32
+ function contextLines(context: AgentTurnContext | undefined): string[] {
33
+ if (!context?.slideId) return [];
34
+ const file = `slides/${context.slideId}/index.tsx`;
35
+ const lines: string[] = [];
36
+ if (context.newDeck) {
37
+ lines.push(
38
+ `${file} is a blank deck created for this request. Draft the deck the user describes into it with the create-slide skill: replace the placeholder page, keep the slide id, and do not create another folder.`,
39
+ 'Ask the create-slide scoping questions in a single round, only for what the request leaves unclear; skip any it already answers.',
40
+ PAGE_BY_PAGE,
41
+ DECK_SLUG,
42
+ );
43
+ return lines;
44
+ }
45
+ lines.push(`The user is viewing slide "${context.slideId}": ${file}.`);
46
+ if (UNTITLED_SLIDE_ID_RE.test(context.slideId)) lines.push(DECK_SLUG);
47
+ if (context.slideTitle) {
48
+ lines.push(`<slide-title>${escapeTag(context.slideTitle)}</slide-title>`);
49
+ }
50
+ if (context.pageNumber && context.totalPages) {
51
+ lines.push(
52
+ `They are on page ${context.pageNumber} of ${context.totalPages} (0-based index ${context.pageNumber - 1} in the default export array).`,
53
+ );
54
+ }
55
+ if (context.selection) {
56
+ const { line, column, tagName, text } = context.selection;
57
+ lines.push(
58
+ `They have selected a <${escapeTag(tagName)}> element at ${file}:${line}:${column}.${text ? ' Its text is in <selected-text>.' : ''} "This element" refers to it.`,
59
+ );
60
+ if (text) lines.push(`<selected-text>${escapeTag(text)}</selected-text>`);
61
+ }
62
+ if (context.pendingComments) {
63
+ lines.push(
64
+ `There are ${context.pendingComments} pending @slide-comment markers in this slide's source.`,
65
+ );
66
+ }
67
+ return lines;
68
+ }
69
+
70
+ const DECK_SLUG_RE = /\s*<deck-slug>\s*([^<]*?)\s*<\/deck-slug>\s*/gi;
71
+
72
+ export function extractDeckSlug(text: string): string | null {
73
+ let slug: string | null = null;
74
+ for (const match of text.matchAll(DECK_SLUG_RE)) slug = match[1] || slug;
75
+ return slug;
76
+ }
77
+
78
+ // Also drops a tag that is still streaming in, so the marker never flashes
79
+ // in the transcript.
80
+ export function stripDeckSlug(text: string): string {
81
+ return text
82
+ .replace(DECK_SLUG_RE, '\n')
83
+ .replace(/\s*<deck-slug>[^<]*$/i, '')
84
+ .trimEnd();
85
+ }
86
+
87
+ export function applyCommentsPrompt(slideId: string): string {
88
+ return `Use the apply-comments skill to apply every pending @slide-comment marker in slides/${slideId}/index.tsx, then remove the markers.`;
89
+ }
90
+
91
+ const QUESTIONS_RE = /<open-slide-questions>([\s\S]*?)<\/open-slide-questions>/i;
92
+
93
+ export const TEXT_QUESTIONS_INSTRUCTION =
94
+ 'You cannot ask the user anything interactively here, and nothing you print in the middle of a turn can be answered. When you need answers before continuing, stop working and end your turn with a final message holding only an <open-slide-questions> block: a JSON array of at most 4 questions shaped like [{"header":"Pages","question":"How many pages?","multiSelect":false,"options":[{"label":"6-10 (Recommended)","description":"Standard length"}]}], each with 2 to 4 options, written in the user\'s language. The panel shows them as a form and sends the answers back as your next message. Write nothing after the block and do not continue the task in the same turn.';
95
+
96
+ // Splits a reply written under TEXT_QUESTIONS_INSTRUCTION into its prose and
97
+ // questions. Returns null when the reply holds no usable block.
98
+ export function extractQuestionBlock(
99
+ text: string,
100
+ ): { text: string; questions: AgentQuestion[] } | null {
101
+ const match = QUESTIONS_RE.exec(text);
102
+ if (!match) return null;
103
+ const body = match[1]
104
+ .trim()
105
+ .replace(/^```(?:json)?\s*/i, '')
106
+ .replace(/\s*```$/, '');
107
+ let raw: unknown;
108
+ try {
109
+ raw = JSON.parse(body);
110
+ } catch {
111
+ return null;
112
+ }
113
+ const questions = parseQuestions({
114
+ questions: Array.isArray(raw) ? raw : (raw as { questions?: unknown } | null)?.questions,
115
+ });
116
+ if (questions.length === 0) return null;
117
+ return { text: text.replace(QUESTIONS_RE, '').trim(), questions };
118
+ }
119
+
120
+ export function answersPrompt(
121
+ questions: AgentQuestion[],
122
+ answers: Record<string, string> | null,
123
+ ): string {
124
+ if (!answers) {
125
+ return 'I skipped your questions. Continue the task with your best judgement and sensible defaults.';
126
+ }
127
+ const lines = questions.map((q) => `- ${q.question} ${answers[q.question] || '(no answer)'}`);
128
+ return `My answers:\n${lines.join('\n')}\n\nContinue the task with these answers.`;
129
+ }
130
+
131
+ // For agents whose history can't be rewound: the edited message goes on top
132
+ // of the old conversation, so the agent is told to set that part aside.
133
+ export function editedPrompt(original: string, text: string): string {
134
+ return `I went back and edited an earlier message of mine. Treat everything after it in this conversation, including your replies, as discarded, and respond to the edited version instead. Files keep whatever changes were already made.\n\n<original-message>\n${escapeTag(original)}\n</original-message>\n\n${text}`;
135
+ }
136
+
137
+ // For agents rewound to before the edited message: their history forgets the
138
+ // later turns, but the files on disk still hold what those turns wrote.
139
+ export function rewoundPrompt(text: string): string {
140
+ return `${text}\n\n<open-slide-note>I went back and edited this message. Files keep the changes made after it in the discarded part of this conversation, so read the current files before editing them.</open-slide-note>`;
141
+ }
@@ -0,0 +1,123 @@
1
+ import path from 'node:path';
2
+ import type { AgentEffort, AgentModelOption, AgentProviderId, AgentQuestion } from './types.ts';
3
+
4
+ export type ProviderEvent =
5
+ | { type: 'session-id'; id: string }
6
+ // A point in the provider's own history that a later edit can rewind to.
7
+ | { type: 'checkpoint'; id: string }
8
+ | { type: 'text-start'; id: string }
9
+ | { type: 'text-delta'; id: string; text: string }
10
+ | { type: 'text-end'; id: string; text: string | null }
11
+ | { type: 'tool-start'; id: string; name: string; title: string; detail: string | null }
12
+ | { type: 'tool-end'; id: string; ok: boolean }
13
+ | { type: 'error'; text: string }
14
+ | { type: 'result'; durationMs: number | null; costUsd: number | null }
15
+ // Questions the agent wrote into its reply instead of asking through a
16
+ // tool: the turn ends, and the answers arrive as the next prompt.
17
+ | { type: 'questions'; questions: AgentQuestion[] };
18
+
19
+ export type PermissionRequest = { tool: string; title: string; detail: string | null };
20
+
21
+ export type ProviderRunOptions = {
22
+ cwd: string;
23
+ // Directories the agent may write to without asking: the deck and asset
24
+ // folders. Edits anywhere else (configs, package.json) need approval.
25
+ writableRoots: string[];
26
+ prompt: string;
27
+ systemPromptAppend: string;
28
+ resume: string | null;
29
+ resumeAt: string | null;
30
+ model: string | null;
31
+ effort: AgentEffort | null;
32
+ signal: AbortSignal;
33
+ emit: (event: ProviderEvent) => void;
34
+ askUser: (questions: AgentQuestion[]) => Promise<Record<string, string> | null>;
35
+ requestPermission: (request: PermissionRequest) => Promise<boolean>;
36
+ };
37
+
38
+ export interface AgentProvider {
39
+ id: AgentProviderId;
40
+ name: string;
41
+ sdkPackage: string;
42
+ cliCommand: string;
43
+ askInstruction: string;
44
+ canRewind: boolean;
45
+ models(): Promise<AgentModelOption[]>;
46
+ run(opts: ProviderRunOptions): Promise<void>;
47
+ }
48
+
49
+ export class ProviderUnavailableError extends Error {
50
+ constructor(provider: AgentProvider) {
51
+ super(
52
+ `${provider.name} is not connected: install ${provider.sdkPackage} in this workspace to use it from the agent panel.`,
53
+ );
54
+ this.name = 'ProviderUnavailableError';
55
+ }
56
+ }
57
+
58
+ const DETAIL_MAX = 400;
59
+
60
+ export function clipDetail(value: unknown): string | null {
61
+ if (value === undefined || value === null) return null;
62
+ const text = typeof value === 'string' ? value : safeJson(value);
63
+ if (!text) return null;
64
+ const oneLine = text.replace(/\s+/g, ' ').trim();
65
+ return oneLine.length > DETAIL_MAX ? `${oneLine.slice(0, DETAIL_MAX - 1)}…` : oneLine;
66
+ }
67
+
68
+ function safeJson(value: unknown): string {
69
+ try {
70
+ return JSON.stringify(value);
71
+ } catch {
72
+ return String(value);
73
+ }
74
+ }
75
+
76
+ export function isInsideRoots(cwd: string, file: unknown, roots: string[]): boolean {
77
+ if (typeof file !== 'string' || !file) return false;
78
+ const resolved = path.resolve(cwd, file);
79
+ return roots.some((root) => {
80
+ const rel = path.relative(root, resolved);
81
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
82
+ });
83
+ }
84
+
85
+ export function relativeToCwd(cwd: string, file: unknown): string | null {
86
+ if (typeof file !== 'string' || !file) return null;
87
+ const normalizedCwd = cwd.replace(/[\\/]+$/, '');
88
+ if (file.startsWith(normalizedCwd)) {
89
+ const rest = file.slice(normalizedCwd.length).replace(/^[\\/]+/, '');
90
+ return rest || '.';
91
+ }
92
+ return file;
93
+ }
94
+
95
+ export function parseQuestions(input: Record<string, unknown>): AgentQuestion[] {
96
+ const raw = Array.isArray(input.questions) ? input.questions : [];
97
+ const questions: AgentQuestion[] = [];
98
+ for (const q of raw) {
99
+ if (!q || typeof q !== 'object') continue;
100
+ const r = q as Record<string, unknown>;
101
+ const options = Array.isArray(r.options)
102
+ ? r.options
103
+ .map((o) => {
104
+ if (!o || typeof o !== 'object') return null;
105
+ const opt = o as Record<string, unknown>;
106
+ if (typeof opt.label !== 'string') return null;
107
+ return {
108
+ label: opt.label,
109
+ description: typeof opt.description === 'string' ? opt.description : undefined,
110
+ };
111
+ })
112
+ .filter((o): o is { label: string; description: string | undefined } => o !== null)
113
+ : [];
114
+ if (typeof r.question !== 'string') continue;
115
+ questions.push({
116
+ header: typeof r.header === 'string' ? r.header : '',
117
+ question: r.question,
118
+ options,
119
+ multiSelect: r.multiSelect === true,
120
+ });
121
+ }
122
+ return questions;
123
+ }
@@ -0,0 +1,151 @@
1
+ export const AGENT_PROVIDER_IDS = ['claude', 'codex'] as const;
2
+
3
+ export type AgentProviderId = (typeof AGENT_PROVIDER_IDS)[number];
4
+
5
+ export const AGENT_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
6
+
7
+ export type AgentEffort = (typeof AGENT_EFFORTS)[number];
8
+
9
+ export function isAgentProviderId(value: unknown): value is AgentProviderId {
10
+ return typeof value === 'string' && (AGENT_PROVIDER_IDS as readonly string[]).includes(value);
11
+ }
12
+
13
+ export function isAgentEffort(value: unknown): value is AgentEffort {
14
+ return typeof value === 'string' && (AGENT_EFFORTS as readonly string[]).includes(value);
15
+ }
16
+
17
+ export type AgentModelOption = {
18
+ // Empty when the option stands for the CLI's own default model.
19
+ id: string;
20
+ label: string;
21
+ efforts: AgentEffort[];
22
+ };
23
+
24
+ export type AgentProviderStatus = {
25
+ id: AgentProviderId;
26
+ name: string;
27
+ sdkPackage: string;
28
+ sdkVersion: string | null;
29
+ cliCommand: string;
30
+ cliFound: boolean;
31
+ cliVersion: string | null;
32
+ ready: boolean;
33
+ models: AgentModelOption[];
34
+ };
35
+
36
+ export type AgentStatus = {
37
+ enabled: boolean;
38
+ packageManager: string;
39
+ providers: AgentProviderStatus[];
40
+ };
41
+
42
+ type AgentQuestionOption = { label: string; description?: string };
43
+
44
+ export type AgentQuestion = {
45
+ header: string;
46
+ question: string;
47
+ options: AgentQuestionOption[];
48
+ multiSelect: boolean;
49
+ };
50
+
51
+ export type AgentItem =
52
+ | {
53
+ kind: 'user';
54
+ id: string;
55
+ ts: string;
56
+ text: string;
57
+ // The provider history point just before this message, so editing it
58
+ // can rewind the agent's conversation there.
59
+ resumeAt?: string;
60
+ newDeck?: boolean;
61
+ }
62
+ | { kind: 'assistant'; id: string; ts: string; text: string; streaming: boolean }
63
+ | {
64
+ kind: 'tool';
65
+ id: string;
66
+ ts: string;
67
+ name: string;
68
+ title: string;
69
+ detail: string | null;
70
+ status: 'running' | 'done' | 'error';
71
+ }
72
+ | {
73
+ kind: 'question';
74
+ id: string;
75
+ ts: string;
76
+ questions: AgentQuestion[];
77
+ answers: Record<string, string> | null;
78
+ status: 'pending' | 'answered' | 'cancelled';
79
+ }
80
+ | {
81
+ kind: 'permission';
82
+ id: string;
83
+ ts: string;
84
+ tool: string;
85
+ title: string;
86
+ detail: string | null;
87
+ status: 'pending' | 'allowed' | 'denied';
88
+ }
89
+ | { kind: 'error'; id: string; ts: string; text: string }
90
+ | {
91
+ kind: 'result';
92
+ id: string;
93
+ ts: string;
94
+ durationMs: number | null;
95
+ costUsd: number | null;
96
+ aborted: boolean;
97
+ };
98
+
99
+ type AgentSessionStatus = 'idle' | 'running';
100
+
101
+ export type AgentSessionSummary = {
102
+ id: string;
103
+ provider: AgentProviderId;
104
+ title: string;
105
+ createdAt: string;
106
+ updatedAt: string;
107
+ status: AgentSessionStatus;
108
+ slideId: string | null;
109
+ };
110
+
111
+ export type AgentSession = AgentSessionSummary & {
112
+ providerSessionId: string | null;
113
+ providerCheckpoint?: string | null;
114
+ // Set once the oldest items were dropped to stay under the item cap.
115
+ trimmed?: boolean;
116
+ items: AgentItem[];
117
+ };
118
+
119
+ export type AgentTurnContext = {
120
+ slideId?: string;
121
+ slideTitle?: string;
122
+ pageNumber?: number;
123
+ totalPages?: number;
124
+ pendingComments?: number;
125
+ selection?: { line: number; column: number; tagName: string; text: string } | null;
126
+ newDeck?: boolean;
127
+ };
128
+
129
+ export type RespondPayload =
130
+ | { answers: Record<string, string> }
131
+ | { allow: boolean }
132
+ | { cancel: true };
133
+
134
+ export type AgentStreamEvent =
135
+ | { type: 'session'; session: AgentSessionSummary }
136
+ | { type: 'session-removed'; sessionId: string }
137
+ | { type: 'item'; sessionId: string; item: AgentItem }
138
+ | { type: 'delta'; sessionId: string; itemId: string; text: string }
139
+ | { type: 'truncate'; sessionId: string; itemId: string }
140
+ | { type: 'slide-renamed'; from: string; to: string };
141
+
142
+ export function sessionSummary(session: AgentSession): AgentSessionSummary {
143
+ const {
144
+ providerSessionId: _p,
145
+ providerCheckpoint: _c,
146
+ trimmed: _t,
147
+ items: _i,
148
+ ...summary
149
+ } = session;
150
+ return summary;
151
+ }
@@ -0,0 +1,35 @@
1
+ import { Sparkles } from 'lucide-react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { useAgentDock, useAgentStatus } from '@/lib/agent/use-agent';
4
+ import { useLocale } from '@/lib/use-locale';
5
+ import { ProviderLogo } from './provider-logo';
6
+
7
+ export const AGENT_DOCK_W = 380;
8
+
9
+ export function AgentToggleButton() {
10
+ const t = useLocale();
11
+ const { open, toggle } = useAgentDock();
12
+ const { provider } = useAgentStatus();
13
+ if (import.meta.env.PROD) return null;
14
+ return (
15
+ <Button
16
+ size="sm"
17
+ variant={open ? 'default' : 'ghost'}
18
+ onClick={toggle}
19
+ aria-pressed={open}
20
+ aria-label={t.agent.openTitle}
21
+ title={t.agent.openTitle}
22
+ data-agent-ui
23
+ >
24
+ {provider ? (
25
+ <ProviderLogo provider={provider.id} className="size-3.5" />
26
+ ) : (
27
+ <Sparkles className="size-3.5" />
28
+ )}
29
+ <span className="hidden lg:inline">{t.agent.open}</span>
30
+ <kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] lg:inline">
31
+ A
32
+ </kbd>
33
+ </Button>
34
+ );
35
+ }
@@ -0,0 +1,45 @@
1
+ import { useMemo } from 'react';
2
+ import { NoteMarkdown } from '@/components/note-markdown';
3
+
4
+ type Segment = { type: 'text'; text: string } | { type: 'code'; code: string };
5
+
6
+ const FENCE = /^```[^\n]*\n([\s\S]*?)\n?```\s*$/m;
7
+
8
+ function splitFences(text: string): Segment[] {
9
+ const segments: Segment[] = [];
10
+ let rest = text;
11
+ while (rest) {
12
+ const m = FENCE.exec(rest);
13
+ if (!m) {
14
+ segments.push({ type: 'text', text: rest });
15
+ break;
16
+ }
17
+ const before = rest.slice(0, m.index);
18
+ if (before.trim()) segments.push({ type: 'text', text: before });
19
+ segments.push({ type: 'code', code: m[1] });
20
+ rest = rest.slice(m.index + m[0].length);
21
+ }
22
+ return segments;
23
+ }
24
+
25
+ export function AgentMarkdown({ text }: { text: string }) {
26
+ const segments = useMemo(() => splitFences(text), [text]);
27
+ return (
28
+ <div className="space-y-2 text-[12.5px] leading-[1.55] [&_ol]:my-1 [&_ul]:my-1">
29
+ {segments.map((segment, i) =>
30
+ segment.type === 'code' ? (
31
+ <pre
32
+ // biome-ignore lint/suspicious/noArrayIndexKey: static parse output, never reordered
33
+ key={i}
34
+ className="overflow-x-auto rounded-[6px] border border-hairline bg-muted/60 px-2.5 py-2 font-mono text-[11px] leading-relaxed"
35
+ >
36
+ <code>{segment.code}</code>
37
+ </pre>
38
+ ) : (
39
+ // biome-ignore lint/suspicious/noArrayIndexKey: static parse output, never reordered
40
+ <NoteMarkdown key={i} text={segment.text} />
41
+ ),
42
+ )}
43
+ </div>
44
+ );
45
+ }