@columenlabs/lumen-ui 0.1.281 → 0.1.283

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,7 +1,7 @@
1
1
  export { type AssistantAdapter, assistantClient } from './client.js';
2
2
  export { ChatImage } from './chat-image.js';
3
3
  export { type AssistantCommand, type AssistantMode, type ComposerExtra, LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
4
- export { assistantAdminSections, assistantSettingsSections } from './settings.js';
4
+ export { AutoCompactField, assistantAdminSections, assistantSettingsSections } from './settings.js';
5
5
  export { AssistantChatCost, AssistantUsageTable, UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
6
6
  export { type AssistantSource, type AssistantSourceItem, type ComposeRequest, type PageSelection, composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
7
7
  export { type AssistantChatState, type LiveItem, applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -4,7 +4,7 @@
4
4
  export { assistantClient } from './client.js';
5
5
  export { ChatImage } from './chat-image.js';
6
6
  export { LumenAssistant, LumenComposer, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
7
- export { assistantAdminSections, assistantSettingsSections } from './settings.js';
7
+ export { AutoCompactField, assistantAdminSections, assistantSettingsSections } from './settings.js';
8
8
  export { AssistantChatCost, AssistantUsageTable, UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
9
9
  export { composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
10
10
  export { applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -59,8 +59,8 @@ export interface LumenAssistantProps {
59
59
  mode?: string;
60
60
  };
61
61
  /**
62
- * Where the server compacts the chat, in tokens (the agent's `compactAt()`), named in the context
63
- * donut's hover. 150k by default.
62
+ * Where the server compacts the chat, in tokens (the agent's `compactAt()`): the context donut turns
63
+ * yellow at 75% of it and red at it. The person's Auto-compact (status `compact_at`) wins; 150k by default.
64
64
  */
65
65
  compactAt?: number;
66
66
  }
@@ -108,7 +108,9 @@ export declare function readAssistantImage(file: File): Promise<AssistantImage |
108
108
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
109
109
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
110
110
  */
111
- export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, meter, }: {
111
+ export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, meter, voice, }: {
112
+ /** A mic beside Send that types what the person says (`VoiceButton`); hidden where the browser can't. */
113
+ voice?: boolean;
112
114
  /** Drawn in the icon row after the paperclip and the modes: `LumenAssistant` puts its context donut here. */
113
115
  meter?: ReactNode;
114
116
  /** Toggles beside the paperclip (`LumenAssistant` `modes`). */
@@ -4,7 +4,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
4
4
  // (the person's messages, the answer as it streams, each tool call as a line, drafted changes as
5
5
  // cards to Save or Dismiss, work handed to a higher tier in its own section); and the composer,
6
6
  // with Stop, a queued message and the chat's cost and context under it.
7
- import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, } from '@columenlabs/lumen-ui-types/assistant';
7
+ import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, COMPACT_AT, } from '@columenlabs/lumen-ui-types/assistant';
8
8
  import { useQuery, useQueryClient } from '@tanstack/react-query';
9
9
  import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, PaperclipIcon, PencilIcon, PlusIcon, RefreshCwIcon, ReplyIcon, SearchIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
10
10
  import { Fragment, createContext, useContext, useEffect, useRef, useState, } from 'react';
@@ -24,6 +24,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/colla
24
24
  import { InputGroup, InputGroupAddon, InputGroupTextarea } from '../ui/input-group.js';
25
25
  import { Spinner } from '../ui/spinner.js';
26
26
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
27
+ import { VoiceButton } from '../voice-input.js';
27
28
  import { ChatImage } from './chat-image.js';
28
29
  import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
29
30
  import { ImageCard, ImagePending, toolIs } from './image-card.js';
@@ -36,7 +37,6 @@ import { useAssistantChat, } from './use-assistant-chat.js';
36
37
  const ChatMarkdown = lazyPart(() => import('./chat-markdown.js').then((m) => m.default), null, ({ children }) => _jsx("p", { className: "whitespace-pre-wrap", children: children }));
37
38
  /** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
38
39
  const CONTEXT_MAX = 200_000;
39
- const COMPACT_AT = 150_000;
40
40
  export const assistantStatusKey = (client) => [
41
41
  'lumen-assistant',
42
42
  client.key,
@@ -191,7 +191,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
191
191
  title: c.title,
192
192
  updated_at: new Date(c.at).toISOString(),
193
193
  ...(chatScope && allScopes && c.scope ? { where: chatScope.name?.(c.scope) } : {}),
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
+ })) ?? (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: s?.compact_at ?? compactAt, items: chat.items }) })] }));
195
195
  }
196
196
  function Messages({ chat, status, onSettings, onRetry, onFresh, }) {
197
197
  const end = useRef(null);
@@ -581,7 +581,7 @@ const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
581
581
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
582
582
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
583
583
  */
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
+ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, modes = [], onSend, onStop, meter, voice = true, }) {
585
585
  const [text, setText] = useState('');
586
586
  const [caret, setCaret] = useState(0);
587
587
  const [closed, setClosed] = useState(null);
@@ -904,7 +904,7 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
904
904
  if (e.target.files)
905
905
  attach(e.target.files);
906
906
  e.target.value = '';
907
- } })] })), 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))), meter, _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
+ } })] })), 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))), meter, _jsxs("div", { className: "ml-auto flex items-center gap-0.5", children: [voice && (_jsx(VoiceButton, { value: text, onChange: setText, size: "icon-xs", disabled: disabled })), 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() }))] })] })] })] }));
908
908
  }
