openk8s 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +11 -10
  3. package/src/app/__tests__/app-state.test.ts +47 -5
  4. package/src/app/__tests__/utils.test.ts +5 -14
  5. package/src/app/app-actions.ts +5 -0
  6. package/src/app/app-state.ts +11 -2
  7. package/src/app/app.tsx +57 -46
  8. package/src/app/components/detail-sections.tsx +2 -2
  9. package/src/app/components/footer.tsx +23 -30
  10. package/src/app/components/inspector.tsx +28 -43
  11. package/src/app/components/kind-rows.tsx +1 -1
  12. package/src/app/components/notification-card.tsx +145 -0
  13. package/src/app/components/notification-tray.tsx +19 -38
  14. package/src/app/components/overlays/delete-confirm-overlay.tsx +15 -17
  15. package/src/app/components/overlays/helm-rollback-overlay.tsx +12 -66
  16. package/src/app/components/overlays/help-overlay.tsx +173 -0
  17. package/src/app/components/overlays/index.ts +4 -1
  18. package/src/app/components/overlays/logs-dialog.tsx +47 -67
  19. package/src/app/components/overlays/notification-history-overlay.tsx +79 -0
  20. package/src/app/components/overlays/port-forward-list-overlay.tsx +85 -0
  21. package/src/app/components/overlays/port-forward-overlay.tsx +16 -56
  22. package/src/app/components/overlays/scale-dialog.tsx +12 -67
  23. package/src/app/components/overlays/select-overlay.tsx +5 -14
  24. package/src/app/components/overlays/shared.tsx +85 -6
  25. package/src/app/components/resource-rows.tsx +1 -1
  26. package/src/app/constants.ts +24 -0
  27. package/src/app/hooks/keyboard/filter-handlers.ts +2 -1
  28. package/src/app/hooks/keyboard/global-handlers.ts +32 -11
  29. package/src/app/hooks/keyboard/helm-handlers.ts +14 -9
  30. package/src/app/hooks/keyboard/keys.ts +18 -0
  31. package/src/app/hooks/keyboard/logs-handlers.ts +3 -1
  32. package/src/app/hooks/keyboard/navigation-handlers.ts +5 -4
  33. package/src/app/hooks/keyboard/overlay-handlers.ts +11 -7
  34. package/src/app/hooks/keyboard/port-forward-handlers.ts +19 -14
  35. package/src/app/hooks/keyboard/shell-edit-handlers.ts +31 -15
  36. package/src/app/hooks/use-app-keyboard.ts +22 -2
  37. package/src/app/hooks/use-app-side-effects.ts +10 -7
  38. package/src/app/hooks/use-clipboard.ts +8 -10
  39. package/src/app/hooks/use-data-fetching.ts +8 -4
  40. package/src/app/hooks/use-log-stream.ts +8 -4
  41. package/src/app/hooks/use-notifications.ts +92 -0
  42. package/src/app/hooks/use-port-forward.ts +19 -4
  43. package/src/app/persistence.ts +7 -3
  44. package/src/app/syntax-theme.ts +31 -0
  45. package/src/app/theme.ts +2 -3
  46. package/src/app/use-footer-hints.ts +21 -16
  47. package/src/app/utils.ts +1 -9
  48. package/src/assets/tree-sitter/yaml/highlights.scm +79 -0
  49. package/src/assets/tree-sitter/yaml/tree-sitter-yaml.wasm +0 -0
  50. package/src/index.tsx +22 -2
  51. package/src/lib/k8s/__tests__/k8s-format.test.ts +17 -0
  52. package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
  53. package/src/lib/k8s/detail-builders/event-builder.ts +17 -11
  54. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
  55. package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
  56. package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
  57. package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
  58. package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
  59. package/src/lib/k8s/k8s-format.ts +14 -9
  60. package/src/lib/k8s/resource-detail-builder.ts +1 -1
  61. package/src/lib/k8s/resource-parser.ts +17 -2
  62. package/src/lib/k8s/types.ts +10 -1
  63. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
  64. package/src/lib/kubectl/kubectl-helpers.ts +16 -11
  65. package/src/lib/kubectl/kubectl-service.ts +38 -38
  66. package/src/lib/kubectl/kubectl-types.ts +10 -1
  67. package/src/lib/kubectl/metrics-utils.ts +21 -7
  68. package/src/lib/kubectl/spawn-utils.ts +50 -11
  69. package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
  70. package/src/app/components/inspector-tokens.ts +0 -93
  71. package/src/app/components/port-forwards-tray.tsx +0 -57
