pi-extensions 0.1.21 → 0.1.23
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/.github/workflows/skill-apply-optimize.yml +19 -0
- package/.github/workflows/skill-review.yml +17 -0
- package/.github/workflows/weather-native-bridge.yml +86 -0
- package/extending-pi/SKILL.md +43 -12
- package/files-widget/package.json +3 -3
- package/package.json +2 -6
- package/ralph-wiggum/index.ts +10 -0
- package/ralph-wiggum/package.json +1 -1
- package/usage-extension/CHANGELOG.md +4 -0
- package/usage-extension/README.md +18 -2
- package/usage-extension/index.ts +168 -99
- package/usage-extension/package.json +1 -1
- package/weather/CHANGELOG.md +16 -0
- package/weather/LICENSE +21 -0
- package/weather/README.md +132 -0
- package/weather/index.ts +1319 -0
- package/weather/native/weathr-bridge/Cargo.toml +15 -0
- package/weather/native/weathr-bridge/build.rs +3 -0
- package/weather/native/weathr-bridge/index.d.ts +25 -0
- package/weather/native/weathr-bridge/index.js +315 -0
- package/weather/native/weathr-bridge/package.json +41 -0
- package/weather/native/weathr-bridge/src/lib.rs +347 -0
- package/weather/package.json +52 -0
package/weather/index.ts
ADDED
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { closeSync, constants as fsConstants, openSync } from "node:fs";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
|
|
10
|
+
const WEATHER_COLUMNS = 100;
|
|
11
|
+
const WEATHER_ROWS = 30;
|
|
12
|
+
const WEATHER_STATUS_KEY = "weather-widget";
|
|
13
|
+
const WEATHER_CONFIG_HOME = path.join(os.homedir(), ".pi", "weather-widget");
|
|
14
|
+
|
|
15
|
+
const DEFAULT_WEATHER_CONFIG = `hide_hud = false
|
|
16
|
+
|
|
17
|
+
[location]
|
|
18
|
+
latitude = 52.5200
|
|
19
|
+
longitude = 13.4050
|
|
20
|
+
auto = true # set false to force the latitude/longitude above
|
|
21
|
+
hide = false
|
|
22
|
+
|
|
23
|
+
[units]
|
|
24
|
+
temperature = "celsius"
|
|
25
|
+
wind_speed = "kmh"
|
|
26
|
+
precipitation = "mm"
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const WEATHER_SIMULATION_CONDITIONS = new Set([
|
|
30
|
+
"clear",
|
|
31
|
+
"partly-cloudy",
|
|
32
|
+
"cloudy",
|
|
33
|
+
"overcast",
|
|
34
|
+
"fog",
|
|
35
|
+
"drizzle",
|
|
36
|
+
"rain",
|
|
37
|
+
"freezing-rain",
|
|
38
|
+
"rain-showers",
|
|
39
|
+
"snow",
|
|
40
|
+
"snow-grains",
|
|
41
|
+
"snow-showers",
|
|
42
|
+
"thunderstorm",
|
|
43
|
+
"thunderstorm-hail",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
type ParserMode = "normal" | "escape" | "csi" | "osc" | "osc_escape";
|
|
47
|
+
|
|
48
|
+
interface ParsedWeatherArgs {
|
|
49
|
+
forwardedArgs: string[];
|
|
50
|
+
ignoredTokens: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface WeatherWidgetOptions {
|
|
54
|
+
tui: { requestRender: () => void };
|
|
55
|
+
onClose: () => void;
|
|
56
|
+
scriptPath: string;
|
|
57
|
+
weathrPath: string;
|
|
58
|
+
weathrArgs: string[];
|
|
59
|
+
configHome: string;
|
|
60
|
+
columns: number;
|
|
61
|
+
rows: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface NativeWeatherSnapshot {
|
|
65
|
+
stdout: string;
|
|
66
|
+
stderr: string;
|
|
67
|
+
exited: boolean;
|
|
68
|
+
exitCode?: number;
|
|
69
|
+
exitSignal?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface NativeWeatherProcess {
|
|
73
|
+
poll(): NativeWeatherSnapshot;
|
|
74
|
+
writeInput(input: string): boolean;
|
|
75
|
+
stop(): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface NativeWeatherBridgeModule {
|
|
79
|
+
NativeWeatherProcess: new (
|
|
80
|
+
scriptPath: string,
|
|
81
|
+
weathrPath: string,
|
|
82
|
+
args: string[],
|
|
83
|
+
configHome: string,
|
|
84
|
+
columns: number,
|
|
85
|
+
rows: number,
|
|
86
|
+
) => NativeWeatherProcess;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const NATIVE_POLL_INTERVAL_MS = 33;
|
|
90
|
+
const NATIVE_STARTUP_TIMEOUT_MS = 1500;
|
|
91
|
+
const require = createRequire(import.meta.url);
|
|
92
|
+
let nativeWeatherBridgeModule: NativeWeatherBridgeModule | null | undefined;
|
|
93
|
+
let nativeWeatherBridgeLoadError: unknown | null = null;
|
|
94
|
+
|
|
95
|
+
function isNativeWeatherBridgeModule(value: unknown): value is NativeWeatherBridgeModule {
|
|
96
|
+
if (typeof value !== "object" || value === null) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const constructorValue = Reflect.get(value, "NativeWeatherProcess");
|
|
101
|
+
return typeof constructorValue === "function";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getNativeWeatherBridgeModule(): NativeWeatherBridgeModule | null {
|
|
105
|
+
if (nativeWeatherBridgeModule !== undefined) {
|
|
106
|
+
return nativeWeatherBridgeModule;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const loaded: unknown = require("./native/weathr-bridge/index.js");
|
|
111
|
+
if (!isNativeWeatherBridgeModule(loaded)) {
|
|
112
|
+
nativeWeatherBridgeLoadError = new Error("Invalid native weather bridge module shape");
|
|
113
|
+
nativeWeatherBridgeModule = null;
|
|
114
|
+
return nativeWeatherBridgeModule;
|
|
115
|
+
}
|
|
116
|
+
nativeWeatherBridgeLoadError = null;
|
|
117
|
+
nativeWeatherBridgeModule = loaded;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
nativeWeatherBridgeLoadError = error;
|
|
120
|
+
nativeWeatherBridgeModule = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return nativeWeatherBridgeModule;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface ScreenCell {
|
|
127
|
+
character: string;
|
|
128
|
+
style: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createBlankCell(): ScreenCell {
|
|
132
|
+
return {
|
|
133
|
+
character: " ",
|
|
134
|
+
style: "",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class AnsiScreenBuffer {
|
|
139
|
+
private readonly cells: ScreenCell[][];
|
|
140
|
+
private row = 0;
|
|
141
|
+
private col = 0;
|
|
142
|
+
private mode: ParserMode = "normal";
|
|
143
|
+
private csiBuffer = "";
|
|
144
|
+
private currentStyle = "";
|
|
145
|
+
private readonly formatTokens = new Set<string>();
|
|
146
|
+
private foregroundToken: string | null = null;
|
|
147
|
+
private backgroundToken: string | null = null;
|
|
148
|
+
|
|
149
|
+
constructor(
|
|
150
|
+
private readonly columns: number,
|
|
151
|
+
private readonly rows: number,
|
|
152
|
+
) {
|
|
153
|
+
this.cells = Array.from({ length: rows }, () =>
|
|
154
|
+
Array.from({ length: columns }, () => createBlankCell()),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
clear(): void {
|
|
159
|
+
for (let row = 0; row < this.rows; row += 1) {
|
|
160
|
+
const currentRow = this.cells[row];
|
|
161
|
+
if (!currentRow) continue;
|
|
162
|
+
for (let col = 0; col < this.columns; col += 1) {
|
|
163
|
+
const cell = currentRow[col];
|
|
164
|
+
if (!cell) continue;
|
|
165
|
+
cell.character = " ";
|
|
166
|
+
cell.style = "";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
this.row = 0;
|
|
170
|
+
this.col = 0;
|
|
171
|
+
this.mode = "normal";
|
|
172
|
+
this.csiBuffer = "";
|
|
173
|
+
this.resetStyleState();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
feed(chunk: string): void {
|
|
177
|
+
for (const character of chunk) {
|
|
178
|
+
this.consume(character);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
getLines(): string[] {
|
|
183
|
+
return this.cells.map((line) => this.renderLine(line));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private renderLine(line: ScreenCell[]): string {
|
|
187
|
+
let lastVisibleIndex = -1;
|
|
188
|
+
for (let index = line.length - 1; index >= 0; index -= 1) {
|
|
189
|
+
const cell = line[index];
|
|
190
|
+
if (cell && cell.character !== " ") {
|
|
191
|
+
lastVisibleIndex = index;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (lastVisibleIndex < 0) {
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let output = "";
|
|
200
|
+
let activeStyle = "";
|
|
201
|
+
for (let index = 0; index <= lastVisibleIndex; index += 1) {
|
|
202
|
+
const cell = line[index];
|
|
203
|
+
if (!cell) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (cell.style !== activeStyle) {
|
|
207
|
+
if (cell.style.length === 0) {
|
|
208
|
+
if (activeStyle.length > 0) {
|
|
209
|
+
output += "\u001b[0m";
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
output += cell.style;
|
|
213
|
+
}
|
|
214
|
+
activeStyle = cell.style;
|
|
215
|
+
}
|
|
216
|
+
output += cell.character;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (activeStyle.length > 0) {
|
|
220
|
+
output += "\u001b[0m";
|
|
221
|
+
}
|
|
222
|
+
return output;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private consume(character: string): void {
|
|
226
|
+
switch (this.mode) {
|
|
227
|
+
case "normal":
|
|
228
|
+
this.consumeNormal(character);
|
|
229
|
+
return;
|
|
230
|
+
case "escape":
|
|
231
|
+
this.consumeEscape(character);
|
|
232
|
+
return;
|
|
233
|
+
case "csi":
|
|
234
|
+
this.consumeCsi(character);
|
|
235
|
+
return;
|
|
236
|
+
case "osc":
|
|
237
|
+
this.consumeOsc(character);
|
|
238
|
+
return;
|
|
239
|
+
case "osc_escape":
|
|
240
|
+
this.consumeOscEscape(character);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private consumeNormal(character: string): void {
|
|
246
|
+
if (character === "\u001b") {
|
|
247
|
+
this.mode = "escape";
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (character === "\n") {
|
|
252
|
+
this.row = Math.min(this.rows - 1, this.row + 1);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (character === "\r") {
|
|
257
|
+
this.col = 0;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (character === "\b") {
|
|
262
|
+
this.col = Math.max(0, this.col - 1);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (character === "\t") {
|
|
267
|
+
const tabWidth = 4;
|
|
268
|
+
const targetCol = Math.min(this.columns - 1, this.col + (tabWidth - (this.col % tabWidth)));
|
|
269
|
+
while (this.col < targetCol) {
|
|
270
|
+
this.writeChar(" ");
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const codePoint = character.codePointAt(0);
|
|
276
|
+
if (codePoint === undefined || codePoint < 0x20) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.writeChar(character);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private consumeEscape(character: string): void {
|
|
284
|
+
if (character === "[") {
|
|
285
|
+
this.mode = "csi";
|
|
286
|
+
this.csiBuffer = "";
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (character === "]") {
|
|
291
|
+
this.mode = "osc";
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.mode = "normal";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private consumeCsi(character: string): void {
|
|
299
|
+
if (!this.isFinalCsiCharacter(character)) {
|
|
300
|
+
this.csiBuffer += character;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
this.applyCsi(this.csiBuffer, character);
|
|
305
|
+
this.mode = "normal";
|
|
306
|
+
this.csiBuffer = "";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private consumeOsc(character: string): void {
|
|
310
|
+
if (character === "\u0007") {
|
|
311
|
+
this.mode = "normal";
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (character === "\u001b") {
|
|
316
|
+
this.mode = "osc_escape";
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private consumeOscEscape(character: string): void {
|
|
321
|
+
if (character === "\\") {
|
|
322
|
+
this.mode = "normal";
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.mode = "osc";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private applyCsi(sequence: string, finalChar: string): void {
|
|
329
|
+
switch (finalChar) {
|
|
330
|
+
case "H":
|
|
331
|
+
case "f": {
|
|
332
|
+
const [rowRaw, colRaw] = sequence.split(";");
|
|
333
|
+
const targetRow = this.parseCsiNumber(rowRaw, 1) - 1;
|
|
334
|
+
const targetCol = this.parseCsiNumber(colRaw, 1) - 1;
|
|
335
|
+
this.row = this.clamp(targetRow, 0, this.rows - 1);
|
|
336
|
+
this.col = this.clamp(targetCol, 0, this.columns - 1);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
case "A": {
|
|
340
|
+
const amount = this.parseCsiNumber(sequence, 1);
|
|
341
|
+
this.row = this.clamp(this.row - amount, 0, this.rows - 1);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
case "B": {
|
|
345
|
+
const amount = this.parseCsiNumber(sequence, 1);
|
|
346
|
+
this.row = this.clamp(this.row + amount, 0, this.rows - 1);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
case "C": {
|
|
350
|
+
const amount = this.parseCsiNumber(sequence, 1);
|
|
351
|
+
this.col = this.clamp(this.col + amount, 0, this.columns - 1);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
case "D": {
|
|
355
|
+
const amount = this.parseCsiNumber(sequence, 1);
|
|
356
|
+
this.col = this.clamp(this.col - amount, 0, this.columns - 1);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
case "J": {
|
|
360
|
+
this.eraseDisplay(this.parseCsiNumber(sequence, 0));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
case "K": {
|
|
364
|
+
this.eraseLine(this.parseCsiNumber(sequence, 0));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
case "h": {
|
|
368
|
+
if (sequence === "?1049") {
|
|
369
|
+
this.clear();
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
case "l": {
|
|
374
|
+
if (sequence === "?1049") {
|
|
375
|
+
this.clear();
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
case "m": {
|
|
380
|
+
this.applySgr(sequence);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
default:
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private applySgr(sequence: string): void {
|
|
389
|
+
const tokens = sequence.length === 0
|
|
390
|
+
? ["0"]
|
|
391
|
+
: sequence
|
|
392
|
+
.split(";")
|
|
393
|
+
.map((token) => token.trim())
|
|
394
|
+
.filter((token) => token.length > 0);
|
|
395
|
+
if (tokens.length === 0) {
|
|
396
|
+
this.resetStyleState();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
401
|
+
const token = tokens[index];
|
|
402
|
+
const code = Number.parseInt(token, 10);
|
|
403
|
+
if (Number.isNaN(code)) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (code === 0) {
|
|
408
|
+
this.resetStyleState();
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (code >= 1 && code <= 9) {
|
|
412
|
+
this.formatTokens.add(String(code));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (code === 22) {
|
|
416
|
+
this.formatTokens.delete("1");
|
|
417
|
+
this.formatTokens.delete("2");
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (code === 23) {
|
|
421
|
+
this.formatTokens.delete("3");
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (code === 24) {
|
|
425
|
+
this.formatTokens.delete("4");
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (code === 25) {
|
|
429
|
+
this.formatTokens.delete("5");
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (code === 27) {
|
|
433
|
+
this.formatTokens.delete("7");
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (code === 28) {
|
|
437
|
+
this.formatTokens.delete("8");
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (code === 29) {
|
|
441
|
+
this.formatTokens.delete("9");
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
|
|
445
|
+
this.foregroundToken = String(code);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (code === 39) {
|
|
449
|
+
this.foregroundToken = null;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
|
|
453
|
+
this.backgroundToken = String(code);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (code === 49) {
|
|
457
|
+
this.backgroundToken = null;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (code === 38 || code === 48) {
|
|
461
|
+
const mode = tokens[index + 1];
|
|
462
|
+
if (mode === "5") {
|
|
463
|
+
const value = tokens[index + 2];
|
|
464
|
+
if (value) {
|
|
465
|
+
const tokenValue = `${code};5;${value}`;
|
|
466
|
+
if (code === 38) {
|
|
467
|
+
this.foregroundToken = tokenValue;
|
|
468
|
+
} else {
|
|
469
|
+
this.backgroundToken = tokenValue;
|
|
470
|
+
}
|
|
471
|
+
index += 2;
|
|
472
|
+
}
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (mode === "2") {
|
|
476
|
+
const r = tokens[index + 2];
|
|
477
|
+
const g = tokens[index + 3];
|
|
478
|
+
const b = tokens[index + 4];
|
|
479
|
+
if (r && g && b) {
|
|
480
|
+
const tokenValue = `${code};2;${r};${g};${b}`;
|
|
481
|
+
if (code === 38) {
|
|
482
|
+
this.foregroundToken = tokenValue;
|
|
483
|
+
} else {
|
|
484
|
+
this.backgroundToken = tokenValue;
|
|
485
|
+
}
|
|
486
|
+
index += 4;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
this.rebuildCurrentStyle();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private resetStyleState(): void {
|
|
496
|
+
this.formatTokens.clear();
|
|
497
|
+
this.foregroundToken = null;
|
|
498
|
+
this.backgroundToken = null;
|
|
499
|
+
this.currentStyle = "";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private rebuildCurrentStyle(): void {
|
|
503
|
+
const orderedFormats = ["1", "2", "3", "4", "5", "7", "8", "9"]
|
|
504
|
+
.filter((token) => this.formatTokens.has(token));
|
|
505
|
+
const tokens = [...orderedFormats];
|
|
506
|
+
if (this.foregroundToken) {
|
|
507
|
+
tokens.push(this.foregroundToken);
|
|
508
|
+
}
|
|
509
|
+
if (this.backgroundToken) {
|
|
510
|
+
tokens.push(this.backgroundToken);
|
|
511
|
+
}
|
|
512
|
+
this.currentStyle = tokens.length === 0 ? "" : `\u001b[${tokens.join(";")}m`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private eraseDisplay(mode: number): void {
|
|
516
|
+
if (mode === 2 || mode === 3) {
|
|
517
|
+
this.clear();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (mode === 0) {
|
|
522
|
+
for (let row = this.row; row < this.rows; row += 1) {
|
|
523
|
+
const currentRow = this.cells[row];
|
|
524
|
+
if (!currentRow) continue;
|
|
525
|
+
const startCol = row === this.row ? this.col : 0;
|
|
526
|
+
for (let col = startCol; col < this.columns; col += 1) {
|
|
527
|
+
const cell = currentRow[col];
|
|
528
|
+
if (!cell) continue;
|
|
529
|
+
cell.character = " ";
|
|
530
|
+
cell.style = "";
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (mode === 1) {
|
|
537
|
+
for (let row = 0; row <= this.row; row += 1) {
|
|
538
|
+
const currentRow = this.cells[row];
|
|
539
|
+
if (!currentRow) continue;
|
|
540
|
+
const endCol = row === this.row ? this.col : this.columns - 1;
|
|
541
|
+
for (let col = 0; col <= endCol; col += 1) {
|
|
542
|
+
const cell = currentRow[col];
|
|
543
|
+
if (!cell) continue;
|
|
544
|
+
cell.character = " ";
|
|
545
|
+
cell.style = "";
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private eraseLine(mode: number): void {
|
|
552
|
+
const currentRow = this.cells[this.row];
|
|
553
|
+
if (!currentRow) return;
|
|
554
|
+
|
|
555
|
+
if (mode === 2) {
|
|
556
|
+
for (let col = 0; col < this.columns; col += 1) {
|
|
557
|
+
const cell = currentRow[col];
|
|
558
|
+
if (!cell) continue;
|
|
559
|
+
cell.character = " ";
|
|
560
|
+
cell.style = "";
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (mode === 1) {
|
|
566
|
+
for (let col = 0; col <= this.col; col += 1) {
|
|
567
|
+
const cell = currentRow[col];
|
|
568
|
+
if (!cell) continue;
|
|
569
|
+
cell.character = " ";
|
|
570
|
+
cell.style = "";
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (let col = this.col; col < this.columns; col += 1) {
|
|
576
|
+
const cell = currentRow[col];
|
|
577
|
+
if (!cell) continue;
|
|
578
|
+
cell.character = " ";
|
|
579
|
+
cell.style = "";
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private writeChar(character: string): void {
|
|
584
|
+
const currentRow = this.cells[this.row];
|
|
585
|
+
if (!currentRow) return;
|
|
586
|
+
const cell = currentRow[this.col];
|
|
587
|
+
if (!cell) return;
|
|
588
|
+
cell.character = character;
|
|
589
|
+
cell.style = this.currentStyle;
|
|
590
|
+
this.col += 1;
|
|
591
|
+
if (this.col >= this.columns) {
|
|
592
|
+
this.col = 0;
|
|
593
|
+
if (this.row < this.rows - 1) {
|
|
594
|
+
this.row += 1;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private parseCsiNumber(raw: string | undefined, fallback: number): number {
|
|
600
|
+
if (!raw || raw.length === 0) {
|
|
601
|
+
return fallback;
|
|
602
|
+
}
|
|
603
|
+
const normalized = raw.replace(/^\?/u, "");
|
|
604
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
605
|
+
if (Number.isNaN(parsed)) {
|
|
606
|
+
return fallback;
|
|
607
|
+
}
|
|
608
|
+
return parsed;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private isFinalCsiCharacter(character: string): boolean {
|
|
612
|
+
const codePoint = character.codePointAt(0);
|
|
613
|
+
if (codePoint === undefined) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
return codePoint >= 0x40 && codePoint <= 0x7e;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private clamp(value: number, min: number, max: number): number {
|
|
620
|
+
return Math.max(min, Math.min(max, value));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
class WeatherWidgetComponent {
|
|
625
|
+
private readonly screen: AnsiScreenBuffer;
|
|
626
|
+
private process: ChildProcess | null = null;
|
|
627
|
+
private nativeProcess: NativeWeatherProcess | null = null;
|
|
628
|
+
private nativePollHandle: ReturnType<typeof setInterval> | null = null;
|
|
629
|
+
private nativeStartupTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
630
|
+
private hasOutput = false;
|
|
631
|
+
private lastNotice: string | undefined;
|
|
632
|
+
private readonly expectedExitPids = new Set<number>();
|
|
633
|
+
private activeRunId = 0;
|
|
634
|
+
private nativeFallbackWarned = false;
|
|
635
|
+
|
|
636
|
+
constructor(private readonly options: WeatherWidgetOptions) {
|
|
637
|
+
this.screen = new AnsiScreenBuffer(options.columns, options.rows);
|
|
638
|
+
this.startProcess();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
handleInput(data: string): void {
|
|
642
|
+
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
643
|
+
this.dispose();
|
|
644
|
+
this.options.onClose();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (data === "r" || data === "R") {
|
|
649
|
+
this.restart();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
render(width: number): string[] {
|
|
654
|
+
if (!this.hasOutput) {
|
|
655
|
+
if (this.lastNotice) {
|
|
656
|
+
return [truncateToWidth(this.lastNotice, width)];
|
|
657
|
+
}
|
|
658
|
+
return [truncateToWidth("Starting weather widget...", width)];
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const lines = this.screen.getLines().map((line) => truncateToWidth(line, width));
|
|
662
|
+
if (this.lastNotice) {
|
|
663
|
+
lines.push(truncateToWidth(this.lastNotice, width));
|
|
664
|
+
}
|
|
665
|
+
return lines;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
invalidate(): void {}
|
|
669
|
+
|
|
670
|
+
private consumeStdout(output: string): boolean {
|
|
671
|
+
if (output.length === 0) {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
this.clearNativeStartupTimeout();
|
|
675
|
+
this.screen.feed(output);
|
|
676
|
+
this.hasOutput = true;
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private consumeStderr(output: string): boolean {
|
|
681
|
+
const message = output.trim();
|
|
682
|
+
if (message.length === 0) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
this.lastNotice = message;
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private setExitNotice(reason: string): void {
|
|
690
|
+
this.lastNotice = `weathr exited (${reason}). Press R to restart.`;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
dispose(): void {
|
|
694
|
+
this.activeRunId += 1;
|
|
695
|
+
this.stopNativeProcess();
|
|
696
|
+
this.stopScriptProcess();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private restart(): void {
|
|
700
|
+
this.dispose();
|
|
701
|
+
this.screen.clear();
|
|
702
|
+
this.hasOutput = false;
|
|
703
|
+
this.lastNotice = undefined;
|
|
704
|
+
this.startProcess();
|
|
705
|
+
this.options.tui.requestRender();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private shouldUseNativeBridge(): boolean {
|
|
709
|
+
return process.env.PI_WEATHER_NATIVE !== "0";
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private startProcess(): void {
|
|
713
|
+
const runId = this.activeRunId + 1;
|
|
714
|
+
this.activeRunId = runId;
|
|
715
|
+
|
|
716
|
+
const nativeModule = getNativeWeatherBridgeModule();
|
|
717
|
+
if (this.shouldUseNativeBridge() && nativeModule && this.startNativeProcess(nativeModule, runId)) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (!this.nativeFallbackWarned && nativeWeatherBridgeLoadError) {
|
|
722
|
+
this.nativeFallbackWarned = true;
|
|
723
|
+
this.lastNotice = "Native weather bridge unavailable. Using shell fallback.";
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
this.startScriptProcess(runId);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private startNativeProcess(nativeModule: NativeWeatherBridgeModule, runId: number): boolean {
|
|
730
|
+
try {
|
|
731
|
+
this.nativeProcess = new nativeModule.NativeWeatherProcess(
|
|
732
|
+
this.options.scriptPath,
|
|
733
|
+
this.options.weathrPath,
|
|
734
|
+
this.options.weathrArgs,
|
|
735
|
+
this.options.configHome,
|
|
736
|
+
this.options.columns,
|
|
737
|
+
this.options.rows,
|
|
738
|
+
);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
741
|
+
this.lastNotice = `Native weather bridge failed: ${message}. Using shell fallback.`;
|
|
742
|
+
this.nativeProcess = null;
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
this.clearNativePollHandle();
|
|
747
|
+
this.nativePollHandle = setInterval(() => {
|
|
748
|
+
this.pollNativeProcess(runId);
|
|
749
|
+
}, NATIVE_POLL_INTERVAL_MS);
|
|
750
|
+
this.scheduleNativeStartupFallback(runId);
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private pollNativeProcess(runId: number): void {
|
|
755
|
+
if (runId !== this.activeRunId) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const process = this.nativeProcess;
|
|
759
|
+
if (!process) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let snapshot: NativeWeatherSnapshot;
|
|
764
|
+
try {
|
|
765
|
+
snapshot = process.poll();
|
|
766
|
+
} catch (error) {
|
|
767
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
768
|
+
this.clearNativePollHandle();
|
|
769
|
+
this.clearNativeStartupTimeout();
|
|
770
|
+
this.nativeProcess = null;
|
|
771
|
+
this.lastNotice = `Native weather bridge crashed: ${message}. Press R to restart.`;
|
|
772
|
+
this.options.tui.requestRender();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const renderedStdout = this.consumeStdout(snapshot.stdout);
|
|
777
|
+
const renderedStderr = this.consumeStderr(snapshot.stderr);
|
|
778
|
+
let renderedExit = false;
|
|
779
|
+
if (snapshot.exited) {
|
|
780
|
+
this.clearNativePollHandle();
|
|
781
|
+
this.clearNativeStartupTimeout();
|
|
782
|
+
this.nativeProcess = null;
|
|
783
|
+
const reason = this.formatNativeExitReason(snapshot);
|
|
784
|
+
this.setExitNotice(reason);
|
|
785
|
+
renderedExit = true;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (renderedStdout || renderedStderr || renderedExit) {
|
|
789
|
+
this.options.tui.requestRender();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private formatNativeExitReason(snapshot: NativeWeatherSnapshot): string {
|
|
794
|
+
if (typeof snapshot.exitCode === "number") {
|
|
795
|
+
return `code ${snapshot.exitCode}`;
|
|
796
|
+
}
|
|
797
|
+
if (snapshot.exitSignal && snapshot.exitSignal.length > 0) {
|
|
798
|
+
return `signal ${snapshot.exitSignal}`;
|
|
799
|
+
}
|
|
800
|
+
return "unknown";
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private clearNativePollHandle(): void {
|
|
804
|
+
if (!this.nativePollHandle) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
clearInterval(this.nativePollHandle);
|
|
808
|
+
this.nativePollHandle = null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private scheduleNativeStartupFallback(runId: number): void {
|
|
812
|
+
this.clearNativeStartupTimeout();
|
|
813
|
+
this.nativeStartupTimeout = setTimeout(() => {
|
|
814
|
+
if (runId !== this.activeRunId) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (this.hasOutput) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (!this.nativeProcess) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
this.stopNativeProcess();
|
|
824
|
+
this.lastNotice = "Native weather bridge produced no output. Falling back to shell bridge.";
|
|
825
|
+
this.startScriptProcess(runId);
|
|
826
|
+
this.options.tui.requestRender();
|
|
827
|
+
}, NATIVE_STARTUP_TIMEOUT_MS);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private clearNativeStartupTimeout(): void {
|
|
831
|
+
if (!this.nativeStartupTimeout) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
clearTimeout(this.nativeStartupTimeout);
|
|
835
|
+
this.nativeStartupTimeout = null;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private stopNativeProcess(): void {
|
|
839
|
+
this.clearNativePollHandle();
|
|
840
|
+
this.clearNativeStartupTimeout();
|
|
841
|
+
const nativeProcess = this.nativeProcess;
|
|
842
|
+
this.nativeProcess = null;
|
|
843
|
+
if (!nativeProcess) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
nativeProcess.writeInput("q");
|
|
849
|
+
} catch {
|
|
850
|
+
// Best effort.
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
try {
|
|
854
|
+
nativeProcess.stop();
|
|
855
|
+
} catch {
|
|
856
|
+
// Best effort.
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private startScriptProcess(runId: number): void {
|
|
861
|
+
const escapedBinary = shellQuote(this.options.weathrPath);
|
|
862
|
+
const escapedArgs = this.options.weathrArgs.map(shellQuote).join(" ");
|
|
863
|
+
const weatherCommand = escapedArgs.length > 0 ? `${escapedBinary} ${escapedArgs}` : escapedBinary;
|
|
864
|
+
const shellCommand = `stty cols ${this.options.columns} rows ${this.options.rows}; exec ${weatherCommand}`;
|
|
865
|
+
|
|
866
|
+
const scriptStdin = resolveScriptStdin();
|
|
867
|
+
let child: ChildProcess;
|
|
868
|
+
try {
|
|
869
|
+
child = spawn(this.options.scriptPath, ["-q", "/dev/null", "sh", "-c", shellCommand], {
|
|
870
|
+
env: createWeatherEnv(this.options.configHome),
|
|
871
|
+
stdio: [scriptStdin, "pipe", "pipe"],
|
|
872
|
+
});
|
|
873
|
+
} catch (error) {
|
|
874
|
+
if (typeof scriptStdin === "number") {
|
|
875
|
+
try {
|
|
876
|
+
closeSync(scriptStdin);
|
|
877
|
+
} catch {
|
|
878
|
+
// Best effort.
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
882
|
+
this.lastNotice = `Failed to start weathr: ${message}`;
|
|
883
|
+
this.options.tui.requestRender();
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (typeof scriptStdin === "number") {
|
|
887
|
+
try {
|
|
888
|
+
closeSync(scriptStdin);
|
|
889
|
+
} catch {
|
|
890
|
+
// Best effort.
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (!child.stdout || !child.stderr) {
|
|
895
|
+
this.lastNotice = "Failed to start weathr: missing stdio streams.";
|
|
896
|
+
this.options.tui.requestRender();
|
|
897
|
+
try {
|
|
898
|
+
child.kill("SIGTERM");
|
|
899
|
+
} catch {
|
|
900
|
+
// Best effort.
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
this.process = child;
|
|
906
|
+
child.stdout.setEncoding("utf8");
|
|
907
|
+
child.stderr.setEncoding("utf8");
|
|
908
|
+
|
|
909
|
+
child.stdout.on("data", (chunk: string | Buffer) => {
|
|
910
|
+
if (runId !== this.activeRunId) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const output = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
914
|
+
if (this.consumeStdout(output)) {
|
|
915
|
+
this.options.tui.requestRender();
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
child.stderr.on("data", (chunk: string | Buffer) => {
|
|
920
|
+
if (runId !== this.activeRunId) {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const output = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
924
|
+
if (this.consumeStderr(output)) {
|
|
925
|
+
this.options.tui.requestRender();
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
child.on("error", (error: Error) => {
|
|
930
|
+
if (runId !== this.activeRunId) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
this.process = null;
|
|
934
|
+
this.lastNotice = `Failed to start weathr: ${error.message}`;
|
|
935
|
+
this.options.tui.requestRender();
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
939
|
+
if (child.pid !== undefined && this.expectedExitPids.delete(child.pid)) {
|
|
940
|
+
if (runId === this.activeRunId) {
|
|
941
|
+
this.process = null;
|
|
942
|
+
}
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (runId !== this.activeRunId) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
this.process = null;
|
|
949
|
+
const reason = code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`;
|
|
950
|
+
this.setExitNotice(reason);
|
|
951
|
+
this.options.tui.requestRender();
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private stopScriptProcess(): void {
|
|
956
|
+
const activeProcess = this.process;
|
|
957
|
+
this.process = null;
|
|
958
|
+
if (!activeProcess) {
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (activeProcess.pid !== undefined) {
|
|
963
|
+
this.expectedExitPids.add(activeProcess.pid);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
if (activeProcess.stdin && activeProcess.stdin.writable) {
|
|
968
|
+
activeProcess.stdin.write("q");
|
|
969
|
+
}
|
|
970
|
+
} catch {
|
|
971
|
+
// Best effort.
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
setTimeout(() => {
|
|
975
|
+
if (!activeProcess.killed) {
|
|
976
|
+
activeProcess.kill("SIGTERM");
|
|
977
|
+
}
|
|
978
|
+
}, 100);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function resolveScriptStdin(): "pipe" | number {
|
|
983
|
+
try {
|
|
984
|
+
return openSync("/dev/null", fsConstants.O_RDONLY);
|
|
985
|
+
} catch {
|
|
986
|
+
return "pipe";
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function createWeatherEnv(configHome: string): NodeJS.ProcessEnv {
|
|
991
|
+
const env: NodeJS.ProcessEnv = {
|
|
992
|
+
...process.env,
|
|
993
|
+
XDG_CONFIG_HOME: configHome,
|
|
994
|
+
};
|
|
995
|
+
if ("NO_COLOR" in env) {
|
|
996
|
+
delete env.NO_COLOR;
|
|
997
|
+
}
|
|
998
|
+
if (!env.COLORTERM || env.COLORTERM.length === 0) {
|
|
999
|
+
env.COLORTERM = "truecolor";
|
|
1000
|
+
}
|
|
1001
|
+
if (!env.TERM || env.TERM.length === 0) {
|
|
1002
|
+
env.TERM = "xterm-256color";
|
|
1003
|
+
}
|
|
1004
|
+
return env;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function shellQuote(value: string): string {
|
|
1008
|
+
return `'${value.split("'").join(`'"'"'`)}'`;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function ensureWeatherConfig(configHome: string): Promise<string> {
|
|
1012
|
+
const configDir = path.join(configHome, "weathr");
|
|
1013
|
+
const configPath = path.join(configDir, "config.toml");
|
|
1014
|
+
|
|
1015
|
+
try {
|
|
1016
|
+
await fs.access(configPath, fsConstants.F_OK);
|
|
1017
|
+
return configPath;
|
|
1018
|
+
} catch {
|
|
1019
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
1020
|
+
await fs.writeFile(configPath, DEFAULT_WEATHER_CONFIG, "utf8");
|
|
1021
|
+
return configPath;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async function isExecutable(filePath: string): Promise<boolean> {
|
|
1026
|
+
try {
|
|
1027
|
+
await fs.access(filePath, fsConstants.X_OK);
|
|
1028
|
+
return true;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function collectPathExecutables(binaryName: string): string[] {
|
|
1035
|
+
const pathValue = process.env.PATH;
|
|
1036
|
+
if (!pathValue) {
|
|
1037
|
+
return [];
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const pathEntries = pathValue
|
|
1041
|
+
.split(path.delimiter)
|
|
1042
|
+
.map((entry) => entry.trim())
|
|
1043
|
+
.filter((entry) => entry.length > 0);
|
|
1044
|
+
|
|
1045
|
+
return pathEntries.map((entry) => path.join(entry, binaryName));
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function resolveExecutable(binaryName: string, extraCandidates: string[]): Promise<string | null> {
|
|
1049
|
+
const candidates = [...collectPathExecutables(binaryName), ...extraCandidates];
|
|
1050
|
+
for (const candidate of candidates) {
|
|
1051
|
+
if (candidate.length === 0) continue;
|
|
1052
|
+
if (await isExecutable(candidate)) {
|
|
1053
|
+
return candidate;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
interface WeatherConfigSummary {
|
|
1060
|
+
auto: boolean | null;
|
|
1061
|
+
latitude: number | null;
|
|
1062
|
+
longitude: number | null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function summarizeWeatherConfig(configText: string): WeatherConfigSummary {
|
|
1066
|
+
let inLocationSection = false;
|
|
1067
|
+
let auto: boolean | null = null;
|
|
1068
|
+
let latitude: number | null = null;
|
|
1069
|
+
let longitude: number | null = null;
|
|
1070
|
+
|
|
1071
|
+
for (const rawLine of configText.split(/\r?\n/u)) {
|
|
1072
|
+
const lineWithoutComment = rawLine.split("#")[0];
|
|
1073
|
+
if (!lineWithoutComment) {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const line = lineWithoutComment.trim();
|
|
1078
|
+
if (line.length === 0) {
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (line.startsWith("[") && line.endsWith("]")) {
|
|
1083
|
+
inLocationSection = line === "[location]";
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (!inLocationSection) {
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const separatorIndex = line.indexOf("=");
|
|
1092
|
+
if (separatorIndex < 0) {
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
1097
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
1098
|
+
|
|
1099
|
+
switch (key) {
|
|
1100
|
+
case "auto": {
|
|
1101
|
+
if (rawValue === "true") {
|
|
1102
|
+
auto = true;
|
|
1103
|
+
} else if (rawValue === "false") {
|
|
1104
|
+
auto = false;
|
|
1105
|
+
}
|
|
1106
|
+
break;
|
|
1107
|
+
}
|
|
1108
|
+
case "latitude": {
|
|
1109
|
+
const parsed = Number.parseFloat(rawValue);
|
|
1110
|
+
if (!Number.isNaN(parsed)) {
|
|
1111
|
+
latitude = parsed;
|
|
1112
|
+
}
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
case "longitude": {
|
|
1116
|
+
const parsed = Number.parseFloat(rawValue);
|
|
1117
|
+
if (!Number.isNaN(parsed)) {
|
|
1118
|
+
longitude = parsed;
|
|
1119
|
+
}
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
default:
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return {
|
|
1128
|
+
auto,
|
|
1129
|
+
latitude,
|
|
1130
|
+
longitude,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function parseWeatherArgs(rawArgs: string | undefined): ParsedWeatherArgs {
|
|
1135
|
+
const trimmed = rawArgs?.trim();
|
|
1136
|
+
if (!trimmed || trimmed.length === 0) {
|
|
1137
|
+
return { forwardedArgs: [], ignoredTokens: [] };
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const tokens = trimmed
|
|
1141
|
+
.split(/\s+/u)
|
|
1142
|
+
.map((token) => token.trim().toLowerCase())
|
|
1143
|
+
.filter((token) => token.length > 0);
|
|
1144
|
+
|
|
1145
|
+
if (tokens.length === 1) {
|
|
1146
|
+
const onlyToken = tokens[0];
|
|
1147
|
+
if (onlyToken && WEATHER_SIMULATION_CONDITIONS.has(onlyToken)) {
|
|
1148
|
+
return {
|
|
1149
|
+
forwardedArgs: ["--simulate", onlyToken],
|
|
1150
|
+
ignoredTokens: [],
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const forwardedArgs: string[] = [];
|
|
1156
|
+
const ignoredTokens: string[] = [];
|
|
1157
|
+
|
|
1158
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
1159
|
+
const token = tokens[index];
|
|
1160
|
+
if (!token) continue;
|
|
1161
|
+
|
|
1162
|
+
if (WEATHER_SIMULATION_CONDITIONS.has(token)) {
|
|
1163
|
+
forwardedArgs.push("--simulate", token);
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
switch (token) {
|
|
1168
|
+
case "simulate":
|
|
1169
|
+
case "--simulate": {
|
|
1170
|
+
const condition = tokens[index + 1];
|
|
1171
|
+
if (condition && WEATHER_SIMULATION_CONDITIONS.has(condition)) {
|
|
1172
|
+
forwardedArgs.push("--simulate", condition);
|
|
1173
|
+
index += 1;
|
|
1174
|
+
} else {
|
|
1175
|
+
ignoredTokens.push(token);
|
|
1176
|
+
}
|
|
1177
|
+
break;
|
|
1178
|
+
}
|
|
1179
|
+
case "night":
|
|
1180
|
+
case "--night":
|
|
1181
|
+
forwardedArgs.push("--night");
|
|
1182
|
+
break;
|
|
1183
|
+
case "leaves":
|
|
1184
|
+
case "--leaves":
|
|
1185
|
+
forwardedArgs.push("--leaves");
|
|
1186
|
+
break;
|
|
1187
|
+
case "auto-location":
|
|
1188
|
+
case "--auto-location":
|
|
1189
|
+
forwardedArgs.push("--auto-location");
|
|
1190
|
+
break;
|
|
1191
|
+
case "hide-location":
|
|
1192
|
+
case "--hide-location":
|
|
1193
|
+
forwardedArgs.push("--hide-location");
|
|
1194
|
+
break;
|
|
1195
|
+
case "hide-hud":
|
|
1196
|
+
case "--hide-hud":
|
|
1197
|
+
forwardedArgs.push("--hide-hud");
|
|
1198
|
+
break;
|
|
1199
|
+
case "imperial":
|
|
1200
|
+
case "--imperial":
|
|
1201
|
+
forwardedArgs.push("--imperial");
|
|
1202
|
+
break;
|
|
1203
|
+
case "metric":
|
|
1204
|
+
case "--metric":
|
|
1205
|
+
forwardedArgs.push("--metric");
|
|
1206
|
+
break;
|
|
1207
|
+
default:
|
|
1208
|
+
ignoredTokens.push(token);
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
return { forwardedArgs, ignoredTokens };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function openWeatherWidget(args: string | undefined, ctx: ExtensionCommandContext): Promise<void> {
|
|
1217
|
+
if (!ctx.hasUI) {
|
|
1218
|
+
ctx.ui.notify("/weather requires interactive mode", "error");
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const scriptPath = await resolveExecutable("script", ["/usr/bin/script"]);
|
|
1223
|
+
if (!scriptPath) {
|
|
1224
|
+
ctx.ui.notify("Missing `script` command. Install util-linux (Linux) or use macOS default.", "error");
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const weathrPath = await resolveExecutable("weathr", [
|
|
1229
|
+
path.join(os.homedir(), ".cargo", "bin", "weathr"),
|
|
1230
|
+
"/opt/homebrew/bin/weathr",
|
|
1231
|
+
"/usr/local/bin/weathr",
|
|
1232
|
+
]);
|
|
1233
|
+
if (!weathrPath) {
|
|
1234
|
+
ctx.ui.notify("`weathr` is not installed. Run: cargo install weathr", "error");
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
try {
|
|
1239
|
+
await ensureWeatherConfig(WEATHER_CONFIG_HOME);
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1242
|
+
ctx.ui.notify(`Failed to create weather config: ${message}`, "error");
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const parsed = parseWeatherArgs(args);
|
|
1247
|
+
if (parsed.ignoredTokens.length > 0) {
|
|
1248
|
+
ctx.ui.notify(`Ignored args: ${parsed.ignoredTokens.join(", ")}`, "warning");
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
ctx.ui.setStatus(WEATHER_STATUS_KEY, "ESC/Q close • R restart");
|
|
1252
|
+
|
|
1253
|
+
let component: WeatherWidgetComponent | null = null;
|
|
1254
|
+
try {
|
|
1255
|
+
await ctx.ui.custom((tui, _theme, _keybindings, done) => {
|
|
1256
|
+
component = new WeatherWidgetComponent({
|
|
1257
|
+
tui,
|
|
1258
|
+
onClose: () => done(undefined),
|
|
1259
|
+
scriptPath,
|
|
1260
|
+
weathrPath,
|
|
1261
|
+
weathrArgs: parsed.forwardedArgs,
|
|
1262
|
+
configHome: WEATHER_CONFIG_HOME,
|
|
1263
|
+
columns: WEATHER_COLUMNS,
|
|
1264
|
+
rows: WEATHER_ROWS,
|
|
1265
|
+
});
|
|
1266
|
+
return component;
|
|
1267
|
+
});
|
|
1268
|
+
} finally {
|
|
1269
|
+
component?.dispose();
|
|
1270
|
+
ctx.ui.setStatus(WEATHER_STATUS_KEY, undefined);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
async function editWeatherConfig(ctx: ExtensionCommandContext): Promise<void> {
|
|
1275
|
+
if (!ctx.hasUI) {
|
|
1276
|
+
ctx.ui.notify("/weather-config requires interactive mode", "error");
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const configPath = await ensureWeatherConfig(WEATHER_CONFIG_HOME);
|
|
1281
|
+
let currentConfig = DEFAULT_WEATHER_CONFIG;
|
|
1282
|
+
try {
|
|
1283
|
+
currentConfig = await fs.readFile(configPath, "utf8");
|
|
1284
|
+
} catch {
|
|
1285
|
+
currentConfig = DEFAULT_WEATHER_CONFIG;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const edited = await ctx.ui.editor("weathr config.toml", currentConfig);
|
|
1289
|
+
if (edited === undefined) {
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
await fs.writeFile(configPath, edited, "utf8");
|
|
1294
|
+
ctx.ui.notify(`Saved ${configPath}`, "info");
|
|
1295
|
+
|
|
1296
|
+
const summary = summarizeWeatherConfig(edited);
|
|
1297
|
+
if (summary.auto === true && summary.latitude !== null && summary.longitude !== null) {
|
|
1298
|
+
ctx.ui.notify(
|
|
1299
|
+
"location.auto=true overrides latitude/longitude. Set auto=false to use your coordinates.",
|
|
1300
|
+
"warning",
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
export default function weatherExtension(pi: ExtensionAPI): void {
|
|
1306
|
+
pi.registerCommand("weather", {
|
|
1307
|
+
description: "Open live weather widget (Esc/Q close, R restart)",
|
|
1308
|
+
handler: async (args, ctx) => {
|
|
1309
|
+
await openWeatherWidget(args, ctx);
|
|
1310
|
+
},
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
pi.registerCommand("weather-config", {
|
|
1314
|
+
description: "Edit weather widget config.toml",
|
|
1315
|
+
handler: async (_args, ctx) => {
|
|
1316
|
+
await editWeatherConfig(ctx);
|
|
1317
|
+
},
|
|
1318
|
+
});
|
|
1319
|
+
}
|