pi-web 0.4.5 → 0.6.0

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/README.md CHANGED
@@ -10,14 +10,21 @@ A web UI for the [pi coding agent](https://github.com/badlogic/pi-mono).
10
10
  npx -y pi-web@latest
11
11
  ```
12
12
 
13
- Then open [http://localhost:3100](http://localhost:3100) in your browser.
13
+ Then open [http://127.0.0.1:8192](http://127.0.0.1:8192) in your browser.
14
14
 
15
15
  ## Options
16
16
 
17
17
  ```
18
- --port <number> Port to listen on (default: 3100, env: PORT)
19
- --host <string> Host to bind to (default: localhost, env: HOST)
20
- --help Show help
18
+ --port <number> Port to listen on (default: 8192, env: PORT)
19
+ --host <string> Host to bind to (default: 127.0.0.1, env: HOST)
20
+ --agent <pi|omp> Agent backend profile (default: pi)
21
+ --help Show help
22
+ ```
23
+
24
+ To run against Oh My Pi, start with:
25
+
26
+ ```bash
27
+ npx -y pi-web@latest --agent omp
21
28
  ```
22
29
 
23
30
  ## Features
@@ -35,7 +42,8 @@ Then open [http://localhost:3100](http://localhost:3100) in your browser.
35
42
  git clone https://github.com/ravshansbox/pi-web
36
43
  cd pi-web
37
44
  npm install
38
- npm run dev
45
+ npm run dev:pi # Pi backend
46
+ npm run dev:omp # Oh My Pi backend
39
47
  ```
40
48
 
41
49
  Requires Node.js 22+.
@@ -1,6 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { createReadStream, existsSync, readFileSync, statSync, unlinkSync } from 'node:fs';
3
- import { dirname, extname, join, normalize } from 'node:path';
3
+ import { readdir } from 'node:fs/promises';
4
+ import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { WebSocketServer, WebSocket } from 'ws';
6
7
  import { RpcSession } from './rpc.js';
@@ -13,9 +14,10 @@ pi-web - Web UI for the pi coding agent
13
14
  Usage: pi-web [options]
14
15
 
15
16
  Options:
16
- --port <number> Port to listen on (default: 8192, env: PORT)
17
- --host <string> Host to bind to (default: 127.0.0.1, env: HOST)
18
- -h, --help Show this help message
17
+ --port <number> Port to listen on (default: 8192, env: PORT)
18
+ --host <string> Host to bind to (default: 127.0.0.1, env: HOST)
19
+ --agent <pi|omp> Agent backend profile (default: pi)
20
+ -h, --help Show this help message
19
21
  `.trim());
20
22
  process.exit(0);
21
23
  }
@@ -29,15 +31,64 @@ function getArg(name) {
29
31
  return process.argv[idx + 1];
30
32
  return undefined;
31
33
  }
34
+ function parseAgent(value) {
35
+ const agent = (value || 'pi').toLowerCase();
36
+ if (agent === 'pi' || agent === 'omp')
37
+ return agent;
38
+ console.error(`invalid --agent value "${value}". expected "pi" or "omp"`);
39
+ process.exit(1);
40
+ }
41
+ function getAgentCommand(agent) {
42
+ return agent === 'omp'
43
+ ? 'npx -y @oh-my-pi/pi-coding-agent@latest'
44
+ : 'npx -y @mariozechner/pi-coding-agent@latest';
45
+ }
46
+ const AGENT = parseAgent(getArg('agent'));
32
47
  const PORT = parseInt(getArg('port') || process.env.PORT || '8192', 10);
33
48
  const HOST = getArg('host') || process.env.HOST || '127.0.0.1';
34
- const PI_CMD = process.env.PI_CMD || 'npx -y @mariozechner/pi-coding-agent@latest';
35
- const isDev = process.argv.includes('--watch') || process.env.NODE_ENV === 'development';
49
+ const AGENT_CMD = getAgentCommand(AGENT);
50
+ const DEFAULT_IDLE_SESSION_TTL_MS = 60_000;
51
+ const idleSessionTtlMsEnv = parseInt(process.env.PI_WEB_IDLE_SESSION_TTL_MS || '', 10);
52
+ const IDLE_SESSION_TTL_MS = Number.isFinite(idleSessionTtlMsEnv) && idleSessionTtlMsEnv >= 0
53
+ ? idleSessionTtlMsEnv
54
+ : DEFAULT_IDLE_SESSION_TTL_MS;
55
+ const isWatchMode = process.argv.includes('--watch') ||
56
+ process.execArgv.some((arg) => arg === '--watch' || arg.startsWith('--watch-')) ||
57
+ process.env.WATCH_REPORT_DEPENDENCIES != null;
58
+ const isDev = process.env.NODE_ENV === 'development' || isWatchMode;
36
59
  const distDirCandidates = [join(__dirname, 'dist'), join(__dirname, '..', '..', 'dist')];
37
60
  const distDir = distDirCandidates.find((candidate) => existsSync(join(candidate, 'index.html'))) ??
38
61
  distDirCandidates[0];
39
62
  const htmlPath = join(distDir, 'index.html');
40
63
  const htmlCache = isDev || !existsSync(htmlPath) ? null : readFileSync(htmlPath, 'utf-8');
64
+ const HOME_DIR = resolve(process.env.HOME || '/');
65
+ function isWithinRoot(path, root) {
66
+ const rel = relative(root, path);
67
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
68
+ }
69
+ async function listFolders(cwdQuery) {
70
+ const requested = cwdQuery ? resolve(cwdQuery) : HOME_DIR;
71
+ const cwd = isWithinRoot(requested, HOME_DIR) ? requested : HOME_DIR;
72
+ try {
73
+ const entries = await readdir(cwd, { withFileTypes: true });
74
+ const folders = entries
75
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
76
+ .map((entry) => ({
77
+ name: entry.name,
78
+ path: join(cwd, entry.name),
79
+ }))
80
+ .sort((a, b) => a.name.localeCompare(b.name));
81
+ return { cwd, root: HOME_DIR, folders };
82
+ }
83
+ catch (err) {
84
+ return {
85
+ cwd,
86
+ root: HOME_DIR,
87
+ folders: [],
88
+ error: `unable to read ${cwd}: ${String(err)}`,
89
+ };
90
+ }
91
+ }
41
92
  const contentTypes = {
42
93
  '.html': 'text/html',
43
94
  '.js': 'text/javascript',
@@ -66,10 +117,24 @@ const server = createServer((req, res) => {
66
117
  res.end(htmlCache ?? readFileSync(htmlPath, 'utf-8'));
67
118
  return;
68
119
  }
69
- if (req.url === '/api/sessions') {
120
+ if (req.url?.startsWith('/api/sessions')) {
70
121
  const url = new URL(req.url, `http://${req.headers.host}`);
71
122
  const cwd = url.searchParams.get('cwd') || undefined;
72
- listSessions({ cwd, limit: 50 })
123
+ listSessions({ cwd, limit: 50, agent: AGENT })
124
+ .then((data) => {
125
+ res.writeHead(200, { 'Content-Type': 'application/json' });
126
+ res.end(JSON.stringify(data));
127
+ })
128
+ .catch((err) => {
129
+ res.writeHead(500, { 'Content-Type': 'application/json' });
130
+ res.end(JSON.stringify({ error: String(err) }));
131
+ });
132
+ return;
133
+ }
134
+ if (req.url?.startsWith('/api/folders')) {
135
+ const url = new URL(req.url, `http://${req.headers.host}`);
136
+ const cwd = url.searchParams.get('cwd');
137
+ listFolders(cwd)
73
138
  .then((data) => {
74
139
  res.writeHead(200, { 'Content-Type': 'application/json' });
75
140
  res.end(JSON.stringify(data));
@@ -89,7 +154,7 @@ const server = createServer((req, res) => {
89
154
  res.end(JSON.stringify({ error: 'cwd and filename parameters required' }));
90
155
  return;
91
156
  }
92
- const file = getSessionFilePath(cwd, filename);
157
+ const file = getSessionFilePath(cwd, filename, AGENT);
93
158
  if (req.method === 'DELETE') {
94
159
  try {
95
160
  unlinkSync(file);
@@ -140,6 +205,140 @@ const server = createServer((req, res) => {
140
205
  });
141
206
  const wss = new WebSocketServer({ server });
142
207
  const rpcSessions = new Map();
208
+ const socketBindings = new Map();
209
+ function buildSessionKey(cwd, sessionFile) {
210
+ return `${cwd}::${sessionFile ? basename(sessionFile) : '__new__'}`;
211
+ }
212
+ function sendToSocket(ws, payload) {
213
+ if (ws.readyState !== WebSocket.OPEN)
214
+ return;
215
+ ws.send(JSON.stringify(payload));
216
+ }
217
+ function broadcast(managed, payload) {
218
+ for (const client of managed.clients) {
219
+ sendToSocket(client, payload);
220
+ }
221
+ }
222
+ function clearIdleCleanupTimer(managed) {
223
+ if (!managed.idleCleanupTimer)
224
+ return;
225
+ clearTimeout(managed.idleCleanupTimer);
226
+ managed.idleCleanupTimer = null;
227
+ }
228
+ function unregisterManagedSession(managed) {
229
+ for (const key of managed.keys) {
230
+ if (rpcSessions.get(key) === managed)
231
+ rpcSessions.delete(key);
232
+ }
233
+ managed.keys.clear();
234
+ }
235
+ function registerManagedSessionKey(managed, key) {
236
+ managed.keys.add(key);
237
+ rpcSessions.set(key, managed);
238
+ }
239
+ function closeManagedSession(managed) {
240
+ if (managed.isClosing)
241
+ return;
242
+ managed.isClosing = true;
243
+ clearIdleCleanupTimer(managed);
244
+ unregisterManagedSession(managed);
245
+ managed.rpc.kill();
246
+ }
247
+ function cleanupIfIdle(managed) {
248
+ if (managed.isClosing)
249
+ return;
250
+ if (managed.clients.size > 0) {
251
+ clearIdleCleanupTimer(managed);
252
+ return;
253
+ }
254
+ if (managed.isAgentRunning) {
255
+ clearIdleCleanupTimer(managed);
256
+ return;
257
+ }
258
+ if (managed.idleCleanupTimer)
259
+ return;
260
+ managed.idleCleanupTimer = setTimeout(() => {
261
+ managed.idleCleanupTimer = null;
262
+ if (managed.isClosing)
263
+ return;
264
+ if (managed.clients.size > 0)
265
+ return;
266
+ if (managed.isAgentRunning)
267
+ return;
268
+ closeManagedSession(managed);
269
+ }, IDLE_SESSION_TTL_MS);
270
+ }
271
+ function detachSocket(ws) {
272
+ const current = socketBindings.get(ws);
273
+ if (!current)
274
+ return;
275
+ current.clients.delete(ws);
276
+ socketBindings.delete(ws);
277
+ cleanupIfIdle(current);
278
+ }
279
+ function registerDiscoveredSessionKey(managed, event) {
280
+ if (event?.type !== 'response' || event?.command !== 'get_state')
281
+ return;
282
+ const sessionPath = event?.data?.sessionFile;
283
+ if (typeof sessionPath !== 'string' || sessionPath.length === 0)
284
+ return;
285
+ const key = buildSessionKey(managed.cwd, basename(sessionPath));
286
+ registerManagedSessionKey(managed, key);
287
+ }
288
+ function createManagedSession(cwd, sessionFile) {
289
+ const sessionPath = sessionFile ? getSessionFilePath(cwd, sessionFile, AGENT) : undefined;
290
+ let managed = null;
291
+ const rpc = new RpcSession({
292
+ piCmd: AGENT_CMD,
293
+ cwd,
294
+ sessionFile: sessionPath,
295
+ onEvent: (event) => {
296
+ if (!managed)
297
+ return;
298
+ if (event?.type === 'agent_start') {
299
+ managed.isAgentRunning = true;
300
+ clearIdleCleanupTimer(managed);
301
+ }
302
+ if (event?.type === 'agent_end') {
303
+ managed.isAgentRunning = false;
304
+ cleanupIfIdle(managed);
305
+ }
306
+ registerDiscoveredSessionKey(managed, event);
307
+ broadcast(managed, { type: 'rpc_event', event });
308
+ },
309
+ onError: (error) => {
310
+ if (!managed)
311
+ return;
312
+ broadcast(managed, { type: 'error', message: error });
313
+ },
314
+ onExit: (code) => {
315
+ if (!managed)
316
+ return;
317
+ managed.isAgentRunning = false;
318
+ managed.isClosing = true;
319
+ clearIdleCleanupTimer(managed);
320
+ unregisterManagedSession(managed);
321
+ broadcast(managed, { type: 'session_ended', code });
322
+ for (const client of managed.clients) {
323
+ if (socketBindings.get(client) === managed)
324
+ socketBindings.delete(client);
325
+ }
326
+ managed.clients.clear();
327
+ },
328
+ });
329
+ managed = {
330
+ cwd,
331
+ sessionFile,
332
+ rpc,
333
+ clients: new Set(),
334
+ isAgentRunning: false,
335
+ keys: new Set(),
336
+ idleCleanupTimer: null,
337
+ isClosing: false,
338
+ };
339
+ registerManagedSessionKey(managed, buildSessionKey(cwd, sessionFile));
340
+ return managed;
341
+ }
143
342
  wss.on('connection', (ws) => {
144
343
  ws.on('message', (raw) => {
145
344
  let msg;
@@ -150,59 +349,42 @@ wss.on('connection', (ws) => {
150
349
  return;
151
350
  }
152
351
  if (msg.type === 'start_session') {
153
- const previousRpc = rpcSessions.get(ws);
154
- if (previousRpc) {
155
- rpcSessions.delete(ws);
156
- previousRpc.kill();
352
+ const cwd = typeof msg.cwd === 'string' && msg.cwd.trim().length > 0
353
+ ? resolve(msg.cwd)
354
+ : resolve(process.env.HOME || '/');
355
+ const sessionFile = typeof msg.sessionFile === 'string' && msg.sessionFile.length > 0
356
+ ? basename(msg.sessionFile)
357
+ : null;
358
+ const key = buildSessionKey(cwd, sessionFile);
359
+ const currentlyBound = socketBindings.get(ws);
360
+ if (currentlyBound?.keys.has(key)) {
361
+ clearIdleCleanupTimer(currentlyBound);
362
+ return;
157
363
  }
158
- const rpcRef = { current: null };
159
- const isCurrentRpc = () => rpcRef.current != null && rpcSessions.get(ws) === rpcRef.current;
160
- const rpc = new RpcSession({
161
- piCmd: PI_CMD,
162
- cwd: msg.cwd || process.env.HOME || '/',
163
- sessionFile: msg.sessionFile
164
- ? getSessionFilePath(msg.cwd || process.env.HOME || '/', msg.sessionFile)
165
- : undefined,
166
- onEvent: (event) => {
167
- if (!isCurrentRpc())
168
- return;
169
- if (ws.readyState === WebSocket.OPEN)
170
- ws.send(JSON.stringify({ type: 'rpc_event', event }));
171
- },
172
- onError: (error) => {
173
- if (!isCurrentRpc())
174
- return;
175
- if (ws.readyState === WebSocket.OPEN)
176
- ws.send(JSON.stringify({ type: 'error', message: error }));
177
- },
178
- onExit: (code) => {
179
- if (!isCurrentRpc())
180
- return;
181
- rpcSessions.delete(ws);
182
- if (ws.readyState === WebSocket.OPEN)
183
- ws.send(JSON.stringify({ type: 'session_ended', code }));
184
- },
185
- });
186
- rpcRef.current = rpc;
187
- rpcSessions.set(ws, rpc);
364
+ detachSocket(ws);
365
+ let managed = rpcSessions.get(key);
366
+ if (!managed || managed.isClosing)
367
+ managed = createManagedSession(cwd, sessionFile);
368
+ managed.clients.add(ws);
369
+ clearIdleCleanupTimer(managed);
370
+ socketBindings.set(ws, managed);
188
371
  return;
189
372
  }
190
373
  if (msg.type === 'rpc_command') {
191
- const rpc = rpcSessions.get(ws);
192
- if (!rpc) {
193
- ws.send(JSON.stringify({ type: 'error', message: 'no active session' }));
374
+ const managed = socketBindings.get(ws);
375
+ if (!managed) {
376
+ sendToSocket(ws, { type: 'error', message: 'no active session' });
194
377
  return;
195
378
  }
196
- rpc.send(msg.command);
379
+ managed.rpc.send(msg.command);
197
380
  return;
198
381
  }
199
382
  if (msg.type === 'ping') {
200
- ws.send(JSON.stringify({ type: 'pong' }));
383
+ sendToSocket(ws, { type: 'pong' });
201
384
  }
202
385
  });
203
386
  ws.on('close', () => {
204
- rpcSessions.get(ws)?.kill();
205
- rpcSessions.delete(ws);
387
+ detachSocket(ws);
206
388
  });
207
389
  });
208
390
  server.listen(PORT, HOST, () => {
@@ -1,25 +1,40 @@
1
1
  import { readdir } from 'node:fs/promises';
2
- import { basename, join } from 'node:path';
2
+ import { basename, join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createReadStream } from 'node:fs';
5
5
  import { createInterface } from 'node:readline';
6
- const SESSION_DIR = join(homedir(), '.pi', 'agent', 'sessions');
7
- function cwdToSessionDir(cwd) {
8
- return '-' + cwd.replace(/\//g, '-') + '--';
6
+ const HOME_DIR = resolve(homedir());
7
+ function getSessionDir(agent) {
8
+ const configDir = agent === 'omp' ? '.omp' : '.pi';
9
+ return join(HOME_DIR, configDir, 'agent', 'sessions');
9
10
  }
10
- export function getSessionFilePath(cwd, filename) {
11
- return join(SESSION_DIR, cwdToSessionDir(cwd), filename);
11
+ function cwdToSessionDir(cwd, agent) {
12
+ const normalisedCwd = resolve(cwd);
13
+ if (agent === 'omp') {
14
+ if (normalisedCwd === HOME_DIR ||
15
+ normalisedCwd.startsWith(`${HOME_DIR}/`) ||
16
+ normalisedCwd.startsWith(`${HOME_DIR}\\`)) {
17
+ const relative = normalisedCwd.slice(HOME_DIR.length).replace(/^[/\\]/, '');
18
+ return `-${relative.replace(/[/\\:]/g, '-')}`;
19
+ }
20
+ }
21
+ const encoded = normalisedCwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-');
22
+ return `--${encoded}--`;
23
+ }
24
+ export function getSessionFilePath(cwd, filename, agent = 'pi') {
25
+ return join(getSessionDir(agent), cwdToSessionDir(cwd, agent), filename);
12
26
  }
13
27
  export async function listSessions(opts) {
14
- const { cwd, limit = 30 } = opts;
28
+ const { cwd, limit = 30, agent = 'pi' } = opts;
15
29
  const results = [];
30
+ const sessionDir = getSessionDir(agent);
16
31
  try {
17
- const cwdDirs = await readdir(SESSION_DIR, { withFileTypes: true });
32
+ const cwdDirs = await readdir(sessionDir, { withFileTypes: true });
18
33
  const targetDirs = cwd
19
- ? cwdDirs.filter((d) => d.isDirectory() && d.name === cwdToSessionDir(cwd))
34
+ ? cwdDirs.filter((d) => d.isDirectory() && d.name === cwdToSessionDir(cwd, agent))
20
35
  : cwdDirs.filter((d) => d.isDirectory());
21
36
  for (const dir of targetDirs) {
22
- const dirPath = join(SESSION_DIR, dir.name);
37
+ const dirPath = join(sessionDir, dir.name);
23
38
  let files;
24
39
  try {
25
40
  files = (await readdir(dirPath)).filter((f) => f.endsWith('.jsonl'));
@@ -0,0 +1 @@
1
+ @import"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap";@layer components;@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-duration:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-semibold:600;--tracking-wide:.025em;--leading-relaxed:1.625;--radius-lg:.5rem;--radius-xl:.75rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-pi-accent:#5a8080;--color-pi-border-muted:#b0b0b0;--color-pi-success:#588458;--color-pi-error:#a55;--color-pi-warning:#9a7326;--color-pi-muted:#6c6c6c;--color-pi-dim:#767676;--color-pi-user-bg:#e8e8e8;--color-pi-tool-success:#e8f0e8;--color-pi-tool-error:#f0e8e8;--color-pi-md-heading:#9a7326;--color-pi-md-link:#547da7;--color-pi-md-code:#5a8080;--color-pi-md-code-block:#588458;--color-pi-tool-output:#6c6c6c;--color-pi-page-bg:#f8f8f8;--color-pi-card-bg:#fff}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.start{inset-inline-start:var(--spacing)}.-top-1\.5{top:calc(var(--spacing) * -1.5)}.top-3{top:calc(var(--spacing) * 3)}.-right-1\.5{right:calc(var(--spacing) * -1.5)}.right-3{right:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.my-1\.5{margin-block:calc(var(--spacing) * 1.5)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.hidden{display:none}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-10{height:calc(var(--spacing) * 10)}.h-\[42px\]{height:42px}.h-full{height:100%}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-\[200px\]{max-height:200px}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-\[42px\]{min-height:42px}.w-4{width:calc(var(--spacing) * 4)}.w-6{width:calc(var(--spacing) * 6)}.w-10{width:calc(var(--spacing) * 10)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.rotate-180{rotate:180deg}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pi-border-muted>:not(:last-child)){border-color:var(--color-pi-border-muted)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-pi-border-muted{border-color:var(--color-pi-border-muted)}.border-pi-border-muted\/40{border-color:#b0b0b066}@supports (color:color-mix(in lab,red,red)){.border-pi-border-muted\/40{border-color:color-mix(in oklab,var(--color-pi-border-muted) 40%,transparent)}}.bg-pi-accent{background-color:var(--color-pi-accent)}.bg-pi-card-bg{background-color:var(--color-pi-card-bg)}.bg-pi-error{background-color:var(--color-pi-error)}.bg-pi-page-bg{background-color:var(--color-pi-page-bg)}.bg-pi-tool-success{background-color:var(--color-pi-tool-success)}.bg-pi-user-bg{background-color:var(--color-pi-user-bg)}.bg-pi-warning{background-color:var(--color-pi-warning)}.bg-white{background-color:var(--color-white)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-pi-accent{color:var(--color-pi-accent)}.text-pi-dim{color:var(--color-pi-dim)}.text-pi-error{color:var(--color-pi-error)}.text-pi-md-code{color:var(--color-pi-md-code)}.text-pi-md-code-block{color:var(--color-pi-md-code-block)}.text-pi-md-heading{color:var(--color-pi-md-heading)}.text-pi-md-link{color:var(--color-pi-md-link)}.text-pi-muted{color:var(--color-pi-muted)}.text-pi-success{color:var(--color-pi-success)}.text-pi-tool-output{color:var(--color-pi-tool-output)}.text-pi-warning{color:var(--color-pi-warning)}.text-white{color:var(--color-white)}.italic{font-style:italic}.underline{text-decoration-line:underline}.opacity-60{opacity:.6}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.outline-none{--tw-outline-style:none;outline-style:none}@media(hover:hover){.group-hover\:inline-flex:is(:where(.group):hover *){display:inline-flex}.hover\:bg-pi-tool-error:hover{background-color:var(--color-pi-tool-error)}.hover\:bg-pi-user-bg:hover{background-color:var(--color-pi-user-bg)}.hover\:text-pi-accent:hover{color:var(--color-pi-accent)}.hover\:text-pi-error:hover{color:var(--color-pi-error)}.hover\:opacity-85:hover{opacity:.85}.hover\:opacity-90:hover{opacity:.9}.hover\:brightness-95:hover{--tw-brightness:brightness(95%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-pi-accent:focus{border-color:var(--color-pi-accent)}.disabled\:cursor-default:disabled{cursor:default}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:48rem){.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}}html,body,#root{height:100dvh;overflow:hidden}*,:before,:after{font-family:JetBrains Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace!important;font-size:14px!important}@supports (-webkit-touch-callout:none){.prompt-input{font-size:16px!important}}@keyframes pulse-dot{0%,to{opacity:1}50%{opacity:.4}}.animate-pulse-dot{animation:1s infinite pulse-dot}.select-fit-content{field-sizing:content}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}