@hanzo/build 0.2.52 → 0.2.54

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.
Files changed (96) hide show
  1. package/README.md +103 -49
  2. package/lib/api/billing.d.ts +39 -0
  3. package/lib/api/billing.js +41 -0
  4. package/lib/api/coding.d.ts +4 -1
  5. package/lib/api/coding.js +2 -0
  6. package/lib/api/environment.d.ts +12 -0
  7. package/lib/api/environment.js +39 -1
  8. package/lib/api/github.d.ts +24 -4
  9. package/lib/api/github.js +60 -16
  10. package/lib/api/limits.d.ts +69 -0
  11. package/lib/api/limits.js +86 -0
  12. package/lib/api/links.d.ts +42 -0
  13. package/lib/api/links.js +65 -0
  14. package/lib/api/pref.d.ts +5 -3
  15. package/lib/api/pref.js +0 -3
  16. package/lib/api/turn.d.ts +10 -1
  17. package/lib/api/turn.js +16 -4
  18. package/lib/api/work.d.ts +75 -38
  19. package/lib/api/work.js +132 -44
  20. package/lib/builder.d.ts +0 -10
  21. package/lib/builder.js +12 -52
  22. package/lib/choice.d.ts +21 -0
  23. package/lib/choice.js +40 -1
  24. package/lib/customize/agents.d.ts +1 -1
  25. package/lib/customize/agents.js +21 -19
  26. package/lib/customize/connectors.d.ts +1 -1
  27. package/lib/customize/connectors.js +80 -31
  28. package/lib/customize/index.d.ts +2 -0
  29. package/lib/customize/index.js +23 -19
  30. package/lib/customize/load.d.ts +9 -0
  31. package/lib/customize/load.js +22 -0
  32. package/lib/customize/plugins.d.ts +1 -1
  33. package/lib/customize/plugins.js +21 -19
  34. package/lib/customize/say.d.ts +1 -0
  35. package/lib/customize/say.js +38 -0
  36. package/lib/customize/skills.d.ts +1 -1
  37. package/lib/customize/skills.js +23 -17
  38. package/lib/customize/ui.d.ts +28 -11
  39. package/lib/customize/ui.js +29 -21
  40. package/lib/data.d.ts +13 -0
  41. package/lib/data.js +70 -0
  42. package/lib/desk.d.ts +6 -1
  43. package/lib/desk.js +43 -19
  44. package/lib/environment.d.ts +11 -3
  45. package/lib/environment.js +53 -13
  46. package/lib/foot.js +10 -6
  47. package/lib/forge.js +139 -71
  48. package/lib/index.d.ts +28 -2
  49. package/lib/index.js +26 -2
  50. package/lib/landing.d.ts +0 -10
  51. package/lib/landing.js +70 -38
  52. package/lib/merge.d.ts +223 -0
  53. package/lib/merge.js +380 -0
  54. package/lib/meter.d.ts +31 -0
  55. package/lib/meter.js +45 -0
  56. package/lib/mind.d.ts +60 -0
  57. package/lib/mind.js +187 -0
  58. package/lib/nav.d.ts +11 -0
  59. package/lib/nav.js +75 -0
  60. package/lib/panel.d.ts +46 -0
  61. package/lib/panel.js +116 -0
  62. package/lib/pick.d.ts +8 -0
  63. package/lib/pick.js +18 -1
  64. package/lib/plan.d.ts +78 -0
  65. package/lib/plan.js +133 -0
  66. package/lib/project.js +16 -6
  67. package/lib/prompt.d.ts +75 -0
  68. package/lib/prompt.js +125 -0
  69. package/lib/prose.js +14 -10
  70. package/lib/reply.d.ts +36 -0
  71. package/lib/reply.js +124 -0
  72. package/lib/route.d.ts +16 -0
  73. package/lib/route.js +40 -0
  74. package/lib/run.js +87 -52
  75. package/lib/section.d.ts +1 -1
  76. package/lib/section.js +7 -11
  77. package/lib/settings/billing.js +16 -9
  78. package/lib/settings/code.js +15 -12
  79. package/lib/settings/credits.d.ts +9 -0
  80. package/lib/settings/credits.js +110 -0
  81. package/lib/settings/plan.d.ts +17 -0
  82. package/lib/settings/plan.js +108 -0
  83. package/lib/settings/usage.js +11 -119
  84. package/lib/shelf.js +3 -3
  85. package/lib/standing.d.ts +44 -0
  86. package/lib/standing.js +64 -0
  87. package/lib/sync.js +22 -8
  88. package/lib/tabs.d.ts +53 -0
  89. package/lib/tabs.js +197 -0
  90. package/lib/tools.d.ts +2 -9
  91. package/lib/tools.js +20 -25
  92. package/lib/transcript.d.ts +10 -1
  93. package/lib/transcript.js +25 -10
  94. package/lib/voice.d.ts +2 -13
  95. package/lib/voice.js +7 -73
  96. package/package.json +20 -3
