@hoox-sh/hoox-tui 0.2.0

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 (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Shared CLI bridge test double.
8
+ *
9
+ * Installed once from `test-setup.ts` via `mock.module` so every TUI test
10
+ * file gets a full no-op surface without each suite re-mocking the module
11
+ * (and accidentally stripping methods other suites need).
12
+ *
13
+ * Override per-test with:
14
+ * cliBridgeDouble.checkSetup.mockResolvedValueOnce({ ... })
15
+ * or reassign mockImplementation, then call `resetCliBridgeDouble()` in
16
+ * afterEach / beforeEach so defaults return for the next case.
17
+ */
18
+ import { mock } from "bun:test";
19
+ import type { CliResult } from "./types";
20
+
21
+ /** Standard successful CliResult shell. */
22
+ export function okCliResult<T = null>(
23
+ data: T | null = null,
24
+ overrides: Partial<CliResult<T>> = {}
25
+ ): Promise<CliResult<T>> {
26
+ return Promise.resolve({
27
+ success: true,
28
+ exitCode: 0,
29
+ stdout: "",
30
+ stderr: "",
31
+ data: data as T,
32
+ duration: 0,
33
+ command: "hoox (test double)",
34
+ errorType: null,
35
+ ...overrides,
36
+ });
37
+ }
38
+
39
+ /** Standard failed CliResult shell. */
40
+ export function failCliResult(
41
+ stderr = "test failure",
42
+ overrides: Partial<CliResult<null>> = {}
43
+ ): Promise<CliResult<null>> {
44
+ return Promise.resolve({
45
+ success: false,
46
+ exitCode: 1,
47
+ stdout: "",
48
+ stderr,
49
+ data: null,
50
+ duration: 0,
51
+ command: "hoox (test double)",
52
+ errorType: "non-zero-exit",
53
+ ...overrides,
54
+ });
55
+ }
56
+
57
+ function applyDefaults(): void {
58
+ cliBridgeDouble.resolveBinary.mockImplementation(
59
+ async () => "/usr/local/bin/hoox"
60
+ );
61
+ cliBridgeDouble.invalidateCache.mockImplementation(() => {});
62
+ cliBridgeDouble.onError.mockImplementation(() => () => {});
63
+ cliBridgeDouble.abort.mockImplementation(() => {});
64
+ cliBridgeDouble.dispose.mockImplementation(() => {});
65
+
66
+ const ok = () => okCliResult();
67
+ cliBridgeDouble.deployAll.mockImplementation(ok);
68
+ cliBridgeDouble.deployWorker.mockImplementation(ok);
69
+ cliBridgeDouble.checkHealth.mockImplementation(ok);
70
+ cliBridgeDouble.checkHealthFix.mockImplementation(ok);
71
+ cliBridgeDouble.checkFix.mockImplementation(ok);
72
+ cliBridgeDouble.workerLogs.mockImplementation(ok);
73
+ cliBridgeDouble.configShow.mockImplementation(ok);
74
+ cliBridgeDouble.configValidate.mockImplementation(ok);
75
+ cliBridgeDouble.monitorStatus.mockImplementation(ok);
76
+ cliBridgeDouble.rebuild.mockImplementation(ok);
77
+ cliBridgeDouble.repairWorker.mockImplementation(ok);
78
+ cliBridgeDouble.checkSetup.mockImplementation(ok);
79
+ cliBridgeDouble.monitorKillSwitch.mockImplementation(() =>
80
+ okCliResult({ engaged: false })
81
+ );
82
+ cliBridgeDouble.monitorQueueDepth.mockImplementation(() => okCliResult([]));
83
+ cliBridgeDouble.dbQuery.mockImplementation(() =>
84
+ okCliResult({
85
+ columns: [],
86
+ rows: [],
87
+ rowCount: 0,
88
+ executionTimeMs: null,
89
+ meta: null,
90
+ })
91
+ );
92
+ cliBridgeDouble.configKvList.mockImplementation(() =>
93
+ okCliResult({
94
+ keys: [],
95
+ timestamp: new Date().toISOString(),
96
+ namespaceId: null,
97
+ })
98
+ );
99
+ cliBridgeDouble.configKvGet.mockImplementation(() => okCliResult(null));
100
+ cliBridgeDouble.configKvSet.mockImplementation(() => okCliResult(null));
101
+ cliBridgeDouble.configSecretsList.mockImplementation(() =>
102
+ okCliResult({ secrets: [], timestamp: new Date().toISOString() })
103
+ );
104
+ cliBridgeDouble.agentHealthCheck.mockImplementation(() =>
105
+ okCliResult({ providers: [], timestamp: new Date().toISOString() })
106
+ );
107
+ cliBridgeDouble.pyneHealthCheck.mockImplementation(() =>
108
+ okCliResult({
109
+ worker: "pyne-worker",
110
+ url: "https://pyne-worker.example/health",
111
+ status: "healthy" as const,
112
+ httpStatus: 200,
113
+ latencyMs: 12,
114
+ timestamp: new Date().toISOString(),
115
+ })
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Mutable double — methods are bun `mock()` fns so tests can
121
+ * `mockResolvedValue` / `mockImplementation` per case.
122
+ */
123
+ export const cliBridgeDouble = {
124
+ resolveBinary: mock(async () => "/usr/local/bin/hoox"),
125
+ invalidateCache: mock(() => {}),
126
+ onError: mock(() => () => {}),
127
+ abort: mock(() => {}),
128
+ dispose: mock(() => {}),
129
+ deployAll: mock(() => okCliResult()),
130
+ deployWorker: mock(() => okCliResult()),
131
+ checkHealth: mock(() => okCliResult()),
132
+ checkHealthFix: mock(() => okCliResult()),
133
+ checkFix: mock(() => okCliResult()),
134
+ workerLogs: mock(() => okCliResult()),
135
+ configShow: mock(() => okCliResult()),
136
+ configValidate: mock(() => okCliResult()),
137
+ monitorStatus: mock(() => okCliResult()),
138
+ rebuild: mock(() => okCliResult()),
139
+ repairWorker: mock(() => okCliResult()),
140
+ checkSetup: mock(() => okCliResult()),
141
+ monitorKillSwitch: mock(() => okCliResult({ engaged: false })),
142
+ monitorQueueDepth: mock(() => okCliResult([])),
143
+ dbQuery: mock(() =>
144
+ okCliResult({
145
+ columns: [],
146
+ rows: [],
147
+ rowCount: 0,
148
+ executionTimeMs: null,
149
+ meta: null,
150
+ })
151
+ ),
152
+ configKvList: mock(() =>
153
+ okCliResult({
154
+ keys: [],
155
+ timestamp: new Date().toISOString(),
156
+ namespaceId: null,
157
+ })
158
+ ),
159
+ configKvGet: mock(() => okCliResult(null)),
160
+ configKvSet: mock(() => okCliResult(null)),
161
+ configSecretsList: mock(() =>
162
+ okCliResult({ secrets: [], timestamp: new Date().toISOString() })
163
+ ),
164
+ agentHealthCheck: mock(() =>
165
+ okCliResult({ providers: [], timestamp: new Date().toISOString() })
166
+ ),
167
+ pyneHealthCheck: mock(() =>
168
+ okCliResult({
169
+ worker: "pyne-worker",
170
+ url: "https://pyne-worker.example/health",
171
+ status: "healthy" as const,
172
+ httpStatus: 200,
173
+ latencyMs: 12,
174
+ timestamp: new Date().toISOString(),
175
+ })
176
+ ),
177
+ };
178
+
179
+ applyDefaults();
180
+
181
+ /** Clear call history and restore default implementations. */
182
+ export function resetCliBridgeDouble(): void {
183
+ for (const value of Object.values(cliBridgeDouble)) {
184
+ if (value && typeof value === "function" && "mockClear" in value) {
185
+ (value as ReturnType<typeof mock>).mockClear();
186
+ }
187
+ }
188
+ applyDefaults();
189
+ }
190
+
191
+ /** Factory for mock.module — re-exports real standalone + index helpers. */
192
+ export async function createCliBridgeModuleMock(): Promise<
193
+ Record<string, unknown>
194
+ > {
195
+ // Load real module exports *before* mock.module is installed so tests that
196
+ // import tryParseJsonLoose / sanitizeCliArgsForLog still get the real fns.
197
+ const realBridge = await import("./services/cli-bridge/index");
198
+ const standalone = await import("./services/cli-bridge/standalone");
199
+ const types = await import("./services/cli-bridge/types");
200
+ return {
201
+ cliBridge: cliBridgeDouble,
202
+ validateReadOnlySql: standalone.validateReadOnlySql,
203
+ agentChatStream: standalone.agentChatStream,
204
+ AI_MODEL_OPTIONS: standalone.AI_MODEL_OPTIONS,
205
+ tryParseJsonLoose: realBridge.tryParseJsonLoose,
206
+ sanitizeCliArgsForLog: realBridge.sanitizeCliArgsForLog,
207
+ // Type re-exports are erased at runtime; keep values if any exist
208
+ ...Object.fromEntries(
209
+ Object.entries(types).filter(([, v]) => typeof v !== "undefined")
210
+ ),
211
+ };
212
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Layout Component Tests — Sidebar + StatusBar structure aligned with
8
+ * view-registry (single source of truth for nav items).
9
+ */
10
+ import { describe, it, expect, beforeEach } from "bun:test";
11
+ import { useUIStore } from "@hoox-sh/hoox-shared/stores/ui-store";
12
+ import { useServiceStore } from "@hoox-sh/hoox-shared/stores/service-store";
13
+ import type { ViewId } from "@hoox-sh/hoox-shared";
14
+ import {
15
+ SIDEBAR_ITEMS,
16
+ getViewShortcutMap,
17
+ getCtrlAltViewMap,
18
+ REGISTERED_VIEW_IDS,
19
+ } from "../../view-registry";
20
+ import { SIDEBAR_WIDTH } from "./sidebar";
21
+
22
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
23
+
24
+ function resetStores() {
25
+ useUIStore.setState({
26
+ activeView: "dashboard",
27
+ sidebarExpanded: true,
28
+ modal: null,
29
+ commandPaletteOpen: false,
30
+ previousView: null,
31
+ });
32
+ useServiceStore.setState({
33
+ workers: [],
34
+ connectionStatus: "offline",
35
+ lastUpdated: 0,
36
+ lastError: null,
37
+ retryCount: 0,
38
+ reconnectDelay: 0,
39
+ disconnectedAt: null,
40
+ });
41
+ }
42
+
43
+ // ─── Test Suite ───────────────────────────────────────────────────────────────
44
+
45
+ describe("Layout", () => {
46
+ beforeEach(() => {
47
+ resetStores();
48
+ });
49
+
50
+ // ── Sidebar ──────────────────────────────────────────────────────────────
51
+
52
+ describe("Sidebar", () => {
53
+ it("lists every registered view", () => {
54
+ expect(SIDEBAR_ITEMS).toHaveLength(REGISTERED_VIEW_IDS.length);
55
+ expect(SIDEBAR_ITEMS.map((i) => i.id)).toEqual(REGISTERED_VIEW_IDS);
56
+ });
57
+
58
+ it("all items have unique view IDs", () => {
59
+ const ids = SIDEBAR_ITEMS.map((i) => i.id);
60
+ expect(new Set(ids).size).toBe(SIDEBAR_ITEMS.length);
61
+ });
62
+
63
+ it("first item is DASHBOARD with shortcut 1", () => {
64
+ expect(SIDEBAR_ITEMS[0]?.label).toBe("DASHBOARD");
65
+ expect(SIDEBAR_ITEMS[0]?.id).toBe("dashboard");
66
+ expect(SIDEBAR_ITEMS[0]?.shortcut).toBe("1");
67
+ });
68
+
69
+ it("includes Ctrl+0 queues and Ctrl+Alt letter views", () => {
70
+ const byId = Object.fromEntries(SIDEBAR_ITEMS.map((i) => [i.id, i]));
71
+ expect(byId["queue-depth"]?.shortcut).toBe("0");
72
+ expect(byId["kv-viewer"]?.shortcut).toBe("^K");
73
+ expect(byId["secrets-viewer"]?.shortcut).toBe("^S");
74
+ });
75
+
76
+ it("sidebar shows all items when expanded", () => {
77
+ expect(useUIStore.getState().sidebarExpanded).toBe(true);
78
+ });
79
+
80
+ it("sidebar collapses via store", () => {
81
+ useUIStore.getState().toggleSidebar();
82
+ expect(useUIStore.getState().sidebarExpanded).toBe(false);
83
+ });
84
+
85
+ it("setView updates activeView correctly for each sidebar item", () => {
86
+ for (const item of SIDEBAR_ITEMS) {
87
+ useUIStore.getState().setView(item.id);
88
+ expect(useUIStore.getState().activeView).toBe(item.id);
89
+ }
90
+ });
91
+
92
+ it("active view is highlighted via accent color indicator", () => {
93
+ expect(useUIStore.getState().activeView).toBe("dashboard");
94
+ useUIStore.getState().setView("trade-monitor");
95
+ expect(useUIStore.getState().activeView).toBe("trade-monitor");
96
+ });
97
+
98
+ it("sidebar toggle preserves active view", () => {
99
+ useUIStore.getState().setView("workers");
100
+ useUIStore.getState().toggleSidebar();
101
+ expect(useUIStore.getState().activeView).toBe("workers");
102
+ expect(useUIStore.getState().sidebarExpanded).toBe(false);
103
+ });
104
+
105
+ it("uses fixed width of 24 columns", () => {
106
+ expect(SIDEBAR_WIDTH).toBe(24);
107
+ });
108
+ });
109
+
110
+ // ── StatusBar ────────────────────────────────────────────────────────────
111
+
112
+ describe("StatusBar", () => {
113
+ it("shows CONNECTED when connection is healthy", () => {
114
+ useServiceStore.setState({ connectionStatus: "connected" });
115
+ expect(useServiceStore.getState().connectionStatus).toBe("connected");
116
+ });
117
+
118
+ it("shows POLLING when in polling state", () => {
119
+ useServiceStore.setState({ connectionStatus: "polling" });
120
+ expect(useServiceStore.getState().connectionStatus).toBe("polling");
121
+ });
122
+
123
+ it("shows RECONNECTING when retrying", () => {
124
+ useServiceStore.setState({ connectionStatus: "reconnecting" });
125
+ expect(useServiceStore.getState().connectionStatus).toBe("reconnecting");
126
+ });
127
+
128
+ it("shows OFFLINE when disconnected", () => {
129
+ useServiceStore.setState({ connectionStatus: "offline" });
130
+ expect(useServiceStore.getState().connectionStatus).toBe("offline");
131
+ });
132
+
133
+ it("shows retry count during reconnecting", () => {
134
+ useServiceStore.setState({
135
+ connectionStatus: "reconnecting",
136
+ retryCount: 3,
137
+ reconnectDelay: 4000,
138
+ });
139
+ expect(useServiceStore.getState().retryCount).toBe(3);
140
+ expect(useServiceStore.getState().reconnectDelay).toBe(4000);
141
+ });
142
+
143
+ it("shows last updated timestamp when data is available", () => {
144
+ useServiceStore.setState({ lastUpdated: Date.now() });
145
+ expect(useServiceStore.getState().lastUpdated).toBeGreaterThan(0);
146
+ });
147
+
148
+ it("shows error message when connection is lost", () => {
149
+ useServiceStore.setState({
150
+ connectionStatus: "reconnecting",
151
+ lastError: "DNS resolution failed",
152
+ });
153
+ expect(useServiceStore.getState().lastError).toContain(
154
+ "DNS resolution failed"
155
+ );
156
+ });
157
+
158
+ it("shows keyboard hints in status bar", () => {
159
+ const hints = ["Ctrl+P", "Ctrl+B", "Ctrl+Q"];
160
+ expect(hints).toHaveLength(3);
161
+ });
162
+ });
163
+
164
+ // ── View Switching ───────────────────────────────────────────────────────
165
+
166
+ describe("view switching", () => {
167
+ it("Ctrl digit map covers dashboard…queue-depth", () => {
168
+ const map = getViewShortcutMap();
169
+ expect(map["1"]).toBe("dashboard");
170
+ expect(map["2"]).toBe("workers");
171
+ expect(map["0"]).toBe("queue-depth");
172
+ for (const view of Object.values(map)) {
173
+ useUIStore.getState().setView(view);
174
+ expect(useUIStore.getState().activeView).toBe(view);
175
+ }
176
+ });
177
+
178
+ it("Ctrl+Alt map covers letter-chord views", () => {
179
+ const map = getCtrlAltViewMap();
180
+ for (const view of Object.values(map) as ViewId[]) {
181
+ useUIStore.getState().setView(view);
182
+ expect(useUIStore.getState().activeView).toBe(view);
183
+ }
184
+ });
185
+
186
+ it("view switching closes command palette", () => {
187
+ useUIStore.getState().openPalette();
188
+ useUIStore.getState().setView("settings");
189
+ expect(useUIStore.getState().commandPaletteOpen).toBe(false);
190
+ });
191
+ });
192
+
193
+ // ── Layout Structure ─────────────────────────────────────────────────────
194
+
195
+ describe("layout structure", () => {
196
+ it("status bar is always 1 row tall (summary line)", () => {
197
+ const STATUSBAR_HEIGHT = 1;
198
+ expect(STATUSBAR_HEIGHT).toBe(1);
199
+ });
200
+
201
+ it("brand header is HOOX", () => {
202
+ expect("HOOX").toBe("HOOX");
203
+ });
204
+ });
205
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /** @jsxImportSource @opentui/react */
7
+
8
+ import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
9
+ import { SIDEBAR_ITEMS } from "../../view-registry";
10
+ import { CoolBrackets, CoolGlyph } from "../shared/cool-brackets";
11
+
12
+ /** Fixed sidebar width (columns). */
13
+ export const SIDEBAR_WIDTH = 24;
14
+
15
+ /**
16
+ * Sidebar — left navigation panel with view links.
17
+ * Items and shortcut hints come from the view registry (single source of truth).
18
+ */
19
+ export function Sidebar() {
20
+ const activeView = useUIStore((s) => s.activeView);
21
+ const sidebarExpanded = useUIStore((s) => s.sidebarExpanded);
22
+ const setView = useUIStore((s) => s.setView);
23
+
24
+ if (!sidebarExpanded) return null;
25
+
26
+ return (
27
+ <box
28
+ flexDirection="column"
29
+ width={SIDEBAR_WIDTH}
30
+ padding={1}
31
+ gap={0}
32
+ border={true}
33
+ borderStyle="single"
34
+ borderColor={Colors.border}
35
+ backgroundColor={Colors.card}
36
+ >
37
+ {/* Brand header — static accent brackets */}
38
+ <CoolBrackets open="┌" close="┐" gap={1}>
39
+ <text fg={Colors.foreground} bold>
40
+ HOOX
41
+ </text>
42
+ </CoolBrackets>
43
+ <text fg={Colors.dim}>─────────────────</text>
44
+
45
+ {/* Navigation items (registry order) */}
46
+ {SIDEBAR_ITEMS.map((item) => {
47
+ const isActive = item.id === activeView;
48
+ return (
49
+ <box flexDirection="row" gap={1} key={item.id}>
50
+ {isActive ? <CoolGlyph char="▸" /> : <text fg={Colors.dim}> </text>}
51
+ <text
52
+ fg={isActive ? Colors.accent : Colors.muted}
53
+ bold={isActive}
54
+ onMouseUp={() => setView(item.id)}
55
+ >
56
+ {item.label}
57
+ </text>
58
+ </box>
59
+ );
60
+ })}
61
+
62
+ {/* Shortcut hints — digits are Ctrl; letter chords are Ctrl+Alt */}
63
+ <box flexGrow={1} />
64
+ <text fg={Colors.dim} dim>
65
+ Ctrl+0-9 · Ctrl+Alt+…
66
+ </text>
67
+ </box>
68
+ );
69
+ }