@nexus-ai-fs/tui 0.9.18

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 (193) hide show
  1. package/README.md +30 -0
  2. package/package.json +48 -0
  3. package/src/app.tsx +349 -0
  4. package/src/index.tsx +137 -0
  5. package/src/opentui-env.d.ts +61 -0
  6. package/src/panels/access/access-panel.tsx +597 -0
  7. package/src/panels/access/alert-list.tsx +77 -0
  8. package/src/panels/access/constraint-creator.tsx +128 -0
  9. package/src/panels/access/constraint-list.tsx +72 -0
  10. package/src/panels/access/credential-list.tsx +68 -0
  11. package/src/panels/access/delegation-chain-view.tsx +110 -0
  12. package/src/panels/access/delegation-completer.tsx +120 -0
  13. package/src/panels/access/delegation-creator.tsx +237 -0
  14. package/src/panels/access/delegation-list.tsx +74 -0
  15. package/src/panels/access/fraud-score-view.tsx +94 -0
  16. package/src/panels/access/manifest-creator.tsx +167 -0
  17. package/src/panels/access/manifest-list.tsx +105 -0
  18. package/src/panels/access/namespace-config-view.tsx +525 -0
  19. package/src/panels/access/permission-checker.tsx +231 -0
  20. package/src/panels/agents/agent-status-view.tsx +196 -0
  21. package/src/panels/agents/agents-panel.tsx +493 -0
  22. package/src/panels/agents/delegation-list.tsx +154 -0
  23. package/src/panels/agents/inbox-view.tsx +96 -0
  24. package/src/panels/agents/trajectories-tab.tsx +40 -0
  25. package/src/panels/api-console/api-console-panel.tsx +189 -0
  26. package/src/panels/api-console/codegen-viewer.tsx +36 -0
  27. package/src/panels/api-console/codegen.ts +112 -0
  28. package/src/panels/api-console/endpoint-list.tsx +57 -0
  29. package/src/panels/api-console/request-builder.tsx +69 -0
  30. package/src/panels/api-console/response-viewer.tsx +54 -0
  31. package/src/panels/connectors/available-tab.tsx +357 -0
  32. package/src/panels/connectors/connector-row.tsx +121 -0
  33. package/src/panels/connectors/connectors-panel.tsx +88 -0
  34. package/src/panels/connectors/error-parser.ts +116 -0
  35. package/src/panels/connectors/mounted-tab.tsx +179 -0
  36. package/src/panels/connectors/skills-tab.tsx +235 -0
  37. package/src/panels/connectors/template-generator.ts +211 -0
  38. package/src/panels/connectors/write-tab.tsx +514 -0
  39. package/src/panels/events/audit-tab.tsx +69 -0
  40. package/src/panels/events/audit-trail.tsx +75 -0
  41. package/src/panels/events/connector-detail.tsx +49 -0
  42. package/src/panels/events/connector-list.tsx +73 -0
  43. package/src/panels/events/connectors-tab.tsx +92 -0
  44. package/src/panels/events/event-replay.tsx +80 -0
  45. package/src/panels/events/events-panel.tsx +414 -0
  46. package/src/panels/events/events-tab.tsx +212 -0
  47. package/src/panels/events/lock-list.tsx +54 -0
  48. package/src/panels/events/locks-tab.tsx +103 -0
  49. package/src/panels/events/mcl-replay.tsx +77 -0
  50. package/src/panels/events/mcl-tab.tsx +83 -0
  51. package/src/panels/events/operations-tab-wrapper.tsx +62 -0
  52. package/src/panels/events/operations-tab.tsx +41 -0
  53. package/src/panels/events/replay-tab.tsx +76 -0
  54. package/src/panels/events/secrets-audit.tsx +64 -0
  55. package/src/panels/events/secrets-tab.tsx +75 -0
  56. package/src/panels/events/subscription-list.tsx +54 -0
  57. package/src/panels/events/subscriptions-tab.tsx +82 -0
  58. package/src/panels/files/file-aspects.tsx +93 -0
  59. package/src/panels/files/file-editor.tsx +160 -0
  60. package/src/panels/files/file-explorer-keybindings.ts +468 -0
  61. package/src/panels/files/file-explorer-panel.tsx +545 -0
  62. package/src/panels/files/file-lineage.tsx +163 -0
  63. package/src/panels/files/file-list-item.tsx +28 -0
  64. package/src/panels/files/file-metadata.tsx +62 -0
  65. package/src/panels/files/file-preview.tsx +108 -0
  66. package/src/panels/files/file-schema.tsx +89 -0
  67. package/src/panels/files/file-tree-node.tsx +44 -0
  68. package/src/panels/files/file-tree.tsx +169 -0
  69. package/src/panels/files/share-links-tab.tsx +33 -0
  70. package/src/panels/files/uploads-tab.tsx +45 -0
  71. package/src/panels/payments/approval-list.tsx +83 -0
  72. package/src/panels/payments/balance-card.tsx +43 -0
  73. package/src/panels/payments/budget-card.tsx +70 -0
  74. package/src/panels/payments/payments-panel.tsx +451 -0
  75. package/src/panels/payments/policy-list.tsx +64 -0
  76. package/src/panels/payments/reservation-list.tsx +78 -0
  77. package/src/panels/payments/transaction-list.tsx +103 -0
  78. package/src/panels/payments/transfer-form.tsx +109 -0
  79. package/src/panels/search/column-search.tsx +79 -0
  80. package/src/panels/search/knowledge-view.tsx +100 -0
  81. package/src/panels/search/memory-list.tsx +197 -0
  82. package/src/panels/search/playbook-list.tsx +77 -0
  83. package/src/panels/search/rlm-answer-view.tsx +105 -0
  84. package/src/panels/search/search-panel.tsx +405 -0
  85. package/src/panels/search/search-results.tsx +116 -0
  86. package/src/panels/stack/stack-panel.tsx +474 -0
  87. package/src/panels/versions/conflicts-tab.tsx +59 -0
  88. package/src/panels/versions/entry-detail.tsx +89 -0
  89. package/src/panels/versions/transaction-actions.tsx +34 -0
  90. package/src/panels/versions/transaction-list.tsx +90 -0
  91. package/src/panels/versions/versions-panel.tsx +276 -0
  92. package/src/panels/workflows/execution-list.tsx +102 -0
  93. package/src/panels/workflows/scheduler-view.tsx +135 -0
  94. package/src/panels/workflows/workflow-list.tsx +88 -0
  95. package/src/panels/workflows/workflows-panel.tsx +295 -0
  96. package/src/panels/zones/brick-detail.tsx +136 -0
  97. package/src/panels/zones/brick-list.tsx +56 -0
  98. package/src/panels/zones/cache-tab.tsx +118 -0
  99. package/src/panels/zones/drift-view.tsx +97 -0
  100. package/src/panels/zones/mcp-mounts-tab.tsx +38 -0
  101. package/src/panels/zones/memories-tab.tsx +37 -0
  102. package/src/panels/zones/reindex-status.tsx +84 -0
  103. package/src/panels/zones/workspaces-tab.tsx +37 -0
  104. package/src/panels/zones/zone-list.tsx +73 -0
  105. package/src/panels/zones/zones-panel.tsx +559 -0
  106. package/src/services/command-runner.ts +303 -0
  107. package/src/shared/accessibility-announcements.ts +44 -0
  108. package/src/shared/action-registry.ts +466 -0
  109. package/src/shared/brick-states.ts +91 -0
  110. package/src/shared/command-palette.ts +35 -0
  111. package/src/shared/components/announcement-bar.tsx +30 -0
  112. package/src/shared/components/app-confirm-dialog.tsx +29 -0
  113. package/src/shared/components/breadcrumb.tsx +21 -0
  114. package/src/shared/components/brick-gate.tsx +60 -0
  115. package/src/shared/components/command-output.tsx +95 -0
  116. package/src/shared/components/command-palette.tsx +97 -0
  117. package/src/shared/components/confirm-dialog.tsx +61 -0
  118. package/src/shared/components/diff-viewer.tsx +219 -0
  119. package/src/shared/components/empty-state.tsx +36 -0
  120. package/src/shared/components/error-bar.tsx +60 -0
  121. package/src/shared/components/error-boundary.tsx +53 -0
  122. package/src/shared/components/help-overlay.tsx +99 -0
  123. package/src/shared/components/identity-switcher.tsx +168 -0
  124. package/src/shared/components/loading-indicator.tsx +40 -0
  125. package/src/shared/components/pagination-bar.tsx +68 -0
  126. package/src/shared/components/pre-connection-screen.tsx +398 -0
  127. package/src/shared/components/scroll-indicator.tsx +46 -0
  128. package/src/shared/components/side-nav-utils.ts +68 -0
  129. package/src/shared/components/side-nav.tsx +287 -0
  130. package/src/shared/components/spinner.tsx +26 -0
  131. package/src/shared/components/status-bar.tsx +117 -0
  132. package/src/shared/components/styled-text.tsx +72 -0
  133. package/src/shared/components/sub-tab-bar-utils.ts +100 -0
  134. package/src/shared/components/sub-tab-bar.tsx +40 -0
  135. package/src/shared/components/tab-bar-utils.ts +36 -0
  136. package/src/shared/components/tab-bar.tsx +50 -0
  137. package/src/shared/components/text-input.tsx +73 -0
  138. package/src/shared/components/tooltip.tsx +53 -0
  139. package/src/shared/components/virtual-list.tsx +93 -0
  140. package/src/shared/components/welcome-screen.tsx +111 -0
  141. package/src/shared/hooks/use-api.ts +10 -0
  142. package/src/shared/hooks/use-brick-available.ts +42 -0
  143. package/src/shared/hooks/use-confirm.ts +66 -0
  144. package/src/shared/hooks/use-connection-state.ts +67 -0
  145. package/src/shared/hooks/use-copy.ts +31 -0
  146. package/src/shared/hooks/use-fresh-server.ts +62 -0
  147. package/src/shared/hooks/use-keyboard.ts +58 -0
  148. package/src/shared/hooks/use-list-navigation.ts +106 -0
  149. package/src/shared/hooks/use-swr.ts +117 -0
  150. package/src/shared/hooks/use-tab-fallback.ts +32 -0
  151. package/src/shared/hooks/use-text-input.ts +113 -0
  152. package/src/shared/hooks/use-visible-tabs.ts +61 -0
  153. package/src/shared/lib/circular-buffer.ts +82 -0
  154. package/src/shared/lib/clipboard.ts +14 -0
  155. package/src/shared/nav-items.ts +73 -0
  156. package/src/shared/navigation.ts +110 -0
  157. package/src/shared/status-breadcrumb.ts +74 -0
  158. package/src/shared/syntax-style.ts +3 -0
  159. package/src/shared/tab-visibility.ts +15 -0
  160. package/src/shared/text-style.ts +23 -0
  161. package/src/shared/theme.ts +179 -0
  162. package/src/shared/utils/format-size.ts +20 -0
  163. package/src/shared/utils/format-text.ts +10 -0
  164. package/src/shared/utils/format-time.ts +72 -0
  165. package/src/shared/utils/lru-cache.ts +75 -0
  166. package/src/stores/access-store-types.ts +154 -0
  167. package/src/stores/access-store.ts +674 -0
  168. package/src/stores/agents-store.ts +404 -0
  169. package/src/stores/announcement-store.ts +46 -0
  170. package/src/stores/api-console-store.ts +476 -0
  171. package/src/stores/connectors-store.ts +434 -0
  172. package/src/stores/create-api-action.ts +140 -0
  173. package/src/stores/delegation-store.ts +300 -0
  174. package/src/stores/error-store.ts +102 -0
  175. package/src/stores/events-store.ts +163 -0
  176. package/src/stores/files-store.ts +630 -0
  177. package/src/stores/first-run-store.ts +34 -0
  178. package/src/stores/global-store.ts +255 -0
  179. package/src/stores/infra-store.ts +461 -0
  180. package/src/stores/knowledge-store.ts +358 -0
  181. package/src/stores/lineage-store.ts +126 -0
  182. package/src/stores/mcp-store.ts +147 -0
  183. package/src/stores/payments-store.ts +545 -0
  184. package/src/stores/search-store-types.ts +155 -0
  185. package/src/stores/search-store.ts +656 -0
  186. package/src/stores/share-link-store.ts +151 -0
  187. package/src/stores/stack-store.ts +352 -0
  188. package/src/stores/ui-store.ts +161 -0
  189. package/src/stores/upload-store.ts +131 -0
  190. package/src/stores/versions-store.ts +355 -0
  191. package/src/stores/workflows-store.ts +402 -0
  192. package/src/stores/workspace-store.ts +185 -0
  193. package/src/stores/zones-store.ts +378 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Vertical sidebar navigation replacing the horizontal TabBar.
