@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.
- package/README.md +30 -0
- package/package.json +48 -0
- package/src/app.tsx +349 -0
- package/src/index.tsx +137 -0
- package/src/opentui-env.d.ts +61 -0
- package/src/panels/access/access-panel.tsx +597 -0
- package/src/panels/access/alert-list.tsx +77 -0
- package/src/panels/access/constraint-creator.tsx +128 -0
- package/src/panels/access/constraint-list.tsx +72 -0
- package/src/panels/access/credential-list.tsx +68 -0
- package/src/panels/access/delegation-chain-view.tsx +110 -0
- package/src/panels/access/delegation-completer.tsx +120 -0
- package/src/panels/access/delegation-creator.tsx +237 -0
- package/src/panels/access/delegation-list.tsx +74 -0
- package/src/panels/access/fraud-score-view.tsx +94 -0
- package/src/panels/access/manifest-creator.tsx +167 -0
- package/src/panels/access/manifest-list.tsx +105 -0
- package/src/panels/access/namespace-config-view.tsx +525 -0
- package/src/panels/access/permission-checker.tsx +231 -0
- package/src/panels/agents/agent-status-view.tsx +196 -0
- package/src/panels/agents/agents-panel.tsx +493 -0
- package/src/panels/agents/delegation-list.tsx +154 -0
- package/src/panels/agents/inbox-view.tsx +96 -0
- package/src/panels/agents/trajectories-tab.tsx +40 -0
- package/src/panels/api-console/api-console-panel.tsx +189 -0
- package/src/panels/api-console/codegen-viewer.tsx +36 -0
- package/src/panels/api-console/codegen.ts +112 -0
- package/src/panels/api-console/endpoint-list.tsx +57 -0
- package/src/panels/api-console/request-builder.tsx +69 -0
- package/src/panels/api-console/response-viewer.tsx +54 -0
- package/src/panels/connectors/available-tab.tsx +357 -0
- package/src/panels/connectors/connector-row.tsx +121 -0
- package/src/panels/connectors/connectors-panel.tsx +88 -0
- package/src/panels/connectors/error-parser.ts +116 -0
- package/src/panels/connectors/mounted-tab.tsx +179 -0
- package/src/panels/connectors/skills-tab.tsx +235 -0
- package/src/panels/connectors/template-generator.ts +211 -0
- package/src/panels/connectors/write-tab.tsx +514 -0
- package/src/panels/events/audit-tab.tsx +69 -0
- package/src/panels/events/audit-trail.tsx +75 -0
- package/src/panels/events/connector-detail.tsx +49 -0
- package/src/panels/events/connector-list.tsx +73 -0
- package/src/panels/events/connectors-tab.tsx +92 -0
- package/src/panels/events/event-replay.tsx +80 -0
- package/src/panels/events/events-panel.tsx +414 -0
- package/src/panels/events/events-tab.tsx +212 -0
- package/src/panels/events/lock-list.tsx +54 -0
- package/src/panels/events/locks-tab.tsx +103 -0
- package/src/panels/events/mcl-replay.tsx +77 -0
- package/src/panels/events/mcl-tab.tsx +83 -0
- package/src/panels/events/operations-tab-wrapper.tsx +62 -0
- package/src/panels/events/operations-tab.tsx +41 -0
- package/src/panels/events/replay-tab.tsx +76 -0
- package/src/panels/events/secrets-audit.tsx +64 -0
- package/src/panels/events/secrets-tab.tsx +75 -0
- package/src/panels/events/subscription-list.tsx +54 -0
- package/src/panels/events/subscriptions-tab.tsx +82 -0
- package/src/panels/files/file-aspects.tsx +93 -0
- package/src/panels/files/file-editor.tsx +160 -0
- package/src/panels/files/file-explorer-keybindings.ts +468 -0
- package/src/panels/files/file-explorer-panel.tsx +545 -0
- package/src/panels/files/file-lineage.tsx +163 -0
- package/src/panels/files/file-list-item.tsx +28 -0
- package/src/panels/files/file-metadata.tsx +62 -0
- package/src/panels/files/file-preview.tsx +108 -0
- package/src/panels/files/file-schema.tsx +89 -0
- package/src/panels/files/file-tree-node.tsx +44 -0
- package/src/panels/files/file-tree.tsx +169 -0
- package/src/panels/files/share-links-tab.tsx +33 -0
- package/src/panels/files/uploads-tab.tsx +45 -0
- package/src/panels/payments/approval-list.tsx +83 -0
- package/src/panels/payments/balance-card.tsx +43 -0
- package/src/panels/payments/budget-card.tsx +70 -0
- package/src/panels/payments/payments-panel.tsx +451 -0
- package/src/panels/payments/policy-list.tsx +64 -0
- package/src/panels/payments/reservation-list.tsx +78 -0
- package/src/panels/payments/transaction-list.tsx +103 -0
- package/src/panels/payments/transfer-form.tsx +109 -0
- package/src/panels/search/column-search.tsx +79 -0
- package/src/panels/search/knowledge-view.tsx +100 -0
- package/src/panels/search/memory-list.tsx +197 -0
- package/src/panels/search/playbook-list.tsx +77 -0
- package/src/panels/search/rlm-answer-view.tsx +105 -0
- package/src/panels/search/search-panel.tsx +405 -0
- package/src/panels/search/search-results.tsx +116 -0
- package/src/panels/stack/stack-panel.tsx +474 -0
- package/src/panels/versions/conflicts-tab.tsx +59 -0
- package/src/panels/versions/entry-detail.tsx +89 -0
- package/src/panels/versions/transaction-actions.tsx +34 -0
- package/src/panels/versions/transaction-list.tsx +90 -0
- package/src/panels/versions/versions-panel.tsx +276 -0
- package/src/panels/workflows/execution-list.tsx +102 -0
- package/src/panels/workflows/scheduler-view.tsx +135 -0
- package/src/panels/workflows/workflow-list.tsx +88 -0
- package/src/panels/workflows/workflows-panel.tsx +295 -0
- package/src/panels/zones/brick-detail.tsx +136 -0
- package/src/panels/zones/brick-list.tsx +56 -0
- package/src/panels/zones/cache-tab.tsx +118 -0
- package/src/panels/zones/drift-view.tsx +97 -0
- package/src/panels/zones/mcp-mounts-tab.tsx +38 -0
- package/src/panels/zones/memories-tab.tsx +37 -0
- package/src/panels/zones/reindex-status.tsx +84 -0
- package/src/panels/zones/workspaces-tab.tsx +37 -0
- package/src/panels/zones/zone-list.tsx +73 -0
- package/src/panels/zones/zones-panel.tsx +559 -0
- package/src/services/command-runner.ts +303 -0
- package/src/shared/accessibility-announcements.ts +44 -0
- package/src/shared/action-registry.ts +466 -0
- package/src/shared/brick-states.ts +91 -0
- package/src/shared/command-palette.ts +35 -0
- package/src/shared/components/announcement-bar.tsx +30 -0
- package/src/shared/components/app-confirm-dialog.tsx +29 -0
- package/src/shared/components/breadcrumb.tsx +21 -0
- package/src/shared/components/brick-gate.tsx +60 -0
- package/src/shared/components/command-output.tsx +95 -0
- package/src/shared/components/command-palette.tsx +97 -0
- package/src/shared/components/confirm-dialog.tsx +61 -0
- package/src/shared/components/diff-viewer.tsx +219 -0
- package/src/shared/components/empty-state.tsx +36 -0
- package/src/shared/components/error-bar.tsx +60 -0
- package/src/shared/components/error-boundary.tsx +53 -0
- package/src/shared/components/help-overlay.tsx +99 -0
- package/src/shared/components/identity-switcher.tsx +168 -0
- package/src/shared/components/loading-indicator.tsx +40 -0
- package/src/shared/components/pagination-bar.tsx +68 -0
- package/src/shared/components/pre-connection-screen.tsx +398 -0
- package/src/shared/components/scroll-indicator.tsx +46 -0
- package/src/shared/components/side-nav-utils.ts +68 -0
- package/src/shared/components/side-nav.tsx +287 -0
- package/src/shared/components/spinner.tsx +26 -0
- package/src/shared/components/status-bar.tsx +117 -0
- package/src/shared/components/styled-text.tsx +72 -0
- package/src/shared/components/sub-tab-bar-utils.ts +100 -0
- package/src/shared/components/sub-tab-bar.tsx +40 -0
- package/src/shared/components/tab-bar-utils.ts +36 -0
- package/src/shared/components/tab-bar.tsx +50 -0
- package/src/shared/components/text-input.tsx +73 -0
- package/src/shared/components/tooltip.tsx +53 -0
- package/src/shared/components/virtual-list.tsx +93 -0
- package/src/shared/components/welcome-screen.tsx +111 -0
- package/src/shared/hooks/use-api.ts +10 -0
- package/src/shared/hooks/use-brick-available.ts +42 -0
- package/src/shared/hooks/use-confirm.ts +66 -0
- package/src/shared/hooks/use-connection-state.ts +67 -0
- package/src/shared/hooks/use-copy.ts +31 -0
- package/src/shared/hooks/use-fresh-server.ts +62 -0
- package/src/shared/hooks/use-keyboard.ts +58 -0
- package/src/shared/hooks/use-list-navigation.ts +106 -0
- package/src/shared/hooks/use-swr.ts +117 -0
- package/src/shared/hooks/use-tab-fallback.ts +32 -0
- package/src/shared/hooks/use-text-input.ts +113 -0
- package/src/shared/hooks/use-visible-tabs.ts +61 -0
- package/src/shared/lib/circular-buffer.ts +82 -0
- package/src/shared/lib/clipboard.ts +14 -0
- package/src/shared/nav-items.ts +73 -0
- package/src/shared/navigation.ts +110 -0
- package/src/shared/status-breadcrumb.ts +74 -0
- package/src/shared/syntax-style.ts +3 -0
- package/src/shared/tab-visibility.ts +15 -0
- package/src/shared/text-style.ts +23 -0
- package/src/shared/theme.ts +179 -0
- package/src/shared/utils/format-size.ts +20 -0
- package/src/shared/utils/format-text.ts +10 -0
- package/src/shared/utils/format-time.ts +72 -0
- package/src/shared/utils/lru-cache.ts +75 -0
- package/src/stores/access-store-types.ts +154 -0
- package/src/stores/access-store.ts +674 -0
- package/src/stores/agents-store.ts +404 -0
- package/src/stores/announcement-store.ts +46 -0
- package/src/stores/api-console-store.ts +476 -0
- package/src/stores/connectors-store.ts +434 -0
- package/src/stores/create-api-action.ts +140 -0
- package/src/stores/delegation-store.ts +300 -0
- package/src/stores/error-store.ts +102 -0
- package/src/stores/events-store.ts +163 -0
- package/src/stores/files-store.ts +630 -0
- package/src/stores/first-run-store.ts +34 -0
- package/src/stores/global-store.ts +255 -0
- package/src/stores/infra-store.ts +461 -0
- package/src/stores/knowledge-store.ts +358 -0
- package/src/stores/lineage-store.ts +126 -0
- package/src/stores/mcp-store.ts +147 -0
- package/src/stores/payments-store.ts +545 -0
- package/src/stores/search-store-types.ts +155 -0
- package/src/stores/search-store.ts +656 -0
- package/src/stores/share-link-store.ts +151 -0
- package/src/stores/stack-store.ts +352 -0
- package/src/stores/ui-store.ts +161 -0
- package/src/stores/upload-store.ts +131 -0
- package/src/stores/versions-store.ts +355 -0
- package/src/stores/workflows-store.ts +402 -0
- package/src/stores/workspace-store.ts +185 -0
- 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
|
+
}
|