909
909
  /**
910
910
  * What waits above the box while an answer runs: steering messages until the answer reads them,
@@ -929,30 +929,67 @@ const pointedRef = (p) => p.kind === 'card'
929
929
  text: textOf(p.el),
930
930
  }
931
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;
932
+ /** Share of the compact point from which the donut turns yellow; red at the compact point itself. */
933
+ const CONTEXT_WATCH = 0.75;
934
+ /** What the context pop-up counts from the chat: its turns, and the images and pointed-at things sent. */
935
+ function chatCounts(items) {
936
+ let turns = 0;
937
+ let attached = 0;
938
+ for (const i of items) {
939
+ if (i.kind !== 'user' || i.steered)
940
+ continue;
941
+ turns += 1;
942
+ attached += (i.images?.length ?? 0) + (i.refs?.length ?? 0);
943
+ }
944
+ return { turns, attached };
945
+ }
935
946
  /**
936
- * How full the chat's context is, as a small donut in the composer's icon row (after the paperclip and the modes):
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.
947
+ * How full the chat's context is, as a small donut in the composer's icon row (after the paperclip and
948
+ * the modes). It fills against `CONTEXT_MAX`: neutral, yellow from 75% of the compact point, red at it
949
+ * (75k and 100k when chats compact at 100k). Hover, focus or a tap (touch has no hover) opens its pop-up:
950
+ * context used of the most with a bar, that the chat compacts on its own, its turns, the images and
951
+ * attachments sent, and how much of the last turn came from the prompt cache. No price: a person reads
952
+ * their use in Settings.
940
953
  */
