openk8s 1.0.1 → 1.0.2

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 (47) hide show
  1. package/package.json +5 -2
  2. package/src/app/__tests__/app-state.test.ts +376 -0
  3. package/src/app/__tests__/components/inspector-tokens.test.ts +101 -0
  4. package/src/app/__tests__/utils.test.ts +358 -0
  5. package/src/app/app-actions.ts +262 -0
  6. package/src/app/app-state.ts +4 -262
  7. package/src/app/app.tsx +22 -170
  8. package/src/app/components/detail-sections.tsx +131 -0
  9. package/src/app/components/footer.tsx +52 -0
  10. package/src/app/components/header.tsx +37 -0
  11. package/src/app/components/inspector-tokens.ts +93 -0
  12. package/src/app/components/inspector.tsx +3 -239
  13. package/src/app/components/resource-rows.tsx +5 -0
  14. package/src/app/hooks/keyboard/filter-handlers.ts +40 -0
  15. package/src/app/hooks/keyboard/global-handlers.ts +134 -0
  16. package/src/app/hooks/keyboard/helm-handlers.ts +104 -0
  17. package/src/app/hooks/keyboard/logs-handlers.ts +80 -0
  18. package/src/app/hooks/keyboard/navigation-handlers.ts +103 -0
  19. package/src/app/hooks/keyboard/overlay-handlers.ts +138 -0
  20. package/src/app/hooks/keyboard/port-forward-handlers.ts +71 -0
  21. package/src/app/hooks/keyboard/shell-edit-handlers.ts +253 -0
  22. package/src/app/hooks/use-app-keyboard.ts +56 -621
  23. package/src/app/hooks/use-app-side-effects.ts +1 -1
  24. package/src/app/hooks/use-clipboard.ts +1 -1
  25. package/src/app/hooks/use-data-fetching.ts +2 -9
  26. package/src/app/hooks/use-log-stream.ts +1 -1
  27. package/src/app/hooks/use-port-forward.ts +1 -1
  28. package/src/app/use-footer-hints.ts +107 -0
  29. package/src/index.tsx +4 -0
  30. package/src/lib/k8s/__tests__/k8s-format.test.ts +42 -0
  31. package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +215 -0
  32. package/src/lib/k8s/__tests__/resource-parser.test.ts +455 -0
  33. package/src/lib/k8s/detail-builders/event-builder.ts +21 -0
  34. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +63 -0
  35. package/src/lib/k8s/detail-builders/node-builder.ts +41 -0
  36. package/src/lib/k8s/detail-builders/overview-builder.ts +103 -0
  37. package/src/lib/k8s/detail-builders/pod-builder.ts +140 -0
  38. package/src/lib/k8s/detail-builders/rbac-builder.ts +57 -0
  39. package/src/lib/k8s/resource-detail-builder.ts +22 -502
  40. package/src/lib/kubectl/__tests__/kubectl-helpers.test.ts +343 -0
  41. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +84 -0
  42. package/src/lib/kubectl/__tests__/spawn-utils.test.ts +56 -0
  43. package/src/lib/kubectl/kubectl-helpers.ts +246 -0
  44. package/src/lib/kubectl/kubectl-service.ts +77 -565
  45. package/src/lib/kubectl/kubectl-types.ts +248 -0
  46. package/src/lib/kubectl/metrics-utils.ts +33 -0
  47. package/src/lib/kubectl/spawn-utils.ts +27 -0
@@ -1,17 +1,7 @@
1
- import type {
2
- ActivePortForward,
3
- AppError,
4
- AppState,
5
- EventItem,
6
- LoadStatus,
7
- LogOptions,
8
- Notification,
9
- OverlayId,
10
- PaneId,
11
- ResourceDetail,
12
- ResourceKind,
13
- ResourceListItem,
14
- } from "../lib/k8s/types";
1
+ import type { AppAction } from "./app-actions";
2
+ import type { AppState, PaneId } from "../lib/k8s/types";
3
+
4
+ export { type AppAction } from "./app-actions";
15
5
 
