@hanzo/build 0.2.59 → 0.2.61

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.
package/lib/prefs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
3
  * The person's own settings, read once for the whole builder and shared by
4
4
  * everything that follows them: Settings writes them, New starts from the
@@ -8,6 +8,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * The last document read is kept in this browser, per org, so the page is
9
9
  * drawn the person's way before the platform answers. A save shows at once and
10
10
  * is taken back, key by key, if the platform refuses it.
11
+ *
12
+ * A host mounts it too, above the builder (the Hanzo app, for Chat's side
13
+ * panel), and the builder's own then reads through that one: one document, one
14
+ * read. The look (`Look`) is the builder's alone: a host has its own.
11
15
  */
12
16
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
13
17
  import { merge, read, save } from './api/pref.js';
@@ -15,6 +19,9 @@ import { useKept, useRead } from './data.js';
15
19
  import { useHost, useTarget } from './host.js';
16
20
  const Context = createContext(null);
17
21
  export function PrefsProvider({ children }) {
22
+ return useContext(Context) ? children : _jsx(Keep, { children: children });
23
+ }
24
+ function Keep({ children }) {
18
25
  const host = useHost();
19
26
  const t = useTarget();
20
27
  const signed = Boolean(host.person);
@@ -48,11 +55,11 @@ export function PrefsProvider({ children }) {
48
55
  }
49
56
  }, [signed, t, keep]);
50
57
  const value = useMemo(() => ({ prefs: kept, loading: doc.loading, error: doc.error, save: write }), [kept, doc.loading, doc.error, write]);
51
- return (_jsxs(Context.Provider, { value: value, children: [_jsx(Look, { prefs: kept }), children] }));
58
+ return _jsx(Context.Provider, { value: value, children: children });
52
59
  }
53
- export function usePrefs() {
60
+ export function usePrefs(need = true) {
54
61
  const p = useContext(Context);
55
- if (!p)
62
+ if (!p && need)
56
63
  throw new Error('The builder was rendered without its settings');
57
64
  return p;
58
65
  }
@@ -66,9 +73,9 @@ const STILL = '*,*::before,*::after{animation-duration:.01ms!important;animation
66
73
  * root re-applies its appearance on mount, and a rule marked important outlasts
67
74
  * that, while the builder is on the page and no longer.
68
75
  */
69
- function Look({ prefs }) {
76
+ export function Look() {
70
77
  const host = useHost();
71
- const { theme, text, motion } = prefs;
78
+ const { theme, text, motion } = usePrefs().prefs;
72
79
  useEffect(() => {
73
80
  if (theme && theme !== host.theme)
74
81
  host.chooseTheme?.(theme);
package/lib/project.js CHANGED
@@ -40,9 +40,9 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
40
40
  */
41
41
  import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
42
42
  import { Clock, ExternalLink, MousePointerClick, PanelLeft, RefreshCcw, Share2, Upload, } from '@hanzogui/lucide-icons-2';
43
- import { Attachments, CHAT, Console, DEVICES, Feedback, FileTabs, FileTree, ModeSelect, PageSelect, PreviewFrame, ProjectChip, Suggestions, SUGGESTIONS, VIEWS, Views, Workspace, fold, } from '@hanzo/ui/agents';
43
+ import { Attachments, CHAT, Console, DEVICES, Feedback, FileTabs, FileTree, PageSelect, PreviewFrame, ProjectChip, VIEWS, Views, Workspace, fold, } from '@hanzo/ui/agents';
44
44
  import { Message } from '@hanzo/ui/chat';
45
- import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
45
+ import { Button, Dialog, DialogContent, DialogTitle, Grip } from '@hanzo/ui';
46
46
  import { HanzoMark } from '@hanzo/ui/product';
47
47
  import { useDictation, useVoice } from '@hanzo/voice';
48
48
  import { useEffect, useMemo, useRef, useState } from 'react';
@@ -57,9 +57,7 @@ import { decode, outcome, pull, said, scrub, who } from './api/turn.js';
57
57
  import { verdict as record } from './api/verdict.js';
58
58
  import { useKept, useProjects, useRead, useRun } from './data.js';
59
59
  import { Merge } from './git.js';
60
- import { Grip } from './grip.js';
61
60
  import { useHost, useTarget } from './host.js';
62
- import { MODES } from './landing.js';
63
61
  import { useMind } from './mind.js';
64
62
  import { Out } from './out.js';
65
63
  import { Prompt, useEar } from './prompt.js';
@@ -192,7 +190,6 @@ export function Project({ at }) {
192
190
  const [asked, setAsked] = useState(false);
193
191
  const shut = cramped ? !asked : collapsed;
194
192
  const [dock, setDock] = useKept('hanzo.build.dock', 36);
195
- const [dismissed, setDismissed] = useKept(`hanzo.build.hints.${slug}`, false);
196
193
  const [mode, setMode] = useKept('hanzo.build.mode', 'build');
197
194
  const [page, setPage] = useState('/');
198
195
  const [draft, setDraft] = useState('');
@@ -417,9 +414,9 @@ export function Project({ at }) {
417
414
  ? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
418
415
  : named
419
416
  ? `${named.org}/${named.name} is open on ${home}. Say what to change and it gets built there.`
420
- : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { "data-slot": "project-ask", px: "$3", pb: "$3", gap: "$2", minW: 0, $md: { pr: 0 }, children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
417
+ : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { "data-slot": "project-ask", px: "$3", pb: "$3", gap: "$2", minW: 0, $md: { pr: 0 }, children: [note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
421
418
  ? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch'), (e) => setNote(e.message))
422
- : undefined, busy: busy || (running && !draft.trim()), placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", onAttach: () => pick(attached, setAttached, setNote), tools: _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), bg: "transparent", minH: 24, px: "$1.5", self: "center" }), mind: running ? null : mind, voice: voice })] }), wide && !shut ? (_jsx(Grip, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", r: -GUTTER, width: GUTTER })) : null] }));
419
+ : undefined, busy: busy || (running && !draft.trim()), placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", plus: { upload: () => pick(attached, setAttached, setNote) }, mode: running ? null : { value: mode === 'plan' ? 'plan' : 'auto', set: (m) => setMode(m === 'plan' ? 'plan' : 'build') }, mind: running ? null : mind, voice: voice })] }), wide && !shut ? (_jsx(Grip, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", r: -GUTTER, width: GUTTER })) : null] }));
423
420
  // The preview stays loaded while another view is open, so coming back to it