package/README.md CHANGED
@@ -155,7 +155,7 @@ Three resizable panes: resource kinds (left), resources (center), inspector (rig
155
155
 
156
156
  - **Context & namespace switching** — browse any cluster in your kubeconfig, any namespace
157
157
  - **Resource browsing** — auto-discovers API resource kinds and lists resources with status, age, and summary; curates a sensible default ordering
158
- - **Log streaming** — `kubectl logs --follow` with search, container picker, tail count, since duration, and previous logs toggle; Deployments/StatefulSets use `--all-pods`
158
+ - **Log streaming** — `kubectl logs --follow` with search, container picker, tail count, since duration, and previous logs toggle; Deployments/StatefulSets use `--all-containers`
159
159
  - **Shell** — drop into a container via `kubectl exec -it sh` (renderer suspends, resumes on exit)
160
160
  - **Edit** — `kubectl edit` for resources, temp-file + `helm upgrade --reuse-values` for HelmReleases (opens `$EDITOR`)
161
161
  - **Port-forward** — declare ports, start/stop forwards inline with live status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openk8s",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "A terminal UI for Kubernetes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,8 @@
34
34
  "dev": "bun run --watch src/index.tsx",
35
35
  "prepare": "husky || exit 0",
36
36
  "test": "bun test",
37
- "test:watch": "bun test --watch"
37
+ "test:watch": "bun test --watch",
38
+ "typecheck": "tsc --noEmit"
38
39
  },
39
40
  "release": {
40
41
  "branches": [
@@ -42,18 +43,18 @@
42
43
  ]
43
44
  },
44
45
  "devDependencies": {
45
- "@commitlint/cli": "^21.0.1",
46
- "@commitlint/config-conventional": "^21.0.1",
46
+ "@commitlint/cli": "^21.0.2",
47
+ "@commitlint/config-conventional": "^21.0.2",
47
48
  "@types/bun": "latest",
48
- "@types/node": "^25.8.0",
49
- "@types/react": "^19.2.14",
49
+ "@types/node": "^25.9.3",
50
+ "@types/react": "^19.2.17",
50
51
  "husky": "^9.1.7",
51
- "semantic-release": "^25.0.3",
52
+ "semantic-release": "^25.0.5",
52
53
  "typescript": "^6.0.3"
53
54
  },
54
55
  "dependencies": {
55
- "@opentui/core": "^0.2.12",
56
- "@opentui/react": "^0.2.12",
57
- "react": "^19.2.6"
56
+ "@opentui/core": "^0.4.1",
57
+ "@opentui/react": "^0.4.1",
58
+ "react": "^19.2.7"
58
59
  }
59
60
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { reducer, initialState, DEFAULT_NAMESPACE } from "../app-state";
3
+ import { NOTIFICATION_HISTORY_LIMIT } from "../constants";
3
4
  import type { AppAction } from "../app-actions";
4
5
  import type { AppState, ResourceListItem, ActivePortForward, Notification } from "../../lib/k8s/types";
5
6
 
