@raingor/pi-web-switch 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@raingor/pi-web-switch",
3
3
  "private": false,
4
- "version": "0.4.0",
4
+ "version": "0.4.2",
5
5
  "type": "module",
6
- "main": "dist-electron/main.js",
6
+ "main": "dist-electron/main/main.cjs",
7
7
  "description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
8
8
  "keywords": [
9
9
  "pi-package",
@@ -29,7 +29,8 @@
29
29
  "preview": "vite preview",
30
30
  "electron:dev": "node scripts/electron-dev.mjs",
31
31
  "electron:build": "tsc -b && vite build && vite build --config vite.electron.config.ts && vite build --config vite.preload.config.ts && electron-builder",
32
- "electron:preview": "vite build && vite build --config vite.electron.config.ts && vite build --config vite.preload.config.ts && env -u ELECTRON_RUN_AS_NODE npx electron ."
32
+ "electron:preview": "vite build && vite build --config vite.electron.config.ts && vite build --config vite.preload.config.ts && env -u ELECTRON_RUN_AS_NODE npx electron .",
33
+ "tray:icon": "node scripts/generate-tray-icon.mjs"
33
34
  },
34
35
  "files": [
35
36
  "pi-package",
@@ -52,6 +53,10 @@
52
53
  ]
53
54
  },
54
55
  "dependencies": {
56
+ "@earendil-works/pi-agent-core": "^0.83.0",
57
+ "@earendil-works/pi-ai": "^0.83.0",
58
+ "@earendil-works/pi-coding-agent": "^0.83.0",
59
+ "@earendil-works/pi-tui": "^0.83.0",
55
60
  "lucide-react": "^0.487.0",
56
61
  "react": "^19.1.0",
57
62
  "react-dom": "^19.1.0",
@@ -85,8 +90,20 @@
85
90
  "dist-electron/**/*"
86
91
  ],
87
92
  "mac": {
88
- "target": "dmg",
89
- "icon": "build/icon.icns"
93
+ "target": [
94
+ {
95
+ "target": "dmg"
96
+ },
97
+ {
98
+ "target": "zip"
99
+ }
100
+ ],
101
+ "icon": "build/icon.icns",
102
+ "extendInfo": {
103
+ "LSMinimumSystemVersion": "11.0.0",
104
+ "CFBundleName": "pi-web-switch"
105
+ },
106
+ "category": "public.app-category.developer-tools"
90
107
  },
