@columenlabs/lumen-ui 0.1.81 → 0.1.82

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,14 @@
1
+ import type { ChatItem } from '@columenlabs/lumen-ui-types/assistant';
2
+ /** The whole chat, its title as the heading. */
3
+ export declare function chatMarkdown(title: string, items: ChatItem[], name: string): string;
4
+ /** One turn: a message and everything the assistant did for it. */
5
+ export declare function turnMarkdown(items: ChatItem[], name: string): string;
6
+ /** Splits a chat into turns: each starts at a message from the person. */
7
+ export declare function chatTurns<T extends {
8
+ kind: string;
9
+ }>(items: T[]): {
10
+ from: number;
11
+ items: T[];
12
+ }[];
13
+ /** Puts Markdown on the clipboard and says so. */
14
+ export declare function copyMarkdown(md: string, what: string): void;
@@ -0,0 +1,74 @@
1
+ import { lumenToast } from '../lumen-toast.js';
2
+ /** The whole chat, its title as the heading. */
3
+ export function chatMarkdown(title, items, name) {
4
+ return [`# ${title || `${name} chat`}`, ...items.map((i) => itemMarkdown(i, 2, name))]
5
+ .filter(Boolean)
6
+ .join('\n\n')
7
+ .concat('\n');
8
+ }
9
+ /** One turn: a message and everything the assistant did for it. */
10
+ export function turnMarkdown(items, name) {
11
+ return `${items
12
+ .map((i) => itemMarkdown(i, 2, name))
13
+ .filter(Boolean)
14
+ .join('\n\n')}\n`;
15
+ }
16
+ /** Splits a chat into turns: each starts at a message from the person. */
17
+ export function chatTurns(items) {
18
+ const starts = items.flatMap((x, i) => (x.kind === 'user' || i === 0 ? [i] : []));
19
+ return starts.map((from, t) => ({
20
+ from,
21
+ items: items.slice(from, starts[t + 1] ?? items.length),
22
+ }));
23
+ }
24
+ /** Puts Markdown on the clipboard and says so. */
25
+ export function copyMarkdown(md, what) {
26
+ void navigator.clipboard.writeText(md).then(() => lumenToast(`${what} copied as Markdown`), () => lumenToast.error(`Couldn't copy the ${what.toLowerCase()}`));
27
+ }
28
+ const fence = (body) => {
29
+ const ticks = body.includes('```') ? '````' : '```';
30
+ return `${ticks}json\n${body}\n${ticks}`;
31
+ };
32
+ function itemMarkdown(i, depth, name) {
33
+ const h = '#'.repeat(Math.min(depth, 6));
34
+ switch (i.kind) {
35
+ case 'user': {
36
+ const refs = i.refs?.length
37
+ ? `\n\nPointing at: ${i.refs.map((r) => r.label).join(', ')}`
38
+ : '';
39
+ return `${h} You\n\n${i.text}${refs}`;
40
+ }
41
+ case 'text':
42
+ return `${h} ${name}\n\n${i.text}`;
43
+ case 'step': {
44
+ const facts = [
45
+ i.rows !== undefined ? `${i.rows} rows` : '',
46
+ `${(i.ms / 1000).toFixed(1)} s`,
47
+ i.write ? i.write.status : '',
48
+ ].filter(Boolean);
49
+ return `**${i.ok ? 'Tool' : 'Failed'}: ${i.title}** (${facts.join(', ')})${i.error ? `\n\nError: ${i.error}` : ''}`;
50
+ }
51
+ case 'draft':
52
+ return [
53
+ `**Card: ${i.title}** (${i.status})`,
54
+ fence(JSON.stringify(i.args, null, 2)),
55
+ i.error ? `Error: ${i.error}` : '',
56
+ ]
57
+ .filter(Boolean)
58
+ .join('\n\n');
59
+ case 'handoff':
60
+ return [
61
+ `${h} ${i.label} (${i.why})`,
62
+ `Task: ${i.task}`,
63
+ ...i.items.map((x) => itemMarkdown(x, depth + 1, i.label)),
64
+ ].join('\n\n');
65
+ case 'advice':
66
+ return `_${i.error ? 'Could not ask' : 'Asked'} ${i.label} for advice._`;
67
+ case 'compacted':
68
+ return '_Compacted._';
69
+ case 'stopped':
70
+ return '_Stopped._';
71
+ case 'error':
72
+ return `**Error${i.code ? ` (${i.code})` : ''}:** ${i.text}`;
73
+ }
74
+ }
@@ -52,8 +52,10 @@ export interface ComposerExtra {
52
52
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
53
53
  * this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
54
54
  */
55
- export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, onSend, onStop, }: {
55
+ export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, page, onSend, onStop, }: {
56
56
  name: string;
57
+ /** The page's name for `#` This page; the document's title by default. */
58
+ page?: string;
57
59
  busy: boolean;
58
60
  queued: string | null;
59
61
  disabled?: boolean;
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
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';
3
+ import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, LightbulbIcon, PencilIcon, PlusIcon, SparklesIcon, SquareIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
4
4
  import { createContext, useContext, useEffect, useRef, useState } from 'react';
5
5
  import Markdown from 'react-markdown';
6
6
  import { LumenAssistantButton, LumenAssistantChats, LumenAssistantPanel, assistantOpen, useAssistantHotkey, } from '../assistant-panel.js';
@@ -9,13 +9,15 @@ import { errorText } from '../integrations.js';
9
9
  import { cn } from '../lib/utils.js';
10
10
  import { LumenSelector, LumenSelectorList, } from '../lumen-selector.js';
11
11
  import { lumenToast } from '../lumen-toast.js';
12
+ import { cardsOnScreen, kindName, pointAt, sectionsOnScreen, targetEl, textOf, } from '../spotlight.js';
12
13
  import { Alert, AlertDescription } from '../ui/alert.js';
13
14
  import { Button } from '../ui/button.js';
14
15
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
15
16
  import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
16
17
  import { Spinner } from '../ui/spinner.js';
17
18
  import { Textarea } from '../ui/textarea.js';
18
- import { selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
19
+ import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
20
+ import { composeAssistant, selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
19
21
  import { useAssistantChat } from './use-assistant-chat.js';
20
22
  /** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
21
23
  const CONTEXT_MAX = 200_000;
@@ -48,6 +50,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
48
50
  enabled: false,
49
51
  });
50
52
  const s = status.data;
53
+ const name = s?.name ?? 'Assistant';
51
54
  // A finished answer changes the spend and the chats list.
52
55
  useEffect(() => {
53
56
  if (chat.busy)
@@ -71,6 +74,16 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
71
74
  about: 'Summarise the chat so far to free up context',
72
75
  run: () => '/compact',
73
76
  },
77
+ {
78
+ name: 'copy',
79
+ about: 'Copy the last turn as Markdown',
80
+ run: () => {
81
+ const last = chatTurns(chat.items).at(-1);
82
+ if (last)
83
+ copyMarkdown(turnMarkdown(last.items, name), 'Turn');
84
+ return undefined;
85
+ },
86
+ },
74
87
  {
75
88
  name: 'clear',
76
89
  about: 'Start a new chat',
@@ -126,7 +139,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
126
139
  id: c.id,
127
140
  title: c.title,
128
141
  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 })] }));
142
+ })) ?? (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: CopyIcon, label: "Copy chat", disabled: !chat.items.length, onClick: () => copyMarkdown(chatMarkdown(chat.title, chat.items, name), 'Chat') }), _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(AssistantName.Provider, { value: name, children: _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, page: context?.label, onSend: (text, extra) => chat.send(text, { ...(context ? { context } : {}), ...extra }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage })] }));
130
143
  }
131
144
  function Messages({ chat, status, onSettings, }) {
132
145
  const end = useRef(null);
@@ -135,7 +148,7 @@ function Messages({ chat, status, onSettings, }) {
135
148
  useEffect(() => {
136
149
  end.current?.scrollIntoView({ block: 'end' });
137
150
  }, [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 })] }));
151
+ 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, turns: true }) }) })), 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
152
  }
140
153
  const isLive = (i) => !!i.live || (i.kind === 'handoff' && i.items.some(isLive));
141
154
  const WAIT = {
@@ -145,11 +158,19 @@ const WAIT = {
145
158
  app_cap: () => "You reached this month's cap on the shared keys. Add your own key, or wait for next month.",
146
159
  own_cap: () => 'Your own keys reached their monthly cap. Raise it in Settings ▸ Model providers.',
147
160
  };
161
+ /** The assistant's name, for a copied turn's headings. */
162
+ const AssistantName = createContext('Assistant');
148
163
  /** A tier's name for a message's `@` chip. */
149
164
  const TierNames = createContext((id) => id);
150
165
  /** Takes back a change made at once (Undo on its line). */
151
166
  const UndoWrite = createContext(null);
152
- function Items({ items, onSettle, busy, }) {
167
+ function Items({ items, onSettle, busy, turns, }) {
168
+ const name = useContext(AssistantName);
169
+ if (turns)
170
+ return (_jsx("div", { className: "flex flex-col gap-3", children: chatTurns(items).map((t) => {
171
+ const open = busy && t.from + t.items.length === items.length;
172
+ return (_jsxs("div", { "data-slot": "assistant-turn", className: "group/turn flex flex-col gap-3", children: [_jsx(Items, { items: t.items, onSettle: onSettle, busy: busy }), !open && (_jsx("div", { className: "-mt-2 flex justify-end opacity-0 transition-opacity group-hover/turn:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100", children: _jsx(IconButton, { icon: CopyIcon, label: "Copy this turn", className: "size-6 text-muted-foreground", onClick: () => copyMarkdown(turnMarkdown(t.items, name), 'Turn') }) }))] }, t.from));
173
+ }) }));
153
174
  // Cards drafted one after another sit in one stack, so many of them skim as lines.
154
175
  const rows = [];
155
176
  items.forEach((item, at) => {
@@ -306,7 +327,7 @@ function ComposerChip({ sign, label, onRemove, }) {
306
327
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
307
328
  * this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
308
329
  */
309
- export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], onSend, onStop, }) {
330
+ export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], page, onSend, onStop, }) {
310
331
  const [text, setText] = useState('');
311
332
  const [caret, setCaret] = useState(0);
312
333
  const [closed, setClosed] = useState(null);
@@ -340,11 +361,7 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
340
361
  const asked = word?.[3] ?? '';
341
362
  const open = closed !== `${text}|${caret}`;
342
363
  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;
364
+ const at = trigger === '@' && tiers.length > 1 ? '@' : trigger === '#' ? '#' : null;
348
365
  const picking = open ? (slash ?? at) : null;
349
366
  const extra = () => ({
350
367
  ...(tier ? { tier } : {}),
@@ -404,10 +421,70 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
404
421
  box.current?.setSelectionRange(start, start);
405
422
  });
406
423
  };
424
+ // Each `#` row makes its chip when picked, so what it carries (a card's words) is read then.
407
425
  const sourceItems = new Map();
408
426
  const picked = selection ? selectionRef(selection) : null;
427
+ const added = (r) => refs.some((x) => sameRef(x, r)) ? { disabled: 'Added' } : {};
428
+ const onPage = [];
429
+ if (picking === '#') {
430
+ const pageRef = () => ({
431
+ kind: 'page',
432
+ label: page || document.title || 'This page',
433
+ path: location.pathname + location.search,
434
+ text: textOf(document.querySelector('main') ?? document.body, 6000),
435
+ });
436
+ sourceItems.set('page', pageRef);
437
+ onPage.push({
438
+ value: 'page',
439
+ label: 'This page',
440
+ group: 'On this page',
441
+ icon: FileTextIcon,
442
+ detail: page || document.title,
443
+ keywords: ['screen', 'page'],
444
+ ...added({ kind: 'page', label: page || document.title || 'This page' }),
445
+ });
446
+ for (const c of cardsOnScreen()) {
447
+ const label = c.title || kindName(c.kind);
448
+ const ref = { kind: 'card', label, id: c.id };
449
+ sourceItems.set(`card:${c.id}`, () => ({
450
+ ...ref,
451
+ card_kind: kindName(c.kind),
452
+ text: textOf(targetEl(`card:${c.id}`)),
453
+ }));
454
+ onPage.push({
455
+ value: `card:${c.id}`,
456
+ label,
457
+ group: 'On this page',
458
+ detail: kindName(c.kind),
459
+ target: `card:${c.id}`,
460
+ ...added(ref),
461
+ });
462
+ }
463
+ for (const x of sectionsOnScreen().slice(0, 60)) {
464
+ const ref = { kind: x.kind, label: x.label, id: x.id };
465
+ sourceItems.set(`section:${x.id}`, () => ({
466
+ ...ref,
467
+ text: textOf(targetEl(`section:${x.id}`)),
468
+ }));
469
+ onPage.push({
470
+ value: `section:${x.id}`,
471
+ label: x.label,
472
+ group: 'On this page',
473
+ detail: x.kind,
474
+ target: `section:${x.id}`,
475
+ ...added(ref),
476
+ });
477
+ }
478
+ onPage.push({
479
+ value: 'point',
480
+ label: 'Point at something…',
481
+ group: 'On this page',
482
+ icon: CrosshairIcon,
483
+ description: 'Click a card or a part of the page; Esc cancels',
484
+ });
485
+ }
409
486
  if (picked)
410
- sourceItems.set('selection', picked);
487
+ sourceItems.set('selection', () => picked);
411
488
  const sourceOptions = picking === '#'
412
489
  ? [
413
490
  ...(picked
@@ -417,13 +494,14 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
417
494
  label: picked.label,
418
495
  group: 'Selected text',
419
496
  ...(selection?.where ? { detail: selection.where } : {}),
420
- ...(refs.some((r) => sameRef(r, picked)) ? { disabled: 'Added' } : {}),
497
+ ...added(picked),
421
498
  },
422
499
  ]
423
500
  : []),
501
+ ...onPage,
424
502
  ...sources.flatMap((s) => s.items(asked).map((i) => {
425
503
  const value = `${s.id}:${i.id}`;
426
- sourceItems.set(value, i.ref);
504
+ sourceItems.set(value, () => i.ref);
427
505
  return {
428
506
  value,
429
507
  label: i.label,
@@ -432,17 +510,31 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
432
510
  ...(s.icon ? { icon: s.icon } : {}),
433
511
  ...(i.keywords ? { keywords: i.keywords } : {}),
434
512
  ...(i.target ? { target: i.target } : {}),
435
- ...(refs.some((r) => sameRef(r, i.ref)) ? { disabled: 'Added' } : {}),
513
+ ...added(i.ref),
436
514
  };
437
515
  })),
438
516
  ]
439
517
  : [];
518
+ // Point at…: on a phone the sheet covers the page, so it steps aside while the person picks and
519
+ // comes back with the pick as a chip and the words typed so far.
520
+ const point = async () => {
521
+ const phone = window.matchMedia('(max-width: 767px)').matches;
522
+ const typed = text.slice(0, caret - 1 - asked.length) + text.slice(caret).replace(/^ /, '');
523
+ if (phone)
524
+ assistantOpen.write(false);
525
+ const p = await pointAt();
526
+ if (phone)
527
+ composeAssistant({
528
+ text: typed,
529
+ refs: [...refs, ...(p ? [pointedRef(p)] : [])],
530
+ ...(tier ? { tier } : {}),
531
+ });
532
+ else if (p)
533
+ setRefs((prev) => [...prev, pointedRef(p)]);
534
+ requestAnimationFrame(() => box.current?.focus());
535
+ };
440
536
  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);
