herm-tui 1.0.0-dev.1 → 1.0.0-dev.11

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 (192) hide show
  1. package/README.md +8 -4
  2. package/assets/eikons/ares.eikon +367 -0
  3. package/assets/eikons/default.eikon +398 -0
  4. package/assets/eikons/mono.eikon +395 -0
  5. package/db.worker.js +81 -0
  6. package/highlights-eq9cgrbb.scm +604 -0
  7. package/highlights-ghv9g403.scm +205 -0
  8. package/highlights-hk7bwhj4.scm +284 -0
  9. package/highlights-r812a2qc.scm +150 -0
  10. package/highlights-x6tmsnaa.scm +115 -0
  11. package/index.js +4151 -0
  12. package/injections-73j83es3.scm +27 -0
  13. package/package.json +14 -64
  14. package/parser.worker.js +8 -0
  15. package/tree-sitter-3jzf13jk.wasm +0 -0
  16. package/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  17. package/tree-sitter-markdown-411r6y9b.wasm +0 -0
  18. package/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  19. package/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  20. package/tree-sitter-zig-e78zbjpm.wasm +0 -0
  21. package/scripts/postinstall.ts +0 -29
  22. package/src/app/gateway.tsx +0 -83
  23. package/src/app/gatewayEvents.ts +0 -203
  24. package/src/app/launch.ts +0 -41
  25. package/src/app/skin.tsx +0 -31
  26. package/src/app/spawnHistory.ts +0 -75
  27. package/src/app/tabs.ts +0 -23
  28. package/src/app/turnReducer.ts +0 -390
  29. package/src/app/useAppKeys.ts +0 -268
  30. package/src/app/useAtRefPopover.ts +0 -99
  31. package/src/app/useInputHistory.ts +0 -66
  32. package/src/app/useSession.ts +0 -102
  33. package/src/app/useSlashCommands.ts +0 -70
  34. package/src/app/useSlashPopover.ts +0 -48
  35. package/src/app.tsx +0 -917
  36. package/src/commands/slash.ts +0 -151
  37. package/src/components/avatar/AnimatedAvatar.tsx +0 -66
  38. package/src/components/avatar/eikon.ts +0 -144
  39. package/src/components/avatar/states/error.ts +0 -1155
  40. package/src/components/avatar/states/idle.ts +0 -1155
  41. package/src/components/avatar/states/index.ts +0 -30
  42. package/src/components/avatar/states/listening.ts +0 -1155
  43. package/src/components/avatar/states/speaking.ts +0 -1155
  44. package/src/components/avatar/states/thinking.ts +0 -1155
  45. package/src/components/avatar/states/working.ts +0 -1155
  46. package/src/components/chat/AtRefPopover.tsx +0 -54
  47. package/src/components/chat/CodeBlock.tsx +0 -67
  48. package/src/components/chat/Composer.tsx +0 -347
  49. package/src/components/chat/DiffBlock.tsx +0 -116
  50. package/src/components/chat/ErrorBlock.tsx +0 -70
  51. package/src/components/chat/MediaChip.tsx +0 -114
  52. package/src/components/chat/MessageItem.tsx +0 -282
  53. package/src/components/chat/MessageList.tsx +0 -114
  54. package/src/components/chat/PromptCard.tsx +0 -359
  55. package/src/components/chat/SlashPopover.tsx +0 -158
  56. package/src/components/chat/ThoughtCloud.tsx +0 -185
  57. package/src/components/chat/TypingIndicator.tsx +0 -25
  58. package/src/components/chat/tool/Subagent.tsx +0 -75
  59. package/src/components/chat/tool/frame.tsx +0 -69
  60. package/src/components/chat/tool/index.tsx +0 -65
  61. package/src/components/chat/tool/preview.ts +0 -57
  62. package/src/components/sidebar/ContextGauge.tsx +0 -102
  63. package/src/components/sidebar/Sidebar.tsx +0 -143
  64. package/src/components/tabs/TabBar.tsx +0 -50
  65. package/src/components/ui/FileLink.tsx +0 -52
  66. package/src/config/index.ts +0 -156
  67. package/src/config/lane.ts +0 -161
  68. package/src/config/models.ts +0 -95
  69. package/src/config/rules.ts +0 -80
  70. package/src/config/schema.ts +0 -308
  71. package/src/dialogs/alert.tsx +0 -52
  72. package/src/dialogs/chafa.tsx +0 -72
  73. package/src/dialogs/confirm.tsx +0 -58
  74. package/src/dialogs/curator.tsx +0 -153
  75. package/src/dialogs/eikon-picker.tsx +0 -95
  76. package/src/dialogs/help.tsx +0 -80
  77. package/src/dialogs/history.tsx +0 -92
  78. package/src/dialogs/info.tsx +0 -115
  79. package/src/dialogs/keys.tsx +0 -170
  80. package/src/dialogs/logs.tsx +0 -42
  81. package/src/dialogs/message.tsx +0 -38
  82. package/src/dialogs/model-picker.tsx +0 -123
  83. package/src/dialogs/new-profile.tsx +0 -69
  84. package/src/dialogs/new-task.tsx +0 -103
  85. package/src/dialogs/profile.tsx +0 -55
  86. package/src/dialogs/rollback.tsx +0 -190
  87. package/src/dialogs/spawn-history.tsx +0 -80
  88. package/src/dialogs/text-prompt.tsx +0 -68
  89. package/src/dialogs/theme-picker.tsx +0 -50
  90. package/src/home/index.ts +0 -23
  91. package/src/home/store.ts +0 -267
  92. package/src/index.tsx +0 -113
  93. package/src/keys/catalog.ts +0 -115
  94. package/src/keys/chord.ts +0 -125
  95. package/src/keys/conflicts.ts +0 -48
  96. package/src/keys/context.tsx +0 -112
  97. package/src/keys/index.ts +0 -5
  98. package/src/keys/list.ts +0 -94
  99. package/src/keys/oc-compat.ts +0 -87
  100. package/src/tabs/Agents.tsx +0 -607
  101. package/src/tabs/Analytics.tsx +0 -154
  102. package/src/tabs/Chat.tsx +0 -50
  103. package/src/tabs/Config.tsx +0 -605
  104. package/src/tabs/Context.tsx +0 -599
  105. package/src/tabs/Cron.tsx +0 -294
  106. package/src/tabs/Env.tsx +0 -227
  107. package/src/tabs/Kanban.tsx +0 -367
  108. package/src/tabs/Memory.tsx +0 -294
  109. package/src/tabs/Sessions.tsx +0 -786
  110. package/src/tabs/Skills.tsx +0 -507
  111. package/src/tabs/Toolsets.tsx +0 -266
  112. package/src/theme/builtin.ts +0 -78
  113. package/src/theme/context.tsx +0 -106
  114. package/src/theme/index.ts +0 -4
  115. package/src/theme/resolve.ts +0 -134
  116. package/src/theme/syntax.ts +0 -31
  117. package/src/theme/themes/aura.json +0 -69
  118. package/src/theme/themes/ayu.json +0 -80
  119. package/src/theme/themes/carbonfox.json +0 -248
  120. package/src/theme/themes/catppuccin-frappe.json +0 -233
  121. package/src/theme/themes/catppuccin-macchiato.json +0 -233
  122. package/src/theme/themes/catppuccin.json +0 -112
  123. package/src/theme/themes/cobalt2.json +0 -228
  124. package/src/theme/themes/cursor.json +0 -249
  125. package/src/theme/themes/dracula.json +0 -219
  126. package/src/theme/themes/everforest.json +0 -241
  127. package/src/theme/themes/flexoki.json +0 -237
  128. package/src/theme/themes/github.json +0 -233
  129. package/src/theme/themes/gruvbox.json +0 -242
  130. package/src/theme/themes/kanagawa.json +0 -77
  131. package/src/theme/themes/lucent-orng.json +0 -237
  132. package/src/theme/themes/material.json +0 -235
  133. package/src/theme/themes/matrix.json +0 -77
  134. package/src/theme/themes/mercury.json +0 -252
  135. package/src/theme/themes/monokai.json +0 -221
  136. package/src/theme/themes/nightowl.json +0 -221
  137. package/src/theme/themes/nord.json +0 -223
  138. package/src/theme/themes/one-dark.json +0 -84
  139. package/src/theme/themes/opencode.json +0 -245
  140. package/src/theme/themes/orng.json +0 -249
  141. package/src/theme/themes/osaka-jade.json +0 -93
  142. package/src/theme/themes/palenight.json +0 -222
  143. package/src/theme/themes/rosepine.json +0 -234
  144. package/src/theme/themes/solarized.json +0 -223
  145. package/src/theme/themes/synthwave84.json +0 -226
  146. package/src/theme/themes/tokyonight.json +0 -243
  147. package/src/theme/themes/vercel.json +0 -245
  148. package/src/theme/themes/vesper.json +0 -218
  149. package/src/theme/themes/zenburn.json +0 -223
  150. package/src/theme/types.ts +0 -119
  151. package/src/types/message.ts +0 -97
  152. package/src/ui/ChafaImage.tsx +0 -64
  153. package/src/ui/Splash.tsx +0 -118
  154. package/src/ui/borders.ts +0 -28
  155. package/src/ui/command.tsx +0 -104
  156. package/src/ui/dialog-select.tsx +0 -164
  157. package/src/ui/dialog.tsx +0 -102
  158. package/src/ui/fmt.ts +0 -82
  159. package/src/ui/kv.tsx +0 -28
  160. package/src/ui/shell.tsx +0 -45
  161. package/src/ui/spinner.tsx +0 -59
  162. package/src/ui/splash-art.ts +0 -123
  163. package/src/ui/table.tsx +0 -117
  164. package/src/ui/ticker.tsx +0 -90
  165. package/src/ui/toast.tsx +0 -130
  166. package/src/utils/categorical.ts +0 -77
  167. package/src/utils/chafa.ts +0 -173
  168. package/src/utils/clipboard.ts +0 -67
  169. package/src/utils/context-segments.ts +0 -317
  170. package/src/utils/control.ts +0 -495
  171. package/src/utils/drop.ts +0 -25
  172. package/src/utils/editor.ts +0 -33
  173. package/src/utils/fuzzy.ts +0 -45
  174. package/src/utils/gateway-client.ts +0 -253
  175. package/src/utils/gateway-types.ts +0 -282
  176. package/src/utils/git.ts +0 -57
  177. package/src/utils/hermes-analytics.ts +0 -134
  178. package/src/utils/hermes-home.ts +0 -821
  179. package/src/utils/hermes-kanban.ts +0 -154
  180. package/src/utils/hermes-profiles.ts +0 -217
  181. package/src/utils/interpolate.ts +0 -31
  182. package/src/utils/math-unicode.ts +0 -818
  183. package/src/utils/memory-activity.ts +0 -140
  184. package/src/utils/open-file.ts +0 -13
  185. package/src/utils/paths.ts +0 -52
  186. package/src/utils/perf.ts +0 -235
  187. package/src/utils/preferences.ts +0 -150
  188. package/src/utils/sessions-db.ts +0 -396
  189. package/src/utils/subagent-tree.ts +0 -146
  190. package/src/utils/terminal-reset.ts +0 -129
  191. package/src/utils/tips.ts +0 -67
  192. package/src/utils/tokens.ts +0 -87
