@columenlabs/lumen-ui 0.1.266 → 0.1.268

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.
@@ -1,5 +1,6 @@
1
1
  import { type AllowanceUnit, type AssistantImage, type AssistantMessage, type AssistantQuote, type AssistantRefNote, type AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import { type LucideIcon } from 'lucide-react';
3
+ import { type ReactNode } from 'react';
3
4
  import { type LumenOption } from '../lumen-selector.js';
4
5
  import type { AssistantAdapter } from './client.js';
5
6
  import { type AssistantSource } from './sources.js';
@@ -58,8 +59,8 @@ export interface LumenAssistantProps {
58
59
  mode?: string;
59
60
  };
60
61
  /**
61
- * Where the server compacts the chat, in tokens (the agent's `compactAt()`), drawn as the tick on
62
- * the context bar. 150k by default.
62
+ * Where the server compacts the chat, in tokens (the agent's `compactAt()`), named in the context
63
+ * donut's hover. 150k by default.
63
64
  */
64
65
  compactAt?: number;
65
66
  }
@@ -107,7 +108,9 @@ export declare function readAssistantImage(file: File): Promise<AssistantImage |
107
108
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
108
109
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
109
110
  */
110
- export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, }: {
111
+ export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, meter, }: {
112
+ /** Drawn in the icon row right after the paperclip: `LumenAssistant` puts its context donut here. */
113
+ meter?: ReactNode;
111
114
  /** Toggles beside the paperclip (`LumenAssistant` `modes`). */
112
115
  modes?: AssistantMode[];
113
116
  name: string;
@@ -23,6 +23,7 @@ import { Button } from '../ui/button.js';
23
23
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
24
24
  import { InputGroup, InputGroupAddon, InputGroupTextarea } from '../ui/input-group.js';
25
25
  import { Spinner } from '../ui/spinner.js';
26
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
26
27
  import { ChatImage } from './chat-image.js';
27
28
  import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
28
29
  import { ImageCard, ImagePending, toolIs } from './image-card.js';
@@ -190,7 +191,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
190
191
  title: c.title,
191
192
  updated_at: new Date(c.at).toISOString(),
192
193
  ...(chatScope && allScopes && c.scope ? { where: chatScope.name?.(c.scope) } : {}),
193
- })) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, toggle: chatScope && { label: chatScope.label, on: allScopes, onChange: setAllScopes }, 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(Screen.Provider, { value: controls, children: _jsx(ImageTool.Provider, { value: imageTool ?? null, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings, onRetry: (at, user) => chat.retry(at, user, extra()), onFresh: (choice) => chat.fresh(choice, extra()) }) }) }) }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, queue: chat.queue, canSteer: chat.canSteer, onSteerQueued: chat.steerQueued, onUnqueue: chat.unqueue, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, modes: modes, onSend: (text, more, how) => chat.send(text, { ...extra(), ...more }, how), onStop: chat.stop }), _jsx(ContextLine, { usage: chat.usage, compactAt: compactAt })] }));
194
+ })) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, toggle: chatScope && { label: chatScope.label, on: allScopes, onChange: setAllScopes }, 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(Screen.Provider, { value: controls, children: _jsx(ImageTool.Provider, { value: imageTool ?? null, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings, onRetry: (at, user) => chat.retry(at, user, extra()), onFresh: (choice) => chat.fresh(choice, extra()) }) }) }) }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, queue: chat.queue, canSteer: chat.canSteer, onSteerQueued: chat.steerQueued, onUnqueue: chat.unqueue, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, modes: modes, onSend: (text, more, how) => chat.send(text, { ...extra(), ...more }, how), onStop: chat.stop, meter: _jsx(ContextDonut, { usage: chat.usage, compactAt: compactAt }) })] }));
194
195
  }