537
+ const hints = [commands.length && '/', tiers.length > 1 && '@', '#'].filter(Boolean);
446
538
  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
539
  value: c.name,
448
540
  label: `/${c.name}`,
@@ -456,10 +548,14 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
456
548
  setTier(o.value);
457
549
  dropWord();
458
550
  }, 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);
551
+ dropWord();
552
+ if (o.value === 'point') {
553
+ void point();
554
+ return;
555
+ }
556
+ const r = sourceItems.get(o.value)?.();
460
557
  if (r)
461
558
  setRefs((p) => [...p, r]);
462
- dropWord();
463
559
  }, 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
560
  setText(e.target.value);
465
561
  setCaret(e.target.selectionStart);
@@ -472,7 +568,21 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
472
568
  }
473
569
  } }), _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
570
  }
475
- const sameRef = (a, b) => a.kind === b.kind && JSON.stringify(a) === JSON.stringify(b);
571
+ // The same thing pointed at twice: its kind and what finds it, whatever its words read then.
572
+ const refKey = (r) => r.kind === 'page'
573
+ ? 'page'
574
+ : JSON.stringify([r.kind, r.id ?? r.label, r.kind === 'selection' ? r.text : null]);
575
+ const sameRef = (a, b) => refKey(a) === refKey(b);
576
+ /** A card or section the person pointed at, as a chip with its words. */
577
+ const pointedRef = (p) => p.kind === 'card'
578
+ ? {
579
+ kind: 'card',
580
+ label: p.card.title || kindName(p.card.kind),
581
+ id: p.card.id,
582
+ card_kind: kindName(p.card.kind),
583
+ text: textOf(p.el),
584
+ }
585
+ : { kind: p.section.kind, label: p.section.label, id: p.section.id, text: textOf(p.el) };
476
586
  /** The chat's cost and tokens (a popup splits them per part), and how full the context is. */
