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.
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Shows an inline view with usage stats grouped by provider.
5
5
  * - Tab cycles: Today → This Week → All Time
6
+ * - D toggles deduped view, R toggles raw view, M cycles both
6
7
  * - Arrow keys navigate providers
7
8
  * - Enter expands/collapses to show models
8
9
  */
@@ -56,6 +57,8 @@ interface UsageData {
56
57
  }
57
58
 
58
59
  type TabName = "today" | "thisWeek" | "allTime";
60
+ type UsageCountMode = "deduped" | "raw";
61
+ type UsageDataByMode = Record<UsageCountMode, UsageData>;
59
62
 
60
63
  // =============================================================================
61
64
  // Column Configuration
@@ -96,31 +99,27 @@ function getSessionsDir(): string {
96
99
  return join(agentDir, "sessions");
97
100
  }
98
101
 
99
- async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
100
- const sessionsDir = getSessionsDir();
101
- const files: string[] = [];
102
-
102
+ async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
103
103
  try {
104
- const cwdDirs = await readdir(sessionsDir, { withFileTypes: true });
105
- for (const dir of cwdDirs) {
106
- if (signal?.aborted) return files;
107
- if (!dir.isDirectory()) continue;
108
- const cwdPath = join(sessionsDir, dir.name);
109
- try {
110
- const sessionFiles = await readdir(cwdPath);
111
- for (const file of sessionFiles) {
112
- if (file.endsWith(".jsonl")) {
113
- files.push(join(cwdPath, file));
114
- }
115
- }
116
- } catch {
117
- // Skip directories we can't read
104
+ const entries = await readdir(dir, { withFileTypes: true });
105
+ for (const entry of entries) {
106
+ if (signal?.aborted) return;
107
+ const entryPath = join(dir, entry.name);
108
+ if (entry.isDirectory()) {
109
+ await collectSessionFilesRecursively(entryPath, files, signal);
110
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
111
+ files.push(entryPath);
118
112
  }
119
113
  }
120
114
  } catch {
121
- // Return empty if we can't read sessions dir
115
+ // Skip directories we can't read
122
116
  }
117
+ }
123
118
 
119
+ async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
120
+ const files: string[] = [];
121
+ await collectSessionFilesRecursively(getSessionsDir(), files, signal);
122
+ files.sort();
124
123
  return files;
125
124
  }
126
125
 
@@ -135,16 +134,23 @@ interface SessionMessage {
135
134
  timestamp: number;
136
135
  }
137
136
 
137
+ interface ParsedSessionFile {
138
+ sessionId: string;
139
+ rawMessages: SessionMessage[];
140
+ dedupedMessages: SessionMessage[];
141
+ }
142
+
138
143
  async function parseSessionFile(
139
144
  filePath: string,
140
145
  seenHashes: Set<string>,
141
146
  signal?: AbortSignal
142
- ): Promise<{ sessionId: string; messages: SessionMessage[] } | null> {
147
+ ): Promise<ParsedSessionFile | null> {
143
148
  try {
144
149
  const content = await readFile(filePath, "utf8");
145
150
  if (signal?.aborted) return null;
146
151
  const lines = content.trim().split("\n");
147
- const messages: SessionMessage[] = [];
152
+ const rawMessages: SessionMessage[] = [];
153
+ const dedupedMessages: SessionMessage[] = [];
148
154
  let sessionId = "";
149
155
 
150
156
  for (let i = 0; i < lines.length; i++) {
@@ -169,14 +175,7 @@ async function parseSessionFile(
169
175
  const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
170
176
  const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
171
177
 
172
- // Deduplicate by timestamp + total tokens (same as ccusage)
173
- // Session files contain many duplicate entries
174
- const totalTokens = input + output + cacheRead + cacheWrite;
175
- const hash = `${timestamp}:${totalTokens}`;
176
- if (seenHashes.has(hash)) continue;
177
- seenHashes.add(hash);
178
-
179
- messages.push({
178
+ const sessionMessage: SessionMessage = {
180
179
  provider: msg.provider,
181
180
  model: msg.model,
182
181
  cost: msg.usage.cost?.total || 0,
@@ -185,7 +184,16 @@ async function parseSessionFile(
185
184
  cacheRead,
186
185
  cacheWrite,
187
186
  timestamp,
188
- });
187
+ };
188
+ rawMessages.push(sessionMessage);
189
+
190
+ // Deduplicate copied history across branched session files.
191
+ // Keep the existing ccusage-style hash so current totals remain comparable.
192
+ const totalTokens = input + output + cacheRead + cacheWrite;
193
+ const hash = `${timestamp}:${totalTokens}`;
194
+ if (seenHashes.has(hash)) continue;
195
+ seenHashes.add(hash);
196
+ dedupedMessages.push(sessionMessage);
189
197
  }
190
198
  }
191
199
  } catch {
@@ -193,7 +201,7 @@ async function parseSessionFile(
193
201
  }
194
202
  }
195
203
 
196
- return sessionId ? { sessionId, messages } : null;
204
+ return sessionId ? { sessionId, rawMessages, dedupedMessages } : null;
197
205
  } catch {
198
206
  return null;
199
207
  }
@@ -232,7 +240,74 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
232
240
  };
