@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,261 @@
1
+ /**
2
+ * @pi-unipi/updater — Update Available TUI Overlay
3
+ *
4
+ * Shows when an update is found on session start.
5
+ * Displays version diff, inline changelog for newer versions,
6
+ * [Y] Update / [n] Skip prompt. Auto mode: countdown with cancel.
7
+ */
8
+
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, getNewerVersions } from "../changelog.js";
13
+ import { renderMarkdown } from "../markdown.js";
14
+ import { installUpdate } from "../installer.js";
15
+ import { writeSkippedVersion } from "../cache.js";
16
+ import { loadConfig } from "../settings.js";
17
+ import type { ChangelogEntry, UpdateCheckResult } from "../../types.js";
18
+
19
+ /**
20
+ * Pad content to exact visible width.
21
+ */
22
+ function padVisible(content: string, targetWidth: number): string {
23
+ const vw = visibleWidth(content);
24
+ const pad = Math.max(0, targetWidth - vw);
25
+ return content + " ".repeat(pad);
26
+ }
27
+
28
+ interface UpdateState {
29
+ result: UpdateCheckResult;
30
+ contentLines: string[];
31
+ scroll: number;
32
+ installing: boolean;
33
+ installError: string | null;
34
+ autoCountdown: number;
35
+ autoCancelled: boolean;
36
+ autoTimer: ReturnType<typeof setInterval> | null;
37
+ }
38
+
39
+ /**
40
+ * Render the update available overlay.
41
+ */
42
+ export function renderUpdateOverlay(checkResult: UpdateCheckResult) {
43
+ return (
44
+ tui: any,
45
+ theme: Theme,
46
+ _kb: any,
47
+ done: (result: { updated: boolean } | null) => void,
48
+ ) => {
49
+ const config = loadConfig();
50
+
51
+ // Load changelog for newer versions
52
+ let newerVersions: ChangelogEntry[] = [];
53
+ const changelogPath = join(process.cwd(), "CHANGELOG.md");
54
+ try {
55
+ const entries = parseChangelog(changelogPath);
56
+ newerVersions = getNewerVersions(entries, checkResult.currentVersion);
57
+ } catch (_err) {
58
+ // No changelog
59
+ }
60
+
61
+ // Build content lines from changelog using markdown renderer
62
+ const contentLines: string[] = [];
63
+ for (const entry of newerVersions) {
64
+ const title = entry.date
65
+ ? `${theme.bold(entry.version)} — ${theme.fg("muted", entry.date)}`
66
+ : `${theme.bold(entry.version)} — ${theme.fg("muted", "Unreleased")}`;
67
+ contentLines.push(` ${title}`);
68
+ // Use markdown renderer for the body content
69
+ const bodyLines = renderMarkdown(entry.body, (tui.width ?? 80) - 6, theme);
70
+ for (const line of bodyLines) {
71
+ contentLines.push(` ${line}`);
72
+ }
73
+ contentLines.push("");
74
+ }
75
+ if (contentLines.length === 0) {
76
+ contentLines.push(` ${theme.fg("muted", "No changelog available for this update.")}`);
77
+ }
78
+
79
+ const state: UpdateState = {
80
+ result: checkResult,
81
+ contentLines,
82
+ scroll: 0,
83
+ installing: false,
84
+ installError: null,
85
+ autoCountdown: 5,
86
+ autoCancelled: false,
87
+ autoTimer: null,
88
+ };
89
+
90
+ // Auto mode countdown
91
+ if (config.autoUpdate === "auto") {
92
+ state.autoTimer = setInterval(() => {
93
+ if (state.autoCancelled || state.installing) {
94
+ if (state.autoTimer) clearInterval(state.autoTimer);
95
+ return;
96
+ }
97
+ state.autoCountdown--;
98
+ if (state.autoCountdown <= 0) {
99
+ if (state.autoTimer) clearInterval(state.autoTimer);
100
+ doInstall();
101
+ }
102
+ tui.requestRender();
103
+ }, 1000);
104
+ }
105
+
106
+ const doInstall = async () => {
107
+ if (state.installing) return;
108
+ state.installing = true;
109
+ tui.requestRender();
110
+
111
+ const result = await installUpdate();
112
+ if (result.success) {
113
+ done({ updated: true });
114
+ } else {
115
+ state.installing = false;
116
+ state.installError = result.error ?? "Installation failed";
117
+ tui.requestRender();
118
+ }
119
+ };
120
+
121
+ const render = (width: number): string[] => {
122
+ const innerWidth = Math.max(22, width - 2);
123
+ const lines: string[] = [];
124
+
125
+ // ── Header ──────────────────────────────────────────────────────
126
+ lines.push(theme.fg("accent", `╭${"─".repeat(innerWidth)}╮`));
127
+ lines.push(
128
+ theme.fg("accent", "│") +
129
+ padVisible(theme.fg("accent", theme.bold(" 📦 Update Available ")), innerWidth) +
130
+ theme.fg("accent", "│"),
131
+ );
132
+ lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
133
+
134
+ // ── Version info ────────────────────────────────────────────────
135
+ const current = theme.fg("muted", state.result.currentVersion);
136
+ const latest = theme.fg("success", theme.bold(state.result.latestVersion));
137
+ lines.push(
138
+ theme.fg("accent", "│") +
139
+ padVisible(truncateToWidth(` ${current} → ${latest}`, innerWidth), innerWidth) +
140
+ theme.fg("accent", "│"),
141
+ );
142
+ lines.push(
143
+ theme.fg("accent", "│") +
144
+ padVisible("", innerWidth) +
145
+ theme.fg("accent", "│"),
146
+ );
147
+
148
+ // ── Changelog content (scrollable) ──────────────────────────────
149
+ const contentHeight = 12;
150
+ const maxScroll = Math.max(0, state.contentLines.length - contentHeight);
151
+ state.scroll = Math.min(state.scroll, maxScroll);
152
+ state.scroll = Math.max(0, state.scroll);
153
+
154
+ const visible = state.contentLines.slice(state.scroll, state.scroll + contentHeight);
155
+ for (let i = 0; i < contentHeight; i++) {
156
+ const line = visible[i] ?? "";
157
+ lines.push(
158
+ theme.fg("accent", "│") +
159
+ padVisible(truncateToWidth(line, innerWidth), innerWidth) +
160
+ theme.fg("accent", "│"),
161
+ );
162
+ }
163
+
164
+ // ── Action bar ──────────────────────────────────────────────────
165
+ lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
166
+
167
+ if (state.installing) {
168
+ lines.push(
169
+ theme.fg("accent", "│") +
170
+ padVisible(theme.fg("warning", " Installing update..."), innerWidth) +
171
+ theme.fg("accent", "│"),
172
+ );
173
+ } else if (state.installError) {
174
+ lines.push(
175
+ theme.fg("accent", "│") +
176
+ padVisible(theme.fg("error", ` Error: ${state.installError}`), innerWidth) +
177
+ theme.fg("accent", "│"),
178
+ );
179
+ lines.push(
180
+ theme.fg("accent", "│") +
181
+ padVisible(theme.fg("muted", " Press any key to dismiss"), innerWidth) +
182
+ theme.fg("accent", "│"),
183
+ );
184
+ } else if (config.autoUpdate === "auto" && !state.autoCancelled) {
185
+ const actionLine = ` ${theme.fg("success", "[Y]")} Update now ${theme.fg("muted", "[n]")} Cancel Auto-updating in ${theme.fg("warning", String(state.autoCountdown))}...`;
186
+ lines.push(
187
+ theme.fg("accent", "│") +
188
+ padVisible(truncateToWidth(actionLine, innerWidth), innerWidth) +
189
+ theme.fg("accent", "│"),
190
+ );
191
+ } else {
192
+ const actionLine = ` ${theme.fg("success", "[Y]")} Update now ${theme.fg("muted", "[n]")} Skip ${theme.fg("accent", "j/k")}: scroll`;
193
+ lines.push(
194
+ theme.fg("accent", "│") +
195
+ padVisible(truncateToWidth(actionLine, innerWidth), innerWidth) +
196
+ theme.fg("accent", "│"),
197
+ );
198
+ }
199
+
200
+ lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
201
+
202
+ return lines;
203
+ };
204
+
205
+ const handleInput = (data: string) => {
206
+ // Dismiss install error
207
+ if (state.installError) {
208
+ done({ updated: false });
209
+ return;
210
+ }
211
+
212
+ if (state.installing) return;
213
+
214
+ // Cancel auto countdown
215
+ if (config.autoUpdate === "auto" && !state.autoCancelled) {
216
+ if (data === "n") {
217
+ state.autoCancelled = true;
218
+ if (state.autoTimer) clearInterval(state.autoTimer);
219
+ writeSkippedVersion(state.result.latestVersion);
220
+ done({ updated: false });
221
+ return;
222
+ }
223
+ }
224
+
225
+ // Y — install
226
+ if (data === "y" || data === "Y") {
227
+ doInstall();
228
+ return;
229
+ }
230
+
231
+ // n — skip
232
+ if (data === "n") {
233
+ writeSkippedVersion(state.result.latestVersion);
234
+ done({ updated: false });
235
+ return;
236
+ }
237
+
238
+ // q/Esc — skip
239
+ if (matchesKey(data, Key.escape) || data === "q") {
240
+ writeSkippedVersion(state.result.latestVersion);
241
+ done({ updated: false });
242
+ return;
243
+ }
244
+
245
+ // Scroll
246
+ if (matchesKey(data, Key.down) || data === "j") {
247
+ state.scroll++;
248
+ } else if (matchesKey(data, Key.up) || data === "k") {
249
+ state.scroll = Math.max(0, state.scroll - 1);
250
+ } else if (data === "g") {
251
+ state.scroll = 0;
252
+ } else if (data === "G") {
253
+ state.scroll = 999999;
254
+ }
255
+
256
+ tui.requestRender();
257
+ };
258
+
259
+ return { render, handleInput, invalidate: () => {}, focused: true };
260
+ };
261
+ }