oh-my-claudecode 0.2.7 → 0.2.9
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/README.md +170 -68
- package/commands/cthulhu.md +9 -1
- package/commands/invoke-shub.md +8 -2
- package/commands/shoggoth.md +15 -25
- package/commands/yog-sothoth.md +18 -25
- package/dist/agents/render.d.ts +11 -0
- package/dist/agents/render.d.ts.map +1 -0
- package/dist/agents/render.js +69 -0
- package/dist/agents/render.js.map +1 -0
- package/dist/cli/dashboard.d.ts +12 -0
- package/dist/cli/dashboard.d.ts.map +1 -0
- package/dist/cli/dashboard.js +58 -0
- package/dist/cli/dashboard.js.map +1 -0
- package/dist/cli/doctor.d.ts +11 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +163 -9
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +72 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/install.d.ts +6 -0
- package/dist/cli/install.d.ts.map +1 -1
- package/dist/cli/install.js +211 -44
- package/dist/cli/install.js.map +1 -1
- package/dist/cli/lint.d.ts +26 -0
- package/dist/cli/lint.d.ts.map +1 -0
- package/dist/cli/lint.js +86 -0
- package/dist/cli/lint.js.map +1 -0
- package/dist/cli/stats.d.ts +56 -0
- package/dist/cli/stats.d.ts.map +1 -0
- package/dist/cli/stats.js +197 -0
- package/dist/cli/stats.js.map +1 -0
- package/dist/cli/sync.d.ts +44 -0
- package/dist/cli/sync.d.ts.map +1 -0
- package/dist/cli/sync.js +154 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/config/schema.d.ts +337 -331
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +14 -10
- package/dist/config/schema.js.map +1 -1
- package/dist/features/block-summarizer/index.js +1 -0
- package/dist/features/block-summarizer/index.js.map +1 -1
- package/dist/features/yith-archive/config.d.ts.map +1 -1
- package/dist/features/yith-archive/config.js +11 -15
- package/dist/features/yith-archive/config.js.map +1 -1
- package/dist/features/yith-archive/eval/schemas.d.ts +2 -2
- package/dist/features/yith-archive/functions/migrate.d.ts.map +1 -1
- package/dist/features/yith-archive/functions/migrate.js +7 -3
- package/dist/features/yith-archive/functions/migrate.js.map +1 -1
- package/dist/features/yith-archive/functions/opencode-import.d.ts.map +1 -1
- package/dist/features/yith-archive/functions/opencode-import.js +54 -27
- package/dist/features/yith-archive/functions/opencode-import.js.map +1 -1
- package/dist/features/yith-archive/functions/smart-search.js.map +1 -1
- package/dist/features/yith-archive/functions/temporal-graph.d.ts.map +1 -1
- package/dist/features/yith-archive/functions/temporal-graph.js +1 -0
- package/dist/features/yith-archive/functions/temporal-graph.js.map +1 -1
- package/dist/features/yith-archive/providers/embedding/local.d.ts +17 -6
- package/dist/features/yith-archive/providers/embedding/local.d.ts.map +1 -1
- package/dist/features/yith-archive/providers/embedding/local.js +32 -14
- package/dist/features/yith-archive/providers/embedding/local.js.map +1 -1
- package/dist/features/yith-archive/state/fake-sdk.d.ts.map +1 -1
- package/dist/features/yith-archive/state/fake-sdk.js.map +1 -1
- package/dist/features/yith-archive/state/reranker.d.ts.map +1 -1
- package/dist/features/yith-archive/state/reranker.js +9 -2
- package/dist/features/yith-archive/state/reranker.js.map +1 -1
- package/dist/features/yith-archive/state/vector-index.d.ts.map +1 -1
- package/dist/features/yith-archive/state/vector-index.js +1 -0
- package/dist/features/yith-archive/state/vector-index.js.map +1 -1
- package/dist/hooks/agent-sync.d.ts +16 -0
- package/dist/hooks/agent-sync.d.ts.map +1 -0
- package/dist/hooks/agent-sync.js +42 -0
- package/dist/hooks/agent-sync.js.map +1 -0
- package/dist/hooks/comment-checker.d.ts +1 -1
- package/dist/hooks/comment-checker.d.ts.map +1 -1
- package/dist/hooks/comment-checker.js +10 -0
- package/dist/hooks/comment-checker.js.map +1 -1
- package/dist/hooks/cthulhu-auto.d.ts +1 -1
- package/dist/hooks/cthulhu-auto.d.ts.map +1 -1
- package/dist/hooks/cthulhu-auto.js +77 -8
- package/dist/hooks/cthulhu-auto.js.map +1 -1
- package/dist/hooks/cthulhu-preflight.d.ts.map +1 -1
- package/dist/hooks/cthulhu-preflight.js +6 -5
- package/dist/hooks/cthulhu-preflight.js.map +1 -1
- package/dist/hooks/design-detector-hook.d.ts +6 -5
- package/dist/hooks/design-detector-hook.d.ts.map +1 -1
- package/dist/hooks/design-detector-hook.js +27 -8
- package/dist/hooks/design-detector-hook.js.map +1 -1
- package/dist/hooks/index.d.ts +2 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +43 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/web-research-hook.d.ts +6 -5
- package/dist/hooks/web-research-hook.d.ts.map +1 -1
- package/dist/hooks/web-research-hook.js +30 -9
- package/dist/hooks/web-research-hook.js.map +1 -1
- package/dist/hooks/write-guard.d.ts +1 -1
- package/dist/hooks/write-guard.d.ts.map +1 -1
- package/dist/hooks/write-guard.js +10 -0
- package/dist/hooks/write-guard.js.map +1 -1
- package/dist/hooks/yith-capture.d.ts +9 -3
- package/dist/hooks/yith-capture.d.ts.map +1 -1
- package/dist/hooks/yith-capture.js +43 -14
- package/dist/hooks/yith-capture.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -5
- package/dist/index.js.map +1 -1
- package/dist/linters/type-safety-ast.d.ts.map +1 -1
- package/dist/linters/type-safety-ast.js +42 -24
- package/dist/linters/type-safety-ast.js.map +1 -1
- package/dist/shared/ascii-logo.d.ts +24 -0
- package/dist/shared/ascii-logo.d.ts.map +1 -0
- package/dist/shared/ascii-logo.js +77 -0
- package/dist/shared/ascii-logo.js.map +1 -0
- package/dist/shared/model-resolution.d.ts +19 -6
- package/dist/shared/model-resolution.d.ts.map +1 -1
- package/dist/shared/model-resolution.js +25 -12
- package/dist/shared/model-resolution.js.map +1 -1
- package/package.json +9 -6
- package/tui/dashboard.tsx +504 -0
- package/tui/data.ts +178 -0
- package/tui/theme.ts +51 -0
- package/tui/tsconfig.json +15 -0
- package/tui/wizard.tsx +219 -0
- package/dist/plugin-handlers/config-handler.d.ts +0 -21
- package/dist/plugin-handlers/config-handler.d.ts.map +0 -1
- package/dist/plugin-handlers/config-handler.js +0 -33
- package/dist/plugin-handlers/config-handler.js.map +0 -1
- package/dist/plugin-handlers/index.d.ts +0 -2
- package/dist/plugin-handlers/index.d.ts.map +0 -1
- package/dist/plugin-handlers/index.js +0 -2
- package/dist/plugin-handlers/index.js.map +0 -1
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oh-my-claudecode dashboard — OpenTUI app, run with Bun via
|
|
3
|
+
* `oh-my-claudecode dashboard`.
|
|
4
|
+
*
|
|
5
|
+
* Design language: gruvbox dark soft, editorial calm. No border soup —
|
|
6
|
+
* whitespace, dim caps section labels, one ember-orange accent, and a
|
|
7
|
+
* single thin rule under the nav. Health leads with a verdict sentence;
|
|
8
|
+
* detail is opt-in. Pure renderer: data via `stats --json`, settings
|
|
9
|
+
* writes go to the config file + background sync.
|
|
10
|
+
*
|
|
11
|
+
* Keys: ←→/1-4 tabs · ↑↓ select · enter toggle · a all checks ·
|
|
12
|
+
* r refresh · q quit
|
|
13
|
+
*/
|
|
14
|
+
import { createCliRenderer } from "@opentui/core"
|
|
15
|
+
import { createRoot, useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
16
|
+
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
17
|
+
import {
|
|
18
|
+
loadSnapshotAsync,
|
|
19
|
+
toggleDisabled,
|
|
20
|
+
togglePillar,
|
|
21
|
+
type StatsSnapshot,
|
|
22
|
+
} from "./data.ts"
|
|
23
|
+
import {
|
|
24
|
+
theme,
|
|
25
|
+
statusColor,
|
|
26
|
+
statusIcon,
|
|
27
|
+
formatBytes,
|
|
28
|
+
formatAge,
|
|
29
|
+
formatCount,
|
|
30
|
+
} from "./theme.ts"
|
|
31
|
+
import { renderWordRows, LOGO_SEGMENTS, LOGO_TAGLINE } from "../dist/shared/ascii-logo.js"
|
|
32
|
+
|
|
33
|
+
const TABS = ["overview", "doctor", "agents", "settings"] as const
|
|
34
|
+
type Tab = (typeof TABS)[number]
|
|
35
|
+
|
|
36
|
+
// ── Typography helpers ──────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function SectionLabel({ children }: { children: string }) {
|
|
39
|
+
return (
|
|
40
|
+
<text style={{ fg: theme.faint }}>
|
|
41
|
+
{children.toUpperCase().split("").join(" ")}
|
|
42
|
+
</text>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Rule({ width }: { width: number }) {
|
|
47
|
+
return <text style={{ fg: theme.bgHi }}>{"─".repeat(Math.max(0, width))}</text>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function BigStat(props: { label: string; value: string; color?: string }) {
|
|
51
|
+
return (
|
|
52
|
+
<box style={{ flexDirection: "column", marginRight: 5 }}>
|
|
53
|
+
<text style={{ fg: props.color ?? theme.fg }}>{props.value}</text>
|
|
54
|
+
<text style={{ fg: theme.dim }}>{props.label}</text>
|
|
55
|
+
</box>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Field(props: { label: string; value: string; valueColor?: string; note?: string }) {
|
|
60
|
+
return (
|
|
61
|
+
<text>
|
|
62
|
+
<span style={{ fg: theme.dim }}>{props.label.padEnd(14)}</span>
|
|
63
|
+
<span style={{ fg: props.valueColor ?? theme.fg }}>{props.value}</span>
|
|
64
|
+
{props.note ? <span style={{ fg: theme.faint }}>{" " + props.note}</span> : null}
|
|
65
|
+
</text>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One-sentence health verdict — the calm summary that replaces the wall. */
|
|
70
|
+
function verdict(snapshot: StatsSnapshot): { text: string; color: string; icon: string } {
|
|
71
|
+
const errors = snapshot.doctor.filter((d) => d.status === "error").length
|
|
72
|
+
const warns = snapshot.doctor.filter((d) => d.status === "warn").length
|
|
73
|
+
if (errors > 0)
|
|
74
|
+
return {
|
|
75
|
+
icon: "○",
|
|
76
|
+
color: theme.err,
|
|
77
|
+
text: `${errors} check${errors > 1 ? "s" : ""} failing, ${warns} advisory — see doctor`,
|
|
78
|
+
}
|
|
79
|
+
if (warns > 0)
|
|
80
|
+
return {
|
|
81
|
+
icon: "◐",
|
|
82
|
+
color: theme.warn,
|
|
83
|
+
text: `working, with ${warns} advisor${warns > 1 ? "ies" : "y"} — see doctor`,
|
|
84
|
+
}
|
|
85
|
+
return { icon: "●", color: theme.ok, text: "all systems quiet" }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Splash ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function Splash({ version, loading }: { version: string | null; loading: boolean }) {
|
|
91
|
+
const segments = LOGO_SEGMENTS.map((s) => ({ ...s, rows: renderWordRows(s.text) }))
|
|
92
|
+
return (
|
|
93
|
+
<box
|
|
94
|
+
style={{
|
|
95
|
+
backgroundColor: theme.bg,
|
|
96
|
+
flexDirection: "column",
|
|
97
|
+
flexGrow: 1,
|
|
98
|
+
justifyContent: "center",
|
|
99
|
+
alignItems: "center",
|
|
100
|
+
gap: 1,
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<box style={{ flexDirection: "column" }}>
|
|
104
|
+
{[0, 1, 2].map((row) => (
|
|
105
|
+
<text key={row}>
|
|
106
|
+
{segments.map((segment, i) => (
|
|
107
|
+
<span key={i} style={{ fg: segment.color }}>
|
|
108
|
+
{segment.rows[row] + " "}
|
|
109
|
+
</span>
|
|
110
|
+
))}
|
|
111
|
+
</text>
|
|
112
|
+
))}
|
|
113
|
+
</box>
|
|
114
|
+
<text style={{ fg: theme.dim }}>{LOGO_TAGLINE}</text>
|
|
115
|
+
<text style={{ fg: theme.faint }}>{version ? `v${version}` : " "}</text>
|
|
116
|
+
<text> </text>
|
|
117
|
+
<text style={{ fg: theme.faint }}>
|
|
118
|
+
{loading ? "consulting the necronomicon…" : "press any key"}
|
|
119
|
+
</text>
|
|
120
|
+
</box>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Tabs ────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function OverviewTab({ snapshot }: { snapshot: StatsSnapshot }) {
|
|
127
|
+
const a = snapshot.archive
|
|
128
|
+
const c = snapshot.capture
|
|
129
|
+
const v = verdict(snapshot)
|
|
130
|
+
const captureAgeDays = c.lastSuccessEpoch
|
|
131
|
+
? (Date.now() / 1000 - c.lastSuccessEpoch) / 86400
|
|
132
|
+
: Infinity
|
|
133
|
+
const phases = Object.entries(a.bindPhases)
|
|
134
|
+
const ritualDone = phases.length > 0 && phases.every(([, s]) => s === "completed")
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<box style={{ flexDirection: "column", gap: 1, flexGrow: 1 }}>
|
|
138
|
+
<text>
|
|
139
|
+
<span style={{ fg: v.color }}>{v.icon} </span>
|
|
140
|
+
<span style={{ fg: theme.muted }}>{v.text}</span>
|
|
141
|
+
</text>
|
|
142
|
+
<text> </text>
|
|
143
|
+
|
|
144
|
+
<SectionLabel>archive</SectionLabel>
|
|
145
|
+
<box style={{ flexDirection: "row" }}>
|
|
146
|
+
<BigStat label="memories" value={formatCount(a.memories)} color={theme.accent} />
|
|
147
|
+
<BigStat label="observations" value={formatCount(a.observations)} />
|
|
148
|
+
<BigStat label="sessions" value={formatCount(a.sessions)} />
|
|
149
|
+
<BigStat
|
|
150
|
+
label="awaiting compression"
|
|
151
|
+
value={formatCount(a.pendingCompression)}
|
|
152
|
+
color={a.pendingCompression > 0 ? theme.gold : theme.fg}
|
|
153
|
+
/>
|
|
154
|
+
</box>
|
|
155
|
+
<text> </text>
|
|
156
|
+
|
|
157
|
+
<SectionLabel>system</SectionLabel>
|
|
158
|
+
<Field
|
|
159
|
+
label="embeddings"
|
|
160
|
+
value={a.embeddingProvider ?? "none"}
|
|
161
|
+
note={`${a.embeddingDimensions ?? "?"} dims · model cache ${formatBytes(a.modelCacheBytes)}`}
|
|
162
|
+
/>
|
|
163
|
+
<Field
|
|
164
|
+
label="capture"
|
|
165
|
+
value={`last ingest ${formatAge(c.lastSuccessEpoch)}`}
|
|
166
|
+
valueColor={captureAgeDays < 2 ? theme.fg : captureAgeDays < 7 ? theme.warn : theme.err}
|
|
167
|
+
note={`${c.cronInstalled ? "cron active" : "cron missing"} · retention ${
|
|
168
|
+
c.retentionDays ? `${c.retentionDays}d` : "~30d default"
|
|
169
|
+
}`}
|
|
170
|
+
/>
|
|
171
|
+
<Field label="archive size" value={formatBytes(a.diskBytes)} note="necronomicon.json" />
|
|
172
|
+
<Field
|
|
173
|
+
label="ritual"
|
|
174
|
+
value={
|
|
175
|
+
phases.length === 0
|
|
176
|
+
? "not bound"
|
|
177
|
+
: ritualDone
|
|
178
|
+
? "complete"
|
|
179
|
+
: phases
|
|
180
|
+
.filter(([, s]) => s !== "completed")
|
|
181
|
+
.map(([p, s]) => `${p}: ${s}`)
|
|
182
|
+
.join(" · ")
|
|
183
|
+
}
|
|
184
|
+
valueColor={phases.length === 0 ? theme.warn : ritualDone ? theme.fg : theme.gold}
|
|
185
|
+
note={phases.length === 0 ? "run `oh-my-claudecode bind`" : undefined}
|
|
186
|
+
/>
|
|
187
|
+
</box>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function DoctorTab({ snapshot, showAll }: { snapshot: StatsSnapshot; showAll: boolean }) {
|
|
192
|
+
const v = verdict(snapshot)
|
|
193
|
+
const issues = snapshot.doctor.filter((d) => d.status !== "ok")
|
|
194
|
+
const visible = showAll ? snapshot.doctor : issues
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<box style={{ flexDirection: "column", gap: 1, flexGrow: 1 }}>
|
|
198
|
+
<text>
|
|
199
|
+
<span style={{ fg: v.color }}>{v.icon} </span>
|
|
200
|
+
<span style={{ fg: theme.muted }}>{v.text}</span>
|
|
201
|
+
</text>
|
|
202
|
+
<text> </text>
|
|
203
|
+
{issues.length === 0 && !showAll ? (
|
|
204
|
+
<text style={{ fg: theme.dim }}>
|
|
205
|
+
Nothing needs attention. Press <span style={{ fg: theme.accent }}>a</span> to see
|
|
206
|
+
every check.
|
|
207
|
+
</text>
|
|
208
|
+
) : (
|
|
209
|
+
<>
|
|
210
|
+
<SectionLabel>{showAll ? "all checks" : "needs attention"}</SectionLabel>
|
|
211
|
+
<scrollbox style={{ flexGrow: 1 }}>
|
|
212
|
+
{visible.map((check) => (
|
|
213
|
+
<box key={check.name} style={{ flexDirection: "column", marginBottom: 1 }}>
|
|
214
|
+
<text>
|
|
215
|
+
<span style={{ fg: statusColor(check.status) }}>{statusIcon(check.status)}</span>
|
|
216
|
+
<span style={{ fg: check.status === "ok" ? theme.dim : theme.fg }}>
|
|
217
|
+
{" " + check.name}
|
|
218
|
+
</span>
|
|
219
|
+
</text>
|
|
220
|
+
<text style={{ fg: check.status === "ok" ? theme.faint : theme.dim }}>
|
|
221
|
+
{" " + check.message}
|
|
222
|
+
</text>
|
|
223
|
+
</box>
|
|
224
|
+
))}
|
|
225
|
+
</scrollbox>
|
|
226
|
+
<text style={{ fg: theme.faint }}>
|
|
227
|
+
{showAll ? "a hide passing checks" : "a show all checks"}
|
|
228
|
+
</text>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</box>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function AgentsTab({ snapshot }: { snapshot: StatsSnapshot }) {
|
|
236
|
+
const installed = snapshot.agents.filter((agent) => agent.installed).length
|
|
237
|
+
return (
|
|
238
|
+
<box style={{ flexDirection: "column", gap: 1, flexGrow: 1 }}>
|
|
239
|
+
<text>
|
|
240
|
+
<span style={{ fg: installed === snapshot.agents.length ? theme.ok : theme.warn }}>
|
|
241
|
+
{installed === snapshot.agents.length ? "●" : "◐"}{" "}
|
|
242
|
+
</span>
|
|
243
|
+
<span style={{ fg: theme.muted }}>
|
|
244
|
+
{installed} of {snapshot.agents.length} elder gods summoned
|
|
245
|
+
{installed < snapshot.agents.length ? " — run `oh-my-claudecode sync`" : ""}
|
|
246
|
+
</span>
|
|
247
|
+
</text>
|
|
248
|
+
<text> </text>
|
|
249
|
+
<SectionLabel>roster</SectionLabel>
|
|
250
|
+
<scrollbox style={{ flexGrow: 1 }}>
|
|
251
|
+
{snapshot.agents.map((agent) => (
|
|
252
|
+
<text key={agent.name}>
|
|
253
|
+
<span style={{ fg: agent.installed ? theme.ok : theme.faint }}>
|
|
254
|
+
{agent.installed ? "● " : "○ "}
|
|
255
|
+
</span>
|
|
256
|
+
<span style={{ fg: theme.fg }}>{agent.name.padEnd(17)}</span>
|
|
257
|
+
<span style={{ fg: theme.accent }}>{agent.model.padEnd(9)}</span>
|
|
258
|
+
<span style={{ fg: theme.dim }}>{agent.category.padEnd(15)}</span>
|
|
259
|
+
<span style={{ fg: theme.faint }}>{agent.cost.toLowerCase()}</span>
|
|
260
|
+
</text>
|
|
261
|
+
))}
|
|
262
|
+
</scrollbox>
|
|
263
|
+
</box>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface SettingRow {
|
|
268
|
+
id: string
|
|
269
|
+
group: "pillars" | "hooks" | "agents"
|
|
270
|
+
label: string
|
|
271
|
+
detail: string
|
|
272
|
+
enabled: boolean
|
|
273
|
+
toggle: () => void
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function SettingsTab({
|
|
277
|
+
snapshot,
|
|
278
|
+
refresh,
|
|
279
|
+
}: {
|
|
280
|
+
snapshot: StatsSnapshot
|
|
281
|
+
refresh: () => void
|
|
282
|
+
}) {
|
|
283
|
+
const [cursor, setCursor] = useState(0)
|
|
284
|
+
|
|
285
|
+
const rows: SettingRow[] = useMemo(() => {
|
|
286
|
+
const pillars: SettingRow[] = (
|
|
287
|
+
[
|
|
288
|
+
["web_research", "web research", "background dagon spawns for date-sensitive queries"],
|
|
289
|
+
["type_safety", "type safety", "the lint pillar"],
|
|
290
|
+
["frontend_design", "design routing", "route ui work to nodens"],
|
|
291
|
+
] as const
|
|
292
|
+
).map(([key, label, detail]) => ({
|
|
293
|
+
id: `pillar:${key}`,
|
|
294
|
+
group: "pillars" as const,
|
|
295
|
+
label,
|
|
296
|
+
detail,
|
|
297
|
+
enabled:
|
|
298
|
+
key === "web_research"
|
|
299
|
+
? snapshot.config.webResearch
|
|
300
|
+
: key === "type_safety"
|
|
301
|
+
? snapshot.config.typeSafety
|
|
302
|
+
: snapshot.config.frontendDesign,
|
|
303
|
+
toggle: () => {
|
|
304
|
+
togglePillar(key)
|
|
305
|
+
refresh()
|
|
306
|
+
},
|
|
307
|
+
}))
|
|
308
|
+
const hookNames = [
|
|
309
|
+
...new Set([...snapshot.hooks.map((h) => h.name), ...snapshot.config.disabledHooks]),
|
|
310
|
+
]
|
|
311
|
+
const hooks: SettingRow[] = hookNames.map((name) => ({
|
|
312
|
+
id: `hook:${name}`,
|
|
313
|
+
group: "hooks" as const,
|
|
314
|
+
label: name,
|
|
315
|
+
detail: snapshot.hooks.find((h) => h.name === name)?.event ?? "disabled",
|
|
316
|
+
enabled: !snapshot.config.disabledHooks.includes(name),
|
|
317
|
+
toggle: () => {
|
|
318
|
+
toggleDisabled("disabled_hooks", name)
|
|
319
|
+
refresh()
|
|
320
|
+
},
|
|
321
|
+
}))
|
|
322
|
+
const agentNames = [
|
|
323
|
+
...new Set([...snapshot.agents.map((a) => a.name), ...snapshot.config.disabledAgents]),
|
|
324
|
+
]
|
|
325
|
+
const agents: SettingRow[] = agentNames.map((name) => ({
|
|
326
|
+
id: `agent:${name}`,
|
|
327
|
+
group: "agents" as const,
|
|
328
|
+
label: name,
|
|
329
|
+
detail: snapshot.agents.find((a) => a.name === name)?.model ?? "disabled",
|
|
330
|
+
enabled: !snapshot.config.disabledAgents.includes(name),
|
|
331
|
+
toggle: () => {
|
|
332
|
+
toggleDisabled("disabled_agents", name)
|
|
333
|
+
refresh()
|
|
334
|
+
},
|
|
335
|
+
}))
|
|
336
|
+
return [...pillars, ...hooks, ...agents]
|
|
337
|
+
}, [snapshot, refresh])
|
|
338
|
+
|
|
339
|
+
useKeyboard((key) => {
|
|
340
|
+
if (key.name === "up") setCursor((c) => Math.max(0, c - 1))
|
|
341
|
+
if (key.name === "down") setCursor((c) => Math.min(rows.length - 1, c + 1))
|
|
342
|
+
if (key.name === "return" || key.name === "space") {
|
|
343
|
+
rows[Math.min(cursor, rows.length - 1)]?.toggle()
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
let lastGroup = ""
|
|
348
|
+
return (
|
|
349
|
+
<box style={{ flexDirection: "column", gap: 1, flexGrow: 1 }}>
|
|
350
|
+
{/* Keyed by cursor — remount per move so rows repaint (see nav note). */}
|
|
351
|
+
<scrollbox key={cursor} style={{ flexGrow: 1 }}>
|
|
352
|
+
{rows.map((row, i) => {
|
|
353
|
+
const header = row.group !== lastGroup ? row.group : null
|
|
354
|
+
lastGroup = row.group
|
|
355
|
+
return (
|
|
356
|
+
<box key={row.id} style={{ flexDirection: "column" }}>
|
|
357
|
+
{header ? (
|
|
358
|
+
<box style={{ flexDirection: "column", marginBottom: 1, marginTop: i === 0 ? 0 : 1 }}>
|
|
359
|
+
<SectionLabel>{header}</SectionLabel>
|
|
360
|
+
</box>
|
|
361
|
+
) : null}
|
|
362
|
+
{/* Cursor glyph moves (content change) — see nav comment. */}
|
|
363
|
+
<text>
|
|
364
|
+
<span style={{ fg: theme.accent }}>{i === cursor ? "▍" : " "}</span>
|
|
365
|
+
<span style={{ fg: row.enabled ? theme.ok : theme.faint }}>
|
|
366
|
+
{row.enabled ? " on " : " off "}
|
|
367
|
+
</span>
|
|
368
|
+
<span style={{ fg: i === cursor ? theme.fg : theme.muted }}>
|
|
369
|
+
{row.label.padEnd(28)}
|
|
370
|
+
</span>
|
|
371
|
+
<span style={{ fg: theme.faint }}>{row.detail}</span>
|
|
372
|
+
</text>
|
|
373
|
+
</box>
|
|
374
|
+
)
|
|
375
|
+
})}
|
|
376
|
+
</scrollbox>
|
|
377
|
+
<text style={{ fg: theme.faint }}>
|
|
378
|
+
changes write {snapshot.config.path} and re-sync agents
|
|
379
|
+
</text>
|
|
380
|
+
</box>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── App shell ───────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
function App() {
|
|
387
|
+
const [{ snapshot, error }, setData] = useState<{
|
|
388
|
+
snapshot: StatsSnapshot | null
|
|
389
|
+
error: string | null
|
|
390
|
+
}>({ snapshot: null, error: null })
|
|
391
|
+
const [loading, setLoading] = useState(true)
|
|
392
|
+
const [tab, setTab] = useState<Tab>("overview")
|
|
393
|
+
const [splash, setSplash] = useState(true)
|
|
394
|
+
const [showAllChecks, setShowAllChecks] = useState(false)
|
|
395
|
+
const { width } = useTerminalDimensions()
|
|
396
|
+
|
|
397
|
+
const refresh = useCallback(() => {
|
|
398
|
+
setLoading(true)
|
|
399
|
+
void loadSnapshotAsync().then((data) => {
|
|
400
|
+
setData(data)
|
|
401
|
+
setLoading(false)
|
|
402
|
+
})
|
|
403
|
+
}, [])
|
|
404
|
+
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
refresh()
|
|
407
|
+
}, [refresh])
|
|
408
|
+
|
|
409
|
+
useKeyboard((key) => {
|
|
410
|
+
// Always honor quit, even on the splash/loading screen.
|
|
411
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) process.exit(0)
|
|
412
|
+
if (splash) {
|
|
413
|
+
if (!loading) setSplash(false)
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
if (key.name === "r") refresh()
|
|
417
|
+
if (key.name === "a") setShowAllChecks((s) => !s)
|
|
418
|
+
if (key.name === "left") setTab((t) => TABS[(TABS.indexOf(t) + TABS.length - 1) % TABS.length])
|
|
419
|
+
if (key.name === "right" || key.name === "tab") setTab((t) => TABS[(TABS.indexOf(t) + 1) % TABS.length])
|
|
420
|
+
const digitText = /^[1-9]$/.test(key.name ?? "") ? key.name : (key.sequence ?? "")
|
|
421
|
+
const digit = Number.parseInt(digitText ?? "", 10)
|
|
422
|
+
if (digit >= 1 && digit <= TABS.length) setTab(TABS[digit - 1])
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
if (splash) {
|
|
426
|
+
return <Splash version={snapshot?.version ?? null} loading={loading} />
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const contentWidth = Math.max(40, Math.min(width - 6, 100))
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<box
|
|
433
|
+
style={{
|
|
434
|
+
backgroundColor: theme.bg,
|
|
435
|
+
flexDirection: "column",
|
|
436
|
+
flexGrow: 1,
|
|
437
|
+
paddingTop: 1,
|
|
438
|
+
paddingBottom: 1,
|
|
439
|
+
paddingLeft: 3,
|
|
440
|
+
paddingRight: 3,
|
|
441
|
+
gap: 1,
|
|
442
|
+
}}
|
|
443
|
+
>
|
|
444
|
+
{/* Masthead */}
|
|
445
|
+
<text>
|
|
446
|
+
<span style={{ fg: theme.accent }}>oh</span>
|
|
447
|
+
<span style={{ fg: theme.faint }}>-</span>
|
|
448
|
+
<span style={{ fg: theme.gold }}>my</span>
|
|
449
|
+
<span style={{ fg: theme.faint }}>-</span>
|
|
450
|
+
<span style={{ fg: theme.fg }}>claudecode</span>
|
|
451
|
+
<span style={{ fg: theme.faint }}> v{snapshot?.version ?? "?"}</span>
|
|
452
|
+
</text>
|
|
453
|
+
|
|
454
|
+
{/* Nav + body live in one subtree keyed by view state: opentui 0.4
|
|
455
|
+
repaints freshly-mounted nodes reliably but can blank rows on
|
|
456
|
+
in-place text updates, so view changes remount rather than patch. */}
|
|
457
|
+
<box
|
|
458
|
+
key={`${tab}:${showAllChecks}:${snapshot?.generatedAt ?? "-"}`}
|
|
459
|
+
style={{ flexDirection: "column", gap: 1, flexGrow: 1 }}
|
|
460
|
+
>
|
|
461
|
+
<text>
|
|
462
|
+
<span style={{ fg: theme.dim }}>
|
|
463
|
+
{TABS.slice(0, TABS.indexOf(tab))
|
|
464
|
+
.map((t) => ` ${t} `)
|
|
465
|
+
.join(" ")}
|
|
466
|
+
</span>
|
|
467
|
+
<span style={{ fg: theme.accent }}>{` ▸ ${tab} `}</span>
|
|
468
|
+
<span style={{ fg: theme.dim }}>
|
|
469
|
+
{TABS.slice(TABS.indexOf(tab) + 1)
|
|
470
|
+
.map((t) => ` ${t} `)
|
|
471
|
+
.join(" ")}
|
|
472
|
+
</span>
|
|
473
|
+
</text>
|
|
474
|
+
<Rule width={contentWidth} />
|
|
475
|
+
|
|
476
|
+
{snapshot ? (
|
|
477
|
+
tab === "overview" ? (
|
|
478
|
+
<OverviewTab snapshot={snapshot} />
|
|
479
|
+
) : tab === "doctor" ? (
|
|
480
|
+
<DoctorTab snapshot={snapshot} showAll={showAllChecks} />
|
|
481
|
+
) : tab === "agents" ? (
|
|
482
|
+
<AgentsTab snapshot={snapshot} />
|
|
483
|
+
) : (
|
|
484
|
+
<SettingsTab snapshot={snapshot} refresh={refresh} />
|
|
485
|
+
)
|
|
486
|
+
) : (
|
|
487
|
+
<box style={{ flexDirection: "column", gap: 1, flexGrow: 1 }}>
|
|
488
|
+
<text style={{ fg: theme.err }}>couldn't load stats</text>
|
|
489
|
+
<text style={{ fg: theme.dim }}>{error ?? "unknown error"}</text>
|
|
490
|
+
<text style={{ fg: theme.faint }}>r retry · q quit</text>
|
|
491
|
+
</box>
|
|
492
|
+
)}
|
|
493
|
+
</box>
|
|
494
|
+
|
|
495
|
+
{/* Footer */}
|
|
496
|
+
<text style={{ fg: theme.faint }}>
|
|
497
|
+
←→ tabs ↑↓ select enter toggle r refresh q quit
|
|
498
|
+
</text>
|
|
499
|
+
</box>
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: true })
|
|
504
|
+
createRoot(renderer).render(<App />)
|
package/tui/data.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { spawnSync, spawn } from "node:child_process"
|
|
2
|
+
import * as fs from "node:fs"
|
|
3
|
+
import * as os from "node:os"
|
|
4
|
+
import * as path from "node:path"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Data layer for the dashboard TUI. The TUI is a pure renderer — every
|
|
8
|
+
* read goes through `oh-my-claudecode stats --json` (executed via the
|
|
9
|
+
* node CLI, so Bun never loads the archive or native modules), and every
|
|
10
|
+
* settings write edits ~/.claude/oh-my-claudecode.jsonc followed by a
|
|
11
|
+
* background `sync` so the change takes effect.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface StatsSnapshot {
|
|
15
|
+
version: string
|
|
16
|
+
generatedAt: string
|
|
17
|
+
archive: {
|
|
18
|
+
bound: boolean
|
|
19
|
+
memories: number
|
|
20
|
+
observations: number
|
|
21
|
+
sessions: number
|
|
22
|
+
pendingCompression: number
|
|
23
|
+
diskBytes: number
|
|
24
|
+
modelCacheBytes: number
|
|
25
|
+
embeddingProvider: string | null
|
|
26
|
+
embeddingDimensions: number | null
|
|
27
|
+
bindPhases: Record<string, string>
|
|
28
|
+
}
|
|
29
|
+
capture: {
|
|
30
|
+
lastSuccessEpoch: number | null
|
|
31
|
+
cronInstalled: boolean
|
|
32
|
+
retentionDays: number | null
|
|
33
|
+
}
|
|
34
|
+
agents: Array<{ name: string; model: string; installed: boolean; category: string; cost: string }>
|
|
35
|
+
hooks: Array<{ name: string; event: string; installed: boolean }>
|
|
36
|
+
config: {
|
|
37
|
+
path: string
|
|
38
|
+
exists: boolean
|
|
39
|
+
disabledAgents: string[]
|
|
40
|
+
disabledHooks: string[]
|
|
41
|
+
webResearch: boolean
|
|
42
|
+
typeSafety: boolean
|
|
43
|
+
frontendDesign: boolean
|
|
44
|
+
}
|
|
45
|
+
doctor: Array<{ name: string; status: "ok" | "warn" | "error"; message: string }>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PACKAGE_ROOT = path.resolve(new URL(".", import.meta.url).pathname, "..")
|
|
49
|
+
|
|
50
|
+
function cliPath(): string {
|
|
51
|
+
return process.env.OMC_CLI ?? path.join(PACKAGE_ROOT, "dist", "cli", "index.js")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadSnapshot(): { snapshot: StatsSnapshot | null; error: string | null } {
|
|
55
|
+
const result = spawnSync("node", [cliPath(), "stats", "--json"], {
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
timeout: 60_000,
|
|
58
|
+
})
|
|
59
|
+
if (result.status !== 0 || !result.stdout) {
|
|
60
|
+
return {
|
|
61
|
+
snapshot: null,
|
|
62
|
+
error:
|
|
63
|
+
result.stderr?.trim().split("\n").slice(-3).join(" ") ||
|
|
64
|
+
`stats exited with ${result.status}`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return { snapshot: JSON.parse(result.stdout) as StatsSnapshot, error: null }
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { snapshot: null, error: `bad stats JSON: ${String(err)}` }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Async variant — the app must NOT block its mount on stats (collection
|
|
76
|
+
* boots the archive and probes the embedding model, several seconds), or
|
|
77
|
+
* early keypresses land before the keyboard subscription exists.
|
|
78
|
+
*/
|
|
79
|
+
export function loadSnapshotAsync(): Promise<{ snapshot: StatsSnapshot | null; error: string | null }> {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const child = spawn("node", [cliPath(), "stats", "--json"], { stdio: ["ignore", "pipe", "pipe"] })
|
|
82
|
+
let stdout = ""
|
|
83
|
+
let stderr = ""
|
|
84
|
+
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()))
|
|
85
|
+
child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()))
|
|
86
|
+
const timer = setTimeout(() => child.kill(), 60_000)
|
|
87
|
+
child.on("close", (code) => {
|
|
88
|
+
clearTimeout(timer)
|
|
89
|
+
if (code !== 0 || !stdout) {
|
|
90
|
+
resolve({
|
|
91
|
+
snapshot: null,
|
|
92
|
+
error: stderr.trim().split("\n").slice(-3).join(" ") || `stats exited with ${code}`,
|
|
93
|
+
})
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
resolve({ snapshot: JSON.parse(stdout) as StatsSnapshot, error: null })
|
|
98
|
+
} catch (err) {
|
|
99
|
+
resolve({ snapshot: null, error: `bad stats JSON: ${String(err)}` })
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
child.on("error", (err) => {
|
|
103
|
+
clearTimeout(timer)
|
|
104
|
+
resolve({ snapshot: null, error: String(err) })
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Config mutation ─────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const CONFIG_PATH = path.join(os.homedir(), ".claude", "oh-my-claudecode.jsonc")
|
|
112
|
+
|
|
113
|
+
/** Strip // and /* */ comments well enough to JSON.parse the config. */
|
|
114
|
+
function parseJsonc(content: string): Record<string, unknown> {
|
|
115
|
+
const stripped = content
|
|
116
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
117
|
+
.replace(/^\s*\/\/.*$/gm, "")
|
|
118
|
+
.replace(/,(\s*[}\]])/g, "$1")
|
|
119
|
+
const parsed = JSON.parse(stripped || "{}") as unknown
|
|
120
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
121
|
+
? (parsed as Record<string, unknown>)
|
|
122
|
+
: {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readConfig(): Record<string, unknown> {
|
|
126
|
+
try {
|
|
127
|
+
return parseJsonc(fs.readFileSync(CONFIG_PATH, "utf-8"))
|
|
128
|
+
} catch {
|
|
129
|
+
return {}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function writeConfig(config: Record<string, unknown>): void {
|
|
134
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true })
|
|
135
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Toggle a name in a disabled_* array. Returns the new disabled state. */
|
|
139
|
+
export function toggleDisabled(
|
|
140
|
+
key: "disabled_agents" | "disabled_hooks",
|
|
141
|
+
name: string,
|
|
142
|
+
): boolean {
|
|
143
|
+
const config = readConfig()
|
|
144
|
+
const list = Array.isArray(config[key]) ? (config[key] as string[]) : []
|
|
145
|
+
const isDisabled = list.includes(name)
|
|
146
|
+
config[key] = isDisabled ? list.filter((n) => n !== name) : [...list, name]
|
|
147
|
+
writeConfig(config)
|
|
148
|
+
resyncAgents()
|
|
149
|
+
return !isDisabled
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Toggle one of the pillar feature flags. Returns the new enabled state. */
|
|
153
|
+
export function togglePillar(
|
|
154
|
+
key: "web_research" | "type_safety" | "frontend_design",
|
|
155
|
+
): boolean {
|
|
156
|
+
const config = readConfig()
|
|
157
|
+
const section =
|
|
158
|
+
config[key] && typeof config[key] === "object"
|
|
159
|
+
? (config[key] as Record<string, unknown>)
|
|
160
|
+
: {}
|
|
161
|
+
const enabled = section.enabled !== false
|
|
162
|
+
config[key] = { ...section, enabled: !enabled }
|
|
163
|
+
writeConfig(config)
|
|
164
|
+
return !enabled
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Re-render agents in the background after a config change. */
|
|
168
|
+
function resyncAgents(): void {
|
|
169
|
+
try {
|
|
170
|
+
const child = spawn("node", [cliPath(), "sync", "--quiet"], {
|
|
171
|
+
detached: true,
|
|
172
|
+
stdio: "ignore",
|
|
173
|
+
})
|
|
174
|
+
child.unref()
|
|
175
|
+
} catch {
|
|
176
|
+
/* next session's agent-sync hook covers it */
|
|
177
|
+
}
|
|
178
|
+
}
|