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.
- package/README.md +2 -2
- package/README.zh-CN.md +1 -1
- package/dist/server/agent-service.js +595 -106
- package/dist/server/index.js +14 -6
- package/dist/server/terminals.js +307 -39
- package/package.json +1 -1
- package/themes/light.css +76 -7
- package/web/dist/assets/index-BnDkdKFN.css +41 -0
- package/web/dist/assets/index-CgNeNC6a.js +129 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BG-7esPW.css +0 -41
- package/web/dist/assets/index-Bau95_Bc.js +0 -129
package/dist/server/index.js
CHANGED
|
@@ -456,20 +456,26 @@ wss.on("connection", (ws) => {
|
|
|
456
456
|
case "list_providers":
|
|
457
457
|
void cs.listProviders();
|
|
458
458
|
break;
|
|
459
|
-
case "
|
|
460
|
-
cs.
|
|
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.
|
|
469
|
+
cs.getTerminalManager(msg.conversationId)?.input(msg.terminalId, msg.data);
|
|
464
470
|
break;
|
|
465
471
|
case "terminal_resize":
|
|
466
|
-
cs.
|
|
472
|
+
cs.getTerminalManager(msg.conversationId)?.resize(msg.terminalId, msg.cols, msg.rows);
|
|
467
473
|
break;
|
|
468
474
|
case "terminal_kill":
|
|
469
|
-
cs.
|
|
475
|
+
cs.getTerminalManager(msg.conversationId)?.kill(msg.terminalId);
|
|
470
476
|
break;
|
|
471
477
|
case "run_command":
|
|
472
|
-
cs.
|
|
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":
|
package/dist/server/terminals.js
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TerminalManager —
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
|
281
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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.
|
|
458
|
-
|
|
718
|
+
if (this.history.delete(id))
|
|
719
|
+
this.emitList();
|
|
459
720
|
}
|
|
460
|
-
/** Kill every terminal
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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;
|