941
- function ContextDonut({ usage, compactAt }) {
954
+ function ContextDonut({ usage, compactAt, items, }) {
955
+ const [open, setOpen] = useState(false);
956
+ const touch = useRef(false);
957
+ const wasOpen = useRef(false);
942
958
  const ctx = usage?.context ?? 0;
943
959
  if (ctx <= 0)
944
960
  return null;
945
961
  const share = Math.min(1, ctx / CONTEXT_MAX);
946
962
  const r = 6;
947
963
  const round = 2 * Math.PI * r;
948
- const tone = ctx >= CONTEXT_ALERT
964
+ const level = ctx >= compactAt ? 'alert' : ctx >= compactAt * CONTEXT_WATCH ? 'watch' : 'ok';
965
+ const tone = level === 'alert'
949
966
  ? 'text-destructive'
950
- : ctx >= CONTEXT_WATCH
967
+ : level === 'watch'
951
968
  ? 'text-warning'
952
969
  : 'text-muted-foreground';
953
970
  const cached = usage && usage.in > 0 ? Math.round((usage.read / usage.in) * 100) : null;
971
+ const { turns, attached } = chatCounts(items);
954
972
  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-foreground/25" }), _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 items-start gap-0.5 text-left", 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"] }))] })] }));
973
+ const rows = [
974
+ ['This chat', `${turns} ${turns === 1 ? 'turn' : 'turns'}`],
975
+ ['Images and attachments', String(attached)],
976
+ ...(cached !== null ? [['Cache read', `${cached}%`]] : []),
977
+ ];
978
+ return (_jsxs(Tooltip, { open: open, onOpenChange: setOpen, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": said, "aria-expanded": open, "data-testid": "context-donut", "data-level": level,
979
+ // A tap opens or closes the pop-up (touch has no hover); a mouse click leaves it open.
980
+ // The press itself counts as outside the pop-up and closes it, so the tap reads the state before.
981
+ onPointerDown: (e) => {
982
+ touch.current = e.pointerType !== 'mouse';
983
+ wasOpen.current = open;
984
+ e.preventDefault();
985
+ }, onClick: (e) => {
986
+ e.preventDefault();
987
+ setOpen(touch.current ? !wasOpen.current : true);
988
+ }, 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-foreground/25" }), _jsx("circle", { cx: "8", cy: "8", r: r, fill: "none", strokeWidth: "2.5", stroke: "currentColor", strokeDasharray: `${share * round} ${round}` })] }) }) }), _jsxs(TooltipContent, { "data-testid": "context-popup", className: "flex w-56 flex-col items-stretch gap-1.5 px-3 py-2.5 text-left font-sans tabular-nums", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsx("span", { className: "font-medium", children: "Context" }), _jsxs("span", { className: "whitespace-nowrap", children: [tokens(ctx), " ", _jsxs("span", { className: "text-muted-foreground", children: ["/ ", tokens(CONTEXT_MAX)] })] })] }), _jsx("div", { className: "h-1 overflow-hidden rounded-full bg-muted-foreground/20", children: _jsx("div", { className: cn('h-full rounded-full', level === 'alert'
989
+ ? 'bg-destructive'
990
+ : level === 'watch'
991
+ ? 'bg-warning'
992
+ : 'bg-foreground/60'), style: { width: `${Math.max(2, share * 100)}%` } }) }), _jsx("span", { className: "text-muted-foreground", children: "Auto-compacts as needed" }), _jsx("div", { className: "my-0.5 border-t" }), rows.map(([k, v]) => (_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsx("span", { className: "truncate text-muted-foreground", children: k }), _jsx("span", { className: "whitespace-nowrap", children: v })] }, k)))] })] }));
956
993
  }
