openk8s 1.0.4 → 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 +5 -2
  18. package/src/app/components/overlays/logs-dialog.tsx +50 -80
  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 +45 -17
  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 +11 -5
  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 +39 -39
  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
@@ -17,7 +17,7 @@ export function KindRows({ resourceKinds, selectedKind, onSelect, onActivate }:
17
17
 
18
18
  useEffect(() => {
19
19
  scrollRef.current?.scrollChildIntoView(`kind:${selectedKind}`);
20
- }, [selectedKind]);
20
+ }, [selectedKind, resourceKinds]);
21
21
 
22
22
  if (resourceKinds.length === 0) {
23
23
  return (
@@ -0,0 +1,145 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { TextAttributes } from "@opentui/core";
3
+
4
+ import { GLYPHS, KEY_HINT, OVERLAY_SURFACE, TEXT_PRIMARY, TEXT_SUBTLE, toneStyles } from "../theme";
5
+ import {
6
+ NOTIFICATION_PROGRESS_INTERVAL_MS,
7
+ NOTIFICATION_SLIDE_FRAMES,
8
+ NOTIFICATION_SLIDE_INTERVAL_MS,
9
+ TOAST_DURATION_MS,
10
+ } from "../constants";
11
+ import type { Notification } from "../../lib/k8s/types";
12
+
13
+ export interface NotificationCardProps {
14
+ notification: Notification;
15
+ onDismiss: (id: string) => void;
16
+ onAction: (notificationId: string, actionId: string) => void;
17
+ }
18
+
19
+ const TONE_ICONS: Record<Notification["tone"], string> = {
20
+ info: GLYPHS.dotEmpty,
21
+ success: GLYPHS.dot,
22
+ warning: GLYPHS.warn,
23
+ danger: GLYPHS.cross,
24
+ };
25
+
26
+ const SLIDE_DISTANCE = 15;
27
+
28
+ function easeOut(t: number): number {
29
+ return 1 - Math.pow(1 - t, 2);
30
+ }
31
+
32
+ export function NotificationCard({ notification, onDismiss, onAction }: NotificationCardProps) {
33
+ const palette = toneStyles(notification.tone);
34
+ const icon = TONE_ICONS[notification.tone];
35
+
36
+ const [phase, setPhase] = useState<"entering" | "visible" | "exiting">("entering");
37
+ const [slideOffset, setSlideOffset] = useState(SLIDE_DISTANCE);
38
+ const [progress, setProgress] = useState(1);
39
+
40
+ const slideFrameRef = useRef(0);
41
+ const progressRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
42
+
43
+ // Slide-in animation
44
+ useEffect(() => {
45
+ const interval = setInterval(() => {
46
+ slideFrameRef.current += 1;
47
+ const frame = slideFrameRef.current;
48
+ if (frame >= NOTIFICATION_SLIDE_FRAMES) {
49
+ setSlideOffset(0);
50
+ setPhase("visible");
51
+ clearInterval(interval);
52
+ return;
53
+ }
54
+ const t = frame / NOTIFICATION_SLIDE_FRAMES;
55
+ setSlideOffset(Math.round(SLIDE_DISTANCE * (1 - easeOut(t))));
56
+ }, NOTIFICATION_SLIDE_INTERVAL_MS);
57
+ return () => clearInterval(interval);
58
+ }, []);
59
+
60
+ // Progress bar (non-persistent only)
61
+ useEffect(() => {
62
+ if (notification.persistent || phase !== "visible") return;
63
+
64
+ const startTime = Date.now();
65
+ progressRef.current = setInterval(() => {
66
+ const elapsed = Date.now() - startTime;
67
+ const remaining = Math.max(0, 1 - elapsed / TOAST_DURATION_MS);
68
+ setProgress(remaining);
69
+ if (remaining <= 0) {
70
+ if (progressRef.current) clearInterval(progressRef.current);
71
+ }
72
+ }, NOTIFICATION_PROGRESS_INTERVAL_MS);
73
+
74
+ return () => {
75
+ if (progressRef.current) clearInterval(progressRef.current);
76
+ };
77
+ }, [notification.persistent, phase]);
78
+
79
+ const handleDismiss = (): void => {
80
+ if (phase === "exiting") return;
81
+ if (progressRef.current) clearInterval(progressRef.current);
82
+ setPhase("exiting");
83
+ slideFrameRef.current = 0;
84
+ const interval = setInterval(() => {
85
+ slideFrameRef.current += 1;
86
+ const frame = slideFrameRef.current;
87
+ if (frame >= NOTIFICATION_SLIDE_FRAMES) {
88
+ clearInterval(interval);
89
+ onDismiss(notification.id);
90
+ return;
91
+ }
92
+ const t = frame / NOTIFICATION_SLIDE_FRAMES;
93
+ setSlideOffset(Math.round(SLIDE_DISTANCE * easeOut(t)));
94
+ }, NOTIFICATION_SLIDE_INTERVAL_MS);
95
+ };
96
+
97
+ const barWidth = Math.round(progress * 20);
98
+
99
+ return (
100
+ <box
101
+ style={{
102
+ border: true,
103
+ borderColor: palette.border,
104
+ borderStyle: "rounded",
105
+ backgroundColor: OVERLAY_SURFACE,
106
+ paddingLeft: 1,
107
+ paddingRight: 1,
108
+ flexDirection: "column",
109
+ marginLeft: slideOffset,
110
+ }}
111
+ onMouseDown={handleDismiss}
112
+ >
113
+ <box style={{ flexDirection: "row", alignItems: "center" }}>
114
+ <text fg={palette.fg}>
115
+ {icon}
116
+ </text>
117
+ <text fg={TEXT_PRIMARY} wrapMode="word" style={{ flexGrow: 1, marginLeft: 1 }}>
118
+ {notification.message}
119
+ </text>
120
+ </box>
121
+ {notification.actions.length > 0 ? (
122
+ <box style={{ flexDirection: "row", columnGap: 2, marginTop: 0 }}>
123
+ {notification.actions.map((action) => (
124
+ <text
125
+ key={action.id}
126
+ fg={KEY_HINT}
127
+ attributes={TextAttributes.BOLD}
128
+ onMouseDown={(e: unknown) => {
129
+ (e as { stopPropagation?: () => void })?.stopPropagation?.();
130
+ onAction(notification.id, action.id);
131
+ }}
132
+ >
133
+ {`[${action.label}]`}
134
+ </text>
135
+ ))}
136
+ {!notification.persistent && (
137
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
138
+ {`${"█".repeat(barWidth)}${"░".repeat(20 - barWidth)}`}
139
+ </text>
140
+ )}
141
+ </box>
142
+ ) : null}
143
+ </box>
144
+ );
145
+ }
@@ -1,59 +1,40 @@
1
- import { GLYPHS, OVERLAY_SURFACE, TEXT_SUBTLE, toneStyles } from "../theme";
1
+ import { MAX_VISIBLE_NOTIFICATIONS, NOTIFICATION_WIDTH_PCT } from "../constants";
2
2
  import type { Notification } from "../../lib/k8s/types";
3
+ import { NotificationCard } from "./notification-card";
3
4
 
4
5
  export interface NotificationTrayProps {
5
6
  notifications: Notification[];
6
7
  onDismiss: (id: string) => void;
8
+ onAction: (notificationId: string, actionId: string) => void;
7
9
  }
8
10
 
9
- export function NotificationTray({ notifications, onDismiss }: NotificationTrayProps) {
11
+ export function NotificationTray({ notifications, onDismiss, onAction }: NotificationTrayProps) {
10
12
  if (notifications.length === 0) {
11
- return undefined;
13
+ return null;
12
14
  }
13
15
 
14
- // Show at most 3, most recent last (rendered top-to-bottom)
15
- const visible = notifications.slice(-3);
16
+ // Show at most MAX_VISIBLE_NOTIFICATIONS, most recent last (rendered bottom-to-top)
17
+ const visible = notifications.slice(-MAX_VISIBLE_NOTIFICATIONS);
16
18
 
17
19
  return (
18
20
  <box
19
21
  style={{
20
22
  position: "absolute",
21
- left: "55%",
22
- top: 3,
23
- width: "44%",
23
+ bottom: 5,
24
+ right: 1,
25
+ width: NOTIFICATION_WIDTH_PCT,
24
26
  flexDirection: "column",
25
- rowGap: 1,
27
+ rowGap: 0,
26
28
  }}
27
29
  >
28
- {visible.map((notification) => {
29
- const palette = toneStyles(notification.tone);
30
-
31
- return (
32
- <box
33
- key={notification.id}
34
- style={{
35
- border: true,
36
- borderColor: palette.border,
37
- borderStyle: "rounded",
38
- backgroundColor: OVERLAY_SURFACE,
39
- paddingLeft: 1,
40
- paddingRight: 1,
41
- paddingTop: 0,
42
- paddingBottom: 0,
43
- flexDirection: "row",
44
- justifyContent: "space-between",
45
- alignItems: "center",
46
- }}
47
- >
48
- <text fg={palette.fg} wrapMode="word" style={{ flexShrink: 1, flexGrow: 1 }}>
49
- {notification.message}
50
- </text>
51
- <text fg={TEXT_SUBTLE} onMouseDown={() => onDismiss(notification.id)}>
52
- {` ${GLYPHS.stop}`}
53
- </text>
54
- </box>
55
- );
56
- })}
30
+ {visible.map((notification) => (
31
+ <NotificationCard
32
+ key={notification.id}
33
+ notification={notification}
34
+ onDismiss={onDismiss}
35
+ onAction={onAction}
36
+ />
37
+ ))}
57
38
  </box>
58
39
  );
59
40
  }
@@ -3,13 +3,12 @@ import { TextAttributes } from "@opentui/core";
3
3
  import {
4
4
  DANGER,
5
5
  GLYPHS,
6
- KEY_HINT,
7
6
  OVERLAY_SURFACE,
8
- TEXT_MUTED,
7
+ ATTR_DIM,
9
8
  TEXT_SUBTLE,
10
9
  toneStyles,
11
10
  } from "../../theme";
12
- import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
11
+ import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH, KeyHintRow } from "./shared";
13
12
  import { statusLine } from "../../utils";
14
13
  import type { ResourceRef } from "../../../lib/k8s/types";
15
14
 
@@ -20,7 +19,11 @@ export interface DeleteConfirmOverlayProps {
20
19
  export function DeleteConfirmOverlay({ targets }: DeleteConfirmOverlayProps) {
21
20
  const palette = toneStyles("danger");
22
21
  const title =
23
- targets.length <= 1 ? `Delete ${statusLine({ ref: targets[0] })}?` : `Delete ${targets.length} selected resources?`;
22
+ targets.length === 0
23
+ ? "No selection"
24
+ : targets.length === 1
25
+ ? `Delete ${statusLine({ ref: targets[0] })}?`
26
+ : `Delete ${targets.length} selected resources?`;
24
27
  const overlayHeight = Math.min(22, Math.max(9, targets.length + 8));
25
28
 
26
29
  return (
@@ -43,8 +46,12 @@ export function DeleteConfirmOverlay({ targets }: DeleteConfirmOverlayProps) {
43
46
  <text fg={palette.fg} attributes={TextAttributes.BOLD}>
44
47
  {title}
45
48
  </text>
46
- {targets.length <= 1 ? (
47
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
49
+ {targets.length === 0 ? (
50
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
51
+ Nothing selected for deletion.
52
+ </text>
53
+ ) : targets.length === 1 ? (
54
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
48
55
  Runs kubectl delete on the current selection.
49
56
  </text>
50
57
  ) : (
@@ -54,7 +61,7 @@ export function DeleteConfirmOverlay({ targets }: DeleteConfirmOverlayProps) {
54
61
  <text
55
62
  key={`${target.kind}:${target.namespace ?? "cluster"}:${target.name}`}
56
63
  fg={TEXT_SUBTLE}
57
- attributes={TEXT_MUTED}
64
+ attributes={ATTR_DIM}
58
65
  >
59
66
  <span fg={DANGER}>{GLYPHS.cross}</span>
60
67
  {" "}
@@ -64,16 +71,7 @@ export function DeleteConfirmOverlay({ targets }: DeleteConfirmOverlayProps) {
64
71
  </box>
65
72
  </scrollbox>
66
73
  )}
67
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
68
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
69
- <span fg={DANGER}>{GLYPHS.enter}</span>
70
- {" confirm"}
71
- </text>
72
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
73
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
74
- {" cancel"}
75
- </text>
76
- </box>
74
+ <KeyHintRow hints={[[GLYPHS.enter, "confirm"], [GLYPHS.esc, "cancel"]]} />
77
75
  </box>
78
76
  );
79
77
  }
@@ -1,16 +1,7 @@
1
1
  import { TextAttributes } from "@opentui/core";
2
2
 
3
- import {
4
- FILTER_BACKGROUND,
5
- GLYPHS,
6
- KEY_HINT,
7
- OVERLAY_SURFACE,
8
- PANEL_BORDER_ACTIVE,
9
- TEXT_MUTED,
10
- TEXT_SUBTLE,
11
- toneStyles,
12
- } from "../../theme";
13
- import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
3
+ import { GLYPHS, ATTR_DIM, TEXT_SUBTLE, toneStyles } from "../../theme";
4
+ import { DialogFrame, KeyHintRow, TextInput } from "./shared";
14
5
 
15
6
  export interface HelmRollbackOverlayProps {
16
7
  releaseName: string;
@@ -22,65 +13,20 @@ export function HelmRollbackOverlay({ releaseName, revisionValue, onChange }: He
22
13
  const palette = toneStyles("warning");
23
14
 
24
15
  return (
25
- <box
26
- style={{
27
- position: "absolute",
28
- left: DIALOG_LEFT,
29
- top: DIALOG_TOP,
30
- width: DIALOG_WIDTH,
31
- height: 12,
32
- border: true,
33
- borderStyle: "rounded",
34
- borderColor: palette.border,
35
- backgroundColor: OVERLAY_SURFACE,
36
- padding: 1,
37
- flexDirection: "column",
38
- }}
39
- title="Helm Rollback"
40
- >
16
+ <DialogFrame title="Helm Rollback" tone="warning" height={12}>
41
17
  <text fg={palette.fg} attributes={TextAttributes.BOLD}>
42
18
  {releaseName}
43
19
  </text>
44
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
20
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
45
21
  {"Leave revision empty to roll back to previous version"}
46
22
  </text>
47
- <box
48
- style={{
49
- border: true,
50
- borderColor: PANEL_BORDER_ACTIVE,
51
- borderStyle: "rounded",
52
- backgroundColor: FILTER_BACKGROUND,
53
- paddingLeft: 1,
54
- paddingRight: 1,
55
- marginTop: 1,
56
- height: 3,
57
- justifyContent: "center",
58
- alignItems: "center",
59
- flexDirection: "row",
60
- }}
61
- >
62
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
63
- Revision:
64
- </text>
65
- <input
66
- focused
67
- value={revisionValue}
68
- placeholder="(previous)"
69
- style={{ flexGrow: 1, marginLeft: 1 }}
70
- onInput={onChange}
71
- onChange={onChange}
72
- />
73
- </box>
74
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
75
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
76
- <span fg={KEY_HINT}>{GLYPHS.enter}</span>
77
- {" rollback"}
78
- </text>
79
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
80
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
81
- {" cancel"}
82
- </text>
83
- </box>
84
- </box>
23
+ <TextInput
24
+ label="Revision:"
25
+ value={revisionValue}
26
+ placeholder="(previous)"
27
+ onChange={onChange}
28
+ />
29
+ <KeyHintRow hints={[[GLYPHS.enter, "rollback"], [GLYPHS.esc, "cancel"]]} />
30
+ </DialogFrame>
85
31
  );
86
32
  }
@@ -0,0 +1,173 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+
4
+ import { GLYPHS, OVERLAY_SURFACE, PANEL_BORDER_ACTIVE, KEY_HINT, TEXT_SUBTLE, ATTR_DIM, toneStyles } from "../../theme";
5
+ import { KeyHintRow } from "./shared";
6
+
7
+ interface HelpSection {
8
+ title: string;
9
+ rows: [string, string][];
10
+ }
11
+
12
+ const LEFT_SECTIONS: HelpSection[] = [
13
+ {
14
+ title: "Navigation",
15
+ rows: [
16
+ ["Tab / Shift+Tab", "cycle panes"],
17
+ ["Up/Down or k/j", "navigate list"],
18
+ ["Left or h", "back / prev tab"],
19
+ ["Right", "forward / next tab"],
20
+ ["Space", "multi-select resource"],
21
+ ],
22
+ },
23
+ {
24
+ title: "Inspector Tabs",
25
+ rows: [
26
+ ["S", "summary tab"],
27
+ ["Y", "yaml tab (press again to copy)"],
28
+ ["V", "events tab"],
29
+ ["I", "describe tab"],
30
+ ],
31
+ },
32
+ {
33
+ title: "Resource Actions",
34
+ rows: [
35
+ ["L", "logs"],
36
+ ["E", "edit (or edit helm values)"],
37
+ ["X", "shell (kubectl exec)"],
38
+ ["F", "port forward (per-resource)"],
39
+ ["D", "delete"],
40
+ ["Shift+S", "scale"],
41
+ ["Shift+R", "rollout restart"],
42
+ ],
43
+ },
44
+ {
45
+ title: "Helm Actions",
46
+ rows: [
47
+ ["B", "rollback"],
48
+ ["U", "upgrade --reuse-values"],
49
+ ],
50
+ },
51
+ ];
52
+
53
+ const RIGHT_SECTIONS: HelpSection[] = [
54
+ {
55
+ title: "Overlays",
56
+ rows: [
57
+ ["C", "cluster switcher"],
58
+ ["N", "namespace switcher"],
59
+ ["Shift+N", "notification history"],
60
+ ["Shift+F", "active port forwards"],
61
+ ["?", "this help"],
62
+ ],
63
+ },
64
+ {
65
+ title: "Filter",
66
+ rows: [
67
+ ["/", "enter filter mode"],
68
+ ["Enter", "confirm filter"],
69
+ ["Escape", "cancel filter"],
70
+ ["Ctrl+U", "clear filter"],
71
+ ],
72
+ },
73
+ {
74
+ title: "Logs Overlay",
75
+ rows: [
76
+ ["/", "search"],
77
+ ["N / Shift+N", "next / prev match"],
78
+ ["C", "switch container"],
79
+ ["P", "toggle previous logs"],
80
+ ["T", "set tail lines"],
81
+ ["S", "set since duration"],
82
+ ["Up/Down", "scroll logs"],
83
+ ],
84
+ },
85
+ {
86
+ title: "Port Forward List",
87
+ rows: [
88
+ ["X", "stop selected forward"],
89
+ ["C", "stop all forwards"],
90
+ ],
91
+ },
92
+ {
93
+ title: "Global",
94
+ rows: [
95
+ ["R", "refresh current view"],
96
+ ["Escape", "close overlay"],
97
+ ["Ctrl+C", "quit"],
98
+ ],
99
+ },
100
+ ];
101
+
102
+ const ALL_SECTIONS = [...LEFT_SECTIONS, ...RIGHT_SECTIONS];
103
+
104
+ const TWO_COLUMN_THRESHOLD = 90;
105
+
106
+ function renderSection(section: HelpSection, index: number, keyWidth: number): React.ReactNode {
107
+ return (
108
+ <box key={section.title} style={{ flexDirection: "column", width: "100%" }}>
109
+ {index > 0 && <box style={{ height: 1 }} />}
110
+ <text fg={toneStyles("accent").fg} attributes={TextAttributes.BOLD}>
111
+ {section.title}
112
+ </text>
113
+ {section.rows.map(([key, label], rowIdx) => (
114
+ <box key={`${index}:${rowIdx}`} style={{ flexDirection: "row", width: "100%", paddingLeft: 2 }}>
115
+ <box style={{ width: keyWidth }}>
116
+ <text fg={KEY_HINT}>{key}</text>
117
+ </box>
118
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
119
+ {label}
120
+ </text>
121
+ </box>
122
+ ))}
123
+ </box>
124
+ );
125
+ }
126
+
127
+ function renderColumn(sections: HelpSection[], keyWidth: number): React.ReactNode {
128
+ return (
129
+ <box style={{ flexDirection: "column", flexGrow: 1, width: "100%" }}>
130
+ {sections.map((section, index) => renderSection(section, index, keyWidth))}
131
+ </box>
132
+ );
133
+ }
134
+
135
+ export function HelpOverlay() {
136
+ const dims = useTerminalDimensions();
137
+ const twoColumns = dims.width >= TWO_COLUMN_THRESHOLD;
138
+
139
+ const left = twoColumns ? "8%" : "16%";
140
+ const width = twoColumns ? "84%" : "68%";
141
+ const keyWidth = twoColumns ? 20 : 24;
142
+
143
+ return (
144
+ <box
145
+ style={{
146
+ position: "absolute",
147
+ left,
148
+ top: "6%",
149
+ width,
150
+ height: "86%",
151
+ border: true,
152
+ borderColor: PANEL_BORDER_ACTIVE,
153
+ borderStyle: "rounded",
154
+ backgroundColor: OVERLAY_SURFACE,
155
+ padding: 1,
156
+ flexDirection: "column",
157
+ }}
158
+ title="Keyboard Shortcuts"
159
+ >
160
+ <scrollbox style={{ flexGrow: 1 }} focused>
161
+ {twoColumns ? (
162
+ <box style={{ flexDirection: "row", width: "100%", columnGap: 2 }}>
163
+ {renderColumn(LEFT_SECTIONS, keyWidth)}
164
+ {renderColumn(RIGHT_SECTIONS, keyWidth)}
165
+ </box>
166
+ ) : (
167
+ renderColumn(ALL_SECTIONS, keyWidth)
168
+ )}
169
+ </scrollbox>
170
+ <KeyHintRow hints={[[GLYPHS.esc, "close"]]} />
171
+ </box>
172
+ );
173
+ }
@@ -1,4 +1,4 @@
1
- export { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH, KeyHintRow } from "./shared";
1
+ export { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH, DialogFrame, KeyHintRow, TextInput } from "./shared";
2
2
  export { SelectOverlay, type SelectOverlayProps } from "./select-overlay";
3
3
  export { DeleteConfirmOverlay, type DeleteConfirmOverlayProps } from "./delete-confirm-overlay";
4
4
  export {
@@ -7,6 +7,9 @@ export {
7
7
  type PortForwardOverlayProps,
8
8
  buildPortForwardEntries,
9
9
  } from "./port-forward-overlay";
10
- export { LogsDialog, type LogsDialogActions, type LogsDialogProps, logLineColor } from "./logs-dialog";
10
+ export { PortForwardListOverlay, type PortForwardListOverlayProps } from "./port-forward-list-overlay";
11
+ export { LogsDialog, type LogsDialogActions, type LogsDialogProps } from "./logs-dialog";
11
12
  export { ScaleDialog, type ScaleDialogProps } from "./scale-dialog";
12
13
  export { HelmRollbackOverlay, type HelmRollbackOverlayProps } from "./helm-rollback-overlay";
14
+ export { NotificationHistoryOverlay, type NotificationHistoryOverlayProps } from "./notification-history-overlay";
15
+ export { HelpOverlay } from "./help-overlay";