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
@@ -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,22 +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";
16
-
17
- function logLineColor(line: string): string {
18
- const lower = line.toLowerCase();
19
- if (/\b(error|err|fatal|panic|critical)\b/.test(lower)) return toneStyles("danger").fg;
20
- if (/\bwarn(?:ing)?\b/.test(lower)) return toneStyles("warning").fg;
21
- if (/\binfo(?:rmation)?\b/.test(lower)) return toneStyles("info").fg;
22
- if (/\bdebug\b/.test(lower)) return TEXT_SUBTLE;
23
- if (/\btrace\b/.test(lower)) return YAML_COMMENT;
24
- return TEXT_SUBTLE;
25
- }
17
+ import { KeyHintRow, TextInput } from "./shared";
26
18
 
27
19
  export interface LogsDialogActions {
28
20
  nextMatch: () => void;
@@ -43,7 +35,7 @@ export interface LogsDialogProps {
43
35
  searchMode?: boolean | undefined;
44
36
  searchText?: string | undefined;
45
37
  onSearchChange?: ((value: string) => void) | undefined;
46
- actionsRef?: React.MutableRefObject<LogsDialogActions | null> | undefined;
38
+ actionsRef?: MutableRefObject<LogsDialogActions | null> | undefined;
47
39
  }
48
40
 
49
41
  export function LogsDialog({
@@ -62,7 +54,7 @@ export function LogsDialog({
62
54
  onSearchChange,
63
55
  actionsRef,
64
56
  }: LogsDialogProps) {
65
- const lines = logsText ? logsText.split("\n") : [];
57
+ const lines = useMemo(() => (logsText ? logsText.split("\n") : []), [logsText]);
66
58
  const palette = toneStyles("info");
67
59
  const [currentMatchIdx, setCurrentMatchIdx] = useState(0);
68
60
 
@@ -73,18 +65,31 @@ export function LogsDialog({
73
65
  if (line.toLowerCase().includes(lower)) acc.push(i);
74
66
  return acc;
75
67
  }, []);
76
- // eslint-disable-next-line react-hooks/exhaustive-deps
77
- }, [logsText, searchText]);
68
+ }, [lines, searchText]);
78
69
 
70
+ // Reset to first match when search text changes, and clamp when matches shrink (e.g. new logs stream in)
79
71
  useEffect(() => {
80
- if (!searchText) return;
72
+ if (!searchText) {
73
+ setCurrentMatchIdx(0);
74
+ return;
75
+ }
81
76
  setCurrentMatchIdx(0);
82
77
  const firstLine = matchIndices[0];
83
78
  if (firstLine !== undefined) {
84
79
  scrollRef.current?.scrollTo(firstLine);
85
80
  }
86
- // eslint-disable-next-line react-hooks/exhaustive-deps
87
- }, [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]);
88
93
 
