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.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +82 -0
- package/scripts/postinstall.ts +29 -0
- package/src/app/gateway.tsx +83 -0
- package/src/app/gatewayEvents.ts +203 -0
- package/src/app/launch.ts +41 -0
- package/src/app/skin.tsx +31 -0
- package/src/app/spawnHistory.ts +75 -0
- package/src/app/tabs.ts +23 -0
- package/src/app/turnReducer.ts +390 -0
- package/src/app/useAppKeys.ts +268 -0
- package/src/app/useAtRefPopover.ts +99 -0
- package/src/app/useInputHistory.ts +66 -0
- package/src/app/useSession.ts +102 -0
- package/src/app/useSlashCommands.ts +70 -0
- package/src/app/useSlashPopover.ts +48 -0
- package/src/app.tsx +917 -0
- package/src/commands/slash.ts +151 -0
- package/src/components/avatar/AnimatedAvatar.tsx +66 -0
- package/src/components/avatar/eikon.ts +144 -0
- package/src/components/avatar/states/error.ts +1155 -0
- package/src/components/avatar/states/idle.ts +1155 -0
- package/src/components/avatar/states/index.ts +30 -0
- package/src/components/avatar/states/listening.ts +1155 -0
- package/src/components/avatar/states/speaking.ts +1155 -0
- package/src/components/avatar/states/thinking.ts +1155 -0
- package/src/components/avatar/states/working.ts +1155 -0
- package/src/components/chat/AtRefPopover.tsx +54 -0
- package/src/components/chat/CodeBlock.tsx +67 -0
- package/src/components/chat/Composer.tsx +347 -0
- package/src/components/chat/DiffBlock.tsx +116 -0
- package/src/components/chat/ErrorBlock.tsx +70 -0
- package/src/components/chat/MediaChip.tsx +114 -0
- package/src/components/chat/MessageItem.tsx +282 -0
- package/src/components/chat/MessageList.tsx +114 -0
- package/src/components/chat/PromptCard.tsx +359 -0
- package/src/components/chat/SlashPopover.tsx +158 -0
- package/src/components/chat/ThoughtCloud.tsx +185 -0
- package/src/components/chat/TypingIndicator.tsx +25 -0
- package/src/components/chat/tool/Subagent.tsx +75 -0
- package/src/components/chat/tool/frame.tsx +69 -0
- package/src/components/chat/tool/index.tsx +65 -0
- package/src/components/chat/tool/preview.ts +57 -0
- package/src/components/sidebar/ContextGauge.tsx +102 -0
- package/src/components/sidebar/Sidebar.tsx +143 -0
- package/src/components/tabs/TabBar.tsx +50 -0
- package/src/components/ui/FileLink.tsx +52 -0
- package/src/config/index.ts +156 -0
- package/src/config/lane.ts +161 -0
- package/src/config/models.ts +95 -0
- package/src/config/rules.ts +80 -0
- package/src/config/schema.ts +308 -0
- package/src/dialogs/alert.tsx +52 -0
- package/src/dialogs/chafa.tsx +72 -0
- package/src/dialogs/confirm.tsx +58 -0
- package/src/dialogs/curator.tsx +153 -0
- package/src/dialogs/eikon-picker.tsx +95 -0
- package/src/dialogs/help.tsx +80 -0
- package/src/dialogs/history.tsx +92 -0
- package/src/dialogs/info.tsx +115 -0
- package/src/dialogs/keys.tsx +170 -0
- package/src/dialogs/logs.tsx +42 -0
- package/src/dialogs/message.tsx +38 -0
- package/src/dialogs/model-picker.tsx +123 -0
- package/src/dialogs/new-profile.tsx +69 -0
- package/src/dialogs/new-task.tsx +103 -0
- package/src/dialogs/profile.tsx +55 -0
- package/src/dialogs/rollback.tsx +190 -0
- package/src/dialogs/spawn-history.tsx +80 -0
- package/src/dialogs/text-prompt.tsx +68 -0
- package/src/dialogs/theme-picker.tsx +50 -0
- package/src/home/index.ts +23 -0
- package/src/home/store.ts +267 -0
- package/src/index.tsx +113 -0
- package/src/keys/catalog.ts +115 -0
- package/src/keys/chord.ts +125 -0
- package/src/keys/conflicts.ts +48 -0
- package/src/keys/context.tsx +112 -0
- package/src/keys/index.ts +5 -0
- package/src/keys/list.ts +94 -0
- package/src/keys/oc-compat.ts +87 -0
- package/src/tabs/Agents.tsx +607 -0
- package/src/tabs/Analytics.tsx +154 -0
- package/src/tabs/Chat.tsx +50 -0
- package/src/tabs/Config.tsx +605 -0
- package/src/tabs/Context.tsx +599 -0
- package/src/tabs/Cron.tsx +294 -0
- package/src/tabs/Env.tsx +227 -0
- package/src/tabs/Kanban.tsx +367 -0
- package/src/tabs/Memory.tsx +294 -0
- package/src/tabs/Sessions.tsx +786 -0
- package/src/tabs/Skills.tsx +507 -0
- package/src/tabs/Toolsets.tsx +266 -0
- package/src/theme/builtin.ts +78 -0
- package/src/theme/context.tsx +106 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/resolve.ts +134 -0
- package/src/theme/syntax.ts +31 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +119 -0
- package/src/types/message.ts +97 -0
- package/src/ui/ChafaImage.tsx +64 -0
- package/src/ui/Splash.tsx +118 -0
- package/src/ui/borders.ts +28 -0
- package/src/ui/command.tsx +104 -0
- package/src/ui/dialog-select.tsx +164 -0
- package/src/ui/dialog.tsx +102 -0
- package/src/ui/fmt.ts +82 -0
- package/src/ui/kv.tsx +28 -0
- package/src/ui/shell.tsx +45 -0
- package/src/ui/spinner.tsx +59 -0
- package/src/ui/splash-art.ts +123 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/ticker.tsx +90 -0
- package/src/ui/toast.tsx +130 -0
- package/src/utils/categorical.ts +77 -0
- package/src/utils/chafa.ts +173 -0
- package/src/utils/clipboard.ts +67 -0
- package/src/utils/context-segments.ts +317 -0
- package/src/utils/control.ts +495 -0
- package/src/utils/drop.ts +25 -0
- package/src/utils/editor.ts +33 -0
- package/src/utils/fuzzy.ts +45 -0
- package/src/utils/gateway-client.ts +253 -0
- package/src/utils/gateway-types.ts +282 -0
- package/src/utils/git.ts +57 -0
- package/src/utils/hermes-analytics.ts +134 -0
- package/src/utils/hermes-home.ts +821 -0
- package/src/utils/hermes-kanban.ts +154 -0
- package/src/utils/hermes-profiles.ts +217 -0
- package/src/utils/interpolate.ts +31 -0
- package/src/utils/math-unicode.ts +818 -0
- package/src/utils/memory-activity.ts +140 -0
- package/src/utils/open-file.ts +13 -0
- package/src/utils/paths.ts +52 -0
- package/src/utils/perf.ts +235 -0
- package/src/utils/preferences.ts +150 -0
- package/src/utils/sessions-db.ts +396 -0
- package/src/utils/subagent-tree.ts +146 -0
- package/src/utils/terminal-reset.ts +129 -0
- package/src/utils/tips.ts +67 -0
- package/src/utils/tokens.ts +87 -0
|
@@ -0,0 +1,507 @@
|
|
|
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
|
+
});
|