@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,151 @@
1
+ /**
2
+ * Store for share link management (via JSON-RPC).
3
+ */
4
+
5
+ import { create } from "zustand";
6
+ import type { FetchClient } from "@nexus-ai-fs/api-client";
7
+ import { createApiAction, categorizeError } from "./create-api-action.js";
8
+ import { useErrorStore } from "./error-store.js";
9
+
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ export interface ShareLink {
15
+ readonly link_id: string;
16
+ readonly path: string;
17
+ readonly permission_level: string;
18
+ readonly status: string;
19
+ readonly access_count: number;
20
+ readonly has_password: boolean;
21
+ readonly expires_at: string | null;
22
+ readonly created_at: string;
23
+ }
24
+
25
+ export interface ShareLinkAccessLog {
26
+ readonly timestamp: string;
27
+ readonly ip_address: string;
28
+ readonly user_agent: string;
29
+ readonly success: boolean;
30
+ }
31
+
32
+ // =============================================================================
33
+ // Store
34
+ // =============================================================================
35
+
36
+ export interface ShareLinkState {
37
+ readonly links: readonly ShareLink[];
38
+ readonly linksLoading: boolean;
39
+ readonly selectedLinkIndex: number;
40
+ readonly accessLogs: readonly ShareLinkAccessLog[];
41
+ readonly accessLogsLoading: boolean;
42
+ readonly error: string | null;
43
+
44
+ readonly fetchLinks: (client: FetchClient, options?: { includeRevoked?: boolean; includeExpired?: boolean }) => Promise<void>;
45
+ readonly createLink: (params: { path: string; permission_level: string; password?: string; expires_in_hours?: number; max_access_count?: number }, client: FetchClient) => Promise<void>;
46
+ readonly revokeLink: (linkId: string, client: FetchClient) => Promise<void>;
47
+ readonly fetchAccessLogs: (linkId: string, client: FetchClient) => Promise<void>;
48
+ readonly setSelectedLinkIndex: (index: number) => void;
49
+ }
50
+
51
+ const SOURCE = "files";
52
+
53
+ export const useShareLinkStore = create<ShareLinkState>((set, get) => ({
54
+ links: [],
55
+ linksLoading: false,
56
+ selectedLinkIndex: 0,
57
+ accessLogs: [],
58
+ accessLogsLoading: false,
59
+ error: null,
60
+
61
+ // =========================================================================
62
+ // Actions with loading keys — createApiAction
63
+ // =========================================================================
64
+
65
+ fetchLinks: createApiAction<ShareLinkState, [FetchClient, ({ includeRevoked?: boolean; includeExpired?: boolean } | undefined)?]>(set, {
66
+ loadingKey: "linksLoading",
67
+ source: SOURCE,
68
+ errorMessage: "Failed to fetch share links",
69
+ action: async (client, options) => {
70
+ const response = await client.post<{ result: readonly ShareLink[] }>(
71
+ "/api/nfs/list_share_links",
72
+ {
73
+ params: {
74
+ path: "",
75
+ include_revoked: options?.includeRevoked ?? false,
76
+ include_expired: options?.includeExpired ?? false,
77
+ },
78
+ },
79
+ );
80
+ return {
81
+ links: Array.isArray(response?.result) ? response.result : [],
82
+ selectedLinkIndex: 0,
83
+ };
84
+ },
85
+ }),
86
+
87
+ fetchAccessLogs: createApiAction<ShareLinkState, [string, FetchClient]>(set, {
88
+ loadingKey: "accessLogsLoading",
89
+ source: SOURCE,
90
+ errorMessage: "Failed to fetch access logs",
91
+ action: async (linkId, client) => {
92
+ const response = await client.post<{ result: readonly ShareLinkAccessLog[] }>(
93
+ "/api/nfs/get_share_link_access_logs",
94
+ {
95
+ params: { link_id: linkId },
96
+ },
97
+ );
98
+ return {
99
+ accessLogs: response.result ?? [],
100
+ };
101
+ },
102
+ }),
103
+
104
+ // =========================================================================
105
+ // Actions without loading keys — inline with error store integration
106
+ // =========================================================================
107
+
108
+ createLink: async (params, client) => {
109
+ set({ error: null });
110
+ try {
111
+ await client.post<{ result: ShareLink }>(
112
+ "/api/nfs/create_share_link",
113
+ {
114
+ params: {
115
+ path: params.path,
116
+ permission_level: params.permission_level,
117
+ password: params.password,
118
+ expires_in_hours: params.expires_in_hours,
119
+ max_access_count: params.max_access_count,
120
+ },
121
+ },
122
+ );
123
+ await get().fetchLinks(client);
124
+ } catch (err) {
125
+ const message = err instanceof Error ? err.message : "Failed to create share link";
126
+ set({ error: message });
127
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
128
+ }
129
+ },
130
+
131
+ revokeLink: async (linkId, client) => {
132
+ set({ error: null });
133
+ try {
134
+ await client.post<{ result: unknown }>(
135
+ "/api/nfs/revoke_share_link",
136
+ {
137
+ params: { link_id: linkId },
138
+ },
139
+ );
140
+ await get().fetchLinks(client);
141
+ } catch (err) {
142
+ const message = err instanceof Error ? err.message : "Failed to revoke share link";
143
+ set({ error: message });
144
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
145
+ }
146
+ },
147
+
148
+ setSelectedLinkIndex: (index) => {
149
+ set({ selectedLinkIndex: index });
150
+ },
151
+ }));
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Zustand store for the Stack panel: Docker containers, nexus.yaml config,
3
+ * .state.json runtime state, and server health details.
4
+ *
5
+ * Reads local files via Bun.file() and runs `docker compose ps` via Bun.spawn().
6
+ * Server health is fetched via the FetchClient from the global store.
7
+ */
8
+
9
+ import { create } from "zustand";
10
+ import type { FetchClient } from "@nexus-ai-fs/api-client";
11
+ import { useErrorStore } from "./error-store.js";
12
+ import { categorizeError } from "./create-api-action.js";
13
+ import { useUiStore } from "./ui-store.js";
14
+
15
+ // =============================================================================
16
+ // Types
17
+ // =============================================================================
18
+
19
+ export interface ContainerInfo {
20
+ readonly name: string;
21
+ readonly service: string;
22
+ readonly state: string;
23
+ readonly health: string;
24
+ readonly ports: string;
25
+ readonly image: string;
26
+ }
27
+
28
+ export interface HealthComponent {
29
+ readonly name: string;
30
+ readonly status: string;
31
+ readonly detail: string;
32
+ }
33
+
34
+ export interface DetailedHealth {
35
+ readonly status: string;
36
+ readonly service: string;
37
+ readonly components: Record<string, { status: string; detail?: string }>;
38
+ }
39
+
40
+ export type StackTab = "containers" | "config" | "state";
41
+
42
+ /** Paths to config/state files used by the stack. */
43
+ export interface StackPaths {
44
+ readonly projectRoot: string;
45
+ readonly nexusYaml: string;
46
+ readonly stateJson: string;
47
+ readonly composeFile: string;
48
+ readonly dataDir: string;
49
+ }
50
+
51
+ export interface StackState {
52
+ // Tabs
53
+ readonly activeTab: StackTab;
54
+
55
+ // Docker containers
56
+ readonly containers: readonly ContainerInfo[];
57
+ readonly containersLoading: boolean;
58
+
59
+ // nexus.yaml raw content
60
+ readonly configYaml: string;
61
+ readonly configLoading: boolean;
62
+
63
+ // .state.json parsed
64
+ readonly stateJson: Record<string, unknown> | null;
65
+ readonly stateLoading: boolean;
66
+
67
+ // Server health details
68
+ readonly healthDetails: DetailedHealth | null;
69
+ readonly healthLoading: boolean;
70
+
71
+ // File paths
72
+ readonly paths: StackPaths | null;
73
+
74
+ // General
75
+ readonly error: string | null;
76
+ readonly lastRefreshed: number;
77
+
78
+ // Actions
79
+ readonly setActiveTab: (tab: StackTab) => void;
80
+ readonly fetchContainers: () => Promise<void>;
81
+ readonly fetchConfig: () => Promise<void>;
82
+ readonly fetchState: () => Promise<void>;
83
+ readonly fetchHealth: (client: FetchClient) => Promise<void>;
84
+ readonly refreshAll: (client: FetchClient | null) => Promise<void>;
85
+ }
86
+
87
+ // =============================================================================
88
+ // Helpers
89
+ // =============================================================================
90
+
91
+ /**
92
+ * Find the project root by walking up from CWD looking for nexus.yaml.
93
+ * Walks up to 20 levels to handle git worktrees nested inside the main repo
94
+ * (e.g. .claude/worktrees/<name>/packages/nexus-tui/).
95
+ * Returns CWD if not found (commands will just fail gracefully).
96
+ */
97
+ function findProjectRoot(): string {
98
+ const path = require("node:path");
99
+ const fs = require("node:fs");
100
+ let dir = process.cwd();
101
+ for (let i = 0; i < 20; i++) {
102
+ if (fs.existsSync(path.join(dir, "nexus.yaml"))) return dir;
103
+ const parent = path.dirname(dir);
104
+ if (parent === dir) break;
105
+ dir = parent;
106
+ }
107
+ return process.cwd();
108
+ }
109
+
110
+ /**
111
+ * Deduplicate port mappings from Docker Publishers array.
112
+ * Docker lists each mapping twice (IPv4 0.0.0.0 + IPv6 ::).
113
+ * Only show published (host-mapped) ports, skip unexposed ones.
114
+ */
115
+ function formatPorts(publishers: { URL?: string; PublishedPort?: number; TargetPort?: number }[]): string {
116
+ const seen = new Set<string>();
117
+ const parts: string[] = [];
118
+ for (const p of publishers) {
119
+ if (!p.PublishedPort) continue; // skip unexposed ports
120
+ const key = `${p.PublishedPort}->${p.TargetPort}`;
121
+ if (seen.has(key)) continue;
122
+ seen.add(key);
123
+ parts.push(key);
124
+ }
125
+ return parts.join(", ");
126
+ }
127
+
128
+ function containerFromObj(obj: Record<string, unknown>): ContainerInfo {
129
+ const publishers = obj.Publishers;
130
+ return {
131
+ name: (obj.Name ?? obj.Names ?? "") as string,
132
+ service: (obj.Service ?? "") as string,
133
+ state: (obj.State ?? "") as string,
134
+ health: (obj.Health ?? "") as string,
135
+ ports: Array.isArray(publishers)
136
+ ? formatPorts(publishers as { URL?: string; PublishedPort?: number; TargetPort?: number }[])
137
+ : (obj.Ports ?? "") as string,
138
+ image: (obj.Image ?? "") as string,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Parse `docker compose ps --format json` output.
144
+ * Handles both formats:
145
+ * - NDJSON (one JSON object per line) — newer Compose versions
146
+ * - JSON array (single `[...]` blob) — older Compose versions
147
+ */
148
+ function parseDockerPs(output: string): ContainerInfo[] {
149
+ const trimmed = output.trim();
150
+ if (!trimmed) return [];
151
+
152
+ // Try JSON array first (older Compose: single [...] output)
153
+ if (trimmed.startsWith("[")) {
154
+ try {
155
+ const arr = JSON.parse(trimmed);
156
+ if (Array.isArray(arr)) {
157
+ return arr.map(containerFromObj);
158
+ }
159
+ } catch {
160
+ // Fall through to NDJSON parsing
161
+ }
162
+ }
163
+
164
+ // NDJSON: one JSON object per line (newer Compose)
165
+ const containers: ContainerInfo[] = [];
166
+ for (const line of trimmed.split("\n")) {
167
+ const l = line.trim();
168
+ if (!l || !l.startsWith("{")) continue;
169
+ try {
170
+ containers.push(containerFromObj(JSON.parse(l)));
171
+ } catch {
172
+ // Skip non-JSON lines
173
+ }
174
+ }
175
+ return containers;
176
+ }
177
+
178
+ // =============================================================================
179
+ // Store
180
+ // =============================================================================
181
+
182
+ export const useStackStore = create<StackState>((set, get) => ({
183
+ activeTab: "containers",
184
+ containers: [],
185
+ containersLoading: false,
186
+ configYaml: "",
187
+ configLoading: false,
188
+ stateJson: null,
189
+ stateLoading: false,
190
+ healthDetails: null,
191
+ healthLoading: false,
192
+ paths: null,
193
+ error: null,
194
+ lastRefreshed: 0,
195
+
196
+ setActiveTab: (tab) => set({ activeTab: tab }),
197
+
198
+ fetchContainers: async () => {
199
+ set({ containersLoading: true, error: null });
200
+ try {
201
+ const projectRoot = findProjectRoot();
202
+ const proc = Bun.spawn(
203
+ ["docker", "compose", "ps", "--format", "json", "-a"],
204
+ {
205
+ cwd: projectRoot,
206
+ stdout: "pipe",
207
+ stderr: "pipe",
208
+ env: { ...process.env },
209
+ },
210
+ );
211
+
212
+ const [stdout, stderr, exitCode] = await Promise.all([
213
+ new Response(proc.stdout).text(),
214
+ new Response(proc.stderr).text(),
215
+ proc.exited,
216
+ ]);
217
+
218
+ if (exitCode !== 0) {
219
+ const errMsg = stderr.trim() || `docker compose ps exited with code ${exitCode}`;
220
+ set({ containers: [], containersLoading: false, error: errMsg });
221
+ return;
222
+ }
223
+
224
+ const containers = parseDockerPs(stdout);
225
+ set({ containers, containersLoading: false });
226
+ useUiStore.getState().markDataUpdated("stack");
227
+ } catch (err) {
228
+ const message = err instanceof Error ? err.message : "Failed to query Docker";
229
+ set({ containersLoading: false, error: message });
230
+ }
231
+ },
232
+
233
+ fetchConfig: async () => {
234
+ set({ configLoading: true });
235
+ try {
236
+ const projectRoot = findProjectRoot();
237
+ const path = require("node:path");
238
+ const fs = require("node:fs");
239
+ const yamlPath = path.join(projectRoot, "nexus.yaml");
240
+ const file = Bun.file(yamlPath);
241
+ const exists = await file.exists();
242
+ if (exists) {
243
+ const text = await file.text();
244
+
245
+ // Resolve all file paths from the config
246
+ let dataDir = path.join(projectRoot, "nexus-data");
247
+ let composeFile = path.join(projectRoot, "nexus-stack.yml");
248
+ const dataDirMatch = text.match(/^data_dir:\s*(.+)$/m);
249
+ if (dataDirMatch?.[1]) {
250
+ const parsed = dataDirMatch[1].trim().replace(/^["']|["']$/g, "");
251
+ dataDir = path.isAbsolute(parsed) ? parsed : path.join(projectRoot, parsed);
252
+ }
253
+ const composeMatch = text.match(/^compose_file:\s*(.+)$/m);
254
+ if (composeMatch?.[1]) {
255
+ const parsed = composeMatch[1].trim().replace(/^["']|["']$/g, "");
256
+ composeFile = path.isAbsolute(parsed) ? parsed : path.join(projectRoot, parsed);
257
+ }
258
+
259
+ set({
260
+ configYaml: text,
261
+ configLoading: false,
262
+ paths: {
263
+ projectRoot,
264
+ nexusYaml: yamlPath,
265
+ stateJson: path.join(dataDir, ".state.json"),
266
+ composeFile,
267
+ dataDir,
268
+ },
269
+ });
270
+ } else {
271
+ set({ configYaml: "", configLoading: false, paths: null });
272
+ }
273
+ useUiStore.getState().markDataUpdated("stack");
274
+ } catch (err) {
275
+ const message = err instanceof Error ? err.message : "Failed to read nexus.yaml";
276
+ set({ configYaml: `Error: ${message}`, configLoading: false });
277
+ }
278
+ },
279
+
280
+ fetchState: async () => {
281
+ set({ stateLoading: true });
282
+ try {
283
+ const projectRoot = findProjectRoot();
284
+ const path = require("node:path");
285
+ const fs = require("node:fs");
286
+
287
+ // Read nexus.yaml to find data_dir
288
+ let dataDir = path.join(projectRoot, "nexus-data");
289
+ try {
290
+ const yaml = fs.readFileSync(path.join(projectRoot, "nexus.yaml"), "utf-8") as string;
291
+ const match = yaml.match(/^data_dir:\s*(.+)$/m);
292
+ if (match?.[1]) {
293
+ const parsed = match[1].trim().replace(/^["']|["']$/g, "");
294
+ dataDir = path.isAbsolute(parsed) ? parsed : path.join(projectRoot, parsed);
295
+ }
296
+ } catch {
297
+ // Use default
298
+ }
299
+
300
+ const stateFile = Bun.file(path.join(dataDir, ".state.json"));
301
+ const exists = await stateFile.exists();
302
+ if (exists) {
303
+ const text = await stateFile.text();
304
+ const parsed = JSON.parse(text);
305
+ set({ stateJson: parsed, stateLoading: false });
306
+ } else {
307
+ set({ stateJson: null, stateLoading: false });
308
+ }
309
+ useUiStore.getState().markDataUpdated("stack");
310
+ } catch (err) {
311
+ const message = err instanceof Error ? err.message : "Failed to read .state.json";
312
+ set({ stateJson: null, stateLoading: false, error: message });
313
+ }
314
+ },
315
+
316
+ fetchHealth: async (client: FetchClient) => {
317
+ set({ healthLoading: true });
318
+ try {
319
+ const health = await client.get<DetailedHealth>("/health/detailed");
320
+ set({ healthDetails: health, healthLoading: false });
321
+ useUiStore.getState().markDataUpdated("stack");
322
+ } catch {
323
+ // Fall back to basic health
324
+ try {
325
+ const basic = await client.get<{ status: string; service: string }>("/health");
326
+ set({
327
+ healthDetails: { status: basic.status, service: basic.service, components: {} },
328
+ healthLoading: false,
329
+ });
330
+ useUiStore.getState().markDataUpdated("stack");
331
+ } catch (err) {
332
+ const message = err instanceof Error ? err.message : "Health check failed";
333
+ set({ healthDetails: null, healthLoading: false });
334
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: "stack" });
335
+ }
336
+ }
337
+ },
338
+
339
+ refreshAll: async (client) => {
340
+ const { fetchContainers, fetchConfig, fetchState, fetchHealth } = get();
341
+ const promises: Promise<void>[] = [
342
+ fetchContainers(),
343
+ fetchConfig(),
344
+ fetchState(),
345
+ ];
346
+ if (client) {
347
+ promises.push(fetchHealth(client));
348
+ }
349
+ await Promise.allSettled(promises);
350
+ set({ lastRefreshed: Date.now() });
351
+ },
352
+ }));
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Cross-cutting UI state store.
3
+ *
4
+ * Owns state that spans multiple panels: focus pane, zoom, scroll positions.
5
+ * Keeps domain stores (files-store, agents-store, etc.) free of UI chrome.
6
+ *
7
+ * @see Issue #3066 Architecture Decision 2A
8
+ */
9
+
10
+ import { create } from "zustand";
11
+ import type { PanelId } from "./global-store.js";
12
+
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+
17
+ export type FocusPane = "left" | "right";
18
+
19
+ export interface UiState {
20
+ /** Per-panel focus pane for split views. */
21
+ readonly focusPane: Readonly<Record<string, FocusPane>>;
22
+
23
+ /** Panel currently in fullscreen zoom mode, or null. */
24
+ readonly zoomedPanel: PanelId | null;
25
+
26
+ /** Persisted scroll positions keyed by "panel:list" identifier. */
27
+ readonly scrollPositions: Readonly<Record<string, number>>;
28
+
29
+ /** True when a global overlay (welcome, help, identity switcher) is active. */
30
+ readonly overlayActive: boolean;
31
+
32
+ /** True when a full-screen panel-level editor is open (suppresses global keybindings). */
33
+ readonly fileEditorOpen: boolean;
34
+
35
+ /** Whether the side navigation bar is visible (toggled via Ctrl+B). */
36
+ readonly sideNavVisible: boolean;
37
+
38
+ /** Timestamp (ms) of the last successful data update per panel. 0 = never fetched. */
39
+ readonly panelDataTimestamps: Readonly<Partial<Record<PanelId, number>>>;
40
+
41
+ /** Timestamp (ms) of the last time the user visited each panel. 0 = never visited. */
42
+ readonly panelVisitTimestamps: Readonly<Partial<Record<PanelId, number>>>;
43
+
44
+ /** Mirror of global-store activePanel, kept in sync by markPanelVisited. */
45
+ readonly activePanelId: PanelId;
46
+
47
+ // Actions
48
+ readonly setFocusPane: (panel: string, pane: FocusPane) => void;
49
+ readonly toggleFocusPane: (panel: string) => void;
50
+ readonly getFocusPane: (panel: string) => FocusPane;
51
+ readonly toggleZoom: (panel: PanelId) => void;
52
+ readonly clearZoom: () => void;
53
+ readonly setScrollPosition: (key: string, position: number) => void;
54
+ readonly getScrollPosition: (key: string) => number;
55
+ readonly setOverlayActive: (active: boolean) => void;
56
+ readonly setFileEditorOpen: (open: boolean) => void;
57
+ readonly toggleSideNav: () => void;
58
+ readonly setSideNavVisible: (visible: boolean) => void;
59
+ readonly markDataUpdated: (panel: PanelId) => void;
60
+ readonly markPanelVisited: (panel: PanelId) => void;
61
+ readonly resetFreshnessTimestamps: () => void;
62
+ }
63
+
64
+ // =============================================================================
65
+ // Store
66
+ // =============================================================================
67
+
68
+ export const useUiStore = create<UiState>((set, get) => ({
69
+ focusPane: {},
70
+ zoomedPanel: null,
71
+ scrollPositions: {},
72
+ overlayActive: false,
73
+ fileEditorOpen: false,
74
+ sideNavVisible: true,
75
+ panelDataTimestamps: {},
76
+ // "files" is the default active panel — mark it visited at startup so data
77
+ // fetched during the initial load does not produce a false-positive unseen dot.
78
+ panelVisitTimestamps: { files: Date.now() },
79
+ activePanelId: "files" as PanelId,
80
+
81
+ setFocusPane: (panel, pane) => {
82
+ set((state) => ({
83
+ focusPane: { ...state.focusPane, [panel]: pane },
84
+ }));
85
+ },
86
+
87
+ toggleFocusPane: (panel) => {
88
+ const current = get().focusPane[panel];
89
+ const next: FocusPane = current === "left" ? "right" : current === "right" ? "left" : "right";
90
+ set((state) => ({
91
+ focusPane: { ...state.focusPane, [panel]: next },
92
+ }));
93
+ },
94
+
95
+ getFocusPane: (panel) => {
96
+ return get().focusPane[panel] ?? "left";
97
+ },
98
+
99
+ toggleZoom: (panel) => {
100
+ set((state) => ({
101
+ zoomedPanel: state.zoomedPanel === panel ? null : panel,
102
+ }));
103
+ },
104
+
105
+ clearZoom: () => {
106
+ set({ zoomedPanel: null });
107
+ },
108
+
109
+ setScrollPosition: (key, position) => {
110
+ set((state) => ({
111
+ scrollPositions: { ...state.scrollPositions, [key]: position },
112
+ }));
113
+ },
114
+
115
+ getScrollPosition: (key) => {
116
+ return get().scrollPositions[key] ?? 0;
117
+ },
118
+
119
+ setOverlayActive: (active) => {
120
+ set({ overlayActive: active });
121
+ },
122
+
123
+ setFileEditorOpen: (open) => {
124
+ set({ fileEditorOpen: open });
125
+ },
126
+
127
+ toggleSideNav: () => {
128
+ set((state) => ({ sideNavVisible: !state.sideNavVisible }));
129
+ },
130
+
131
+ setSideNavVisible: (visible) => {
132
+ set({ sideNavVisible: visible });
133
+ },
134
+
135
+ markDataUpdated: (panel) => {
136
+ const now = Date.now();
137
+ // If the user is currently viewing this panel, also update the visit
138
+ // timestamp so the panel is not marked "unseen" when the user leaves.
139
+ const isActive = get().activePanelId === panel;
140
+ set((state) => ({
141
+ panelDataTimestamps: { ...state.panelDataTimestamps, [panel]: now },
142
+ ...(isActive
143
+ ? { panelVisitTimestamps: { ...state.panelVisitTimestamps, [panel]: now } }
144
+ : {}),
145
+ }));
146
+ },
147
+
148
+ markPanelVisited: (panel) => {
149
+ set((state) => ({
150
+ panelVisitTimestamps: { ...state.panelVisitTimestamps, [panel]: Date.now() },
151
+ activePanelId: panel,
152
+ }));
153
+ },
154
+
155
+ resetFreshnessTimestamps: () => {
156
+ set({
157
+ panelDataTimestamps: {},
158
+ panelVisitTimestamps: { [get().activePanelId]: Date.now() },
159
+ });
160
+ },
161
+ }));