89
94
  const nextMatch = () => {
90
95
  if (matchIndices.length === 0) return;
@@ -102,9 +107,11 @@ export function LogsDialog({
102
107
  if (lineIdx !== undefined) scrollRef.current?.scrollTo(lineIdx);
103
108
  };
104
109
 
105
- if (actionsRef) {
106
- actionsRef.current = { nextMatch, prevMatch };
107
- }
110
+ useEffect(() => {
111
+ if (actionsRef) {
112
+ actionsRef.current = { nextMatch, prevMatch };
113
+ }
114
+ });
108
115
 
109
116
  const warningFg = toneStyles("warning").fg;
110
117
  const multiContainer = (containers?.length ?? 0) > 1;
@@ -131,22 +138,22 @@ export function LogsDialog({
131
138
  >
132
139
  {/* Options status bar */}
133
140
  <box style={{ flexDirection: "row", columnGap: 2, marginBottom: 0 }}>
134
- <text fg={logsOptions.previous ? warningFg : TEXT_SUBTLE} attributes={TEXT_MUTED}>
141
+ <text fg={logsOptions.previous ? warningFg : TEXT_SUBTLE} attributes={ATTR_DIM}>
135
142
  <span fg={KEY_HINT}>P</span>
136
143
  {` prev:${logsOptions.previous ? "on" : "off"}`}
137
144
  </text>
138
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
145
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
139
146
  <span fg={KEY_HINT}>T</span>
140
147
  {` tail:${logsOptions.tail}`}
141
148
  </text>
142
149
  {logsOptions.since ? (
143
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
150
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
144
151
  <span fg={KEY_HINT}>S</span>
145
152
  {` since:${logsOptions.since}`}
146
153
  </text>
147
154
  ) : undefined}
148
155
  {multiContainer ? (
149
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
156
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
150
157
  <span fg={KEY_HINT}>C</span>
151
158
  {logsContainer ? ` ctr:${logsContainer}` : " all-ctrs"}
152
159
  </text>
@@ -184,7 +191,7 @@ export function LogsDialog({
184
191
  const matched = line.slice(matchStart, matchStart + searchText.length);
185
192
  const after = line.slice(matchStart + searchText.length);
186
193
  return (
187
- <text key={`${index}:${line}`} fg={logLineColor(line)}>
194
+ <text key={`${index}:${line}`} fg={TEXT_SUBTLE}>
188
195
  {before}
189
196
  <span fg={warningFg} attributes={isCurrentMatch ? TextAttributes.BOLD : TextAttributes.DIM}>
190
197
  {matched}
@@ -194,7 +201,7 @@ export function LogsDialog({
194
201
  );
195
202
  }
196
203
  return (
197
- <text key={`${index}:${line}`} fg={logLineColor(line)}>
204
+ <text key={`${index}:${line}`} fg={TEXT_SUBTLE}>
198
205
  {line}
199
206
  </text>
200
207
  );
@@ -205,33 +212,12 @@ export function LogsDialog({
205
212
 
206
213
  {/* Option input row */}
207
214
  {inputMode ? (
208
- <box
209
- style={{
210
- border: true,
211
- borderColor: PANEL_BORDER_ACTIVE,
212
- borderStyle: "rounded",
213
- backgroundColor: FILTER_BACKGROUND,
214
- paddingLeft: 1,
215
- paddingRight: 1,
216
- marginTop: 1,
217
- height: 3,
218
- justifyContent: "center",
219
- alignItems: "center",
220
- flexDirection: "row",
221
- }}
222
- >
223
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
224
- {inputMode === "tail" ? "Tail lines:" : "Since (e.g. 1h, 30m):"}
225
- </text>
226
- <input
227
- focused
228
- value={inputValue ?? ""}
229
- placeholder={inputMode === "tail" ? "120" : "1h"}
230
- style={{ flexGrow: 1, marginLeft: 1 }}
231
- onInput={onOptionInputChange}
232
- onChange={onOptionInputChange}
233
- />
234
- </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
+ />
235
221
  ) : undefined}
236
222
 
237
223
  {/* Search input row */}
@@ -261,11 +247,11 @@ export function LogsDialog({
261
247
  onChange={onSearchChange}
262
248
  />
263
249
  {matchIndices.length > 0 ? (
264
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
250
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
265
251
  {` ${currentMatchIdx + 1}/${matchIndices.length}`}
266
252
  </text>
267
253
  ) : searchText ? (
268
- <text fg={toneStyles("danger").fg} attributes={TEXT_MUTED}>
254
+ <text fg={toneStyles("danger").fg} attributes={ATTR_DIM}>
269
255
  {" no match"}
270
256
  </text>
271
257
  ) : undefined}
@@ -273,31 +259,15 @@ export function LogsDialog({
273
259
  ) : undefined}
274
260
 
275
261
  {/* Hints */}
276
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
277
- {inputMode || searchMode ? (
278
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
279
- <span fg={KEY_HINT}>{GLYPHS.enter}</span>
280
- {" confirm"}
281
- </text>
282
- ) : (
283
- <>
284
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
285
- <span fg={KEY_HINT}>{"/"}</span>
286
- {" search"}
287
- </text>
288
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
289
- <span fg={KEY_HINT}>{"↑↓"}</span>
290
- {" scroll"}
291
- </text>
292
- </>
293
- )}
294
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
295
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
296
- {" close"}
297
- </text>
298
- </box>
262
+ <KeyHintRow
263
+ hints={
264
+ inputMode || searchMode
265
+ ? [[GLYPHS.enter, "confirm"], [GLYPHS.esc, "close"]]
266
+ : [["/", "search"], ["↑↓", "scroll"], [GLYPHS.esc, "close"]]
267
+ }
268
+ />
299
269
  </box>
300
270
  );
301
271
  }
302
272
 
303
- export { logLineColor };
273
+
@@ -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
  }
@@ -1,17 +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_PRIMARY,
11
- TEXT_SUBTLE,
12
- toneStyles,
13
- } from "../../theme";
14
- import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
3
+ import { GLYPHS, TEXT_PRIMARY, TEXT_SUBTLE, ATTR_DIM, toneStyles } from "../../theme";
4
+ import { DialogFrame, KeyHintRow, TextInput } from "./shared";
15
5
 
16
6
  export interface ScaleDialogProps {
17
7
  target: string;
@@ -24,26 +14,11 @@ export function ScaleDialog({ target, currentReplicas, replicasValue, onChange }
24
14
  const palette = toneStyles("accent");
25
15
 
26
16
  return (
27
- <box
28
- style={{
29
- position: "absolute",
30
- left: DIALOG_LEFT,
31
- top: DIALOG_TOP,
32
- width: DIALOG_WIDTH,
33
- height: 12,
34
- border: true,
35
- borderStyle: "rounded",
36
- borderColor: palette.border,
37
- backgroundColor: OVERLAY_SURFACE,
38
- padding: 1,
39
- flexDirection: "column",
40
- }}
41
- title="Scale Replicas"
42
- >
17
+ <DialogFrame title="Scale Replicas" tone="accent" height={12}>
43
18
  <text fg={palette.fg} attributes={TextAttributes.BOLD}>
44
19
  {target}
45
20
  </text>
46
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
21
+ <text fg={TEXT_SUBTLE} attributes={ATTR_DIM}>
47
22
  {currentReplicas !== undefined ? (
48
23
  <>
49
24
  {"Current "}
@@ -54,43 +29,13 @@ export function ScaleDialog({ target, currentReplicas, replicasValue, onChange }
54
29
  "Replicas unknown"
55
30
  )}
56
31
  </text>
57
- <box
58
- style={{
59
- border: true,
60
- borderColor: PANEL_BORDER_ACTIVE,
61
- borderStyle: "rounded",
62
- backgroundColor: FILTER_BACKGROUND,
63
- paddingLeft: 1,
64
- paddingRight: 1,
65
- marginTop: 1,
66
- height: 3,
67
- justifyContent: "center",
68
- alignItems: "center",
69
- flexDirection: "row",
70
- }}
71
- >
72
- <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
73
- Target:
74
- </text>
75
- <input
76
- focused
77
- value={replicasValue}
78
- placeholder={currentReplicas !== undefined ? String(currentReplicas) : "0"}
79
- style={{ flexGrow: 1, marginLeft: 1 }}
80
- onInput={onChange}
81
- onChange={onChange}
82
- />
83
- </box>
84
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
85
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
86
- <span fg={KEY_HINT}>{GLYPHS.enter}</span>
87
- {" scale"}
88
- </text>
89
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
90
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
91
- {" cancel"}
92
- </text>
93
- </box>
94
- </box>
32
+ <TextInput
33
+ label="Target:"
34
+ value={replicasValue}
35
+ placeholder={currentReplicas !== undefined ? String(currentReplicas) : "0"}
36
+ onChange={onChange}
37
+ />
38
+ <KeyHintRow hints={[[GLYPHS.enter, "scale"], [GLYPHS.esc, "cancel"]]} />
39
+ </DialogFrame>
95
40
  );
96
41
  }
@@ -1,13 +1,12 @@
1
1
  import {
2
2
  GLYPHS,
3
- KEY_HINT,
4
3
  OVERLAY_SURFACE,
5
4
  PANEL_BORDER_ACTIVE,
6
5
  ROW_SELECTED,
7
- TEXT_MUTED,
8
6
  TEXT_PRIMARY,
9
7
  TEXT_SUBTLE,
10
8
  } from "../../theme";
9
+ import { KeyHintRow } from "./shared";
11
10
 
12
11
  export interface SelectOverlayProps {
13
12
  title: string;
@@ -17,6 +16,7 @@ export interface SelectOverlayProps {
17
16
  }
18
17
 
19
18
  export function SelectOverlay({ title, items, selectedIndex, onChange }: SelectOverlayProps) {
19
+ const clampedIndex = items.length > 0 ? Math.min(selectedIndex, items.length - 1) : 0;
20
20
  return (
21
21
  <box
22
22
  style={{
@@ -48,21 +48,12 @@ export function SelectOverlay({ title, items, selectedIndex, onChange }: SelectO
48
48
  selectedDescriptionColor={TEXT_PRIMARY}
49
49
  showDescription
50
50
  showScrollIndicator
51
- selectedIndex={selectedIndex}
51
+ selectedIndex={clampedIndex}
52
52
  options={items}
53
- onChange={(index) => onChange(index)}
53
+ onChange={onChange}
54
54
  />
55
55
  </box>
56
- <box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
57
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
58
- <span fg={KEY_HINT}>{GLYPHS.enter}</span>
59
- {" select"}
60
- </text>
61
- <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
62
- <span fg={KEY_HINT}>{GLYPHS.esc}</span>
63
- {" close"}
64
- </text>
65
- </box>
56
+ <KeyHintRow hints={[[GLYPHS.enter, "select"], [GLYPHS.esc, "close"]]} />
66
57
  </box>
67
58
  );
68
59
  }