openk8s 1.0.5 → 1.2.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 (72) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -10
  3. package/src/app/__tests__/app-state.test.ts +48 -6
  4. package/src/app/__tests__/utils.test.ts +6 -15
  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 +40 -83
  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 +171 -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 +30 -21
  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 +32 -16
  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 +10 -6
  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-detail-builder.test.ts +4 -6
  53. package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
  54. package/src/lib/k8s/detail-builders/event-builder.ts +18 -12
  55. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
  56. package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
  57. package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
  58. package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
  59. package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
  60. package/src/lib/k8s/k8s-format.ts +14 -9
  61. package/src/lib/k8s/resource-detail-builder.ts +4 -7
  62. package/src/lib/k8s/resource-parser.ts +17 -2
  63. package/src/lib/k8s/types.ts +12 -4
  64. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
  65. package/src/lib/kubectl/kubectl-helpers.ts +16 -11
  66. package/src/lib/kubectl/kubectl-service.ts +38 -54
  67. package/src/lib/kubectl/kubectl-types.ts +10 -1
  68. package/src/lib/kubectl/metrics-utils.ts +21 -7
  69. package/src/lib/kubectl/spawn-utils.ts +50 -11
  70. package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
  71. package/src/app/components/inspector-tokens.ts +0 -93
  72. package/src/app/components/port-forwards-tray.tsx +0 -57
@@ -0,0 +1,171 @@
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 (incl. recent events)"],
27
+ ["Y", "yaml tab (press again to copy)"],
28
+ ],
29
+ },
30
+ {
31
+ title: "Resource Actions",
32
+ rows: [
33
+ ["L", "logs"],
34
+ ["E", "edit (or edit helm values)"],
35
+ ["X", "shell (kubectl exec)"],
36
+ ["F", "port forward (per-resource)"],
37
+ ["D", "delete"],
38
+ ["Shift+S", "scale"],
39
+ ["Shift+R", "rollout restart"],
40
+ ],
41
+ },
42
+ {
43
+ title: "Helm Actions",
44
+ rows: [
45
+ ["B", "rollback"],
46
+ ["U", "upgrade --reuse-values"],
47
+ ],
48
+ },
49
+ ];
50
+
51
+ const RIGHT_SECTIONS: HelpSection[] = [
52
+ {
53
+ title: "Overlays",
54
+ rows: [
55
+ ["C", "cluster switcher"],
56
+ ["N", "namespace switcher"],
57
+ ["Shift+N", "notification history"],
58
+ ["Shift+F", "active port forwards"],
59
+ ["?", "this help"],
60
+ ],
61
+ },
62
+ {
63
+ title: "Filter",
64
+ rows: [
65
+ ["/", "enter filter mode"],
66
+ ["Enter", "confirm filter"],
67
+ ["Escape", "cancel filter"],
68
+ ["Ctrl+U", "clear filter"],
69
+ ],
70
+ },
71
+ {
72
+ title: "Logs Overlay",
73
+ rows: [
74
+ ["/", "search"],
75
+ ["N / Shift+N", "next / prev match"],
76
+ ["C", "switch container"],
77
+ ["P", "toggle previous logs"],
78
+ ["T", "set tail lines"],
79
+ ["S", "set since duration"],
80
+ ["Up/Down", "scroll logs"],
81
+ ],
82
+ },
83
+ {
84
+ title: "Port Forward List",
85
+ rows: [
86
+ ["X", "stop selected forward"],
87
+ ["C", "stop all forwards"],
88
+ ],
89
+ },
90
+ {
91
+ title: "Global",
92
+ rows: [
93
+ ["R", "refresh current view"],
94
+ ["Escape", "close overlay"],
95
+ ["Ctrl+C", "quit"],
96
+ ],
97
+ },
98
+ ];
99
+
100
+ const ALL_SECTIONS = [...LEFT_SECTIONS, ...RIGHT_SECTIONS];
101
+
102
+ const TWO_COLUMN_THRESHOLD = 90;
103
+
104
+ function renderSection(section: HelpSection, index: number, keyWidth: number): React.ReactNode {
105
+ return (
106
+ <box key={section.title} style={{ flexDirection: "column", width: "100%" }}>
107
+ {index > 0 && <box style={{ height: 1 }} />}
108
+ <text fg={toneStyles("accent").fg} attributes={TextAttributes.BOLD}>
109
+ {section.title}
110
+ </text>
111
+ {section.rows.map(([key, label], rowIdx) => (
112
+ <box key={`${index}:${rowIdx}`} style={{ flexDirection: "row", width: "100%", paddingLeft: 2 }}>
113
+ <box style={{ width: keyWidth }}>
114
+ <text fg={KEY_HINT}>{key}</text>
115
+ </box>
116
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
117
+ {label}
118
+ </text>
119
+ </box>
120
+ ))}
121
+ </box>
122
+ );
123
+ }
124
+
125
+ function renderColumn(sections: HelpSection[], keyWidth: number): React.ReactNode {
126
+ return (
127
+ <box style={{ flexDirection: "column", flexGrow: 1, width: "100%" }}>
128
+ {sections.map((section, index) => renderSection(section, index, keyWidth))}
129
+ </box>
130
+ );
131
+ }
132
+
133
+ export function HelpOverlay() {
134
+ const dims = useTerminalDimensions();
135
+ const twoColumns = dims.width >= TWO_COLUMN_THRESHOLD;
136
+
137
+ const left = twoColumns ? "8%" : "16%";
138
+ const width = twoColumns ? "84%" : "68%";
139
+ const keyWidth = twoColumns ? 20 : 24;
140
+
141
+ return (
142
+ <box
143
+ style={{
144
+ position: "absolute",
145
+ left,
146
+ top: "6%",
147
+ width,
148
+ height: "86%",
149
+ border: true,
150
+ borderColor: PANEL_BORDER_ACTIVE,
151
+ borderStyle: "rounded",
152
+ backgroundColor: OVERLAY_SURFACE,
153
+ padding: 1,
154
+ flexDirection: "column",
155
+ }}
156
+ title="Keyboard Shortcuts"
157
+ >
158
+ <scrollbox style={{ flexGrow: 1 }} focused>
159
+ {twoColumns ? (
160
+ <box style={{ flexDirection: "row", width: "100%", columnGap: 2 }}>
161
+ {renderColumn(LEFT_SECTIONS, keyWidth)}
162
+ {renderColumn(RIGHT_SECTIONS, keyWidth)}
163
+ </box>
164
+ ) : (
165
+ renderColumn(ALL_SECTIONS, keyWidth)
166
+ )}
167
+ </scrollbox>
168
+ <KeyHintRow hints={[[GLYPHS.esc, "close"]]} />
169
+ </box>
170
+ );
171
+ }
@@ -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 { PortForwardListOverlay, type PortForwardListOverlayProps } from "./port-forward-list-overlay";
10
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";
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
+ import type { MutableRefObject } from "react";
2
3
  import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