195
196
  function Messages({ chat, status, onSettings, onRetry, onFresh, }) {
196
197
  const end = useRef(null);
@@ -580,7 +581,7 @@ const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
580
581
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
581
582
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
582
583
  */
583
- export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, modes = [], onSend, onStop, }) {
584
+ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, modes = [], onSend, onStop, meter, }) {
584
585
  const [text, setText] = useState('');
585
586
  const [caret, setCaret] = useState(0);
586
587
  const [closed, setClosed] = useState(null);
@@ -903,7 +904,7 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
903
904
  if (e.target.files)
904
905
  attach(e.target.files);
905
906
  e.target.value = '';
906
- } })] })), modes.map((md) => (_jsx(IconButton, { icon: md.icon, label: md.label, size: "icon-xs", variant: use === md.id ? 'secondary' : 'ghost', "aria-pressed": use === md.id, className: cn(use === md.id && 'text-primary'), disabled: disabled, onClick: () => setUse((u) => (u === md.id ? null : md.id)) }, md.id))), _jsx("div", { className: "ml-auto", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : busy ? (_jsxs("span", { className: "flex gap-1", children: [_jsx(IconButton, { icon: ListPlusIcon, label: canSteer ? 'Queue for after (⌥↩)' : 'Queue for after (↩)', variant: canSteer ? 'outline' : 'default', size: "icon-xs", disabled: disabled, onClick: () => send('queue') }), canSteer && (_jsx(IconButton, { icon: CornerDownRightIcon, label: "Steer now (\u21A9)", variant: "default", size: "icon-xs", disabled: disabled, onClick: () => send('steer') }))] })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: "Send", variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: () => send() })) })] })] })] }));
907
+ } })] })), meter, modes.map((md) => (_jsx(IconButton, { icon: md.icon, label: md.label, size: "icon-xs", variant: use === md.id ? 'secondary' : 'ghost', "aria-pressed": use === md.id, className: cn(use === md.id && 'text-primary'), disabled: disabled, onClick: () => setUse((u) => (u === md.id ? null : md.id)) }, md.id))), _jsx("div", { className: "ml-auto", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : busy ? (_jsxs("span", { className: "flex gap-1", children: [_jsx(IconButton, { icon: ListPlusIcon, label: canSteer ? 'Queue for after (⌥↩)' : 'Queue for after (↩)', variant: canSteer ? 'outline' : 'default', size: "icon-xs", disabled: disabled, onClick: () => send('queue') }), canSteer && (_jsx(IconButton, { icon: CornerDownRightIcon, label: "Steer now (\u21A9)", variant: "default", size: "icon-xs", disabled: disabled, onClick: () => send('steer') }))] })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: "Send", variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: () => send() })) })] })] })] }));
907
908
  }
908
909
  /**
909
910
  * What waits above the box while an answer runs: steering messages until the answer reads them,
@@ -928,15 +929,30 @@ const pointedRef = (p) => p.kind === 'card'
928
929
  text: textOf(p.el),
929
930
  }
930
931
  : { kind: p.section.kind, label: p.section.label, id: p.section.id, text: textOf(p.el) };
932
+ /** Context tokens from which the donut turns yellow, and red. */
933
+ const CONTEXT_WATCH = 75_000;
934
+ const CONTEXT_ALERT = 100_000;
931
935
  /**
932
- * How full the chat's context is (a thin bar with a tick where it compacts). What a chat cost is
933
- * not shown here: a person reads their use in Settings, not on every answer.
936
+ * How full the chat's context is, as a small donut in the composer's icon row (after the paperclip):
937
+ * it fills against `CONTEXT_MAX`, neutral below 75k, yellow from 75k, red from 100k. The hover gives
938
+ * the tokens used, where the chat compacts, and how much of the last turn came from the prompt cache.
939
+ * What a chat cost is not shown here: a person reads their use in Settings.
934
940
  */
935
- function ContextLine({ usage, compactAt }) {
941
+ function ContextDonut({ usage, compactAt }) {
936
942
  const ctx = usage?.context ?? 0;
937
943
  if (ctx <= 0)
938
944
  return null;
939
- return (_jsx("div", { className: "flex shrink-0 items-center gap-3 px-3 pb-2 text-muted-foreground text-xs", children: _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 > compactAt ? '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: `${(compactAt / CONTEXT_MAX) * 100}%` } })] }), _jsx("span", { className: "tabular-nums", children: tokens(ctx) })] }) }));
945
+ const share = Math.min(1, ctx / CONTEXT_MAX);
946
+ const r = 6;
947
+ const round = 2 * Math.PI * r;
948
+ const tone = ctx >= CONTEXT_ALERT
949
+ ? 'text-destructive'
950
+ : ctx >= CONTEXT_WATCH
951
+ ? 'text-warning'
952
+ : 'text-muted-foreground';
953
+ const cached = usage && usage.in > 0 ? Math.round((usage.read / usage.in) * 100) : null;
954
+ const said = `Context ${tokens(ctx)} of ${tokens(CONTEXT_MAX)}`;
955
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": said, "data-testid": "context-donut", className: cn('grid size-6 place-items-center rounded-md hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', tone), children: _jsxs("svg", { viewBox: "0 0 16 16", className: "size-4 -rotate-90", "aria-hidden": "true", children: [_jsx("title", { children: said }), _jsx("circle", { cx: "8", cy: "8", r: r, fill: "none", strokeWidth: "2.5", className: "stroke-muted" }), _jsx("circle", { cx: "8", cy: "8", r: r, fill: "none", strokeWidth: "2.5", stroke: "currentColor", strokeDasharray: `${share * round} ${round}` })] }) }) }), _jsxs(TooltipContent, { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "num", children: said }), _jsxs("span", { className: "num text-muted-foreground", children: ["Compacts at ", tokens(compactAt)] }), cached !== null && (_jsxs("span", { className: "num text-muted-foreground", children: [cached, "% of the last turn read from cache"] }))] })] }));
940
956
  }
941
957
  /**
942
958
  * The assistant docked in LumenShell: put `button` in `headerActions` and `panel` in `panel`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.266",
3
+ "version": "0.1.268",
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.266",
52
+ "@columenlabs/lumen-ui-types": "0.1.268",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",