@@ -253,21 +254,62 @@ describe("reducer", () => {
253
254
 
254
255
  // ── Notifications ────────────────────────────────────────────────────────
255
256
 
257
+ function makeNotification(overrides: Partial<Notification> = {}): Notification {
258
+ return {
259
+ id: "n-1",
260
+ tone: "info",
261
+ message: "Hello",
262
+ persistent: false,
263
+ createdAt: Date.now(),
264
+ actions: [],
265
+ ...overrides,
266
+ };
267
+ }
268
+
256
269
  describe("pushNotification", () => {
257
270
  test("adds a notification", () => {
258
- const notification: Notification = { id: "n-1", tone: "info", message: "Hello" };
271
+ const notification = makeNotification();
259
272
  const state = reduce({ type: "pushNotification", notification });
260
273
  expect(state.notifications).toHaveLength(1);
261
274
  });
262
275
  });
263
276
 
264
277
  describe("dismissNotification", () => {
265
- test("removes a notification", () => {
266
- const n1: Notification = { id: "n-1", tone: "info", message: "Hello" };
267
- const n2: Notification = { id: "n-2", tone: "success", message: "Done" };
268
- const state = reduce({ type: "dismissNotification", id: "n-1" }, { notifications: [n1, n2] });
278
+ test("removes a notification and adds to history", () => {
279
+ const n1 = makeNotification({ id: "n-1" });
280
+ const n2 = makeNotification({ id: "n-2", tone: "success", message: "Done" });
281
+ const state = reduce({ type: "dismissNotification", id: "n-1" }, { notifications: [n1, n2], notificationHistory: [] });
269
282
  expect(state.notifications).toHaveLength(1);
270
283
  expect(state.notifications[0]?.id).toBe("n-2");
284
+ expect(state.notificationHistory).toHaveLength(1);
285
+ expect(state.notificationHistory[0]?.id).toBe("n-1");
286
+ });
287
+
288
+ test("does not add to history if notification not found", () => {
289
+ const state = reduce({ type: "dismissNotification", id: "missing" }, { notifications: [], notificationHistory: [] });
290
+ expect(state.notificationHistory).toHaveLength(0);
291
+ });
292
+ });
293
+
294
+ describe("clearNotificationHistory", () => {
295
+ test("clears all history", () => {
296
+ const n1 = makeNotification({ id: "n-1" });
297
+ const state = reduce({ type: "clearNotificationHistory" }, { notificationHistory: [n1] });
298
+ expect(state.notificationHistory).toHaveLength(0);
299
+ });
300
+ });
301
+
302
+ describe("notification history cap", () => {
303
+ test("caps history at NOTIFICATION_HISTORY_LIMIT", () => {
304
+ const notifications = Array.from({ length: NOTIFICATION_HISTORY_LIMIT + 5 }, (_, i) =>
305
+ makeNotification({ id: `n-${i}` }),
306
+ );
307
+ let state: { notifications: Notification[]; notificationHistory: Notification[] } = { notifications: [], notificationHistory: [] };
308
+ for (const n of notifications) {
309
+ state = reduce({ type: "pushNotification", notification: n }, state);
310
+ state = reduce({ type: "dismissNotification", id: n.id }, state);
311
+ }
312
+ expect(state.notificationHistory.length).toBeLessThanOrEqual(NOTIFICATION_HISTORY_LIMIT);
271
313
  });
272
314
  });
273
315
 
@@ -10,7 +10,6 @@ import {
10
10
  parseLocalPort,
11
11
  statusTone,
12
12
  kindDescription,
13
- currentKindLabel,
14
13
  resourcePreview,
15
14
  normalizeFilter,
16
15
  filterResources,
@@ -187,19 +186,6 @@ describe("kindDescription", () => {
187
186
  });
188
187
  });
189
188
 
190
- // ── currentKindLabel ─────────────────────────────────────────────────────────
191
-
192
- describe("currentKindLabel", () => {
193
- test("finds kind by name", () => {
194
- const kinds: ResourceKind[] = [{ name: "pods", namespaced: true, shortNames: ["po"] }];
195
- expect(currentKindLabel({ resourceKinds: kinds, selectedKind: "pods" })).toBe("pods");
196
- });
197
-
198
- test("falls back to selected kind string", () => {
199
- expect(currentKindLabel({ resourceKinds: [], selectedKind: "pods" })).toBe("pods");
200
- });
201
- });
202
-
203
189
  // ── resourcePreview ──────────────────────────────────────────────────────────
204
190
 
205
191
  describe("resourcePreview", () => {
@@ -357,6 +343,11 @@ describe("truncate", () => {
357
343
  test("handles single-char limit", () => {
358
344
  expect(truncate("ab", 1)).toBe("\u2026");
359
345
  });
346
+
347
+ test("handles zero maxLen", () => {
348
+ expect(truncate("abc", 0)).toBe("\u2026");
349
+ expect(truncate("", 0)).toBe("");
350
+ });
360
351
  });
361
352
 
362
353
  // ── formatClusterName ─────────────────────────────────────────────────────────
@@ -198,6 +198,10 @@ export interface DismissNotificationAction {
198
198
  id: string;
199
199
  }
200
200
 
201
+ export interface ClearNotificationHistoryAction {
202
+ type: "clearNotificationHistory";
203
+ }
204
+
201
205
  export interface SetRevealedSecretValueAction {
202
206
  type: "setRevealedSecretValue";
203
207
  id: string;
@@ -256,6 +260,7 @@ export type AppAction =
256
260
  | SetLogsOptionsAction
257
261
  | PushNotificationAction
258
262
  | DismissNotificationAction
263
+ | ClearNotificationHistoryAction
259
264
  | SetRevealedSecretValueAction
260
265
  | SetPodMetricsAction
261
266
  | SetNodeMetricsAction
@@ -1,5 +1,6 @@
1
1
  import type { AppAction } from "./app-actions";
2
- import type { AppState, PaneId } from "../lib/k8s/types";
2
+ import type { AppState, Notification, PaneId } from "../lib/k8s/types";
3
+ import { NOTIFICATION_HISTORY_LIMIT } from "./constants";
3
4
 
4
5
  export { type AppAction } from "./app-actions";
5
6
 
@@ -39,6 +40,7 @@ export const initialState: AppState = {
39
40
  revealedSecretValues: {},
40
41
  activePortForwards: [],
41
42
  notifications: [],
43
+ notificationHistory: [],
42
44
  podMetrics: {},
43
45
  nodeMetrics: {},
44
46
  helmRollbackRevision: "",
@@ -189,11 +191,18 @@ export function reducer(state: AppState, action: AppAction): AppState {
189
191
  ...state,
190
192
  notifications: [...state.notifications, action.notification],
191
193
  };
192
- case "dismissNotification":
194
+ case "dismissNotification": {
195
+ const dismissed = state.notifications.find((n) => n.id === action.id);
196
+ if (!dismissed) return state;
197
+ const history = [dismissed, ...state.notificationHistory].slice(0, NOTIFICATION_HISTORY_LIMIT);
193
198
  return {
194
199
  ...state,
195
200
  notifications: state.notifications.filter((n) => n.id !== action.id),
201
+ notificationHistory: history,
196
202
  };
203
+ }
204
+ case "clearNotificationHistory":
205
+ return { ...state, notificationHistory: [] };
197
206
  case "setRevealedSecretValue":
198
207
  return {
199
208
  ...state,
package/src/app/app.tsx CHANGED
@@ -1,9 +1,17 @@
1
1
  import { type ScrollBoxRenderable } from "@opentui/core";
2
2
  import { useRenderer, useTerminalDimensions } from "@opentui/react";
3
- import { useCallback, useMemo, useReducer, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
4
4
 
5
5
  import { DEFAULT_NAMESPACE, initialState, reducer } from "./app-state";
6
6
  import { loadPersistedState } from "./persistence";
7
+ import {
8
+ INSPECTOR_MIN_WIDTH,
9
+ INSPECTOR_PANE_WIDTH,
10
+ KIND_PANE_WIDTH,
11
+ POLL_CLUSTER_MS,
12
+ POLL_METRICS_MS,
13
+ POLL_RESOURCE_MS,
14
+ } from "./constants";
7
15
  import {
8
16
  FILTER_BACKGROUND,
9
17
  GLYPHS,
@@ -14,13 +22,12 @@ import {
14
22
  PANEL_BORDER_ACTIVE,
15
23
  RESOURCE_SURFACE,
16
24
  SURFACE,
17
- TEXT_MUTED,
25
+ ATTR_DIM,
18
26
  TEXT_PRIMARY,
19
27
  TEXT_SUBTLE,
20
28
  paneBorder,
21
29
  } from "./theme";
22
30
  import {
23
- currentKindLabel,
24
31
  filterResources,
25
32
  formatClusterName,
26
33
  nextVisibleResourceName,
@@ -32,7 +39,7 @@ import {
32
39
  } from "./utils";
33
40
  import { usePollingTick } from "./use-polling-tick";
34
41
  import { useFooterHints, useFooterHintRows } from "./use-footer-hints";
35
- import { InspectorBody, InspectorTabs, INSPECTOR_TABS } from "./components/inspector";
42
+ import { InspectorBody, InspectorTabs } from "./components/inspector";
36
43
  import { KindRows } from "./components/kind-rows";
37
44
  import { ResourceRows } from "./components/resource-rows";
38
45
  import { Header } from "./components/header";
@@ -42,17 +49,19 @@ import {
42
49
  HelmRollbackOverlay,
43
50
  LogsDialog,
44
51
  type LogsDialogActions,
52
+ NotificationHistoryOverlay,
53
+ HelpOverlay,
45
54
  PortForwardOverlay,
55
+ PortForwardListOverlay,
46
56
  ScaleDialog,
47
57
  SelectOverlay,
48
58
  buildPortForwardEntries,
49
59
  } from "./components/overlays";
50
- import { PortForwardsTray } from "./components/port-forwards-tray";
51
60
  import { NotificationTray } from "./components/notification-tray";
52
61
  import { KubectlService } from "../lib/kubectl/kubectl-service";
53
62
  import type { ClusterContext, NamespaceItem, OverlayId, PaneId, ResourceDetailLine } from "../lib/k8s/types";
54
- import type { NotificationTone } from "../lib/k8s/types";
55
63
 
64
+ import { useNotifications } from "./hooks/use-notifications";
56
65
  import { useClipboard } from "./hooks/use-clipboard";
57
66
  import { usePortForward } from "./hooks/use-port-forward";
58
67
  import { useLogStream } from "./hooks/use-log-stream";
@@ -73,10 +82,13 @@ const OVERLAY_TITLES: Record<OverlayId, string> = {
73
82
  "namespace-switcher": "Namespace Switcher",
74
83
  "delete-confirm": "Confirm Delete",
75
84
  "port-forward": "Port Forward",
85
+ "port-forward-list": "Port Forwards",
76
86
  logs: "Logs",
77
87
  scale: "Scale",
78
88
  "container-picker": "Select Container",
79
89
  "helm-rollback": "Helm Rollback",
90
+ "notification-history": "Notification History",
91
+ help: "Keyboard Shortcuts",
80
92
  };
81
93
 
82
94
  export function App() {
@@ -86,8 +98,7 @@ export function App() {
86
98
  const { activeContext, activeNamespace } = loadPersistedState();
87
99
  return {
88
100
  ...initialState,
89
- ...(activeContext !== undefined ? { activeContext } : {}),
90
- ...(activeNamespace !== undefined ? { activeNamespace } : {}),
101
+ ...(activeContext !== undefined ? { activeContext } : {}), ...(activeNamespace !== undefined ? { activeNamespace } : {}),
91
102
  };
92
103
  });
93
104
  const [overlayIndex, setOverlayIndex] = useState(0);
@@ -102,11 +113,12 @@ export function App() {
102
113
 
103
114
  const logsScrollRef = useRef<ScrollBoxRenderable | null>(null);
104
115
  const logsActionsRef = useRef<LogsDialogActions | null>(null);
105
- const notificationTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
106
116
 
107
- const clusterPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: 30_000 });
108
- const viewPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: 5_000 });
109
- const metricsPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: 15_000 });
117
+ const { toast, toastError, dismiss: dismissNotification, executeAction: executeNotificationAction } = useNotifications(dispatch);
118
+
119
+ const clusterPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: POLL_CLUSTER_MS });
120
+ const viewPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: POLL_RESOURCE_MS });
121
+ const metricsPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: POLL_METRICS_MS });
110
122
 