233
241
  }
234
242
 
235
- async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
243
+ function emptyUsageData(): UsageData {
244
+ return {
245
+ today: emptyTimeFilteredStats(),
246
+ thisWeek: emptyTimeFilteredStats(),
247
+ allTime: emptyTimeFilteredStats(),
248
+ };
249
+ }
250
+
251
+ function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number): TabName[] {
252
+ const periods: TabName[] = ["allTime"];
253
+ if (timestamp >= todayMs) periods.push("today");
254
+ if (timestamp >= weekStartMs) periods.push("thisWeek");
255
+ return periods;
256
+ }
257
+
258
+ function addMessagesToUsageData(
259
+ data: UsageData,
260
+ sessionId: string,
261
+ messages: SessionMessage[],
262
+ todayMs: number,
263
+ weekStartMs: number
264
+ ): void {
265
+ const sessionContributed = { today: false, thisWeek: false, allTime: false };
266
+
267
+ for (const msg of messages) {
268
+ const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs);
269
+ const tokens = {
270
+ // Total = input + output only. cacheRead/cacheWrite are tracked separately.
271
+ // cacheRead tokens were already counted when first sent, so including them
272
+ // would double-count and massively inflate totals (cache hits repeat every message).
273
+ total: msg.input + msg.output,
274
+ input: msg.input,
275
+ output: msg.output,
276
+ cache: msg.cacheRead + msg.cacheWrite,
277
+ };
278
+
279
+ for (const period of periods) {
280
+ const stats = data[period];
281
+
282
+ let providerStats = stats.providers.get(msg.provider);
283
+ if (!providerStats) {
284
+ providerStats = emptyProviderStats();
285
+ stats.providers.set(msg.provider, providerStats);
286
+ }
287
+
288
+ let modelStats = providerStats.models.get(msg.model);
289
+ if (!modelStats) {
290
+ modelStats = emptyModelStats();
291
+ providerStats.models.set(msg.model, modelStats);
292
+ }
293
+
294
+ modelStats.sessions.add(sessionId);
295
+ accumulateStats(modelStats, msg.cost, tokens);
296
+
297
+ providerStats.sessions.add(sessionId);
298
+ accumulateStats(providerStats, msg.cost, tokens);
299
+
300
+ accumulateStats(stats.totals, msg.cost, tokens);
301
+ sessionContributed[period] = true;
302
+ }
303
+ }
304
+
305
+ if (sessionContributed.today) data.today.totals.sessions++;
306
+ if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
307
+ if (sessionContributed.allTime) data.allTime.totals.sessions++;
308
+ }
309
+
310
+ async function collectUsageData(signal?: AbortSignal): Promise<UsageDataByMode | null> {
236
311
  const startOfToday = new Date();
237
312
  startOfToday.setHours(0, 0, 0, 0);
238
313
  const todayMs = startOfToday.getTime();
@@ -245,15 +320,14 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
245
320
  startOfWeek.setHours(0, 0, 0, 0);
246
321
  const weekStartMs = startOfWeek.getTime();
247
322
 
248
- const data: UsageData = {
249
- today: emptyTimeFilteredStats(),
250
- thisWeek: emptyTimeFilteredStats(),
251
- allTime: emptyTimeFilteredStats(),
323
+ const data: UsageDataByMode = {
324
+ deduped: emptyUsageData(),
325
+ raw: emptyUsageData(),
252
326
  };
253
327
 
254
328
  const sessionFiles = await getAllSessionFiles(signal);
255
329
  if (signal?.aborted) return null;
256
- const seenHashes = new Set<string>(); // Deduplicate across all files
330
+ const seenHashes = new Set<string>();
257
331
 
258
332
  for (const filePath of sessionFiles) {
259
333
  if (signal?.aborted) return null;
@@ -261,59 +335,8 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
261
335
  if (signal?.aborted) return null;
262
336
  if (!parsed) continue;
263
337
 
264
- const { sessionId, messages } = parsed;
265
- const sessionContributed = { today: false, thisWeek: false, allTime: false };
266
-
267
- for (const msg of messages) {
268
- if (signal?.aborted) return null;
269
- const periods: TabName[] = ["allTime"];
270
- if (msg.timestamp >= todayMs) periods.push("today");
271
- if (msg.timestamp >= weekStartMs) periods.push("thisWeek");
272
-
273
- const tokens = {
274
- // Total = input + output only. cacheRead/cacheWrite are tracked separately.
275
- // cacheRead tokens were already counted when first sent, so including them
276
- // would double-count and massively inflate totals (cache hits repeat every message).
277
- total: msg.input + msg.output,
278
- input: msg.input,
279
- output: msg.output,
280
- cache: msg.cacheRead + msg.cacheWrite,
281
- };
282
-
283
- for (const period of periods) {
284
- const stats = data[period];
285
-
286
- // Get or create provider stats
287
- let providerStats = stats.providers.get(msg.provider);
288
- if (!providerStats) {
289
- providerStats = emptyProviderStats();
290
- stats.providers.set(msg.provider, providerStats);
291
- }
292
-
293
- // Get or create model stats
294
- let modelStats = providerStats.models.get(msg.model);
295
- if (!modelStats) {
296
- modelStats = emptyModelStats();
297
- providerStats.models.set(msg.model, modelStats);
298
- }
299
-
300
- // Accumulate stats at all levels
301
- modelStats.sessions.add(sessionId);
302
- accumulateStats(modelStats, msg.cost, tokens);
303
-
304
- providerStats.sessions.add(sessionId);
305
- accumulateStats(providerStats, msg.cost, tokens);
306
-
307
- accumulateStats(stats.totals, msg.cost, tokens);
308
-
309
- sessionContributed[period] = true;
310
- }
311
- }
312
-
313
- // Count unique sessions per period
314
- if (sessionContributed.today) data.today.totals.sessions++;
315
- if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
316
- if (sessionContributed.allTime) data.allTime.totals.sessions++;
338
+ addMessagesToUsageData(data.raw, parsed.sessionId, parsed.rawMessages, todayMs, weekStartMs);
339
+ addMessagesToUsageData(data.deduped, parsed.sessionId, parsed.dedupedMessages, todayMs, weekStartMs);
317
340
 
318
341
  await new Promise<void>((resolve) => setImmediate(resolve));
319
342
  }
@@ -372,9 +395,17 @@ const TAB_LABELS: Record<TabName, string> = {
372
395
 
373
396
  const TAB_ORDER: TabName[] = ["today", "thisWeek", "allTime"];
374
397
 
398
+ const MODE_LABELS: Record<UsageCountMode, string> = {
399
+ deduped: "Deduped",
400
+ raw: "Raw",
401
+ };
402
+
403
+ const MODE_ORDER: UsageCountMode[] = ["deduped", "raw"];
404
+
375
405
  class UsageComponent {
376
406
  private activeTab: TabName = "allTime";
377
- private data: UsageData;
407
+ private activeMode: UsageCountMode = "deduped";
408
+ private data: UsageDataByMode;
378
409
  private selectedIndex = 0;
379
410
  private expanded = new Set<string>();
380
411
  private providerOrder: string[] = [];
@@ -382,7 +413,7 @@ class UsageComponent {
382
413
  private requestRender: () => void;
383
414
  private done: () => void;
384
415
 
385
- constructor(theme: Theme, data: UsageData, requestRender: () => void, done: () => void) {
416
+ constructor(theme: Theme, data: UsageDataByMode, requestRender: () => void, done: () => void) {
386
417
  this.theme = theme;
387
418
  this.requestRender = requestRender;
388
419
  this.done = done;
@@ -390,14 +421,25 @@ class UsageComponent {
390
421
  this.updateProviderOrder();
391
422
  }
392
423
 
424
+ private getActiveStats(): TimeFilteredStats {
425
+ return this.data[this.activeMode][this.activeTab];
426
+ }
427
+
393
428
  private updateProviderOrder(): void {
394
- const stats = this.data[this.activeTab];
429
+ const stats = this.getActiveStats();
395
430
  this.providerOrder = Array.from(stats.providers.entries())
396
431
  .sort((a, b) => b[1].cost - a[1].cost)
397
432
  .map(([name]) => name);
398
433
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
399
434
  }
400
435
 
436
+ private cycleMode(step: 1 | -1): void {
437
+ const idx = MODE_ORDER.indexOf(this.activeMode);
438
+ this.activeMode = MODE_ORDER[(idx + step + MODE_ORDER.length) % MODE_ORDER.length]!;
439
+ this.updateProviderOrder();
440
+ this.requestRender();
441
+ }
442
+
401
443
  handleInput(data: string): void {
402
444
  if (matchesKey(data, "escape") || matchesKey(data, "q")) {
403
445
  this.done();
@@ -414,6 +456,20 @@ class UsageComponent {
414
456
  this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
415
457
  this.updateProviderOrder();
416
458
  this.requestRender();
459
+ } else if (matchesKey(data, "m")) {
460
+ this.cycleMode(1);
461
+ } else if (matchesKey(data, "d")) {
462
+ if (this.activeMode !== "deduped") {
463
+ this.activeMode = "deduped";
464
+ this.updateProviderOrder();
465
+ this.requestRender();
466
+ }
467
+ } else if (matchesKey(data, "r")) {
468
+ if (this.activeMode !== "raw") {
469
+ this.activeMode = "raw";
470
+ this.updateProviderOrder();
471
+ this.requestRender();
472
+ }
417
473
  } else if (matchesKey(data, "up")) {
418
474
  if (this.selectedIndex > 0) {
419
475
  this.selectedIndex--;
@@ -445,6 +501,7 @@ class UsageComponent {
445
501
  return [
446
502
  ...this.renderTitle(),
447
503
  ...this.renderTabs(),
504
+ ...this.renderModes(),
448
505
  ...this.renderHeader(),
449
506
  ...this.renderRows(),
450
507
  ...this.renderTotals(),
@@ -463,7 +520,19 @@ class UsageComponent {
463
520
  const label = TAB_LABELS[tab];
464
521
  return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
465
522
  }).join(" ");
466
- return [tabs, ""];
523
+ return [tabs];
524
+ }
525
+
526
+ private renderModes(): string[] {
527
+ const th = this.theme;
528
+ const modes = MODE_ORDER.map((mode) => {
529
+ const label = MODE_LABELS[mode];
530
+ return mode === this.activeMode ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
531
+ }).join(" ");
532
+ const note = this.activeMode === "deduped"
533
+ ? "Dedupes copied branched-history messages. Recursive subagent sessions included."
534
+ : "Counts raw message totals from all session files. Recursive subagent sessions included.";
535
+ return [modes, th.fg("dim", note), ""];
467
536
  }
468
537
 
469
538
  private renderHeader(): string[] {
@@ -504,7 +573,7 @@ class UsageComponent {
504
573
 
505
574
  private renderRows(): string[] {
506
575
  const th = this.theme;
507
- const stats = this.data[this.activeTab];
576
+ const stats = this.getActiveStats();
508
577
  const lines: string[] = [];
509
578
 
510
579
  if (this.providerOrder.length === 0) {
@@ -542,7 +611,7 @@ class UsageComponent {
542
611
 
543
612
  private renderTotals(): string[] {
544
613
  const th = this.theme;
545
- const stats = this.data[this.activeTab];
614
+ const stats = this.getActiveStats();
546
615
 
547
616
  let totalRow = padRight(th.bold("Total"), NAME_COL_WIDTH);
548
617
  for (const col of DATA_COLUMNS) {
@@ -554,7 +623,7 @@ class UsageComponent {
554
623
  }
555
624
 
556
625
  private renderHelp(): string[] {
557
- return [this.theme.fg("dim", "[Tab/←→] period [↑↓] select [Enter] expand [q] close")];
626
+ return [this.theme.fg("dim", "[Tab/←→] period [m/d/r] count mode [↑↓] select [Enter] expand [q] close")];
558
627
  }
559
628
 
560
629
  invalidate(): void {}
@@ -573,7 +642,7 @@ export default function (pi: ExtensionAPI) {
573
642
  return;
574
643
  }
575
644
 
576
- const data = await ctx.ui.custom<UsageData | null>((tui, theme, _kb, done) => {
645
+ const data = await ctx.ui.custom<UsageDataByMode | null>((tui, theme, _kb, done) => {
577
646
  const loader = new CancellableLoader(
578
647
  tui,
579
648
  (s: string) => theme.fg("accent", s),
@@ -581,7 +650,7 @@ export default function (pi: ExtensionAPI) {
581
650
  "Loading Usage..."
582
651
  );
583
652
  let finished = false;
584
- const finish = (value: UsageData | null) => {
653
+ const finish = (value: UsageDataByMode | null) => {
585
654
  if (finished) return;
586
655
  finished = true;
587
656
  loader.dispose();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 - 2026-02-12
4
+ - Added an embedded demo GIF in the README that links to the MP4 demo hosted on GitHub.
5
+ - Kept demo media out of npm installs while improving README preview on GitHub/npm.
6
+
7
+ ## 0.1.0 - 2026-02-12
8
+ - Initial release of `/weather` weather widget extension.
9
+ - Added native Rust bridge (`native/weathr-bridge`) with automatic shell fallback.
10
+ - Added ANSI color preservation in the weather widget output.
11
+ - Fixed shell fallback PTY bootstrap under Bun by binding `script` stdin to `/dev/null` (avoids socket `tcgetattr` errors without stealing ESC input from Pi).
12
+ - `/weather-config` now warns when `location.auto=true` (which overrides manual latitude/longitude).
13
+ - Added optional dependency support for platform prebuilt native bridge packages (`@tmustier/pi-weather-bridge-*`).
14
+ - Added release automation workflow for publishing native bridge platform packages (`.github/workflows/weather-native-bridge.yml`).
15
+ - `/weather` now renders in the main custom UI area (above the editor) instead of centered overlay mode.
16
+ - Added `/weather-config` command and isolated config at `~/.pi/weather-widget/weathr/config.toml`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Mustier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ # Weather Widget Extension
2
+
3
+ Run the [weathr](https://github.com/veirt/weathr) terminal weather app inside Pi via `/weather`.
4
+
5
+ It opens in the main widget area above the input box (same interaction style as `/snake`), supports live weather + simulation flags, keeps controls inside Pi, and preserves ANSI colors.
6
+
7
+ The extension prefers a Rust N-API bridge (`native/weathr-bridge`) and falls back to a shell bridge if native isn't built.
8
+
9
+ ## Demo
10
+
11
+ [![Weather widget demo](https://raw.githubusercontent.com/tmustier/pi-extensions/main/weather/assets/weather-demo.gif)](https://raw.githubusercontent.com/tmustier/pi-extensions/main/weather/assets/weather-demo.mp4)
12
+
13
+ [Open MP4 demo directly](https://raw.githubusercontent.com/tmustier/pi-extensions/main/weather/assets/weather-demo.mp4)
14
+
15
+ _Demo media is loaded from GitHub links and kept out of npm installs (package `files` whitelist + repo `.npmignore`)._
16
+
17
+ ## Install
18
+
19
+ ### Pi package manager
20
+
21
+ ```bash
22
+ pi install npm:@tmustier/pi-weather
23
+ ```
24
+
25
+ ```bash
26
+ pi install git:github.com/tmustier/pi-extensions
27
+ ```
28
+
29
+ Then filter to just this extension in `~/.pi/agent/settings.json`:
30
+
31
+ ```json
32
+ {
33
+ "packages": [
34
+ {
35
+ "source": "git:github.com/tmustier/pi-extensions",
36
+ "extensions": ["weather/index.ts"]
37
+ }
38
+ ]
39
+ }
40
+ ```
41
+
42
+ ### Local clone
43
+
44
+ ```bash
45
+ ln -s ~/pi-extensions/weather ~/.pi/agent/extensions/weather
46
+ ```
47
+
48
+ Or add to `~/.pi/agent/settings.json`:
49
+
50
+ ```json
51
+ {
52
+ "extensions": ["~/pi-extensions/weather"]
53
+ }
54
+ ```
55
+
56
+ ## Commands
57
+
58
+ - `/weather` — open live weather widget
59
+ - `/weather rain` — shortcut for `--simulate rain`
60
+ - `/weather --simulate snow --night`
61
+ - `/weather-config` — edit widget config (`config.toml`)
62
+
63
+ While open:
64
+ - `Esc` or `Q` closes the widget
65
+ - `R` restarts the weather process
66
+
67
+ ## Requirements
68
+
69
+ - `weathr` installed and available on PATH (or in `~/.cargo/bin/weathr`)
70
+ - `script` command available (macOS default, `util-linux` on Linux)
71
+
72
+ Install weathr:
73
+
74
+ ```bash
75
+ cargo install weathr
76
+ ```
77
+
78
+ Build the native Rust bridge locally (optional, for development):
79
+
80
+ ```bash
81
+ cd ~/pi-extensions/weather
82
+ npm run build:native
83
+ ```
84
+
85
+ Requires Rust + Node.
86
+
87
+ For npm users, the extension can load prebuilt optional packages (`@tmustier/pi-weather-bridge-*`) when published.
88
+
89
+ Troubleshooting:
90
+
91
+ - The extension auto-falls back to shell mode if native bridge has no output.
92
+ - If no matching prebuilt native package is installed for your platform, it falls back to shell mode.
93
+ - It explicitly unsets `NO_COLOR` for the weather child process and sets `COLORTERM=truecolor` when missing.
94
+ - Shell fallback binds `script` stdin to `/dev/null` (avoids Bun socket `tcgetattr` issues while preserving ANSI color output and ESC handling in Pi).
95
+ - Force shell mode manually:
96
+
97
+ ```bash
98
+ PI_WEATHER_NATIVE=0 pi
99
+ ```
100
+
101
+ ## Config Location
102
+
103
+ The extension uses an isolated config home:
104
+
105
+ - `~/.pi/weather-widget/weathr/config.toml`
106
+
107
+ Use `/weather-config` to edit it.
108
+
109
+ > If you set custom `latitude`/`longitude`, also set `location.auto = false` or `weathr` will keep auto-detecting your location.
110
+
111
+ ## Publishing native prebuilt packages
112
+
113
+ To ship `weathr-bridge` without requiring Rust at install time:
114
+
115
+ - Run GitHub Actions workflow `.github/workflows/weather-native-bridge.yml` (manual `workflow_dispatch`).
116
+ - The workflow builds prebuilt `.node` files per target, syncs them into `native/weathr-bridge/npm/*`, and publishes `@tmustier/pi-weather-bridge-*` platform packages.
117
+ - Then publish `@tmustier/pi-weather` (this extension) so consumers pick up the matching optional dependency versions.
118
+ - Keep versions in sync (`weather/package.json`, `native/weathr-bridge/package.json`, and `native/weathr-bridge/npm/*/package.json`).
119
+
120
+ Manual fallback (if not using the workflow):
121
+
122
+ ```bash
123
+ cd ~/pi-extensions/weather
124
+ npm run native:prepare-packages
125
+ # build / download per-target pi_weather_bridge.<target>.node files into native/weathr-bridge/artifacts
126
+ npm run native:sync-artifacts
127
+ npm run native:publish-packages
128
+ ```
129
+
130
+ ## Changelog
131
+
132
+ See `CHANGELOG.md`.