4
 
4
5
  import {
@@ -7,12 +8,13 @@ import {
7
8
  KEY_HINT,
8
9
  OVERLAY_SURFACE,
9
10
  PANEL_BORDER_ACTIVE,
10
- TEXT_MUTED,
11
+ ATTR_DIM,
11
12
  TEXT_SUBTLE,
12
13
  YAML_COMMENT,
13
14
  toneStyles,
14
15
  } from "../../theme";
15
16
  import type { LoadStatus, LogOptions } from "../../../lib/k8s/types";
17
+ import { KeyHintRow, TextInput } from "./shared";
16
18
 
17
19
  export interface LogsDialogActions {
18
20
  nextMatch: () => void;
@@ -33,7 +35,7 @@ export interface LogsDialogProps {
33
35
  searchMode?: boolean | undefined;
34
36
  searchText?: string | undefined;
35
37
  onSearchChange?: ((value: string) => void) | undefined;
36
- actionsRef?: React.MutableRefObject<LogsDialogActions | null> | undefined;
38
+ actionsRef?: MutableRefObject<LogsDialogActions | null> | undefined;
37
39
  }
38
40
 
39
41
  export function LogsDialog({
@@ -52,7 +54,7 @@ export function LogsDialog({
52
54
  onSearchChange,
53
55
  actionsRef,
54
56
  }: LogsDialogProps) {
55
- const lines = logsText ? logsText.split("\n") : [];
57
+ const lines = useMemo(() => (logsText ? logsText.split("\n") : []), [logsText]);
56
58
  const palette = toneStyles("info");
57
59
  const [currentMatchIdx, setCurrentMatchIdx] = useState(0);
58
60
 
@@ -63,18 +65,31 @@ export function LogsDialog({
63
65
  if (line.toLowerCase().includes(lower)) acc.push(i);
64
66
  return acc;
65
67
  }, []);
66
- // eslint-disable-next-line react-hooks/exhaustive-deps
67
- }, [logsText, searchText]);
68
+ }, [lines, searchText]);
68
69
 
70
+ // Reset to first match when search text changes, and clamp when matches shrink (e.g. new logs stream in)
69
71
  useEffect(() => {
70
- if (!searchText) return;
72
+ if (!searchText) {
73
+ setCurrentMatchIdx(0);
74
+ return;
75
+ }
71
76
  setCurrentMatchIdx(0);
72
77
  const firstLine = matchIndices[0];
73
78
  if (firstLine !== undefined) {
74
79
  scrollRef.current?.scrollTo(firstLine);
75
80
  }
76
- // eslint-disable-next-line react-hooks/exhaustive-deps
77
- }, [searchText]);
81
+ }, [searchText, matchIndices]);
82
+
83
+ // Keep currentMatchIdx in range when matchIndices shrinks
84
+ useEffect(() => {
85
+ if (matchIndices.length === 0) {
86
+ if (currentMatchIdx !== 0) setCurrentMatchIdx(0);
87
+ return;
88
+ }
89
+ if (currentMatchIdx >= matchIndices.length) {
90
+ setCurrentMatchIdx(matchIndices.length - 1);
91
+ }
92
+ }, [matchIndices, currentMatchIdx]);
78
93
 
