@columenlabs/lumen-ui 0.1.76 → 0.1.78

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,40 @@
1
+ import type { AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState } from '@columenlabs/lumen-ui-types/assistant';
2
+ type Test = NonNullable<KeyState['test']>;
3
+ export interface AssistantAdapter {
4
+ /** Names the cache its reads keep (two assistants on one page need two keys). */
5
+ key: string;
6
+ status(): Promise<AssistantStatus>;
7
+ settings(patch: AssistantSettingsPatch): Promise<AssistantStatus>;
8
+ /** Admin: what each person may spend a month on the app's keys. */
9
+ appCap(usd: number): Promise<AssistantStatus>;
10
+ /** Admin: the app's key of a provider (tested before it is kept); null removes it. */
11
+ appKey(provider: AssistantProvider, key: string | null): Promise<AssistantStatus>;
12
+ testAppKey(provider: AssistantProvider): Promise<Test>;
13
+ /** The person's own key of a provider; null removes it. */
14
+ ownKey(provider: AssistantProvider, key: string | null): Promise<AssistantStatus>;
15
+ /** What the person may spend a month on their own keys. */
16
+ ownCap(usd: number): Promise<AssistantStatus>;
17
+ testOwnKey(provider: AssistantProvider): Promise<Test>;
18
+ /** Starts a plan sign-in (ChatGPT): the address to open and the one it ends on. */
19
+ startSignin(provider: AssistantProvider): Promise<{
20
+ url: string;
21
+ redirect: string;
22
+ }>;
23
+ /** Finishes it from the address the sign-in tab ended on. */
24
+ finishSignin(provider: AssistantProvider, url: string): Promise<AssistantStatus>;
25
+ /** Admin: ScrapeQL's Access service token; null removes it. */
26
+ scrapeql(token: {
27
+ client_id: string;
28
+ client_secret: string;
29
+ } | null): Promise<AssistantStatus>;
30
+ testScrapeql(): Promise<Test>;
31
+ chats(): Promise<AssistantChatRow[]>;
32
+ chat(id: string): Promise<AssistantChat>;
33
+ removeChat(id: string): Promise<void>;
34
+ /** One answer: `onEvent` hears each event as it streams; abort `signal` to stop it. */
35
+ send(msg: AssistantMessage, onEvent: (e: AssistantEvent) => void, signal?: AbortSignal): Promise<void>;
36
+ settleDraft(chatId: string, draftId: string, action: 'save' | 'dismiss'): Promise<DraftItem>;
37
+ }
38
+ /** The adapter over `assistantRoutes` mounted at `base` (`/api/assistant`). */
39
+ export declare function assistantClient(base: string): AssistantAdapter;
40
+ export {};
@@ -0,0 +1,80 @@
1
+ import { ApiError, apiJson } from '../api-fetch.js';
2
+ /** Reads `event:` / `data:` blocks off a server-sent event stream. */
3
+ async function readEvents(body, on) {
4
+ const reader = body.getReader();
5
+ const dec = new TextDecoder();
6
+ let buf = '';
7
+ for (;;) {
8
+ const { done, value } = await reader.read();
9
+ if (done)
10
+ break;
11
+ buf += dec.decode(value, { stream: true });
12
+ let at = buf.indexOf('\n\n');
13
+ while (at >= 0) {
14
+ const block = buf.slice(0, at);
15
+ buf = buf.slice(at + 2);
16
+ let event = 'message';
17
+ let data = '';
18
+ for (const line of block.split('\n')) {
19
+ if (line.startsWith('event:'))
20
+ event = line.slice(6).trim();
21
+ else if (line.startsWith('data:'))
22
+ data += line.slice(5).trim();
23
+ }
24
+ if (data) {
25
+ try {
26
+ on({ event, data: JSON.parse(data) });
27
+ }
28
+ catch { }
29
+ }
30
+ at = buf.indexOf('\n\n');
31
+ }
32
+ }
33
+ }
34
+ /** The adapter over `assistantRoutes` mounted at `base` (`/api/assistant`). */
35
+ export function assistantClient(base) {
36
+ const root = base.replace(/\/+$/, '');
37
+ const get = (path) => apiJson(`${root}${path}`, {}, 'assistant');
38
+ const send = (method, path, body = {}) => apiJson(`${root}${path}`, { method, body: JSON.stringify(body) }, 'assistant');
39
+ return {
40
+ key: `assistant:${root}`,
41
+ status: () => get('/status'),
42
+ settings: (patch) => send('PUT', '/settings', patch),
43
+ appCap: (usd) => send('PUT', '/app', { cap_usd: usd }),
44
+ appKey: (p, key) => key
45
+ ? send('PUT', `/key?provider=${p}`, { api_key: key })
46
+ : send('DELETE', `/key?provider=${p}`),
47
+ testAppKey: (p) => send('POST', `/key/test?provider=${p}`),
48
+ ownKey: (p, key) => key
49
+ ? send('PUT', `/my-key?provider=${p}`, { api_key: key })
50
+ : send('DELETE', `/my-key?provider=${p}`),
51
+ ownCap: (usd) => send('PUT', '/my-key/cap', { cap_usd: usd }),
52
+ testOwnKey: (p) => send('POST', `/my-key/test?provider=${p}`),
53
+ startSignin: (p) => send('POST', `/my-signin?provider=${p}`),
54
+ finishSignin: (p, url) => send('POST', `/my-signin/finish?provider=${p}`, { url }),
55
+ scrapeql: (token) => token ? send('PUT', '/web/scrapeql', token) : send('DELETE', '/web/scrapeql'),
56
+ testScrapeql: () => send('POST', '/web/test'),
57
+ chats: () => get('/chats'),
58
+ chat: (id) => get(`/chats/${encodeURIComponent(id)}`),
59
+ removeChat: async (id) => {
60
+ await send('DELETE', `/chats/${encodeURIComponent(id)}`);
61
+ },
62
+ settleDraft: (chatId, draftId, action) => send('POST', `/chats/${encodeURIComponent(chatId)}/drafts/${encodeURIComponent(draftId)}`, {
63
+ action,
64
+ }),
65
+ async send(msg, onEvent, signal) {
66
+ const url = `${root}/chat`;
67
+ const res = await fetch(url, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
70
+ body: JSON.stringify(msg),
71
+ ...(signal ? { signal } : {}),
72
+ });
73
+ if (!res.ok || !res.body) {
74
+ const b = (await res.json().catch(() => ({})));
75
+ throw new ApiError(b.error ?? `The answer failed (${res.status})`, 'POST', url, res.status, b.code);
76
+ }
77
+ await readEvents(res.body, onEvent);
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,4 @@
1
+ export { type AssistantAdapter, assistantClient } from './client.js';
2
+ export { LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
3
+ export { assistantSettingsSections } from './settings.js';
4
+ export { type AssistantChatState, type LiveItem, applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -0,0 +1,7 @@
1
+ // `@columenlabs/lumen-ui/assistant`: an assistant's chat panel and Settings sections, over the
2
+ // routes of `@columenlabs/lumen-ai/assistant`; the shapes both share are
3
+ // `@columenlabs/lumen-ui-types/assistant`.
4
+ export { assistantClient } from './client.js';
5
+ export { LumenAssistant, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
6
+ export { assistantSettingsSections } from './settings.js';
7
+ export { applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -0,0 +1,44 @@
1
+ import type { AssistantMessage, AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
2
+ import type { AssistantAdapter } from './client.js';
3
+ import { type AssistantChatState } from './use-assistant-chat.js';
4
+ export declare const assistantStatusKey: (client: AssistantAdapter) => string[];
5
+ /** The person's assistant status, shared by the panel and Settings. */
6
+ export declare function useAssistantStatus(client: AssistantAdapter): import("@tanstack/react-query").UseQueryResult<AssistantStatus, Error>;
7
+ export declare const usd: (n: number) => string;
8
+ export interface LumenAssistantProps {
9
+ client: AssistantAdapter;
10
+ /** The page the person is on, sent with each message. */
11
+ context?: AssistantMessage['context'];
12
+ /** Opens the app's Settings at the assistant (from a "not connected" line). */
13
+ onSettings?: () => void;
14
+ /** Days chats are kept, for the Chats menu's line. */
15
+ keepDays?: number;
16
+ /** Closes the panel (its ✕). */
17
+ onClose?: () => void;
18
+ /** An outside chat state (the dock shares one between the button and the panel). */
19
+ chat?: AssistantChatState;
20
+ }
21
+ /** The assistant's chat: header, messages and composer. */
22
+ export declare function LumenAssistant({ client, context, onSettings, keepDays, onClose, chat: outside, }: LumenAssistantProps): import("react").JSX.Element;
23
+ /** The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. */
24
+ export declare function LumenComposer({ name, busy, queued, disabled, onSend, onStop, }: {
25
+ name: string;
26
+ busy: boolean;
27
+ queued: string | null;
28
+ disabled?: boolean;
29
+ onSend: (text: string) => void;
30
+ onStop: () => void;
31
+ }): import("react").JSX.Element;
32
+ /**
33
+ * The assistant docked in LumenShell: put `button` in `headerActions` and `panel` in `panel`.
34
+ * Open or closed is this browser tab's own, and ⌘J toggles it.
35
+ */
36
+ export declare function useLumenAssistantDock(client: AssistantAdapter, o?: Omit<LumenAssistantProps, 'client' | 'chat' | 'onClose'> & {
37
+ name: string;
38
+ }): {
39
+ open: boolean;
40
+ setOpen: (v: boolean) => void;
41
+ chat: AssistantChatState;
42
+ button: import("react").JSX.Element;
43
+ panel: import("react").JSX.Element;
44
+ };
@@ -0,0 +1,209 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { ArrowUpIcon, CheckIcon, CircleAlertIcon, CornerDownRightIcon, LightbulbIcon, PlusIcon, SparklesIcon, SquareIcon, WrenchIcon, XIcon, } from 'lucide-react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import Markdown from 'react-markdown';
6
+ import { LumenAssistantButton, LumenAssistantChats, LumenAssistantPanel, assistantOpen, useAssistantHotkey, } from '../assistant-panel.js';
7
+ import { IconButton } from '../icon-button.js';
8
+ import { errorText } from '../integrations.js';
9
+ import { cn } from '../lib/utils.js';
10
+ import { LumenSelector } from '../lumen-selector.js';
11
+ import { lumenToast } from '../lumen-toast.js';
12
+ import { Alert, AlertDescription } from '../ui/alert.js';
13
+ import { Button } from '../ui/button.js';
14
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
15
+ import { Spinner } from '../ui/spinner.js';
16
+ import { Textarea } from '../ui/textarea.js';
17
+ import { useAssistantChat } from './use-assistant-chat.js';
18
+ /** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
19
+ const CONTEXT_MAX = 200_000;
20
+ const COMPACT_AT = 150_000;
21
+ export const assistantStatusKey = (client) => [
22
+ 'lumen-assistant',
23
+ client.key,
24
+ 'status',
25
+ ];
26
+ /** The person's assistant status, shared by the panel and Settings. */
27
+ export function useAssistantStatus(client) {
28
+ return useQuery({ queryKey: assistantStatusKey(client), queryFn: () => client.status() });
29
+ }
30
+ export const usd = (n) => n === 0 ? '$0' : n < 0.01 ? '<$0.01' : `$${n.toFixed(n < 10 ? 2 : 0)}`;
31
+ const tokens = (n) => n < 1000
32
+ ? String(n)
33
+ : n < 1_000_000
34
+ ? `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
35
+ : `${(n / 1e6).toFixed(1)}M`;
36
+ const seconds = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
37
+ /** The assistant's chat: header, messages and composer. */
38
+ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, }) {
39
+ const own = useAssistantChat(client);
40
+ const chat = outside ?? own;
41
+ const qc = useQueryClient();
42
+ const status = useAssistantStatus(client);
43
+ const chats = useQuery({
44
+ queryKey: ['lumen-assistant', client.key, 'chats'],
45
+ queryFn: () => client.chats(),
46
+ enabled: false,
47
+ });
48
+ const s = status.data;
49
+ // A finished answer changes the spend and the chats list.
50
+ useEffect(() => {
51
+ if (chat.busy)
52
+ return;
53
+ void qc.invalidateQueries({ queryKey: ['lumen-assistant', client.key] });
54
+ }, [chat.busy, qc, client.key]);
55
+ const setTier = async (tier) => {
56
+ try {
57
+ qc.setQueryData(assistantStatusKey(client), await client.settings({ tier }));
58
+ }
59
+ catch (e) {
60
+ lumenToast.error(errorText(e));
61
+ }
62
+ };
63
+ return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [_jsxs("div", { className: "flex h-12 shrink-0 items-center gap-1 border-b px-3", children: [_jsx(SparklesIcon, { className: "size-4 text-edge" }), _jsx("span", { className: "min-w-0 flex-1 truncate font-medium", children: chat.title || s?.name || 'Assistant' }), s && s.tiers.length > 1 && (_jsx(LumenSelector, { trigger: "chip", size: "sm", label: "Tier", value: s.tier, options: s.tiers.map((t) => ({ value: t.id, label: t.label, detail: t.model_label })), onChange: (t) => void setTier(t) })), _jsx(LumenAssistantChats, { chats: chats.data?.map((c) => ({
64
+ id: c.id,
65
+ title: c.title,
66
+ updated_at: new Date(c.at).toISOString(),
67
+ })) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _jsx(IconButton, { icon: PlusIcon, label: "New chat", disabled: !chat.items.length, onClick: chat.reset }), onClose && (_jsx(IconButton, { icon: XIcon, label: `Close ${s?.name ?? 'assistant'}`, onClick: onClose }))] }), _jsx(Messages, { chat: chat, status: s, onSettings: onSettings }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, disabled: !s || !!s.wait, onSend: (text) => chat.send(text, context ? { context } : undefined), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage })] }));
68
+ }
69
+ function Messages({ chat, status, onSettings, }) {
70
+ const end = useRef(null);
71
+ const count = JSON.stringify(chat.items).length;
72
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll to the end as the chat grows
73
+ useEffect(() => {
74
+ end.current?.scrollIntoView({ block: 'end' });
75
+ }, [count]);
76
+ return (_jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 py-3", children: [status?.wait && (_jsxs(Alert, { className: "mb-3", children: [_jsx(CircleAlertIcon, {}), _jsxs(AlertDescription, { className: "flex flex-col items-start gap-2", children: [WAIT[status.wait](status.admin, status.providers.find((p) => p.id === status.wait_provider)?.label ?? 'API'), onSettings && (_jsx(Button, { size: "sm", variant: "outline", onClick: onSettings, children: "Open Settings" }))] })] })), chat.loading ? (_jsxs("p", { className: "flex items-center gap-2 text-muted-foreground text-sm", children: [_jsx(Spinner, {}), " Loading the chat\u2026"] })) : chat.items.length === 0 && !status?.wait ? (_jsxs("p", { className: "pt-8 text-center text-muted-foreground text-sm", children: ["Ask ", status?.name ?? 'the assistant', " anything."] })) : (_jsx(Items, { items: chat.items, onSettle: chat.settle, busy: chat.busy })), chat.busy && !chat.items.some(isLive) && (_jsxs("p", { className: "flex items-center gap-2 py-1 text-muted-foreground text-sm", children: [_jsx(Spinner, {}), " Thinking\u2026"] })), _jsx("div", { ref: end })] }));
77
+ }
78
+ const isLive = (i) => !!i.live || (i.kind === 'handoff' && i.items.some(isLive));
79
+ const WAIT = {
80
+ not_connected: (admin, provider) => admin
81
+ ? `Add an ${provider} key in Settings ▸ Model providers, or pick another model in Models.`
82
+ : `No ${provider} key is set yet. Add your own in Settings, or pick another model in Models.`,
83
+ app_cap: () => "You reached this month's cap on the shared keys. Add your own key, or wait for next month.",
84
+ own_cap: () => 'Your own keys reached their monthly cap. Raise it in Settings ▸ Model providers.',
85
+ };
86
+ function Items({ items, onSettle, busy, }) {
87
+ return (_jsx("div", { className: "flex flex-col gap-3", children: items.map((item, i) => (_jsx(Item, { item: item, onSettle: onSettle, busy: busy }, i))) }));
88
+ }
89
+ function Item({ item, onSettle, busy, }) {
90
+ switch (item.kind) {
91
+ case 'user':
92
+ return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap", children: [item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
93
+ case 'text':
94
+ return (_jsxs("div", { className: "prose-sm text-sm leading-relaxed [&_code]:font-code [&_code]:text-xs [&_li]:ml-4 [&_ol]:list-decimal [&_p]:my-1.5 [&_ul]:list-disc", children: [_jsx(Markdown, { allowedElements: ['p', 'strong', 'em', 'code', 'ul', 'ol', 'li', 'a', 'pre', 'br'], unwrapDisallowed: true, children: item.text }), item.live && (_jsx("span", { className: "ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-foreground/60 align-middle" }))] }));
95
+ case 'step':
96
+ return _jsx(Step, { step: item, live: !!item.live });
97
+ case 'draft':
98
+ return _jsx(Draft, { draft: item, onSettle: onSettle, busy: busy });
99
+ case 'handoff':
100
+ return _jsx(HandOff, { item: item, onSettle: onSettle, busy: busy });
101
+ case 'advice':
102
+ return (_jsxs("p", { className: "flex items-center gap-1.5 text-muted-foreground text-xs", children: [item.live ? _jsx(Spinner, { className: "size-3" }) : _jsx(LightbulbIcon, { className: "size-3.5" }), item.live
103
+ ? `Asking ${item.label}…`
104
+ : item.error
105
+ ? `${item.label} could not advise (${item.error})`
106
+ : `Asked ${item.label}`] }));
107
+ case 'compacted':
108
+ return _jsx(Rule, { children: "Compacted: earlier messages are sent as a summary" });
109
+ case 'stopped':
110
+ return _jsx(Rule, { children: item.reason === 'cap' ? "Stopped at this month's spend cap" : 'Stopped' });
111
+ case 'error':
112
+ return (_jsxs("p", { role: "alert", className: "flex items-start gap-1.5 text-loss text-sm", children: [_jsx(CircleAlertIcon, { className: "mt-0.5 size-4 shrink-0" }), item.text] }));
113
+ }
114
+ }
115
+ const Rule = ({ children }) => (_jsx("p", { className: "flex items-center gap-2 text-muted-foreground text-xs before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border", children: children }));
116
+ function Step({ step, live }) {
117
+ return (_jsxs("p", { className: cn('flex min-w-0 items-center gap-1.5 text-xs', step.ok ? 'text-muted-foreground' : 'text-loss'), children: [live ? (_jsx(Spinner, { className: "size-3" })) : step.ok ? (_jsx(WrenchIcon, { className: "size-3.5 shrink-0" })) : (_jsx(CircleAlertIcon, { className: "size-3.5 shrink-0" })), _jsx("span", { className: "truncate", children: step.title }), !live && (_jsxs("span", { className: "shrink-0 tabular-nums", children: [step.rows !== undefined && `${step.rows} row${step.rows === 1 ? '' : 's'} · `, seconds(step.ms)] })), step.error && _jsxs("span", { className: "truncate", children: ["\u00B7 ", step.error] })] }));
118
+ }
119
+ /** The arguments as short lines: `name: value`. */
120
+ function argLines(args) {
121
+ if (!args || typeof args !== 'object')
122
+ return [];
123
+ return Object.entries(args).map(([k, v]) => [
124
+ k,
125
+ typeof v === 'string' ? v : JSON.stringify(v),
126
+ ]);
127
+ }
128
+ function Draft({ draft, onSettle, busy, }) {
129
+ const [pending, setPending] = useState(null);
130
+ const go = async (action) => {
131
+ setPending(action);
132
+ try {
133
+ await onSettle(draft, action);
134
+ }
135
+ catch (e) {
136
+ lumenToast.error(errorText(e));
137
+ }
138
+ finally {
139
+ setPending(null);
140
+ }
141
+ };
142
+ return (_jsxs("div", { "data-slot": "assistant-draft", className: "flex flex-col gap-2 rounded-lg border bg-card p-3 text-sm", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate font-medium", children: draft.title }), draft.status === 'saved' ? (_jsxs("span", { className: "flex items-center gap-1 text-gain text-xs", children: [_jsx(CheckIcon, { className: "size-3.5" }), " Saved"] })) : draft.status === 'dismissed' ? (_jsx("span", { className: "text-muted-foreground text-xs", children: "Dismissed" })) : draft.status === 'failed' ? (_jsx("span", { className: "text-loss text-xs", children: "Failed" })) : null] }), _jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs", children: argLines(draft.args)
143
+ .slice(0, 8)
144
+ .map(([k, v]) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "text-muted-foreground", children: k }), _jsx("dd", { className: "truncate font-code", children: v })] }, k))) }), draft.error && _jsx("p", { className: "text-loss text-xs", children: draft.error }), draft.status === 'open' && (_jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: !!pending || busy, onClick: () => void go('dismiss'), children: "Dismiss" }), _jsxs(Button, { size: "sm", disabled: !!pending || busy, onClick: () => void go('save'), children: [pending === 'save' ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), "Save"] })] }))] }));
145
+ }
146
+ const WHY = {
147
+ asked: 'you asked',
148
+ judgement: 'needs judgement',
149
+ stuck: 'stuck',
150
+ check_failed: 'a check failed',
151
+ };
152
+ function HandOff({ item, onSettle, busy, }) {
153
+ return (_jsxs("section", { className: "flex flex-col gap-2 border-l-2 border-primary/40 pl-3", children: [_jsxs("p", { className: "flex items-center gap-1.5 text-xs", children: [_jsx(CornerDownRightIcon, { className: "size-3.5 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: item.label }), _jsxs("span", { className: "text-muted-foreground", children: ["\u00B7 ", WHY[item.why]] }), item.usage && (_jsx("span", { className: "ml-auto text-muted-foreground tabular-nums", children: usd(item.usage.usd) }))] }), _jsx("p", { className: "text-muted-foreground text-xs", children: item.task }), _jsx(Items, { items: item.items, onSettle: onSettle, busy: busy })] }));
154
+ }
155
+ /** The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. */
156
+ export function LumenComposer({ name, busy, queued, disabled, onSend, onStop, }) {
157
+ const [text, setText] = useState('');
158
+ const send = () => {
159
+ if (!text.trim() || disabled)
160
+ return;
161
+ onSend(text);
162
+ setText('');
163
+ };
164
+ const slash = text.startsWith('/') && !text.includes(' ');
165
+ return (_jsxs("div", { className: "shrink-0 border-t p-3", children: [queued && (_jsxs("p", { className: "mb-2 truncate text-muted-foreground text-xs", children: ["Queued: ", _jsx("span", { className: "text-foreground", children: queued })] })), slash && (_jsxs("button", { type: "button", className: "mb-2 flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs hover:bg-muted", onClick: () => setText('/compact'), children: [_jsx("span", { className: "font-code", children: "/compact" }), _jsx("span", { className: "text-muted-foreground", children: "Summarise the chat so far" })] })), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { "aria-label": `Message ${name}`, placeholder: `Ask ${name}…`, className: "max-h-48 min-h-11 resize-none pr-11", value: text, disabled: disabled, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
166
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
167
+ e.preventDefault();
168
+ send();
169
+ }
170
+ } }), _jsx("div", { className: "absolute right-1.5 bottom-1.5", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: busy ? 'Queue' : 'Send', variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: send })) })] })] }));
171
+ }
172
+ /** The chat's cost and tokens (a popup splits them per part), and how full the context is. */
173
+ function UsageLine({ usage }) {
174
+ if (!usage || !usage.calls)
175
+ return null;
176
+ const ctx = usage.context ?? 0;
177
+ return (_jsxs("div", { className: "flex shrink-0 items-center gap-3 px-3 pb-2 text-muted-foreground text-xs", children: [_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "tabular-nums hover:text-foreground", children: [usd(usage.usd), " \u00B7 ", tokens(usage.in + usage.out), " tokens"] }) }), _jsxs(PopoverContent, { align: "start", className: "w-72 gap-1 p-2 text-xs", children: [usage.parts.map((p) => (_jsxs("div", { className: "flex justify-between gap-2 tabular-nums", children: [_jsx("span", { className: "truncate", children: p.label }), _jsxs("span", { children: [tokens(p.in), " in \u00B7 ", tokens(p.out), " out \u00B7 ", usd(p.usd)] })] }, p.label))), _jsxs("p", { className: "pt-1 text-muted-foreground", children: [tokens(usage.read), " of ", tokens(usage.in), " in read from cache"] })] })] }), ctx > 0 && (_jsxs("span", { className: "ml-auto flex items-center gap-1.5", title: `Context ${tokens(ctx)} of ${tokens(CONTEXT_MAX)}`, children: [_jsxs("span", { className: "relative h-1 w-16 overflow-hidden rounded-full bg-muted", children: [_jsx("span", { className: cn('absolute inset-y-0 left-0', ctx > COMPACT_AT ? 'bg-warning' : 'bg-primary/60'), style: { width: `${Math.min(100, (ctx / CONTEXT_MAX) * 100)}%` } }), _jsx("span", { className: "absolute inset-y-0 w-px bg-foreground/40", style: { left: `${(COMPACT_AT / CONTEXT_MAX) * 100}%` } })] }), _jsx("span", { className: "tabular-nums", children: tokens(ctx) })] }))] }));
178
+ }
179
+ /**
180
+ * The assistant docked in LumenShell: put `button` in `headerActions` and `panel` in `panel`.
181
+ * Open or closed is this browser tab's own, and ⌘J toggles it.
182
+ */
183
+ export function useLumenAssistantDock(client, o = {
184
+ name: 'Assistant',
185
+ }) {
186
+ const open = assistantOpen.use();
187
+ const chat = useAssistantChat(client);
188
+ const [unread, setUnread] = useState(false);
189
+ const wasBusy = useRef(false);
190
+ useEffect(() => {
191
+ if (wasBusy.current && !chat.busy && !assistantOpen.get())
192
+ setUnread(true);
193
+ wasBusy.current = chat.busy;
194
+ }, [chat.busy]);
195
+ const setOpen = (v) => {
196
+ assistantOpen.write(v);
197
+ if (v)
198
+ setUnread(false);
199
+ };
200
+ useAssistantHotkey(() => setOpen(!assistantOpen.get()));
201
+ const { name, ...props } = o;
202
+ return {
203
+ open,
204
+ setOpen,
205
+ chat,
206
+ button: (_jsx(LumenAssistantButton, { name: name, open: open, unread: unread, onOpen: () => setOpen(true) })),
207
+ panel: (_jsx(LumenAssistantPanel, { name: name, open: open, onOpenChange: setOpen, children: () => (_jsx(LumenAssistant, { client: client, chat: chat, onClose: () => setOpen(false), ...props })) })),
208
+ };
209
+ }
@@ -0,0 +1,13 @@
1
+ import type { SettingsSectionDef } from '../settings.js';
2
+ import type { AssistantAdapter } from './client.js';
3
+ /**
4
+ * The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
5
+ * Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
6
+ */
7
+ export declare function assistantSettingsSections({ client, name, web, }: {
8
+ client: AssistantAdapter;
9
+ /** The assistant's name, the parent section's title. */
10
+ name: string;
11
+ /** Show the Web section (the app set up `web` on the server). */
12
+ web?: boolean;
13
+ }): SettingsSectionDef[];