16
6
  export const DEFAULT_NAMESPACE = "default";
17
7
 
@@ -54,254 +44,6 @@ export const initialState: AppState = {
54
44
  helmRollbackRevision: "",
55
45
  };
56
46
 
57
- export interface SetActivePaneAction {
58
- type: "setActivePane";
59
- pane: PaneId;
60
- }
61
-
62
- export interface CyclePaneAction {
63
- type: "cyclePane";
64
- direction: 1 | -1;
65
- }
66
-
67
- export interface SetOverlayAction {
68
- type: "setOverlay";
69
- overlay?: OverlayId | undefined;
70
- }
71
-
72
- export interface SetInspectorTabAction {
73
- type: "setInspectorTab";
74
- tab: AppState["inspectorTab"];
75
- }
76
-
77
- export interface SetKubectlAvailableAction {
78
- type: "setKubectlAvailable";
79
- available: boolean;
80
- }
81
-
82
- export interface SetStatusMessageAction {
83
- type: "setStatusMessage";
84
- message: string;
85
- }
86
-
87
- export interface SetErrorAction {
88
- type: "setError";
89
- error?: AppError | undefined;
90
- }
91
-
92
- export interface SetContextsStatusAction {
93
- type: "setContextsStatus";
94
- status: LoadStatus;
95
- }
96
-
97
- export interface SetContextsAction {
98
- type: "setContexts";
99
- contexts: AppState["contexts"];
100
- activeContext?: string | undefined;
101
- }
102
-
103
- export interface SetActiveContextAction {
104
- type: "setActiveContext";
105
- activeContext?: string | undefined;
106
- }
107
-
108
- export interface SetNamespacesStatusAction {
109
- type: "setNamespacesStatus";
110
- status: LoadStatus;
111
- }
112
-
113
- export interface SetNamespacesAction {
114
- type: "setNamespaces";
115
- namespaces: AppState["namespaces"];
116
- activeNamespace: string;
117
- }
118
-
119
- export interface SetActiveNamespaceAction {
120
- type: "setActiveNamespace";
121
- namespace: string;
122
- }
123
-
124
- export interface SetResourceKindsStatusAction {
125
- type: "setResourceKindsStatus";
126
- status: LoadStatus;
127
- }
128
-
129
- export interface SetResourceKindsAction {
130
- type: "setResourceKinds";
131
- resourceKinds: ResourceKind[];
132
- selectedKind: string;
133
- }
134
-
135
- export interface SetSelectedKindAction {
136
- type: "setSelectedKind";
137
- kind: string;
138
- }
139
-
140
- export interface SetResourcesStatusAction {
141
- type: "setResourcesStatus";
142
- status: LoadStatus;
143
- }
144
-
145
- export interface SetResourcesAction {
146
- type: "setResources";
147
- resources: ResourceListItem[];
148
- selectedResourceName?: string | undefined;
149
- }
150
-
151
- export interface ToggleSelectedResourceAction {
152
- type: "toggleSelectedResource";
153
- name: string;
154
- }
155
-
156
- export interface ClearSelectedResourcesAction {
157
- type: "clearSelectedResources";
158
- }
159
-
160
- export interface SetResourceFilterAction {
161
- type: "setResourceFilter";
162
- value: string;
163
- }
164
-
165
- export interface SetSelectedResourceNameAction {
166
- type: "setSelectedResourceName";
167
- name?: string | undefined;
168
- }
169
-
170
- export interface SetSelectedResourceDetailStatusAction {
171
- type: "setSelectedResourceDetailStatus";
172
- status: LoadStatus;
173
- }
174
-
175
- export interface SetSelectedResourceDetailAction {
176
- type: "setSelectedResourceDetail";
177
- detail?: ResourceDetail | undefined;
178
- }
179
-
180
- export interface SetEventsStatusAction {
181
- type: "setEventsStatus";
182
- status: LoadStatus;
183
- }
184
-
185
- export interface SetEventsAction {
186
- type: "setEvents";
187
- events: EventItem[];
188
- }
189
-
190
- export interface ToggleRevealDetailLineAction {
191
- type: "toggleRevealDetailLine";
192
- id: string;
193
- }
194
-
195
- export interface AddActivePortForwardAction {
196
- type: "addActivePortForward";
197
- forward: ActivePortForward;
198
- }
199
-
200
- export interface UpdateActivePortForwardAction {
201
- type: "updateActivePortForward";
202
- id: string;
203
- patch: Partial<ActivePortForward>;
204
- }
205
-
206
- export interface RemoveActivePortForwardAction {
207
- type: "removeActivePortForward";
208
- id: string;
209
- }
210
-
211
- export interface ClearTransientViewsAction {
212
- type: "clearTransientViews";
213
- }
214
-
215
- export interface SetLogsDataAction {
216
- type: "setLogsData";
217
- logsTarget?: string | undefined;
218
- logsText: string;
219
- logsStatus: LoadStatus;
220
- }
221
-
222
- export interface SetLogsContainerAction {
223
- type: "setLogsContainer";
224
- container?: string | undefined;
225
- }
226
-
227
- export interface SetLogsOptionsAction {
228
- type: "setLogsOptions";
229
- options: LogOptions;
230
- }
231
-
232
- export interface PushNotificationAction {
233
- type: "pushNotification";
234
- notification: Notification;
235
- }
236
-
237
- export interface DismissNotificationAction {
238
- type: "dismissNotification";
239
- id: string;
240
- }
241
-
242
- export interface SetRevealedSecretValueAction {
243
- type: "setRevealedSecretValue";
244
- id: string;
245
- value: string;
246
- }
247
-
248
- export interface SetPodMetricsAction {
249
- type: "setPodMetrics";
250
- metrics: AppState["podMetrics"];
251
- }
252
-
253
- export interface SetNodeMetricsAction {
254
- type: "setNodeMetrics";
255
- metrics: AppState["nodeMetrics"];
256
- }
257
-
258
- export interface SetHelmRollbackRevisionAction {
259
- type: "setHelmRollbackRevision";
260
- value: string;
261
- }
262
-
263
- export type AppAction =
264
- | SetActivePaneAction
265
- | CyclePaneAction
266
- | SetOverlayAction
267
- | SetInspectorTabAction
268
- | SetKubectlAvailableAction
269
- | SetStatusMessageAction
270
- | SetErrorAction
271
- | SetContextsStatusAction
272
- | SetContextsAction
273
- | SetActiveContextAction
274
- | SetNamespacesStatusAction
275
- | SetNamespacesAction
276
- | SetActiveNamespaceAction
277
- | SetResourceKindsStatusAction
278
- | SetResourceKindsAction
279
- | SetSelectedKindAction
280
- | SetResourcesStatusAction
281
- | SetResourcesAction
282
- | ToggleSelectedResourceAction
283
- | ClearSelectedResourcesAction
284
- | SetResourceFilterAction
285
- | SetSelectedResourceNameAction
286
- | SetSelectedResourceDetailStatusAction
287
- | SetSelectedResourceDetailAction
288
- | SetEventsStatusAction
289
- | SetEventsAction
290
- | ToggleRevealDetailLineAction
291
- | AddActivePortForwardAction
292
- | UpdateActivePortForwardAction
293
- | RemoveActivePortForwardAction
294
- | ClearTransientViewsAction
295
- | SetLogsDataAction
296
- | SetLogsContainerAction
297
- | SetLogsOptionsAction
298
- | PushNotificationAction
299
- | DismissNotificationAction
300
- | SetRevealedSecretValueAction
301
- | SetPodMetricsAction
302
- | SetNodeMetricsAction
303
- | SetHelmRollbackRevisionAction;
304
-
305
47
  const PANE_ORDER: PaneId[] = ["clusters", "resources", "inspector"];
