@columenlabs/lumen-ui 0.1.77 → 0.1.79

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,518 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CornerDownRightIcon, LightbulbIcon, PencilIcon, PlusIcon, SparklesIcon, SquareIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
4
+ import { createContext, useContext, 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, LumenSelectorList, } 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
15
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
16
+ import { Spinner } from '../ui/spinner.js';
17
+ import { Textarea } from '../ui/textarea.js';
18
+ import { selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
19
+ import { useAssistantChat } from './use-assistant-chat.js';
20
+ /** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
21
+ const CONTEXT_MAX = 200_000;
22
+ const COMPACT_AT = 150_000;
23
+ export const assistantStatusKey = (client) => [
24
+ 'lumen-assistant',
25
+ client.key,
26
+ 'status',
27
+ ];
28
+ /** The person's assistant status, shared by the panel and Settings. */
29
+ export function useAssistantStatus(client) {
30
+ return useQuery({ queryKey: assistantStatusKey(client), queryFn: () => client.status() });
31
+ }
32
+ export const usd = (n) => n === 0 ? '$0' : n < 0.01 ? '<$0.01' : `$${n.toFixed(n < 10 ? 2 : 0)}`;
33
+ const tokens = (n) => n < 1000
34
+ ? String(n)
35
+ : n < 1_000_000
36
+ ? `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
37
+ : `${(n / 1e6).toFixed(1)}M`;
38
+ const seconds = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
39
+ /** The assistant's chat: header, messages and composer. */
40
+ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, commands = [], sources = [], }) {
41
+ const own = useAssistantChat(client);
42
+ const chat = outside ?? own;
43
+ const qc = useQueryClient();
44
+ const status = useAssistantStatus(client);
45
+ const chats = useQuery({
46
+ queryKey: ['lumen-assistant', client.key, 'chats'],
47
+ queryFn: () => client.chats(),
48
+ enabled: false,
49
+ });
50
+ const s = status.data;
51
+ // A finished answer changes the spend and the chats list.
52
+ useEffect(() => {
53
+ if (chat.busy)
54
+ return;
55
+ void qc.invalidateQueries({ queryKey: ['lumen-assistant', client.key] });
56
+ }, [chat.busy, qc, client.key]);
57
+ const setTier = async (tier) => {
58
+ try {
59
+ qc.setQueryData(assistantStatusKey(client), await client.settings({ tier }));
60
+ }
61
+ catch (e) {
62
+ lumenToast.error(errorText(e));
63
+ }
64
+ };
65
+ const tiers = s?.tiers ?? [];
66
+ const above = tiers.slice(tiers.findIndex((t) => t.id === s?.tier) + 1);
67
+ // The kit's commands; the app's own (`commands`, then the server's) come first and win a name.
68
+ const builtIns = [
69
+ {
70
+ name: 'compact',
71
+ about: 'Summarise the chat so far to free up context',
72
+ run: () => '/compact',
73
+ },
74
+ {
75
+ name: 'clear',
76
+ about: 'Start a new chat',
77
+ run: () => {
78
+ chat.reset();
79
+ return undefined;
80
+ },
81
+ },
82
+ ...(tiers.length > 1
83
+ ? [
84
+ {
85
+ name: 'model',
86
+ about: 'Answer on another tier from now on',
87
+ args: '<tier>',
88
+ options: () => tiers.map((t) => ({ value: t.id, label: t.label, detail: t.model_label })),
89
+ run: (arg) => {
90
+ const t = tiers.find((x) => x.id === arg || x.label.toLowerCase() === arg.toLowerCase());
91
+ if (t)
92
+ void setTier(t.id);
93
+ else
94
+ lumenToast.error(`No tier is called ${arg || 'that'}.`);
95
+ return undefined;
96
+ },
97
+ },
98
+ ]
99
+ : []),
100
+ ...(above[0]
101
+ ? [
102
+ {
103
+ name: 'escalate',
104
+ about: `Hand a task to ${above[0].label}`,
105
+ args: '<task>',
106
+ run: (task) => {
107
+ if (task.trim())
108
+ chat.send(task, { ...(context ? { context } : {}), tier: above[0]?.id });
109
+ return undefined;
110
+ },
111
+ },
112
+ ]
113
+ : []),
114
+ ];
115
+ const served = (s?.commands ?? []).map((c) => ({
116
+ ...c,
117
+ run: (args) => `/${c.name}${args ? ` ${args}` : ''}`,
118
+ }));
119
+ const allCommands = [];
120
+ for (const c of [...commands, ...served, ...builtIns])
121
+ if (!allCommands.some((x) => x.name === c.name))
122
+ allCommands.push(c);
123
+ const shown = useShownAssistantSources();
124
+ const allSources = [...shown, ...sources.filter((x) => !shown.some((y) => y.id === x.id))];
125
+ return (_jsxs("div", { "data-assistant": "", 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) => ({
126
+ id: c.id,
127
+ title: c.title,
128
+ updated_at: new Date(c.at).toISOString(),
129
+ })) ?? (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, commands: allCommands, tiers: tiers, sources: allSources, onSend: (text, extra) => chat.send(text, { ...(context ? { context } : {}), ...extra }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage })] }));
130
+ }
131
+ function Messages({ chat, status, onSettings, }) {
132
+ const end = useRef(null);
133
+ const count = JSON.stringify(chat.items).length;
134
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll to the end as the chat grows
135
+ useEffect(() => {
136
+ end.current?.scrollIntoView({ block: 'end' });
137
+ }, [count]);
138
+ 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(TierNames.Provider, { value: (id) => status?.tiers.find((t) => t.id === id)?.label ?? id, children: _jsx(UndoWrite.Provider, { value: chat.undo, children: _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 })] }));
139
+ }
140
+ const isLive = (i) => !!i.live || (i.kind === 'handoff' && i.items.some(isLive));
141
+ const WAIT = {
142
+ not_connected: (admin, provider) => admin
143
+ ? `Add an ${provider} key in Settings ▸ Model providers, or pick another model in Models.`
144
+ : `No ${provider} key is set yet. Add your own in Settings, or pick another model in Models.`,
145
+ app_cap: () => "You reached this month's cap on the shared keys. Add your own key, or wait for next month.",
146
+ own_cap: () => 'Your own keys reached their monthly cap. Raise it in Settings ▸ Model providers.',
147
+ };
148
+ /** A tier's name for a message's `@` chip. */
149
+ const TierNames = createContext((id) => id);
150
+ /** Takes back a change made at once (Undo on its line). */
151
+ const UndoWrite = createContext(null);
152
+ function Items({ items, onSettle, busy, }) {
153
+ // Cards drafted one after another sit in one stack, so many of them skim as lines.
154
+ const rows = [];
155
+ items.forEach((item, at) => {
156
+ const last = rows[rows.length - 1];
157
+ const prev = items[at - 1];
158
+ if (item.kind === 'draft' && prev?.kind === 'draft' && last) {
159
+ if ('drafts' in last)
160
+ last.drafts.push(item);
161
+ else
162
+ rows[rows.length - 1] = { at: last.at, drafts: [prev, item] };
163
+ }
164
+ else
165
+ rows.push({ at, item: item });
166
+ });
167
+ return (_jsx("div", { className: "flex flex-col gap-3", children: rows.map((r) => 'drafts' in r ? (_jsx(DraftStack, { drafts: r.drafts, onSettle: onSettle, busy: busy }, r.at)) : (_jsx(Item, { item: r.item, onSettle: onSettle, busy: busy }, r.at))) }));
168
+ }
169
+ function Item({ item, onSettle, busy, }) {
170
+ const tierName = useContext(TierNames);
171
+ switch (item.kind) {
172
+ case 'user':
173
+ return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap", children: [(item.tier || item.refs?.length) && (_jsxs("span", { className: "mb-1 flex flex-wrap gap-1", children: [item.tier && _jsx(ComposerChip, { sign: "@", label: tierName(item.tier) }), item.refs?.map((r, i) => (_jsx(ComposerChip, { sign: "#", label: r.label }, `${r.kind}:${r.label}:${i}`)))] })), item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
174
+ case 'text':
175
+ 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" }))] }));
176
+ case 'step':
177
+ return _jsx(Step, { step: item, live: !!item.live });
178
+ case 'draft':
179
+ return _jsx(Draft, { draft: item, onSettle: onSettle, busy: busy });
180
+ case 'handoff':
181
+ return _jsx(HandOff, { item: item, onSettle: onSettle, busy: busy });
182
+ case 'advice':
183
+ 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
184
+ ? `Asking ${item.label}…`
185
+ : item.error
186
+ ? `${item.label} could not advise (${item.error})`
187
+ : `Asked ${item.label}`] }));
188
+ case 'compacted':
189
+ return _jsx(Rule, { children: "Compacted: earlier messages are sent as a summary" });
190
+ case 'stopped':
191
+ return _jsx(Rule, { children: item.reason === 'cap' ? "Stopped at this month's spend cap" : 'Stopped' });
192
+ case 'error':
193
+ 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] }));
194
+ }
195
+ }
196
+ 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 }));
197
+ function Step({ step, live }) {
198
+ const undo = useContext(UndoWrite);
199
+ const [undoing, setUndoing] = useState(false);
200
+ const w = step.write;
201
+ const take = async () => {
202
+ if (!undo)
203
+ return;
204
+ setUndoing(true);
205
+ try {
206
+ await undo(step);
207
+ }
208
+ catch (e) {
209
+ lumenToast.error(errorText(e));
210
+ }
211
+ finally {
212
+ setUndoing(false);
213
+ }
214
+ };
215
+ 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" })) : w ? (_jsx(PencilIcon, { className: "size-3.5 shrink-0" })) : 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] }), w?.status === 'undone' ? (_jsx("span", { className: "shrink-0", children: "\u00B7 Undone" })) : (w?.undo &&
216
+ undo && (_jsxs(Button, { size: "sm", variant: "ghost", className: "ml-auto h-6 shrink-0 px-2 text-xs", disabled: undoing, onClick: () => void take(), children: [undoing ? _jsx(Spinner, {}) : _jsx(Undo2Icon, {}), "Undo"] })))] }));
217
+ }
218
+ /** The arguments as short lines: `name: value`. */
219
+ function argLines(args) {
220
+ if (!args || typeof args !== 'object')
221
+ return [];
222
+ return Object.entries(args).map(([k, v]) => [
223
+ k,
224
+ typeof v === 'string' ? v : JSON.stringify(v),
225
+ ]);
226
+ }
227
+ /** One line that says what a card would change: its first few values. */
228
+ const argSummary = (args) => argLines(args)
229
+ .slice(0, 3)
230
+ .map(([, v]) => v)
231
+ .join(' · ');
232
+ function DraftStatus({ draft }) {
233
+ return draft.status === 'saved' ? (_jsxs("span", { className: "flex shrink-0 items-center gap-1 text-gain text-xs", children: [_jsx(CheckIcon, { className: "size-3.5" }), " Saved"] })) : draft.status === 'dismissed' ? (_jsx("span", { className: "shrink-0 text-muted-foreground text-xs", children: "Dismissed" })) : draft.status === 'failed' ? (_jsx("span", { className: "shrink-0 text-loss text-xs", children: "Failed" })) : null;
234
+ }
235
+ /** Saves or dismisses a card, a toast on failure. */
236
+ function useSettle(onSettle) {
237
+ const [pending, setPending] = useState(null);
238
+ const go = async (draft, action) => {
239
+ setPending(action);
240
+ try {
241
+ await onSettle(draft, action);
242
+ }
243
+ catch (e) {
244
+ lumenToast.error(errorText(e));
245
+ }
246
+ finally {
247
+ setPending(null);
248
+ }
249
+ };
250
+ return { pending, go };
251
+ }
252
+ /**
253
+ * A drafted change. Open, it shows what it would run with and Save or Dismiss; settled, or as a
254
+ * row in a stack of cards, it is one line that opens in place.
255
+ */
256
+ function Draft({ draft, onSettle, busy, row, }) {
257
+ const { pending, go } = useSettle(onSettle);
258
+ const [open, setOpen] = useState(!row && draft.status === 'open');
259
+ const lines = argLines(draft.args);
260
+ const actions = draft.status === 'open' && (_jsxs("div", { className: "flex shrink-0 justify-end gap-1", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: !!pending || busy, onClick: () => void go(draft, 'dismiss'), children: "Dismiss" }), _jsxs(Button, { size: "sm", disabled: !!pending || busy, onClick: () => void go(draft, 'save'), children: [pending === 'save' ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), "Save"] })] }));
261
+ return (_jsxs(Collapsible, { open: open, onOpenChange: setOpen, "data-slot": "assistant-draft", className: cn('flex flex-col text-sm', row ? 'border-b px-1 py-1.5 last:border-b-0' : 'gap-2 rounded-lg border bg-card p-3'), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": `${open ? 'Hide' : 'Show'} ${draft.title}`, className: "flex min-w-0 flex-1 items-center gap-1.5 text-left", children: [_jsx(ChevronRightIcon, { className: cn('size-3.5 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90') }), _jsx("span", { className: "shrink-0 font-medium", children: draft.title }), !open && (_jsx("span", { className: "min-w-0 truncate text-muted-foreground text-xs", children: argSummary(draft.args) }))] }) }), _jsx(DraftStatus, { draft: draft }), row && draft.status === 'open' && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: XIcon, label: `Dismiss ${draft.title}`, size: "icon-xs", disabled: !!pending || busy, onClick: () => void go(draft, 'dismiss') }), _jsx(IconButton, { icon: pending === 'save' ? Spinner : CheckIcon, label: `Save ${draft.title}`, size: "icon-xs", variant: "outline", disabled: !!pending || busy, onClick: () => void go(draft, 'save') })] }))] }), _jsxs(CollapsibleContent, { className: cn('flex flex-col gap-2', row && 'pt-1.5 pl-5'), children: [_jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs", children: lines.slice(0, 12).map(([k, v]) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "text-muted-foreground", children: k }), _jsx("dd", { className: "font-code break-words", children: v })] }, k))) }), !row && actions] }), draft.error && _jsx("p", { className: "text-loss text-xs", children: draft.error })] }));
262
+ }
263
+ /** Two or more cards in a row: one stack with a count, Save all and Dismiss all, a line each. */
264
+ function DraftStack({ drafts, onSettle, busy, }) {
265
+ const [all, setAll] = useState(null);
266
+ const open = drafts.filter((d) => d.status === 'open');
267
+ const saved = drafts.filter((d) => d.status === 'saved').length;
268
+ const failed = drafts.filter((d) => d.status === 'failed').length;
269
+ // One at a time, in order: a later change may build on an earlier one.
270
+ const settleAll = async (action) => {
271
+ setAll(action);
272
+ try {
273
+ for (const d of open)
274
+ await onSettle(d, action);
275
+ }
276
+ catch (e) {
277
+ lumenToast.error(errorText(e));
278
+ }
279
+ finally {
280
+ setAll(null);
281
+ }
282
+ };
283
+ return (_jsxs("div", { "data-slot": "assistant-draft-stack", className: "flex flex-col rounded-lg border bg-card p-2 text-sm", children: [_jsxs("div", { className: "flex items-center gap-2 px-1 pb-1.5", children: [_jsxs("span", { className: "min-w-0 flex-1 text-xs", children: [_jsxs("span", { className: "font-medium", children: [drafts.length, " changes"] }), _jsxs("span", { className: "text-muted-foreground", children: [' · ', [
284
+ open.length && `${open.length} to review`,
285
+ saved && `${saved} saved`,
286
+ failed && `${failed} failed`,
287
+ ]
288
+ .filter(Boolean)
289
+ .join(' · ') || 'all settled'] })] }), open.length > 1 && (_jsxs(_Fragment, { children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: !!all || busy, onClick: () => void settleAll('dismiss'), children: "Dismiss all" }), _jsxs(Button, { size: "sm", disabled: !!all || busy, onClick: () => void settleAll('save'), children: [all === 'save' ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), "Save all"] })] }))] }), _jsx("div", { className: "flex flex-col border-t", children: drafts.map((d) => (_jsx(Draft, { draft: d, onSettle: onSettle, busy: busy || !!all, row: true }, d.id))) })] }));
290
+ }
291
+ const WHY = {
292
+ asked: 'you asked',
293
+ judgement: 'needs judgement',
294
+ stuck: 'stuck',
295
+ check_failed: 'a check failed',
296
+ };
297
+ function HandOff({ item, onSettle, busy, }) {
298
+ 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 })] }));
299
+ }
300
+ /** A chip over the message box: a tier picked with `@` or a thing pointed at with `#`. */
301
+ function ComposerChip({ sign, label, onRemove, }) {
302
+ return (_jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: sign }), _jsx("span", { className: "truncate", children: label }), onRemove && (_jsx("button", { type: "button", "aria-label": `Remove ${label}`, className: "text-muted-foreground hover:text-foreground", onClick: onRemove, children: _jsx(XIcon, { className: "size-3" }) }))] }));
303
+ }
304
+ /**
305
+ * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
306
+ * triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
307
+ * this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
308
+ */
309
+ export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], onSend, onStop, }) {
310
+ const [text, setText] = useState('');
311
+ const [caret, setCaret] = useState(0);
312
+ const [closed, setClosed] = useState(null);
313
+ const [tier, setTier] = useState(null);
314
+ const [refs, setRefs] = useState([]);
315
+ const list = useRef(null);
316
+ const box = useRef(null);
317
+ const selection = usePageSelection();
318
+ // An app's compose request (`composeAssistant`): its chips and text, ready to finish.
319
+ const [request, take] = useComposeRequest();
320
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only a new request fills the box.
321
+ useEffect(() => {
322
+ if (!request)
323
+ return;
324
+ take();
325
+ if (request.refs?.length)
326
+ setRefs((p) => [...p, ...(request.refs ?? [])]);
327
+ if (request.tier)
328
+ setTier(request.tier);
329
+ if (request.text !== undefined) {
330
+ setText(request.text);
331
+ setCaret(request.text.length);
332
+ }
333
+ requestAnimationFrame(() => box.current?.focus());
334
+ }, [request]);
335
+ const m = /^\/(\S*)(?:\s(.*))?$/s.exec(text);
336
+ const cmd = m && m[2] !== undefined ? commands.find((c) => c.name === m[1]) : undefined;
337
+ // `@` or `#` as the word the caret is in: `pick #sto|` lists the sources' items matching "sto".
338
+ const word = /(^|\s)([@#])([^\s@#]*)$/.exec(text.slice(0, caret));
339
+ const trigger = word ? word[2] : null;
340
+ const asked = word?.[3] ?? '';
341
+ const open = closed !== `${text}|${caret}`;
342
+ const slash = m && commands.length ? (m[2] === undefined ? 'command' : cmd?.options ? 'arg' : null) : null;
343
+ const at = trigger === '@' && tiers.length > 1
344
+ ? '@'
345
+ : trigger === '#' && (sources.length || selection)
346
+ ? '#'
347
+ : null;
348
+ const picking = open ? (slash ?? at) : null;
349
+ const extra = () => ({
350
+ ...(tier ? { tier } : {}),
351
+ ...(refs.length ? { refs } : {}),
352
+ });
353
+ const clear = () => {
354
+ setText('');
355
+ setCaret(0);
356
+ setTier(null);
357
+ setRefs([]);
358
+ };
359
+ const run = async (c, args) => {
360
+ const keep = extra();
361
+ clear();
362
+ try {
363
+ const out = await c.run(args.trim());
364
+ if (out)
365
+ onSend(out, keep);
366
+ }
367
+ catch (e) {
368
+ lumenToast.error(errorText(e));
369
+ }
370
+ };
371
+ const send = () => {
372
+ if (!text.trim() || disabled)
373
+ return;
374
+ const c = m ? commands.find((x) => x.name === m[1]) : undefined;
375
+ if (c) {
376
+ void run(c, m?.[2] ?? '');
377
+ return;
378
+ }
379
+ onSend(text, extra());
380
+ clear();
381
+ };
382
+ const pickCommand = (o) => {
383
+ const c = commands.find((x) => x.name === o.value);
384
+ if (!c)
385
+ return;
386
+ if (c.args) {
387
+ setText(`/${c.name} `);
388
+ setCaret(c.name.length + 2);
389
+ box.current?.focus();
390
+ }
391
+ else
392
+ void run(c, '');
393
+ };
394
+ // Takes the `@word` or `#word` out of the text once its pick is a chip.
395
+ const dropWord = () => {
396
+ if (!word)
397
+ return;
398
+ const start = caret - 1 - asked.length;
399
+ const next = text.slice(0, start) + text.slice(caret).replace(/^ /, '');
400
+ setText(next);
401
+ setCaret(start);
402
+ requestAnimationFrame(() => {
403
+ box.current?.focus();
404
+ box.current?.setSelectionRange(start, start);
405
+ });
406
+ };
407
+ const sourceItems = new Map();
408
+ const picked = selection ? selectionRef(selection) : null;
409
+ if (picked)
410
+ sourceItems.set('selection', picked);
411
+ const sourceOptions = picking === '#'
412
+ ? [
413
+ ...(picked
414
+ ? [
415
+ {
416
+ value: 'selection',
417
+ label: picked.label,
418
+ group: 'Selected text',
419
+ ...(selection?.where ? { detail: selection.where } : {}),
420
+ ...(refs.some((r) => sameRef(r, picked)) ? { disabled: 'Added' } : {}),
421
+ },
422
+ ]
423
+ : []),
424
+ ...sources.flatMap((s) => s.items(asked).map((i) => {
425
+ const value = `${s.id}:${i.id}`;
426
+ sourceItems.set(value, i.ref);
427
+ return {
428
+ value,
429
+ label: i.label,
430
+ group: s.label,
431
+ ...(i.detail ? { detail: i.detail } : {}),
432
+ ...(s.icon ? { icon: s.icon } : {}),
433
+ ...(i.keywords ? { keywords: i.keywords } : {}),
434
+ ...(i.target ? { target: i.target } : {}),
435
+ ...(refs.some((r) => sameRef(r, i.ref)) ? { disabled: 'Added' } : {}),
436
+ };
437
+ })),
438
+ ]
439
+ : [];
440
+ const tierLabel = tiers.find((t) => t.id === tier)?.label;
441
+ const hints = [
442
+ commands.length && '/',
443
+ tiers.length > 1 && '@',
444
+ (sources.length || selection) && '#',
445
+ ].filter(Boolean);
446
+ 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 })] })), picking === 'command' && (_jsx(LumenSelectorList, { handle: list, label: "Commands", className: "mb-2", fuzzy: true, query: m?.[1] ?? '', options: commands.map((c) => ({
447
+ value: c.name,
448
+ label: `/${c.name}`,
449
+ ...(c.args ? { detail: c.args } : {}),
450
+ description: c.about,
451
+ })), empty: "No command matches", onPick: pickCommand, onClose: () => setClosed(`${text}|${caret}`) })), picking === 'arg' && cmd?.options && (_jsx(LumenSelectorList, { handle: list, label: `/${cmd.name}`, className: "mb-2", fuzzy: true, query: m?.[2] ?? '', options: cmd.options(), onPick: (o) => void run(cmd, o.value), onClose: () => setClosed(`${text}|${caret}`) })), picking === '@' && (_jsx(LumenSelectorList, { handle: list, label: "Answer this message on", className: "mb-2", fuzzy: true, query: asked, options: tiers.map((t) => ({
452
+ value: t.id,
453
+ label: t.label,
454
+ ...(t.model_label ? { detail: t.model_label } : {}),
455
+ })), empty: "No tier matches", onPick: (o) => {
456
+ setTier(o.value);
457
+ dropWord();
458
+ }, onClose: () => setClosed(`${text}|${caret}`) })), picking === '#' && (_jsx(LumenSelectorList, { handle: list, label: "Point at", className: "mb-2", query: asked, options: sourceOptions, empty: "Nothing matches", onPick: (o) => {
459
+ const r = sourceItems.get(o.value);
460
+ if (r)
461
+ setRefs((p) => [...p, r]);
462
+ dropWord();
463
+ }, onClose: () => setClosed(`${text}|${caret}`) })), (tierLabel || refs.length > 0) && (_jsxs("div", { className: "mb-2 flex flex-wrap gap-1", children: [tierLabel && _jsx(ComposerChip, { sign: "@", label: tierLabel, onRemove: () => setTier(null) }), refs.map((r, i) => (_jsx(ComposerChip, { sign: "#", label: r.label, onRemove: () => setRefs((p) => p.filter((_, j) => j !== i)) }, `${r.kind}:${r.label}:${i}`)))] })), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { ref: box, "aria-label": `Message ${name}`, placeholder: `Ask ${name}…${hints.length ? ` (${hints.join(' ')})` : ''}`, className: "max-h-48 min-h-11 resize-none pr-11", value: text, disabled: disabled, onChange: (e) => {
464
+ setText(e.target.value);
465
+ setCaret(e.target.selectionStart);
466
+ }, onSelect: (e) => setCaret(e.currentTarget.selectionStart), onKeyDown: (e) => {
467
+ if (picking && list.current?.key(e))
468
+ return;
469
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
470
+ e.preventDefault();
471
+ send();
472
+ }
473
+ } }), _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 })) })] })] }));
474
+ }
475
+ const sameRef = (a, b) => a.kind === b.kind && JSON.stringify(a) === JSON.stringify(b);
476
+ /** The chat's cost and tokens (a popup splits them per part), and how full the context is. */
477
+ function UsageLine({ usage }) {
478
+ if (!usage || !usage.calls)
479
+ return null;
480
+ const ctx = usage.context ?? 0;
481
+ 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) })] }))] }));
482
+ }
483
+ /**
484
+ * The assistant docked in LumenShell: put `button` in `headerActions` and `panel` in `panel`.
485
+ * Open or closed is this browser tab's own, and ⌘J toggles it.
486
+ */
487
+ export function useLumenAssistantDock(client, o = {
488
+ name: 'Assistant',
489
+ }) {
490
+ const open = assistantOpen.use();
491
+ const chat = useAssistantChat(client);
492
+ const [request] = useComposeRequest();
493
+ useEffect(() => {
494
+ if (request && !assistantOpen.get())
495
+ assistantOpen.write(true);
496
+ }, [request]);
497
+ const [unread, setUnread] = useState(false);
498
+ const wasBusy = useRef(false);
499
+ useEffect(() => {
500
+ if (wasBusy.current && !chat.busy && !assistantOpen.get())
501
+ setUnread(true);
502
+ wasBusy.current = chat.busy;
503
+ }, [chat.busy]);
504
+ const setOpen = (v) => {
505
+ assistantOpen.write(v);
506
+ if (v)
507
+ setUnread(false);
508
+ };
509
+ useAssistantHotkey(() => setOpen(!assistantOpen.get()));
510
+ const { name, ...props } = o;
511
+ return {
512
+ open,
513
+ setOpen,
514
+ chat,
515
+ button: (_jsx(LumenAssistantButton, { name: name, open: open, unread: unread, onOpen: () => setOpen(true) })),
516
+ panel: (_jsx(LumenAssistantPanel, { name: name, open: open, onOpenChange: setOpen, children: () => (_jsx(LumenAssistant, { client: client, chat: chat, onClose: () => setOpen(false), ...props })) })),
517
+ };
518
+ }
@@ -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[];