3
+ *
4
+ * Features:
5
+ * - 12 panels with keyboard shortcuts
6
+ * - 6-state indicators: active (bold + ◂), loading (spinner), error (red ●),
7
+ * unseen (blue ●), stale (dimmed text), healthy (no indicator)
8
+ * - 3 responsive breakpoints: full (>=120), collapsed (80-119), hidden (<80)
9
+ * - Ctrl+B toggles visibility; hidden during zoom
10
+ *
11
+ * @see Issue #3497, #3503
12
+ */
13
+
14
+ import React, { useState, useEffect } from "react";
15
+ import { useTerminalDimensions } from "@opentui/react";
16
+ import { palette } from "../theme.js";
17
+ import { NAV_ITEMS, type NavItem } from "../nav-items.js";
18
+ import { getSideNavMode, STALE_THRESHOLD_MS, type SideNavMode } from "./side-nav-utils.js";
19
+ import type { PanelId } from "../../stores/global-store.js";
20
+ import { useUiStore } from "../../stores/ui-store.js";
21
+
22
+ // Per-panel store imports for indicator selectors (Decision 1A)
23
+ import { useFilesStore } from "../../stores/files-store.js";
24
+ import { useVersionsStore } from "../../stores/versions-store.js";
25
+ import { useAgentsStore } from "../../stores/agents-store.js";
26
+ import { useZonesStore } from "../../stores/zones-store.js";
27
+ import { useAccessStore } from "../../stores/access-store.js";
28
+ import { usePaymentsStore } from "../../stores/payments-store.js";
29
+ import { useSearchStore } from "../../stores/search-store.js";
30
+ import { useWorkflowsStore } from "../../stores/workflows-store.js";
31
+ import { useInfraStore } from "../../stores/infra-store.js";
32
+ import { useApiConsoleStore } from "../../stores/api-console-store.js";
33
+ import { useConnectorsStore } from "../../stores/connectors-store.js";
34
+ import { useStackStore } from "../../stores/stack-store.js";
35
+
36
+ // =============================================================================
37
+ // Spinner frames (same as shared Spinner component)
38
+ // =============================================================================
39
+
40
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
41
+ const SPINNER_INTERVAL_MS = 80;
42
+
43
+ // =============================================================================
44
+ // Per-panel indicator hook (Decision 4A: individual primitive selectors)
45
+ // =============================================================================
46
+
47
+ interface PanelIndicatorMap {
48
+ loading: Readonly<Record<PanelId, boolean>>;
49
+ error: Readonly<Record<PanelId, boolean>>;
50
+ unseen: Readonly<Record<PanelId, boolean>>;
51
+ stale: Readonly<Record<PanelId, boolean>>;
52
+ }
53
+
54
+ /**
55
+ * Subscribes to per-panel loading and error state using individual primitive
56
+ * selectors. Each selector returns a boolean, so Zustand's Object.is check
57
+ * ensures re-renders only fire when the value actually changes.
58
+ *
59
+ * Also derives unseen (new data since last visit) and stale (data not
60
+ * refreshed within STALE_THRESHOLD_MS) from centralized ui-store timestamps.
61
+ */
62
+ function usePanelIndicators(now: number): PanelIndicatorMap {
63
+ // Loading: only 3 stores expose top-level isLoading
64
+ const versionsLoading = useVersionsStore((s) => s.isLoading);
65
+ const zonesLoading = useZonesStore((s) => s.isLoading);
66
+ const consoleLoading = useApiConsoleStore((s) => s.isLoading);
67
+
68
+ // Error: most stores expose top-level error: string | null
69
+ const filesError = useFilesStore((s) => !!s.error);
70
+ const versionsError = useVersionsStore((s) => !!s.error);
71
+ const agentsError = useAgentsStore((s) => !!s.error);
72
+ const zonesError = useZonesStore((s) => !!s.error);
73
+ const accessError = useAccessStore((s) => !!s.error);
74
+ const paymentsError = usePaymentsStore((s) => !!s.error);
75
+ const searchError = useSearchStore((s) => !!s.error);
76
+ const workflowsError = useWorkflowsStore((s) => !!s.error);
77
+ const infraError = useInfraStore((s) => !!s.error);
78
+ const connectorsError = useConnectorsStore((s) => !!s.error);
79
+ const stackError = useStackStore((s) => !!s.error);
80
+
81
+ // Timestamps for unseen/stale derivation
82
+ const dataTs = useUiStore((s) => s.panelDataTimestamps);
83
+ const visitTs = useUiStore((s) => s.panelVisitTimestamps);
84
+
85
+ // Derive unseen and stale per panel
86
+ const unseen = {} as Record<PanelId, boolean>;
87
+ const stale = {} as Record<PanelId, boolean>;
88
+ for (const item of NAV_ITEMS) {
89
+ const lastData = dataTs[item.id] ?? 0;
90
+ const lastVisit = visitTs[item.id] ?? 0;
91
+ unseen[item.id] = lastData > 0 && lastVisit < lastData;
92
+ stale[item.id] = lastData > 0 && now - lastData > STALE_THRESHOLD_MS;
93
+ }
94
+
95
+ return {
96
+ loading: {
97
+ files: false,
98
+ versions: versionsLoading,
99
+ agents: false,
100
+ zones: zonesLoading,
101
+ access: false,
102
+ payments: false,
103
+ search: false,
104
+ workflows: false,
105
+ infrastructure: false,
106
+ console: consoleLoading,
107
+ connectors: false,
108
+ stack: false,
109
+ },
110
+ error: {
111
+ files: filesError,
112
+ versions: versionsError,
113
+ agents: agentsError,
114
+ zones: zonesError,
115
+ access: accessError,
116
+ payments: paymentsError,
117
+ search: searchError,
118
+ workflows: workflowsError,
119
+ infrastructure: infraError,
120
+ console: false, // error is inside ResponseState, not panel-level
121
+ connectors: connectorsError,
122
+ stack: stackError,
123
+ },
124
+ unseen,
125
+ stale,
126
+ };
127
+ }
128
+
129
+ // =============================================================================
130
+ // Component
131
+ // =============================================================================
132
+
133
+ interface SideNavProps {
134
+ readonly activePanel: PanelId;
135
+ readonly visible: boolean;
136
+ }
137
+
138
+ /** Interval (ms) for re-evaluating stale state. */
139
+ const STALE_CHECK_INTERVAL_MS = 10_000;
140
+
141
+ export function SideNav({ activePanel, visible }: SideNavProps): React.ReactNode {
142
+ const { width: columns } = useTerminalDimensions();
143
+ const mode = getSideNavMode(columns);
144
+
145
+ // Periodic tick so stale derivation re-evaluates over time
146
+ const [now, setNow] = useState(Date.now);
147
+ useEffect(() => {
148
+ const timer = setInterval(() => setNow(Date.now()), STALE_CHECK_INTERVAL_MS);
149
+ return () => clearInterval(timer);
150
+ }, []);
151
+
152
+ const indicators = usePanelIndicators(now);
153
+
154
+ // Spinner animation for loading indicators
155
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
156
+ const hasAnyLoading = Object.values(indicators.loading).some(Boolean);
157
+
158
+ useEffect(() => {
159
+ if (!hasAnyLoading) return;
160
+ const timer = setInterval(() => {
161
+ setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
162
+ }, SPINNER_INTERVAL_MS);
163
+ return () => clearInterval(timer);
164
+ }, [hasAnyLoading]);
165
+
166
+ if (!visible || mode === "hidden") return null;
167
+
168
+ return (
169
+ <box
170
+ flexDirection="column"
171
+ width={mode === "full" ? 18 : 6}
172
+ height="100%"
173
+ borderRight
174
+ borderColor={palette.faint}
175
+ >
176
+ {NAV_ITEMS.map((item) => (
177
+ <SideNavItem
178
+ key={item.id}
179
+ item={item}
180
+ isActive={item.id === activePanel}
181
+ isLoading={indicators.loading[item.id]}
182
+ hasError={indicators.error[item.id]}
183
+ isUnseen={indicators.unseen[item.id]}
184
+ isStale={indicators.stale[item.id]}
185
+ mode={mode}
186
+ spinnerFrame={SPINNER_FRAMES[spinnerFrame]!}
187
+ />
188
+ ))}
189
+ </box>
190
+ );
191
+ }
192
+
193
+ // =============================================================================
194
+ // Individual nav item (not memo'd per Decision 4A — 12 text lines is trivial)
195
+ // =============================================================================
196
+
197
+ /** Blue accent for unseen indicators. */
198
+ const UNSEEN_COLOR = "#60A5FA";
199
+
200
+ interface SideNavItemProps {
201
+ readonly item: NavItem;
202
+ readonly isActive: boolean;
203
+ readonly isLoading: boolean;
204
+ readonly hasError: boolean;
205
+ readonly isUnseen: boolean;
206
+ readonly isStale: boolean;
207
+ readonly mode: SideNavMode;
208
+ readonly spinnerFrame: string;
209
+ }
210
+
211
+ function SideNavItem({
212
+ item,
213
+ isActive,
214
+ isLoading,
215
+ hasError,
216
+ isUnseen,
217
+ isStale,
218
+ mode,
219
+ spinnerFrame,
220
+ }: SideNavItemProps): React.ReactNode {
221
+ // Determine the status indicator character
222
+ // Priority: loading > error > unseen (when not active) > active > healthy
223
+ const indicator = isLoading
224
+ ? spinnerFrame
225
+ : hasError
226
+ ? "●"
227
+ : isUnseen && !isActive
228
+ ? "●"
229
+ : isActive
230
+ ? "◂"
231
+ : " ";
232
+
233
+ const indicatorColor = isLoading
234
+ ? palette.accent
235
+ : hasError
236
+ ? palette.error
237
+ : isUnseen && !isActive
238
+ ? UNSEEN_COLOR
239
+ : isActive
240
+ ? palette.accent
241
+ : undefined;
242
+
243
+ // Text color: active > stale (dimmed) > normal muted
244
+ const textColor = isActive
245
+ ? palette.accent
246
+ : isStale && !isUnseen
247
+ ? palette.faint
248
+ : palette.muted;
249
+
250
+ if (mode === "collapsed") {
251
+ // Collapsed: " ◎2◂" — icon + shortcut + indicator
252
+ return (
253
+ <box height={1}>
254
+ <text>
255
+ <span foregroundColor={isActive ? palette.accent : textColor}>
256
+ {` ${item.icon}${item.shortcut}`}
257
+ </span>
258
+ <span foregroundColor={indicatorColor}>{indicator}</span>
259
+ </text>
260
+ </box>
261
+ );
262
+ }
263
+
264
+ // Full: " 2:Versions ◂" — shortcut:label + indicator
265
+ const label = item.fullLabel;
266
+ // Pad label to fill the available width: 18 total - 2 (left " ") - 2 (shortcut + ":") - 1 (indicator) - 1 (right pad) = 12 chars for label
267
+ const paddedLabel = label.padEnd(12);
268
+
269
+ return (
270
+ <box height={1}>
271
+ <text>
272
+ {isActive ? (
273
+ <>
274
+ <span foregroundColor={palette.accent} bold>{` ${item.shortcut}:`}</span>
275
+ <span foregroundColor={palette.accent} bold>{paddedLabel}</span>
276
+ </>
277
+ ) : (
278
+ <>
279
+ <span foregroundColor={textColor}>{` ${item.shortcut}:`}</span>
280
+ <span foregroundColor={textColor}>{paddedLabel}</span>
281
+ </>
282
+ )}
283
+ <span foregroundColor={indicatorColor}>{indicator}</span>
284
+ </text>
285
+ </box>
286
+ );
287
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Animated loading spinner using braille characters.
3
+ */
4
+
5
+ import React, { useState, useEffect } from "react";
6
+
7
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
8
+ const INTERVAL_MS = 80;
9
+
10
+ interface SpinnerProps {
11
+ readonly label?: string;
12
+ }
13
+
14
+ export function Spinner({ label }: SpinnerProps): React.ReactNode {
15
+ const [frame, setFrame] = useState(0);
16
+
17
+ useEffect(() => {
18
+ const timer = setInterval(() => {
19
+ setFrame((prev) => (prev + 1) % FRAMES.length);
20
+ }, INTERVAL_MS);
21
+ return () => clearInterval(timer);
22
+ }, []);
23
+
24
+ const text = label ? `${FRAMES[frame]} ${label}` : FRAMES[frame]!;
25
+ return <text>{text}</text>;
26
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Bottom status bar showing connection state, active identity, and path.
3
+ *
4
+ * Enhanced with semantic colors from theme.ts (Phase A1).
5
+ *
6
+ * Note: OpenTUI does not support nested <text> elements. Use <span> for
7
+ * inline styled segments inside a <text>.
8
+ */
9
+
10
+ import React, { useState, useEffect } from "react";
11
+ import { useGlobalStore } from "../../stores/global-store.js";
12
+ import { useEventsStore } from "../../stores/events-store.js";
13
+ import { connectionColor, palette, statusColor } from "../theme.js";
14
+ import { textStyle } from "../text-style.js";
15
+
16
+ const MIN_COLS = 80;
17
+ const MIN_ROWS = 24;
18
+
19
+ const STATUS_ICONS: Record<string, string> = {
20
+ connected: "●",
21
+ connecting: "◐",
22
+ disconnected: "○",
23
+ error: "✗",
24
+ };
25
+
26
+ export function StatusBar(): React.ReactNode {
27
+ const status = useGlobalStore((s) => s.connectionStatus);
28
+ const config = useGlobalStore((s) => s.config);
29
+ const serverVersion = useGlobalStore((s) => s.serverVersion);
30
+ const zoneId = useGlobalStore((s) => s.zoneId);
31
+ const activePanel = useGlobalStore((s) => s.activePanel);
32
+ const userInfo = useGlobalStore((s) => s.userInfo);
33
+ const enabledBricks = useGlobalStore((s) => s.enabledBricks);
34
+ const profile = useGlobalStore((s) => s.profile);
35
+ const mode = useGlobalStore((s) => s.mode);
36
+
37
+ // Check if events panel has active filters
38
+ const eventFilters = useEventsStore((s) => s.filters);
39
+ const hasActiveFilter = eventFilters.eventType !== null || eventFilters.search !== null;
40
+
41
+ // Terminal size guard (#3245)
42
+ const [terminalTooSmall, setTerminalTooSmall] = useState(false);
43
+ useEffect(() => {
44
+ const check = () => {
45
+ const cols = process.stdout.columns ?? 80;
46
+ const rows = process.stdout.rows ?? 24;
47
+ setTerminalTooSmall(cols < MIN_COLS || rows < MIN_ROWS);
48
+ };
49
+ check();
50
+ process.stdout.on("resize", check);
51
+ return () => { process.stdout.off("resize", check); };
52
+ }, []);
53
+
54
+ const icon = STATUS_ICONS[status] ?? "?";
55
+ const baseUrl = config.baseUrl ?? "localhost:2026";
56
+
57
+ // Build identity segment
58
+ const identityParts: string[] = [];
59
+ if (userInfo?.display_name ?? userInfo?.username) {
60
+ identityParts.push(userInfo!.display_name ?? userInfo!.username!);
61
+ } else if (config.agentId) {
62
+ identityParts.push(`agent:${config.agentId}`);
63
+ }
64
+ if (config.subject && config.subject !== config.agentId) {
65
+ identityParts.push(`sub:${config.subject}`);
66
+ }
67
+
68
+ // Build zone segment
69
+ const zone = config.zoneId ?? zoneId;
70
+
71
+ return (
72
+ <box
73
+ height={1}
74
+ width="100%"
75
+ flexDirection="row"
76
+ >
77
+ <text>
78
+ {terminalTooSmall ? (
79
+ <span style={textStyle({ fg: statusColor.warning })}>{`⚠ Terminal too small (need ${MIN_COLS}×${MIN_ROWS}) `}</span>
80
+ ) : ""}
81
+ <span style={textStyle({ fg: connectionColor[status] })}>{icon}</span>
82
+ <span style={textStyle({ dim: true })}>{` ${status} │ `}</span>
83
+ <span>{baseUrl}</span>
84
+ {identityParts.length > 0 ? (
85
+ <>
86
+ <span style={textStyle({ dim: true })}>{" │ "}</span>
87
+ <span style={textStyle({ fg: statusColor.identity })}>{identityParts.join(", ")}</span>
88
+ </>
89
+ ) : ""}
90
+ {serverVersion ? (
91
+ <>
92
+ <span style={textStyle({ dim: true })}>{" │ "}</span>
93
+ <span style={textStyle({ dim: true })}>{`v${serverVersion}${profile ? `/${profile}` : ""}${mode ? `/${mode}` : ""}`}</span>
94
+ </>
95
+ ) : ""}
96
+ {zone ? (
97
+ <>
98
+ <span style={textStyle({ dim: true })}>{" │ "}</span>
99
+ <span style={textStyle({ fg: statusColor.reference })}>{`zone:${zone}`}</span>
100
+ </>
101
+ ) : ""}
102
+ {enabledBricks.length > 0 ? (
103
+ <>
104
+ <span style={textStyle({ dim: true })}>{" │ "}</span>
105
+ <span style={textStyle({ fg: statusColor.info })}>{`${enabledBricks.length} bricks`}</span>
106
+ </>
107
+ ) : ""}
108
+ <span style={textStyle({ dim: true })}>{" │ "}</span>
109
+ <span style={textStyle({ fg: statusColor.info })}>{`[${activePanel}]`}</span>
110
+ {hasActiveFilter ? (
111
+ <span style={textStyle({ fg: "yellow" })}>{" [filtered]"}</span>
112
+ ) : ""}
113
+ <span style={textStyle({ fg: palette.faint })}>{" │ Ctrl+D:setup ?:help"}</span>
114
+ </text>
115
+ </box>
116
+ );
117
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Render ANSI-styled text in OpenTUI.
3
+ *
4
+ * Takes raw text containing ANSI escape codes and renders it as
5
+ * a series of styled <text> elements using the `anser` library
6
+ * (6M+ weekly downloads, used by Jest/Jupyter).
7
+ *
8
+ * @see Issue #3066 Architecture Decision 1C
9
+ */
10
+
11
+ import React from "react";
12
+ import Anser from "anser";
13
+ import { textStyle } from "../text-style.js";
14
+
15
+ /** Convert anser's "R, G, B" format to "#RRGGBB" hex for terminal compatibility. */
16
+ function rgbToHex(rgb: string): string {
17
+ const parts = rgb.split(",").map((s) => parseInt(s.trim(), 10));
18
+ if (parts.length !== 3 || parts.some(Number.isNaN)) return rgb;
19
+ return `#${parts.map((n) => Math.max(0, Math.min(255, n!)).toString(16).padStart(2, "0")).join("")}`;
20
+ }
21
+
22
+ interface StyledTextProps {
23
+ /** Raw text potentially containing ANSI escape codes. */
24
+ readonly children: string;
25
+ }
26
+
27
+ export function StyledText({ children }: StyledTextProps): React.ReactNode {
28
+ if (!children) return null;
29
+
30
+ const spans = Anser.ansiToJson(children, {
31
+ json: true,
32
+ remove_empty: true,
33
+ });
34
+
35
+ if (spans.length === 0) return null;
36
+
37
+ // Single unstyled span — render directly
38
+ if (spans.length === 1 && !spans[0]!.was_processed) {
39
+ return <text>{spans[0]!.content}</text>;
40
+ }
41
+
42
+ return (
43
+ <text>
44
+ {spans.map((span, i) => {
45
+ const decoration = span.decoration ?? "";
46
+ return (
47
+ <span
48
+ key={i}
49
+ style={textStyle({
50
+ bold: decoration.includes("bold") || undefined,
51
+ dim: decoration.includes("dim") || undefined,
52
+ underline: decoration.includes("underline") || undefined,
53
+ inverse: decoration.includes("reverse") || undefined,
54
+ fg: span.fg ? rgbToHex(span.fg) : undefined,
55
+ bg: span.bg ? rgbToHex(span.bg) : undefined,
56
+ })}
57
+ >
58
+ {span.content}
59
+ </span>
60
+ );
61
+ })}
62
+ </text>
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Strip all ANSI escape sequences from a string, returning plain text.
68
+ * Re-exported from anser for convenience.
69
+ */
70
+ export function stripAnsi(input: string): string {
71
+ return Anser.ansiToText(input);
72
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Pure utility functions for sub-tab bar keyboard cycling.
3
+ *
4
+ * Separated from sub-tab-bar.tsx so tests can import without triggering
5
+ * JSX compilation (matching tab-bar-utils.ts pattern).
6
+ *
7
+ * @see Issue #3498
8
+ */
9
+
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ /** Minimal tab shape consumed by cycling helpers. */
15
+ export interface SubTab {
16
+ readonly id: string;
17
+ readonly label: string;
18
+ }
19
+
20
+ // =============================================================================
21
+ // Cycling helpers
22
+ // =============================================================================
23
+
24
+ /**
25
+ * Cycle forward to the next tab (wraps around).
26
+ *
27
+ * If activeTab is not found in tabs (e.g. brick just disabled it),
28
+ * defaults to the first tab to avoid undefined behavior.
29
+ */
30
+ export function subTabForward<T extends string>(
31
+ tabs: readonly { readonly id: T }[],
32
+ activeTab: T,
33
+ setActiveTab: (tab: T) => void,
34
+ ): void {
35
+ if (tabs.length === 0) return;
36
+ const idx = tabs.findIndex((t) => t.id === activeTab);
37
+ // Guard: if activeTab not in list, jump to first tab
38
+ const nextIdx = idx === -1 ? 0 : (idx + 1) % tabs.length;
39
+ const next = tabs[nextIdx];
40
+ if (next) setActiveTab(next.id);
41
+ }
42
+
43
+ /**
44
+ * Cycle backward to the previous tab (wraps around).
45
+ *
46
+ * Same guard as subTabForward for missing activeTab.
47
+ */
48
+ export function subTabBackward<T extends string>(
49
+ tabs: readonly { readonly id: T }[],
50
+ activeTab: T,
51
+ setActiveTab: (tab: T) => void,
52
+ ): void {
53
+ if (tabs.length === 0) return;
54
+ const idx = tabs.findIndex((t) => t.id === activeTab);
55
+ // Guard: if activeTab not in list, jump to first tab
56
+ const prevIdx = idx === -1 ? 0 : (idx - 1 + tabs.length) % tabs.length;
57
+ const prev = tabs[prevIdx];
58
+ if (prev) setActiveTab(prev.id);
59
+ }
60
+
61
+ // =============================================================================
62
+ // Keyboard binding helper
63
+ // =============================================================================
64
+
65
+ /**
66
+ * Returns a keybinding object with `tab` (forward) and `shift+tab` (backward)
67
+ * entries that panels can spread into useKeyboard.
68
+ *
69
+ * Split-pane panels that need Shift+Tab for focus-toggle can override after
70
+ * spreading: `{ ...subTabCycleBindings(...), "shift+tab": () => toggleFocus("zones") }`
71
+ */
72
+ export function subTabCycleBindings<T extends string>(
73
+ tabs: readonly { readonly id: T }[],
74
+ activeTab: T,
75
+ setActiveTab: (tab: T) => void,
76
+ ): Record<string, () => void> {
77
+ return {
78
+ tab: () => subTabForward(tabs, activeTab, setActiveTab),
79
+ "shift+tab": () => subTabBackward(tabs, activeTab, setActiveTab),
80
+ };
81
+ }
82
+
83
+ // =============================================================================
84
+ // Tab fallback logic
85
+ // =============================================================================
86
+
87
+ /**
88
+ * Determine if the active tab needs to fall back to the first visible tab.
89
+ *
90
+ * Returns the tab ID to switch to, or null if no switch is needed.
91
+ * Pure function — the hook (useTabFallback) wraps this in a useEffect.
92
+ */
93
+ export function tabFallback<T extends string>(
94
+ visibleIds: readonly T[],
95
+ activeTab: T,
96
+ ): T | null {
97
+ if (visibleIds.length === 0) return null;
98
+ if (visibleIds.includes(activeTab)) return null;
99
+ return visibleIds[0]!;
100
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared sub-tab bar component for panel sub-navigation.
3
+ *
4
+ * Render-only — does NOT own keyboard bindings. Panels compose
5
+ * subTabForward/subTabBackward from sub-tab-bar-utils.ts into their
6
+ * own useKeyboard calls.
7
+ *
8
+ * @see Issue #3498
9
+ */
10
+
11
+ import React from "react";
12
+ import type { SubTab } from "./sub-tab-bar-utils.js";
13
+
14
+ export interface SubTabBarProps {
15
+ /** Visible tabs to render (output of useVisibleTabs). */
16
+ readonly tabs: readonly SubTab[];
17
+ /** Currently active tab ID. */
18
+ readonly activeTab: string;
19
+ }
20
+
21
+ /**
22
+ * Renders a horizontal sub-tab bar with bracket notation for the active tab.
23
+ *
24
+ * Example output: `[Zones] Bricks Drift Reindex`
25
+ */
26
+ export function SubTabBar({ tabs, activeTab }: SubTabBarProps): React.ReactNode {
27
+ if (tabs.length === 0) return null;
28
+
29
+ return (
30
+ <box height={1} width="100%">
31
+ <text>
32
+ {tabs
33
+ .map((tab) =>
34
+ tab.id === activeTab ? `[${tab.label}]` : ` ${tab.label} `,
35
+ )
36
+ .join(" ")}
37
+ </text>
38
+ </box>
39
+ );
40
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tab type and pure utility functions for the tab bar (#3243).
3
+ *
4
+ * Separated from tab-bar.tsx so tests can import without triggering
5
+ * JSX compilation (matching the codebase pattern where pure logic
6
+ * is testable without React context).
7
+ */
8
+
9
+ export interface Tab {
10
+ readonly id: string;
11
+ readonly label: string;
12
+ readonly fullLabel?: string;
13
+ readonly shortcut: string;
14
+ }
15
+
16
+ /**
17
+ * Compute the rendered character width of the tab bar for a given label mode.
18
+ *
19
+ * Layout per tab: prefix (2) + shortcut (1) + ":" (1) + label length.
20
+ * Separator between tabs: " │ " (3 chars).
21
+ */
22
+ export function computeTabBarWidth(tabs: readonly Tab[], useFullLabels: boolean): number {
23
+ let width = 0;
24
+ for (let i = 0; i < tabs.length; i++) {
25
+ const tab = tabs[i]!;
26
+ const label = useFullLabels ? (tab.fullLabel ?? tab.label) : tab.label;
27
+ width += 4 + label.length;
28
+ if (i < tabs.length - 1) width += 3;
29
+ }
30
+ return width;
31
+ }
32
+
33
+ /** Returns true when full labels fit within the given column count. */
34
+ export function shouldUseFullLabels(tabs: readonly Tab[], columns: number): boolean {
35
+ return computeTabBarWidth(tabs, true) <= columns;
36
+ }