111
123
  const selectedKind = useMemo(
112
124
  () => state.resourceKinds.find((kind) => kind.name === state.selectedKind),
@@ -191,31 +203,18 @@ export function App() {
191
203
  );
192
204
 
193
205
  const activeForwardKeys = useMemo(
194
- () => new Set(state.activePortForwards.map((f) => `${f.ref.kind.toLowerCase()}:${f.ref.name}`)),
206
+ () => new Set(state.activePortForwards.map((f) => `${f.ref.kind.toLowerCase()}:${f.ref.namespace ?? "cluster"}:${f.ref.name}`)),
195
207
  [state.activePortForwards],
196
208
  );
197
209
 
198
- const selectedKindLabel = useMemo(
199
- () => currentKindLabel({ resourceKinds: state.resourceKinds, selectedKind: state.selectedKind }),
200
- [state.resourceKinds, state.selectedKind],
201
- );
210
+ const selectedKindLabel = state.selectedKind;
202
211
 
203
- const footerHints = useFooterHints(filterMode, state, activeResourceRef, activeDetail, selectedResourcePortForwards, deleteTargets, kubectl);
212
+ const footerHints = useFooterHints(filterMode, state.activePane, activeResourceRef, activeDetail, selectedResourcePortForwards, state.activePortForwards.length, deleteTargets, kubectl);
204
213
  const { footerHintsRow1, footerHintsRow2 } = useFooterHintRows(footerHints, dims.width);
205
214
 
206
215
  // ── Hooks ─────────────────────────────────────────────────────────────────
207
216
 
208
- const toast = useCallback((tone: NotificationTone, message: string): void => {
209
- const id = `notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
210
- dispatch({ type: "pushNotification", notification: { id, tone, message } });
211
- const timer = setTimeout(() => {
212
- notificationTimersRef.current.delete(id);
213
- dispatch({ type: "dismissNotification", id });
214
- }, 2_500);
215
- notificationTimersRef.current.set(id, timer);
216
- }, []);
217
-
218
- const { copyToClipboard, toastError } = useClipboard(dispatch, toast);
217
+ const { copyToClipboard } = useClipboard(dispatch, toast);
219
218
 
220
219
  const { portForwardProcessesRef, stopPortForward, startPortForward, openPortForwardOverlay: rawOpenPortForwardOverlay } = usePortForward(dispatch, kubectl, toastError);
221
220
 
@@ -235,7 +234,14 @@ export function App() {
235
234
  setFilterMode,
236
235
  );
237
236
 
238
- useAppSideEffects(state, dispatch, portForwardProcessesRef, notificationTimersRef, activeResourceRef);
237
+ useAppSideEffects(state, dispatch, portForwardProcessesRef, activeResourceRef);
238
+
239
+ // If the terminal is too narrow to show the inspector, don't let focus rest on it.
240
+ useEffect(() => {
241
+ if (state.activePane === "inspector" && dims.width < INSPECTOR_MIN_WIDTH) {
242
+ dispatch({ type: "setActivePane", pane: "resources" });
243
+ }
244
+ }, [state.activePane, dims.width]);
239
245
 
240
246
  const openPortForwardOverlay = useCallback(() => {
241
247
  rawOpenPortForwardOverlay(activeDetail, selectedResourcePortForwards, setPortForwardLocalPort, setOverlayIndex, dispatch);
@@ -358,7 +364,7 @@ export function App() {
358
364
  title={PANE_TITLES.clusters}
359
365
  onMouseDown={() => dispatch({ type: "setActivePane", pane: "clusters" })}
360
366
  style={{
361
- width: 34,
367
+ width: KIND_PANE_WIDTH,
362
368
  border: true,
363
369
  borderColor: paneBorder(state.activePane === "clusters"),
364
370
  borderStyle: "rounded",
@@ -374,7 +380,7 @@ export function App() {
374
380
  </text>
375
381
  </box>
376
382
  <box style={{ width: "100%" }}>
377
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
383
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
378
384
  <span fg={KEY_HINT}>{GLYPHS.ns}</span>
379
385
  {` ${truncate(state.activeNamespace, 26)}`}
380
386
  </text>
@@ -385,7 +391,7 @@ export function App() {
385
391
  onActivate={() => dispatch({ type: "setActivePane", pane: "clusters" })}
386
392
  onSelect={(kind) => dispatch({ type: "setSelectedKind", kind })}
387
393
  />
388
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
394
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
389
395
  {"["}
390
396
  <span fg={KEY_HINT}>C</span>
391
397
  {"] cluster ["}
@@ -427,7 +433,7 @@ export function App() {
427
433
  flexDirection: "row",
428
434
  }}
429
435
  >
430
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
436
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
431
437
  Filter:
432
438
  </text>
433
439
  <input
@@ -456,12 +462,12 @@ export function App() {
456
462
  </box>
457
463
 
458
464
  {/* Inspector pane — hidden when terminal is too narrow */}
459
- {dims.width >= 110 && (
465
+ {dims.width >= INSPECTOR_MIN_WIDTH && (
460
466
  <box
461
467
  title={PANE_TITLES.inspector}
462
468
  onMouseDown={() => dispatch({ type: "setActivePane", pane: "inspector" })}
463
469
  style={{
464
- width: 52,
470
+ width: INSPECTOR_PANE_WIDTH,
465
471
  border: true,
466
472
  borderColor: paneBorder(state.activePane === "inspector"),
467
473
  borderStyle: "rounded",
@@ -496,18 +502,10 @@ export function App() {
496
502
  )}
497
503
  </box>
498
504
 
499
- <PortForwardsTray forwards={state.activePortForwards} onStop={stopPortForward} />
500
-
501
505
  <NotificationTray
502
506
  notifications={state.notifications}
503
- onDismiss={(id) => {
504
- const timer = notificationTimersRef.current.get(id);
505
- if (timer) {
506
- clearTimeout(timer);
507
- notificationTimersRef.current.delete(id);
508
- }
509
- dispatch({ type: "dismissNotification", id });
510
- }}
507
+ onDismiss={dismissNotification}
508
+ onAction={executeNotificationAction}
511
509
  />
512
510
 
513
511
  {/* Overlays */}
@@ -552,6 +550,19 @@ export function App() {
552
550
  revisionValue={state.helmRollbackRevision}
553
551
  onChange={(value) => dispatch({ type: "setHelmRollbackRevision", value })}
554
552
  />
553
+ ) : state.overlay === "port-forward-list" ? (
554
+ <PortForwardListOverlay
555
+ forwards={state.activePortForwards}
556
+ selectedIndex={overlayIndex}
557
+ onChange={setOverlayIndex}
558
+ />
559
+ ) : state.overlay === "notification-history" ? (
560
+ <NotificationHistoryOverlay
561
+ history={state.notificationHistory}
562
+ onClear={() => dispatch({ type: "clearNotificationHistory" })}
563
+ />
564
+ ) : state.overlay === "help" ? (
565
+ <HelpOverlay />
555
566
  ) : (
556
567
  <SelectOverlay
557
568
  title={OVERLAY_TITLES[state.overlay]}
@@ -59,8 +59,8 @@ export interface DetailSectionsInternalProps {
59
59
  export function DetailSectionsInternal({ sections, revealedIds, revealedSecretValues, onToggleReveal, onCopyText }: DetailSectionsInternalProps) {
60
60
  return (
61
61
  <box style={{ flexDirection: "column", width: "100%" }}>
62
- {sections.map((section) => (
63
- <box key={section.title} style={{ flexDirection: "column", marginBottom: 1 }}>
62
+ {sections.map((section, sectionIndex) => (
63
+ <box key={`${sectionIndex}:${section.title}`} style={{ flexDirection: "column", marginBottom: 1 }}>
64
64
  <text fg={KEY_HINT}>
65
65
  {"─ "}
66
66
  <span fg={TEXT_PRIMARY} attributes={TextAttributes.BOLD}>{section.title}</span>
@@ -1,16 +1,29 @@
1
- import { TextAttributes } from "@opentui/core";
2
-
3
- import { KEY_HINT, TEXT_MUTED, TEXT_SUBTLE, toneStyles } from "../theme";
1
+ import { KEY_HINT, ATTR_DIM, TEXT_SUBTLE, toneStyles } from "../theme";
2
+ import { FOOTER_PADDING } from "../constants";
4
3
 
5
4
  export interface FooterProps {
6
5
  hintRows: [string, string][][];
7
6
  }
8
7
 
8
+ function FooterHintRow({ hints }: { hints: [string, string][] }) {
9
+ return (
10
+ <box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
11
+ <box style={{ flexDirection: "row", columnGap: 2 }}>
12
+ {hints.map(([key, label], index) => (
13
+ <text key={`${index}:${key}:${label}`} fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
14
+ {"["}
15
+ <span fg={KEY_HINT}>{key}</span>
16
+ {`] ${label}`}
17
+ </text>
18
+ ))}
19
+ </box>
20
+ </box>
21
+ );
22
+ }
23
+
9
24
  export function Footer({ hintRows }: FooterProps) {
10
- const [row1, row2] = hintRows;
11
25
  const palette = toneStyles("neutral");
12
- const textFg = TEXT_SUBTLE;
13
- const dimAttr = TEXT_MUTED;
26
+ const [row1, row2] = hintRows;
14
27
 
15
28
  return (
16
29
  <box
@@ -20,33 +33,13 @@ export function Footer({ hintRows }: FooterProps) {
20
33
  borderColor: palette.border,
21
34
  borderStyle: "rounded",
22
35
  backgroundColor: palette.bg,
23
- paddingLeft: 1,
24
- paddingRight: 1,
36
+ paddingLeft: FOOTER_PADDING,
37
+ paddingRight: FOOTER_PADDING,
25
38
  flexDirection: "column",
26
39
  }}
27
40
  >
28
- <box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
29
- <box style={{ flexDirection: "row", columnGap: 2 }}>
30
- {(row1 ?? []).map(([key, label]) => (
31
- <text key={`${key}:${label}`} fg={textFg} attributes={dimAttr}>
32
- {"["}
33
- <span fg={KEY_HINT}>{key}</span>
34
- {`] ${label}`}
35
- </text>
36
- ))}
37
- </box>
38
- </box>
39
- <box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
40
- <box style={{ flexDirection: "row", columnGap: 2 }}>
41
- {(row2 ?? []).map(([key, label]) => (
42
- <text key={`${key}:${label}`} fg={textFg} attributes={dimAttr}>
43
- {"["}
44
- <span fg={KEY_HINT}>{key}</span>
45
- {`] ${label}`}
46
- </text>
47
- ))}
48
- </box>
49
- </box>
41
+ <FooterHintRow hints={row1 ?? []} />
42
+ <FooterHintRow hints={row2 ?? []} />
50
43
  </box>
51
44
  );
52
45
  }
@@ -9,7 +9,7 @@ import {
9
9
  TEXT_PRIMARY,
10
10
  TEXT_SUBTLE,
11
11
  } from "../theme";
12
- import { tokenizeYamlLine, tokenizeDescribeLine } from "./inspector-tokens";
12
+ import { yamlSyntaxStyle } from "../syntax-theme";
13
13
  import { EventList, DetailSectionsInternal } from "./detail-sections";
14
14
  import type { EventItem, InspectorTab, PaneId, ResourceDetail } from "../../lib/k8s/types";
15
15
 
@@ -101,41 +101,33 @@ export function InspectorBody({
101
101
  return (
102
102
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
103
103
  <box style={{ height: 1 }} />
104
- <scrollbox
104
+ <box
105
105
  focused={active}
106
- scrollX
107
- horizontalScrollbarOptions={{ showArrows: false }}
108
106
  onMouseDown={() => {
109
107
  onActivate();
110
108
  if (detail?.yaml) onCopyText(detail.yaml, "YAML");
111
109
  }}
112
110
  flexGrow={1}
113
111
  >
114
- <box style={{ flexDirection: "column", paddingRight: 1 }}>
115
- {detail ? (
116
- detail.yaml.split("\n").map((line, index) => {
117
- const tokens = tokenizeYamlLine(line);
118
- return (
119
- <box key={`${index}:${line}`} style={{ flexDirection: "row" }}>
120
- {tokens.map((token, tokenIndex) => (
121
- <text key={tokenIndex} fg={token.fg} wrapMode="none">
122
- {token.text}
123
- </text>
124
- ))}
125
- </box>
126
- );
127
- })
128
- ) : detailStatus === "loading" ? (
129
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
130
- Loading YAML...
131
- </text>
132
- ) : (
133
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
134
- No YAML available
135
- </text>
136
- )}
137
- </box>
138
- </scrollbox>
112
+ {detail ? (
113
+ <code
114
+ content={detail.yaml}
115
+ filetype="yaml"
116
+ syntaxStyle={yamlSyntaxStyle}
117
+ drawUnstyledText
118
+ wrapMode="none"
119
+ style={{ width: "100%", height: "100%" }}
120
+ />
121
+ ) : detailStatus === "loading" ? (
122
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
123
+ Loading YAML...
124
+ </text>
125
+ ) : (
126
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
127
+ No YAML available
128
+ </text>
129
+ )}
130
+ </box>
139
131
  </box>
140
132
  );
141
133
  }
@@ -153,18 +145,11 @@ export function InspectorBody({
153
145
  >
154
146
  <box style={{ flexDirection: "column", paddingRight: 1 }}>
155
147
  {detail?.describe ? (
156
- detail.describe.split("\n").map((line, index) => {
157
- const tokens = tokenizeDescribeLine(line);
158
- return (
159
- <box key={`${index}:${line}`} style={{ flexDirection: "row" }}>
160
- {tokens.map((token, tokenIndex) => (
161
- <text key={tokenIndex} fg={token.fg} wrapMode="none">
162
- {token.text}
163
- </text>
164
- ))}
165
- </box>
166
- );
167
- })
148
+ detail.describe.split("\n").map((line, index) => (
149
+ <text key={`${index}:${line}`} fg={TEXT_SUBTLE} wrapMode="none">
150
+ {line || " "}
151
+ </text>
152
+ ))
168
153
  ) : detailStatus === "loading" ? (
169
154
  <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
170
155
  Loading describe output...
@@ -200,8 +185,8 @@ export function InspectorBody({
200
185
  Loading resource detail...
201
186
  </text>
202
187
  ) : (
203
- fallbackLines.map((line) => (
204
- <text key={line} fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
188
+ fallbackLines.map((line, index) => (
189
+ <text key={`${index}:${line}`} fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
205
190
  {line}
206
191
  </text>
207
192
  ))