424
421
  // is the page as it was left.
425
422
  const preview = (_jsxs(YStack, { "data-slot": "preview", flex: 1, minH: 0, minW: 0, gap: "$2", display: view === 'preview' ? 'flex' : 'none', children: [_jsxs(XStack, { "data-slot": "preview-bar", items: "center", gap: "$1.5", minW: 0, shrink: 0, "$max-md": { px: '$2' }, children: [_jsx(XStack, { render: "button", "aria-label": "Reload the preview", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 16 }) }), _jsx(PageSelect, { pages: pages, value: page, onChange: setPage, shrink: 1 }), _jsx(XStack, { flex: 1 }), _jsx(Views, { views: DEVICES, value: device, onChange: (d) => setDevice(d), label: "Device", labels: "none" }), src ? (_jsx(Out, { href: src, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: src, title: `${title} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: stalled
package/lib/prompt.d.ts CHANGED
@@ -3,7 +3,9 @@ import { type PauseSource } from '@hanzo/ui/models';
3
3
  import { type Machine, type Speech } from '@hanzo/voice';
4
4
  import { type ReactNode } from 'react';
5
5
  import type { Target } from './api/call.ts';
6
+ import { type Mode } from './gear.ts';
6
7
  import { type Mind } from './mind.ts';
8
+ import { type PlusProps, type Shelf } from './plus.tsx';
7
9
  /** The reading measure: @hanzo/design's prose width, which Width (appearance) retunes. */
8
10
  export declare const MEASURE = "var(--container-prose, 48rem)";
9
11
  /** The space between turns, px: one rhythm for Chat and Dev. */
@@ -14,12 +16,13 @@ export declare const measure: {
14
16
  readonly maxW: "calc(var(--container-prose, 48rem) - 1.5rem)";
15
17
  readonly mx: 'auto';
16
18
  };
17
- /** The round controls' box, px: the pill's row less its padding. */
19
+ /** The round controls' box, px. */
18
20
  export declare const ROUND = 32;
21
+ /** What every Composer placeholder says, unless a surface names who is listening. */
22
+ export declare const ASK = "Type / for skills";
19
23
  /**
20
24
  * The frame as a pane of its own, floating on the pane it is drawn in: the
21
- * `--pane-*` cut on design's first paper rung (a second rung's drop reads as a
22
- * gradient through a translucent pane).
25
+ * `--pane-*` cut on design's first paper rung.
23
26
  */
24
27
  export declare const cut: {
25
28
  readonly bg: 'var(--pane-fill, var(--sheet-1))';
@@ -31,6 +34,13 @@ export declare const cut: {
31
34
  };
32
35
  /** The platform's ear and voice for a target: dictation, talk and Listen all hear through it. */
33
36
  export declare function useEar(t: Target): Speech;
37
+ /** One thing the conversation carries, shown after `+`. */
38
+ export interface Chip {
39
+ id: string;
40
+ label: string;
41
+ icon?: ReactNode;
42
+ onRemove: () => void;
43
+ }
34
44
  export interface PromptOptions {
35
45
  value: string;
36
46
  onChange: (value: string) => void;
@@ -49,31 +59,70 @@ export interface PromptOptions {
49
59
  /** The model and effort. Null where the next send uses neither (steering a run). */
50
60
  mind?: Mind | null;
51
61
  limits?: PauseSource | null;
52
- /** The paperclip. Absent, there is none. */
53
- onAttach?: () => void;
54
- /** After the paperclip: what this surface alone offers (Dev's mode). */
55
- tools?: ReactNode;
62
+ /** A model the plan does not grant was asked for: the way to the plan that does. */
63
+ onUpgrade?: (model: string) => void;
64
+ /** The `+` menu. Absent, there is none. */
65
+ plus?: PlusProps;
66
+ /** What the conversation carries, as chips after `+`. */
67
+ on?: Chip[];
68
+ /** `/` lists these (the skills) above the frame. */
69
+ slash?: Shelf;
70
+ /** `@` lists these (the plugins) above the frame. */
71
+ at?: Shelf;
72
+ /** The mode, as the pill under the frame. */
73
+ mode?: {
74
+ value: Mode;
75
+ set: (m: Mode) => void;
76
+ } | null;
77
+ /** At the start of the row under the frame: Dev's where and which repository. */
78
+ where?: ReactNode;
79
+ /** Under the row (above the frame on a phone): the person's suggestions. */
80
+ below?: ReactNode;
56
81
  /** Dictation into the draft (@hanzo/voice `useVoice` + `useDictation`). */
57
82
  voice?: Machine;
58
- /** A spoken conversation (@hanzo/voice `useTalk`). */
83
+ /** The language dictation listens in, null for Auto, and how to change it. */
84
+ language?: string | null;
85
+ onLanguage?: (id: string | null) => void;
86
+ /** A spoken conversation (@hanzo/voice `useTalk`): the round button while the draft is empty. */
59
87
  talk?: Machine;
60
88
  /** Drawn on a pane: the frame is cut as a pane of its own (`cut`). */
61
89
  framed?: boolean;
62
- /** Centred just above the pill: what every message is sent under. */
90
+ /** Centred just above the frame: what every message is sent under. */
63
91
  note?: ReactNode;
64
- /** A phone: the field takes the pill's whole width above the controls, whatever the draft. */
92
+ /** A phone: suggestions go above the frame and every target is 44px. */
65
93
  narrow?: boolean;
66
94
  }
67
- /**
68
- * The chip beside send, and the panel it opens: the effort, a sentence saying
69
- * what serves the ask, and the model.
70
- */
71
- export declare function Tune({ mind, limits, disabled }: {
72
- mind: Mind;
73
- limits?: PauseSource | null;
74
- disabled?: boolean;
95
+ /** The row under the frame: where (Dev), then the model, the effort and the mode. */
96
+ export declare function Tune({ mind, mode, where, onUpgrade }: {
97
+ mind?: Mind | null;
98
+ mode?: PromptOptions['mode'];
99
+ where?: ReactNode;
100
+ onUpgrade?: (model: string) => void;
75
101
  }): import("react").JSX.Element;
102
+ /** The `/` or `@` the caret ends on, and the words typed after it. */
103
+ export declare function trigger(value: string): {
104
+ char: '/' | '@';
105
+ query: string;
106
+ at: number;
107
+ } | null;
76
108
  /** @hanzo/ui `Composer` props for the one composer. */
77
- export declare function prompt(o: PromptOptions): ComposerProps;
109
+ export declare function prompt(o: PromptOptions, state?: {
110
+ index: number;
111
+ closed: string | null;
112
+ }): ComposerProps;
113
+ /** Ctrl+U (⌘U) uploads, while a composer with an upload is on the page. */
114
+ export declare function useUpload(upload: (() => void) | undefined): void;
115
+ /**
116
+ * The composer's own state — which row of a `/` or `@` list is lit — and the
117
+ * keys that move it. A surface that hands `prompt` to `Chat` calls this and
118
+ * passes its answer through; `<Prompt>` does it for itself.
119
+ */
120
+ export declare function useMention(o: PromptOptions): {
121
+ state: {
122
+ index: number;
123
+ closed: string | null;
124
+ };
125
+ field: ComposerProps['field'];
126
+ };
78
127
  /** The composer drawn on its own. */
79
128
  export declare function Prompt(o: PromptOptions): import("react").JSX.Element;
package/lib/prompt.js CHANGED
@@ -1,48 +1,54 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * The composer, once, for Chat and Dev: one pill. While the draft is a line,
4
- * the pill is one row — attach (`+`) and the surface's own tools at the start,
5
- * the field, then the model and effort (`Tune`), dictation, talk and send. A
6
- * draft longer than a line takes the pill's whole width above, and the
7
- * controls keep the bottom row. A note the surface owes every message (the
8
- * "can make mistakes" line) stands centred just above the pill.
3
+ * The composer, once, for Chat and Dev. A frame: the field on top ("Type / for
4
+ * skills"), and under it one row — `+` at the start, what the conversation
5
+ * carries as small chips, then at the end dictation (the mic and its menu) and
6
+ * one round button that is voice mode while the draft is empty, send once it
7
+ * has words, and stop while a turn runs. Nothing else is in the frame.
8
+ *
9
+ * Under the frame, at its end, one quiet row: the model, the effort (dim) and
10
+ * the mode as a pill, each opening its choices; Dev puts where the run goes
11
+ * and its repository at the row's start (`where`). Under that, the person's
12
+ * suggestions (`below`; above the frame on a phone).
13
+ *
14
+ * `/` opens the skills and `@` the plugins, in a list just above the frame.
9
15
  *
10
16
  * `prompt` builds @hanzo/ui's `Composer` props, so a surface that draws the
11
17
  * composer through `Chat` (its `composer` prop) and one that draws it directly
12
- * (`<Prompt>`) draw the same thing. What each surface sends, and what it puts
13
- * above the frame (`head`), stays its own.
14
- *
15
- * The frame is as wide as the text above it: the reading measure, less the
16
- * thread column's own gutters, centred (`measure`). A transcript reads at
17
- * `MEASURE` and this lines up with its words.
18
+ * (`<Prompt>`) draw the same thing. The frame is as wide as the text above it
19
+ * (`measure`).
18
20
  */
19
21
  import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
20
- import { ArrowUp, AudioLines, Check, ChevronDown, Mic, Plus, Square } from '@hanzogui/lucide-icons-2';
22
+ import { ArrowUp, AudioLines, Check, Square, X } from '@hanzogui/lucide-icons-2';
21
23
  import { Control } from '@hanzo/composer';
22
- import { Popover, PopoverContent, PopoverTrigger, Separator, Tooltip, TooltipContent, TooltipTrigger } from '@hanzo/ui';
24
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSearch, DropdownMenuTrigger, Popover, PopoverContent, PopoverTrigger } from '@hanzo/ui';
23
25
  import { Composer } from '@hanzo/ui/chat';
24
- import { ModelPicker } from '@hanzo/ui/models';
26
+ import { groupModels, matchesModel } from '@hanzo/ui/models';
25
27
  import { speech, Voice } from '@hanzo/voice';
26
- import { useMemo, useState } from 'react';
28
+ import { useEffect, useMemo, useRef, useState } from 'react';
27
29
  import { ENSO } from './api/models.js';
28
- import { EFFORTS, nameOf, reasons } from './mind.js';
30
+ import { MODES } from './gear.js';
31
+ import { Dictation } from './mic.js';
32
+ import { EFFORTS, grants, nameOf, reasons, ROUTER_IDS } from './mind.js';
33
+ import { Plus } from './plus.js';
29
34
  /** The reading measure: @hanzo/design's prose width, which Width (appearance) retunes. */
30
35
  export const MEASURE = 'var(--container-prose, 48rem)';
31
36
  /** The space between turns, px: one rhythm for Chat and Dev. */
32
37
  export const GAP = 20;
33
38
  /** The composer's width: the measure less the thread column's 0.75rem gutters, centred, so its edges are the text's. */
34
39
  export const measure = { width: 'calc(100% - 1.5rem)', maxW: `calc(${MEASURE} - 1.5rem)`, mx: 'auto' };
35
- /** The round controls' box, px: the pill's row less its padding. */
40
+ /** The round controls' box, px. */
36
41
  export const ROUND = 32;
37
- /** The pill's height on one line, px, and the corner a taller draft keeps. */
38
- const PILL = 44;
39
- const CORNER = 22;
40
- /** About a line of draft at the measure: past it, or past a newline, the field takes the pill's width. */
41
- const LINE = 72;
42
+ /** The frame's corner, px: 12 on a laptop, 22 on a phone. */
43
+ const CORNER = 12;
44
+ const CORNER_PHONE = 22;
45
+ /** A round control with no ground of its own until it is pointed at. */
46
+ const QUIET = { bg: 'transparent', color: 'var(--foreground)', hoverStyle: { bg: '$hover' } };
47
+ /** What every Composer placeholder says, unless a surface names who is listening. */
48
+ export const ASK = 'Type / for skills';
42
49
  /**
43
50
  * The frame as a pane of its own, floating on the pane it is drawn in: the
44
- * `--pane-*` cut on design's first paper rung (a second rung's drop reads as a
45
- * gradient through a translucent pane).
51
+ * `--pane-*` cut on design's first paper rung.
46
52
  */
47
53
  export const cut = {
48
54
  bg: 'var(--pane-fill, var(--sheet-1))',
@@ -56,104 +62,173 @@ export const cut = {
56
62
  export function useEar(t) {
57
63
  return useMemo(() => speech({ baseUrl: t.api, token: t.token, ...(t.org ? { org: t.org } : {}) }), [t]);
58
64
  }
59
- /** Enso's mark: the ring. */
60
- function Ring() {
61
- return _jsx(YStack, { "aria-hidden": true, width: 12, height: 12, rounded: 99, borderWidth: 1.5, borderColor: "$ink", shrink: 0 });
65
+ /** A text button in the row under the frame. */
66
+ function Word({ children, dim = false, label, open }) {
67
+ return (_jsx(XStack, { render: "button", "aria-label": label, "aria-expanded": open, items: "center", height: 24, px: 6, rounded: 6, cursor: "pointer", bg: open ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, "$max-sm": { height: 44, px: 8 }, children: _jsx(SizableText, { fontSize: 14, fontWeight: dim ? '400' : '500', color: dim ? '$hint' : '$ink', numberOfLines: 1, children: children }) }));
62
68
  }
63
- /**
64
- * The chip beside send, and the panel it opens: the effort, a sentence saying
65
- * what serves the ask, and the model.
66
- */
67
- export function Tune({ mind, limits, disabled = false }) {
69
+ /** A titled list of choices, each with a line saying what it does and a check on the one in force. */
70
+ function Choices({ title, items, value, onPick, }) {
71
+ return (_jsxs(YStack, { role: "listbox", "aria-label": title, gap: 2, "data-slot": "choices", children: [_jsx(SizableText, { fontSize: 13, color: "$hint", px: 10, pt: 6, pb: 4, children: title }), items.map((c) => (_jsxs(XStack, { render: "button", role: "option", "aria-selected": c.id === value, "aria-disabled": c.off || undefined, onPress: () => !c.off && onPick(c.id), items: "flex-start", gap: 12, px: 10, py: 8, rounded: 8, width: "100%", cursor: c.off ? 'default' : 'pointer', opacity: c.off ? 0.5 : 1, hoverStyle: c.off ? undefined : { bg: '$hover' }, children: [_jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", gap: 2, children: [_jsx(SizableText, { fontSize: 14, color: "$ink", style: { textAlign: 'left' }, children: c.label }), _jsx(SizableText, { fontSize: 13, color: "$hint", style: { textAlign: 'left' }, children: c.note })] }), c.id === value ? _jsx(Check, { size: 16, color: "var(--primary)", mt: 2, "aria-hidden": true }) : null] }, c.id)))] }));
72
+ }
73
+ /** A word in the row that opens a panel of choices. */
74
+ function Opens({ label, word, dim, children }) {
75
+ const [open, setOpen] = useState(false);
76
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, placement: "top-end", children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(XStack, { children: _jsx(Word, { label: label, dim: dim, open: open, children: word }) }) }), _jsx(PopoverContent, { solid: true, p: 6, width: 288, maxW: "calc(100vw - 24px)", children: children(() => setOpen(false)) })] }));
77
+ }
78
+ /** The model's name, and the catalog behind it: Free's lane to pick from, the rest an upgrade. */
79
+ function Model({ mind, onUpgrade }) {
68
80
  const [open, setOpen] = useState(false);
69
- const name = nameOf(mind.model, mind.models);
70
- const thinks = reasons(mind.model, mind.models);
71
- const pace = EFFORTS.find((e) => e.id === mind.effort);
81
+ const [q, setQ] = useState('');
82
+ const groups = useMemo(() => {
83
+ const list = mind.models.filter((m) => !ROUTER_IDS.has(m.id) && (m.outputs?.includes('text') ?? true) && (!q.trim() || matchesModel(m, q)));
84
+ return groupModels(list);
85
+ }, [mind.models, q]);
72
86
  const routed = mind.model === ENSO;
73
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { disabled: disabled, children: _jsxs(XStack, { "data-slot": "mind", role: "button", tabIndex: disabled ? -1 : 0, "aria-label": thinks ? `Model and effort: ${name}, ${pace.label}` : `Model: ${name}`, "aria-haspopup": "dialog", "aria-expanded": open, items: "center", gap: "$1.5", px: "$2.5", height: ROUND, maxW: 220, rounded: 999, cursor: disabled ? 'default' : 'pointer', hoverStyle: disabled ? undefined : { bg: '$hover' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, opacity: disabled ? 0.4 : 1, onKeyDown: (e) => {
74
- if (disabled || (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown'))
75
- return;
76
- e.preventDefault?.();
77
- setOpen(true);
78
- }, children: [routed ? _jsx(Ring, {}) : null, _jsx(SizableText, { size: "$1", color: "$ink", fontWeight: "600", numberOfLines: 1, minW: 0, shrink: 1, children: name }), thinks ? (_jsx(SizableText, { size: "$1", color: "$soft", fontWeight: "500", shrink: 0, children: pace.label })) : null, _jsx(ChevronDown, { size: 12, color: "$soft", "aria-hidden": true })] }) }), _jsx(PopoverContent, { solid: true, align: "end", p: 6, width: 280, children: _jsxs(YStack, { gap: "$1", "data-slot": "mind-panel", role: "dialog", "aria-label": "Model and effort", children: [EFFORTS.map((e) => (_jsxs(XStack, { render: "button", role: "button", "aria-pressed": e.id === mind.effort, "aria-disabled": !thinks || undefined, onPress: () => {
79
- if (!thinks)
80
- return;
81
- mind.pace(e.id);
82
- setOpen(false);
83
- }, items: "center", gap: "$2", px: "$2", py: "$2", rounded: "$3", width: "100%", opacity: thinks ? 1 : 0.45, cursor: thinks ? 'pointer' : 'default', hoverStyle: thinks ? { bg: '$hover' } : undefined, children: [_jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(SizableText, { size: "$2", color: "$ink", style: { textAlign: 'left' }, children: e.label }), _jsx(SizableText, { size: "$1", color: "$soft", style: { textAlign: 'left' }, children: e.note })] }), e.id === mind.effort && thinks ? _jsx(Check, { size: 14, "aria-hidden": true }) : null] }, e.id))), _jsx(Separator, { my: "$1" }), _jsxs(YStack, { px: "$2", pt: "$1", pb: "$2", gap: "$2", children: [_jsx(SizableText, { size: "$1", color: "$soft", "data-slot": "mind-note", children: routed
84
- ? 'Enso picks the model for each message and step. Pick one to use it instead.'
85
- : `${name} answers every message and step${thinks ? '' : ', without an effort setting'}.` }), routed ? null : (_jsxs(XStack, { render: "button", role: "button", "aria-label": "Use Enso", onPress: () => {
86
- mind.pick(ENSO);
87
- setOpen(false);
88
- }, items: "center", gap: "$2", px: "$2", height: 28, rounded: "$3", self: "flex-start", borderWidth: 1, borderColor: "$borderColor", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: [_jsx(Ring, {}), _jsx(SizableText, { size: "$1", color: "$ink", children: "Use Enso" })] })), _jsx(ModelPicker, { size: "sm", name: "Model", models: mind.models, limits: limits,
89
- // The router is named on the chip; a catalog that does not list it leaves the picker open.
90
- value: routed && !mind.models.some((m) => m.id === ENSO) ? undefined : mind.model, placeholder: "Pick a model", onChange: (id) => {
91
- mind.pick(id);
92
- setOpen(false);
93
- }, loading: mind.loading, error: mind.error })] })] }) })] }));
87
+ return (_jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, placement: "top-end", children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(XStack, { "data-slot": "mind", children: _jsx(Word, { label: `Model: ${nameOf(mind.model, mind.models)}`, open: open, children: nameOf(mind.model, mind.models) }) }) }), _jsxs(DropdownMenuContent, { align: "end", minW: 280, maxH: 420, "data-slot": "model-menu", children: [_jsx(DropdownMenuSearch, { value: q, onValueChange: setQ, placeholder: "Search models" }), !mind.free && !q.trim() ? (_jsx(DropdownMenuItem, { onSelect: () => mind.pick(ENSO), children: _jsxs(XStack, { flex: 1, items: "center", gap: 8, children: [_jsxs(YStack, { flex: 1, children: [_jsx(SizableText, { fontSize: 14, color: "$ink", children: "Enso" }), _jsx(SizableText, { fontSize: 12, color: "$hint", children: "Picks the model for each message and step" })] }), routed ? _jsx(Check, { size: 14, color: "var(--primary)", "aria-hidden": true }) : null] }) })) : null, groups.map((g) => (_jsxs(YStack, { role: "group", "aria-label": g.label, children: [_jsx(SizableText, { fontSize: 12, color: "$hint", px: 8, pt: 8, pb: 2, children: g.label }), g.models.map((m) => {
88
+ const ok = grants(mind.free, m.id, mind.models);
89
+ return (_jsx(DropdownMenuItem, { "data-slot": "model-row", onSelect: () => (ok ? mind.pick(m.id) : onUpgrade?.(m.id)), children: _jsxs(XStack, { flex: 1, items: "center", gap: 8, children: [_jsx(SizableText, { fontSize: 14, color: ok ? '$ink' : '$hint', numberOfLines: 1, flex: 1, children: nameOf(m.id, mind.models) }), ok ? null : (_jsx(SizableText, { fontSize: 12, color: "$hint", px: 6, py: 1, rounded: 6, borderWidth: 1, borderColor: "$borderColor", children: "Upgrade" })), m.id === mind.model ? _jsx(Check, { size: 14, color: "var(--primary)", "aria-hidden": true }) : null] }) }, m.id));
90
+ })] }, g.key)))] })] }));
91
+ }
92
+ /** The row under the frame: where (Dev), then the model, the effort and the mode. */
93
+ export function Tune({ mind, mode, where, onUpgrade }) {
94
+ const thinks = mind ? reasons(mind.model, mind.models) : false;
95
+ const pace = mind ? EFFORTS.find((e) => e.id === mind.effort) : null;
96
+ return (_jsxs(XStack, { "data-slot": "tune", width: "100%", items: "center", gap: 2, flexWrap: "wrap", rowGap: 4, children: [where ? (_jsx(XStack, { items: "center", gap: 2, flexWrap: "wrap", minW: 0, "$max-sm": { width: '100%' }, children: where })) : null, _jsx(XStack, { flex: 1, "$max-sm": { display: 'none' } }), _jsxs(XStack, { items: "center", gap: 2, ml: "auto", shrink: 0, children: [mind ? _jsx(Model, { mind: mind, onUpgrade: onUpgrade }) : null, mind && thinks && pace ? (_jsx(Opens, { label: `Effort: ${pace.label}`, word: pace.label, dim: true, children: (close) => (_jsx(Choices, { title: "Effort", items: EFFORTS, value: mind.effort, onPick: (e) => {
97
+ mind.pace(e);
98
+ close();
99
+ } })) })) : null, mode ? (_jsxs(Popover, { allowFlip: true, placement: "top-end", children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(XStack, { render: "button", "data-slot": "mode", "aria-label": `Mode: ${MODES.find((m) => m.id === mode.value)?.label}`, items: "center", height: 24, px: 8, ml: 4, rounded: 6, bg: "$hover", cursor: "pointer", hoverStyle: { bg: '$raised' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, "$max-sm": { height: 44, px: 12 }, children: _jsx(SizableText, { fontSize: 14, color: "$ink", children: MODES.find((m) => m.id === mode.value)?.label }) }) }), _jsx(PopoverContent, { solid: true, p: 6, width: 288, maxW: "calc(100vw - 24px)", children: _jsx(Choices, { title: "Mode", items: MODES, value: mode.value, onPick: mode.set }) })] })) : null] })] }));
94
100
  }
95
- /** The pill's `+`: files go with the message. Its name on hover. */
96
- function Attach({ onAttach, disabled }) {
97
- return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onAttach, children: _jsx(Plus, { size: 18 }) }) }), _jsx(TooltipContent, { children: _jsx(SizableText, { size: "$2", children: "Attach files" }) })] }));
101
+ /** The `/` or `@` the caret ends on, and the words typed after it. */
102
+ export function trigger(value) {
103
+ const m = /(^|\s)([/@])([^\s/@]*)$/.exec(value);
104
+ return m ? { char: m[2], query: m[3].toLowerCase(), at: m.index + m[1].length } : null;
105
+ }
106
+ /** The list `/` or `@` opens above the frame. */
107
+ function Mention({ shelf, rows, index, onPick, tag, name }) {
108
+ return (_jsxs(YStack, { role: "listbox", "aria-label": name, "data-slot": "mention", width: "100%", maxH: 240, overflowY: "auto", overflowX: "hidden", p: 6, rounded: CORNER, borderWidth: 1, borderColor: "$borderColor", bg: "$background", "$max-sm": { rounded: CORNER_PHONE }, children: [_jsx(SizableText, { fontSize: 13, color: "$hint", px: 8, pt: 4, pb: 6, children: shelf.search ?? `Type to search ${name.toLowerCase()}` }), rows.length ? (rows.map((r, i) => (_jsxs(XStack, { render: "button", role: "option", "aria-selected": i === index, onPress: () => onPick(r.id), items: "center", gap: 10, px: 8, height: 36, rounded: 8, bg: i === index ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, cursor: "pointer", "$max-sm": { height: 46 }, children: [r.icon ? (_jsx(XStack, { width: 16, items: "center", justify: "center", "aria-hidden": true, children: r.icon })) : null, _jsx(SizableText, { fontSize: 15, color: "$ink", numberOfLines: 1, flex: 1, children: r.label }), _jsx(SizableText, { fontSize: 13, color: "$hint", children: r.on ? 'On' : tag })] }, r.id)))) : (_jsx(SizableText, { fontSize: 14, color: "$hint", px: 8, py: 8, children: shelf.loading ? 'Reading…' : 'Nothing matches' }))] }));
98
109
  }
99
110
  /** @hanzo/ui `Composer` props for the one composer. */
100
- export function prompt(o) {
111
+ export function prompt(o, state) {
101
112
  const lit = o.ready ?? Boolean(o.value.trim());
102
113
  const busy = Boolean(o.busy);
103
114
  const disabled = Boolean(o.disabled);
104
115
  const live = busy ? Boolean(o.onStop) : lit && !disabled;
105
- // A draft past a line takes the pill's width above the controls. The same
106
- // field either way, moved by flex order alone: a different tree would remount
107
- // it and drop the caret mid-word.
108
- const tall = Boolean(o.narrow) || o.value.includes('\n') || o.value.length > LINE;
109
- const head = o.head || o.note ? (_jsxs(YStack, { ...measure, gap: "$1.5", "data-slot": "prompt-head", children: [o.head, o.note ? (_jsx(SizableText, { "data-slot": "prompt-note", fontSize: 12, color: "$soft", text: "center", self: "center", children: o.note })) : null] })) : undefined;
116
+ const narrow = Boolean(o.narrow);
117
+ // `/` and `@`: the shelf the caret's word opens, filtered by what follows it.
118
+ const typed = trigger(o.value);
119
+ const shelf = typed ? (typed.char === '/' ? o.slash : o.at) : undefined;
120
+ const listed = shelf && typed && state?.closed !== o.value ? shelf.rows.filter((r) => r.label.toLowerCase().includes(typed.query)).slice(0, 8) : null;
121
+ const index = Math.min(state?.index ?? 0, Math.max(0, (listed?.length ?? 1) - 1));
122
+ const choose = (id) => {
123
+ if (!typed || !shelf)
124
+ return;
125
+ o.onChange(o.value.slice(0, typed.at).trimEnd() + (typed.at > 0 ? ' ' : ''));
126
+ shelf.pick(id);
127
+ };
128
+ const mention = listed && shelf && typed ? (_jsx(Mention, { shelf: shelf, rows: listed, index: index, onPick: choose, tag: typed.char === '/' ? 'Skill' : 'Plugin', name: typed.char === '/' ? 'Skills' : 'Plugins' })) : null;
129
+ const head = o.head || o.note || mention || (narrow && o.below) ? (_jsxs(YStack, { ...measure, gap: "$1.5", "data-slot": "prompt-head", children: [narrow ? o.below : null, o.head, mention, o.note ? (_jsx(SizableText, { "data-slot": "prompt-note", fontSize: 12, color: "$soft", text: "center", self: "center", children: o.note })) : null] })) : undefined;
130
+ const foot = o.mind || o.mode || o.where || (!narrow && o.below) ? (_jsxs(YStack, { width: "100%", gap: 10, "data-slot": "prompt-foot", children: [_jsx(Tune, { mind: o.mind, mode: o.mode, where: o.where, onUpgrade: o.onUpgrade }), narrow ? null : o.below] })) : undefined;
131
+ const round = (_jsx(Control, { type: "button", size: ROUND, fill: true, rounded: 999, "data-slot": "composer-send", "aria-label": busy ? 'Stop' : 'Send', "aria-disabled": !live || undefined, opacity: live ? 1 : 0.4, onClick: () => {
132
+ if (!live)
133
+ return;
134
+ if (busy)
135
+ o.onStop?.();
136
+ else
137
+ o.onSend();
138
+ }, children: busy ? _jsx(Square, { size: 13, fill: "currentColor" }) : _jsx(ArrowUp, { size: 16, strokeWidth: 2.5 }) }));
110
139
  return {
111
140
  value: o.value,
112
141
  onChange: o.onChange,
113
- onSend: o.onSend,
142
+ onSend: () => {
143
+ if (listed?.length)
144
+ return choose(listed[index].id);
145
+ o.onSend();
146
+ },
114
147
  ...(o.onStop ? { onStop: o.onStop } : {}),
115
148
  busy,
116
149
  disabled,
117
- ...(o.placeholder ? { placeholder: o.placeholder } : {}),
150
+ placeholder: o.placeholder ?? ASK,
118
151
  ...(o.label ? { label: o.label } : {}),
119
- // The field: first and the pill's width on a tall draft, after `+` on a line.
120
- // inline-style: flex `order`, which no gui prop carries; the field keeps its node either way.
121
- // An empty field keeps its placeholder to the line, cut short rather than wrapped inside the pill.
152
+ rows: 2,
122
153
  field: {
123
154
  ...o.field,
124
- style: { ...(o.field?.style ?? {}), order: tall ? 0 : 1, ...(o.value ? null : { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }) },
125
- ...(tall ? { width: '100%', flexBasis: '100%', px: '$2' } : null),
126
- // A phone keeps the field a readable width: what does not fit beside it goes to a second row.
127
- '$max-sm': { minW: 140 },
155
+ fontSize: 16,
156
+ lineHeight: 24,
157
+ px: 8,
158
+ pt: 2,
159
+ minH: 48,
160
+ bg: 'transparent',
128
161
  },
129
- // Above the pill and at its width, so a chip lines up with the field's edge.
130
162
  head,
131
- inline: true,
163
+ foot,
132
164
  ...measure,
133
- minH: PILL,
134
- rounded: tall ? CORNER : 999,
135
- pl: 6,
136
- pr: 6,
137
- gap: 4,
138
- items: tall ? 'flex-end' : 'center',
139
- flexWrap: tall ? 'wrap' : 'nowrap',
140
- '$max-sm': { flexWrap: 'wrap', rounded: CORNER },
165
+ rounded: CORNER,
166
+ p: 8,
167
+ pt: 12,
168
+ gap: 6,
141
169
  borderColor: '$borderColor',
142
170
  bg: '$edge',
171
+ '$max-sm': { rounded: CORNER_PHONE },
143
172
  ...(o.framed ? { boxShadow: 'var(--shadow-sheet-1)' } : null),
144
- children: (_jsxs(XStack, { items: "center", gap: 2, "data-slot": "prompt-start", style: { order: tall ? 1 : 0 }, shrink: 0, children: [o.onAttach ? _jsx(Attach, { onAttach: o.onAttach, disabled: disabled }) : null, o.tools] })),
145
- send: (_jsxs(XStack, { items: "center", gap: 2, "data-slot": "prompt-end", style: { order: 2 }, ml: tall ? 'auto' : 0, "$max-sm": { ml: 'auto' }, shrink: 0, children: [o.mind ? _jsx(Tune, { mind: o.mind, limits: o.limits, disabled: disabled && !busy }) : null, o.voice ? (_jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: o.voice, disabled: disabled, says: { idle: 'Dictate', listening: 'Dictating — click to stop' }, children: (state) => _jsx(Mic, { size: 15, fill: state === 'idle' ? 'none' : 'currentColor' }) }) })) : null, o.talk ? (_jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: o.talk, disabled: disabled, "data-slot": "composer-talk", says: { idle: 'Talk with Hanzo', listening: 'In conversation — click to hang up', speaking: 'Hanzo is speaking — talk to interrupt' }, children: () => _jsx(AudioLines, { size: 15 }) }) })) : null, _jsx(Control, { type: "button", size: ROUND, fill: true, rounded: 999, "data-slot": "composer-send", "aria-label": busy ? 'Stop' : 'Send', "aria-disabled": !live || undefined, opacity: live ? 1 : 0.4, onClick: () => {
146
- if (!live)
147
- return;
148
- if (busy)
149
- o.onStop?.();
150
- else
151
- o.onSend();
152
- }, children: busy ? _jsx(Square, { size: 13, fill: "currentColor" }) : _jsx(ArrowUp, { size: 15, strokeWidth: 2.5 }) })] })),
173
+ children: (_jsxs(XStack, { items: "center", gap: 4, "data-slot": "prompt-start", flex: 1, minW: 0, flexWrap: "wrap", children: [o.plus ? _jsx(Plus, { ...o.plus, disabled: disabled || o.plus.disabled }) : null, (o.on ?? []).map((c) => (_jsxs(XStack, { "data-slot": "prompt-chip", items: "center", gap: 6, pl: 8, pr: 4, height: 28, rounded: 8, bg: "$hover", maxW: 180, children: [c.icon ? _jsx(XStack, { "aria-hidden": true, children: c.icon }) : null, _jsx(SizableText, { fontSize: 13, color: "$ink", numberOfLines: 1, shrink: 1, children: c.label }), _jsx(XStack, { render: "button", "aria-label": `Remove ${c.label}`, onPress: c.onRemove, width: 20, height: 20, rounded: 6, items: "center", justify: "center", hoverStyle: { bg: '$raised' }, cursor: "pointer", children: _jsx(X, { size: 12, color: "$hint" }) })] }, c.id)))] })),
174
+ send: (_jsxs(XStack, { items: "center", gap: 6, "data-slot": "prompt-end", shrink: 0, children: [o.voice ? _jsx(Dictation, { voice: o.voice, disabled: disabled, language: o.language ?? null, ...(o.onLanguage ? { onLanguage: o.onLanguage } : {}) }) : null, !busy && !lit && o.talk ? (_jsx(Control, { asChild: true, size: ROUND, fill: narrow, rounded: 999, ...(narrow ? null : QUIET), children: _jsx(Voice, { voice: o.talk, disabled: disabled, "data-slot": "composer-talk", says: { idle: 'Voice mode', listening: 'In conversation — click to hang up', speaking: 'Hanzo is speaking — talk to interrupt' }, children: () => _jsx(AudioLines, { size: 16 }) }) })) : (round)] })),
175
+ };
176
+ }
177
+ /** Ctrl+U (⌘U) uploads, while a composer with an upload is on the page. */
178
+ export function useUpload(upload) {
179
+ const now = useRef(upload);
180
+ now.current = upload;
181
+ useEffect(() => {
182
+ if (!upload)
183
+ return;
184
+ const key = (e) => {
185
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'u') {
186
+ e.preventDefault();
187
+ now.current?.();
188
+ }
189
+ };
190
+ window.addEventListener('keydown', key);
191
+ return () => window.removeEventListener('keydown', key);
192
+ }, [Boolean(upload)]); // eslint-disable-line react-hooks/exhaustive-deps
193
+ }
194
+ /**
195
+ * The composer's own state — which row of a `/` or `@` list is lit — and the
196
+ * keys that move it. A surface that hands `prompt` to `Chat` calls this and
197
+ * passes its answer through; `<Prompt>` does it for itself.
198
+ */
199
+ export function useMention(o) {
200
+ const [index, setIndex] = useState(0);
201
+ const [closed, setClosed] = useState(null);
202
+ useUpload(o.plus?.upload);
203
+ const typed = trigger(o.value);
204
+ const shelf = typed ? (typed.char === '/' ? o.slash : o.at) : undefined;
205
+ const open = Boolean(shelf && closed !== o.value);
206
+ useEffect(() => setIndex(0), [typed?.char, typed?.query]);
207
+ const field = {
208
+ ...o.field,
209
+ onKeyDown: (e) => {
210
+ const key = e.key ?? e.nativeEvent?.key;
211
+ if (open && key === 'ArrowDown') {
212
+ e.preventDefault?.();
213
+ setIndex((i) => i + 1);
214
+ }
215
+ else if (open && key === 'ArrowUp') {
216
+ e.preventDefault?.();
217
+ setIndex((i) => Math.max(0, i - 1));
218
+ }
219
+ else if (open && key === 'Escape') {
220
+ e.preventDefault?.();
221
+ setClosed(o.value);
222
+ }
223
+ else
224
+ o.field?.onKeyDown?.(e);
225
+ },
153
226
  };
227
+ return { state: { index, closed }, field };
154
228
  }
155
229
  /** The composer drawn on its own. */
156
230
  export function Prompt(o) {
157
231
  const media = useMedia();
158
- return _jsx(Composer, { ...prompt({ ...o, narrow: o.narrow ?? !media.sm }) });
232
+ const { state, field } = useMention(o);
233
+ return _jsx(Composer, { ...prompt({ ...o, field, narrow: o.narrow ?? !media.sm }, state) });
159
234
  }