957
994
  /**
958
995
  * The assistant docked in LumenShell: put `button` in `headerActions` and `panel` in `panel`.
@@ -1,5 +1,13 @@
1
1
  import type { SettingsSectionDef } from '../settings.js';
2
2
  import type { AssistantAdapter } from './client.js';
3
+ /**
4
+ * Settings: the context size past which a chat is summarized out of sight (Auto-compact), the
5
+ * person's own; the app's default until they pick. An app that shows only some of the kit's
6
+ * sections puts this row in its own.
7
+ */
8
+ export declare function AutoCompactField({ client }: {
9
+ client: AssistantAdapter;
10
+ }): import("react").JSX.Element | null;
3
11
  /**
4
12
  * The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
5
13
  * where the app keeps its admin settings, shown only to admins.
@@ -1,4 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { COMPACT_AT, COMPACT_CHOICES } from '@columenlabs/lumen-ui-types/assistant';
2
3
  import { useQuery, useQueryClient } from '@tanstack/react-query';
3
4
  import { ChartColumnIcon, CoinsIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, Link2Icon, LogInIcon, PlugIcon, ReceiptIcon, SendIcon, SparklesIcon, WrenchIcon, } from 'lucide-react';
4
5
  import { useId, useState } from 'react';
@@ -18,7 +19,7 @@ import { Label } from '../ui/label.js';
18
19
  import { Switch } from '../ui/switch.js';
19
20
  import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
20
21
  import { allowanceAmount, assistantStatusKey, usd, useAssistantStatus } from './lumen-assistant.js';
21
- import { AssistantUsageTable, OwnKeyUsage, UsageHistoryView } from './usage-history.js';
22
+ import { AssistantUsageTable, OwnKeyUsage, UsageHistoryView, tokens } from './usage-history.js';
22
23
  const EFFORTS = [
23
24
  { value: 'default', label: 'Model default' },
24
25
  { value: 'low', label: 'Low' },
@@ -84,12 +85,30 @@ function General({ client }) {
84
85
  value: t.id,
85
86
  label: t.label,
86
87
  })),
87
- ], onChange: (advice) => void set({ advice }) }) })), _jsx(SettingField, { label: "Screen", info: "Takes you to a page, a Settings row, a chart or a card when you ask. Each move has an Undo; nothing is saved.", children: _jsx(LumenSelector, { label: "Screen", size: "sm", className: "w-44", value: s.screen ?? 'move', options: [
88
+ ], onChange: (advice) => void set({ advice }) }) })), _jsx(AutoCompactField, { client: client }), _jsx(SettingField, { label: "Screen", info: "Takes you to a page, a Settings row, a chart or a card when you ask. Each move has an Undo; nothing is saved.", children: _jsx(LumenSelector, { label: "Screen", size: "sm", className: "w-44", value: s.screen ?? 'move', options: [
88
89
  { value: 'move', label: 'Move' },
89
90
  { value: 'off', label: 'Off' },
90
91
  ], onChange: (screen) => void set({ screen: screen }) }) })] }));
91
92
  } }));
92
93
  }
94
+ /**
95
+ * Settings: the context size past which a chat is summarized out of sight (Auto-compact), the
96
+ * person's own; the app's default until they pick. An app that shows only some of the kit's
97
+ * sections puts this row in its own.
98
+ */
99
+ export function AutoCompactField({ client }) {
100
+ const { apply } = useApply(client);
101
+ const s = useAssistantStatus(client).data;
102
+ if (!s)
103
+ return null;
104
+ const now = s.compact_at ?? COMPACT_AT;
105
+ const choices = [...COMPACT_CHOICES];
106
+ if (!choices.includes(now))
107
+ choices.push(now);
108
+ return (_jsx(SettingField, { label: "Auto-compact", info: "Past this much context, the chat's older part is summarized out of sight, so long chats stay quick.", children: _jsx(LumenSelector, { label: "Auto-compact", size: "sm", className: "w-44", value: String(now), options: choices
109
+ .sort((a, b) => a - b)
110
+ .map((n) => ({ value: String(n), label: `At ${tokens(n)}` })), onChange: (v) => void apply('settings', () => client.settings({ compact_at: Number(v) })) }) }));
111
+ }
93
112
  // ---------------------------------------------------------------- model providers
94
113
  /** A dollar cap a month, saved on Enter or leaving the field. */