91
108
  "win": {
92
109
  "target": "nsis",
@@ -8,6 +8,7 @@ import { join, resolve, dirname } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  const PI_SWITCH_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
+ const PI_DIR = join(homedir(), ".pi", "agent");
11
12
 
12
13
  let serverProcess: ReturnType<typeof spawn> | null = null;
13
14
 
@@ -17,8 +18,188 @@ function getPackageManager(): "npm" | "pnpm" | "yarn" {
17
18
  return "npm";
18
19
  }
19
20
 
21
+ // ─── Usage reader ────────────────────────────────────────
22
+ // Reads ~/.pi/agent/sessions/*.jsonl directly and aggregates today / 7d stats,
23
+ // so the user can see usage at a glance without launching the dashboard.
24
+
25
+ interface UsageRecord {
26
+ date: string;
27
+ hour?: number;
28
+ providerId: string;
29
+ modelId: string;
30
+ inputTokens: number;
31
+ outputTokens: number;
32
+ cacheReadTokens: number;
33
+ cacheWriteTokens: number;
34
+ requests: number;
35
+ cost: number;
36
+ }
37
+
38
+ const CN_TZ = "Asia/Shanghai";
39
+
40
+ function cnDateParts(ts: string | number): { date: string; hour: number } {
41
+ const d = new Date(ts);
42
+ if (isNaN(d.getTime())) return { date: "unknown", hour: 0 };
43
+ const date = new Intl.DateTimeFormat("en-CA", {
44
+ timeZone: CN_TZ,
45
+ year: "numeric",
46
+ month: "2-digit",
47
+ day: "2-digit",
48
+ }).format(d);
49
+ const hour = Number(
50
+ new Intl.DateTimeFormat("en-US", {
51
+ timeZone: CN_TZ,
52
+ hour: "2-digit",
53
+ hour12: false,
54
+ }).format(d)
55
+ );
56
+ return { date, hour: hour === 24 ? 0 : hour };
57
+ }
58
+
59
+ function parseSessionFile(filePath: string): UsageRecord[] {
60
+ const records: UsageRecord[] = [];
61
+ try {
62
+ const raw = readFileSync(filePath, "utf-8");
63
+ const lines = raw.split("\n").filter((l) => l.trim());
64
+
65
+ let currentProvider = "unknown";
66
+ let currentModel = "unknown";
67
+
68
+ for (const line of lines) {
69
+ try {
70
+ const obj = JSON.parse(line);
71
+ const type = obj.type;
72
+
73
+ if (type === "model_change") {
74
+ currentProvider = obj.provider || currentProvider;
75
+ currentModel = obj.modelId || currentModel;
76
+ continue;
77
+ }
78
+
79
+ if (type === "message" && obj.message?.role === "assistant") {
80
+ const usage = obj.message.usage;
81
+ if (!usage || !usage.input) continue;
82
+
83
+ const timestamp = obj.timestamp || obj.message.timestamp;
84
+ const { date, hour } = cnDateParts(timestamp);
85
+
86
+ records.push({
87
+ date,
88
+ hour,
89
+ providerId: obj.message.provider || currentProvider,
90
+ modelId: obj.message.model || currentModel,
91
+ inputTokens: usage.input ?? 0,
92
+ outputTokens: usage.output ?? 0,
93
+ cacheReadTokens: usage.cacheRead ?? 0,
94
+ cacheWriteTokens: usage.cacheWrite ?? 0,
95
+ requests: 1,
96
+ cost: usage.cost?.total ?? 0,
97
+ });
98
+ }
99
+ } catch {
100
+ // skip malformed lines
101
+ }
102
+ }
103
+ } catch {
104
+ // skip unreadable files
105
+ }
106
+ return records;
107
+ }
108
+
109
+ function readAllUsage(): UsageRecord[] {
110
+ const sessionsPath = join(PI_DIR, "sessions");
111
+ const { readdirSync, existsSync: exists, statSync } = require("node:fs");
112
+ if (!exists(sessionsPath)) return [];
113
+
114
+ const dirs = readdirSync(sessionsPath)
115
+ .filter((name: string) => name.startsWith("--"))
116
+ .map((name: string) => join(sessionsPath, name))
117
+ .filter((dir: string) => statSync(dir).isDirectory());
118
+
119
+ const allRecords: UsageRecord[] = [];
120
+ for (const dir of dirs) {
121
+ try {
122
+ const files = readdirSync(dir).filter((f: string) => f.endsWith(".jsonl"));
123
+ for (const file of files) {
124
+ const records = parseSessionFile(join(dir, file));
125
+ allRecords.push(...records);
126
+ }
127
+ } catch {
128
+ // skip unreadable directories
129
+ }
130
+ }
131
+ return allRecords;
132
+ }
133
+
134
+ function aggregateSummary() {
135
+ const records = readAllUsage();
136
+ // pi-reader buckets dates in Asia/Shanghai (CN_TZ); use the same timezone
137
+ // so "today" lines up with the session data around midnight UTC.
138
+ const cnDate = (d: Date) =>
139
+ new Intl.DateTimeFormat("en-CA", {
140
+ timeZone: "Asia/Shanghai",
141
+ year: "numeric",
142
+ month: "2-digit",
143
+ day: "2-digit",
144
+ }).format(d);
145
+ const today = cnDate(new Date());
146
+ const sevenDaysAgo = cnDate(new Date(Date.now() - 6 * 24 * 60 * 60 * 1000));
147
+
148
+ const todayRecs = records.filter((r) => r.date === today);
149
+ const sevenDayRecs = records.filter((r) => r.date >= sevenDaysAgo);
150
+
151
+ const sum = (recs: UsageRecord[]) => {
152
+ let tokens = 0,
153
+ cost = 0,
154
+ requests = 0;
155
+ for (const r of recs) {
156
+ tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
157
+ cost += r.cost;
158
+ requests += r.requests;
159
+ }
160
+ return { tokens, cost, requests };
161
+ };
162
+
163
+ // Per-day breakdown for last 7 days
164
+ const dailyMap = new Map<string, { tokens: number; cost: number; requests: number }>();
165
+ for (const r of sevenDayRecs) {
166
+ const d = dailyMap.get(r.date) ?? { tokens: 0, cost: 0, requests: 0 };
167
+ d.tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
168
+ d.cost += r.cost;
169
+ d.requests += r.requests;
170
+ dailyMap.set(r.date, d);
171
+ }
172
+ const daily = Array.from(dailyMap.entries())
173
+ .map(([date, v]) => ({ date, ...v }))
174
+ .sort((a, b) => a.date.localeCompare(b.date));
175
+
176
+ return {
177
+ today: sum(todayRecs),
178
+ sevenDays: sum(sevenDayRecs),
179
+ daily,
180
+ };
181
+ }
182
+
183
+ function formatTokens(n: number): string {
184
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
185
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
186
+ return n.toLocaleString();
187
+ }
188
+
189
+ function formatCost(n: number): string {
190
+ if (n === 0) return "$0.00";
191
+ if (n < 0.01) return `$${n.toFixed(4)}`;
192
+ return `$${n.toFixed(2)}`;
193
+ }
194
+
195
+ function shortDate(iso: string): string {
196
+ const parts = iso.split("-");
197
+ if (parts.length !== 3) return iso;
198
+ return `${parts[1]}/${parts[2]}`;
199
+ }
200
+
20
201
  export default function (api: ExtensionAPI, ctx: ExtensionContext) {
21
- // Register a command to start/stop the web UI
202
+ // /pi-switch start|stop|status launch the dashboard web UI
22
203
  api.registerCommand({
23
204
  name: "pi-switch",
24
205
  description: "Start or stop the pi-web-switch dashboard",
@@ -41,7 +222,7 @@ export default function (api: ExtensionAPI, ctx: ExtensionContext) {
41
222
  return ctx.say(
42
223
  <Box borderStyle="round" borderColor="yellow" paddingLeft={1} paddingRight={1}>
43
224
  <Text>pi-web-switch is not running.</Text>
44
- <Text>Use `/pi-web-switch start` to launch the dashboard.</Text>
225
+ <Text>Use `/pi-switch start` to launch the dashboard.</Text>
45
226
  </Box>
46
227
  );
47
228
  }
@@ -84,14 +265,57 @@ export default function (api: ExtensionAPI, ctx: ExtensionContext) {
84
265
 
85
266
  serverProcess.unref();
86
267
 
87
- // Wait a moment then check
88
268
  await new Promise((r) => setTimeout(r, 2000));
89
269
 
90
270
  return ctx.say(
91
271
  <Box borderStyle="round" borderColor="green" paddingLeft={1} paddingRight={1}>
92
272
  <Text>pi-web-switch started!</Text>
93
273
  <Text>Dashboard: http://localhost:{port}</Text>
94
- <Text>Use `/pi-web-switch stop` to stop the server.</Text>
274
+ <Text>Use `/pi-switch stop` to stop the server.</Text>
275
+ </Box>
276
+ );
277
+ }
278
+ },
279
+ });
280
+
281
+ // /pi-usage — quick usage summary (today + 7d) in the terminal
282
+ api.registerCommand({
283
+ name: "pi-usage",
284
+ description: "Show pi usage summary (today / 7 days) without launching the dashboard",
285
+ params: Type.Object({}),
286
+ execute: async () => {
287
+ try {
288
+ const s = aggregateSummary();
289
+ const spark = s.daily
290
+ .map((d) => {
291
+ const max = Math.max(1, ...s.daily.map((x) => x.tokens));
292
+ const bars = Math.round((d.tokens / max) * 8);
293
+ return `${shortDate(d.date)} ${"█".repeat(bars)}${"░".repeat(8 - bars)} ${formatTokens(d.tokens)}`;
294
+ })
295
+ .join("\n");
296
+
297
+ return ctx.say(
298
+ <Box borderStyle="round" borderColor="green" paddingLeft={1} paddingRight={1}>
299
+ <Text bold>📊 pi usage summary</Text>
300
+ <Text> </Text>
301
+ <Text bold>Today ({new Date().toISOString().slice(0, 10)})</Text>
302
+ <Text> Tokens: {formatTokens(s.today.tokens)}</Text>
303
+ <Text> Cost: {formatCost(s.today.cost)}</Text>
304
+ <Text> Requests: {s.today.requests}</Text>
305
+ <Text> </Text>
306
+ <Text bold>Last 7 days</Text>
307
+ <Text> Tokens: {formatTokens(s.sevenDays.tokens)}</Text>
308
+ <Text> Cost: {formatCost(s.sevenDays.cost)}</Text>
309
+ <Text> Requests: {s.sevenDays.requests}</Text>
310
+ <Text> </Text>
311
+ <Text bold>Daily trend</Text>
312
+ <Text>{spark}</Text>
313
+ </Box>
314
+ );
315
+ } catch (err) {
316
+ return ctx.say(
317
+ <Box borderStyle="round" borderColor="red" paddingLeft={1} paddingRight={1}>
318
+ <Text>Failed to read usage: {String(err)}</Text>
95
319
  </Box>
96
320
  );
97
321
  }
Binary file
Binary file
Binary file
package/public/pi.svg CHANGED
@@ -1,47 +1,12 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
2
2
  <defs>
3
- <!-- Brand blue diagonal gradient -->
4
- <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
5
- <stop offset="0" stop-color="#6ba6fb"/>
6
- <stop offset="0.5" stop-color="#2563eb"/>
7
- <stop offset="1" stop-color="#1e40af"/>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="0.3" y2="1">
4
+ <stop offset="0" stop-color="#3b82f6"/>
5
+ <stop offset="1" stop-color="#1d4ed8"/>
8
6
  </linearGradient>
9
- <!-- Soft top gloss for a tactile, premium feel -->
10
- <linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
11
- <stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
12
- <stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
13
- </linearGradient>
14
- <!-- Subtle vertical shading on the pi glyph -->
15
- <linearGradient id="piGrad" x1="0" y1="0" x2="0" y2="1">
16
- <stop offset="0" stop-color="#ffffff"/>
17
- <stop offset="1" stop-color="#d9e6ff"/>
18
- </linearGradient>
19
- <!-- Toggle knob highlight -->
20
- <radialGradient id="knob" cx="0.35" cy="0.3" r="0.85">
21
- <stop offset="0" stop-color="#ffffff"/>
22
- <stop offset="1" stop-color="#cdd9f2"/>
23
- </radialGradient>
24
- <filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
25
- <feDropShadow dx="0" dy="7" stdDeviation="11" flood-color="#0a2a6b" flood-opacity="0.38"/>
26
- </filter>
27
- <filter id="knobShadow" x="-60%" y="-60%" width="220%" height="220%">
28
- <feDropShadow dx="0" dy="3" stdDeviation="5" flood-color="#0a2a6b" flood-opacity="0.35"/>
29
- </filter>
30
7
  </defs>
31
-
32
- <!-- Squircle background -->
33
- <rect x="0" y="0" width="512" height="512" rx="115" fill="url(#bg)"/>
34
- <!-- Top gloss highlight -->
35
- <rect x="0" y="0" width="512" height="250" rx="115" fill="url(#gloss)"/>
36
-
37
- <!-- Toggle switch track (behind the pi) -->
38
- <rect x="92" y="217" width="328" height="78" rx="39" fill="#ffffff" opacity="0.20"/>
39
- <rect x="92" y="217" width="328" height="78" rx="39" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="2"/>
40
- <!-- Toggle knob in the "on" position -->
41
- <circle cx="378" cy="256" r="30" fill="url(#knob)" filter="url(#knobShadow)"/>
42
-
43
- <!-- Pi glyph -->
44
- <text x="256" y="256" text-anchor="middle" dominant-baseline="central"
8
+ <rect x="0" y="0" width="512" height="512" rx="113" fill="url(#bg)"/>
9
+ <text x="256" y="270" text-anchor="middle" dominant-baseline="central"
45
10
  font-family="-apple-system, 'SF Pro Display', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
46
- font-size="258" font-weight="700" fill="url(#piGrad)" filter="url(#softShadow)">π</text>
11
+ font-size="240" font-weight="500" fill="#ffffff">π</text>
47
12
  </svg>
package/public/sw.js CHANGED
@@ -1,14 +1,20 @@
1
1
  // ─── pi-web-switch Service Worker ───────────────────────
2
- // Cache-first for static assets, network-first for API calls.
3
- // Bump CACHE_VERSION to invalidate old caches on deploy.
2
+ // Strategy:
3
+ // - HTML navigation (index.html / SPA routes): NETWORK-FIRST.
4
+ // Every refresh must hit the network so deploys show up on a plain
5
+ // Cmd+R reload; the cached copy is only a fallback for offline use.
6
+ // - Static assets (hashed js/css): CACHE-FIRST — safe because Vite
7
+ // fingerprints filenames, so a new deploy means new URLs.
8
+ // - API calls: NETWORK-FIRST, fallback to cache.
9
+ // Bump CACHE_VERSION when the SW logic changes to purge old caches.
4
10
 
5
- const CACHE_VERSION = "pi-web-switch-v1";
11
+ const CACHE_VERSION = "pi-web-switch-v2";
6
12
  const STATIC_CACHE = `${CACHE_VERSION}-static`;
7
13
  const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
8
14
 
15
+ // NOTE: index.html is intentionally NOT precached. Pre-caching it would
16
+ // make Cmd+R reloads serve the stale page after a deploy.
9
17
  const PRECACHE_URLS = [
10
- "/",
11
- "/index.html",
12
18
  "/manifest.webmanifest",
13
19
  "/icon-192.png",
14
20
  "/icon-512.png",
@@ -64,6 +70,23 @@ self.addEventListener("fetch", (event) => {
64
70
  return;
65
71
  }
66
72
 
73
+ // HTML navigation: network-first so a plain refresh always gets the
74
+ // latest build. Fall back to the last cached copy only when offline.
75
+ if (request.mode === "navigate") {
76
+ event.respondWith(
77
+ fetch(request)
78
+ .then((response) => {
79
+ if (response && response.status === 200 && response.type === "basic") {
80
+ const copy = response.clone();
81
+ caches.open(RUNTIME_CACHE).then((cache) => cache.put("/index.html", copy));
82
+ }
83
+ return response;
84
+ })
85
+ .catch(() => caches.match("/index.html"))
86
+ );
87
+ return;
88
+ }
89
+
67
90
  // Static assets: cache-first, then network (and cache the result).
68
91
  event.respondWith(
69
92
  caches.match(request).then((cached) => {
Binary file