package/lib/plan.js ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A plan as a person reads it, and how much of it is used.
3
+ *
4
+ * NAMED BY FAMILY. A slug is the catalog's rung (`max-20x`); a person reads the
5
+ * family — Free, Go, Pro, Max, Team, Agency, Enterprise — with the rung inside
6
+ * it as a small tag (`20x`). A slug outside the families reads as words
7
+ * (`growth-plus` is Growth Plus); one that is an id is never printed, and reads
8
+ * as the tier it is served under.
9
+ *
10
+ * MEASURED IN SHARES. `GET /v1/ai/limits` (cloud apps/ai/limits_ops.go) answers
11
+ * every window as a percent used, its state and when it starts over — the
12
+ * session, the UTC day, and each class's billing period — never an amount.
13
+ *
14
+ * THREE KINDS OF ACCOUNT, three meters: on a PLAN its windows are the meter, on
15
+ * FREE the free allowance left today is, and with CREDITS and no plan (pay as
16
+ * you go) the balance is. A plan's meter never shows money.
17
+ */
18
+ /** The families, by the slug's first word. `dev` is the slug Pro is sold under. */
19
+ const FAMILY = {
20
+ free: 'Free',
21
+ go: 'Go',
22
+ dev: 'Pro',
23
+ pro: 'Pro',
24
+ max: 'Max',
25
+ team: 'Team',
26
+ agency: 'Agency',
27
+ advisory: 'Agency',
28
+ dedicated: 'Agency',
29
+ pod: 'Agency',
30
+ enterprise: 'Enterprise',
31
+ };
32
+ /** The tier classes billing serves a rung under (`GET /v1/billing/tier` `tier.name`) that name a plan. */
33
+ const TIER = { pro: 'Pro', enterprise: 'Enterprise' };
34
+ /** A slug that is an id rather than a name: a UUID, a run of hex, a prefixed id, or digits. */
35
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-|[0-9a-f]{12,}|^[a-z]+_[0-9a-z]{6,}$|^\d+$/i;
36
+ /** A word as a title reads it; a multiple (`2x`) stays as it is. */
37
+ const word = (w) => (/^\d+x$/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1));
38
+ /**
39
+ * A plan as a person reads it, from the slug billing serves (`max-20x`) and the
40
+ * tier class beside it (`pro`). Null where neither names a plan: no slug and a
41
+ * free or trial tier, which is no plan at all.
42
+ */
43
+ export function label(slug, tier) {
44
+ const raw = (slug ?? '').trim().toLowerCase();
45
+ const fallback = () => {
46
+ const t = TIER[(tier ?? '').trim().toLowerCase()];
47
+ return t ? { name: t, tag: '' } : null;
48
+ };
49
+ if (!raw)
50
+ return fallback();
51
+ // A rung that is an id still IS a rung: the account is on a plan, so it reads as one.
52
+ if (ID.test(raw))
53
+ return fallback() ?? { name: 'Paid plan', tag: '' };
54
+ const s = raw.replace(/[\s_]+/g, '-');
55
+ const [head, ...rest] = s.split('-').filter(Boolean);
56
+ const family = FAMILY[head];
57
+ if (!family)
58
+ return { name: s.split('-').filter(Boolean).map(word).join(' '), tag: '' };
59
+ const tail = rest.join('-');
60
+ const tag = /^\d+x$/.test(tail) ? tail : /^(annual|yearly|year)$/.test(tail) ? 'Annual' : tail ? rest.map(word).join(' ') : '';
61
+ return { name: family, tag };
62
+ }
63
+ /** A label as a sentence says it: `Max 20x`. */
64
+ export const said = (l) => (l ? (l.tag ? `${l.name} ${l.tag}` : l.name) : '');
65
+ const STATES = ['ok', 'near', 'limited'];
66
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
67
+ /**
68
+ * One window off the wire, or null for a window that bounds nothing. The
69
+ * server states a window's state only when the plan sets it a limit
70
+ * (limits_ops.go `window`), so a window with no state is unbounded and not drawn.
71
+ */
72
+ export function share(v) {
73
+ const o = obj(v);
74
+ const state = STATES.find((s) => s === o?.state);
75
+ if (!o || !state || typeof o.percent !== 'number' || !Number.isFinite(o.percent))
76
+ return null;
77
+ return {
78
+ percent: Math.min(100, Math.max(0, Math.round(o.percent))),
79
+ state,
80
+ resets: typeof o.resets_at === 'string' ? o.resets_at : '',
81
+ };
82
+ }
83
+ /**
84
+ * The meter for a plan slug (billing's or the limits', `free` or '' for none),
85
+ * the prepaid balance in cents (null where it could not be read) and the tier
86
+ * class billing serves the account under: a paid plan's windows — a rung, or a
87
+ * paid class with no rung (a plan recorded outside checkout, a partner org) —
88
+ * and with no plan, the balance where there is money in it, else the free
89
+ * allowance.
90
+ */
91
+ export function kind(plan, balance, tier) {
92
+ const p = (plan ?? '').trim().toLowerCase();
93
+ if ((p && p !== 'free') || TIER[(tier ?? '').trim().toLowerCase()])
94
+ return 'plan';
95
+ return balance !== null && balance > 0 ? 'credits' : 'free';
96
+ }
97
+ /**
98
+ * When something starts over, as a sentence says it: a time for within a day
99
+ * (`5:00 PM`), else a date (`Nov 1`), periods ending on UTC days. '' for none.
100
+ */
101
+ export function when(iso, now = Date.now()) {
102
+ const t = iso ? Date.parse(iso) : NaN;
103
+ if (Number.isNaN(t))
104
+ return '';
105
+ const d = new Date(t);
106
+ return t - now < 86_400_000
107
+ ? d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
108
+ : d.toLocaleDateString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric' });
109
+ }
110
+ /** A class of model as a sentence names it. */
111
+ const CLASS = { premium: 'premium', ours: 'Hanzo' };
112
+ /**
113
+ * What a reader whose plan's included usage is used is told: the plan, which
114
+ * models, and when they come back — `Your Max 20x plan’s included premium model
115
+ * usage is used until 5:00 PM.`
116
+ */
117
+ export function spent(l, classes, resets, now = Date.now()) {
118
+ const names = classes.map((c) => CLASS[c]).filter(Boolean);
119
+ const what = names.length ? `${names.join(' and ')} model usage` : 'usage';
120
+ const at = when(resets, now);
121
+ return `Your ${l ? `${said(l)} plan’s` : 'plan’s'} included ${what} is used${at ? ` until ${at}` : ''}.`;
122
+ }
123
+ /**
124
+ * The ways past a spent allowance, in this order and two at most: keep going
125
+ * on credits — or, with none the server would let pay, add some — then the
126
+ * upgrade; and a switch to another model where the server offers one. Taken
127
+ * from what the server sent, so nothing is offered that it would refuse.
128
+ */
129
+ export function ways(actions) {
130
+ const pick = (kind) => actions.find((a) => a.kind === kind);
131
+ const topup = pick('topup');
132
+ return [pick('credits') ?? (topup ? { ...topup, label: 'Add credits' } : undefined), pick('upgrade'), pick('switch')].filter((a) => a !== undefined);
133
+ }
package/lib/project.js CHANGED
@@ -41,9 +41,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
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
43
  import { Attachments, CHAT, Console, DEVICES, Feedback, FileTabs, FileTree, ModeSelect, PageSelect, PreviewFrame, ProjectChip, Suggestions, SUGGESTIONS, VIEWS, Views, Workspace, fold, } from '@hanzo/ui/agents';