79
94
  const nextMatch = () => {
80
95
  if (matchIndices.length === 0) return;
@@ -92,9 +107,11 @@ export function LogsDialog({
92
107
  if (lineIdx !== undefined) scrollRef.current?.scrollTo(lineIdx);
93
108
  };
94
109
 
95
- if (actionsRef) {
96
- actionsRef.current = { nextMatch, prevMatch };
97
- }
110
+ useEffect(() => {
111
+ if (actionsRef) {
112
+ actionsRef.current = { nextMatch, prevMatch };
113
+ }
114
+ });
98
115
 
99
116
  const warningFg = toneStyles("warning").fg;
100
117
  const multiContainer = (containers?.length ?? 0) > 1;
@@ -121,22 +138,22 @@ export function LogsDialog({
121
138
  >
122
139
  {/* Options status bar */}
123
140
  <box style={{ flexDirection: "row", columnGap: 2, marginBottom: 0 }}>
124
- <text fg={logsOptions.previous ? warningFg : TEXT_SUBTLE} attributes={TEXT_MUTED}>
141
+ <text fg={logsOptions.previous ? warningFg : TEXT_SUBTLE} attributes={ATTR_DIM}>
125
142
  <span fg={KEY_HINT}>P</span>
126
143
  {` prev:${logsOptions.previous ? "on" : "off"}`}
127
144
  </text>
128
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
145
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
129
146
  <span fg={KEY_HINT}>T</span>
130
147
  {` tail:${logsOptions.tail}`}
131
148
  </text>
132
149
  {logsOptions.since ? (
133
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
150
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
134
151
  <span fg={KEY_HINT}>S</span>
135
152
  {` since:${logsOptions.since}`}
136
153
  </text>
137
154
  ) : undefined}
138
155
  {multiContainer ? (
139
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
156
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
140
157
  <span fg={KEY_HINT}>C</span>
141
158
  {logsContainer ? ` ctr:${logsContainer}` : " all-ctrs"}
142
159
  </text>
@@ -195,33 +212,12 @@ export function LogsDialog({
195
212
 
196
213
  {/* Option input row */}
197
214
  {inputMode ? (
198
- <box
199
- style={{
200
- border: true,
201
- borderColor: PANEL_BORDER_ACTIVE,
202
- borderStyle: "rounded",
203
- backgroundColor: FILTER_BACKGROUND,
204
- paddingLeft: 1,
205
- paddingRight: 1,
206
- marginTop: 1,
207
- height: 3,
208
- justifyContent: "center",
209
- alignItems: "center",
210
- flexDirection: "row",
211
- }}
212
- >
213
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
214
- {inputMode === "tail" ? "Tail lines:" : "Since (e.g. 1h, 30m):"}
215
- </text>
216
- <input
217
- focused
218
- value={inputValue ?? ""}
219
- placeholder={inputMode === "tail" ? "120" : "1h"}
220
- style={{ flexGrow: 1, marginLeft: 1 }}
221
- onInput={onOptionInputChange}
222
- onChange={onOptionInputChange}
223
- />
224
- </box>
215
+ <TextInput
216
+ label={inputMode === "tail" ? "Tail lines:" : "Since (e.g. 1h, 30m):"}
217
+ value={inputValue ?? ""}
218
+ placeholder={inputMode === "tail" ? "120" : "1h"}
219
+ onChange={onOptionInputChange ?? (() => undefined)}
220
+ />
225
221
  ) : undefined}
226
222
 
227
223
  {/* Search input row */}
@@ -251,11 +247,11 @@ export function LogsDialog({
251
247
  onChange={onSearchChange}
252
248
  />
253
249
  {matchIndices.length > 0 ? (
254
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
250
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
255
251
  {` ${currentMatchIdx + 1}/${matchIndices.length}`}
256
252
  </text>
257
253
  ) : searchText ? (
258
- <text fg={toneStyles("danger").fg} attributes={TEXT_MUTED}>
254
+ <text fg={toneStyles("danger").fg} attributes={ATTR_DIM}>
259
255
  {" no match"}
260
256
  </text>
261
257
  ) : undefined}
@@ -263,29 +259,13 @@ export function LogsDialog({
263
259
  ) : undefined}
264
260
 
265
261
  {/* Hints */}
266
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
267
- {inputMode || searchMode ? (
268
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
269
- <span fg={KEY_HINT}>{GLYPHS.enter}</span>
270
- {" confirm"}
271
- </text>
272
- ) : (
273
- <>
274
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
275
- <span fg={KEY_HINT}>{"/"}</span>
276
- {" search"}
277
- </text>
278
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
279
- <span fg={KEY_HINT}>{"↑↓"}</span>
280
- {" scroll"}
281
- </text>
282
- </>
283
- )}
284
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
285
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
286
- {" close"}
287
- </text>
288
- </box>
262
+ <KeyHintRow
263
+ hints={
264
+ inputMode || searchMode
265
+ ? [[GLYPHS.enter, "confirm"], [GLYPHS.esc, "close"]]
266
+ : [["/", "search"], ["↑↓", "scroll"], [GLYPHS.esc, "close"]]
267
+ }
268
+ />
289
269
  </box>
290
270
  );
291
271
  }
@@ -0,0 +1,79 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+
3
+ import { GLYPHS, OVERLAY_SURFACE, PANEL_BORDER_ACTIVE, TEXT_PRIMARY, TEXT_SUBTLE, ATTR_DIM, toneStyles } from "../../theme";
4
+ import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH, KeyHintRow } from "./shared";
5
+ import type { Notification } from "../../../lib/k8s/types";
6
+
7
+ export interface NotificationHistoryOverlayProps {
8
+ history: Notification[];
9
+ onClear: () => void;
10
+ }
11
+
12
+ const TONE_ICONS: Record<Notification["tone"], string> = {
13
+ info: GLYPHS.dotEmpty,
14
+ success: GLYPHS.dot,
15
+ warning: GLYPHS.warn,
16
+ danger: GLYPHS.cross,
17
+ };
18
+
19
+ function formatTimeAgo(timestamp: number): string {
20
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
21
+ if (seconds < 60) return `${seconds}s ago`;
22
+ const minutes = Math.floor(seconds / 60);
23
+ if (minutes < 60) return `${minutes}m ago`;
24
+ const hours = Math.floor(minutes / 60);
25
+ if (hours < 24) return `${hours}h ago`;
26
+ const days = Math.floor(hours / 24);
27
+ return `${days}d ago`;
28
+ }
29
+
30
+ export function NotificationHistoryOverlay({ history, onClear }: NotificationHistoryOverlayProps) {
31
+ return (
32
+ <box
33
+ style={{
34
+ position: "absolute",
35
+ left: DIALOG_LEFT,
36
+ top: DIALOG_TOP,
37
+ width: DIALOG_WIDTH,
38
+ height: "60%",
39
+ border: true,
40
+ borderColor: PANEL_BORDER_ACTIVE,
41
+ borderStyle: "rounded",
42
+ backgroundColor: OVERLAY_SURFACE,
43
+ padding: 1,
44
+ flexDirection: "column",
45
+ }}
46
+ title="Notification History"
47
+ >
48
+ {history.length === 0 ? (
49
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
50
+ No notification history
51
+ </text>
52
+ ) : (
53
+ <scrollbox style={{ flexGrow: 1 }} focused>
54
+ <box style={{ flexDirection: "column", width: "100%" }}>
55
+ {history.map((notification, index) => {
56
+ const palette = toneStyles(notification.tone);
57
+ const icon = TONE_ICONS[notification.tone];
58
+ return (
59
+ <box
60
+ key={`${index}:${notification.id}`}
61
+ style={{ flexDirection: "row", marginBottom: 0, width: "100%" }}
62
+ >
63
+ <text fg={palette.fg}>{icon}</text>
64
+ <text fg={TEXT_PRIMARY} wrapMode="word" style={{ flexGrow: 1, marginLeft: 1 }}>
65
+ {notification.message}
66
+ </text>
67
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
68
+ {formatTimeAgo(notification.createdAt)}
69
+ </text>
70
+ </box>
71
+ );
72
+ })}
73
+ </box>
74
+ </scrollbox>
75
+ )}
76
+ <KeyHintRow hints={[["C", "clear history"], [GLYPHS.esc, "close"]]} />
77
+ </box>
78
+ );
79
+ }
@@ -0,0 +1,85 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+
3
+ import {
4
+ GLYPHS,
5
+ OVERLAY_SURFACE,
6
+ PANEL_BORDER_ACTIVE,
7
+ ROW_SELECTED,
8
+ TEXT_PRIMARY,
9
+ TEXT_SUBTLE,
10
+ } from "../../theme";
11
+ import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH, KeyHintRow } from "./shared";
12
+ import { statusLine } from "../../utils";
13
+ import type { ActivePortForward } from "../../../lib/k8s/types";
14
+
15
+ export interface PortForwardListOverlayProps {
16
+ forwards: ActivePortForward[];
17
+ selectedIndex: number;
18
+ onChange: (index: number) => void;
19
+ }
20
+
21
+ export function PortForwardListOverlay({ forwards, selectedIndex, onChange }: PortForwardListOverlayProps) {
22
+ const clampedIndex = forwards.length > 0 ? Math.min(selectedIndex, forwards.length - 1) : 0;
23
+
24
+ const items = forwards.map((f) => {
25
+ const isReady = f.status === "ready";
26
+ const dot = isReady ? GLYPHS.dot : GLYPHS.dotEmpty;
27
+ return {
28
+ name: `${dot} ${statusLine({ ref: f.ref })} localhost:${f.localPort} \u2192 :${f.remotePort}`,
29
+ description: f.status,
30
+ };
31
+ });
32
+
33
+ const hints: [string, string][] = [];
34
+ if (forwards.length > 0) {
35
+ hints.push(["X", "stop"]);
36
+ hints.push(["C", "stop all"]);
37
+ }
38
+ hints.push([GLYPHS.esc, "close"]);
39
+
40
+ return (
41
+ <box
42
+ style={{
43
+ position: "absolute",
44
+ left: DIALOG_LEFT,
45
+ top: DIALOG_TOP,
46
+ width: DIALOG_WIDTH,
47
+ height: "60%",
48
+ border: true,
49
+ borderColor: PANEL_BORDER_ACTIVE,
50
+ borderStyle: "rounded",
51
+ backgroundColor: OVERLAY_SURFACE,
52
+ padding: 1,
53
+ flexDirection: "column",
54
+ }}
55
+ title="Port Forwards"
56
+ >
57
+ {forwards.length === 0 ? (
58
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
59
+ No active port forwards
60
+ </text>
61
+ ) : (
62
+ <box style={{ flexGrow: 1 }}>
63
+ <select
64
+ focused
65
+ style={{ flexGrow: 1 }}
66
+ backgroundColor={OVERLAY_SURFACE}
67
+ focusedBackgroundColor={OVERLAY_SURFACE}
68
+ textColor={TEXT_PRIMARY}
69
+ focusedTextColor={TEXT_PRIMARY}
70
+ selectedBackgroundColor={ROW_SELECTED}
71
+ selectedTextColor={TEXT_PRIMARY}
72
+ descriptionColor={TEXT_SUBTLE}
73
+ selectedDescriptionColor={TEXT_PRIMARY}
74
+ showDescription
75
+ showScrollIndicator
76
+ selectedIndex={clampedIndex}
77
+ options={items}
78
+ onChange={onChange}
79
+ />
80
+ </box>
81
+ )}
82
+ <KeyHintRow hints={hints} />
83
+ </box>
84
+ );
85
+ }
@@ -1,17 +1,14 @@
1
1
  import { TextAttributes } from "@opentui/core";
2
2
 
3
3
  import {
4
- FILTER_BACKGROUND,
5
4
  GLYPHS,
6
5
  KEY_HINT,
7
6
  OVERLAY_SURFACE,
8
- PANEL_BORDER_ACTIVE,
9
- TEXT_MUTED,
10
7
  TEXT_PRIMARY,
11
8
  TEXT_SUBTLE,
12
9
  toneStyles,
13
10
  } from "../../theme";
14
- import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
11
+ import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH, KeyHintRow, TextInput } from "./shared";
15
12
  import type { ActivePortForward } from "../../../lib/k8s/types";
16
13
 
17
14
  export interface PortForwardEntry {
@@ -101,7 +98,7 @@ export function PortForwardOverlay({
101
98
 
102
99
  return (
103
100
  <text
104
- key={entry.remotePort}
101
+ key={`${entry.remotePort}:${entry.suggestedLocalPort}`}
105
102
  fg={selected ? TEXT_PRIMARY : TEXT_SUBTLE}
106
103
  attributes={selected ? undefined : TextAttributes.DIM}
107
104
  >
@@ -126,59 +123,22 @@ export function PortForwardOverlay({
126
123
  </box>
127
124
 
128
125
  {selectedEntry && !selectedIsActive && (
129
- <box
130
- style={{
131
- border: true,
132
- borderColor: PANEL_BORDER_ACTIVE,
133
- borderStyle: "rounded",
134
- backgroundColor: FILTER_BACKGROUND,
135
- paddingLeft: 1,
136
- paddingRight: 1,
137
- marginTop: 1,
138
- height: 3,
139
- justifyContent: "center",
140
- alignItems: "center",
141
- flexDirection: "row",
142
- }}
143
- >
144
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
145
- Local:
146
- </text>
147
- <input
148
- focused
149
- value={localPortValue}
150
- placeholder={String(selectedEntry.suggestedLocalPort)}
151
- style={{ flexGrow: 1, marginLeft: 1 }}
152
- onInput={onChange}
153
- onChange={onChange}
154
- />
155
- </box>
126
+ <TextInput
127
+ label="Local:"
128
+ value={localPortValue}
129
+ placeholder={String(selectedEntry.suggestedLocalPort)}
130
+ onChange={onChange}
131
+ />
156
132
  )}
157
133
 
158
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
159
- {anyInactive && (
160
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
161
- <span fg={KEY_HINT}>{GLYPHS.enter}</span>
162
- {" start"}
163
- </text>
164
- )}
165
- {anyActive && (
166
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
167
- <span fg={KEY_HINT}>{"X"}</span>
168
- {" stop"}
169
- </text>
170
- )}
171
- {entries.length > 1 && (
172
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
173
- <span fg={KEY_HINT}>{"↑↓"}</span>
174
- {" navigate"}
175
- </text>
176
- )}
177
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
178
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
179
- {" close"}
180
- </text>
181
- </box>
134
+ <KeyHintRow
135
+ hints={[
136
+ ...(anyInactive ? [[GLYPHS.enter, "start"] as [string, string]] : []),
137
+ ...(anyActive ? [["X", "stop"] as [string, string]] : []),
138
+ ...(entries.length > 1 ? [["↑↓", "navigate"] as [string, string]] : []),
139
+ [GLYPHS.esc, "close"],
140
+ ]}
141
+ />
182
142
  </box>
183
143
  );
184
144
  }