477
587
  function UsageLine({ usage }) {
478
588
  if (!usage || !usage.calls)
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { LumenViewCard, type LumenViewCardProps } from './lumen-view-card.js';
4
4
  export { LumenSelector, LumenSelectorList, type LumenSelectorListHandle, type LumenSelectorProps, type LumenOption, type LumenOptionGroup, SelectorRowContent, } from './lumen-selector.js';
5
5
  export { ColorChip, ColorDot, colorIcon } from './color-dot.js';
6
6
  export { EditCell, EditInputCell } from './view-edit-cell.js';
7
- export { type CardOnScreen, cardsOnScreen, kindName, onScreen, pointAtCard, spotlight, } from './spotlight.js';
7
+ export { type CardOnScreen, cardsOnScreen, kindName, onScreen, pointAt, pointAtCard, type PointedAt, type SectionOnScreen, sectionsOnScreen, spotlight, targetEl, textOf, } from './spotlight.js';
8
8
  export { LinkDot, LinkPicker, type LinkColor, linkVar } from './lumen-links.js';
9
9
  export { CardSegments, CardSwitch } from './card-switch.js';
10
10
  export { PageInput, PageInputs } from './page-inputs.js';
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ export { LumenViewCard } from './lumen-view-card.js';
11
11
  export { LumenSelector, LumenSelectorList, SelectorRowContent, } from './lumen-selector.js';
12
12
  export { ColorChip, ColorDot, colorIcon } from './color-dot.js';
13
13
  export { EditCell, EditInputCell } from './view-edit-cell.js';
14
- export { cardsOnScreen, kindName, onScreen, pointAtCard, spotlight, } from './spotlight.js';
14
+ export { cardsOnScreen, kindName, onScreen, pointAt, pointAtCard, sectionsOnScreen, spotlight, targetEl, textOf, } from './spotlight.js';
15
15
  export { LinkDot, LinkPicker, linkVar } from './lumen-links.js';
16
16
  export { CardSegments, CardSwitch } from './card-switch.js';
17
17
  export { PageInput, PageInputs } from './page-inputs.js';
@@ -4,14 +4,29 @@ export interface CardOnScreen {
4
4
  /** `view` for a table card, else the widget's kind (chart, tiles, ...). */
5
5
  kind: string;
6
6
  }
7
+ export interface SectionOnScreen {
8
+ /** `data-assistant-id`, else its name. */
9
+ id: string;
10
+ /** `data-assistant-section`: what the person calls it. */
11
+ label: string;
12
+ /** `data-assistant-kind`: what it is (role, story), `section` by default. */
13
+ kind: string;
14
+ }
15
+ export declare const sectionOf: (el: HTMLElement) => SectionOnScreen;
16
+ /** The marked sections on the page now, top to bottom (the assistant's own are left out). */
17
+ export declare const sectionsOnScreen: () => SectionOnScreen[];
18
+ /** An element's text as the model reads it: its words, cut to `max` characters. */
19
+ export declare const textOf: (el: Element | null, max?: number) => string;
20
+ /** The element a `card:<id>` or `section:<id>` target stands for. */
21
+ export declare const targetEl: (target: string) => HTMLElement | null;
7
22
  export declare const cardOf: (el: HTMLElement) => CardOnScreen;
8
23
  /** The cards on the page now, top to bottom. */
9
24
  export declare const cardsOnScreen: () => CardOnScreen[];
10
25
  /** Where a target is on screen now: a card on the page, a sidebar page, or neither. */
11
- export declare function onScreen(target: string | null | undefined): 'card' | 'nav' | null;
26
+ export declare function onScreen(target: string | null | undefined): 'card' | 'section' | 'nav' | null;
12
27
  /**
13
- * Lights one target (`card:<id>` or an app path) and dims the page around it; null clears. The
14
- * target scrolls into view only after a short hover.
28
+ * Lights one target (`card:<id>`, `section:<id>` or an app path) and dims the page around it; null
29
+ * clears. The target scrolls into view only after a short hover.
15
30
  */
16
31
  export declare function spotlight(target: string | null | undefined, name?: string): void;
17
32
  /**
@@ -19,5 +34,20 @@ export declare function spotlight(target: string | null | undefined, name?: stri
19
34
  * name; a click picks it, Esc or a click off any card cancels.
20
35
  */
21
36
  export declare function pointAtCard(): Promise<CardOnScreen | null>;
37
+ /** What `pointAt` picked: a card, or a section an app marked. */
38
+ export type PointedAt = {
39
+ kind: 'card';
40
+ card: CardOnScreen;
41
+ el: HTMLElement;
42
+ } | {
43
+ kind: 'section';
44
+ section: SectionOnScreen;
45
+ el: HTMLElement;
46
+ };
47
+ /**
48
+ * Point at a card or a marked section: the one under the cursor lights up with its name, the
49
+ * innermost when they nest; a click picks it, Esc or a click off any cancels.
50
+ */
51
+ export declare const pointAt: () => Promise<PointedAt | null>;
22
52
  /** A card's style (`kind:style`) as a person says it: a table, a donut, a page's own card. */
23
53
  export declare function kindName(styleId: string): string;
package/dist/spotlight.js CHANGED
@@ -5,10 +5,37 @@
5
5
  // sidebar page gets the sidebar's muted fill. No border, no glow.
6
6
  // pointAtCard() the cards dim, the one under the cursor lights up; a click picks it,
7
7
  // Esc cancels. Works on any LumenLayout card with no per-page setup.
8
+ // pointAt() the same over cards and page sections: any element an app marks
9
+ // `data-assistant-section="<name>"` (a résumé role, a story, a job),
10
+ // with `data-assistant-kind` (what it is) and `data-assistant-id`.
11
+ // A section's target is `section:<id>`; lit, it gets a muted fill.
8
12
  //
9
13
  // Both drive the DOM directly through data attributes the theme styles: one spotlight at a time.
10
14
  import { WIDGET_STYLES } from '@columenlabs/lumen-ui-types/views';
11
15
  const CARD = '[data-slot="lumen-card"][data-card]';
16
+ const SECTION = '[data-assistant-section]';
17
+ const sectionId = (el) => el.dataset.assistantId || el.dataset.assistantSection || '';
18
+ const sectionEl = (id) => [...document.querySelectorAll(SECTION)].find((el) => sectionId(el) === id) ?? null;
19
+ export const sectionOf = (el) => ({
20
+ id: sectionId(el),
21
+ label: el.dataset.assistantSection ?? '',
22
+ kind: el.dataset.assistantKind || 'section',
23
+ });
24
+ /** The marked sections on the page now, top to bottom (the assistant's own are left out). */
25
+ export const sectionsOnScreen = () => [...document.querySelectorAll(SECTION)]
26
+ .filter((el) => !el.closest('[data-assistant]'))
27
+ .map(sectionOf);
28
+ /** An element's text as the model reads it: its words, cut to `max` characters. */
29
+ export const textOf = (el, max = 3000) => (el?.innerText ?? '')
30
+ .replace(/\n{3,}/g, '\n\n')
31
+ .trim()
32
+ .slice(0, max);
33
+ /** The element a `card:<id>` or `section:<id>` target stands for. */
34
+ export const targetEl = (target) => target.startsWith('card:')
35
+ ? cardEl(target.slice(5))
36
+ : target.startsWith('section:')
37
+ ? sectionEl(target.slice(8))
38
+ : null;
12
39
  const cardEl = (id) => document.querySelector(`${CARD.slice(0, -1)}="${CSS.escape(id)}"]`);
13
40
  const navEl = (path) => [...document.querySelectorAll('[data-sidebar="sidebar"] a[href]')].find((a) => new URL(a.href).pathname.replace(/\/$/, '').endsWith(path.replace(/\/$/, ''))) ?? null;
14
41
  export const cardOf = (el) => ({
@@ -31,20 +58,20 @@ function clear() {
31
58
  tag?.remove();
32
59
  tag = null;
33
60
  }
34
- function light(el, name, nav) {
61
+ function light(el, name, mode) {
35
62
  if (lit !== el) {
36
63
  if (lit)
37
64
  delete lit.dataset.spotlit;
38
65
  lit = el;
39
66
  el.dataset.spotlit = '';
40
67
  }
41
- if (nav) {
68
+ if (mode === 'nav') {
42
69
  document.body.dataset.spotlight = 'nav';
43
70
  tag?.remove();
44
71
  tag = null;
45
72
  return;
46
73
  }
47
- document.body.dataset.spotlight = 'card';
74
+ document.body.dataset.spotlight = mode;
48
75
  if (!name)
49
76
  return;
50
77
  tag ??= document.body.appendChild(document.createElement('div'));
@@ -60,42 +87,68 @@ export function onScreen(target) {
60
87
  return null;
61
88
  if (target.startsWith('card:'))
62
89
  return cardEl(target.slice(5)) ? 'card' : null;
90
+ if (target.startsWith('section:'))
91
+ return sectionEl(target.slice(8)) ? 'section' : null;
63
92
  return navEl(target) ? 'nav' : null;
64
93
  }
65
94
  /**
66
- * Lights one target (`card:<id>` or an app path) and dims the page around it; null clears. The
67
- * target scrolls into view only after a short hover.
95
+ * Lights one target (`card:<id>`, `section:<id>` or an app path) and dims the page around it; null
96
+ * clears. The target scrolls into view only after a short hover.
68
97
  */
69
98
  export function spotlight(target, name) {
70
99
  if (!target)
71
100
  return clear();
72
- const card = target.startsWith('card:');
73
- const el = card ? cardEl(target.slice(5)) : navEl(target);
101
+ const mode = target.startsWith('card:')
102
+ ? 'card'
103
+ : target.startsWith('section:')
104
+ ? 'section'
105
+ : 'nav';
106
+ const el = mode === 'nav' ? navEl(target) : targetEl(target);
74
107
  if (!el)
75
108
  return clear();
76
- light(el, name, !card);
109
+ light(el, name, mode);
77
110
  clearTimeout(scrollTimer);
78
111
  scrollTimer = setTimeout(() => {
79
112
  const r = el.getBoundingClientRect();
80
113
  if (r.bottom < 0 || r.top > window.innerHeight)
81
114
  el.scrollIntoView({ block: 'center', behavior: 'smooth' });
82
- if (card && name)
83
- light(el, name, false);
115
+ if (mode !== 'nav' && name)
116
+ light(el, name, mode);
84
117
  }, 600);
85
118
  }
86
119
  /**
87
120
  * Point at the page: the cards dim and the one under the cursor (or finger) lights up with its
88
121
  * name; a click picks it, Esc or a click off any card cancels.
89
122
  */
90
- export function pointAtCard() {
123
+ export async function pointAtCard() {
124
+ const picked = await pick(CARD);
125
+ return picked?.kind === 'card' ? picked.card : null;
126
+ }
127
+ /**
128
+ * Point at a card or a marked section: the one under the cursor lights up with its name, the
129
+ * innermost when they nest; a click picks it, Esc or a click off any cancels.
130
+ */
131
+ export const pointAt = () => pick(`${SECTION}, ${CARD}`);
132
+ function pick(selector) {
91
133
  clear();
92
134
  document.body.dataset.pointing = '';
93
135
  return new Promise((done) => {
94
- const under = (e) => e.target?.closest?.(CARD) ?? null;
136
+ const under = (e) => {
137
+ const el = e.target?.closest?.(selector) ?? null;
138
+ return el?.closest('[data-assistant]') ? null : el;
139
+ };
140
+ const what = (el) => el.matches(CARD)
141
+ ? { kind: 'card', card: cardOf(el), el }
142
+ : { kind: 'section', section: sectionOf(el), el };
95
143
  const move = (e) => {
96
144
  const el = under(e);
97
- if (el)
98
- light(el, `${el.dataset.cardTitle ?? ''} · ${kindName(cardOf(el).kind)}`, false);
145
+ if (el) {
146
+ const p = what(el);
147
+ if (p.kind === 'card')
148
+ light(el, `${p.card.title} · ${kindName(p.card.kind)}`, 'card');
149
+ else
150
+ light(el, p.section.label, 'section');
151
+ }
99
152
  else if (lit) {
100
153
  delete lit.dataset.spotlit;
101
154
  lit = null;
@@ -115,7 +168,7 @@ export function pointAtCard() {
115
168
  e.preventDefault();
116
169
  e.stopPropagation();
117
170
  const el = under(e);
118
- end(el ? cardOf(el) : null);
171
+ end(el ? what(el) : null);
119
172
  };
120
173
  const key = (e) => {
121
174
  if (e.key !== 'Escape')
package/dist/theme.css CHANGED
@@ -525,6 +525,12 @@ body[data-pointing],
525
525
  body[data-pointing] [data-slot="lumen-card"] * {
526
526
  cursor: crosshair;
527
527
  }
528
+ body[data-spotlight="section"] [data-spotlit],
529
+ body[data-pointing] [data-assistant-section][data-spotlit] {
530
+ background: color-mix(in oklab, var(--primary) 8%, transparent);
531
+ border-radius: calc(var(--radius) * 0.6);
532
+ transition: background 120ms ease-out;
533
+ }
528
534
  body[data-spotlight="nav"] [data-spotlit] {
529
535
  background: var(--sidebar-accent);
530
536
  color: var(--sidebar-accent-foreground);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.81",
3
+ "version": "0.1.82",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@codemirror/lang-javascript": "6.2.5",
50
50
  "@codemirror/lang-json": "6.0.2",
51
51
  "@codemirror/lang-sql": "6.10.0",
52
- "@columenlabs/lumen-ui-types": "0.1.81",
52
+ "@columenlabs/lumen-ui-types": "0.1.82",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",