44
- import { Composer, Message } from '@hanzo/ui/chat';
44
+ import { Message } from '@hanzo/ui/chat';
45
45
  import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
46
46
  import { HanzoMark } from '@hanzo/ui/product';
47
+ import { useDictation, useVoice } from '@hanzo/voice';
47
48
  import { useEffect, useMemo, useRef, useState } from 'react';
48
49
  import { read as pushed } from './api/changes.js';
49
50
  import { codebases, one } from './api/codebases.js';
@@ -52,17 +53,20 @@ import { blob, tree } from './api/git.js';
52
53
  import { declare } from './api/platform.js';
53
54
  import { address, deployments, name as repoName, ours, templates } from './api/projects.js';
54
55
  import { list, message, stop } from './api/sessions.js';
55
- import { decode, outcome, pull, said, who } from './api/turn.js';
56
+ import { decode, outcome, pull, said, scrub, who } from './api/turn.js';
56
57
  import { verdict as record } from './api/verdict.js';
57
58
  import { useKept, useProjects, useRead, useRun } from './data.js';
58
59
  import { Merge } from './git.js';
59
60
  import { Grip } from './grip.js';
60
61
  import { useHost, useTarget } from './host.js';
61
62
  import { MODES } from './landing.js';
63
+ import { useMind } from './mind.js';
62
64
  import { Out } from './out.js';