@@ -1,605 +0,0 @@
1
- import { useState, useEffect, useCallback, memo, type ReactNode } from "react";
2
- import { useKeyboard } from "@opentui/react";
3
- import { useKeys, handleListKey, useFollow } from "../keys";
4
- import { useGateway } from "../app/gateway";
5
- import { useTheme } from "../theme";
6
- import { useToast } from "../ui/toast";
7
- import { useDialog } from "../ui/dialog";
8
- import { openConfirm } from "../dialogs/confirm";
9
- import { TabShell } from "../ui/shell";
10
- import { Col, Hdr, VBAR } from "../ui/table";
11
- import { stringify as yamlStringify, parse as yamlParse } from "yaml";
12
- import { writeConfig, verifyWrite, maxEffect } from "../config/lane";
13
- import { check as checkRule } from "../config/rules";
14
- import { buildFields, groupOf, sections, GROUPS, EFFECT_GLYPH, type Field, type Section } from "../config";
15
- import { readSlots, assign, resetAux, AUX_TASKS, type Slot } from "../config/models";
16
- import { openModelPicker } from "../dialogs/model-picker";
17
- import { managedSystem, makeSource } from "../utils/hermes-home";
18
- import { FileLink } from "../components/ui/FileLink";
19
-
20
- // ─── Helpers ─────────────────────────────────────────────────────────
21
-
22
- const flatten = (obj: Record<string, unknown>, prefix = ""): [string, unknown][] =>
23
- Object.entries(obj).flatMap(([k, v]) => {
24
- const key = prefix ? `${prefix}.${k}` : k;
25
- if (v && typeof v === "object" && !Array.isArray(v))
26
- return flatten(v as Record<string, unknown>, key);
27
- return [[key, v]];
28
- });
29
-
30
- const setNested = (obj: Record<string, unknown>, path: string, val: unknown) => {
31
- const parts = path.split(".");
32
- let cur: Record<string, unknown> = obj;
33
- for (let i = 0; i < parts.length - 1; i++) {
34
- if (!cur[parts[i]] || typeof cur[parts[i]] !== "object")
35
- cur[parts[i]] = {};
36
- cur = cur[parts[i]] as Record<string, unknown>;
37
- }
38
- cur[parts[parts.length - 1]] = val;
39
- };
40
-
41
- const getNested = (obj: Record<string, unknown>, path: string): unknown => {
42
- const parts = path.split(".");
43
- let cur: unknown = obj;
44
- for (const p of parts) {
45
- if (cur && typeof cur === "object" && !Array.isArray(cur))
46
- cur = (cur as Record<string, unknown>)[p];
47
- else return undefined;
48
- }
49
- return cur;
50
- };
51
-
52
- // ─── Field Row ───────────────────────────────────────────────────────
53
-
54
- const FieldRow = memo((props: {
55
- id: string;
56
- field: Field;
57
- active: boolean;
58
- changed: boolean;
59
- editing: boolean;
60
- buf: string;
61
- readonly?: boolean;
62
- error?: string;
63
- /** Search mode: resolved category shown as a pill so hits stay attributable. */
64
- badge?: string;
65
- }) => {
66
- const theme = useTheme().theme;
67
- const f = props.field;
68
- const bg = props.active ? theme.backgroundElement : undefined;
69
- const indicator = props.active ? "▸ " : " ";
70
- const mark = props.changed ? "● " : f.set ? "·" : " ";
71
- const markFg = props.changed ? theme.warning : theme.textMuted;
72
-
73
- const display = (): string => {
74
- if (props.editing) return props.buf + "█";
75
- if (f.type === "readonly") {
76
- const n = Array.isArray(f.value) ? f.value.length
77
- : f.value && typeof f.value === "object" ? Object.keys(f.value).length
78
- : 0;
79
- return n === 0 ? "—" : `${n} item${n === 1 ? "" : "s"}`;
80
- }
81
- if (f.type === "boolean") return f.value ? "✓ ON" : "✗ OFF";
82
- return String(f.value ?? "");
83
- };
84
-
85
- const hint = (): string => {
86
- if (props.readonly || f.type === "readonly") return "🔒";
87
- if (f.type === "boolean") return "[space]";
88
- if (f.type === "select") return "[h/l]";
89
- return "[enter]";
90
- };
91
-
92
- const ro = props.readonly || f.type === "readonly";
93
- const valFg = ro || !f.set ? theme.textMuted
94
- : f.type === "boolean" ? (f.value ? theme.success : theme.error)
95
- : theme.text;
96
- const labelFg = ro ? theme.textMuted : props.active ? theme.accent : theme.text;
97
-
98
- const lead = 4 + (props.badge !== undefined ? 12 : 0);
99
- const glyph = props.active ? EFFECT_GLYPH[f.effect] : "";
100
-
101
- return (
102
- <box id={props.id} flexDirection="column" backgroundColor={bg}>
103
- <box flexDirection="row" height={1}>
104
- <Col w={2} fg={markFg}>{mark}</Col>
105
- <Col w={2} fg={props.active ? theme.primary : theme.text}>{indicator}</Col>
106
- {props.badge !== undefined
107
- ? <Col w={12} fg={theme.textMuted}>{props.badge}</Col>
108
- : null}
109
- <Col w={40} fg={labelFg}>{f.label}</Col>
110
- <Col grow min={6} fg={valFg}>{display()}</Col>
111
- <Col w={2} fg={theme.textMuted}>{glyph}</Col>
112
- <Col w={9} fg={theme.textMuted} right>{props.active ? hint() : ""}</Col>
113
- </box>
114
- {props.error ? (
115
- <box flexDirection="row" height={1}>
116
- <Col w={lead + 40} fg={theme.textMuted}>{""}</Col>
117
- <Col grow min={6} fg={theme.error}>{`✗ ${props.error}`}</Col>
118
- </box>
119
- ) : props.active && f.doc ? (
120
- <box flexDirection="row" minHeight={1}>
121
- <box width={lead} flexShrink={0} />
122
- <box width={40} flexShrink={0} minHeight={1}>
123
- <text wrapMode="word" fg={theme.textMuted}>{f.doc}</text>
124
- </box>
125
- </box>
126
- ) : null}
127
- </box>
128
- );
129
- });
130
-
131
- // ─── Model slots (synthetic `models` category) ───────────────────────
132
- // Main + 9 aux-task slots from config.yaml. Enter opens the same
133
- // provider→model picker as Ctrl+M but routed through assign(); `x`
134
- // resets an aux slot to auto. Writes apply immediately (not queued
135
- // through the FieldRow dirty/save flow) because main is an rpc-lane
136
- // hot-swap and aux is a 2-key atomic pair — both mirror webui
137
- // ModelSettingsPanel which also commits on pick.
138
-
139
- const SlotRow = memo((p: { id: string; s: Slot; on: boolean }) => {
140
- const theme = useTheme().theme;
141
- const main = p.s.kind === "main";
142
- const val = main
143
- ? `${p.s.provider || "(unset)"} · ${p.s.model || "(unset)"}`
144
- : p.s.auto
145
- ? "auto (use main model)"
146
- : `${p.s.provider} · ${p.s.model || "(provider default)"}`;
147
- return (
148
- <box id={p.id} flexDirection="row" height={1}
149
- backgroundColor={p.on ? theme.backgroundElement : undefined}>
150
- <Col w={2} fg={p.on ? theme.primary : theme.text}>{p.on ? "▸ " : " "}</Col>
151
- <Col w={2} fg={main ? theme.primary : theme.textMuted}>{main ? "★" : " "}</Col>
152
- <Col w={16} fg={p.on ? theme.accent : theme.text}>{p.s.label}</Col>
153
- <Col w={22} fg={theme.textMuted}>{p.s.hint}</Col>
154
- <Col grow min={10} fg={p.s.auto ? theme.textMuted : theme.text}>{val}</Col>
155
- <Col w={14} fg={theme.textMuted} right>
156
- {p.on ? (main ? "[enter]" : "[enter] [x]") : ""}
157
- </Col>
158
- </box>
159
- );
160
- });
161
-
162
- // ─── Main Component ──────────────────────────────────────────────────
163
-
164
- export const Config = memo((props: { focused?: boolean }) => {
165
- const theme = useTheme().theme;
166
- const gw = useGateway();
167
- const toast = useToast();
168
- const dialog = useDialog();
169
- const [raw, setRaw] = useState<Record<string, unknown>>({});
170
- const [original, setOriginal] = useState<Record<string, unknown>>({});
171
- const [yaml, setYaml] = useState("");
172
- const [mode, setMode] = useState<"form" | "yaml">("form");
173
- const [cat, setCat] = useState(0);
174
- const [cursor, setCursor] = useState(0);
175
- const [editing, setEditing] = useState(false);
176
- const [buf, setBuf] = useState("");
177
- const [err, setErr] = useState<Record<string, string>>({});
178
- const [searching, setSearching] = useState(false);
179
- const [query, setQuery] = useState("");
180
- const [focus, setFocus] = useState<"categories" | "fields">("categories");
181
- const [managed, setManaged] = useState<string | null>(null);
182
-
183
- useEffect(() => { managedSystem().then(setManaged) }, []);
184
-
185
- const load = useCallback(() => {
186
- gw.request<{ config?: Record<string, unknown> }>("config.get", { key: "full" })
187
- .then(res => {
188
- const parsed = res.config ?? {};
189
- setRaw(structuredClone(parsed));
190
- setOriginal(structuredClone(parsed));
191
- setYaml(yamlStringify(parsed));
192
- setErr({});
193
- })
194
- .catch(() => {
195
- setRaw({});
196
- setOriginal({});
197
- setYaml("");
198
- });
199
- }, [gw]);
200
-
201
- useEffect(() => { load(); }, [load]);
202
-
203
- const all = buildFields(raw);
204
- const grouped = all.reduce((map, f) => {
205
- const g = groupOf(f.key);
206
- if (!map.has(g)) map.set(g, []);
207
- map.get(g)!.push(f);
208
- return map;
209
- }, new Map<string, Field[]>(GROUPS.map(g => [g, []])));
210
- // `models` is a synthetic category — it renders SlotRows over the
211
- // same `raw` object, not FieldRows over schema keys. Pinned after
212
- // `general` so it's where a user scanning for "where do I set the
213
- // model" lands, instead of the 56-field `auxiliary` group.
214
- const groups = [...grouped.keys()];
215
- groups.splice(1, 0, "models");
216
-
217
- const active = groups[cat] ?? groups[0];
218
- const onSlots = active === "models" && !searching;
219
- const slots = readSlots(raw);
220
- const secs: Section[] = searching && query.trim()
221
- ? [{ head: null, items: all.filter(f => f.key.toLowerCase().includes(query.toLowerCase())) }]
222
- : sections(active, grouped.get(active) ?? []);
223
- const fields = secs.flatMap(s => s.items);
224
-
225
- const count = onSlots ? slots.length : fields.length;
226
- const follow = useFollow("cfg");
227
- const catFollow = useFollow("cfg-cat");
228
-
229
- const changed = (key: string): boolean =>
230
- JSON.stringify(getNested(raw, key)) !== JSON.stringify(getNested(original, key));
231
-
232
- const nChanged = all.reduce((n, f) => n + (changed(f.key) ? 1 : 0), 0);
233
-
234
- const update = (key: string, val: unknown) => {
235
- const next = structuredClone(raw);
236
- setNested(next, key, val);
237
- setRaw(next);
238
- setYaml(yamlStringify(next));
239
- };
240
-
241
- const fmt = (v: unknown) =>
242
- v === undefined ? "—" : Array.isArray(v) ? v.join(", ") : String(v);
243
-
244
- const save = async () => {
245
- if (managed) {
246
- toast.show({ variant: "error", message: `Managed by ${managed} — edit configuration.nix` });
247
- return;
248
- }
249
- const nErr = Object.keys(err).length;
250
- if (nErr > 0) {
251
- toast.show({ variant: "error", message: `${nErr} invalid field${nErr === 1 ? "" : "s"}` });
252
- return;
253
- }
254
- const target = mode === "yaml" ? (yamlParse(yaml) ?? {}) : raw;
255
- const flat = flatten(target as Record<string, unknown>);
256
- const diffs = flat
257
- .filter(([key]) => JSON.stringify(getNested(target as Record<string, unknown>, key)) !== JSON.stringify(getNested(original, key)))
258
- .map(([key, val]) => ({ key, from: getNested(original, key), to: val }));
259
- if (diffs.length === 0) {
260
- toast.show({ variant: "info", message: "No changes" });
261
- return;
262
- }
263
- const body = diffs.map(d => `${d.key}: ${fmt(d.from)} → ${fmt(d.to)}`).join("\n");
264
- const ok = await openConfirm(dialog, {
265
- title: `Write ${diffs.length} change${diffs.length === 1 ? "" : "s"} to config.yaml?`,
266
- body, yes: "save",
267
- });
268
- if (!ok) return;
269
- const res = await writeConfig(gw, diffs.map(d => ({ key: d.key, to: d.to })));
270
- for (const w of res.warnings) toast.show({ variant: "info", message: `${w.key}: ${w.msg}` });
271
- load();
272
- if (res.failed.length > 0) {
273
- toast.show({
274
- variant: "error",
275
- message: `${res.failed.length} failed: ${res.failed.map(f => f.key).join(", ")}`,
276
- });
277
- return;
278
- }
279
- const landed = diffs.filter(d => res.ok.includes(d.key));
280
- const miss = await verifyWrite(gw, landed.map(d => ({ key: d.key, to: d.to })));
281
- if (miss.length > 0) {
282
- toast.show({ variant: "error", message: `Write didn't land: ${miss.join(", ")}` });
283
- return;
284
- }
285
- const tier = maxEffect(res.ok);
286
- if (tier === "restart") {
287
- const go = await openConfirm(dialog, {
288
- title: `Saved — ${res.ok.length} setting${res.ok.length === 1 ? "" : "s"} need a gateway restart`,
289
- body: "Restart now? This interrupts any running turn.",
290
- yes: "restart now", no: "later", danger: true,
291
- });
292
- if (go) {
293
- gw.start();
294
- toast.show({ variant: "info", message: "Gateway restarting…" });
295
- }
296
- return;
297
- }
298
- toast.show({
299
- variant: "success",
300
- message: tier === "live" ? "Saved" : "Saved — new sessions pick this up",
301
- });
302
- };
303
-
304
- const pick = useCallback((s: Slot) => {
305
- if (managed) return toast.show({ variant: "error", message: `Managed by ${managed}` });
306
- openModelPicker(dialog, gw, {
307
- title: s.kind === "main" ? "Set main model" : `Set auxiliary · ${s.label}`,
308
- onApply: async (prov, model) => {
309
- const r = await assign(gw, s.key, prov, model);
310
- if (r.failed.length)
311
- return toast.show({ variant: "error", message: r.failed.map(f => f.err).join("; ") });
312
- toast.show({ variant: "success",
313
- message: s.kind === "main" ? `main → ${prov} · ${model}` : `${s.key} → ${prov} · ${model}` });
314
- if (r.warning) toast.show({ variant: "warning", message: r.warning });
315
- load();
316
- },
317
- });
318
- }, [gw, dialog, toast, load, managed]);
319
-
320
- const unset = useCallback((s: Slot) => {
321
- if (managed || s.kind !== "aux" || s.auto) return;
322
- void resetAux(gw, s.key).then(r => {
323
- if (r.failed.length)
324
- return toast.show({ variant: "error", message: r.failed.map(f => f.err).join("; ") });
325
- toast.show({ variant: "success", message: `${s.key} → auto` });
326
- load();
327
- });
328
- }, [gw, toast, load, managed]);
329
-
330
- const unsetAll = useCallback(() =>
331
- openConfirm(dialog, {
332
- title: "Reset all auxiliary slots to auto?",
333
- body: `${AUX_TASKS.length} slots. Each falls back to the main model.`,
334
- yes: "reset", danger: true,
335
- }).then(ok => {
336
- if (!ok) return;
337
- void resetAux(gw, "all").then(r => {
338
- if (r.failed.length)
339
- return toast.show({ variant: "error", message: `${r.failed.length} failed` });
340
- toast.show({ variant: "success", message: "All auxiliary slots → auto" });
341
- load();
342
- });
343
- }), [gw, dialog, toast, load]);
344
-
345
- const keys = useKeys();
346
- useKeyboard((key) => {
347
- if (!props.focused || dialog.stack.length > 0) return;
348
- if (key.name === "tab" && !editing && !searching) {
349
- setMode(m => m === "form" ? "yaml" : "form");
350
- return;
351
- }
352
-
353
- if (keys.match("config.save", key)) return void save();
354
-
355
- if (mode === "yaml") {
356
- if (key.name === "backspace") { setYaml(prev => prev.slice(0, -1)); return; }
357
- if (key.name === "return") { setYaml(prev => prev + "\n"); return; }
358
- if (key.raw && key.raw.length === 1 && key.raw >= " ") {
359
- setYaml(prev => prev + key.raw);
360
- return;
361
- }
362
- return;
363
- }
364
-
365
- if (searching) {
366
- if (key.name === "escape") { setSearching(false); setQuery(""); setCursor(0); return; }
367
- if (key.name === "backspace") { setQuery(prev => prev.slice(0, -1)); setCursor(0); return; }
368
- if (key.name === "up") { setCursor(c => Math.max(0, c - 1)); return; }
369
- if (key.name === "down") { setCursor(c => Math.min(count - 1, c + 1)); return; }
370
- if (key.raw && key.raw.length === 1 && key.raw >= " ") {
371
- setQuery(prev => prev + key.raw);
372
- setCursor(0);
373
- return;
374
- }
375
- return;
376
- }
377
-
378
- if (editing) {
379
- const f = fields[cursor];
380
- if (key.name === "escape") {
381
- setEditing(false); setBuf("");
382
- if (f) setErr(e => { const { [f.key]: _, ...rest } = e; return rest });
383
- return;
384
- }
385
- if (key.name === "return") {
386
- if (f) {
387
- const msg = checkRule(f.key, buf);
388
- if (msg) { setErr(e => ({ ...e, [f.key]: msg })); return; }
389
- setErr(e => { const { [f.key]: _, ...rest } = e; return rest });
390
- const val = f.type === "number" ? Number(buf) || 0 : buf;
391
- update(f.key, val);
392
- }
393
- setEditing(false);
394
- setBuf("");
395
- return;
396
- }
397
- if (key.name === "backspace") { setBuf(prev => prev.slice(0, -1)); return; }
398
- if (key.raw && key.raw.length === 1) { setBuf(prev => prev + key.raw); return; }
399
- return;
400
- }
401
-
402
- if (key.name === "left") { setFocus("categories"); return; }
403
- if (key.name === "right") { setFocus("fields"); return; }
404
- if (keys.match("list.search", key)) { setSearching(true); setQuery(""); setCursor(0); return; }
405
-
406
- if (focus === "categories") {
407
- if (key.name === "up") {
408
- setCat(c => { const n = Math.max(0, c - 1); catFollow.opts.scrollTo(n); return n });
409
- setCursor(0);
410
- return;
411
- }
412
- if (key.name === "down") {
413
- setCat(c => { const n = Math.min(groups.length - 1, c + 1); catFollow.opts.scrollTo(n); return n });
414
- setCursor(0);
415
- return;
416
- }
417
- if (key.name === "return") { setFocus("fields"); return; }
418
- return;
419
- }
420
-
421
- if (onSlots) {
422
- const s = slots[cursor];
423
- const matched = handleListKey(keys, key, {
424
- count, setSel: setCursor, ...follow.opts,
425
- onActivate: s ? () => pick(s) : undefined,
426
- onRefresh: () => { load(); toast.show({ variant: "info", message: "Reloaded", duration: 1000 }) },
427
- });
428
- if (matched || !s) return;
429
- if (key.raw === "x") return unset(s);
430
- if (key.raw === "X") return void unsetAll();
431
- return;
432
- }
433
-
434
- const f = fields[cursor];
435
- const writable = !managed;
436
- const matched = handleListKey(keys, key, {
437
- count, setSel: setCursor, ...follow.opts,
438
- onRefresh: () => { load(); toast.show({ variant: "info", message: "Reloaded", duration: 1000 }) },
439
- onToggle: writable && f?.type === "boolean" ? () => update(f.key, !f.value) : undefined,
440
- onActivate: f && writable && (f.type === "string" || f.type === "number")
441
- ? () => { setEditing(true); setBuf(String(f.value ?? "")) }
442
- : undefined,
443
- });
444
- if (matched || !f || !writable) return;
445
-
446
- if (f.type === "select" && f.options) {
447
- const idx = f.options.indexOf(String(f.value));
448
- if (key.raw === "l" || key.raw === "]") {
449
- update(f.key, f.options[(idx + 1) % f.options.length]);
450
- return;
451
- }
452
- if (key.raw === "h" || key.raw === "[") {
453
- update(f.key, f.options[(idx - 1 + f.options.length) % f.options.length]);
454
- return;
455
- }
456
- }
457
- });
458
-
459
- const dirty = nChanged > 0 ? `● ${nChanged} unsaved ` : "";
460
-
461
- if (mode === "yaml") {
462
- return (
463
- <TabShell title="Config · YAML" hint={`Tab form ${keys.print("config.save")} save`}>
464
- <scrollbox scrollY flexGrow={1}>
465
- <text wrapMode="word">
466
- <span fg={theme.text}>{yaml}</span>
467
- <span fg={theme.accent}>█</span>
468
- </text>
469
- </scrollbox>
470
- </TabShell>
471
- );
472
- }
473
-
474
- // Search collapses to single-pane: input row sits above (outside)
475
- // both shells so placement matches scope (all categories). Results
476
- // rows carry a category badge so hits stay attributable; Esc
477
- // restores two-pane.
478
- return (
479
- <box flexDirection="column" flexGrow={1}>
480
- {searching ? (
481
- <box height={1} paddingLeft={1} paddingRight={1}>
482
- <text>
483
- <span fg={theme.accent}>┃ </span>
484
- <span fg={theme.text}>{query}</span>
485
- <span fg={theme.accent}>█</span>
486
- <span fg={theme.textMuted}>{` ${count} of ${all.length} · ↑↓ nav · Esc close`}</span>
487
- </text>
488
- </box>
489
- ) : null}
490
- <box flexDirection="row" flexGrow={1}>
491
- {searching ? null : (
492
- <TabShell title="Config" hint="↑↓ → select" grow={1}
493
- focus={focus === "categories"}>
494
- <scrollbox ref={catFollow.ref} scrollY flexGrow={1}>
495
- {groups.map((c, i) => {
496
- const sel = i === cat;
497
- const hot = sel && focus === "categories";
498
- const items = grouped.get(c) ?? [];
499
- const n = c === "models" ? slots.length : items.length;
500
- const catDirty = items.some(f => changed(f.key));
501
- return (
502
- <box
503
- key={c}
504
- id={catFollow.id(i)}
505
- backgroundColor={hot ? theme.backgroundElement : undefined}
506
- onMouseDown={() => { setCat(i); setCursor(0); setFocus("categories"); }}
507
- >
508
- <text>
509
- <span fg={catDirty ? theme.warning : theme.textMuted}>{catDirty ? "●" : " "}</span>
510
- <span fg={hot ? theme.accent : sel ? theme.primary : theme.text}>
511
- {sel ? "▸ " : " "}{c}
512
- </span>
513
- <span fg={theme.textMuted}>{` (${n})`}</span>
514
- </text>
515
- </box>
516
- );
517
- })}
518
- </scrollbox>
519
- </TabShell>
520
- )}
521
-
522
- <TabShell
523
- title={onSlots ? "models · applies immediately"
524
- : searching ? "Search" : nChanged > 0 ? `${active} · ${nChanged} unsaved` : active}
525
- hint={managed
526
- ? `read-only · managed by ${managed}`
527
- : onSlots
528
- ? "←→ pane ↑↓ nav Enter pick x reset X reset-all"
529
- : `${dirty}Tab yaml ←→ pane ↑↓ nav ${keys.print("list.search")} search ${keys.print("config.save")} save`}
530
- grow={3} focus={focus === "fields" || searching}
531
- >
532
- {managed ? (
533
- <box height={1} flexDirection="row" gap={1}>
534
- <text fg={theme.warning}>🔒 managed install — edit</text>
535
- <FileLink source={makeSource("config.yaml")}>config.yaml</FileLink>
536
- <text fg={theme.warning}>via configuration.nix</text>
537
- </box>
538
- ) : null}
539
- {onSlots ? (
540
- <box key="slots" flexDirection="column" flexGrow={1}>
541
- <box height={1}><text fg={theme.textMuted}>
542
- Auxiliary tasks handle side-jobs. 'auto' = use main model. Per-task api_key/base_url/timeout live in the 'auxiliary' category.
543
- </text></box>
544
- <box height={1} />
545
- <scrollbox ref={follow.ref} scrollY flexGrow={1} verticalScrollbarOptions={VBAR}>
546
- {slots.map((s, i) => (
547
- <SlotRow key={s.key} id={follow.id(i)} s={s}
548
- on={i === cursor && focus === "fields"} />
549
- ))}
550
- </scrollbox>
551
- </box>
552
- ) : (<>
553
- <Hdr>
554
- <Col w={4} fg={theme.textMuted}>{""}</Col>
555
- {searching ? <Col w={12} fg={theme.textMuted} bold>Category</Col> : null}
556
- <Col w={40} fg={theme.textMuted} bold>Field</Col>
557
- <Col grow min={6} fg={theme.textMuted} bold>Value</Col>
558
- <Col w={2} fg={theme.textMuted}>{""}</Col>
559
- <Col w={9} fg={theme.textMuted}>{""}</Col>
560
- </Hdr>
561
- <box height={1} />
562
-
563
- {count === 0 ? (
564
- <box key="empty" flexGrow={1} padding={2}>
565
- <text fg={theme.textMuted}>
566
- {searching ? "No matching fields" : "No fields in this category"}
567
- </text>
568
- </box>
569
- ) : (
570
- <scrollbox ref={follow.ref} key="list" scrollY flexGrow={1}
571
- verticalScrollbarOptions={VBAR}>
572
- {secs.reduce<{ base: number; out: ReactNode[] }>((acc, s) => {
573
- if (s.head !== null) acc.out.push(
574
- <box key={`§${s.head}`} height={1} marginTop={acc.base > 0 ? 1 : 0}>
575
- <text fg={theme.textMuted}>─ {s.head} </text>
576
- </box>
577
- );
578
- s.items.forEach((f, j) => {
579
- const i = acc.base + j;
580
- acc.out.push(
581
- <FieldRow
582
- key={f.key}
583
- id={follow.id(i)}
584
- field={f}
585
- active={i === cursor && (focus === "fields" || searching)}
586
- changed={changed(f.key)}
587
- editing={editing && i === cursor}
588
- buf={buf}
589
- readonly={!!managed}
590
- error={err[f.key]}
591
- badge={searching ? groupOf(f.key) : undefined}
592
- />
593
- );
594
- });
595
- acc.base += s.items.length;
596
- return acc;
597
- }, { base: 0, out: [] }).out}
598
- </scrollbox>
599
- )}
600
- </>)}
601
- </TabShell>
602
- </box>
603
- </box>
604
- );
605
- });