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,507 +0,0 @@
1
- import { useState, useEffect, useCallback, useRef, memo } from "react";
2
- import { useKeyboard } from "@opentui/react";
3
- import type { RGBA } from "@opentui/core";
4
- import { useKeys, handleListKey, useFollow } from "../keys";
5
- import { makeSource, readSkillFrontmatter, listCuratorRuns, readCuratorReport, indexCuratorLineage, type SkillInfo, type SkillUsage, type CuratorRun, type LineageEvent } from "../utils/hermes-home";
6
- import { count as tokenCount } from "../utils/tokens";
7
- import { useGateway } from "../app/gateway";
8
- import { useDialog } from "../ui/dialog";
9
- import { useToast } from "../ui/toast";
10
- import { useTheme } from "../theme";
11
- import { useHome } from "../home";
12
- import { TabShell } from "../ui/shell";
13
- import { KVBlock } from "../ui/kv";
14
- import { KVLink } from "../components/ui/FileLink";
15
- import { Col, Hdr, Marquee, VBAR } from "../ui/table";
16
- import { ago } from "../ui/fmt";
17
- import { openConfirm } from "../dialogs/confirm";
18
- import { openCurator } from "../dialogs/curator";
19
-
20
- const NO_EVENTS: LineageEvent[] = []
21
-
22
- type Hit = { name: string; description?: string }
23
- type Sort = "name" | "used"
24
-
25
- // ISO timestamp → epoch seconds (or null if unparseable/empty).
26
- const iso = (s: string | null | undefined): number | null => {
27
- if (!s) return null;
28
- const t = Date.parse(s);
29
- return Number.isFinite(t) ? Math.floor(t / 1000) : null;
30
- }
31
-
32
- // ─── Skill Row ───────────────────────────────────────────────────────
33
-
34
- const SkillRow = memo((props: {
35
- id: string;
36
- skill: SkillInfo;
37
- usage?: SkillUsage;
38
- selected: boolean;
39
- onSelect: () => void;
40
- onHover: () => void;
41
- }) => {
42
- const theme = useTheme().theme;
43
- const s = props.skill;
44
- const u = props.usage;
45
- const bg = props.selected ? theme.backgroundElement : undefined;
46
- const used = iso(u?.last_used_at) ?? iso(u?.last_viewed_at);
47
- const stale = u?.state === "stale";
48
- const archived = u?.state === "archived";
49
-
50
- return (
51
- <box id={props.id} flexDirection="row" height={1} backgroundColor={bg}
52
- onMouseDown={props.onSelect} onMouseMove={props.onHover}>
53
- <Col w={2} fg={props.selected ? theme.primary : theme.text}>{props.selected ? "▸ " : " "}</Col>
54
- <Col w={2} fg={theme.warning}>{u?.pinned ? "📌" : " "}</Col>
55
- <Marquee grow min={8} active={props.selected}
56
- fg={archived ? theme.textMuted : props.selected ? theme.accent : theme.text}>{s.name}</Marquee>
57
- {archived ? <Col w={10} fg={theme.textMuted}>archived</Col>
58
- : stale ? <Col w={10} fg={theme.warning}>stale</Col>
59
- : <Col w={10} fg={theme.textMuted}>{used ? ago(used) : ""}</Col>}
60
- </box>
61
- );
62
- });
63
-
64
- // ─── Hub Result Row ──────────────────────────────────────────────────
65
-
66
- const HitRow = memo((props: { hit: Hit; selected: boolean; onHover: () => void }) => {
67
- const theme = useTheme().theme;
68
- const on = props.selected;
69
- return (
70
- <box flexDirection="row" height={1} backgroundColor={on ? theme.backgroundElement : undefined}
71
- onMouseMove={props.onHover}>
72
- <Col w={2} fg={on ? theme.primary : theme.textMuted}>{on ? "▸ " : " "}</Col>
73
- <Col w={28} fg={on ? theme.accent : theme.text}>{props.hit.name}</Col>
74
- <Col grow min={8} fg={theme.textMuted}>{props.hit.description || "—"}</Col>
75
- </box>
76
- );
77
- });
78
-
79
- // ─── Detail Panel ────────────────────────────────────────────────────
80
-
81
- const line = (e: LineageEvent): string => {
82
- switch (e.kind) {
83
- case "absorbed": return `absorbed ${e.sources.map(s => `\`${s}\``).join(", ")}`
84
- case "merged": return `merged into \`${e.into}\`${e.reason ? ` — ${e.reason}` : ""}`
85
- case "transition": return `${e.from} → ${e.to}`
86
- case "pruned": return `pruned${e.reason ? ` — ${e.reason}` : ""}`
87
- case "added": return "created by curator"
88
- }
89
- }
90
-
91
- const DetailPanel = memo((props: { skill: SkillInfo; usage?: SkillUsage; events: LineageEvent[] }) => {
92
- const theme = useTheme().theme;
93
- const s = props.skill;
94
- const u = props.usage;
95
- const used = iso(u?.last_used_at);
96
- const viewed = iso(u?.last_viewed_at);
97
- const patched = iso(u?.last_patched_at);
98
-
99
- return (
100
- <box
101
- flexDirection="column"
102
- padding={1}
103
- border
104
- borderColor={theme.border}
105
- backgroundColor={theme.backgroundPanel}
106
- width="50%"
107
- >
108
- <box height={1}>
109
- <text>
110
- <span fg={theme.primary}><strong>Skill Detail</strong></span>
111
- {u?.pinned ? <span fg={theme.warning}> 📌 pinned</span> : null}
112
- {u?.state === "stale" ? <span fg={theme.warning}> · stale</span> : null}
113
- {u?.state === "archived" ? <span fg={theme.textMuted}> · archived</span> : null}
114
- </text>
115
- </box>
116
- <box height={1} />
117
- <box height={1}><text fg={theme.accent}><strong>{s.name}</strong></text></box>
118
- <box height={1} />
119
- <KVBlock rows={([
120
- ["Category", s.category || "uncategorized", theme.info],
121
- ["Tags", s.tags.length > 0 ? s.tags.join(", ") : undefined],
122
- u ? ["Used", u.use_count > 0 ? `${u.use_count}× · last ${used ? ago(used) : "never"}` : "never"] : null,
123
- u && viewed ? ["Viewed", `${u.view_count}× · last ${ago(viewed)}`] : null,
124
- u && patched ? ["Patched", `${u.patch_count}× · last ${ago(patched)}`] : null,
125
- ]).filter(Boolean) as Array<[string, string | undefined, (RGBA | undefined)?]>} />
126
- <KVLink label="File" source={s.source} text={s.source.relative} />
127
- <box height={1} />
128
- {s.description ? (
129
- <text wrapMode="word"><span fg={theme.text}>{s.description}</span></text>
130
- ) : (
131
- <text fg={theme.textMuted}>No description</text>
132
- )}
133
- {props.events.length > 0 ? (
134
- <box flexDirection="column" marginTop={1}>
135
- <box height={1}><text fg={theme.textMuted}>Curator lineage</text></box>
136
- {props.events.map((e, i) => (
137
- <box key={i} flexDirection="row" minHeight={1}>
138
- <box width={10} flexShrink={0}>
139
- <text fg={theme.textMuted}>{ago(e.at)}</text>
140
- </box>
141
- <box flexGrow={1} minHeight={1}>
142
- <text wrapMode="word" fg={theme.text}>{line(e)}</text>
143
- </box>
144
- </box>
145
- ))}
146
- </box>
147
- ) : u ? (
148
- <box height={1} marginTop={1}>
149
- <text fg={theme.textMuted}>No curator events for this skill</text>
150
- </box>
151
- ) : null}
152
- </box>
153
- );
154
- });
155
-
156
- // ─── Empty State ─────────────────────────────────────────────────────
157
-
158
- const EmptyState = memo((props: { searching: boolean }) => {
159
- const theme = useTheme().theme;
160
- return (
161
- <box flexGrow={1} padding={2}>
162
- <text>
163
- <span fg={theme.textMuted}>
164
- {props.searching
165
- ? "No matching skills on hub"
166
- : "No skills found in ~/.hermes/skills/"}
167
- </span>
168
- </text>
169
- </box>
170
- );
171
- });
172
-
173
- // ─── Curator History Panel ───────────────────────────────────────────
174
- // Right-hand pane (swaps with DetailPanel on `h`). Browsable list of
175
- // logs/curator/{id}/ runs with counts from run.json; Enter toggles
176
- // REPORT.md rendered through <markdown>. Independent selection so the
177
- // skills list stays on whatever row it was.
178
-
179
- const HistoryPanel = memo((props: { focused: boolean }) => {
180
- const { theme, syntaxStyle } = useTheme();
181
- const [runs, setRuns] = useState<CuratorRun[]>(() => listCuratorRuns());
182
- const [sel, setSel] = useState(0);
183
- const [open, setOpen] = useState(false);
184
- const [body, setBody] = useState("");
185
- const run = runs[sel];
186
-
187
- useEffect(() => {
188
- if (!open || !run) return;
189
- let live = true;
190
- readCuratorReport(run.id).then(t => { if (live) setBody(t) });
191
- return () => { live = false };
192
- }, [open, run?.id]);
193
-
194
- useKeyboard((key) => {
195
- if (!props.focused) return;
196
- if (key.name === "up") { setOpen(false); return setSel(p => Math.max(0, p - 1)) }
197
- if (key.name === "down") { setOpen(false); return setSel(p => Math.min(runs.length - 1, p + 1)) }
198
- if (key.name === "return") return setOpen(o => !o);
199
- if (key.raw === "r") return setRuns(listCuratorRuns());
200
- });
201
-
202
- return (
203
- <box flexDirection="column" padding={1} border
204
- borderColor={props.focused ? theme.primary : theme.border}
205
- backgroundColor={theme.backgroundPanel} width="50%">
206
- <box height={1}>
207
- <text>
208
- <span fg={theme.primary}><strong>Curator History</strong></span>
209
- <span fg={theme.textMuted}>
210
- {` ${runs.length} run${runs.length === 1 ? "" : "s"}${runs[0] ? ` · last ${ago(runs[0].at)}` : ""}`}
211
- </span>
212
- </text>
213
- </box>
214
- <box height={1}><text fg={theme.textMuted}>↑↓ select · Enter expand · h close</text></box>
215
- <box height={1} />
216
- {runs.length === 0
217
- ? <text fg={theme.textMuted}>no runs in ~/.hermes/logs/curator/</text>
218
- : (
219
- <scrollbox scrollY flexGrow={1}>
220
- <box flexDirection="column" width="100%">
221
- {runs.map((r, i) => {
222
- const on = i === sel;
223
- return (
224
- <box key={r.id} flexDirection="column">
225
- <box height={1} flexDirection="row"
226
- backgroundColor={on ? theme.backgroundElement : undefined}
227
- onMouseDown={() => { setSel(i); setOpen(o => i === sel ? !o : true) }}>
228
- <Col w={2} fg={on ? theme.primary : theme.textMuted}>{on ? "▸ " : " "}</Col>
229
- <Col w={12} fg={on ? theme.accent : theme.text}>{ago(r.at)}</Col>
230
- <Col grow fg={theme.textMuted}>
231
- {`${r.before}→${r.after} arch ${r.archived} cons ${r.consolidated}${r.added ? ` +${r.added}` : ""}`}
232
- </Col>
233
- </box>
234
- {on && open ? (
235
- <box marginLeft={2} marginTop={1} marginBottom={1}>
236
- <markdown content={body || "…"} fg={theme.markdownText} syntaxStyle={syntaxStyle} />
237
- </box>
238
- ) : null}
239
- </box>
240
- );
241
- })}
242
- </box>
243
- </scrollbox>
244
- )}
245
- </box>
246
- );
247
- });
248
-
249
- // ─── Main Component ──────────────────────────────────────────────────
250
-
251
- export const Skills = memo((props: { focused?: boolean }) => {
252
- const theme = useTheme().theme;
253
- const gw = useGateway();
254
- const dialog = useDialog();
255
- const toast = useToast();
256
- const usage = useHome("skillUsage") ?? {};
257
- const curator = useHome("curatorState");
258
- // Built once per tab-open; rebuilt when .curator_state fires (a run
259
- // finished and wrote a fresh run.json).
260
- const lineage = useRef(indexCuratorLineage());
261
- useEffect(() => { lineage.current = indexCuratorLineage() }, [curator?.run_count]);
262
- const [skills, setSkills] = useState<SkillInfo[]>([]);
263
- const [selected, setSelected] = useState(0);
264
- const [searching, setSearching] = useState(false);
265
- const [query, setQuery] = useState("");
266
- const [hits, setHits] = useState<Hit[]>([]);
267
- const [sort, setSort] = useState<Sort>("name");
268
- const [history, setHistory] = useState(false);
269
- const seq = useRef(0);
270
-
271
- const load = useCallback(() => {
272
- gw.request<{ skills: Record<string, string[]> }>("skills.manage", { action: "list" })
273
- .then(res => {
274
- const raw = res.skills ?? {};
275
- const rows: SkillInfo[] = Object.entries(raw).flatMap(([cat, names]) =>
276
- names.map(n => {
277
- const source = makeSource(`skills/${cat}/${n}/SKILL.md`, `${n}/SKILL.md`);
278
- // Gateway list returns names only; enrich from on-disk
279
- // frontmatter so Description/Tags aren't dead columns.
280
- const fm = readSkillFrontmatter(source);
281
- return {
282
- source, category: cat, name: n,
283
- description: fm.description, tags: fm.tags,
284
- tokenEstimate: tokenCount(`${n} ${fm.description}`),
285
- };
286
- })
287
- );
288
- rows.sort((a, b) => a.source.relative.localeCompare(b.source.relative));
289
- setSkills(rows);
290
- })
291
- .catch(() => {});
292
- }, [gw]);
293
-
294
- useEffect(() => {
295
- load();
296
- }, [load]);
297
-
298
- // Hub search — debounced, drop stale responses via seq ref.
299
- useEffect(() => {
300
- const id = ++seq.current;
301
- if (!searching || !query.trim()) { setHits([]); return }
302
- const t = setTimeout(() => {
303
- gw.request<{ results: Hit[] }>("skills.manage", { action: "search", query })
304
- .then(r => {
305
- if (seq.current !== id) return;
306
- setHits(r.results ?? []);
307
- setSelected(0);
308
- })
309
- .catch(() => { if (seq.current === id) setHits([]) });
310
- }, 150);
311
- return () => clearTimeout(t);
312
- }, [gw, query, searching]);
313
-
314
- // Group installed skills by category. When sorted by "used", flatten
315
- // into a single "by-recency" group so the cross-category order is visible.
316
- const groups = sort === "used"
317
- ? new Map<string, SkillInfo[]>([
318
- ["by recency", [...skills].sort((a, b) => {
319
- const ta = iso(usage[a.name]?.last_used_at) ?? iso(usage[a.name]?.last_viewed_at) ?? 0;
320
- const tb = iso(usage[b.name]?.last_used_at) ?? iso(usage[b.name]?.last_viewed_at) ?? 0;
321
- return tb - ta;
322
- })],
323
- ])
324
- : Map.groupBy(skills, s => s.category || "uncategorized");
325
-
326
- // Flat list for keyboard navigation
327
- const flat = [...groups].flatMap(([cat, items]) => [
328
- { type: "header" as const, category: cat },
329
- ...items.map(s => ({ type: "skill" as const, skill: s })),
330
- ]);
331
-
332
- const skillRows = flat.filter(r => r.type === "skill");
333
- const count = searching ? hits.length : skillRows.length;
334
- const current = !searching && skillRows[selected]?.type === "skill"
335
- ? skillRows[selected].skill : null;
336
- const follow = useFollow("sk");
337
-
338
- const exit = useCallback(() => {
339
- setSearching(false); setQuery(""); setHits([]); setSelected(0);
340
- }, []);
341
-
342
- const install = useCallback(async (name: string) => {
343
- const ok = await openConfirm(dialog, {
344
- title: "Install skill?",
345
- body: name,
346
- yes: "install",
347
- });
348
- if (!ok) return;
349
- gw.request("skills.manage", { action: "install", query: name })
350
- .then(() => {
351
- toast.show({ variant: "success", message: `Installed ${name}` });
352
- exit();
353
- load();
354
- })
355
- .catch((e: Error) =>
356
- toast.show({ variant: "error", message: `Install failed: ${e.message}` }));
357
- }, [dialog, gw, toast, exit, load]);
358
-
359
- const keys = useKeys();
360
- useKeyboard((key) => {
361
- if (!props.focused || dialog.stack.length > 0) return;
362
-
363
- if (searching) {
364
- if (key.name === "escape") { exit(); return }
365
- if (key.name === "backspace") { setQuery(p => p.slice(0, -1)); setSelected(0); return }
366
- if (key.name === "up") return setSelected(p => Math.max(0, p - 1));
367
- if (key.name === "down") return setSelected(p => Math.min(count - 1, p + 1));
368
- if (key.name === "return") {
369
- const hit = hits[selected];
370
- if (hit) install(hit.name);
371
- return;
372
- }
373
- if (key.raw && key.raw.length === 1 && key.raw >= " ") {
374
- setQuery(p => p + key.raw); setSelected(0);
375
- }
376
- return;
377
- }
378
-
379
- // `s` toggles sort between category/name (default) and recency.
380
- // Intercept before handleListKey so the stock list vocabulary stays intact.
381
- if (!key.ctrl && !key.meta && key.raw === "s") {
382
- setSort(p => p === "name" ? "used" : "name");
383
- setSelected(0);
384
- return;
385
- }
386
-
387
- // `c` opens the Curator report dialog.
388
- if (!key.ctrl && !key.meta && key.raw === "c") {
389
- openCurator(dialog);
390
- return;
391
- }
392
-
393
- // `h` toggles the curator run-history pane in place of DetailPanel.
394
- // When open it owns ↑↓/Enter/r; Esc or `h` returns here.
395
- if (!key.ctrl && !key.meta && key.raw === "h") {
396
- setHistory(h => !h);
397
- return;
398
- }
399
- if (history) {
400
- if (key.name === "escape") return setHistory(false);
401
- return; // HistoryPanel's own useKeyboard handles the rest
402
- }
403
-
404
- handleListKey(keys, key, {
405
- count, setSel: setSelected, ...follow.opts,
406
- onRefresh: () => { load(); toast.show({ variant: "info", message: "Reloaded", duration: 1000 }) },
407
- onSearch: () => { setSearching(true); setQuery(""); setHits([]); setSelected(0) },
408
- });
409
- });
410
-
411
- // Track which skill index we're on as we iterate through the grouped list
412
- let skillIdx = -1;
413
-
414
- return (
415
- <box flexDirection="row" flexGrow={1}>
416
- <TabShell
417
- title={searching ? `Hub Search (${hits.length})` : `Skills (${skills.length}${sort === "used" ? " · by use" : ""})`}
418
- hint={searching
419
- ? "↑↓ navigate Enter install Esc cancel"
420
- : `↑↓ navigate ${keys.print("list.search")} search hub s sort c curator h history ${keys.print("list.refresh")} refresh`}
421
- >
422
- {/* Search bar */}
423
- {searching ? (
424
- <box height={1}>
425
- <text>
426
- <span fg={theme.accent}>{"/ "}</span>
427
- <span fg={theme.text}>{query}</span>
428
- <span fg={theme.accent}>{"█"}</span>
429
- </text>
430
- </box>
431
- ) : null}
432
-
433
- {searching ? null : (
434
- <Hdr>
435
- <Col w={2} fg={theme.textMuted}>{""}</Col>
436
- <Col grow min={8} fg={theme.textMuted} bold>Name</Col>
437
- </Hdr>
438
- )}
439
- {searching ? null : <box height={1} />}
440
-
441
- {/* List */}
442
- {count === 0 ? (
443
- <EmptyState searching={searching} />
444
- ) : searching ? (
445
- <scrollbox scrollY flexGrow={1}>
446
- <box flexDirection="column" width="100%">
447
- {hits.map((h, i) => (
448
- <HitRow key={h.name} hit={h} selected={i === selected}
449
- onHover={() => setSelected(i)} />
450
- ))}
451
- </box>
452
- </scrollbox>
453
- ) : (
454
- <scrollbox ref={follow.ref} scrollY flexGrow={1} verticalScrollbarOptions={VBAR}>
455
- {flat.map((row, i) => {
456
- if (row.type === "header") {
457
- return (
458
- <box key={`h-${row.category}`} marginTop={i > 0 ? 1 : 0}>
459
- <text fg={theme.info}><strong>{`▾ ${row.category}`}</strong></text>
460
- </box>
461
- );
462
- }
463
- skillIdx++;
464
- const idx = skillIdx;
465
- return (
466
- <SkillRow
467
- key={row.skill.name}
468
- id={follow.id(idx)}
469
- skill={row.skill}
470
- usage={usage[row.skill.name]}
471
- selected={idx === selected}
472
- onSelect={() => setSelected(idx)}
473
- onHover={() => setSelected(idx)}
474
- />
475
- );
476
- })}
477
- </scrollbox>
478
- )}
479
-
480
- {/* Curator footer — summary of last run / paused state. Driven by
481
- fs.watch on ~/.hermes/skills/.curator_state; silent when absent. */}
482
- {!searching && curator ? (
483
- <box height={1} flexShrink={0}>
484
- <text>
485
- <span fg={theme.textMuted}>{"curator · "}</span>
486
- {curator.paused ? (
487
- <span fg={theme.warning}>paused</span>
488
- ) : curator.last_run_at ? (
489
- <span fg={theme.textMuted}>
490
- {`${curator.run_count} run${curator.run_count === 1 ? "" : "s"} · last ${ago(iso(curator.last_run_at) ?? 0)}`}
491
- </span>
492
- ) : (
493
- <span fg={theme.textMuted}>never run</span>
494
- )}
495
- </text>
496
- </box>
497
- ) : null}
498
- </TabShell>
499
-
500
- {/* Right-hand pane: curator history when toggled, else skill detail */}
501
- {history
502
- ? <HistoryPanel focused={!!props.focused && !searching} />
503
- : current ? <DetailPanel skill={current} usage={usage[current.name]}
504
- events={lineage.current.get(current.name) ?? NO_EVENTS} /> : null}
505
- </box>
506
- );
507
- });