65
+ import { Prompt, useEar } from './prompt.js';
66
+ import { useSpoken } from './voice.js';
63
67
  import { Publish } from './publish.js';
64
68
  import { path } from './route.js';
65
- import { Attach, compose, Dictate, Files } from './tools.js';
69
+ import { compose, Files, pick } from './tools.js';
66
70
  const LIVE = new Set(['running', 'paused']);
67
71
  /** How often a project being published is read again, ms. */
68
72
  const AGAIN = 5000;
@@ -196,6 +200,9 @@ export function Project({ at }) {
196
200
  const [note, setNote] = useState('');
197
201
  const [picked, setPicked] = useState([]);
198
202
  const [attached, setAttached] = useState([]);
203
+ // The model and effort Chat and New share, and dictation into the ask.
204
+ const mind = useMind(t);
205
+ const voice = useVoice({ speech: useEar(t), language: useSpoken(), ...useDictation(draft, setDraft) });
199
206
  const [bridge, setBridge] = useState(false);
200
207
  const [editing, setEditing] = useState(false);
201
208
  const [pageLines, setPageLines] = useState([]);
@@ -286,7 +293,7 @@ export function Project({ at }) {
286
293
  .map((e) => {
287
294
  // The console is where a run's error is read whole; the chat says it in one plain line.
288
295
  const body = decode(e.payload);
289
- const error = e.kind === 'status' && body && typeof body === 'object' && typeof body.error === 'string' ? body.error : '';
296
+ const error = e.kind === 'status' && body && typeof body === 'object' && typeof body.error === 'string' ? scrub(body.error) : '';
290
297
  return {
291
298
  id: `r${e.seq || e.id}`,
292
299
  level: error || (e.kind === 'status' && /error/.test(said(e, run.record?.mode).toLowerCase())) ? 'error' : e.kind === 'tool-call' ? 'info' : 'log',
@@ -339,6 +346,9 @@ export function Project({ at }) {
339
346
  base: named ? forge.value?.branch : project?.branch,
340
347
  after: current ?? undefined,
341
348
  mode,
349
+ // Enso named as itself: a run that names none takes the person's saved coding default.
350
+ model: mind.model,
351
+ effort: mind.effort,
342
352
  });
343
353
  setDraft('');
344
354
  setPicked([]);
@@ -407,9 +417,9 @@ export function Project({ at }) {
407
417
  ? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
408
418
  : named
409
419
  ? `${named.org}/${named.name} is open on ${home}. Say what to change and it gets built there.`
410
- : `${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, _jsxs(Composer, { value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
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
411
421
  ? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch'), (e) => setNote(e.message))
412
- : undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] }), 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] }));
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] }));
413
423
  // The preview stays loaded while another view is open, so coming back to it
414
424
  // is the page as it was left.
415
425
  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
@@ -0,0 +1,75 @@
1
+ import { type ComposerProps } from '@hanzo/ui/chat';
2
+ import { type PauseSource } from '@hanzo/ui/models';
3
+ import { type Machine, type Speech } from '@hanzo/voice';
4
+ import { type ReactNode } from 'react';
5
+ import type { Target } from './api/call.ts';
6
+ import { type Mind } from './mind.ts';
7
+ /** The reading measure: @hanzo/design's prose width, which Width (appearance) retunes. */
8
+ export declare const MEASURE = "var(--container-prose, 48rem)";
9
+ /** The space between turns, px: one rhythm for Chat and Dev. */
10
+ export declare const GAP = 20;
11
+ /** The composer's width: the measure less the thread column's 0.75rem gutters, centred, so its edges are the text's. */
12
+ export declare const measure: {
13
+ readonly width: 'calc(100% - 1.5rem)';
14
+ readonly maxW: "calc(var(--container-prose, 48rem) - 1.5rem)";
15
+ readonly mx: 'auto';
16
+ };
17
+ /** The round controls' box, px: a line of the footer row. */
18
+ export declare const ROUND = 30;
19
+ /**
20
+ * 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).
23
+ */
24
+ export declare const cut: {
25
+ readonly bg: 'var(--pane-fill, var(--sheet-1))';
26
+ readonly rounded: 'var(--pane-round, var(--radius-xl))';
27
+ readonly borderWidth: 1;
28
+ readonly borderColor: 'var(--pane-edge, var(--white-08))';
29
+ readonly backdropFilter: 'var(--pane-blur, blur(20px) saturate(1.8))';
30
+ readonly boxShadow: 'var(--shadow-sheet-1)';
31
+ };
32
+ /** The platform's ear and voice for a target: dictation, talk and Listen all hear through it. */
33
+ export declare function useEar(t: Target): Speech;
34
+ export interface PromptOptions {
35
+ value: string;
36
+ onChange: (value: string) => void;
37
+ onSend: () => void;
38
+ /** Stop the turn in flight. Absent, busy only disables send. */
39
+ onStop?: () => void;
40
+ busy?: boolean;
41
+ disabled?: boolean;
42
+ /** Whether send is lit. Default: the draft has words. */
43
+ ready?: boolean;
44
+ placeholder?: string;
45
+ label?: string;
46
+ /** Above the frame, at its width: what the draft is sent with. */
47
+ head?: ReactNode;
48
+ field?: ComposerProps['field'];
49
+ /** The model and effort. Null where the next send uses neither (steering a run). */
50
+ mind?: Mind | null;
51
+ 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;
56
+ /** Dictation into the draft (@hanzo/voice `useVoice` + `useDictation`). */
57
+ voice?: Machine;
58
+ /** A spoken conversation (@hanzo/voice `useTalk`). */
59
+ talk?: Machine;
60
+ /** Drawn on a pane: the frame is cut as a pane of its own (`cut`). */
61
+ framed?: boolean;
62
+ }
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 declare function Tune({ mind, limits, disabled }: {
68
+ mind: Mind;
69
+ limits?: PauseSource | null;
70
+ disabled?: boolean;
71
+ }): import("react").JSX.Element;
72
+ /** @hanzo/ui `Composer` props for the one composer. */
73
+ export declare function prompt(o: PromptOptions): ComposerProps;
74
+ /** The composer drawn on its own. */
75
+ export declare function Prompt(o: PromptOptions): import("react").JSX.Element;
package/lib/prompt.js ADDED
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The composer, once, for Chat and Dev: the field, and under it in the frame
4
+ * the attach button and the surface's own tools at the start, then the model
5
+ * and effort (`Tune`), dictation, talk and send at the end.
6
+ *
7
+ * `prompt` builds @hanzo/ui's `Composer` props, so a surface that draws the
8
+ * composer through `Chat` (its `composer` prop) and one that draws it directly
9
+ * (`<Prompt>`) draw the same thing. What each surface sends, and what it puts
10
+ * above the frame (`head`), stays its own.
11
+ *
12
+ * The frame is as wide as the text above it: the reading measure, less the
13
+ * thread column's own gutters, centred (`measure`). A transcript reads at
14
+ * `MEASURE` and this lines up with its words.
15
+ */
16
+ import { SizableText, XStack, YStack } from '@hanzo/gui';
17
+ import { ArrowUp, AudioLines, Check, ChevronDown, Mic, Paperclip, Square } from '@hanzogui/lucide-icons-2';
18
+ import { Control } from '@hanzo/composer';
19
+ import { Popover, PopoverContent, PopoverTrigger, Separator, Tooltip, TooltipContent, TooltipTrigger } from '@hanzo/ui';
20
+ import { Composer } from '@hanzo/ui/chat';
21
+ import { ModelPicker } from '@hanzo/ui/models';
22
+ import { speech, Voice } from '@hanzo/voice';
23
+ import { useMemo, useState } from 'react';
24
+ import { ENSO } from './api/models.js';
25
+ import { EFFORTS, nameOf, reasons } from './mind.js';
26
+ /** The reading measure: @hanzo/design's prose width, which Width (appearance) retunes. */
27
+ export const MEASURE = 'var(--container-prose, 48rem)';
28
+ /** The space between turns, px: one rhythm for Chat and Dev. */
29
+ export const GAP = 20;
30
+ /** The composer's width: the measure less the thread column's 0.75rem gutters, centred, so its edges are the text's. */
31
+ export const measure = { width: 'calc(100% - 1.5rem)', maxW: `calc(${MEASURE} - 1.5rem)`, mx: 'auto' };
32
+ /** The round controls' box, px: a line of the footer row. */
33
+ export const ROUND = 30;
34
+ /**
35
+ * The frame as a pane of its own, floating on the pane it is drawn in: the
36
+ * `--pane-*` cut on design's first paper rung (a second rung's drop reads as a
37
+ * gradient through a translucent pane).
38
+ */
39
+ export const cut = {
40
+ bg: 'var(--pane-fill, var(--sheet-1))',
41
+ rounded: 'var(--pane-round, var(--radius-xl))',
42
+ borderWidth: 1,
43
+ borderColor: 'var(--pane-edge, var(--white-08))',
44
+ backdropFilter: 'var(--pane-blur, blur(20px) saturate(1.8))',
45
+ boxShadow: 'var(--shadow-sheet-1)',
46
+ };
47
+ /** The platform's ear and voice for a target: dictation, talk and Listen all hear through it. */
48
+ export function useEar(t) {
49
+ return useMemo(() => speech({ baseUrl: t.api, token: t.token, ...(t.org ? { org: t.org } : {}) }), [t]);
50
+ }
51
+ /** Enso's mark: the ring. */
52
+ function Ring() {
53
+ return _jsx(YStack, { "aria-hidden": true, width: 12, height: 12, rounded: 99, borderWidth: 1.5, borderColor: "$ink", shrink: 0 });
54
+ }
55
+ /**
56
+ * The chip beside send, and the panel it opens: the effort, a sentence saying
57
+ * what serves the ask, and the model.
58
+ */
59
+ export function Tune({ mind, limits, disabled = false }) {
60
+ const [open, setOpen] = useState(false);
61
+ const name = nameOf(mind.model, mind.models);
62
+ const thinks = reasons(mind.model, mind.models);
63
+ const pace = EFFORTS.find((e) => e.id === mind.effort);
64
+ const routed = mind.model === ENSO;
65
+ 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: 30, maxW: 220, rounded: "$10", borderWidth: 1, borderColor: "$borderColor", cursor: disabled ? 'default' : 'pointer', hoverStyle: disabled ? undefined : { bg: '$hover' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, opacity: disabled ? 0.4 : 1, onKeyDown: (e) => {
66
+ if (disabled || (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown'))
67
+ return;
68
+ e.preventDefault?.();
69
+ setOpen(true);
70
+ }, 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: () => {
71
+ if (!thinks)
72
+ return;
73
+ mind.pace(e.id);
74
+ setOpen(false);
75
+ }, 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
76
+ ? 'Enso picks the model for each message and step. Pick one to use it instead.'
77
+ : `${name} answers every message and step${thinks ? '' : ', without an effort setting'}.` }), routed ? null : (_jsxs(XStack, { render: "button", role: "button", "aria-label": "Use Enso", onPress: () => {
78
+ mind.pick(ENSO);
79
+ setOpen(false);
80
+ }, 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,
81
+ // The router is named on the chip; a catalog that does not list it leaves the picker open.
82
+ value: routed && !mind.models.some((m) => m.id === ENSO) ? undefined : mind.model, placeholder: "Pick a model", onChange: (id) => {
83
+ mind.pick(id);
84
+ setOpen(false);
85
+ }, loading: mind.loading, error: mind.error })] })] }) })] }));
86
+ }
87
+ /** The paperclip, with its name on hover. */
88
+ function Attach({ onAttach, disabled }) {
89
+ 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(Paperclip, { size: 15 }) }) }), _jsx(TooltipContent, { children: _jsx(SizableText, { size: "$2", children: "Attach files" }) })] }));
90
+ }
91
+ /** @hanzo/ui `Composer` props for the one composer. */
92
+ export function prompt(o) {
93
+ const lit = o.ready ?? Boolean(o.value.trim());
94
+ const busy = Boolean(o.busy);
95
+ const disabled = Boolean(o.disabled);
96
+ const live = busy ? Boolean(o.onStop) : lit && !disabled;
97
+ return {
98
+ value: o.value,
99
+ onChange: o.onChange,
100
+ onSend: o.onSend,
101
+ ...(o.onStop ? { onStop: o.onStop } : {}),
102
+ busy,
103
+ disabled,
104
+ ...(o.placeholder ? { placeholder: o.placeholder } : {}),
105
+ ...(o.label ? { label: o.label } : {}),
106
+ ...(o.field ? { field: o.field } : {}),
107
+ // Above the frame and at its width, so a chip lines up with the field's edge.
108
+ head: o.head ? (_jsx(YStack, { ...measure, gap: "$1.5", "data-slot": "prompt-head", children: o.head })) : undefined,
109
+ ...measure,
110
+ ...(o.framed ? cut : null),
111
+ children: (_jsxs(XStack, { items: "center", gap: "$2", "data-slot": "prompt-start", children: [o.onAttach ? _jsx(Attach, { onAttach: o.onAttach, disabled: disabled }) : null, o.tools] })),
112
+ send: (_jsxs(XStack, { items: "center", gap: "$2", "data-slot": "prompt-end", 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, "data-slot": "composer-send", "aria-label": busy ? 'Stop' : 'Send', "aria-disabled": !live || undefined, opacity: live ? 1 : 0.4, onClick: () => {
113
+ if (!live)
114
+ return;
115
+ if (busy)
116
+ o.onStop?.();
117
+ else
118
+ o.onSend();
119
+ }, children: busy ? _jsx(Square, { size: 13, fill: "currentColor" }) : _jsx(ArrowUp, { size: 15, strokeWidth: 2.5 }) })] })),
120
+ };
121
+ }
122
+ /** The composer drawn on its own. */
123
+ export function Prompt(o) {
124
+ return _jsx(Composer, { ...prompt(o) });
125
+ }
package/lib/prose.js CHANGED
@@ -6,6 +6,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
6
6
  * only the part agents use. Nothing here produces HTML from the text: every
7
7
  * piece is a React element holding a string, and a link is drawn only for an
8
8
  * http(s) address, opened in a new tab.
9
+ *
10
+ * Set at Chat's sizes (@hanzo/rooms `Prose`): running text at $2 on a $3 line,
11
+ * headings a rung apart from $6, code in @hanzo/ui's `Code`, blocks $2 apart,
12
+ * so an answer reads the same in Chat and in a run.
9
13
  */
10
14
  import { ScrollView, SizableText, XStack, YStack } from '@hanzo/gui';
11
15
  import { Code } from '@hanzo/ui/chat';
@@ -16,38 +20,38 @@ function Inline({ text }) {
16
20
  return (_jsx(_Fragment, { children: spans(text).map((s, i) => {
17
21
  switch (s.kind) {
18
22
  case 'code':
19
- return (_jsx(SizableText, { size: "$2", color: "$ink", bg: "$hover", px: "$1", rounded: "$1", style: mono, children: s.text }, i));
23
+ return (_jsx(SizableText, { size: "$1", color: "$ink", bg: "$hover", px: "$1", rounded: "$1", style: mono, children: s.text }, i));
20
24
  case 'strong':
21
- return (_jsx(SizableText, { size: "$3", color: "$ink", fontWeight: "600", children: s.text }, i));
25
+ return (_jsx(SizableText, { size: "$2", lineHeight: "$3", color: "$ink", fontWeight: "600", children: s.text }, i));
22
26
  case 'em':
23
- return (_jsx(SizableText, { size: "$3", color: "$ink", fontStyle: "italic", children: s.text }, i));
27
+ return (_jsx(SizableText, { size: "$2", lineHeight: "$3", color: "$ink", fontStyle: "italic", children: s.text }, i));
24
28
  case 'link':
25
- return (_jsx(Out, { href: s.href, children: _jsx(SizableText, { size: "$3", color: "$ink", textDecorationLine: "underline", children: s.text }) }, i));
29
+ return (_jsx(Out, { href: s.href, children: _jsx(SizableText, { size: "$2", lineHeight: "$3", color: "$ink", textDecorationLine: "underline", children: s.text }) }, i));
26
30
  default:
27
31
  return s.text;
28
32
  }
29
33
  }) }));
30
34
  }
31
- const SIZES = ['$6', '$5', '$4', '$4', '$3', '$3'];
35
+ const SIZES = ['$6', '$4', '$3', '$2', '$2', '$2'];
32
36
  function Draw({ block }) {
33
37
  switch (block.kind) {
34
38
  case 'heading':
35
- return (_jsx(SizableText, { size: SIZES[block.level - 1], color: "$ink", fontWeight: "600", pt: "$1", children: _jsx(Inline, { text: block.text }) }));
39
+ return (_jsx(SizableText, { size: SIZES[block.level - 1], color: "$ink", fontWeight: "600", children: _jsx(Inline, { text: block.text }) }));
36
40
  case 'code':
37
41
  return (_jsx(Code, { language: block.lang || 'text', value: block.text, children: block.text }));
38
42
  case 'rule':
39
43
  return _jsx(YStack, { height: 1, bg: "$borderColor", my: "$1" });
40
44
  case 'quote':
41
- return (_jsx(YStack, { borderLeftWidth: 2, borderColor: "$borderColor", pl: "$3", children: _jsx(SizableText, { size: "$3", color: "$soft", children: _jsx(Inline, { text: block.text }) }) }));
45
+ return (_jsx(YStack, { borderLeftWidth: 2, borderColor: "$borderColor", pl: "$3", children: _jsx(SizableText, { size: "$2", lineHeight: "$3", color: "$soft", children: _jsx(Inline, { text: block.text }) }) }));
42
46
  case 'list':
43
- return (_jsx(YStack, { gap: "$1", role: "list", children: block.items.map((item, i) => (_jsxs(XStack, { gap: "$2", pl: item.depth * 16, role: "listitem", children: [_jsx(SizableText, { size: "$3", color: "$soft", minW: 16, style: { textAlign: 'right' }, children: block.ordered ? `${block.start + i}.` : '•' }), _jsx(SizableText, { flex: 1, minW: 0, size: "$3", color: "$ink", children: _jsx(Inline, { text: item.text }) })] }, i))) }));
47
+ return (_jsx(YStack, { gap: "$1", role: "list", children: block.items.map((item, i) => (_jsxs(XStack, { gap: "$1", pl: item.depth * 16, role: "listitem", children: [_jsx(SizableText, { size: "$2", lineHeight: "$3", color: "$soft", minW: 14, style: { textAlign: 'right' }, children: block.ordered ? `${block.start + i}.` : '•' }), _jsx(SizableText, { flex: 1, minW: 0, size: "$2", lineHeight: "$3", color: "$ink", children: _jsx(Inline, { text: item.text }) })] }, i))) }));
44
48
  case 'table':
45
49
  return (_jsx(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, children: _jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", minW: "100%", children: [block.head, ...block.rows].map((row, r) => (_jsx(XStack, { borderTopWidth: r ? 1 : 0, borderColor: "$borderColor", bg: r ? 'transparent' : '$hover', children: block.head.map((_, c) => (_jsx(SizableText, { flex: 1, minW: 96, px: "$2", py: "$1.5", size: "$2", color: "$ink", fontWeight: r ? '400' : '600', children: _jsx(Inline, { text: row[c] ?? '' }) }, c))) }, r))) }) }));
46
50
  default:
47
- return (_jsx(SizableText, { size: "$3", color: "$ink", children: _jsx(Inline, { text: block.text }) }));
51
+ return (_jsx(SizableText, { size: "$2", lineHeight: "$3", color: "$ink", children: _jsx(Inline, { text: block.text }) }));
48
52
  }
49
53
  }
50
54
  /** A markdown text, drawn. */
51
55
  export function Prose({ text }) {
52
- return (_jsx(YStack, { gap: "$2.5", minW: 0, children: blocks(text).map((b, i) => (_jsx(Draw, { block: b }, i))) }));
56
+ return (_jsx(YStack, { "data-slot": "prose", gap: "$2", minW: 0, children: blocks(text).map((b, i) => (_jsx(Draw, { block: b }, i))) }));
53
57
  }
package/lib/reply.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { Target } from './api/call.ts';
3
+ /** A fenced block an answer wrote out. */
4
+ export interface Artifact {
5
+ /** The name it was given, or what it is: `index.html`, `html block`. */
6
+ name: string;
7
+ body: string;
8
+ /** What a browser should take it as. */
9
+ mime: string;
10
+ }
11
+ /** Every fenced block in `text`, in order. */
12
+ export declare function artifacts(text: string): Artifact[];
13
+ /** Whether a browser draws it as a page or a picture rather than as source. */
14
+ export declare const renders: (a: Artifact) => boolean;
15
+ /** The artifact worth opening first: the last page or picture, else the last block. */
16
+ export declare function lead(list: readonly Artifact[]): Artifact | null;
17
+ /** An address for an artifact's own bytes, typed so a browser renders it. */
18
+ export declare const blob: (a: Artifact) => string;
19
+ /** Markdown as a sentence a voice can read: code said as code, marks dropped. */
20
+ export declare function speakable(raw: string): string;
21
+ /** Reads `text` aloud, calls `done` when it stops for any reason, and answers the way to stop it. */
22
+ export type Listen = (text: string, done: () => void) => () => void;
23
+ /** A reader on the platform's voice (`/v1/audio/speech`), one reply at a time on the page. */
24
+ export declare function speaker(t: Target): Listen;
25
+ /** Copy, Listen and Open under an answer; `children` follow them. */
26
+ export declare function Reply({ text, listen, onOpen, children }: {
27
+ text: string;
28
+ listen?: Listen;
29
+ onOpen?: () => void;
30
+ children?: ReactNode;
31
+ }): import("react").JSX.Element;
32
+ /** Which model answered, where the gateway says it was not the one asked for or a router. */
33
+ export declare function Served({ served, asked }: {
34
+ served: string;
35
+ asked?: string;
36
+ }): import("react").JSX.Element;