306
48
 
307
49
  export function reducer(state: AppState, action: AppAction): AppState {
package/src/app/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
1
+ import { type ScrollBoxRenderable } from "@opentui/core";
2
2
  import { useRenderer, useTerminalDimensions } from "@opentui/react";
3
3
  import { useCallback, useMemo, useReducer, useRef, useState } from "react";
4
4
 
@@ -14,12 +14,10 @@ import {
14
14
  PANEL_BORDER_ACTIVE,
15
15
  RESOURCE_SURFACE,
16
16
  SURFACE,
17
- SURFACE_ACCENT,
18
17
  TEXT_MUTED,
19
18
  TEXT_PRIMARY,
20
19
  TEXT_SUBTLE,
21
20
  paneBorder,
22
- toneStyles,
23
21
  } from "./theme";
24
22
  import {
25
23
  currentKindLabel,
@@ -31,9 +29,12 @@ import {
31
29
  statusLine,
32
30
  } from "./utils";
33
31
  import { usePollingTick } from "./use-polling-tick";
32
+ import { useFooterHints, useFooterHintRows } from "./use-footer-hints";
34
33
  import { InspectorBody, InspectorTabs, INSPECTOR_TABS } from "./components/inspector";
35
34
  import { KindRows } from "./components/kind-rows";
36
35
  import { ResourceRows } from "./components/resource-rows";
36
+ import { Header } from "./components/header";
37
+ import { Footer } from "./components/footer";
37
38
  import {
38
39
  DeleteConfirmOverlay,
39
40
  HelmRollbackOverlay,
@@ -47,13 +48,7 @@ import {
47
48
  import { PortForwardsTray } from "./components/port-forwards-tray";
48
49
  import { NotificationTray } from "./components/notification-tray";
49
50
  import { KubectlService } from "../lib/kubectl/kubectl-service";
50
- import type {
51
- ClusterContext,
52
- NamespaceItem,
53
- OverlayId,
54
- PaneId,
55
- ResourceDetailLine,
56
- } from "../lib/k8s/types";
51
+ import type { ClusterContext, NamespaceItem, OverlayId, PaneId, ResourceDetailLine } from "../lib/k8s/types";
57
52
  import type { NotificationTone } from "../lib/k8s/types";
58
53
 
59
54
  import { useClipboard } from "./hooks/use-clipboard";
@@ -198,7 +193,13 @@ export function App() {
198
193
  [state.activePortForwards],
199
194
  );
200
195
 
201
- const footerPalette = toneStyles("neutral");
196
+ const selectedKindLabel = useMemo(
197
+ () => currentKindLabel({ resourceKinds: state.resourceKinds, selectedKind: state.selectedKind }),
198
+ [state.resourceKinds, state.selectedKind],
199
+ );
200
+
201
+ const footerHints = useFooterHints(filterMode, state, activeResourceRef, activeDetail, selectedResourcePortForwards, deleteTargets, kubectl);
202
+ const { footerHintsRow1, footerHintsRow2 } = useFooterHintRows(footerHints, dims.width);
202
203
 
203
204
  // ── Hooks ─────────────────────────────────────────────────────────────────
204
205
 
@@ -334,168 +335,19 @@ export function App() {
334
335
  }))
335
336
  : [];
336
337
 
337
- // ── Footer hints (context-aware) ─────────────────────────────────────────
338
-
339
- const footerHints = useMemo((): [string, string][] => {
340
- // Filter mode: only show filter-specific controls
341
- if (filterMode) {
342
- return [
343
- [GLYPHS.enter, "confirm"],
344
- [GLYPHS.esc, "cancel"],
345
- ["^U", "clear"],
346
- ];
347
- }
348
-
349
- const isHelm = activeResourceRef?.kind.toLowerCase() === "helmreleases";
350
- const hasResource = Boolean(activeResourceRef);
351
- const hasPortForwards =
352
- (activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0;
353
-
354
- // Clusters pane: kind-list navigation only
355
- if (state.activePane === "clusters") {
356
- return [
357
- [GLYPHS.tab, "panes"],
358
- ["↑↓", "kinds"],
359
- ["R", "refresh"],
360
- ];
361
- }
362
-
363
- // Inspector pane: navigation back, refresh
364
- if (state.activePane === "inspector") {
365
- return [
366
- [GLYPHS.tab, "panes"],
367
- ["←", "back"],
368
- ["R", "refresh"],
369
- ];
370
- }
371
-
372
- // Resources pane — base hints always present
373
- const hints: [string, string][] = [
374
- [GLYPHS.tab, "panes"],
375
- ["↑↓", "navigate"],
376
- ["/", "filter"],
377
- ["R", "refresh"],
378
- ];
379
-
380
- if (!hasResource) return hints;
381
-
382
- if (isHelm) {
383
- hints.push(
384
- ["E", "edit values"],
385
- ...(deleteTargets.length > 0 ? [["D", "delete"] as [string, string]] : []),
386
- ["B", "rollback"],
387
- ["U", "upgrade"],
388
- );
389
- } else {
390
- hints.push(["L", "logs"], ["E", "edit"], ["X", "shell"]);
391
- if (hasPortForwards) hints.push(["F", "forward"]);
392
- if (deleteTargets.length > 0) hints.push(["D", "delete"]);
393
- if (activeResourceRef && kubectl.canScale(activeResourceRef)) hints.push(["⇧S", "scale"]);
394
- if (activeResourceRef && kubectl.canRolloutRestart(activeResourceRef)) hints.push(["⇧R", "restart"]);
395
- }
396
-
397
- return hints;
398
- }, [
399
- filterMode,
400
- state.activePane,
401
- activeResourceRef,
402
- activeDetail,
403
- selectedResourcePortForwards,
404
- deleteTargets,
405
- ]);
406
-
407
- // Split footerHints across two rows so hints never overflow.
408
- // Row 1: primary hints, Row 2: overflow hints.
409
- // Each badge: key.length + label.length + 3 chars; columnGap: 2 between items.
410
- const { footerHintsRow1, footerHintsRow2 } = useMemo(() => {
411
- const innerWidth = dims.width - 4; // 2 border + 2 padding
412
- const avail1 = innerWidth;
413
- const avail2 = innerWidth;
414
- const row1: [string, string][] = [];
415
- const row2: [string, string][] = [];
416
- let used1 = 0;
417
- let used2 = 0;
418
- for (const [key, label] of footerHints) {
419
- const w = key.length + label.length + 3;
420
- const gap1 = row1.length === 0 ? 0 : 2;
421
- const gap2 = row2.length === 0 ? 0 : 2;
422
- if (used1 + gap1 + w <= avail1) {
423
- used1 += gap1 + w;
424
- row1.push([key, label]);
425
- } else if (used2 + gap2 + w <= avail2) {
426
- used2 += gap2 + w;
427
- row2.push([key, label]);
428
- }
429
- // else drop — can't fit either row
430
- }
431
- return { footerHintsRow1: row1, footerHintsRow2: row2 };
432
- }, [footerHints, dims.width]);
433
-
434
338
  // ── Render ────────────────────────────────────────────────────────────────
435
339
 
436
340
  return (
437
341
  <box style={{ flexDirection: "column", flexGrow: 1, backgroundColor: SURFACE }}>
438
- {/* Header bar */}
439
- <box
440
- onMouseDown={() => dispatch({ type: "setActivePane", pane: "clusters" })}
441
- style={{
442
- height: 3,
443
- border: true,
444
- borderColor: PANEL_BORDER,
445
- borderStyle: "rounded",
446
- backgroundColor: SURFACE_ACCENT,
447
- paddingLeft: 1,
448
- paddingRight: 1,
449
- justifyContent: "center",
450
- alignItems: "center",
451
- flexDirection: "row",
452
- }}
453
- >
454
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
455
- {state.activeContext
456
- ? `${state.activeContext} / ${state.activeNamespace} / ${selectedKind?.name ?? "..."}`
457
- : state.statusMessage}
458
- </text>
459
- </box>
342
+ <Header
343
+ activeContext={state.activeContext}
344
+ activeNamespace={state.activeNamespace}
345
+ selectedKindLabel={selectedKindLabel}
346
+ statusMessage={state.statusMessage}
347
+ onActivate={() => dispatch({ type: "setActivePane", pane: "clusters" })}
348
+ />
460
349
 
461
- {/* Footer bar status + hotkeys across two rows */}
462
- <box
463
- style={{
464
- height: 4,
465
- border: true,
466
- borderColor: footerPalette.border,
467
- borderStyle: "rounded",
468
- backgroundColor: footerPalette.bg,
469
- paddingLeft: 1,
470
- paddingRight: 1,
471
- flexDirection: "column",
472
- }}
473
- >
474
- {/* Row 1: first set of key hints */}
475
- <box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
476
- <box style={{ flexDirection: "row", columnGap: 2 }}>
477
- {footerHintsRow1.map(([key, label]) => (
478
- <text key={`${key}:${label}`} fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
479
- {"["}
480
- <span fg={KEY_HINT}>{key}</span>
481
- {`] ${label}`}
482
- </text>
483
- ))}
484
- </box>
485
- </box>
486
- {/* Row 2: overflow hints, right-aligned */}
487
- <box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
488
- <box style={{ flexDirection: "row", columnGap: 2 }}>
489
- {footerHintsRow2.map(([key, label]) => (
490
- <text key={`${key}:${label}`} fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
491
- {"["}
492
- <span fg={KEY_HINT}>{key}</span>
493
- {`] ${label}`}
494
- </text>
495
- ))}
496
- </box>
497
- </box>
498
- </box>
350
+ <Footer hintRows={[footerHintsRow1, footerHintsRow2]} />
499
351
 
500
352
  {/* Main content area */}
501
353
  <box style={{ flexGrow: 1, flexDirection: "row", columnGap: 1, padding: 1, backgroundColor: SURFACE }}>
@@ -538,7 +390,7 @@ export function App() {
538
390
 
539
391
  {/* Resources pane */}
540
392
  <box
541
- title={`${PANE_TITLES.resources}: ${currentKindLabel({ resourceKinds: state.resourceKinds, selectedKind: state.selectedKind })}`}
393
+ title={`${PANE_TITLES.resources}: ${selectedKindLabel}`}
542
394
  onMouseDown={() => dispatch({ type: "setActivePane", pane: "resources" })}
543
395
  style={{
544
396
  flexGrow: 1,
@@ -569,7 +421,7 @@ export function App() {
569
421
  flexDirection: "row",
570
422
  }}
571
423
  >
572
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
424
+ <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
573
425
  Filter:
574
426
  </text>
575
427
  <input
@@ -0,0 +1,131 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+
3
+ import { GLYPHS, KEY_HINT, TEXT_PRIMARY, TEXT_SUBTLE, toneStyles } from "../theme";
4
+ import type { EventItem, ResourceDetailLine, ResourceDetailSection } from "../../lib/k8s/types";
5
+
6
+ export interface EventListProps {
7
+ events: EventItem[];
8
+ status: string;
9
+ }
10
+
11
+ export function EventList({ events, status }: EventListProps) {
12
+ if (status === "loading") {
13
+ return (
14
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
15
+ Loading events...
16
+ </text>
17
+ );
18
+ }
19
+
20
+ if (events.length === 0) {
21
+ return (
22
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
23
+ No related events
24
+ </text>
25
+ );
26
+ }
27
+
28
+ return (
29
+ <box style={{ flexDirection: "column", width: "100%" }}>
30
+ {events.map((event, index) => {
31
+ const isWarning = event.type.toLowerCase() === "warning";
32
+ const palette = toneStyles(isWarning ? "warning" : "info");
33
+ const typeGlyph = isWarning ? GLYPHS.warn : GLYPHS.dot;
34
+
35
+ return (
36
+ <box key={`${event.reason}:${event.age}:${index}`} style={{ flexDirection: "column", marginBottom: 1 }}>
37
+ <text fg={palette.fg}>
38
+ <span fg={palette.border}>{typeGlyph}</span>
39
+ {` ${event.type} `}
40
+ <span fg={TEXT_SUBTLE}>{GLYPHS.sep}</span>
41
+ {` ${event.reason} ${event.age}`}
42
+ </text>
43
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{` ${event.source} ${event.message}`}</text>
44
+ </box>
45
+ );
46
+ })}
47
+ </box>
48
+ );
49
+ }
50
+
51
+ export interface DetailSectionsInternalProps {
52
+ sections: ResourceDetailSection[];
53
+ revealedIds: string[];
54
+ revealedSecretValues: Record<string, string>;
55
+ onToggleReveal: (id: string) => void;
56
+ onCopyText: (text: string, label?: string) => void;
57
+ }
58
+
59
+ export function DetailSectionsInternal({ sections, revealedIds, revealedSecretValues, onToggleReveal, onCopyText }: DetailSectionsInternalProps) {
60
+ return (
61
+ <box style={{ flexDirection: "column", width: "100%" }}>
62
+ {sections.map((section) => (
63
+ <box key={section.title} style={{ flexDirection: "column", marginBottom: 1 }}>
64
+ <text fg={KEY_HINT}>
65
+ {"─ "}
66
+ <span fg={TEXT_PRIMARY} attributes={TextAttributes.BOLD}>{section.title}</span>
67
+ </text>
68
+ {section.lines.map((line, index) => {
69
+ const structuredLine: ResourceDetailLine | undefined =
70
+ typeof line === "string" ? undefined : line;
71
+ const lineId = structuredLine?.id ?? line as string;
72
+ const isRevealable = !!(structuredLine?.revealable && structuredLine.revealedText);
73
+ const isRevealed = isRevealable && revealedIds.includes(structuredLine!.id);
74
+
75
+ const fetchedValue = structuredLine ? revealedSecretValues[structuredLine.id] : undefined;
76
+
77
+ const rawText = structuredLine?.text ?? (line as string);
78
+ const displayText = isRevealable && isRevealed
79
+ ? (fetchedValue ?? structuredLine!.revealedText ?? rawText)
80
+ : rawText;
81
+ const copyValue = fetchedValue ?? structuredLine?.revealedText ?? rawText;
82
+ const hasCopyButton = structuredLine !== undefined;
83
+
84
+ const copyLabel: string | undefined = structuredLine
85
+ ? (() => {
86
+ const eqIdx = rawText.indexOf("=");
87
+ if (eqIdx > 0) return rawText.slice(0, eqIdx);
88
+ const spaceIdx = rawText.indexOf(" ");
89
+ return spaceIdx > 0 ? rawText.slice(0, spaceIdx) : rawText;
90
+ })()
91
+ : undefined;
92
+
93
+ return (
94
+ <box
95
+ key={`${section.title}:${index}:${lineId}`}
96
+ style={{ flexDirection: "row", width: "100%" }}
97
+ >
98
+ <box
99
+ onMouseDown={isRevealable ? () => onToggleReveal(structuredLine!.id) : undefined}
100
+ style={{ flexGrow: 1 }}
101
+ >
102
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
103
+ {" "}
104
+ {displayText}
105
+ </text>
106
+ </box>
107
+ {isRevealable && (
108
+ <box
109
+ onMouseDown={() => onToggleReveal(structuredLine!.id)}
110
+ style={{ paddingLeft: 1 }}
111
+ >
112
+ <text fg={KEY_HINT} attributes={TextAttributes.DIM}>
113
+ {isRevealed ? "[hide]" : "[reveal]"}
114
+ </text>
115
+ </box>
116
+ )}
117
+ {hasCopyButton && (
118
+ <box onMouseDown={() => onCopyText(copyValue, copyLabel)} style={{ paddingLeft: 1 }}>
119
+ <text fg={KEY_HINT} attributes={TextAttributes.DIM}>
120
+ {"[copy]"}
121
+ </text>
122
+ </box>
123
+ )}
124
+ </box>
125
+ );
126
+ })}
127
+ </box>
128
+ ))}
129
+ </box>
130
+ );
131
+ }