herm-tui 1.0.0-dev.1

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 (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -0
  3. package/package.json +82 -0
  4. package/scripts/postinstall.ts +29 -0
  5. package/src/app/gateway.tsx +83 -0
  6. package/src/app/gatewayEvents.ts +203 -0
  7. package/src/app/launch.ts +41 -0
  8. package/src/app/skin.tsx +31 -0
  9. package/src/app/spawnHistory.ts +75 -0
  10. package/src/app/tabs.ts +23 -0
  11. package/src/app/turnReducer.ts +390 -0
  12. package/src/app/useAppKeys.ts +268 -0
  13. package/src/app/useAtRefPopover.ts +99 -0
  14. package/src/app/useInputHistory.ts +66 -0
  15. package/src/app/useSession.ts +102 -0
  16. package/src/app/useSlashCommands.ts +70 -0
  17. package/src/app/useSlashPopover.ts +48 -0
  18. package/src/app.tsx +917 -0
  19. package/src/commands/slash.ts +151 -0
  20. package/src/components/avatar/AnimatedAvatar.tsx +66 -0
  21. package/src/components/avatar/eikon.ts +144 -0
  22. package/src/components/avatar/states/error.ts +1155 -0
  23. package/src/components/avatar/states/idle.ts +1155 -0
  24. package/src/components/avatar/states/index.ts +30 -0
  25. package/src/components/avatar/states/listening.ts +1155 -0
  26. package/src/components/avatar/states/speaking.ts +1155 -0
  27. package/src/components/avatar/states/thinking.ts +1155 -0
  28. package/src/components/avatar/states/working.ts +1155 -0
  29. package/src/components/chat/AtRefPopover.tsx +54 -0
  30. package/src/components/chat/CodeBlock.tsx +67 -0
  31. package/src/components/chat/Composer.tsx +347 -0
  32. package/src/components/chat/DiffBlock.tsx +116 -0
  33. package/src/components/chat/ErrorBlock.tsx +70 -0
  34. package/src/components/chat/MediaChip.tsx +114 -0
  35. package/src/components/chat/MessageItem.tsx +282 -0
  36. package/src/components/chat/MessageList.tsx +114 -0
  37. package/src/components/chat/PromptCard.tsx +359 -0
  38. package/src/components/chat/SlashPopover.tsx +158 -0
  39. package/src/components/chat/ThoughtCloud.tsx +185 -0
  40. package/src/components/chat/TypingIndicator.tsx +25 -0
  41. package/src/components/chat/tool/Subagent.tsx +75 -0
  42. package/src/components/chat/tool/frame.tsx +69 -0
  43. package/src/components/chat/tool/index.tsx +65 -0
  44. package/src/components/chat/tool/preview.ts +57 -0
  45. package/src/components/sidebar/ContextGauge.tsx +102 -0
  46. package/src/components/sidebar/Sidebar.tsx +143 -0
  47. package/src/components/tabs/TabBar.tsx +50 -0
  48. package/src/components/ui/FileLink.tsx +52 -0
  49. package/src/config/index.ts +156 -0
  50. package/src/config/lane.ts +161 -0
  51. package/src/config/models.ts +95 -0
  52. package/src/config/rules.ts +80 -0
  53. package/src/config/schema.ts +308 -0
  54. package/src/dialogs/alert.tsx +52 -0
  55. package/src/dialogs/chafa.tsx +72 -0
  56. package/src/dialogs/confirm.tsx +58 -0
  57. package/src/dialogs/curator.tsx +153 -0
  58. package/src/dialogs/eikon-picker.tsx +95 -0
  59. package/src/dialogs/help.tsx +80 -0
  60. package/src/dialogs/history.tsx +92 -0
  61. package/src/dialogs/info.tsx +115 -0
  62. package/src/dialogs/keys.tsx +170 -0
  63. package/src/dialogs/logs.tsx +42 -0
  64. package/src/dialogs/message.tsx +38 -0
  65. package/src/dialogs/model-picker.tsx +123 -0
  66. package/src/dialogs/new-profile.tsx +69 -0
  67. package/src/dialogs/new-task.tsx +103 -0
  68. package/src/dialogs/profile.tsx +55 -0
  69. package/src/dialogs/rollback.tsx +190 -0
  70. package/src/dialogs/spawn-history.tsx +80 -0
  71. package/src/dialogs/text-prompt.tsx +68 -0
  72. package/src/dialogs/theme-picker.tsx +50 -0
  73. package/src/home/index.ts +23 -0
  74. package/src/home/store.ts +267 -0
  75. package/src/index.tsx +113 -0
  76. package/src/keys/catalog.ts +115 -0
  77. package/src/keys/chord.ts +125 -0
  78. package/src/keys/conflicts.ts +48 -0
  79. package/src/keys/context.tsx +112 -0
  80. package/src/keys/index.ts +5 -0
  81. package/src/keys/list.ts +94 -0
  82. package/src/keys/oc-compat.ts +87 -0
  83. package/src/tabs/Agents.tsx +607 -0
  84. package/src/tabs/Analytics.tsx +154 -0
  85. package/src/tabs/Chat.tsx +50 -0
  86. package/src/tabs/Config.tsx +605 -0
  87. package/src/tabs/Context.tsx +599 -0
  88. package/src/tabs/Cron.tsx +294 -0
  89. package/src/tabs/Env.tsx +227 -0
  90. package/src/tabs/Kanban.tsx +367 -0
  91. package/src/tabs/Memory.tsx +294 -0
  92. package/src/tabs/Sessions.tsx +786 -0
  93. package/src/tabs/Skills.tsx +507 -0
  94. package/src/tabs/Toolsets.tsx +266 -0
  95. package/src/theme/builtin.ts +78 -0
  96. package/src/theme/context.tsx +106 -0
  97. package/src/theme/index.ts +4 -0
  98. package/src/theme/resolve.ts +134 -0
  99. package/src/theme/syntax.ts +31 -0
  100. package/src/theme/themes/aura.json +69 -0
  101. package/src/theme/themes/ayu.json +80 -0
  102. package/src/theme/themes/carbonfox.json +248 -0
  103. package/src/theme/themes/catppuccin-frappe.json +233 -0
  104. package/src/theme/themes/catppuccin-macchiato.json +233 -0
  105. package/src/theme/themes/catppuccin.json +112 -0
  106. package/src/theme/themes/cobalt2.json +228 -0
  107. package/src/theme/themes/cursor.json +249 -0
  108. package/src/theme/themes/dracula.json +219 -0
  109. package/src/theme/themes/everforest.json +241 -0
  110. package/src/theme/themes/flexoki.json +237 -0
  111. package/src/theme/themes/github.json +233 -0
  112. package/src/theme/themes/gruvbox.json +242 -0
  113. package/src/theme/themes/kanagawa.json +77 -0
  114. package/src/theme/themes/lucent-orng.json +237 -0
  115. package/src/theme/themes/material.json +235 -0
  116. package/src/theme/themes/matrix.json +77 -0
  117. package/src/theme/themes/mercury.json +252 -0
  118. package/src/theme/themes/monokai.json +221 -0
  119. package/src/theme/themes/nightowl.json +221 -0
  120. package/src/theme/themes/nord.json +223 -0
  121. package/src/theme/themes/one-dark.json +84 -0
  122. package/src/theme/themes/opencode.json +245 -0
  123. package/src/theme/themes/orng.json +249 -0
  124. package/src/theme/themes/osaka-jade.json +93 -0
  125. package/src/theme/themes/palenight.json +222 -0
  126. package/src/theme/themes/rosepine.json +234 -0
  127. package/src/theme/themes/solarized.json +223 -0
  128. package/src/theme/themes/synthwave84.json +226 -0
  129. package/src/theme/themes/tokyonight.json +243 -0
  130. package/src/theme/themes/vercel.json +245 -0
  131. package/src/theme/themes/vesper.json +218 -0
  132. package/src/theme/themes/zenburn.json +223 -0
  133. package/src/theme/types.ts +119 -0
  134. package/src/types/message.ts +97 -0
  135. package/src/ui/ChafaImage.tsx +64 -0
  136. package/src/ui/Splash.tsx +118 -0
  137. package/src/ui/borders.ts +28 -0
  138. package/src/ui/command.tsx +104 -0
  139. package/src/ui/dialog-select.tsx +164 -0
  140. package/src/ui/dialog.tsx +102 -0
  141. package/src/ui/fmt.ts +82 -0
  142. package/src/ui/kv.tsx +28 -0
  143. package/src/ui/shell.tsx +45 -0
  144. package/src/ui/spinner.tsx +59 -0
  145. package/src/ui/splash-art.ts +123 -0
  146. package/src/ui/table.tsx +117 -0
  147. package/src/ui/ticker.tsx +90 -0
  148. package/src/ui/toast.tsx +130 -0
  149. package/src/utils/categorical.ts +77 -0
  150. package/src/utils/chafa.ts +173 -0
  151. package/src/utils/clipboard.ts +67 -0
  152. package/src/utils/context-segments.ts +317 -0
  153. package/src/utils/control.ts +495 -0
  154. package/src/utils/drop.ts +25 -0
  155. package/src/utils/editor.ts +33 -0
  156. package/src/utils/fuzzy.ts +45 -0
  157. package/src/utils/gateway-client.ts +253 -0
  158. package/src/utils/gateway-types.ts +282 -0
  159. package/src/utils/git.ts +57 -0
  160. package/src/utils/hermes-analytics.ts +134 -0
  161. package/src/utils/hermes-home.ts +821 -0
  162. package/src/utils/hermes-kanban.ts +154 -0
  163. package/src/utils/hermes-profiles.ts +217 -0
  164. package/src/utils/interpolate.ts +31 -0
  165. package/src/utils/math-unicode.ts +818 -0
  166. package/src/utils/memory-activity.ts +140 -0
  167. package/src/utils/open-file.ts +13 -0
  168. package/src/utils/paths.ts +52 -0
  169. package/src/utils/perf.ts +235 -0
  170. package/src/utils/preferences.ts +150 -0
  171. package/src/utils/sessions-db.ts +396 -0
  172. package/src/utils/subagent-tree.ts +146 -0
  173. package/src/utils/terminal-reset.ts +129 -0
  174. package/src/utils/tips.ts +67 -0
  175. package/src/utils/tokens.ts +87 -0
@@ -0,0 +1,190 @@
1
+ // Checkpoint rollback browser — list git-style checkpoints the gateway
2
+ // has captured, preview a unified diff, and restore on confirm.
3
+ //
4
+ // State lives entirely inside <RollbackDialog>; the global dialog
5
+ // provider closes on Esc, so when we want Esc to mean "back to list"
6
+ // from the diff view we immediately re-replace() ourselves with the
7
+ // already-loaded data (our key handler registers after the provider's,
8
+ // so our replace wins the batched setState race).
9
+
10
+ import { useEffect, useState } from "react"
11
+ import { useKeyboard } from "@opentui/react"
12
+ import { useKeys, handleListKey } from "../keys"
13
+ import { useTheme } from "../theme"
14
+ import type { DialogContext } from "../ui/dialog"
15
+ import type { useToast } from "../ui/toast"
16
+ import type { Gateway } from "../app/gateway"
17
+ import { DiffBlock } from "../components/chat/DiffBlock"
18
+ import { ago, trunc } from "../ui/fmt"
19
+
20
+ type Toast = ReturnType<typeof useToast>
21
+
22
+ type Checkpoint = { hash: string; timestamp: number; message: string }
23
+ type ListRes = { enabled: boolean; checkpoints: Checkpoint[] }
24
+ type DiffRes = { stat: string; diff: string; rendered?: string }
25
+ type RestoreRes = { success: boolean; history_removed?: number }
26
+
27
+ type Props = {
28
+ gw: Gateway
29
+ toast: Toast
30
+ dialog: DialogContext
31
+ /** Pre-loaded list (used when Esc re-opens at list view). */
32
+ initial?: ListRes
33
+ sel?: number
34
+ }
35
+
36
+ const RollbackDialog = (props: Props) => {
37
+ const theme = useTheme().theme
38
+ const [data, setData] = useState<ListRes | null>(props.initial ?? null)
39
+ const [sel, setSel] = useState(props.sel ?? 0)
40
+ const [diff, setDiff] = useState<DiffRes | null>(null)
41
+ const [confirm, setConfirm] = useState(false)
42
+
43
+ useEffect(() => {
44
+ if (props.initial) return
45
+ props.gw.request<ListRes>("rollback.list")
46
+ .then(setData)
47
+ .catch((e: Error) => setData({ enabled: false, checkpoints: [], ...{ err: e.message } } as ListRes))
48
+ }, [props.gw, props.initial])
49
+
50
+ const points = data?.checkpoints ?? []
51
+ const cur = points[sel]
52
+
53
+ const open = (cp: Checkpoint) => {
54
+ props.gw.request<DiffRes>("rollback.diff", { hash: cp.hash })
55
+ .then(setDiff)
56
+ .catch((e: Error) => props.toast.error(e))
57
+ }
58
+
59
+ const back = () => {
60
+ setDiff(null)
61
+ setConfirm(false)
62
+ // Provider already dispatched clear() on this Esc — replace() wins
63
+ // the batch. React reconciles same-type at same slot and keeps our
64
+ // state, so the setDiff(null) above is what actually flips the view.
65
+ props.dialog.replace(
66
+ <RollbackDialog gw={props.gw} toast={props.toast} dialog={props.dialog}
67
+ initial={data ?? undefined} sel={sel} />,
68
+ )
69
+ }
70
+
71
+ const restore = (cp: Checkpoint) => {
72
+ props.gw.request<RestoreRes>("rollback.restore", { hash: cp.hash })
73
+ .then(r => {
74
+ if (!r.success) throw new Error("restore rejected")
75
+ const n = r.history_removed
76
+ props.toast.show({ variant: "success",
77
+ message: `Restored ${cp.hash.slice(0, 7)}${n ? ` · ${n} turns removed` : ""}` })
78
+ props.dialog.clear()
79
+ })
80
+ .catch((e: Error) => {
81
+ props.toast.show({ variant: "error", message: `Restore failed: ${e.message}` })
82
+ props.dialog.clear()
83
+ })
84
+ }
85
+
86
+ const keys = useKeys()
87
+ useKeyboard((key) => {
88
+ if (diff) {
89
+ if (confirm) {
90
+ if (keys.match("dialog.confirm", key)) return restore(cur)
91
+ if (keys.match("dialog.deny", key) || keys.match("dialog.cancel", key)) {
92
+ setConfirm(false); return back()
93
+ }
94
+ return
95
+ }
96
+ if (keys.match("dialog.cancel", key)) return back()
97
+ if (key.name === "r") return setConfirm(true)
98
+ return
99
+ }
100
+ if (!data?.enabled) return
101
+ handleListKey(keys, key, {
102
+ count: points.length, setSel,
103
+ onActivate: () => { if (cur) open(cur) },
104
+ })
105
+ })
106
+
107
+ // ── Render ────────────────────────────────────────────────────────
108
+
109
+ if (!data) return (
110
+ <box width={60} height={3}>
111
+ <text fg={theme.textMuted}>Loading checkpoints…</text>
112
+ </box>
113
+ )
114
+
115
+ if (!data.enabled) return (
116
+ <box flexDirection="column" width={60} height={5}>
117
+ <box height={1}><text fg={theme.warning}><strong>Checkpoints disabled</strong></text></box>
118
+ <box height={1} />
119
+ <box height={1}><text fg={theme.textMuted}>Enable checkpoints in config to use /rollback.</text></box>
120
+ <box height={1} />
121
+ <box height={1}><text fg={theme.textMuted}>Esc to close</text></box>
122
+ </box>
123
+ )
124
+
125
+ if (diff) {
126
+ const body = diff.rendered || diff.diff || diff.stat || "(empty diff)"
127
+ return (
128
+ <box flexDirection="column" width={110} height={30}>
129
+ <box height={1}><text>
130
+ <span fg={theme.primary}><strong>Rollback · </strong></span>
131
+ <span fg={theme.accent}>{cur.hash.slice(0, 7)}</span>
132
+ <span fg={theme.textMuted}>{` ${trunc(cur.message, 70)}`}</span>
133
+ </text></box>
134
+ <box height={1}><text fg={theme.textMuted}>{diff.stat || " "}</text></box>
135
+ <box height={1} />
136
+ <scrollbox scrollY flexGrow={1}>
137
+ <box flexDirection="column" width="100%">
138
+ <DiffBlock text={body} />
139
+ </box>
140
+ </scrollbox>
141
+ <box height={1} />
142
+ {confirm ? (
143
+ <box height={1}><text>
144
+ <span fg={theme.warning}><strong>Restore this checkpoint? </strong></span>
145
+ <span fg={theme.textMuted}>[y] restore [n] cancel</span>
146
+ </text></box>
147
+ ) : (
148
+ <box height={1}><text fg={theme.textMuted}>[r] restore · Esc back</text></box>
149
+ )}
150
+ </box>
151
+ )
152
+ }
153
+
154
+ return (
155
+ <box flexDirection="column" width={90} height={Math.min(28, Math.max(8, points.length + 6))}>
156
+ <box height={1}><text fg={theme.primary}><strong>Rollback</strong></text></box>
157
+ <box height={1}><text fg={theme.textMuted}>
158
+ {`${points.length} checkpoints · ↑↓ navigate Enter diff Esc close`}
159
+ </text></box>
160
+ <box height={1} />
161
+ {points.length === 0 ? (
162
+ <box height={1}><text fg={theme.textMuted}>No checkpoints yet.</text></box>
163
+ ) : (
164
+ <scrollbox scrollY flexGrow={1}>
165
+ <box flexDirection="column" width="100%">
166
+ {points.map((cp, i) => {
167
+ const on = i === sel
168
+ return (
169
+ <box key={cp.hash} height={1}
170
+ backgroundColor={on ? theme.backgroundElement : undefined}
171
+ onMouseDown={() => { setSel(i); open(cp) }}
172
+ onMouseOver={() => setSel(i)}>
173
+ <text>
174
+ <span fg={on ? theme.primary : theme.textMuted}>{on ? "▸ " : " "}</span>
175
+ <span fg={theme.accent}>{cp.hash.slice(0, 7).padEnd(9)}</span>
176
+ <span fg={theme.textMuted}>{ago(cp.timestamp).padEnd(12)}</span>
177
+ <span fg={on ? theme.text : theme.textMuted}>{trunc(cp.message, 56)}</span>
178
+ </text>
179
+ </box>
180
+ )
181
+ })}
182
+ </box>
183
+ </scrollbox>
184
+ )}
185
+ </box>
186
+ )
187
+ }
188
+
189
+ export const openRollback = (dialog: DialogContext, gw: Gateway, toast: Toast) =>
190
+ dialog.replace(<RollbackDialog gw={gw} toast={toast} dialog={dialog} />)
@@ -0,0 +1,80 @@
1
+ import type { Gateway } from "../app/gateway"
2
+ import type { DialogContext } from "../ui/dialog"
3
+ import { DialogSelect } from "../ui/dialog-select"
4
+ import type { SpawnTreeEntry, SpawnTreeSnapshot, SpawnSubagent } from "../utils/gateway-types"
5
+ import { useTheme } from "../theme"
6
+ import { dur, when, fmt, trunc } from "../ui/fmt"
7
+
8
+ // Browse persisted spawn trees (spawn_tree.list) and inspect one
9
+ // (spawn_tree.load). Read-only — the live tree lives in the Agents tab.
10
+
11
+ const Status = ({ s }: { s: SpawnSubagent["status"] }) => {
12
+ const theme = useTheme().theme
13
+ const fg = s === "completed" ? theme.success
14
+ : s === "failed" ? theme.error
15
+ : s === "interrupted" ? theme.warning
16
+ : theme.textMuted
17
+ return <span fg={fg}>{s}</span>
18
+ }
19
+
20
+ const SnapshotView = (props: { entry: SpawnTreeEntry; snap: SpawnTreeSnapshot }) => {
21
+ const theme = useTheme().theme
22
+ const subs = props.snap.subagents ?? []
23
+ const tok = subs.reduce((n, s) => n + (s.input_tokens ?? 0) + (s.output_tokens ?? 0), 0)
24
+ const span = props.snap.started_at && props.snap.finished_at
25
+ ? dur(props.snap.finished_at - props.snap.started_at) : "—"
26
+ return (
27
+ <box flexDirection="column" width={80}>
28
+ <text fg={theme.text}><strong>{props.entry.label || `${subs.length} subagents`}</strong></text>
29
+ <text fg={theme.textMuted}>{when(props.entry.finished_at)} · {span} · {subs.length} agents · {fmt(tok)} tok</text>
30
+ <box height={1} />
31
+ <scrollbox scrollY maxHeight={20} contentOptions={{ flexDirection: "column" }}>
32
+ {subs.map(s => (
33
+ <box key={s.subagent_id} flexDirection="column" marginBottom={1}>
34
+ <box height={1}>
35
+ <text>
36
+ <span fg={theme.textMuted}>{"┃ " + "· ".repeat(s.depth)}</span>
37
+ <span fg={theme.text}>{trunc(s.goal.replace(/\s+/g, " "), 60)}</span>
38
+ </text>
39
+ </box>
40
+ <box height={1}>
41
+ <text fg={theme.textMuted}>
42
+ {"┃ " + " ".repeat(2 * s.depth + 2)}
43
+ <Status s={s.status} />
44
+ {` · ${s.tool_count}t`}
45
+ {s.finished_at ? ` · ${dur(s.finished_at - s.started_at)}` : ""}
46
+ {s.model ? ` · ${s.model}` : ""}
47
+ </text>
48
+ </box>
49
+ </box>
50
+ ))}
51
+ </scrollbox>
52
+ </box>
53
+ )
54
+ }
55
+
56
+ export function openSpawnHistory(dialog: DialogContext, gw: Gateway, sessionId: string): void {
57
+ gw.request<{ entries: SpawnTreeEntry[] }>("spawn_tree.list", { session_id: sessionId, limit: 50 })
58
+ .then(r => {
59
+ const entries = r.entries ?? []
60
+ dialog.replace(
61
+ <DialogSelect
62
+ title="Spawn history"
63
+ placeholder={entries.length ? "filter…" : "no saved spawn trees"}
64
+ options={entries.map(e => ({
65
+ value: e.path,
66
+ title: `${e.count.toString().padStart(2)}× ${trunc(e.label || "(unlabeled)", 40)}`,
67
+ description: when(e.finished_at),
68
+ category: e.session_id === sessionId ? "This session" : e.session_id,
69
+ }))}
70
+ onSelect={opt => {
71
+ const entry = entries.find(e => e.path === opt.value)!
72
+ gw.request<SpawnTreeSnapshot>("spawn_tree.load", { path: entry.path })
73
+ .then(snap => dialog.replace(<SnapshotView entry={entry} snap={snap} />))
74
+ .catch(() => dialog.clear())
75
+ }}
76
+ />,
77
+ )
78
+ })
79
+ .catch(() => dialog.clear())
80
+ }
@@ -0,0 +1,68 @@
1
+ // Single-line text prompt dialog. Enter submits, Esc cancels.
2
+
3
+ import { useState } from "react"
4
+ import { useKeyboard } from "@opentui/react"
5
+ import { useTheme } from "../theme"
6
+ import type { DialogContext } from "../ui/dialog"
7
+
8
+ type Props = {
9
+ title: string
10
+ label?: string
11
+ initial?: string
12
+ onSubmit: (value: string) => void
13
+ onCancel: () => void
14
+ }
15
+
16
+ const TextPrompt = (props: Props) => {
17
+ const theme = useTheme().theme
18
+ const [value, setValue] = useState(props.initial ?? "")
19
+
20
+ useKeyboard((key) => {
21
+ if (key.name === "escape") return props.onCancel()
22
+ if (key.name === "return") {
23
+ const v = value.trim()
24
+ if (v) props.onSubmit(v)
25
+ return
26
+ }
27
+ if (key.name === "backspace") return setValue(v => v.slice(0, -1))
28
+ if (key.ctrl && key.name === "u") return setValue("")
29
+ if (!key.ctrl && !key.meta && key.raw && key.raw.length === 1 && key.raw >= " ")
30
+ return setValue(v => v + key.raw)
31
+ })
32
+
33
+ return (
34
+ <box flexDirection="column" width={60}>
35
+ <box height={1}><text fg={theme.primary}><strong>{props.title}</strong></text></box>
36
+ <box height={1} />
37
+ {props.label ? <box height={1}><text fg={theme.textMuted}>{props.label}</text></box> : null}
38
+ <box height={1} flexDirection="row" overflow="hidden">
39
+ <box flexShrink={0}><text fg={theme.accent}>{"┃ "}</text></box>
40
+ <box flexGrow={1} minWidth={0} height={1} overflow="hidden">
41
+ <text>
42
+ <span fg={theme.text}>{value}</span>
43
+ <span fg={theme.accent}>█</span>
44
+ </text>
45
+ </box>
46
+ </box>
47
+ <box height={1} />
48
+ <box height={1}><text fg={theme.textMuted}>
49
+ {value.trim() ? "Enter confirm · Esc cancel · Ctrl+U clear" : "Esc cancel"}
50
+ </text></box>
51
+ </box>
52
+ )
53
+ }
54
+
55
+ export function openTextPrompt(
56
+ dialog: DialogContext,
57
+ opts: { title: string; label?: string; initial?: string },
58
+ ): Promise<string | null> {
59
+ return new Promise((resolve) => {
60
+ dialog.replace(
61
+ <TextPrompt
62
+ title={opts.title} label={opts.label} initial={opts.initial}
63
+ onSubmit={(v) => { dialog.clear(); resolve(v) }}
64
+ onCancel={() => { dialog.clear(); resolve(null) }}
65
+ />,
66
+ )
67
+ })
68
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Theme picker dialog — live preview with DialogSelect.
3
+ */
4
+
5
+ import { useCallback } from "react"
6
+ import { useTheme } from "../theme"
7
+ import { useDialog } from "../ui/dialog"
8
+ import { DialogSelect } from "../ui/dialog-select"
9
+ import type { SelectOption } from "../ui/dialog-select"
10
+
11
+ const ThemePickerDialog = ({ onConfirm }: { onConfirm: () => void }) => {
12
+ const ctx = useTheme()
13
+ const dialog = useDialog()
14
+
15
+ const options: SelectOption[] = ctx.names.map(n => ({
16
+ title: n,
17
+ value: n,
18
+ }))
19
+
20
+ const onMove = useCallback((opt: SelectOption) => {
21
+ ctx.set(opt.value)
22
+ }, [ctx])
23
+
24
+ const onSelect = useCallback((opt: SelectOption) => {
25
+ ctx.set(opt.value)
26
+ onConfirm()
27
+ dialog.clear()
28
+ }, [ctx, dialog, onConfirm])
29
+
30
+ return (
31
+ <DialogSelect
32
+ title="Switch Theme"
33
+ options={options}
34
+ current={ctx.name}
35
+ onSelect={onSelect}
36
+ onMove={onMove}
37
+ placeholder="Search themes..."
38
+ />
39
+ )
40
+ }
41
+
42
+ /** Open the theme picker, reverting on close without selection */
43
+ export const openThemePicker = (dialog: ReturnType<typeof useDialog>, ctx: ReturnType<typeof useTheme>) => {
44
+ const saved = ctx.name
45
+ let confirmed = false
46
+ dialog.replace(
47
+ <ThemePickerDialog onConfirm={() => { confirmed = true }} />,
48
+ () => { if (!confirmed) ctx.set(saved) }
49
+ )
50
+ }
@@ -0,0 +1,23 @@
1
+ import { useEffect, useSyncExternalStore } from "react"
2
+ import { home, type HomeState, type SliceKey } from "./store"
3
+
4
+ export { home } from "./store"
5
+ export type { HomeState, SliceKey } from "./store"
6
+
7
+ /**
8
+ * Subscribe to a single slice of ~/.hermes/ state.
9
+ *
10
+ * Returns `undefined` until the first read resolves, then the current value.
11
+ * Rerenders on fs.watch-driven invalidation or explicit `home.invalidate(k)`,
12
+ * and only for the requested key.
13
+ */
14
+ export function useHome<K extends SliceKey>(k: K): HomeState[K] | undefined {
15
+ const v = useSyncExternalStore(
16
+ (cb) => home.subscribe(k, cb),
17
+ () => home.get(k),
18
+ )
19
+ useEffect(() => {
20
+ void home.ensure(k)
21
+ }, [k])
22
+ return v
23
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Reactive data layer for ~/.hermes/.
3
+ *
4
+ * A slice is a typed reader with optional dependency keys and optional
5
+ * fs.watch paths. `ensure(k)` lazily resolves deps, reads, and starts a
6
+ * watcher on first pull. `invalidate(k)` drops the value, re-reads if there
7
+ * are subscribers, and cascades to dependents.
8
+ *
9
+ * Slices registered below; remaining readers migrate in from
10
+ * utils/hermes-home.ts as they're converted to collectors.
11
+ */
12
+
13
+ import { watch, existsSync, statSync, type FSWatcher } from "node:fs"
14
+ import { dirname, basename } from "node:path"
15
+ import {
16
+ hermesPath,
17
+ readConfig,
18
+ readMemoryFile,
19
+ readMemoryProviders,
20
+ readEnvFile,
21
+ readSoul,
22
+ readLiveSessions,
23
+ readSystemPromptInfo,
24
+ readToolsFromLatestSession,
25
+ queryRecentSessions,
26
+ readSkillUsage,
27
+ readCuratorState,
28
+ type HermesConfig,
29
+ type MemoryFileInfo,
30
+ type MemoryProviderInfo,
31
+ type SoulInfo,
32
+ type LiveSession,
33
+ type SystemPromptInfo,
34
+ type ToolsInfo,
35
+ type SessionRow,
36
+ type SkillUsage,
37
+ type CuratorState,
38
+ } from "../utils/hermes-home"
39
+ import { readMemoryActivity, type MemoryActivity } from "../utils/memory-activity"
40
+
41
+ // ─── State shape ──────────────────────────────────────────────────────
42
+
43
+ export interface HomeState {
44
+ config: HermesConfig | null
45
+ memory: MemoryFileInfo | null
46
+ userProfile: MemoryFileInfo | null
47
+ memoryProviders: MemoryProviderInfo[]
48
+ memoryActivity: MemoryActivity[]
49
+ env: Record<string, string>
50
+ soul: SoulInfo | null
51
+ liveSessions: Record<string, LiveSession>
52
+ recentSessions: SessionRow[]
53
+ systemPrompt: SystemPromptInfo | null
54
+ toolsInfo: ToolsInfo | null
55
+ skillUsage: Record<string, SkillUsage>
56
+ curatorState: CuratorState | null
57
+ }
58
+
59
+ export type SliceKey = keyof HomeState
60
+
61
+ export interface Slice<K extends SliceKey> {
62
+ /** Produce the value. Receives already-resolved dependency values. */
63
+ read: (deps: Partial<HomeState>) => Promise<HomeState[K]>
64
+ /** Slice keys this reader needs resolved first. Invalidation cascades along these edges. */
65
+ deps?: readonly SliceKey[]
66
+ /** Absolute paths to fs.watch. Change → invalidate(k). */
67
+ watch?: readonly string[]
68
+ }
69
+
70
+ type Slices = { [K in SliceKey]: Slice<K> }
71
+
72
+ const SLICES: Slices = {
73
+ config: {
74
+ read: () => readConfig(),
75
+ watch: [hermesPath("config.yaml")],
76
+ },
77
+ memory: {
78
+ read: (d) => readMemoryFile("MEMORY.md", d.config?.memory?.memory_char_limit ?? 2200),
79
+ deps: ["config"],
80
+ watch: [hermesPath("memories/MEMORY.md")],
81
+ },
82
+ userProfile: {
83
+ read: (d) => readMemoryFile("USER.md", d.config?.memory?.user_char_limit ?? 1375),
84
+ deps: ["config"],
85
+ watch: [hermesPath("memories/USER.md")],
86
+ },
87
+ memoryProviders: {
88
+ read: (d) => readMemoryProviders(d.config?.memory?.provider ?? ""),
89
+ deps: ["config"],
90
+ },
91
+ memoryActivity: {
92
+ read: async () => readMemoryActivity(),
93
+ },
94
+ env: {
95
+ read: () => readEnvFile(),
96
+ watch: [hermesPath(".env")],
97
+ },
98
+ soul: {
99
+ read: () => readSoul(),
100
+ watch: [hermesPath("SOUL.md")],
101
+ },
102
+ liveSessions: {
103
+ read: () => readLiveSessions(),
104
+ watch: [hermesPath("sessions/sessions.json")],
105
+ },
106
+ // DB-backed slices are pull-only: WAL mode means writes land in
107
+ // state.db-wal, so watching state.db misses them until checkpoint;
108
+ // and the query is heavy enough that firing on every checkpoint from
109
+ // an always-mounted Sidebar is wasteful. Consumers invalidate on
110
+ // demand (section-open, `r`, post-mutation).
111
+ recentSessions: {
112
+ read: async () => queryRecentSessions(),
113
+ },
114
+ systemPrompt: {
115
+ read: async () => readSystemPromptInfo(),
116
+ },
117
+ toolsInfo: {
118
+ // Scans sessions/ for newest session_*.json — watching the dir picks
119
+ // up new files without tracking a specific one.
120
+ read: () => readToolsFromLatestSession(),
121
+ watch: [hermesPath("sessions")],
122
+ },
123
+ skillUsage: {
124
+ read: () => readSkillUsage(),
125
+ watch: [hermesPath("skills/.usage.json")],
126
+ },
127
+ curatorState: {
128
+ read: () => readCuratorState(),
129
+ watch: [hermesPath("skills/.curator_state")],
130
+ },
131
+ }
132
+
133
+ /** Reverse dep edges: key → slices that listed it in `deps`. */
134
+ const DEPENDENTS: ReadonlyMap<SliceKey, readonly SliceKey[]> = (() => {
135
+ const m = new Map<SliceKey, SliceKey[]>()
136
+ for (const [k, s] of Object.entries(SLICES) as [SliceKey, Slice<SliceKey>][]) {
137
+ for (const d of s.deps ?? []) {
138
+ const arr = m.get(d) ?? []
139
+ arr.push(k)
140
+ m.set(d, arr)
141
+ }
142
+ }
143
+ return m
144
+ })()
145
+
146
+ // ─── Store ────────────────────────────────────────────────────────────
147
+
148
+ const DEBOUNCE_MS = 50
149
+
150
+ export class HomeStore {
151
+ private data: Partial<HomeState> = {}
152
+ private subs = new Map<SliceKey, Set<() => void>>()
153
+ private inflight = new Map<SliceKey, Promise<unknown>>()
154
+ private watchers = new Map<SliceKey, FSWatcher[]>()
155
+ private debounce = new Map<SliceKey, ReturnType<typeof setTimeout>>()
156
+
157
+ /** Current value, or undefined if not yet loaded. Stable ref until changed. */
158
+ get<K extends SliceKey>(k: K): HomeState[K] | undefined {
159
+ return this.data[k] as HomeState[K] | undefined
160
+ }
161
+
162
+ /** Register a change listener. Returns unsubscribe. */
163
+ subscribe<K extends SliceKey>(k: K, cb: () => void): () => void {
164
+ let set = this.subs.get(k)
165
+ if (!set) this.subs.set(k, (set = new Set()))
166
+ set.add(cb)
167
+ return () => set.delete(cb)
168
+ }
169
+
170
+ /**
171
+ * Resolve deps, read, store, start watching (first call only), notify.
172
+ * Concurrent calls for the same key share one inflight promise.
173
+ */
174
+ ensure<K extends SliceKey>(k: K): Promise<HomeState[K]> {
175
+ if (k in this.data) return Promise.resolve(this.data[k] as HomeState[K])
176
+ const hit = this.inflight.get(k)
177
+ if (hit) return hit as Promise<HomeState[K]>
178
+
179
+ const slice = SLICES[k]
180
+ const p = (async () => {
181
+ const deps: Partial<HomeState> = {}
182
+ for (const d of slice.deps ?? []) {
183
+ (deps as Record<SliceKey, unknown>)[d] = await this.ensure(d)
184
+ }
185
+ const v = await slice.read(deps)
186
+ this.data[k] = v
187
+ this.startWatch(k, slice.watch)
188
+ this.notify(k)
189
+ return v
190
+ })().finally(() => this.inflight.delete(k))
191
+
192
+ this.inflight.set(k, p)
193
+ return p
194
+ }
195
+
196
+ /**
197
+ * Drop the cached value; re-read if there are subscribers. Cascades to
198
+ * dependents. Call after TUI-driven writes so the UI reflects the change
199
+ * without waiting on a watch event.
200
+ */
201
+ invalidate(k: SliceKey): void {
202
+ if (!(k in this.data) && !this.inflight.has(k)) return
203
+ delete this.data[k]
204
+ if (this.subs.get(k)?.size) void this.ensure(k)
205
+ for (const dep of DEPENDENTS.get(k) ?? []) this.invalidate(dep)
206
+ }
207
+
208
+ /** Dispose all watchers and timers. Tests must call this. */
209
+ close(): void {
210
+ for (const ws of this.watchers.values()) for (const w of ws) w.close()
211
+ for (const t of this.debounce.values()) clearTimeout(t)
212
+ this.watchers.clear()
213
+ this.debounce.clear()
214
+ this.subs.clear()
215
+ this.inflight.clear()
216
+ this.data = {}
217
+ }
218
+
219
+ private notify(k: SliceKey): void {
220
+ const set = this.subs.get(k)
221
+ if (set) for (const cb of set) cb()
222
+ }
223
+
224
+ private startWatch(k: SliceKey, paths: readonly string[] | undefined): void {
225
+ if (!paths || this.watchers.has(k)) return
226
+ const ws: FSWatcher[] = []
227
+ const fire = () => {
228
+ const prev = this.debounce.get(k)
229
+ if (prev) clearTimeout(prev)
230
+ this.debounce.set(k, setTimeout(() => this.invalidate(k), DEBOUNCE_MS))
231
+ }
232
+ for (const p of paths) {
233
+ // Watching a file by path is unreliable across rewrites on Linux
234
+ // (inotify binds to the inode). Watch the parent dir and filter on
235
+ // basename so rename-into-place editors still fire. Only watch the
236
+ // path itself when it's an existing directory.
237
+ //
238
+ // Known runtime gap: Bun's fs.watch(dir) never emits for entries
239
+ // created after the watcher was armed (Node does). A target that
240
+ // didn't exist at first ensure stays non-reactive for the process
241
+ // lifetime. In practice only an optional file absent at launch is
242
+ // affected; in-TUI writes call invalidate() which re-reads directly,
243
+ // and `r` forces a manual refresh for the rare external-edit case.
244
+ let dir = dirname(p)
245
+ let name: string | null = basename(p)
246
+ try {
247
+ if (existsSync(p) && statSync(p).isDirectory()) {
248
+ dir = p
249
+ name = null
250
+ }
251
+ } catch {
252
+ continue
253
+ }
254
+ if (!existsSync(dir)) continue
255
+ try {
256
+ ws.push(watch(dir, { persistent: false }, (_ev, f) => {
257
+ if (name === null || f === name) fire()
258
+ }))
259
+ } catch {
260
+ // Unwatchable (permissions, exotic fs). Slice still works; just not reactive.
261
+ }
262
+ }
263
+ this.watchers.set(k, ws)
264
+ }
265
+ }
266
+
267
+ export const home = new HomeStore()