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/usage-extension/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
105
|
-
for (const
|
|
106
|
-
if (signal?.aborted) return
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
//
|
|
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<
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
249
|
-
|
|
250
|
-
|
|
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>();
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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<
|
|
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:
|
|
653
|
+
const finish = (value: UsageDataByMode | null) => {
|
|
585
654
|
if (finished) return;
|
|
586
655
|
finished = true;
|
|
587
656
|
loader.dispose();
|
|
@@ -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`.
|
package/weather/LICENSE
ADDED
|
@@ -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
|
+
[](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`.
|