@hanzo/build 0.2.51 → 0.2.53

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 (98) hide show
  1. package/README.md +109 -55
  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 +42 -4
  9. package/lib/api/github.js +79 -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 -24
  19. package/lib/api/work.js +132 -27
  20. package/lib/builder.d.ts +0 -10
  21. package/lib/builder.js +21 -54
  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 +255 -92
  48. package/lib/host.d.ts +1 -1
  49. package/lib/index.d.ts +28 -2
  50. package/lib/index.js +26 -2
  51. package/lib/landing.d.ts +0 -10
  52. package/lib/landing.js +79 -47
  53. package/lib/merge.d.ts +223 -0
  54. package/lib/merge.js +380 -0
  55. package/lib/meter.d.ts +31 -0
  56. package/lib/meter.js +45 -0
  57. package/lib/mind.d.ts +60 -0
  58. package/lib/mind.js +187 -0
  59. package/lib/nav.d.ts +11 -0
  60. package/lib/nav.js +75 -0
  61. package/lib/panel.d.ts +45 -0
  62. package/lib/panel.js +115 -0
  63. package/lib/pick.d.ts +8 -0
  64. package/lib/pick.js +18 -1
  65. package/lib/plan.d.ts +78 -0
  66. package/lib/plan.js +133 -0
  67. package/lib/project.d.ts +16 -1
  68. package/lib/project.js +100 -32
  69. package/lib/prompt.d.ts +75 -0
  70. package/lib/prompt.js +125 -0
  71. package/lib/prose.js +14 -10
  72. package/lib/reply.d.ts +36 -0
  73. package/lib/reply.js +124 -0
  74. package/lib/route.d.ts +28 -4
  75. package/lib/route.js +48 -1
  76. package/lib/run.js +87 -52
  77. package/lib/section.d.ts +1 -1
  78. package/lib/section.js +7 -11
  79. package/lib/settings/billing.js +16 -9
  80. package/lib/settings/code.js +15 -12
  81. package/lib/settings/credits.d.ts +9 -0
  82. package/lib/settings/credits.js +110 -0
  83. package/lib/settings/plan.d.ts +17 -0
  84. package/lib/settings/plan.js +108 -0
  85. package/lib/settings/usage.js +11 -119
  86. package/lib/shelf.js +3 -3
  87. package/lib/standing.d.ts +44 -0
  88. package/lib/standing.js +64 -0
  89. package/lib/sync.js +23 -9
  90. package/lib/tabs.d.ts +53 -0
  91. package/lib/tabs.js +197 -0
  92. package/lib/tools.d.ts +2 -9
  93. package/lib/tools.js +20 -25
  94. package/lib/transcript.d.ts +10 -1
  95. package/lib/transcript.js +25 -10
  96. package/lib/voice.d.ts +2 -13
  97. package/lib/voice.js +7 -73
  98. package/package.json +20 -3
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;
package/lib/reply.js ADDED
@@ -0,0 +1,124 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * What sits under an answer, the same in Chat and Dev: copy what was written,
4
+ * listen to it, and open what it made in the side panel. A surface adds its own
5
+ * after these (Dev's verdict).
6
+ *
7
+ * An answer's artifacts are its fenced blocks (`artifacts`): a page, a picture,
8
+ * a file it wrote out whole. One opens as a page tab in the side panel, rendered
9
+ * from its own bytes (`blob`).
10
+ */
11
+ import { SizableText, XStack } from '@hanzo/gui';
12
+ import { Check, Copy, PanelRight, Square, Volume2 } from '@hanzogui/lucide-icons-2';
13
+ import { mouth, speech } from '@hanzo/voice';
14
+ import { useEffect, useRef, useState } from 'react';
15
+ const FENCE = /```([^\n`]*)\n([\s\S]*?)```/g;
16
+ const MIME = {
17
+ html: 'text/html',
18
+ htm: 'text/html',
19
+ svg: 'image/svg+xml',
20
+ css: 'text/css',
21
+ csv: 'text/csv',
22
+ json: 'application/json',
23
+ md: 'text/markdown',
24
+ markdown: 'text/markdown',
25
+ txt: 'text/plain',
26
+ };
27
+ const suffix = (s) => {
28
+ const dot = s.lastIndexOf('.');
29
+ return dot > 0 ? s.slice(dot + 1).toLowerCase() : '';
30
+ };
31
+ /** An info string is a file name (a dot, no spaces) or a language. */
32
+ const called = (info, n) => {
33
+ const word = info.trim().split(/\s+/)[0] ?? '';
34
+ if (!word)
35
+ return `Block ${n}`;
36
+ if (word.includes('.') && suffix(word))
37
+ return word;
38
+ return `${word} block`;
39
+ };
40
+ /** Every fenced block in `text`, in order. */
41
+ export function artifacts(text) {
42
+ const out = [];
43
+ let n = 0;
44
+ for (const [, info = '', body = ''] of text.matchAll(FENCE)) {
45
+ n += 1;
46
+ const name = called(info, n);
47
+ out.push({ name, body, mime: MIME[suffix(name) || info.trim().toLowerCase().split(/\s+/)[0] || ''] ?? 'text/plain' });
48
+ }
49
+ return out;
50
+ }
51
+ /** Whether a browser draws it as a page or a picture rather than as source. */
52
+ export const renders = (a) => a.mime === 'text/html' || a.mime === 'image/svg+xml';
53
+ /** The artifact worth opening first: the last page or picture, else the last block. */
54
+ export function lead(list) {
55
+ return [...list].reverse().find(renders) ?? list[list.length - 1] ?? null;
56
+ }
57
+ /** An address for an artifact's own bytes, typed so a browser renders it. */
58
+ export const blob = (a) => URL.createObjectURL(new Blob([a.body], { type: a.mime }));
59
+ /** Markdown as a sentence a voice can read: code said as code, marks dropped. */
60
+ export function speakable(raw) {
61
+ return raw
62
+ .replace(/```[\s\S]*?```/g, ' [code snippet] ')
63
+ .replace(/`([^`]+)`/g, '$1')
64
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
65
+ .replace(/^#{1,6}\s+/gm, '')
66
+ .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
67
+ .replace(/^>\s+/gm, '')
68
+ .replace(/^[-*_]{3,}\s*$/gm, '')
69
+ .replace(/^[\s]*[-+*]\s+/gm, '')
70
+ .replace(/\n+/g, '. ')
71
+ .replace(/\s+/g, ' ')
72
+ .trim();
73
+ }
74
+ let hush = null;
75
+ /** A reader on the platform's voice (`/v1/audio/speech`), one reply at a time on the page. */
76
+ export function speaker(t) {
77
+ return (text, done) => {
78
+ hush?.();
79
+ const said = speakable(text);
80
+ if (!said) {
81
+ done();
82
+ return () => { };
83
+ }
84
+ const lips = mouth({ speech: speech({ baseUrl: t.api, token: t.token }) });
85
+ const stop = () => {
86
+ lips.hush();
87
+ if (hush === stop)
88
+ hush = null;
89
+ };
90
+ hush = stop;
91
+ lips.say(said).then(done, done);
92
+ return stop;
93
+ };
94
+ }
95
+ /** One quiet control in the row. It rests dim and comes up under a pointer or focus. */
96
+ function Act({ label, onPress, children, lit = false }) {
97
+ return (_jsx(XStack, { render: "button", role: "button", "aria-label": label, onPress: onPress, items: "center", gap: "$1", px: "$1", height: 24, rounded: "$2", cursor: "pointer", opacity: lit ? 1 : 0.55, hoverStyle: { opacity: 1, bg: '$hover' }, focusStyle: { opacity: 1 }, children: children }));
98
+ }
99
+ /** Copy, Listen and Open under an answer; `children` follow them. */
100
+ export function Reply({ text, listen, onOpen, children }) {
101
+ const [took, setTook] = useState(false);
102
+ const [speaking, setSpeaking] = useState(false);
103
+ const stop = useRef(null);
104
+ useEffect(() => {
105
+ if (!took)
106
+ return;
107
+ const t = setTimeout(() => setTook(false), 1400);
108
+ return () => clearTimeout(t);
109
+ }, [took]);
110
+ useEffect(() => () => stop.current?.(), []);
111
+ return (_jsxs(XStack, { "data-slot": "reply", ml: "$-1", items: "center", gap: "$1.5", flexWrap: "wrap", children: [_jsx(Act, { label: took ? 'Copied' : 'Copy this answer', lit: took, onPress: () => void navigator.clipboard?.writeText(text).then(() => setTook(true), () => { }), children: took ? _jsx(Check, { size: 13 }) : _jsx(Copy, { size: 13 }) }), listen ? (_jsxs(Act, { label: speaking ? 'Stop reading' : 'Listen to this answer', lit: speaking, onPress: () => {
112
+ if (speaking) {
113
+ stop.current?.();
114
+ setSpeaking(false);
115
+ return;
116
+ }
117
+ setSpeaking(true);
118
+ stop.current = listen(text, () => setSpeaking(false));
119
+ }, children: [speaking ? _jsx(Square, { size: 12 }) : _jsx(Volume2, { size: 13 }), _jsx(SizableText, { size: "$1", color: "$ink", children: speaking ? 'Stop' : 'Listen' })] })) : null, onOpen ? (_jsxs(Act, { label: "Open in the side panel", onPress: onOpen, children: [_jsx(PanelRight, { size: 13 }), _jsx(SizableText, { size: "$1", color: "$ink", children: "Open" })] })) : null, children] }));
120
+ }
121
+ /** Which model answered, where the gateway says it was not the one asked for or a router. */
122
+ export function Served({ served, asked }) {
123
+ return (_jsx(SizableText, { size: "$1", color: "$soft", "data-slot": "served", children: `answered by ${served}${asked && asked !== served ? ` · ${asked} was unavailable` : ''}` }));
124
+ }
package/lib/route.d.ts CHANGED
@@ -8,13 +8,25 @@
8
8
  * -/settings[/<section>] a section of Settings
9
9
  * -/customize[/<tab>] skills, connectors and plugins: what the agent brings to a run
10
10
  * -/plans the plans an organization can be on
11
- * <slug> a project's workspace
11
+ * <org>/<repo> a repository's workspace: a project is a repository on the forge
12
+ * <slug> a deployed site's workspace (Artifacts)
12
13
  *
13
14
  * Unambiguous by construction: a project slug is `^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`,
14
- * so it can hold neither the `_` a session id carries nor start with `-`.
15
- * Anything else is not an address here and reads as the empty state.
15
+ * so it can hold neither the `_` a session id carries nor start with `-`, and a
16
+ * repository is the one address with two segments, each starting with a letter
17
+ * or a digit. Anything else is not an address here and reads as the empty state.
18
+ *
19
+ * A host whose address bar shows real paths writes a route with `href` and
20
+ * reads one back with `parse` (hanzo.ai/dev/<href>):
21
+ *
22
+ * '' New
23
+ * /run/<id> one run
24
+ * /projects/<org>/<repo> a repository's workspace
25
+ * /projects/<slug> a deployed site's workspace
26
+ * /machines · /environments the rail's own words for -/settings/machines and -/settings/environments
27
+ * /<rest> every other address, its `-/` dropped: /projects, /customize/connectors, /settings/usage
16
28
  */
17
- export type Screen = 'artifacts' | 'templates' | 'codebases' | 'projects' | 'issues' | 'automations' | 'sync' | 'mcp' | 'plans';
29
+ export type Screen = 'artifacts' | 'templates' | 'projects' | 'issues' | 'automations' | 'sync' | 'mcp' | 'plans';
18
30
  /** Settings, one section each. Every setting the builder has lives on this page, none elsewhere. */
19
31
  export declare const SECTIONS: readonly ['general', 'account', 'privacy', 'billing', 'usage', 'capabilities', 'memory', 'code', 'environments', 'machines', 'keys', 'members', 'integrations', 'notifications'];
20
32
  export type Section = (typeof SECTIONS)[number];
@@ -38,14 +50,26 @@ export type Route = {
38
50
  } | {
39
51
  kind: 'project';
40
52
  slug: string;
53
+ } | {
54
+ kind: 'repo';
55
+ org: string;
56
+ name: string;
41
57
  } | {
42
58
  kind: 'automation';
43
59
  id: string;
44
60
  };
45
61
  export declare const SESSION: RegExp;
46
62
  export declare const SLUG: RegExp;
63
+ /** An organization or a repository name on the forge (api/codebases.ts `NAME`). */
64
+ export declare const NAME: RegExp;
47
65
  /** An automation's id, or `new` for one not yet written. */
48
66
  export declare const AUTOMATION: RegExp;
49
67
  export declare function route(path: string): Route;
68
+ /** Every route that names no record, for a host that writes a page per address ahead of time. */
69
+ export declare const PLACES: readonly Route[];
70
+ /** A route as a real path under the host's mount: '' for New, `/run/<id>`, `/projects/<org>/<repo>`, `/projects/<slug>`, else its path without `-/`. */
71
+ export declare function href(r: Route): string;
72
+ /** The route a real path under the mount names — the inverse of `href`. A query or fragment is not part of it. */
73
+ export declare function parse(address: string): Route;
50
74
  /** The path for a route — the inverse of `route`. */
51
75
  export declare function path(r: Route): string;
package/lib/route.js CHANGED
@@ -19,9 +19,11 @@ export const SECTIONS = [
19
19
  export const TABS = ['skills', 'connectors', 'plugins', 'agents'];
20
20
  export const SESSION = /^sess_[0-9a-f]{32}$/;
21
21
  export const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/;
22
+ /** An organization or a repository name on the forge (api/codebases.ts `NAME`). */
23
+ export const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
22
24
  /** An automation's id, or `new` for one not yet written. */
23
25
  export const AUTOMATION = /^(flow_[0-9a-f]{32}|new)$/;
24
- const SCREENS = ['artifacts', 'templates', 'codebases', 'projects', 'issues', 'automations', 'sync', 'mcp', 'plans'];
26
+ const SCREENS = ['artifacts', 'templates', 'projects', 'issues', 'automations', 'sync', 'mcp', 'plans'];
25
27
  export function route(path) {
26
28
  const p = path.replace(/^\/+|\/+$/g, '');
27
29
  if (SESSION.test(p))
@@ -44,8 +46,51 @@ export function route(path) {
44
46
  }
45
47
  if (SLUG.test(p))
46
48
  return { kind: 'project', slug: p };
49
+ const [org, name, ...rest] = p.split('/');
50
+ if (!rest.length && org && name && NAME.test(org) && NAME.test(name))
51
+ return { kind: 'repo', org, name };
47
52
  return { kind: 'new' };
48
53
  }
54
+ /** Every route that names no record, for a host that writes a page per address ahead of time. */
55
+ export const PLACES = [
56
+ { kind: 'new' },
57
+ { kind: 'automation', id: 'new' },
58
+ ...SCREENS.map((screen) => ({ kind: 'screen', screen })),
59
+ ...SECTIONS.map((section) => ({ kind: 'settings', section })),
60
+ ...TABS.map((tab) => ({ kind: 'customize', tab })),
61
+ ];
62
+ /** The places a real path names in the rail's words rather than the builder's: each rail row is a path of its own. */
63
+ const WORDS = new Map([
64
+ ['-/settings/machines', 'machines'],
65
+ ['-/settings/environments', 'environments'],
66
+ ]);
67
+ const NAMES = new Map([...WORDS].map(([p, word]) => [word, p]));
68
+ /** A route as a real path under the host's mount: '' for New, `/run/<id>`, `/projects/<org>/<repo>`, `/projects/<slug>`, else its path without `-/`. */
69
+ export function href(r) {
70
+ if (r.kind === 'run')
71
+ return `/run/${r.id}`;
72
+ if (r.kind === 'repo')
73
+ return `/projects/${r.org}/${r.name}`;
74
+ if (r.kind === 'project')
75
+ return `/projects/${r.slug}`;
76
+ const p = path(r);
77
+ return p && `/${WORDS.get(p) ?? p.slice(2)}`;
78
+ }
79
+ /** The route a real path under the mount names — the inverse of `href`. A query or fragment is not part of it. */
80
+ export function parse(address) {
81
+ const p = address.split(/[?#]/)[0].replace(/^\/+|\/+$/g, '');
82
+ const [head, one, two, ...rest] = p.split('/');
83
+ if (head === 'run')
84
+ return one && !two && SESSION.test(one) ? { kind: 'run', id: one } : { kind: 'new' };
85
+ if (head === 'projects' && one) {
86
+ if (rest.length)
87
+ return { kind: 'new' };
88
+ if (two)
89
+ return NAME.test(one) && NAME.test(two) ? { kind: 'repo', org: one, name: two } : { kind: 'new' };
90
+ return SLUG.test(one) ? { kind: 'project', slug: one } : { kind: 'new' };
91
+ }
92
+ return p ? route(NAMES.get(p) ?? `-/${p}`) : { kind: 'new' };
93
+ }
49
94
  /** The path for a route — the inverse of `route`. */
50
95
  export function path(r) {
51
96
  switch (r.kind) {
@@ -61,6 +106,8 @@ export function path(r) {
61
106
  return r.tab === 'skills' ? '-/customize' : `-/customize/${r.tab}`;
62
107
  case 'project':
63
108
  return r.slug;
109
+ case 'repo':
110
+ return `${r.org}/${r.name}`;
64
111
  case 'automation':
65
112
  return `-/automations/${r.id}`;
66
113
  }