@pi-unipi/updater 0.1.1

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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @pi-unipi/updater — Changelog TUI Overlay
3
+ *
4
+ * Version list with Current/New labels, Enter opens detail view, Esc/q back.
5
+ * Uses ctx.ui.custom() overlay API with component return pattern.
6
+ */
7
+
8
+ import { existsSync } from "fs";
9
+ import { join } from "path";
10
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import type { Theme } from "@mariozechner/pi-coding-agent";
12
+ import { parseChangelog } from "../changelog.js";
13
+ import { renderMarkdown } from "../markdown.js";
14
+ import { getPackageVersion } from "@pi-unipi/core";
15
+ import type { ChangelogEntry } from "../../types.js";
16
+
17
+ type View = "list" | "detail";
18
+
19
+ interface ChangelogState {
20
+ view: View;
21
+ entries: ChangelogEntry[];
22
+ listIndex: number;
23
+ listScroll: number;
24
+ detailScroll: number;
25
+ installedVersion: string;
26
+ }
27
+
28
+ /**
29
+ * Pad content to exact visible width.
30
+ */
31
+ function padVisible(content: string, targetWidth: number): string {
32
+ const vw = visibleWidth(content);
33
+ const pad = Math.max(0, targetWidth - vw);
34
+ return content + " ".repeat(pad);
35
+ }
36
+
37
+ /**
38
+ * Render the changelog overlay.
39
+ */
40
+ export function renderChangelogOverlay() {
41
+ return (
42
+ tui: any,
43
+ theme: Theme,
44
+ _kb: any,
45
+ done: (result: { viewed: boolean } | null) => void,
46
+ ) => {
47
+ const installedVersion = getPackageVersion(
48
+ new URL("../../..", import.meta.url).pathname,
49
+ );
50
+
51
+ const state: ChangelogState = {
52
+ view: "list",
53
+ entries: [],
54
+ listIndex: 0,
55
+ listScroll: 0,
56
+ detailScroll: 0,
57
+ installedVersion,
58
+ };
59
+
60
+ // Load changelog
61
+ let loaded = false;
62
+ const ensureLoaded = () => {
63
+ if (loaded) return;
64
+ const changelogPath = join(process.cwd(), "CHANGELOG.md");
65
+ if (existsSync(changelogPath)) {
66
+ state.entries = parseChangelog(changelogPath);
67
+ }
68
+ loaded = true;
69
+ };
70
+
71
+ const render = (width: number): string[] => {
72
+ ensureLoaded();
73
+
74
+ const innerWidth = Math.max(22, width - 2);
75
+ const lines: string[] = [];
76
+
77
+ // ── Header ──────────────────────────────────────────────────────
78
+ lines.push(theme.fg("accent", `╭${"─".repeat(innerWidth)}╮`));
79
+ lines.push(
80
+ theme.fg("accent", "│") +
81
+ padVisible(theme.fg("accent", theme.bold(" 📋 Changelog ")), innerWidth) +
82
+ theme.fg("accent", "│"),
83
+ );
84
+ lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
85
+
86
+ // ── Content ─────────────────────────────────────────────────────
87
+ if (state.view === "list") {
88
+ renderListView(lines, innerWidth);
89
+ } else {
90
+ renderDetailView(lines, innerWidth);
91
+ }
92
+
93
+ // ── Footer ──────────────────────────────────────────────────────
94
+ lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
95
+ const footer =
96
+ state.view === "list"
97
+ ? ` ${theme.fg("accent", "j/k")} navigate ${theme.fg("success", "Enter")} view details ${theme.fg("error", "q/Esc")} close`
98
+ : ` ${theme.fg("accent", "j/k")} scroll ${theme.fg("error", "q/Esc")} back to list`;
99
+ lines.push(
100
+ theme.fg("accent", "│") +
101
+ padVisible(truncateToWidth(footer, innerWidth), innerWidth) +
102
+ theme.fg("accent", "│"),
103
+ );
104
+ lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
105
+
106
+ return lines;
107
+ };
108
+
109
+ const renderListView = (lines: string[], innerWidth: number) => {
110
+ if (state.entries.length === 0) {
111
+ lines.push(
112
+ theme.fg("accent", "│") +
113
+ padVisible(theme.fg("muted", " No changelog available."), innerWidth) +
114
+ theme.fg("accent", "│"),
115
+ );
116
+ return;
117
+ }
118
+
119
+ state.listIndex = Math.min(state.listIndex, state.entries.length - 1);
120
+ state.listIndex = Math.max(0, state.listIndex);
121
+
122
+ // Show visible entries
123
+ const maxLines = 20;
124
+ if (state.listIndex < state.listScroll) state.listScroll = state.listIndex;
125
+ if (state.listIndex >= state.listScroll + maxLines) {
126
+ state.listScroll = state.listIndex - maxLines + 1;
127
+ }
128
+
129
+ const visible = state.entries.slice(state.listScroll, state.listScroll + maxLines);
130
+
131
+ for (let i = 0; i < visible.length; i++) {
132
+ const entry = visible[i]!;
133
+ const globalIdx = state.listScroll + i;
134
+ const selected = globalIdx === state.listIndex;
135
+ const prefix = selected ? theme.fg("accent", "▸ ") : " ";
136
+
137
+ let label: string;
138
+ if (entry.version === "Unreleased") {
139
+ label = theme.fg("muted", "Unreleased");
140
+ } else if (entry.version === state.installedVersion) {
141
+ label = theme.fg("success", "✓ Current");
142
+ } else {
143
+ const pa = entry.version.split(".").map(Number);
144
+ const pb = state.installedVersion.split(".").map(Number);
145
+ const isNewer =
146
+ pa[0]! > pb[0]! ||
147
+ (pa[0] === pb[0] && pa[1]! > pb[1]!) ||
148
+ (pa[0] === pb[0] && pa[1] === pb[1] && pa[2]! > pb[2]!);
149
+ label = isNewer ? theme.fg("warning", "↑ New") : "";
150
+ }
151
+
152
+ const version = selected ? theme.bold(entry.version) : theme.fg("text", entry.version);
153
+ const date = entry.date ? ` — ${theme.fg("muted", entry.date)}` : "";
154
+ const line = ` ${prefix}${version}${date} ${label}`;
155
+ lines.push(
156
+ theme.fg("accent", "│") +
157
+ padVisible(
158
+ selected ? theme.bg("selectedBg", truncateToWidth(line, innerWidth)) : truncateToWidth(line, innerWidth),
159
+ innerWidth,
160
+ ) +
161
+ theme.fg("accent", "│"),
162
+ );
163
+ }
164
+ };
165
+
166
+ const renderDetailView = (lines: string[], innerWidth: number) => {
167
+ const entry = state.entries[state.listIndex];
168
+ if (!entry) {
169
+ lines.push(
170
+ theme.fg("accent", "│") +
171
+ padVisible(theme.fg("muted", " No entry selected."), innerWidth) +
172
+ theme.fg("accent", "│"),
173
+ );
174
+ return;
175
+ }
176
+
177
+ const title = entry.date
178
+ ? `${theme.bold(entry.version)} — ${theme.fg("muted", entry.date)}`
179
+ : `${theme.bold(entry.version)} — ${theme.fg("muted", "Unreleased")}`;
180
+ lines.push(
181
+ theme.fg("accent", "│") +
182
+ padVisible(truncateToWidth(` ${title}`, innerWidth), innerWidth) +
183
+ theme.fg("accent", "│"),
184
+ );
185
+ lines.push(
186
+ theme.fg("accent", "│") +
187
+ padVisible("", innerWidth) +
188
+ theme.fg("accent", "│"),
189
+ );
190
+
191
+ const bodyLines = renderMarkdown(entry.body, innerWidth - 2, theme);
192
+ const maxScroll = Math.max(0, bodyLines.length - 15);
193
+ state.detailScroll = Math.min(state.detailScroll, maxScroll);
194
+ state.detailScroll = Math.max(0, state.detailScroll);
195
+
196
+ const visible = bodyLines.slice(state.detailScroll, state.detailScroll + 15);
197
+ for (const line of visible) {
198
+ lines.push(
199
+ theme.fg("accent", "│") +
200
+ padVisible(truncateToWidth(` ${line}`, innerWidth), innerWidth) +
201
+ theme.fg("accent", "│"),
202
+ );
203
+ }
204
+ };
205
+
206
+ const handleInput = (data: string) => {
207
+ ensureLoaded();
208
+
209
+ // Close from list view
210
+ if ((matchesKey(data, Key.escape) || data === "q") && state.view === "list") {
211
+ done({ viewed: true });
212
+ return;
213
+ }
214
+
215
+ // Back from detail view
216
+ if ((matchesKey(data, Key.escape) || data === "q") && state.view === "detail") {
217
+ state.view = "list";
218
+ state.detailScroll = 0;
219
+ tui.requestRender();
220
+ return;
221
+ }
222
+
223
+ // Navigation
224
+ if (state.view === "list") {
225
+ if (matchesKey(data, Key.down) || data === "j") {
226
+ state.listIndex = Math.min(state.listIndex + 1, state.entries.length - 1);
227
+ } else if (matchesKey(data, Key.up) || data === "k") {
228
+ state.listIndex = Math.max(state.listIndex - 1, 0);
229
+ } else if (matchesKey(data, Key.enter)) {
230
+ if (state.entries.length > 0) {
231
+ state.view = "detail";
232
+ state.detailScroll = 0;
233
+ }
234
+ } else if (data === "g") {
235
+ state.listIndex = 0;
236
+ } else if (data === "G") {
237
+ state.listIndex = state.entries.length - 1;
238
+ }
239
+ } else {
240
+ if (matchesKey(data, Key.down) || data === "j") {
241
+ state.detailScroll++;
242
+ } else if (matchesKey(data, Key.up) || data === "k") {
243
+ state.detailScroll = Math.max(0, state.detailScroll - 1);
244
+ } else if (data === "g") {
245
+ state.detailScroll = 0;
246
+ } else if (data === "G") {
247
+ state.detailScroll = 999999;
248
+ }
249
+ }
250
+
251
+ tui.requestRender();
252
+ };
253
+
254
+ return { render, handleInput, invalidate: () => {}, focused: true };
255
+ };
256
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * @pi-unipi/updater — Readme TUI Overlay
3
+ *
4
+ * Package list with versions, Enter opens content view.
5
+ * No-arg /unipi:readme opens directly to root README content.
6
+ * With arg, opens directly to that package's content.
7
+ */
8
+
9
+ import { readFileSync } from "fs";
10
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import type { Theme } from "@mariozechner/pi-coding-agent";
12
+ import { discoverReadmes, resolveReadmePath } from "../readme.js";
13
+ import { renderMarkdown } from "../markdown.js";
14
+ import type { ReadmeEntry } from "../../types.js";
15
+
16
+ type View = "list" | "content";
17
+
18
+ interface ReadmeState {
19
+ view: View;
20
+ entries: ReadmeEntry[];
21
+ listIndex: number;
22
+ listScroll: number;
23
+ contentScroll: number;
24
+ contentLines: string[];
25
+ }
26
+
27
+ /**
28
+ * Pad content to exact visible width.
29
+ */
30
+ function padVisible(content: string, targetWidth: number): string {
31
+ const vw = visibleWidth(content);
32
+ const pad = Math.max(0, targetWidth - vw);
33
+ return content + " ".repeat(pad);
34
+ }
35
+
36
+ /**
37
+ * Render the readme overlay.
38
+ */
39
+ export function renderReadmeOverlay(params?: { openDirect?: string }) {
40
+ return (
41
+ tui: any,
42
+ theme: Theme,
43
+ _kb: any,
44
+ done: (result: { viewed: boolean } | null) => void,
45
+ ) => {
46
+ const state: ReadmeState = {
47
+ view: "list",
48
+ entries: [],
49
+ listIndex: 0,
50
+ listScroll: 0,
51
+ contentScroll: 0,
52
+ contentLines: [],
53
+ };
54
+
55
+ let loaded = false;
56
+ const ensureLoaded = () => {
57
+ if (loaded) return;
58
+ state.entries = discoverReadmes();
59
+
60
+ if (params?.openDirect) {
61
+ const readmePath = resolveReadmePath(params.openDirect);
62
+ if (readmePath) {
63
+ const content = readFileSync(readmePath, "utf-8");
64
+ state.contentLines = renderMarkdown(content, (tui.width ?? 80) - 4, theme);
65
+ state.view = "content";
66
+ }
67
+ }
68
+ loaded = true;
69
+ };
70
+
71
+ const render = (width: number): string[] => {
72
+ ensureLoaded();
73
+
74
+ const innerWidth = Math.max(22, width - 2);
75
+ const lines: string[] = [];
76
+
77
+ // ── Header ──────────────────────────────────────────────────────
78
+ lines.push(theme.fg("accent", `╭${"─".repeat(innerWidth)}╮`));
79
+ lines.push(
80
+ theme.fg("accent", "│") +
81
+ padVisible(theme.fg("accent", theme.bold(" 📖 README Browser ")), innerWidth) +
82
+ theme.fg("accent", "│"),
83
+ );
84
+ lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
85
+
86
+ // ── Content ─────────────────────────────────────────────────────
87
+ if (state.view === "list") {
88
+ renderListView(lines, innerWidth);
89
+ } else {
90
+ renderContentView(lines, innerWidth);
91
+ }
92
+
93
+ // ── Footer ──────────────────────────────────────────────────────
94
+ lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
95
+ const footer =
96
+ state.view === "list"
97
+ ? ` ${theme.fg("accent", "j/k")} navigate ${theme.fg("success", "Enter")} read ${theme.fg("error", "q/Esc")} close`
98
+ : ` ${theme.fg("accent", "j/k")} scroll ${theme.fg("error", "q/Esc")} back to list`;
99
+ lines.push(
100
+ theme.fg("accent", "│") +
101
+ padVisible(truncateToWidth(footer, innerWidth), innerWidth) +
102
+ theme.fg("accent", "│"),
103
+ );
104
+ lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
105
+
106
+ return lines;
107
+ };
108
+
109
+ const renderListView = (lines: string[], innerWidth: number) => {
110
+ if (state.entries.length === 0) {
111
+ lines.push(
112
+ theme.fg("accent", "│") +
113
+ padVisible(theme.fg("muted", " No README files found."), innerWidth) +
114
+ theme.fg("accent", "│"),
115
+ );
116
+ return;
117
+ }
118
+
119
+ state.listIndex = Math.min(state.listIndex, state.entries.length - 1);
120
+ state.listIndex = Math.max(0, state.listIndex);
121
+
122
+ const maxLines = 20;
123
+ if (state.listIndex < state.listScroll) state.listScroll = state.listIndex;
124
+ if (state.listIndex >= state.listScroll + maxLines) {
125
+ state.listScroll = state.listIndex - maxLines + 1;
126
+ }
127
+
128
+ const visible = state.entries.slice(state.listScroll, state.listScroll + maxLines);
129
+
130
+ for (let i = 0; i < visible.length; i++) {
131
+ const entry = visible[i]!;
132
+ const globalIdx = state.listScroll + i;
133
+ const selected = globalIdx === state.listIndex;
134
+ const prefix = selected ? theme.fg("accent", "▸ ") : " ";
135
+
136
+ const name = selected ? theme.bold(entry.name) : theme.fg("text", entry.name);
137
+ const version = theme.fg("muted", `v${entry.version}`);
138
+ const line = ` ${prefix}${name} ${version}`;
139
+ lines.push(
140
+ theme.fg("accent", "│") +
141
+ padVisible(
142
+ selected ? theme.bg("selectedBg", truncateToWidth(line, innerWidth)) : truncateToWidth(line, innerWidth),
143
+ innerWidth,
144
+ ) +
145
+ theme.fg("accent", "│"),
146
+ );
147
+ }
148
+ };
149
+
150
+ const renderContentView = (lines: string[], innerWidth: number) => {
151
+ if (state.contentLines.length === 0) {
152
+ lines.push(
153
+ theme.fg("accent", "│") +
154
+ padVisible(theme.fg("muted", " No content available."), innerWidth) +
155
+ theme.fg("accent", "│"),
156
+ );
157
+ return;
158
+ }
159
+
160
+ const maxLines = 20;
161
+ const maxScroll = Math.max(0, state.contentLines.length - maxLines);
162
+ state.contentScroll = Math.min(state.contentScroll, maxScroll);
163
+ state.contentScroll = Math.max(0, state.contentScroll);
164
+
165
+ const visible = state.contentLines.slice(state.contentScroll, state.contentScroll + maxLines);
166
+ for (const line of visible) {
167
+ lines.push(
168
+ theme.fg("accent", "│") +
169
+ padVisible(truncateToWidth(` ${line}`, innerWidth), innerWidth) +
170
+ theme.fg("accent", "│"),
171
+ );
172
+ }
173
+ };
174
+
175
+ const handleInput = (data: string) => {
176
+ ensureLoaded();
177
+
178
+ // Close from list view
179
+ if ((matchesKey(data, Key.escape) || data === "q") && state.view === "list") {
180
+ done({ viewed: true });
181
+ return;
182
+ }
183
+
184
+ // Back from content view
185
+ if ((matchesKey(data, Key.escape) || data === "q") && state.view === "content") {
186
+ if (params?.openDirect) {
187
+ done({ viewed: true });
188
+ return;
189
+ }
190
+ state.view = "list";
191
+ state.contentScroll = 0;
192
+ tui.requestRender();
193
+ return;
194
+ }
195
+
196
+ if (state.view === "list") {
197
+ if (matchesKey(data, Key.down) || data === "j") {
198
+ state.listIndex = Math.min(state.listIndex + 1, state.entries.length - 1);
199
+ } else if (matchesKey(data, Key.up) || data === "k") {
200
+ state.listIndex = Math.max(state.listIndex - 1, 0);
201
+ } else if (matchesKey(data, Key.enter)) {
202
+ if (state.entries.length > 0) {
203
+ const entry = state.entries[state.listIndex]!;
204
+ try {
205
+ const content = readFileSync(entry.path, "utf-8");
206
+ state.contentLines = renderMarkdown(content, (tui.width ?? 80) - 4, theme);
207
+ state.contentScroll = 0;
208
+ state.view = "content";
209
+ } catch {
210
+ state.contentLines = [" Error reading README file."];
211
+ state.view = "content";
212
+ }
213
+ }
214
+ } else if (data === "g") {
215
+ state.listIndex = 0;
216
+ } else if (data === "G") {
217
+ state.listIndex = state.entries.length - 1;
218
+ }
219
+ } else {
220
+ if (matchesKey(data, Key.down) || data === "j") {
221
+ state.contentScroll++;
222
+ } else if (matchesKey(data, Key.up) || data === "k") {
223
+ state.contentScroll = Math.max(0, state.contentScroll - 1);
224
+ } else if (data === "g") {
225
+ state.contentScroll = 0;
226
+ } else if (data === "G") {
227
+ state.contentScroll = 999999;
228
+ }
229
+ }
230
+
231
+ tui.requestRender();
232
+ };
233
+
234
+ return { render, handleInput, invalidate: () => {}, focused: true };
235
+ };
236
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @pi-unipi/updater — Settings TUI Overlay
3
+ *
4
+ * Check interval radio (30min/1h/6h/1d), auto-update radio (disabled/notify/auto).
5
+ * Space cycles options, Enter saves, Esc cancels.
6
+ */
7
+
8
+ import {
9
+ loadConfig,
10
+ saveConfig,
11
+ getIntervalOptions,
12
+ getAutoUpdateOptions,
13
+ getIntervalLabel,
14
+ } from "../settings.js";
15
+ import type { UpdaterConfig } from "../../types.js";
16
+
17
+ /** ANSI codes */
18
+ const ESC = "\x1b";
19
+ const BOLD = `${ESC}[1m`;
20
+ const DIM = `${ESC}[2m`;
21
+ const TEAL = `${ESC}[36m`;
22
+ const GREEN = `${ESC}[32m`;
23
+ const RESET = `${ESC}[0m`;
24
+
25
+ /** Truncate string to visible width */
26
+ function trunc(text: string, width: number): string {
27
+ let vw = 0;
28
+ let result = "";
29
+ let inEsc = false;
30
+ for (const ch of text) {
31
+ if (ch === "\x1b") { inEsc = true; result += ch; continue; }
32
+ if (inEsc) { result += ch; if (ch === "m") inEsc = false; continue; }
33
+ if (vw >= width) break;
34
+ result += ch;
35
+ vw++;
36
+ }
37
+ return result;
38
+ }
39
+
40
+ /**
41
+ * Render the settings overlay.
42
+ */
43
+ export function renderSettingsOverlay() {
44
+ return (
45
+ tui: any,
46
+ _theme: any,
47
+ _kb: any,
48
+ done: (result: { saved: boolean } | null) => void,
49
+ ) => {
50
+ const state = {
51
+ config: { ...loadConfig() } as UpdaterConfig,
52
+ row: 0,
53
+ };
54
+
55
+ const intervalOptions = getIntervalOptions();
56
+ const modeOptions = getAutoUpdateOptions();
57
+
58
+ const render = (width: number): string[] => {
59
+ const lines: string[] = [];
60
+
61
+ lines.push(trunc(` ${BOLD}⚙ Updater Settings${RESET}`, width));
62
+ lines.push("─".repeat(width));
63
+ lines.push("");
64
+
65
+ // Row 0: Check Interval
66
+ const intervalLabel = getIntervalLabel(state.config.checkIntervalMs);
67
+ const row0Selected = state.row === 0;
68
+ const row0Prefix = row0Selected ? `${TEAL}▸${RESET} ` : " ";
69
+ lines.push(
70
+ trunc(
71
+ ` ${row0Prefix}${BOLD}Check Interval${RESET} ${DIM}${intervalLabel}${RESET}`,
72
+ width,
73
+ ),
74
+ );
75
+
76
+ const intervalLine = intervalOptions
77
+ .map((opt) => {
78
+ const active = opt.ms === state.config.checkIntervalMs;
79
+ return active
80
+ ? `${GREEN}● ${opt.label}${RESET}`
81
+ : `${DIM}○ ${opt.label}${RESET}`;
82
+ })
83
+ .join(" ");
84
+ lines.push(trunc(` ${intervalLine}`, width));
85
+ lines.push("");
86
+
87
+ // Row 1: Auto Update
88
+ const modeLabel = state.config.autoUpdate;
89
+ const row1Selected = state.row === 1;
90
+ const row1Prefix = row1Selected ? `${TEAL}▸${RESET} ` : " ";
91
+ lines.push(
92
+ trunc(
93
+ ` ${row1Prefix}${BOLD}Auto Update${RESET} ${DIM}${modeLabel}${RESET}`,
94
+ width,
95
+ ),
96
+ );
97
+
98
+ const modeLine = modeOptions
99
+ .map((mode) => {
100
+ const active = mode === state.config.autoUpdate;
101
+ return active
102
+ ? `${GREEN}● ${mode}${RESET}`
103
+ : `${DIM}○ ${mode}${RESET}`;
104
+ })
105
+ .join(" ");
106
+ lines.push(trunc(` ${modeLine}`, width));
107
+ lines.push("");
108
+
109
+ lines.push("─".repeat(width));
110
+ lines.push(
111
+ trunc(
112
+ ` j/k: navigate Space: cycle ${GREEN}Enter: save${RESET} ${DIM}Esc: cancel${RESET}`,
113
+ width,
114
+ ),
115
+ );
116
+
117
+ return lines;
118
+ };
119
+
120
+ const handleInput = (data: string) => {
121
+ const key = data.toLowerCase();
122
+
123
+ // Close without saving
124
+ if (key === "\x1b") {
125
+ done({ saved: false });
126
+ return;
127
+ }
128
+
129
+ // Save and close
130
+ if (key === "\r" || key === "\n") {
131
+ saveConfig(state.config);
132
+ done({ saved: true });
133
+ return;
134
+ }
135
+
136
+ // Navigate rows
137
+ if (key === "j" || key === "\x1b[B") {
138
+ state.row = Math.min(state.row + 1, 1);
139
+ } else if (key === "k" || key === "\x1b[A") {
140
+ state.row = Math.max(state.row - 1, 0);
141
+ }
142
+
143
+ // Cycle with Space
144
+ if (key === " ") {
145
+ if (state.row === 0) {
146
+ const currentIdx = intervalOptions.findIndex(
147
+ (opt) => opt.ms === state.config.checkIntervalMs,
148
+ );
149
+ const nextIdx = (currentIdx + 1) % intervalOptions.length;
150
+ state.config.checkIntervalMs = intervalOptions[nextIdx].ms;
151
+ } else {
152
+ const currentIdx = modeOptions.indexOf(state.config.autoUpdate);
153
+ const nextIdx = (currentIdx + 1) % modeOptions.length;
154
+ state.config.autoUpdate = modeOptions[nextIdx];
155
+ }
156
+ }
157
+
158
+ // Cycle with left/right
159
+ if (key === "h" || key === "\x1b[D") {
160
+ if (state.row === 0) {
161
+ const currentIdx = intervalOptions.findIndex(
162
+ (opt) => opt.ms === state.config.checkIntervalMs,
163
+ );
164
+ const prevIdx = (currentIdx - 1 + intervalOptions.length) % intervalOptions.length;
165
+ state.config.checkIntervalMs = intervalOptions[prevIdx].ms;
166
+ } else {
167
+ const currentIdx = modeOptions.indexOf(state.config.autoUpdate);
168
+ const prevIdx = (currentIdx - 1 + modeOptions.length) % modeOptions.length;
169
+ state.config.autoUpdate = modeOptions[prevIdx];
170
+ }
171
+ }
172
+ if (key === "l" || key === "\x1b[C") {
173
+ if (state.row === 0) {
174
+ const currentIdx = intervalOptions.findIndex(
175
+ (opt) => opt.ms === state.config.checkIntervalMs,
176
+ );
177
+ const nextIdx = (currentIdx + 1) % intervalOptions.length;
178
+ state.config.checkIntervalMs = intervalOptions[nextIdx].ms;
179
+ } else {
180
+ const currentIdx = modeOptions.indexOf(state.config.autoUpdate);
181
+ const nextIdx = (currentIdx + 1) % modeOptions.length;
182
+ state.config.autoUpdate = modeOptions[nextIdx];
183
+ }
184
+ }
185
+
186
+ tui.requestRender();
187
+ };
188
+
189
+ return { render, handleInput, invalidate: () => {}, focused: true };
190
+ };
191
+ }