95
114
  function CapInput({ label, value, onSave, }) {
package/dist/index.d.ts CHANGED
@@ -79,6 +79,7 @@ export { NotificationsSettings } from './notifications.js';
79
79
  export { type TasksConfig, TasksButton, TasksProvider, useTaskFor, useTasks } from './tasks.js';
80
80
  export { type McpServer, McpSettings } from './mcp-settings.js';
81
81
  export { lumenToast } from './lumen-toast.js';
82
+ export { useVoiceInput, VoiceButton, type VoiceButtonProps, type VoiceInputOptions, voiceSupported, } from './voice-input.js';
82
83
  export { LumenShell, type LumenShellProps, type ShellUser, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
83
84
  export { API_TIMEOUT_MS, apiJson } from './api-fetch.js';
84
85
  export { FETCH_MS, fetchWithin } from './fetch-within.js';
package/dist/index.js CHANGED
@@ -86,6 +86,7 @@ export { NotificationsSettings } from './notifications.js';
86
86
  export { TasksButton, TasksProvider, useTaskFor, useTasks } from './tasks.js';
87
87
  export { McpSettings } from './mcp-settings.js';
88
88
  export { lumenToast } from './lumen-toast.js';
89
+ export { useVoiceInput, VoiceButton, voiceSupported, } from './voice-input.js';
89
90
  export { LumenShell, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
90
91
  export { API_TIMEOUT_MS, apiJson } from './api-fetch.js';
91
92
  export { FETCH_MS, fetchWithin } from './fetch-within.js';
@@ -0,0 +1,34 @@
1
+ /** True where the browser can turn speech into text. */
2
+ export declare const voiceSupported: () => boolean;
3
+ export type VoiceInputOptions = {
4
+ /** The box's text now. Speech joins the end of it. */
5
+ value: string;
6
+ /** Gets the box's whole new text: what was there plus what was said, the words still being heard included. */
7
+ onChange: (value: string) => void;
8
+ /** Recognition language as a BCP 47 tag; the browser's language by default. */
9
+ lang?: string;
10
+ };
11
+ /**
12
+ * Speech to text into a text box: `listening`, `supported` and `toggle()`. While listening, each word
13
+ * heard goes into the box as `onChange(text)` (live, then settled). If the person types while it
14
+ * listens, what they typed is kept and later speech joins after it.
15
+ */
16
+ export declare function useVoiceInput({ value, onChange, lang }: VoiceInputOptions): {
17
+ supported: boolean;
18
+ listening: boolean;
19
+ start: () => void;
20
+ stop: () => void | undefined;
21
+ toggle: () => void;
22
+ };
23
+ export type VoiceButtonProps = VoiceInputOptions & {
24
+ /** `icon-xs` beside a composer's other tools; `icon-sm` by default. */
25
+ size?: 'icon-xs' | 'icon-sm' | 'icon';
26
+ disabled?: boolean;
27
+ className?: string;
28
+ };
29
+ /**
30
+ * The mic beside a text box: tap to talk, tap again to stop. While it listens the icon turns into a
31
+ * red dot and the words appear in the box as they are heard. Draws nothing where the browser has no
32
+ * speech to text.
33
+ */
34
+ export declare function VoiceButton({ size, disabled, className, ...options }: VoiceButtonProps): import("react").JSX.Element | null;
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // VoiceButton: talk instead of type. A mic beside any text box that writes what the person says into
3
+ // it as they speak, through the browser's own speech recognition (Web Speech API): no server of ours,
4
+ // no cost. Tap to start, tap to stop; the words join what is already in the box, never replace it.
5
+ // Where the browser has no speech recognition (Firefox) the button is not drawn.
6
+ import { MicIcon } from 'lucide-react';
7
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
8
+ import { IconButton } from './icon-button.js';
9
+ import { cn } from './lib/utils.js';
10
+ import { lumenToast } from './lumen-toast.js';
11
+ const recognitionClass = () => {
12
+ if (typeof window === 'undefined')
13
+ return undefined;
14
+ const w = window;
15
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition;
16
+ };
17
+ /** True where the browser can turn speech into text. */
18
+ export const voiceSupported = () => !!recognitionClass();
19
+ /** What each recognition error means, in plain words. Errors left out (`aborted`, `no-speech`) say nothing. */
20
+ const ERRORS = {
21
+ 'not-allowed': "The microphone is blocked. Allow it for this site in your browser's settings, then tap the mic again.",
22
+ 'service-not-allowed': 'This browser does not allow speech to text here. Try Chrome, Edge or Safari, or type instead.',
23
+ 'audio-capture': 'No microphone was found. Connect one, then tap the mic again.',
24
+ network: 'Speech to text needs an internet connection. Check it, then tap the mic again.',
25
+ 'language-not-supported': "Speech to text doesn't support your browser's language yet.",
26
+ };
27
+ /** `base` and `spoken` joined by one space, unless either side already has it. */
28
+ const join = (base, spoken) => {
29
+ const s = spoken.trim();
30
+ if (!s)
31
+ return base;
32
+ if (!base || /\s$/.test(base))
33
+ return base + s;
34
+ return `${base} ${s}`;
35
+ };
36
+ /**
37
+ * Speech to text into a text box: `listening`, `supported` and `toggle()`. While listening, each word
38
+ * heard goes into the box as `onChange(text)` (live, then settled). If the person types while it
39
+ * listens, what they typed is kept and later speech joins after it.
40
+ */
41
+ export function useVoiceInput({ value, onChange, lang }) {
42
+ const supported = useSyncExternalStore(noop, voiceSupported, () => false);
43
+ const [listening, setListening] = useState(false);
44
+ const rec = useRef(null);
45
+ // Latest props, read from the recognition's callbacks.
46
+ const latest = useRef({ value, onChange });
47
+ latest.current = { value, onChange };
48
+ // The text before this stretch of speech, the first result that belongs to it, and what was last written.
49
+ const base = useRef('');
50
+ const from = useRef(0);
51
+ // Every text written this stretch: the box may not have re-rendered with the last one yet.
52
+ const wrote = useRef(new Set());
53
+ const stop = useCallback(() => rec.current?.stop(), []);
54
+ // Leaving the page (or the box) ends listening, so the mic is never left on.
55
+ useEffect(() => () => rec.current?.abort(), []);
56
+ const start = useCallback(() => {
57
+ const Class = recognitionClass();
58
+ if (!Class || rec.current)
59
+ return;
60
+ const r = new Class();
61
+ r.lang = lang ?? (typeof navigator !== 'undefined' ? navigator.language : '') ?? '';
62
+ r.continuous = true;
63
+ r.interimResults = true;
64
+ base.current = latest.current.value;
65
+ from.current = 0;
66
+ wrote.current.clear();
67
+ r.onresult = (e) => {
68
+ const now = latest.current.value;
69
+ // Typed into the box since the last words: keep that text and start the next words after it.
70
+ if (wrote.current.size && !wrote.current.has(now)) {
71
+ base.current = now;
72
+ from.current = e.resultIndex;
73
+ wrote.current.clear();
74
+ }
75
+ let spoken = '';
76
+ for (let i = from.current; i < e.results.length; i++) {
77
+ spoken += ` ${e.results[i]?.[0]?.transcript ?? ''}`;
78
+ }
79
+ const next = join(base.current, spoken.replace(/\s+/g, ' '));
80
+ wrote.current.add(next);
81
+ if (next !== now)
82
+ latest.current.onChange(next);
83
+ };
84
+ r.onerror = (e) => {
85
+ const message = ERRORS[e.error];
86
+ if (message)
87
+ lumenToast.error(message, { id: 'lumen-voice' });
88
+ };
89
+ r.onend = () => {
90
+ rec.current = null;
91
+ setListening(false);
92
+ };
93
+ rec.current = r;
94
+ try {
95
+ r.start();
96
+ setListening(true);
97
+ }
98
+ catch {
99
+ rec.current = null;
100
+ lumenToast.error('Speech to text could not start. Try again.', { id: 'lumen-voice' });
101
+ }
102
+ }, [lang]);
103
+ const toggle = useCallback(() => (rec.current ? stop() : start()), [start, stop]);
104
+ return { supported, listening, start, stop, toggle };
105
+ }
106
+ const noop = () => () => { };
107
+ /**
108
+ * The mic beside a text box: tap to talk, tap again to stop. While it listens the icon turns into a
109
+ * red dot and the words appear in the box as they are heard. Draws nothing where the browser has no
110
+ * speech to text.
111
+ */
112
+ export function VoiceButton({ size = 'icon-sm', disabled, className, ...options }) {
113
+ const { supported, listening, toggle, stop } = useVoiceInput(options);
114
+ useEffect(() => {
115
+ if (disabled && listening)
116
+ stop();
117
+ }, [disabled, listening, stop]);
118
+ if (!supported)
119
+ return null;
120
+ return (_jsx(IconButton, { icon: listening ? ListeningDot : MicIcon, label: listening ? 'Stop listening' : 'Talk to type', size: size, variant: "ghost",
121
+ // Inside a form it must not send it.
122
+ type: "button", "aria-pressed": listening, "data-voice": listening ? 'listening' : 'idle', disabled: disabled, className: cn(listening && 'text-destructive', className), onClick: toggle }));
123
+ }
124
+ /** A red dot that pulses while the mic listens. */
125
+ function ListeningDot({ className }) {
126
+ return (_jsx("span", { className: cn('grid size-4 place-items-center', className), "aria-hidden": true, children: _jsx("span", { className: "size-2.5 animate-pulse rounded-full bg-destructive" }) }));
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.281",
3
+ "version": "0.1.283",
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.281",
52
+ "@columenlabs/lumen-ui-types": "0.1.283",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",