obsidian-catui-acp 0.1.0

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.
@@ -0,0 +1,677 @@
1
+ import { useChat } from "@ai-sdk/react";
2
+ import type { UIMessage } from "ai";
3
+ import ReactMarkdown from "react-markdown";
4
+ import remarkGfm from "remark-gfm";
5
+ import type { Components } from "react-markdown";
6
+ import {
7
+ ArrowUp,
8
+ Bug,
9
+ Check,
10
+ CheckCircle2,
11
+ ChevronDown,
12
+ ChevronRight,
13
+ Copy,
14
+ LoaderCircle,
15
+ Plus,
16
+ ShieldAlert,
17
+ Square,
18
+ X,
19
+ XCircle,
20
+ } from "lucide-react";
21
+ import { useEffect, useMemo, useRef, useState } from "react";
22
+ import type { MutableRefObject } from "react";
23
+ import { AcpChatTransport } from "../chat/acp-chat-transport";
24
+ import type CatuiAcpPlugin from "../main";
25
+ import type { CatuiConnectionStatus } from "../main";
26
+ import type { CatuiSessionSummary } from "../main";
27
+ import type { PermissionChoice } from "../policy/permission-policy";
28
+
29
+ import type { PluginDiagnostics } from "../main";
30
+
31
+ interface ChatAppProps {
32
+ plugin: CatuiAcpPlugin;
33
+ }
34
+
35
+ const buttonBase =
36
+ "catui-reset-button catui:inline-flex catui:h-8 catui:items-center catui:justify-center catui:gap-1.5 catui:rounded-catui-md catui:border catui:border-border catui:bg-bg catui:px-3 catui:text-[12px] catui:font-medium catui:leading-none catui:text-muted catui:transition-colors catui:duration-150 catui:hover:bg-hover catui:hover:text-normal catui:focus-visible:outline-none catui:focus-visible:ring-1 catui:focus-visible:ring-accent catui:disabled:cursor-not-allowed catui:disabled:opacity-50";
37
+ const buttonPrimary =
38
+ "catui:border-accent catui:bg-accent catui:text-on-accent catui:hover:bg-accent-hover catui:hover:text-on-accent";
39
+ const buttonDanger =
40
+ "catui:border-error/30 catui:text-error catui:hover:bg-error-bg catui:hover:text-error";
41
+
42
+ const reasoningPhrases = ["Thinking...", "Reading the vault...", "Working...", "Preparing response..."];
43
+
44
+ function humanizeToolState(state: string | undefined): string {
45
+ switch (state) {
46
+ case "input-streaming":
47
+ return "running";
48
+ case "input-available":
49
+ return "ready";
50
+ case "output-available":
51
+ return "done";
52
+ case "output-error":
53
+ return "error";
54
+ default:
55
+ return state ?? "idle";
56
+ }
57
+ }
58
+
59
+ const markdownComponents: Components = {
60
+ h1: (props) => <h1 className="catui:m-0 catui:mt-2 catui:text-[15px] catui:font-semibold catui:leading-6 catui:text-normal first:catui:mt-0" {...props} />,
61
+ h2: (props) => <h2 className="catui:m-0 catui:mt-2 catui:text-[14px] catui:font-semibold catui:leading-6 catui:text-normal first:catui:mt-0" {...props} />,
62
+ h3: (props) => <h3 className="catui:m-0 catui:mt-1.5 catui:text-[13px] catui:font-semibold catui:leading-5 catui:text-normal first:catui:mt-0" {...props} />,
63
+ h4: (props) => <h4 className="catui:m-0 catui:mt-1.5 catui:text-[13px] catui:font-semibold catui:leading-5 catui:text-muted first:catui:mt-0" {...props} />,
64
+ p: (props) => <p className="catui:m-0 first:catui:mt-0 last:catui:mb-0" {...props} />,
65
+ ul: (props) => <ul className="catui:m-0 catui:list-disc catui:pl-5" {...props} />,
66
+ ol: (props) => <ol className="catui:m-0 catui:list-decimal catui:pl-5" {...props} />,
67
+ li: (props) => <li className="catui:my-0.5 catui:leading-5" {...props} />,
68
+ blockquote: (props) => <blockquote className="catui:m-0 catui:border-l-2 catui:border-border catui:pl-2 catui:text-muted" {...props} />,
69
+ hr: () => <hr className="catui:my-2 catui:border-0 catui:border-t catui:border-border" />,
70
+ a: ({ href, children, ...rest }) => (
71
+ <a
72
+ className="catui:text-accent catui:underline catui:underline-offset-2 catui:decoration-accent/40 catui:transition catui:hover:decoration-accent catui:focus-visible:decoration-accent"
73
+ href={href}
74
+ rel="noopener noreferrer"
75
+ target="_blank"
76
+ {...rest}
77
+ >
78
+ {children}
79
+ </a>
80
+ ),
81
+ strong: (props) => <strong className="catui:font-semibold catui:text-normal" {...props} />,
82
+ em: (props) => <em className="catui:italic" {...props} />,
83
+ del: (props) => <del className="catui:text-muted catui:line-through" {...props} />,
84
+ code: ({ className, children, ...rest }) => {
85
+ const isBlock = /language-/.test(className ?? "");
86
+ if (isBlock) {
87
+ return (
88
+ <code
89
+ className={`catui:font-mono catui:text-[12px] catui:leading-5 ${className ?? ""}`}
90
+ {...rest}
91
+ >
92
+ {children}
93
+ </code>
94
+ );
95
+ }
96
+ return (
97
+ <code
98
+ className="catui:rounded-catui-xs catui:bg-hover catui:px-1 catui:py-0.5 catui:font-mono catui:text-[12px] catui:text-normal"
99
+ {...rest}
100
+ >
101
+ {children}
102
+ </code>
103
+ );
104
+ },
105
+ pre: (props) => (
106
+ <pre
107
+ className="catui:my-1.5 catui:max-h-[260px] catui:overflow-auto catui:rounded-catui-sm catui:border catui:border-border catui:bg-bg catui:p-2.5 catui:font-mono catui:text-[12px] catui:leading-5 catui:text-normal catui:whitespace-pre"
108
+ {...props}
109
+ />
110
+ ),
111
+ table: (props) => (
112
+ <div className="catui:my-1.5 catui:overflow-x-auto catui:rounded-catui-sm catui:border catui:border-border">
113
+ <table className="catui:w-full catui:border-collapse catui:text-[12px]" {...props} />
114
+ </div>
115
+ ),
116
+ thead: (props) => <thead className="catui:bg-panel catui:text-muted" {...props} />,
117
+ tbody: (props) => <tbody className="catui:divide-y catui:divide-border" {...props} />,
118
+ tr: (props) => <tr className="catui:border-b catui:border-border last:catui:border-b-0" {...props} />,
119
+ th: (props) => <th className="catui:px-2 catui:py-1.5 catui:text-left catui:font-semibold catui:text-normal" {...props} />,
120
+ td: (props) => <td className="catui:px-2 catui:py-1.5 catui:text-normal" {...props} />,
121
+ input: (props) => <input className="catui:mr-1 catui:size-3 catui:accent-accent" disabled {...props} />,
122
+ };
123
+
124
+ export function ChatApp({ plugin }: ChatAppProps) {
125
+ const transport = useMemo(() => new AcpChatTransport(plugin), [plugin]);
126
+ const chat = useChat({ transport, throttle: 40 });
127
+ const [input, setInput] = useState("");
128
+ const [status, setStatus] = useState(plugin.connectionStatus);
129
+ const [permission, setPermission] = useState(plugin.pendingPermission);
130
+ const [sessionState, setSessionState] = useState({ activeSessionId: plugin.activeSessionId, sessions: plugin.sessions });
131
+ const [diagnostics, setDiagnostics] = useState<PluginDiagnostics | null>(null);
132
+ const [showDiagnostics, setShowDiagnostics] = useState(false);
133
+ const inputRef = useRef<HTMLTextAreaElement | null>(null);
134
+ const lastActiveSessionIdRef = useRef<string | null>(plugin.activeSessionId);
135
+ const isBusy = chat.status === "submitted" || chat.status === "streaming";
136
+
137
+ useEffect(() => plugin.subscribeStatus(setStatus), [plugin]);
138
+ useEffect(() => plugin.subscribePermission(setPermission), [plugin]);
139
+ useEffect(() => plugin.subscribeSessions(setSessionState), [plugin]);
140
+ useEffect(() => plugin.subscribeDiagnostics(setDiagnostics), [plugin]);
141
+ useEffect(() => {
142
+ if (status === "Connected") void plugin.refreshSessions();
143
+ }, [plugin, status]);
144
+ useEffect(() => {
145
+ if (lastActiveSessionIdRef.current === sessionState.activeSessionId) return;
146
+ lastActiveSessionIdRef.current = sessionState.activeSessionId;
147
+ chat.setMessages([]);
148
+ setInput("");
149
+ if (inputRef.current) inputRef.current.value = "";
150
+ }, [chat, sessionState.activeSessionId]);
151
+
152
+ async function submit() {
153
+ const text = (input || inputRef.current?.value || "").trim();
154
+ if (!text || isBusy) return;
155
+ setInput("");
156
+ if (inputRef.current) inputRef.current.value = "";
157
+ await chat.sendMessage({ text });
158
+ }
159
+
160
+ return (
161
+ <div className="catui:flex catui:h-full catui:min-h-0 catui:flex-col catui:bg-bg catui:font-sans catui:text-normal">
162
+ <Conversation messages={chat.messages} error={chat.error?.message} busy={isBusy} />
163
+ {showDiagnostics && diagnostics ? <DiagnosticsPanel diagnostics={diagnostics} /> : null}
164
+ {permission ? <PermissionPanel detail={permission.detail} onChoice={(choice) => plugin.resolvePermission(choice)} title={permission.title} /> : null}
165
+ <PromptInput
166
+ activeSessionId={sessionState.activeSessionId}
167
+ busy={isBusy}
168
+ diagnostics={diagnostics}
169
+ input={input}
170
+ inputRef={inputRef}
171
+ onCancel={() => void chat.stop().then(() => plugin.cancelPrompt())}
172
+ onClose={(sessionId) => {
173
+ if (isBusy) return;
174
+ void plugin.closeSession(sessionId);
175
+ }}
176
+ onInput={setInput}
177
+ onNew={() => void plugin.startNewSession()}
178
+ onSelect={(sessionId) => {
179
+ if (sessionId === sessionState.activeSessionId || isBusy) return;
180
+ void plugin.loadSession(sessionId);
181
+ }}
182
+ onSubmit={() => void submit()}
183
+ onToggleDiagnostics={() => setShowDiagnostics((value) => !value)}
184
+ sessions={sessionState.sessions}
185
+ showDiagnostics={showDiagnostics}
186
+ status={status}
187
+ />
188
+ </div>
189
+ );
190
+ }
191
+
192
+ function Conversation({ busy, error, messages }: { busy: boolean; error?: string; messages: UIMessage[] }) {
193
+ return (
194
+ <main className="catui-conversation catui:relative catui:min-h-0 catui:flex-1 catui:overflow-y-auto">
195
+ <div className="catui:mx-auto catui:flex catui:w-full catui:max-w-[840px] catui:flex-col catui:gap-2 catui:px-3 catui:py-4">
196
+ {messages.length === 0 ? <EmptyState /> : messages.map((message) => <MessageView key={message.id} message={message} />)}
197
+ {busy ? <ThinkingIndicator /> : null}
198
+ {error ? <div className="catui:my-1 catui:rounded-catui-md catui:bg-error-bg catui:px-3 catui:py-2.5 catui:text-[13px] catui:leading-5 catui:text-error catui:shadow-catui-minimal">{error}</div> : null}
199
+ </div>
200
+ </main>
201
+ );
202
+ }
203
+
204
+ function EmptyState() {
205
+ return (
206
+ <div className="catui:pointer-events-none catui:absolute catui:inset-0 catui:flex catui:flex-col catui:items-center catui:justify-center catui:gap-1 catui:text-center catui:select-none">
207
+ <p className="catui:m-0 catui:text-[14px] catui:text-muted">Ask CatUI about this vault</p>
208
+ <p className="catui:m-0 catui:text-[12px] catui:text-faint">Start with a question or an edit</p>
209
+ </div>
210
+ );
211
+ }
212
+
213
+ function MessageView({ message }: { message: UIMessage }) {
214
+ const isUser = message.role === "user";
215
+ const text = message.parts
216
+ .filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
217
+ .map((part) => part.text)
218
+ .join("\n\n");
219
+ const activities = message.parts.filter((part) => part.type.startsWith("tool-") || part.type === "dynamic-tool" || part.type.startsWith("data-"));
220
+
221
+ if (isUser) {
222
+ return (
223
+ <article className="catui:flex catui:w-full catui:flex-col catui:items-end catui:gap-3 catui:pb-1 catui:pt-2">
224
+ <div className="catui:max-w-[80%] catui:min-w-0 catui:break-words catui:rounded-[16px] catui:bg-user-bubble catui:px-4 catui:py-2 catui:text-[14px] catui:leading-5 catui:text-normal catui:select-text">
225
+ <div className="catui-acp-markdown">
226
+ <ReactMarkdown components={markdownComponents} remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
227
+ </div>
228
+ </div>
229
+ </article>
230
+ );
231
+ }
232
+
233
+ return (
234
+ <article className="catui:flex catui:w-full catui:min-w-0 catui:flex-col catui:gap-1">
235
+ {activities.length > 0 ? <ActivitySection parts={activities} /> : null}
236
+ {text ? (
237
+ <div className="catui-response-card catui:group catui:overflow-hidden catui:rounded-catui-md catui:bg-bg catui:shadow-catui-minimal">
238
+ <div className="catui-acp-markdown catui:max-h-[560px] catui:overflow-y-auto catui:break-words catui:py-3 catui:pl-[22px] catui:pr-4 catui:text-[14px] catui:leading-5 catui:text-normal catui:select-text">
239
+ <ReactMarkdown components={markdownComponents} remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
240
+ </div>
241
+ </div>
242
+ ) : null}
243
+ </article>
244
+ );
245
+ }
246
+
247
+ function ActivitySection({ parts }: { parts: UIMessage["parts"] }) {
248
+ const [expanded, setExpanded] = useState(false);
249
+ const toolNames = parts.map((part) => {
250
+ const toolPart = part as { toolName?: string; type: string };
251
+ return toolPart.toolName ?? toolPart.type.replace(/^tool-/, "").replace(/^data-/, "");
252
+ });
253
+ const failureCount = parts.filter((part) => humanizeToolState((part as { state?: string }).state ?? part.type) === "error").length;
254
+ const preview = `${toolNames[toolNames.length - 1]}${failureCount ? ` · ${failureCount} failed` : ""}`;
255
+
256
+ return (
257
+ <section className="catui-activity catui:select-none">
258
+ <button
259
+ aria-expanded={expanded}
260
+ className="catui-reset-button catui:flex catui:w-full catui:items-center catui:gap-2 catui:rounded-catui-md catui:py-1.5 catui:pl-2.5 catui:pr-1.5 catui:text-left catui:text-[13px] catui:text-muted catui:transition-colors catui:hover:bg-hover catui:focus-visible:outline-none catui:focus-visible:ring-1 catui:focus-visible:ring-accent"
261
+ onClick={() => setExpanded((value) => !value)}
262
+ type="button"
263
+ >
264
+ <span className={`catui:flex catui:size-3 catui:shrink-0 catui:items-center catui:justify-center catui:transition-transform catui:duration-150 ${expanded ? "catui:rotate-90" : ""}`}>
265
+ <ChevronRight size={12} />
266
+ </span>
267
+ <span className="catui:-ml-0.5 catui:shrink-0 catui:rounded-catui-xs catui:bg-bg catui:px-1.5 catui:py-0.5 catui:text-[10px] catui:font-medium catui:tabular-nums catui:shadow-catui-minimal">{parts.length}</span>
268
+ <span className="catui:min-w-0 catui:flex-1 catui:truncate">{preview}</span>
269
+ </button>
270
+ <div className={`catui-activity-list ${expanded ? "catui-activity-list-open" : ""}`}>
271
+ <div className="catui:ml-[13px] catui:max-h-[360px] catui:space-y-0.5 catui:overflow-y-auto catui:border-l-2 catui:border-border catui:py-0 catui:pl-4 catui:pr-2">
272
+ {parts.map((part, index) => <ActivityRow key={index} part={part} />)}
273
+ </div>
274
+ </div>
275
+ </section>
276
+ );
277
+ }
278
+
279
+ function ActivityRow({ part }: { part: UIMessage["parts"][number] }) {
280
+ const toolPart = part as { errorText?: string; input?: unknown; output?: unknown; state?: string; toolName?: string; type: string };
281
+ const state = humanizeToolState(toolPart.state ?? toolPart.type);
282
+ const name = toolPart.toolName ?? toolPart.type.replace(/^tool-/, "");
283
+ const isRunning = state === "running" || state === "ready";
284
+ const isDone = state === "done";
285
+ const hasDetails = toolPart.input !== undefined || toolPart.output !== undefined || Boolean(toolPart.errorText) || part.type.startsWith("data-");
286
+
287
+ return (
288
+ <details className="catui-tool-part catui:min-w-0">
289
+ <summary className={`catui:flex catui:min-h-6 catui:list-none catui:items-center catui:gap-2 catui:py-0.5 catui:text-[13px] catui:text-muted ${hasDetails ? "catui:cursor-pointer catui:hover:text-normal" : "catui:cursor-default"}`}>
290
+ {isRunning ? <LoaderCircle className="catui:shrink-0 catui:animate-spin catui:text-accent" size={12} /> : state === "error" ? <XCircle className="catui:shrink-0 catui:text-error" size={12} /> : isDone ? <CheckCircle2 className="catui:shrink-0 catui:text-success" size={12} /> : <span className="catui:size-3 catui:shrink-0 catui:rounded-full catui:border catui:border-muted" />}
291
+ <span className="catui:min-w-0 catui:flex-1 catui:truncate">{name}</span>
292
+ <span className="catui:shrink-0 catui:text-[11px] catui:text-faint">{state}</span>
293
+ </summary>
294
+ {hasDetails ? <div className="catui:mb-1 catui:ml-5 catui:overflow-hidden catui:rounded-catui-sm catui:bg-foreground-subtle">
295
+ {toolPart.input ? <JsonBlock label="Input" value={toolPart.input} /> : null}
296
+ {toolPart.output ? <JsonBlock label="Output" value={toolPart.output} /> : null}
297
+ {part.type.startsWith("data-") ? <JsonBlock label="Data" value={part} /> : null}
298
+ {toolPart.errorText ? <pre className="catui:m-0 catui:max-h-[180px] catui:overflow-auto catui:border-t catui:border-border catui:p-2.5 catui:font-mono catui:text-[11px] catui:leading-4 catui:text-error catui:whitespace-pre-wrap">{toolPart.errorText}</pre> : null}
299
+ </div> : null}
300
+ </details>
301
+ );
302
+ }
303
+
304
+ function JsonBlock({ label, value }: { label: string; value: unknown }) {
305
+ return (
306
+ <div className="catui:border-b catui:border-border catui:last:border-b-0">
307
+ <div className="catui:px-2.5 catui:pt-2 catui:text-[10px] catui:font-medium catui:uppercase catui:text-faint">{label}</div>
308
+ <pre className="catui:m-0 catui:max-h-[180px] catui:overflow-auto catui:p-2.5 catui:pt-1 catui:font-mono catui:text-[11px] catui:leading-4 catui:text-muted catui:whitespace-pre-wrap">
309
+ {JSON.stringify(value, null, 2)}
310
+ </pre>
311
+ </div>
312
+ );
313
+ }
314
+
315
+ function ThinkingIndicator() {
316
+ const [phraseIndex, setPhraseIndex] = useState(0);
317
+ const [elapsed, setElapsed] = useState(0);
318
+
319
+ useEffect(() => {
320
+ const phraseTimer = window.setInterval(() => {
321
+ setPhraseIndex((current) => (current + 1) % reasoningPhrases.length);
322
+ }, 10000);
323
+ const elapsedTimer = window.setInterval(() => setElapsed((value) => value + 1), 1000);
324
+ return () => {
325
+ window.clearInterval(phraseTimer);
326
+ window.clearInterval(elapsedTimer);
327
+ };
328
+ }, []);
329
+
330
+ return (
331
+ <div className="catui:flex catui:items-center catui:gap-2 catui:-mb-1 catui:px-3 catui:py-1 catui:text-[13px] catui:text-muted" role="status">
332
+ <LoaderCircle aria-hidden="true" className="catui:animate-spin" size={12} />
333
+ <span className="catui-thinking-label" key={phraseIndex}>{reasoningPhrases[phraseIndex]}</span>
334
+ <span className="catui:text-faint catui:tabular-nums">{elapsed}s</span>
335
+ </div>
336
+ );
337
+ }
338
+
339
+ function PermissionPanel({ detail, onChoice, title }: { detail: string; onChoice: (choice: PermissionChoice) => void; title: string }) {
340
+ return (
341
+ <section className="catui:mx-2 catui:mb-1 catui:overflow-hidden catui:rounded-catui-md catui:border catui:border-info/30 catui:bg-info-bg catui:shadow-catui-middle">
342
+ <div className="catui:space-y-3 catui:p-4">
343
+ <div className="catui:flex catui:items-center catui:gap-2 catui:text-[14px] catui:font-medium catui:text-normal">
344
+ <ShieldAlert className="catui:shrink-0 catui:text-info" size={14} />
345
+ <span className="catui:min-w-0 catui:truncate">{title}</span>
346
+ </div>
347
+ <pre className="catui:m-0 catui:max-h-24 catui:overflow-auto catui:rounded-catui-md catui:bg-foreground-soft catui:p-3 catui:font-mono catui:text-[12px] catui:leading-[18px] catui:text-muted catui:whitespace-pre-wrap">{detail}</pre>
348
+ </div>
349
+ <div className="catui:grid catui:grid-cols-2 catui:gap-1.5 catui:border-t catui:border-border catui:px-3 catui:py-2">
350
+ <PermissionButton label="Allow once" onClick={() => onChoice("allow_once")} variant="primary" />
351
+ <PermissionButton label="Always allow" onClick={() => onChoice("allow_always")} />
352
+ <PermissionButton label="Reject once" onClick={() => onChoice("reject_once")} />
353
+ <PermissionButton label="Always reject" onClick={() => onChoice("reject_always")} variant="danger" />
354
+ </div>
355
+ </section>
356
+ );
357
+ }
358
+
359
+ function PermissionButton({ label, onClick, variant }: { label: string; onClick: () => void; variant?: "primary" | "danger" }) {
360
+ const variantClass =
361
+ variant === "primary" ? buttonPrimary : variant === "danger" ? buttonDanger : "";
362
+ return (
363
+ <button className={`${buttonBase} ${variantClass}`} onClick={onClick}>
364
+ {label}
365
+ </button>
366
+ );
367
+ }
368
+
369
+ function PromptInput(props: {
370
+ activeSessionId: string | null;
371
+ busy: boolean;
372
+ diagnostics: PluginDiagnostics | null;
373
+ input: string;
374
+ inputRef: MutableRefObject<HTMLTextAreaElement | null>;
375
+ onCancel: () => void;
376
+ onClose: (sessionId: string) => void;
377
+ onInput: (value: string) => void;
378
+ onNew: () => void;
379
+ onSelect: (sessionId: string) => void;
380
+ onSubmit: () => void;
381
+ onToggleDiagnostics: () => void;
382
+ sessions: CatuiSessionSummary[];
383
+ showDiagnostics: boolean;
384
+ status: CatuiConnectionStatus;
385
+ }) {
386
+ const activeSession = props.sessions.find((session) => session.sessionId === props.activeSessionId);
387
+ const hasErrors = (props.diagnostics?.runtimeErrors.length ?? 0) > 0;
388
+ const connected = props.status === "Connected";
389
+ const starting = props.status === "Starting";
390
+
391
+ return (
392
+ <footer className="catui-input-zone catui:mx-auto catui:mt-1 catui:w-full catui:max-w-[840px] catui:shrink-0 catui:bg-bg catui:px-2">
393
+ <div className="catui:relative catui:overflow-visible catui:rounded-[16px] catui:bg-bg catui:shadow-catui-middle catui:transition-shadow catui:focus-within:shadow-catui-focused">
394
+ <textarea
395
+ ref={props.inputRef}
396
+ className="catui:block catui:min-h-[88px] catui:max-h-[220px] catui:w-full catui:resize-none catui:overflow-y-auto catui:border-0 catui:bg-transparent catui:pb-3 catui:pl-5 catui:pr-4 catui:pt-4 catui:text-[14px] catui:leading-5 catui:text-normal catui:outline-none catui:placeholder:text-faint"
397
+ placeholder="What would you like to work on?"
398
+ value={props.input}
399
+ onChange={(event) => props.onInput(event.currentTarget.value)}
400
+ onKeyDown={(event) => {
401
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
402
+ event.preventDefault();
403
+ props.onSubmit();
404
+ }
405
+ }}
406
+ />
407
+ <div className="catui:flex catui:items-center catui:gap-1 catui:px-2 catui:py-2">
408
+ <button
409
+ className="catui-reset-button catui:inline-flex catui:h-6 catui:shrink-0 catui:items-center catui:gap-1.5 catui:rounded-catui-sm catui:bg-info-bg catui:px-2 catui:text-[12px] catui:font-medium catui:text-normal catui:shadow-catui-tinted catui:transition-colors catui:hover:bg-info/20"
410
+ onClick={props.onToggleDiagnostics}
411
+ title={`${props.status} · permission mode asks before risky actions`}
412
+ type="button"
413
+ >
414
+ {starting ? <LoaderCircle className="catui:animate-spin catui:text-info" size={12} /> : <ShieldAlert className={connected ? "catui:text-info" : "catui:text-error"} size={12} />}
415
+ <span>Ask</span>
416
+ </button>
417
+ <SessionPicker
418
+ activeSessionId={props.activeSessionId}
419
+ busy={props.busy}
420
+ onSelect={props.onSelect}
421
+ sessions={props.sessions}
422
+ />
423
+ <button
424
+ aria-label="Diagnostics"
425
+ aria-pressed={props.showDiagnostics}
426
+ className={`catui-reset-button catui:flex catui:size-6 catui:shrink-0 catui:items-center catui:justify-center catui:rounded-catui-sm catui:transition-colors catui:hover:bg-hover ${hasErrors ? "catui:text-error" : props.showDiagnostics ? "catui:bg-hover catui:text-normal" : "catui:text-muted"}`}
427
+ onClick={props.onToggleDiagnostics}
428
+ title="Diagnostics"
429
+ type="button"
430
+ >
431
+ <Bug size={14} />
432
+ </button>
433
+ <button
434
+ aria-label="New session"
435
+ className="catui-reset-button catui:flex catui:size-6 catui:shrink-0 catui:items-center catui:justify-center catui:rounded-catui-sm catui:text-muted catui:transition-colors catui:hover:bg-hover catui:hover:text-normal"
436
+ onClick={props.onNew}
437
+ title="New session"
438
+ type="button"
439
+ >
440
+ <Plus size={14} />
441
+ </button>
442
+ <button
443
+ aria-label="Close current session"
444
+ className="catui-reset-button catui:flex catui:size-6 catui:shrink-0 catui:items-center catui:justify-center catui:rounded-catui-sm catui:text-muted catui:transition-colors catui:hover:bg-hover catui:hover:text-error catui:disabled:opacity-30"
445
+ disabled={!activeSession}
446
+ onClick={() => activeSession && props.onClose(activeSession.sessionId)}
447
+ title="Close current session"
448
+ type="button"
449
+ >
450
+ <X size={13} />
451
+ </button>
452
+ <div className="catui:min-w-0 catui:flex-1" />
453
+ <button
454
+ className={`catui-reset-button catui:ml-2 catui:flex catui:size-7 catui:shrink-0 catui:items-center catui:justify-center catui:rounded-full catui:transition-colors catui:focus-visible:outline-none catui:focus-visible:ring-1 catui:focus-visible:ring-accent catui:disabled:cursor-not-allowed catui:disabled:opacity-40 ${props.busy ? "catui:bg-foreground-soft catui:text-normal catui:hover:bg-foreground-15 catui:active:bg-foreground-20" : "catui:bg-normal catui:text-bg catui:hover:opacity-80"}`}
455
+ disabled={!props.busy && !props.input.trim()}
456
+ onClick={props.busy ? props.onCancel : props.onSubmit}
457
+ title={props.busy ? "Stop" : "Send"}
458
+ type="button"
459
+ >
460
+ {props.busy ? <Square fill="currentColor" size={12} /> : <ArrowUp size={16} />}
461
+ </button>
462
+ </div>
463
+ </div>
464
+ </footer>
465
+ );
466
+ }
467
+
468
+ function SessionPicker({
469
+ activeSessionId,
470
+ busy,
471
+ onSelect,
472
+ sessions,
473
+ }: {
474
+ activeSessionId: string | null;
475
+ busy: boolean;
476
+ onSelect: (sessionId: string) => void;
477
+ sessions: CatuiSessionSummary[];
478
+ }) {
479
+ const [open, setOpen] = useState(false);
480
+ const rootRef = useRef<HTMLDivElement | null>(null);
481
+ const activeSession = sessions.find((session) => session.sessionId === activeSessionId);
482
+ const activeTitle = getSessionTitle(activeSession);
483
+ const recentSessions = [...sessions]
484
+ .filter((session) => !isEmptySessionTitle(session.title))
485
+ .sort((left, right) => getSessionTimestamp(right) - getSessionTimestamp(left))
486
+ .slice(0, 10);
487
+
488
+ useEffect(() => {
489
+ if (!open) return;
490
+ const handlePointerDown = (event: PointerEvent) => {
491
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
492
+ };
493
+ const handleKeyDown = (event: KeyboardEvent) => {
494
+ if (event.key === "Escape") setOpen(false);
495
+ };
496
+ document.addEventListener("pointerdown", handlePointerDown);
497
+ document.addEventListener("keydown", handleKeyDown);
498
+ return () => {
499
+ document.removeEventListener("pointerdown", handlePointerDown);
500
+ document.removeEventListener("keydown", handleKeyDown);
501
+ };
502
+ }, [open]);
503
+
504
+ return (
505
+ <div ref={rootRef} className="catui:relative catui:min-w-0 catui:max-w-[148px] catui:shrink">
506
+ <button
507
+ aria-expanded={open}
508
+ aria-haspopup="menu"
509
+ aria-label="Active session"
510
+ className="catui-reset-button catui:flex catui:h-6 catui:w-full catui:items-center catui:gap-1 catui:rounded-catui-sm catui:bg-foreground-subtle catui:pl-2 catui:pr-1.5 catui:text-[12px] catui:font-medium catui:text-muted catui:shadow-catui-minimal catui:transition-colors catui:hover:bg-hover catui:hover:text-normal catui:focus-visible:outline-none catui:focus-visible:ring-1 catui:focus-visible:ring-accent"
511
+ disabled={sessions.length === 0}
512
+ onClick={() => setOpen((value) => !value)}
513
+ title={activeTitle}
514
+ type="button"
515
+ >
516
+ <span className="catui:min-w-0 catui:flex-1 catui:truncate catui:text-left">{activeTitle}</span>
517
+ <ChevronDown className={`catui:shrink-0 catui:text-faint catui:transition-transform ${open ? "catui:rotate-180" : ""}`} size={11} />
518
+ </button>
519
+ {open ? (
520
+ <div className="catui-session-menu catui:absolute catui:bottom-full catui:left-0 catui:z-50 catui:mb-2 catui:w-[280px] catui:max-w-[calc(100vw-32px)] catui:overflow-hidden catui:rounded-catui-md catui:border catui:border-border catui:bg-bg catui:p-1.5 catui:shadow-catui-middle" role="menu">
521
+ <div className="catui-session-menu-header catui:flex catui:items-center catui:justify-between catui:px-2 catui:pb-1.5 catui:pt-1">
522
+ <span className="catui-session-menu-heading catui:text-[11px] catui:font-medium catui:text-muted">Recent chats</span>
523
+ <span className="catui-session-menu-count catui:text-[10px] catui:tabular-nums catui:text-faint">{recentSessions.length}</span>
524
+ </div>
525
+ {recentSessions.length > 0 ? (
526
+ <div className="catui:max-h-[280px] catui:overflow-y-auto">
527
+ {recentSessions.map((session) => {
528
+ const selected = session.sessionId === activeSessionId;
529
+ const title = getSessionTitle(session);
530
+ return (
531
+ <button
532
+ key={session.sessionId}
533
+ aria-current={selected ? "true" : undefined}
534
+ className="catui-session-item catui-reset-button catui:flex catui:w-full catui:items-center catui:gap-2 catui:rounded-catui-sm catui:px-2 catui:py-1.5 catui:text-left catui:transition-colors catui:focus-visible:outline-none"
535
+ disabled={busy}
536
+ onClick={() => {
537
+ setOpen(false);
538
+ onSelect(session.sessionId);
539
+ }}
540
+ role="menuitem"
541
+ title={title}
542
+ type="button"
543
+ >
544
+ <span className="catui:min-w-0 catui:flex-1">
545
+ <span className="catui-session-item-title catui:block catui:truncate catui:text-[12px] catui:font-medium catui:text-normal">{title}</span>
546
+ <span className="catui-session-item-time catui:mt-0.5 catui:block catui:text-[10px] catui:text-faint">{formatSessionTime(session.updatedAt)}</span>
547
+ </span>
548
+ <span className="catui:flex catui:size-4 catui:shrink-0 catui:items-center catui:justify-center catui:text-accent">
549
+ {selected ? <Check size={12} strokeWidth={2.25} /> : null}
550
+ </span>
551
+ </button>
552
+ );
553
+ })}
554
+ </div>
555
+ ) : (
556
+ <div className="catui:px-2 catui:py-3 catui:text-center catui:text-[11px] catui:text-faint">No previous chats</div>
557
+ )}
558
+ </div>
559
+ ) : null}
560
+ </div>
561
+ );
562
+ }
563
+
564
+ function getSessionTitle(session: CatuiSessionSummary | undefined): string {
565
+ if (!session || isEmptySessionTitle(session.title)) return "New chat";
566
+ return normalizeSessionTitle(session.title, session.sessionId.slice(0, 8));
567
+ }
568
+
569
+ function isEmptySessionTitle(title: string | undefined): boolean {
570
+ const normalized = title?.trim().toLowerCase();
571
+ return !normalized || normalized === "(no messages)" || normalized === "no messages";
572
+ }
573
+
574
+ function getSessionTimestamp(session: CatuiSessionSummary): number {
575
+ const timestamp = session.updatedAt ? Date.parse(session.updatedAt) : Number.NaN;
576
+ return Number.isNaN(timestamp) ? 0 : timestamp;
577
+ }
578
+
579
+ function formatSessionTime(updatedAt: string | undefined): string {
580
+ if (!updatedAt) return "Earlier";
581
+ const date = new Date(updatedAt);
582
+ if (Number.isNaN(date.getTime())) return "Earlier";
583
+ const now = new Date();
584
+ const sameDay = date.toDateString() === now.toDateString();
585
+ if (sameDay) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
586
+ return date.toLocaleDateString([], { month: "short", day: "numeric" });
587
+ }
588
+
589
+ function normalizeSessionTitle(title: string | undefined, shortId: string): string {
590
+ const cleanTitle = title
591
+ ?.split("\n")
592
+ .map((line) => line.trim())
593
+ .find((line) => line && line !== "Vault context:");
594
+ if (!cleanTitle) return `Session ${shortId}`;
595
+ return cleanTitle.length > 54 ? `${cleanTitle.slice(0, 51)}...` : cleanTitle;
596
+ }
597
+
598
+ function DiagnosticsPanel({ diagnostics }: { diagnostics: PluginDiagnostics }) {
599
+ const [copied, setCopied] = useState(false);
600
+ const errorCount = diagnostics.runtimeErrors.length;
601
+ const sections = [
602
+ {
603
+ key: "info",
604
+ title: "Build",
605
+ body: [
606
+ `plugin: v${diagnostics.pluginVersion}`,
607
+ `obsidian: v${diagnostics.obsidianVersion}`,
608
+ `startedAt: ${diagnostics.startedAt}`,
609
+ `command: ${diagnostics.activeCommand || "(empty)"}`,
610
+ ].join("\n"),
611
+ },
612
+ {
613
+ key: "load",
614
+ title: `Load events (${diagnostics.loadEvents.length})`,
615
+ body: diagnostics.loadEvents.join("\n") || "(none)",
616
+ },
617
+ {
618
+ key: "errors",
619
+ title: `Runtime errors (${errorCount})`,
620
+ body: errorCount === 0 ? "(none)" : diagnostics.runtimeErrors.join("\n\n"),
621
+ },
622
+ {
623
+ key: "stderr",
624
+ title: `CatUI stderr (tail of ${diagnostics.stderrTotal} chars)`,
625
+ body: diagnostics.stderrTail.trim() || "(empty)",
626
+ },
627
+ ];
628
+
629
+ const report = sections
630
+ .map((section) => `## ${section.title}\n${section.body}`)
631
+ .join("\n\n");
632
+
633
+ async function copy() {
634
+ try {
635
+ await navigator.clipboard.writeText(report);
636
+ setCopied(true);
637
+ window.setTimeout(() => setCopied(false), 1200);
638
+ } catch (error) {
639
+ console.error("Diagnostics copy failed", error);
640
+ }
641
+ }
642
+
643
+ return (
644
+ <section className="catui:border-b catui:border-border catui:bg-panel catui:p-3">
645
+ <div className="catui:mb-2 catui:flex catui:items-center catui:justify-between catui:gap-2">
646
+ <div className="catui:text-[12px] catui:font-semibold catui:text-normal">
647
+ Diagnostics
648
+ {errorCount > 0 ? <span className="catui:ml-1.5 catui:text-error">· {errorCount} error{errorCount === 1 ? "" : "s"}</span> : null}
649
+ </div>
650
+ <button
651
+ className="catui-reset-button catui:flex catui:items-center catui:gap-1 catui:rounded-catui-sm catui:border catui:border-border catui:bg-bg catui:px-2 catui:py-1 catui:text-[11px] catui:text-muted catui:transition catui:hover:bg-hover catui:hover:text-normal catui:focus-visible:border-accent catui:focus-visible:shadow-[0_0_0_1px_var(--interactive-accent)]"
652
+ onClick={() => void copy()}
653
+ type="button"
654
+ >
655
+ <Copy size={12} />
656
+ {copied ? "Copied" : "Copy report"}
657
+ </button>
658
+ </div>
659
+ <div className="catui:flex catui:max-h-[260px] catui:flex-col catui:gap-2 catui:overflow-y-auto">
660
+ {sections.map((section) => (
661
+ <details
662
+ className="catui:rounded-catui-sm catui:border catui:border-border catui:bg-bg"
663
+ key={section.key}
664
+ open={section.key === "errors" && errorCount > 0}
665
+ >
666
+ <summary className="catui:flex catui:cursor-pointer catui:items-center catui:justify-between catui:px-2 catui:py-1.5 catui:text-[11px] catui:font-medium catui:text-muted">
667
+ {section.title}
668
+ </summary>
669
+ <pre className="catui:m-0 catui:max-h-[180px] catui:overflow-auto catui:border-t catui:border-border catui:bg-panel catui:p-2 catui:font-mono catui:text-[11px] catui:leading-4 catui:text-normal catui:whitespace-pre-wrap">
670
+ {section.body}
671
+ </pre>
672
+ </details>
673
+ ))}
674
+ </div>
675
+ </section>
676
+ );
677
+ }