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.
Files changed (131) hide show
  1. package/README.md +170 -68
  2. package/commands/cthulhu.md +9 -1
  3. package/commands/invoke-shub.md +8 -2
  4. package/commands/shoggoth.md +15 -25
  5. package/commands/yog-sothoth.md +18 -25
  6. package/dist/agents/render.d.ts +11 -0
  7. package/dist/agents/render.d.ts.map +1 -0
  8. package/dist/agents/render.js +69 -0
  9. package/dist/agents/render.js.map +1 -0
  10. package/dist/cli/dashboard.d.ts +12 -0
  11. package/dist/cli/dashboard.d.ts.map +1 -0
  12. package/dist/cli/dashboard.js +58 -0
  13. package/dist/cli/dashboard.js.map +1 -0
  14. package/dist/cli/doctor.d.ts +11 -0
  15. package/dist/cli/doctor.d.ts.map +1 -1
  16. package/dist/cli/doctor.js +163 -9
  17. package/dist/cli/doctor.js.map +1 -1
  18. package/dist/cli/index.js +72 -3
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/install.d.ts +6 -0
  21. package/dist/cli/install.d.ts.map +1 -1
  22. package/dist/cli/install.js +211 -44
  23. package/dist/cli/install.js.map +1 -1
  24. package/dist/cli/lint.d.ts +26 -0
  25. package/dist/cli/lint.d.ts.map +1 -0
  26. package/dist/cli/lint.js +86 -0
  27. package/dist/cli/lint.js.map +1 -0
  28. package/dist/cli/stats.d.ts +56 -0
  29. package/dist/cli/stats.d.ts.map +1 -0
  30. package/dist/cli/stats.js +197 -0
  31. package/dist/cli/stats.js.map +1 -0
  32. package/dist/cli/sync.d.ts +44 -0
  33. package/dist/cli/sync.d.ts.map +1 -0
  34. package/dist/cli/sync.js +154 -0
  35. package/dist/cli/sync.js.map +1 -0
  36. package/dist/config/schema.d.ts +337 -331
  37. package/dist/config/schema.d.ts.map +1 -1
  38. package/dist/config/schema.js +14 -10
  39. package/dist/config/schema.js.map +1 -1
  40. package/dist/features/block-summarizer/index.js +1 -0
  41. package/dist/features/block-summarizer/index.js.map +1 -1
  42. package/dist/features/yith-archive/config.d.ts.map +1 -1
  43. package/dist/features/yith-archive/config.js +11 -15
  44. package/dist/features/yith-archive/config.js.map +1 -1
  45. package/dist/features/yith-archive/eval/schemas.d.ts +2 -2
  46. package/dist/features/yith-archive/functions/migrate.d.ts.map +1 -1
  47. package/dist/features/yith-archive/functions/migrate.js +7 -3
  48. package/dist/features/yith-archive/functions/migrate.js.map +1 -1
  49. package/dist/features/yith-archive/functions/opencode-import.d.ts.map +1 -1
  50. package/dist/features/yith-archive/functions/opencode-import.js +54 -27
  51. package/dist/features/yith-archive/functions/opencode-import.js.map +1 -1
  52. package/dist/features/yith-archive/functions/smart-search.js.map +1 -1
  53. package/dist/features/yith-archive/functions/temporal-graph.d.ts.map +1 -1
  54. package/dist/features/yith-archive/functions/temporal-graph.js +1 -0
  55. package/dist/features/yith-archive/functions/temporal-graph.js.map +1 -1
  56. package/dist/features/yith-archive/providers/embedding/local.d.ts +17 -6
  57. package/dist/features/yith-archive/providers/embedding/local.d.ts.map +1 -1
  58. package/dist/features/yith-archive/providers/embedding/local.js +32 -14
  59. package/dist/features/yith-archive/providers/embedding/local.js.map +1 -1
  60. package/dist/features/yith-archive/state/fake-sdk.d.ts.map +1 -1
  61. package/dist/features/yith-archive/state/fake-sdk.js.map +1 -1
  62. package/dist/features/yith-archive/state/reranker.d.ts.map +1 -1
  63. package/dist/features/yith-archive/state/reranker.js +9 -2
  64. package/dist/features/yith-archive/state/reranker.js.map +1 -1
  65. package/dist/features/yith-archive/state/vector-index.d.ts.map +1 -1
  66. package/dist/features/yith-archive/state/vector-index.js +1 -0
  67. package/dist/features/yith-archive/state/vector-index.js.map +1 -1
  68. package/dist/hooks/agent-sync.d.ts +16 -0
  69. package/dist/hooks/agent-sync.d.ts.map +1 -0
  70. package/dist/hooks/agent-sync.js +42 -0
  71. package/dist/hooks/agent-sync.js.map +1 -0
  72. package/dist/hooks/comment-checker.d.ts +1 -1
  73. package/dist/hooks/comment-checker.d.ts.map +1 -1
  74. package/dist/hooks/comment-checker.js +10 -0
  75. package/dist/hooks/comment-checker.js.map +1 -1
  76. package/dist/hooks/cthulhu-auto.d.ts +1 -1
  77. package/dist/hooks/cthulhu-auto.d.ts.map +1 -1
  78. package/dist/hooks/cthulhu-auto.js +77 -8
  79. package/dist/hooks/cthulhu-auto.js.map +1 -1
  80. package/dist/hooks/cthulhu-preflight.d.ts.map +1 -1
  81. package/dist/hooks/cthulhu-preflight.js +6 -5
  82. package/dist/hooks/cthulhu-preflight.js.map +1 -1
  83. package/dist/hooks/design-detector-hook.d.ts +6 -5
  84. package/dist/hooks/design-detector-hook.d.ts.map +1 -1
  85. package/dist/hooks/design-detector-hook.js +27 -8
  86. package/dist/hooks/design-detector-hook.js.map +1 -1
  87. package/dist/hooks/index.d.ts +2 -1
  88. package/dist/hooks/index.d.ts.map +1 -1
  89. package/dist/hooks/index.js +43 -0
  90. package/dist/hooks/index.js.map +1 -1
  91. package/dist/hooks/web-research-hook.d.ts +6 -5
  92. package/dist/hooks/web-research-hook.d.ts.map +1 -1
  93. package/dist/hooks/web-research-hook.js +30 -9
  94. package/dist/hooks/web-research-hook.js.map +1 -1
  95. package/dist/hooks/write-guard.d.ts +1 -1
  96. package/dist/hooks/write-guard.d.ts.map +1 -1
  97. package/dist/hooks/write-guard.js +10 -0
  98. package/dist/hooks/write-guard.js.map +1 -1
  99. package/dist/hooks/yith-capture.d.ts +9 -3
  100. package/dist/hooks/yith-capture.d.ts.map +1 -1
  101. package/dist/hooks/yith-capture.js +43 -14
  102. package/dist/hooks/yith-capture.js.map +1 -1
  103. package/dist/index.d.ts +12 -5
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +13 -5
  106. package/dist/index.js.map +1 -1
  107. package/dist/linters/type-safety-ast.d.ts.map +1 -1
  108. package/dist/linters/type-safety-ast.js +42 -24
  109. package/dist/linters/type-safety-ast.js.map +1 -1
  110. package/dist/shared/ascii-logo.d.ts +24 -0
  111. package/dist/shared/ascii-logo.d.ts.map +1 -0
  112. package/dist/shared/ascii-logo.js +77 -0
  113. package/dist/shared/ascii-logo.js.map +1 -0
  114. package/dist/shared/model-resolution.d.ts +19 -6
  115. package/dist/shared/model-resolution.d.ts.map +1 -1
  116. package/dist/shared/model-resolution.js +25 -12
  117. package/dist/shared/model-resolution.js.map +1 -1
  118. package/package.json +9 -6
  119. package/tui/dashboard.tsx +504 -0
  120. package/tui/data.ts +178 -0
  121. package/tui/theme.ts +51 -0
  122. package/tui/tsconfig.json +15 -0
  123. package/tui/wizard.tsx +219 -0
  124. package/dist/plugin-handlers/config-handler.d.ts +0 -21
  125. package/dist/plugin-handlers/config-handler.d.ts.map +0 -1
  126. package/dist/plugin-handlers/config-handler.js +0 -33
  127. package/dist/plugin-handlers/config-handler.js.map +0 -1
  128. package/dist/plugin-handlers/index.d.ts +0 -2
  129. package/dist/plugin-handlers/index.d.ts.map +0 -1
  130. package/dist/plugin-handlers/index.js +0 -2
  131. 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
+ }