pi-web-ui 0.27.0 → 0.28.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.
@@ -456,20 +456,26 @@ wss.on("connection", (ws) => {
456
456
  case "list_providers":
457
457
  void cs.listProviders();
458
458
  break;
459
- case "terminal_create":
460
- cs.terminals.create(msg.terminalId, msg.cwd, msg.cols, msg.rows, cs.cwd);
459
+ case "fetch_models":
460
+ void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api);
461
461
  break;
462
+ case "terminal_create": {
463
+ const tm = cs.getTerminalManager(msg.conversationId);
464
+ if (tm)
465
+ tm.create(msg.terminalId, msg.cwd, msg.cols, msg.rows, cs.getTerminalCwd(msg.conversationId));
466
+ break;
467
+ }
462
468
  case "terminal_input":
463
- cs.terminals.input(msg.terminalId, msg.data);
469
+ cs.getTerminalManager(msg.conversationId)?.input(msg.terminalId, msg.data);
464
470
  break;
465
471
  case "terminal_resize":
466
- cs.terminals.resize(msg.terminalId, msg.cols, msg.rows);
472
+ cs.getTerminalManager(msg.conversationId)?.resize(msg.terminalId, msg.cols, msg.rows);
467
473
  break;
468
474
  case "terminal_kill":
469
- cs.terminals.kill(msg.terminalId);
475
+ cs.getTerminalManager(msg.conversationId)?.kill(msg.terminalId);
470
476
  break;
471
477
  case "run_command":
472
- cs.terminals.runCommand(msg.terminalId, msg.command, msg.cols, msg.rows, cs.cwd);
478
+ cs.getTerminalManager(msg.conversationId)?.runCommand(msg.terminalId, msg.command, msg.cols, msg.rows, cs.getTerminalCwd(msg.conversationId));
473
479
  break;
474
480
  case "list_commands":
475
481
  void cs.listCommands();
@@ -514,6 +520,8 @@ wss.on("connection", (ws) => {
514
520
  visionBridgeModel: msg.visionBridgeModel,
515
521
  visionBridgePromptMode: msg.visionBridgePromptMode,
516
522
  visionBridgePrompt: msg.visionBridgePrompt,
523
+ reviewPrompt: msg.reviewPrompt,
524
+ reviewDisabledSkills: msg.reviewDisabledSkills,
517
525
  });
518
526
  break;
519
527
  case "save_preset":
@@ -1,23 +1,22 @@
1
1
  /**
2
- * TerminalManager — per-client PTY sessions (node-pty) bridged over the
3
- * WebSocket protocol, plus the user command list persisted in
2
+ * TerminalManager — conversation-owned PTY sessions (node-pty) bridged over
3
+ * the WebSocket protocol, plus the user command list persisted in
4
4
  * `<workspaceRoot>/.pi/commands.json`.
5
5
  *
6
- * Each browser client gets its own manager; terminals are shared across that
7
- * client's tabs (they broadcast through the session's emit). When the last
8
- * socket for a client detaches, all its PTYs are killed so no orphaned
9
- * processes survive a closed tab / dropped connection.
6
+ * Each conversation gets its own manager; terminals are shared across browser
7
+ * tabs through the session emit. A socket drop does not kill them: the
8
+ * conversation owns their lifecycle and releases them on disposal.
10
9
  *
11
10
  * Commands file format:
12
11
  * { "commands": [ { "name": "dev", "command": "npm run dev", "cwd": "${pwd}" } ] }
13
12
  * `${pwd}` inside cwd/command resolves to the agent session's current working
14
13
  * directory (the same directory the agent operates in — see set_cwd).
15
14
  */
16
- import { chmodSync, existsSync, readdirSync, statSync } from "node:fs";
15
+ import { chmodSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
17
16
  import { mkdir, readFile, writeFile } from "node:fs/promises";
18
17
  import { createRequire } from "node:module";
19
18
  import { homedir } from "node:os";
20
- import { dirname, isAbsolute, join, resolve } from "node:path";
19
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
21
20
  // MUST run before node-pty is required: rewrites the installed node-pty copies
22
21
  // so their worker/agent handlers tolerate Node `--watch`'s IPC traffic (see the
23
22
  // module itself for details).
@@ -107,6 +106,11 @@ export async function saveCommandsFile(workspaceRoot, commands) {
107
106
  }
108
107
  }
109
108
  const isWindows = process.platform === "win32";
109
+ const MAX_TERMINALS = 16;
110
+ const MAX_TERMINAL_HISTORY = 32;
111
+ const MAX_OUTPUT = 200_000;
112
+ const MAX_INPUT = 64 * 1024;
113
+ const MAX_ID = 80;
110
114
  /** `-i` makes bash interactive; cmd.exe / powershell.exe are interactive on their own. */
111
115
  function bashArgs(shell) {
112
116
  return /[\\/]bash(\.exe)?$/i.test(shell) ? ["-i"] : [];
@@ -275,26 +279,112 @@ function launchdSpawnedOnMac() {
275
279
  return process.platform === "darwin" && process.ppid === 1;
276
280
  }
277
281
  /**
278
- * Owns one or more PTYs for a client. All output is forwarded as
282
+ * Translate a logical key (named key or single character) plus modifiers into
283
+ * the exact byte sequence a PTY expects. Named keys are routed by NAME, so a
284
+ * Ctrl/Alt combo is NEVER derived from the key's first letter: Ctrl+ArrowUp
285
+ * must produce `ESC[1;5A`, not Ctrl+A, and Ctrl+Enter `ESC[13;5u`, not Ctrl+E.
286
+ * - arrows / F1–F4 / Home / End keep their plain form when unmodified and
287
+ * gain the xterm modifier parameter (`ESC[1;<m>X`) under Shift/Alt/Ctrl;
288
+ * - other named keys (Enter/Tab/Backspace/Escape/Insert/Delete/PageUp/PageDown)
289
+ * fall back to the CSI-u form (`ESC[<code>;<m>u`) once modified;
290
+ * - plain characters: Ctrl maps A–Z to 0x01–0x1A (error for non-letters),
291
+ * Shift uppercases, Alt prefixes with ESC.
292
+ */
293
+ export function encodeTerminalKey(key, modifiers = {}) {
294
+ const named = {
295
+ Enter: "\r", Return: "\r", Tab: "\t", Backspace: "\x7f", Escape: "\x1b",
296
+ Up: "\x1b[A", ArrowUp: "\x1b[A", Down: "\x1b[B", ArrowDown: "\x1b[B",
297
+ Left: "\x1b[D", ArrowLeft: "\x1b[D", Right: "\x1b[C", ArrowRight: "\x1b[C",
298
+ Home: "\x1b[H", End: "\x1b[F", Delete: "\x1b[3~", Insert: "\x1b[2~",
299
+ PageUp: "\x1b[5~", PageDown: "\x1b[6~", F1: "\x1bOP", F2: "\x1bOQ", F3: "\x1bOR", F4: "\x1bOS",
300
+ };
301
+ let data = named[key] ?? (key.length === 1 ? key : "");
302
+ if (!data)
303
+ return { error: `不支持的终端按键:${key}` };
304
+ // xterm modifier encoding: 1=plain, 2=Shift, 3=Alt, 5=Ctrl,
305
+ // 6=Ctrl+Shift, 7=Ctrl+Alt, 8=Ctrl+Alt+Shift.
306
+ const modifier = 1 + (modifiers.shift ? 1 : 0) + (modifiers.alt ? 2 : 0) + (modifiers.ctrl ? 4 : 0);
307
+ const arrow = /^\x1b\[([A-DHF])$/.exec(data);
308
+ const functionKey = /^\x1bO([P-S])$/.exec(data);
309
+ const namedCode = {
310
+ Enter: 13, Return: 13, Tab: 9, Backspace: 127, Escape: 27,
311
+ Insert: 2, Delete: 3, Home: 1, End: 4, PageUp: 5, PageDown: 6,
312
+ };
313
+ if (arrow && modifier !== 1) {
314
+ data = `\x1b[1;${modifier}${arrow[1]}`;
315
+ }
316
+ else if (functionKey && modifier !== 1) {
317
+ data = `\x1b[1;${modifier}${functionKey[1]}`;
318
+ }
319
+ else if (namedCode[key] !== undefined && modifier !== 1) {
320
+ // CSI-u keeps named keys identifiable. In particular, Ctrl+Enter and
321
+ // Ctrl+Tab must not be derived from the first letter of "Enter"/"Tab".
322
+ data = `\x1b[${namedCode[key]};${modifier}u`;
323
+ }
324
+ else {
325
+ if (modifiers.ctrl) {
326
+ if (key.length !== 1)
327
+ return { error: `Ctrl 组合键无效:${key}` };
328
+ const code = key.toUpperCase().charCodeAt(0);
329
+ if (code >= 64 && code <= 95)
330
+ data = String.fromCharCode(code - 64);
331
+ else
332
+ return { error: `Ctrl 组合键无效:${key}` };
333
+ }
334
+ else if (modifiers.shift && key.length === 1) {
335
+ data = key.toUpperCase();
336
+ }
337
+ if (modifiers.alt)
338
+ data = "\x1b" + data;
339
+ }
340
+ return { data };
341
+ }
342
+ /**
343
+ * Owns one or more PTYs for a conversation. All output is forwarded as
279
344
  * `terminal_output` messages through the provided emit (broadcast to every
280
- * socket of the client). Returns false from create/runCommand when the spawn
281
- * failed (an error notice + terminal_exit are emitted instead).
345
+ * socket attached to the client session). Failed spawns emit an error notice and
346
+ * terminal_exit instead of throwing into the WebSocket dispatcher.
282
347
  */
283
348
  export class TerminalManager {
284
349
  emit;
350
+ workspaceRoot;
351
+ /** Live PTYs only. Exited entries move to history so they no longer consume
352
+ * the live-terminal limit while their output remains readable/replayable. */
285
353
  terms = new Map();
354
+ history = new Map();
286
355
  seq = 0;
287
356
  tccHintShown = false;
288
- constructor(emit) {
357
+ constructor(emit, workspaceRoot) {
289
358
  this.emit = emit;
359
+ this.workspaceRoot = workspaceRoot;
290
360
  }
291
361
  /** Start a plain interactive shell in the given directory. */
292
- create(id, cwd, cols, rows, fallbackCwd) {
362
+ create(id, cwd, cols, rows, fallbackCwd, title) {
363
+ const valid = this.validateId(id);
364
+ if (valid) {
365
+ this.fail(id, valid);
366
+ return null;
367
+ }
293
368
  if (this.terms.has(id))
294
- return;
295
- if (this.spawnShell(id, cwd || fallbackCwd, cols, rows, `终端 ${++this.seq}`)) {
369
+ return this.info(this.terms.get(id));
370
+ // Every spawn path shares the same admission rule (ensureSpawnAllowed):
371
+ // a NEW live PTY needs a free slot under the cap. Reusing an exited name
372
+ // starts a fresh PTY and discards its old history — but only after the
373
+ // slot check, so a rejected request keeps its retained output.
374
+ if (!this.ensureSpawnAllowed(id))
375
+ return null;
376
+ this.history.delete(id);
377
+ const safeCwd = this.safeCwd(cwd || fallbackCwd);
378
+ if (!safeCwd) {
379
+ this.fail(id, "终端工作目录必须位于当前工作区内");
380
+ return null;
381
+ }
382
+ if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`)) {
296
383
  this.maybeEmitTccHint(id);
384
+ this.emitList();
385
+ return this.info(this.terms.get(id));
297
386
  }
387
+ return null;
298
388
  }
299
389
  /** Warn about unavailable camera/mic TCC grants in a fresh terminal, once per client. */
300
390
  maybeEmitTccHint(id) {
@@ -311,10 +401,27 @@ export class TerminalManager {
311
401
  * same terminal (used when re-running a command by clicking its entry).
312
402
  */
313
403
  runCommand(id, def, cols, rows, pwd) {
314
- const dir = resolveCommandCwd(def.cwd, pwd);
404
+ const invalidId = this.validateId(id);
405
+ if (invalidId) {
406
+ this.fail(id, invalidId);
407
+ return;
408
+ }
409
+ const existing = this.terms.get(id);
410
+ // Same admission rule as create(): a live terminal may be restarted in
411
+ // place, but a NEW live PTY needs a free slot — an id sitting in history
412
+ // (exited) does NOT grant one, or re-running exited terminals while at
413
+ // the cap could push the live count past MAX_TERMINALS.
414
+ if (!existing && !this.ensureSpawnAllowed(id))
415
+ return;
416
+ const hasHistory = this.history.has(id);
417
+ const rawDir = resolveCommandCwd(def.cwd, pwd);
418
+ const dir = this.safeCwd(rawDir);
315
419
  const command = expandPwd(def.command.trim(), pwd);
316
420
  const title = def.name || command || `终端 ${++this.seq}`;
317
- const existing = this.terms.get(id);
421
+ if (!dir) {
422
+ this.fail(id, "终端工作目录必须位于当前工作区内");
423
+ return;
424
+ }
318
425
  if (existing) {
319
426
  // Re-run in place: interrupt the current process (kill the PTY's
320
427
  // process group) and start a fresh shell with the same id. Keep the
@@ -332,26 +439,38 @@ export class TerminalManager {
332
439
  rows = existing.rows || rows;
333
440
  this.terms.delete(id);
334
441
  }
335
- const ok = this.spawnShell(id, dir, cols, rows, title);
442
+ this.history.delete(id);
443
+ const ok = this.spawnShell(id, dir, cols, rows, title, def);
336
444
  if (!ok)
337
445
  return;
446
+ this.emitList();
338
447
  // Clear the previous run's output, then show a banner and run the command
339
448
  // (the PTY input buffer holds it until the shell is ready).
340
- this.writeOut(id, "\x1b[2J\x1b[3J\x1b[H");
341
- this.writeOut(id, `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`);
449
+ const banner = "\x1b[2J\x1b[3J\x1b[H" +
450
+ `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`;
451
+ const fresh = this.terms.get(id);
452
+ if (fresh)
453
+ this.appendOutput(fresh, banner);
454
+ this.writeOut(id, banner);
342
455
  this.maybeEmitTccHint(id);
343
456
  if (command)
344
457
  this.input(id, command + "\r");
345
458
  }
346
459
  /** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
347
- spawnShell(id, cwd, cols, rows, title) {
460
+ spawnShell(id, cwd, cols, rows, title, command) {
348
461
  let abs = cwd;
349
462
  if (!abs)
350
463
  abs = homedir();
351
464
  else if (!isAbsolute(abs))
352
465
  abs = resolve(abs);
353
- if (!existsSync(abs)) {
354
- this.fail(id, `目录不存在:${abs}`);
466
+ try {
467
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
468
+ this.fail(id, `目录不存在或不是目录:${abs}`);
469
+ return false;
470
+ }
471
+ }
472
+ catch {
473
+ this.fail(id, `无法访问终端目录:${abs}`);
355
474
  return false;
356
475
  }
357
476
  // node-pty's spawn-helper may have lost its +x bit since the last repair
@@ -383,6 +502,11 @@ export class TerminalManager {
383
502
  cols: Math.max(2, Math.floor(cols) || 80),
384
503
  rows: Math.max(2, Math.floor(rows) || 24),
385
504
  exited: false,
505
+ exitCode: null,
506
+ command,
507
+ output: "",
508
+ outputOffset: 0,
509
+ waiters: new Set(),
386
510
  };
387
511
  this.terms.set(id, entry);
388
512
  // The closures capture `entry`: after a restart the map points at the
@@ -390,6 +514,7 @@ export class TerminalManager {
390
514
  pty.onData((data) => {
391
515
  if (this.terms.get(id) !== entry)
392
516
  return;
517
+ this.appendOutput(entry, data);
393
518
  this.writeOut(id, data);
394
519
  });
395
520
  pty.onExit(({ exitCode }) => {
@@ -401,10 +526,132 @@ export class TerminalManager {
401
526
  }
402
527
  writeOut(id, data) {
403
528
  const entry = this.terms.get(id);
404
- if (!entry || entry.exited)
529
+ if (!entry)
405
530
  return;
406
531
  this.emit({ type: "terminal_output", terminalId: id, data });
407
532
  }
533
+ appendOutput(entry, data) {
534
+ entry.output += data;
535
+ if (entry.output.length > MAX_OUTPUT) {
536
+ const drop = entry.output.length - MAX_OUTPUT;
537
+ entry.output = entry.output.slice(drop);
538
+ entry.outputOffset += drop;
539
+ }
540
+ for (const wake of entry.waiters)
541
+ wake();
542
+ entry.waiters.clear();
543
+ }
544
+ validateId(id) {
545
+ if (!id || id.length > MAX_ID || !/^[A-Za-z0-9._:-]+$/.test(id)) {
546
+ return "终端名称无效:只能使用字母、数字、.-、_ 或 :(最长 80 字符)";
547
+ }
548
+ return null;
549
+ }
550
+ /**
551
+ * Admission control for EVERY spawn path (create / runCommand): spawning a
552
+ * NEW live PTY is only allowed while the live count is below MAX_TERMINALS.
553
+ * Restarting an id that is ALREADY live is always allowed (no extra slot).
554
+ * History entries (exited terminals) do not reserve a slot — re-spawning
555
+ * one while at the cap is rejected with the standard error feedback.
556
+ */
557
+ ensureSpawnAllowed(id) {
558
+ if (this.terms.has(id))
559
+ return true;
560
+ if (this.terms.size >= MAX_TERMINALS) {
561
+ this.fail(id, `终端数量已达上限(${MAX_TERMINALS})`);
562
+ return false;
563
+ }
564
+ return true;
565
+ }
566
+ safeCwd(raw) {
567
+ try {
568
+ const root = realpathSync(resolve(this.workspaceRoot));
569
+ const candidate = realpathSync(isAbsolute(raw) ? resolve(raw) : resolve(root, raw));
570
+ const rel = relative(root, candidate);
571
+ if (rel === "" || (!rel.startsWith(".." + sep) && rel !== ".." && !isAbsolute(rel))) {
572
+ return candidate;
573
+ }
574
+ }
575
+ catch {
576
+ // Missing directories and broken symlinks are rejected by the boundary.
577
+ }
578
+ return null;
579
+ }
580
+ info(entry) {
581
+ return {
582
+ id: entry.id,
583
+ title: entry.title,
584
+ cwd: entry.cwd,
585
+ cols: entry.cols,
586
+ rows: entry.rows,
587
+ running: !entry.exited,
588
+ exitCode: entry.exitCode,
589
+ command: entry.command,
590
+ };
591
+ }
592
+ has(id) {
593
+ return this.terms.has(id) || this.history.has(id);
594
+ }
595
+ find(id) {
596
+ return this.terms.get(id) ?? this.history.get(id);
597
+ }
598
+ list() {
599
+ return [...this.terms.values(), ...this.history.values()].map((entry) => this.info(entry));
600
+ }
601
+ emitList() {
602
+ this.emit({ type: "terminal_list", terminals: this.list() });
603
+ }
604
+ /** Replay the retained output window after switching back to this conversation. */
605
+ replay() {
606
+ return [...this.terms.values(), ...this.history.values()]
607
+ .filter((entry) => entry.output.length > 0)
608
+ .map((entry) => ({ terminalId: entry.id, data: entry.output }));
609
+ }
610
+ /** Read output after an absolute cursor. */
611
+ read(id, cursor = 0, maxBytes = 20_000) {
612
+ const entry = this.find(id);
613
+ if (!entry)
614
+ return null;
615
+ const start = Math.max(entry.outputOffset, Math.min(cursor, entry.outputOffset + entry.output.length));
616
+ const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
617
+ return { data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset), cursor: end, running: !entry.exited, exitCode: entry.exitCode };
618
+ }
619
+ async waitForOutput(id, cursor, timeoutMs, signal) {
620
+ const current = this.read(id, cursor, 1);
621
+ if (!current || current.cursor > cursor || !current.running)
622
+ return;
623
+ await new Promise((resolvePromise) => {
624
+ const entry = this.find(id);
625
+ if (!entry)
626
+ return resolvePromise();
627
+ let timer;
628
+ const done = () => {
629
+ if (timer)
630
+ clearTimeout(timer);
631
+ entry.waiters.delete(done);
632
+ signal?.removeEventListener("abort", done);
633
+ resolvePromise();
634
+ };
635
+ entry.waiters.add(done);
636
+ timer = setTimeout(done, Math.max(0, Math.min(timeoutMs, 120_000)));
637
+ signal?.addEventListener("abort", done, { once: true });
638
+ });
639
+ }
640
+ inputChecked(id, data) {
641
+ if (data.length > MAX_INPUT)
642
+ return `输入过长(上限 ${MAX_INPUT} 字符)`;
643
+ const entry = this.terms.get(id);
644
+ if (!entry || entry.exited)
645
+ return "终端不存在或进程已退出";
646
+ entry.pty.write(data);
647
+ return null;
648
+ }
649
+ key(id, key, modifiers = {}) {
650
+ const encoded = encodeTerminalKey(key, modifiers);
651
+ if ("error" in encoded)
652
+ return encoded.error;
653
+ return this.inputChecked(id, encoded.data);
654
+ }
408
655
  /** Emit a terminal failure (bad cwd, spawn error) and mark the terminal dead. */
409
656
  fail(id, text) {
410
657
  this.emit({ type: "notice", level: "error", text });
@@ -419,14 +666,24 @@ export class TerminalManager {
419
666
  const entry = this.terms.get(id);
420
667
  if (!entry || entry.exited)
421
668
  return;
669
+ const banner = `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`;
670
+ this.appendOutput(entry, banner);
671
+ this.writeOut(id, banner);
422
672
  entry.exited = true;
423
- this.writeOut(id, `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`);
673
+ entry.exitCode = exitCode;
674
+ this.terms.delete(id);
675
+ while (this.history.size >= MAX_TERMINAL_HISTORY) {
676
+ const oldest = this.history.keys().next().value;
677
+ if (typeof oldest !== "string")
678
+ break;
679
+ this.history.delete(oldest);
680
+ }
681
+ this.history.set(id, entry);
424
682
  this.emit({ type: "terminal_exit", terminalId: id, exitCode });
683
+ this.emitList();
425
684
  }
426
685
  input(id, data) {
427
- const entry = this.terms.get(id);
428
- if (entry && !entry.exited)
429
- entry.pty.write(data);
686
+ void this.inputChecked(id, data);
430
687
  }
431
688
  resize(id, cols, rows) {
432
689
  const entry = this.terms.get(id);
@@ -442,22 +699,26 @@ export class TerminalManager {
442
699
  // PTY already gone — nothing to do.
443
700
  }
444
701
  }
445
- /** Kill one terminal (tab closed). The exit event is emitted by node-pty. */
702
+ /** Kill one terminal (tab closed), including an exited terminal's retained history. */
446
703
  kill(id) {
447
704
  const entry = this.terms.get(id);
448
- if (!entry || entry.exited)
705
+ if (entry) {
706
+ entry.exited = true;
707
+ try {
708
+ entry.pty.kill();
709
+ }
710
+ catch {
711
+ // already dead
712
+ }
713
+ this.terms.delete(id);
714
+ this.emit({ type: "terminal_exit", terminalId: id, exitCode: null });
715
+ this.emitList();
449
716
  return;
450
- entry.exited = true;
451
- try {
452
- entry.pty.kill();
453
- }
454
- catch {
455
- // already dead
456
717
  }
457
- this.terms.delete(id);
458
- this.emit({ type: "terminal_exit", terminalId: id, exitCode: null });
718
+ if (this.history.delete(id))
719
+ this.emitList();
459
720
  }
460
- /** Kill every terminal of this client (disconnect / dispose). */
721
+ /** Kill every terminal owned by this conversation. */
461
722
  killAll() {
462
723
  for (const entry of this.terms.values()) {
463
724
  if (entry.exited)
@@ -470,6 +731,13 @@ export class TerminalManager {
470
731
  // already dead
471
732
  }
472
733
  }
734
+ for (const entry of this.terms.values()) {
735
+ for (const wake of entry.waiters)
736
+ wake();
737
+ entry.waiters.clear();
738
+ }
473
739
  this.terms.clear();
740
+ this.history.clear();
741
+ this.emitList();
474
742
  }
475
743
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/themes/light.css CHANGED
@@ -17,6 +17,28 @@
17
17
  --red: #dc2626;
18
18
  --red-soft: rgba(220, 38, 38, 0.1);
19
19
  --amber: #d97706;
20
+ /* Terminal ANSI palette — light: canvas + padded area both light. */
21
+ --term-bg: #f5f6fa;
22
+ --term-fg: #1c2030;
23
+ --term-cursor: #7c3aed;
24
+ --term-cursor-accent: #f5f6fa;
25
+ --term-selection: rgba(124, 58, 237, 0.3);
26
+ --term-black: #e8eaf0;
27
+ --term-red: #dc2626;
28
+ --term-green: #059669;
29
+ --term-yellow: #d97706;
30
+ --term-blue: #2563eb;
31
+ --term-magenta: #9333ea;
32
+ --term-cyan: #0e7490;
33
+ --term-white: #1c2030;
34
+ --term-bright-black: #8a91a3;
35
+ --term-bright-red: #dc2626;
36
+ --term-bright-green: #059669;
37
+ --term-bright-yellow: #d97706;
38
+ --term-bright-blue: #2563eb;
39
+ --term-bright-magenta: #9333ea;
40
+ --term-bright-cyan: #0e7490;
41
+ --term-bright-white: #000000;
20
42
  --mono: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
21
43
  --sans:
22
44
  -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
@@ -1142,7 +1164,7 @@ body {
1142
1164
  flex-direction: column;
1143
1165
  min-width: 0;
1144
1166
  min-height: 0;
1145
- background: #0b0d12;
1167
+ background: var(--term-bg);
1146
1168
  /* Padding lives here (not on .term-xterm): FitAddon measures the xterm's
1147
1169
  parent element and only subtracts the xterm's own padding, so padding on
1148
1170
  .term-xterm made it compute rows for more space than is actually visible
@@ -1166,7 +1188,7 @@ body {
1166
1188
  background so it blends with the terminal content (three-class selector
1167
1189
  beats the injected xterm.css rule). */
1168
1190
  .term-xterm .xterm .xterm-viewport {
1169
- background-color: #0b0d12;
1191
+ background-color: var(--term-bg);
1170
1192
  }
1171
1193
 
1172
1194
  .term-empty {
@@ -5344,6 +5366,10 @@ body {
5344
5366
  padding: 28px;
5345
5367
  }
5346
5368
 
5369
+ .fp-overlay.fullscreen {
5370
+ padding: 0;
5371
+ }
5372
+
5347
5373
  .fp {
5348
5374
  display: flex;
5349
5375
  flex-direction: column;
@@ -5357,6 +5383,16 @@ body {
5357
5383
  overflow: hidden;
5358
5384
  }
5359
5385
 
5386
+ .fp.fullscreen {
5387
+ width: 100vw;
5388
+ height: 100vh;
5389
+ max-width: none;
5390
+ max-height: none;
5391
+ border-radius: 0;
5392
+ border-left: 0;
5393
+ border-right: 0;
5394
+ }
5395
+
5360
5396
  .fp-head {
5361
5397
  display: flex;
5362
5398
  align-items: center;
@@ -5426,7 +5462,11 @@ body {
5426
5462
  .fp-attach.wrap:hover,
5427
5463
  .fp-attach.wrap.on,
5428
5464
  .fp-attach.edit:hover,
5429
- .fp-attach.edit.on {
5465
+ .fp-attach.edit.on,
5466
+ .fp-attach.zoom-in:hover,
5467
+ .fp-attach.zoom-out:hover,
5468
+ .fp-attach.full:hover,
5469
+ .fp-attach.full.on {
5430
5470
  background: var(--accent);
5431
5471
  border-color: var(--accent);
5432
5472
  color: #fff;
@@ -5465,6 +5505,30 @@ body {
5465
5505
  opacity: 1;
5466
5506
  }
5467
5507
 
5508
+ /* Zoom control in the header: [-] 100% [+], % resets to 100. */
5509
+ .fp-zoom {
5510
+ display: inline-flex;
5511
+ align-items: center;
5512
+ gap: 4px;
5513
+ }
5514
+ .fp-zoom-val {
5515
+ min-width: 42px;
5516
+ height: 26px;
5517
+ padding: 0 4px;
5518
+ border: 1px solid var(--border);
5519
+ border-radius: 6px;
5520
+ background: var(--bg-elev);
5521
+ color: var(--text-dim);
5522
+ font-size: 10.5px;
5523
+ line-height: 1;
5524
+ text-align: center;
5525
+ cursor: pointer;
5526
+ }
5527
+ .fp-zoom-val:hover {
5528
+ border-color: var(--accent);
5529
+ color: var(--text);
5530
+ }
5531
+
5468
5532
  .fp-close {
5469
5533
  display: inline-flex;
5470
5534
  align-items: center;
@@ -5516,7 +5580,7 @@ body {
5516
5580
  padding: 12px 14px;
5517
5581
  background: rgba(0, 0, 0, 0.22);
5518
5582
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
5519
- font-size: 12px;
5583
+ font-size: calc(12px * var(--fp-zoom, 1));
5520
5584
  line-height: 1.55;
5521
5585
  color: var(--text-dim);
5522
5586
  white-space: pre;
@@ -5528,7 +5592,7 @@ body {
5528
5592
  overflow: auto;
5529
5593
  background: rgba(0, 0, 0, 0.22);
5530
5594
  font-family: var(--mono);
5531
- font-size: 12px;
5595
+ font-size: calc(12px * var(--fp-zoom, 1));
5532
5596
  line-height: 1.6;
5533
5597
  padding: 6px 0;
5534
5598
  user-select: none;
@@ -5552,7 +5616,12 @@ body {
5552
5616
  word-break: break-word;
5553
5617
  overflow-wrap: anywhere;
5554
5618
  }
5555
- .fp-markdown .md {
5619
+ /* Markdown typography uses fixed px/clamp sizes, so scale the whole body with
5620
+ CSS zoom (a zoom-out can leave side whitespace — acceptable). */
5621
+ .fp-markdown-zoom {
5622
+ zoom: var(--fp-zoom, 1);
5623
+ }
5624
+ .fp-markdown-zoom > .md {
5556
5625
  width: 100%;
5557
5626
  max-width: 860px;
5558
5627
  min-width: 0;
@@ -5768,7 +5837,7 @@ body {
5768
5837
  background: rgba(0, 0, 0, 0.22);
5769
5838
  color: var(--text);
5770
5839
  font-family: var(--mono);
5771
- font-size: 12px;
5840
+ font-size: calc(12px * var(--fp-zoom, 1));
5772
5841
  line-height: 1.6;
5773
5842
  white-space: pre-wrap;
5774
5843
  overflow-wrap: anywhere;