@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.
- package/README.md +71 -0
- package/index.ts +6 -0
- package/package.json +53 -0
- package/skills/configure-updater/SKILL.md +65 -0
- package/src/cache.ts +67 -0
- package/src/changelog.ts +141 -0
- package/src/checker.ts +84 -0
- package/src/commands.ts +83 -0
- package/src/index.ts +178 -0
- package/src/installer.ts +74 -0
- package/src/markdown.ts +173 -0
- package/src/readme.ts +139 -0
- package/src/settings.ts +98 -0
- package/src/tui/changelog-overlay.ts +256 -0
- package/src/tui/readme-overlay.ts +236 -0
- package/src/tui/settings-overlay.ts +191 -0
- package/src/tui/update-overlay.ts +261 -0
|
@@ -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
|
+
}
|