Spaces:
Running
Running
Agent Manager commited on
Commit ·
290f6a9
1
Parent(s): 8dd62b7
Refine libghostty session ownership and restore
Browse files- README.md +17 -13
- server/migration.test.mjs +30 -16
- server/resize.test.mjs +157 -389
- server/src/demo.js +1 -1
- server/src/index.js +17 -24
- server/src/runner.js +75 -293
- server/src/snapshot.js +27 -39
- web/src/components/TerminalPane.tsx +131 -192
- web/src/styles.css +9 -16
README.md
CHANGED
|
@@ -124,15 +124,22 @@ browser (xterm.js panes)
|
|
| 124 |
|
| 125 |
Each agent is a PTY held by the backend, with a **libghostty-vt** terminal fed
|
| 126 |
from its output. That grid is the authoritative screen, so reopening a pane is a
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
sessions are pinned to a per-session conversation id at creation; Codex sessions
|
| 137 |
are pinned right after first launch (the id is captured from the rollout file
|
| 138 |
Codex creates) — so agents sharing a folder never resume each other's
|
|
@@ -144,11 +151,8 @@ conversations.
|
|
| 144 |
|---|---|---|
|
| 145 |
| `PORT` | `7860` | HTTP + WS port (HF `app_port`) |
|
| 146 |
| `DATA_DIR` | `/data` | Durable root (mounted private Storage Bucket) |
|
| 147 |
-
| `
|
| 148 |
-
| `AM_REPLAY_BYTES` | `262144` | PTY bytes replayed to a reattaching browser |
|
| 149 |
| `AM_RESIZE_SETTLE_MS` | `120` | Quiet period before a resize is applied to the PTY |
|
| 150 |
-
| `AM_RESIZE_CARRY` | `1` | `0` reflows on resize like a plain terminal (duplicates scrollback) |
|
| 151 |
-
| `AM_RESIZE_ARCHIVE_MS` | `700` | Grace period for an app to repaint before rows a shrink pushed off are archived |
|
| 152 |
| `ANTHROPIC_API_KEY` | — | Claude Code / opencode / Hermes (Space **secret**) |
|
| 153 |
| `OPENAI_API_KEY` / `CODEX_API_KEY` | — | Codex (Space secret) |
|
| 154 |
| `GEMINI_API_KEY` | — | Gemini CLI (Space secret) |
|
|
|
|
| 124 |
|
| 125 |
Each agent is a PTY held by the backend, with a **libghostty-vt** terminal fed
|
| 126 |
from its output. That grid is the authoritative screen, so reopening a pane is a
|
| 127 |
+
canonical serialization of its retained history and styled screen rather than a
|
| 128 |
+
truncated PTY byte replay, and agent state is read from the grid instead of
|
| 129 |
+
shelling out per session. Several browsers can watch the same session, but one
|
| 130 |
+
explicit controller owns input and PTY dimensions; interacting with a watcher
|
| 131 |
+
claims control. This prevents background tabs and small phones from resizing a
|
| 132 |
+
desktop session, and prevents several browser emulators from all answering the
|
| 133 |
+
same terminal query.
|
| 134 |
+
|
| 135 |
+
A resize is a controller request. The backend coalesces window-drag bursts,
|
| 136 |
+
allows Ghostty to perform normal reflow, then tells every viewer the confirmed
|
| 137 |
+
geometry before more PTY output arrives. Full history serialization is reserved
|
| 138 |
+
for attach/reconnect. Browser zoom is presentation-only: it changes cell size
|
| 139 |
+
and pans locally without resizing the PTY. Sessions survive browser disconnects
|
| 140 |
+
but not a backend restart or Space sleep/rebuild; with storage the working
|
| 141 |
+
directory and CLI state persist, so a reopened session resumes its own
|
| 142 |
+
conversation. Claude
|
| 143 |
sessions are pinned to a per-session conversation id at creation; Codex sessions
|
| 144 |
are pinned right after first launch (the id is captured from the rollout file
|
| 145 |
Codex creates) — so agents sharing a folder never resume each other's
|
|
|
|
| 151 |
|---|---|---|
|
| 152 |
| `PORT` | `7860` | HTTP + WS port (HF `app_port`) |
|
| 153 |
| `DATA_DIR` | `/data` | Durable root (mounted private Storage Bucket) |
|
| 154 |
+
| `AM_SCROLLBACK_BYTES` | `67108864` | Maximum Ghostty scrollback memory per session |
|
|
|
|
| 155 |
| `AM_RESIZE_SETTLE_MS` | `120` | Quiet period before a resize is applied to the PTY |
|
|
|
|
|
|
|
| 156 |
| `ANTHROPIC_API_KEY` | — | Claude Code / opencode / Hermes (Space **secret**) |
|
| 157 |
| `OPENAI_API_KEY` / `CODEX_API_KEY` | — | Codex (Space secret) |
|
| 158 |
| `GEMINI_API_KEY` | — | Gemini CLI (Space secret) |
|
server/migration.test.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
-
// End-to-end check of the
|
| 2 |
// Uses a `shell` session so it costs no agent tokens.
|
| 3 |
// node migration.test.mjs
|
| 4 |
import { spawn } from 'node:child_process';
|
|
|
|
|
|
|
| 5 |
import path from 'node:path';
|
| 6 |
import { WebSocket } from 'ws';
|
| 7 |
|
| 8 |
-
|
| 9 |
-
// compares strings, so a relative root makes every path look like an escape.
|
| 10 |
-
const DATA_DIR = path.resolve('./.mig');
|
| 11 |
|
| 12 |
const PORT = 7893;
|
| 13 |
const CTRL = '\x00\x00AM:';
|
|
@@ -28,7 +28,7 @@ let bootLog = '';
|
|
| 28 |
srv.stdout.on('data', (d) => { bootLog += d; });
|
| 29 |
srv.stderr.on('data', (d) => { bootLog += d; });
|
| 30 |
|
| 31 |
-
/** A viewer: collects raw bytes and control frames, can send input/resize. */
|
| 32 |
function view(id, cols = 100, rows = 30) {
|
| 33 |
return new Promise((resolve) => {
|
| 34 |
const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
|
|
@@ -36,6 +36,7 @@ function view(id, cols = 100, rows = 30) {
|
|
| 36 |
bytes: '', frames: [], closes: [],
|
| 37 |
type: (d) => ws.send(JSON.stringify({ t: 'i', d })),
|
| 38 |
resize: (c, r) => ws.send(JSON.stringify({ t: 'r', cols: c, rows: r })),
|
|
|
|
| 39 |
lastFrame: (t) => [...v.frames].reverse().find((f) => f.t === t) || null,
|
| 40 |
open: () => ws.readyState === 1,
|
| 41 |
close: () => { try { ws.close(); } catch {} },
|
|
@@ -84,6 +85,7 @@ try {
|
|
| 84 |
const a = await view(id);
|
| 85 |
check('viewer attaches', a.open());
|
| 86 |
await sleep(1500);
|
|
|
|
| 87 |
a.type('echo hello-from-viewer-a\r');
|
| 88 |
await sleep(1300);
|
| 89 |
check('input reaches the PTY and output comes back', a.bytes.includes('hello-from-viewer-a'),
|
|
@@ -103,12 +105,12 @@ try {
|
|
| 103 |
});
|
| 104 |
await sleep(1600);
|
| 105 |
|
| 106 |
-
// --- reattach: restore frame +
|
| 107 |
const b = await view(id);
|
| 108 |
await sleep(1000);
|
| 109 |
const restore = b.lastFrame('restore');
|
| 110 |
check('reattach sends a restore frame', !!restore, restore && `${restore.cols}x${restore.rows}`);
|
| 111 |
-
check('restore
|
| 112 |
`${b.bytes.length}B restored`);
|
| 113 |
check('restore includes work done while detached', b.bytes.includes('ran-while-detached'));
|
| 114 |
check('no tmux [exited] noise', !b.bytes.includes('[exited]'));
|
|
@@ -129,8 +131,8 @@ try {
|
|
| 129 |
check('an idle shell settles back to idle', calm && calm.state === 'idle', calm && calm.state);
|
| 130 |
|
| 131 |
// --- the agent-watch API still works, now off the grid --------------------
|
| 132 |
-
// capturePane()
|
| 133 |
-
//
|
| 134 |
const tailRes = await fetch(`${base}/api/agents/${id}/tail?lines=200`);
|
| 135 |
const tail = await tailRes.json();
|
| 136 |
const tailText = typeof tail === 'string' ? tail : (tail.text || tail.tail || JSON.stringify(tail));
|
|
@@ -141,7 +143,7 @@ try {
|
|
| 141 |
tailText.includes('hello-from-viewer-a') ? 'has scrollback' : 'MISSING scrollback');
|
| 142 |
check('agent tail has no trailing blank padding', !/\n\s*\n\s*$/.test(tailText));
|
| 143 |
|
| 144 |
-
// --- two viewers share one grid
|
| 145 |
const c = await view(id, 150, 40);
|
| 146 |
await sleep(400);
|
| 147 |
c.resize(150, 40);
|
|
@@ -154,19 +156,30 @@ try {
|
|
| 154 |
check('both viewers are told the same grid',
|
| 155 |
!!bGrid && !!cGrid && bGrid.cols === cGrid.cols && bGrid.rows === cGrid.rows,
|
| 156 |
bGrid && cGrid ? `${bGrid.cols}x${bGrid.rows} vs ${cGrid.cols}x${cGrid.rows}` : 'missing grid frame');
|
| 157 |
-
check('grid follows the smallest viewer', !!bGrid && bGrid.cols === 40 && bGrid.rows === 20,
|
| 158 |
bGrid && `${bGrid.cols}x${bGrid.rows}`);
|
| 159 |
-
check('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
c.type('echo seen-by-both\r');
|
| 161 |
await sleep(1100);
|
| 162 |
check('both viewers see the same live output',
|
| 163 |
b.bytes.includes('seen-by-both') && c.bytes.includes('seen-by-both'));
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
b.close();
|
| 166 |
-
await sleep(1000);
|
| 167 |
-
const grown = c.lastFrame('grid');
|
| 168 |
-
check('grid grows back when a viewer leaves', !!grown && grown.cols === 150 && grown.rows === 40,
|
| 169 |
-
grown && `${grown.cols}x${grown.rows}`);
|
| 170 |
|
| 171 |
// --- stopping is explicit ------------------------------------------------
|
| 172 |
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
|
@@ -184,6 +197,7 @@ try {
|
|
| 184 |
srv.kill('SIGTERM');
|
| 185 |
await sleep(600);
|
| 186 |
srv.kill('SIGKILL');
|
|
|
|
| 187 |
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
| 188 |
process.exit(failures ? 1 : 0);
|
| 189 |
}
|
|
|
|
| 1 |
+
// End-to-end check of the libghostty-backed session model.
|
| 2 |
// Uses a `shell` session so it costs no agent tokens.
|
| 3 |
// node migration.test.mjs
|
| 4 |
import { spawn } from 'node:child_process';
|
| 5 |
+
import fs from 'node:fs';
|
| 6 |
+
import os from 'node:os';
|
| 7 |
import path from 'node:path';
|
| 8 |
import { WebSocket } from 'ws';
|
| 9 |
|
| 10 |
+
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-migration-'));
|
|
|
|
|
|
|
| 11 |
|
| 12 |
const PORT = 7893;
|
| 13 |
const CTRL = '\x00\x00AM:';
|
|
|
|
| 28 |
srv.stdout.on('data', (d) => { bootLog += d; });
|
| 29 |
srv.stderr.on('data', (d) => { bootLog += d; });
|
| 30 |
|
| 31 |
+
/** A viewer: collects raw bytes and control frames, can send input/resize/claim. */
|
| 32 |
function view(id, cols = 100, rows = 30) {
|
| 33 |
return new Promise((resolve) => {
|
| 34 |
const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
|
|
|
|
| 36 |
bytes: '', frames: [], closes: [],
|
| 37 |
type: (d) => ws.send(JSON.stringify({ t: 'i', d })),
|
| 38 |
resize: (c, r) => ws.send(JSON.stringify({ t: 'r', cols: c, rows: r })),
|
| 39 |
+
claim: () => ws.send(JSON.stringify({ t: 'claim' })),
|
| 40 |
lastFrame: (t) => [...v.frames].reverse().find((f) => f.t === t) || null,
|
| 41 |
open: () => ws.readyState === 1,
|
| 42 |
close: () => { try { ws.close(); } catch {} },
|
|
|
|
| 85 |
const a = await view(id);
|
| 86 |
check('viewer attaches', a.open());
|
| 87 |
await sleep(1500);
|
| 88 |
+
check('the first viewer controls the session', a.lastFrame('restore')?.controller === true);
|
| 89 |
a.type('echo hello-from-viewer-a\r');
|
| 90 |
await sleep(1300);
|
| 91 |
check('input reaches the PTY and output comes back', a.bytes.includes('hello-from-viewer-a'),
|
|
|
|
| 105 |
});
|
| 106 |
await sleep(1600);
|
| 107 |
|
| 108 |
+
// --- reattach: restore frame + canonical scrollback -----------------------
|
| 109 |
const b = await view(id);
|
| 110 |
await sleep(1000);
|
| 111 |
const restore = b.lastFrame('restore');
|
| 112 |
check('reattach sends a restore frame', !!restore, restore && `${restore.cols}x${restore.rows}`);
|
| 113 |
+
check('restore serializes earlier scrollback', b.bytes.includes('hello-from-viewer-a'),
|
| 114 |
`${b.bytes.length}B restored`);
|
| 115 |
check('restore includes work done while detached', b.bytes.includes('ran-while-detached'));
|
| 116 |
check('no tmux [exited] noise', !b.bytes.includes('[exited]'));
|
|
|
|
| 131 |
check('an idle shell settles back to idle', calm && calm.state === 'idle', calm && calm.state);
|
| 132 |
|
| 133 |
// --- the agent-watch API still works, now off the grid --------------------
|
| 134 |
+
// capturePane() reads the held grid directly. Same shape as the old endpoint:
|
| 135 |
+
// plain text, with screen and scrollback above it.
|
| 136 |
const tailRes = await fetch(`${base}/api/agents/${id}/tail?lines=200`);
|
| 137 |
const tail = await tailRes.json();
|
| 138 |
const tailText = typeof tail === 'string' ? tail : (tail.text || tail.tail || JSON.stringify(tail));
|
|
|
|
| 143 |
tailText.includes('hello-from-viewer-a') ? 'has scrollback' : 'MISSING scrollback');
|
| 144 |
check('agent tail has no trailing blank padding', !/\n\s*\n\s*$/.test(tailText));
|
| 145 |
|
| 146 |
+
// --- two viewers share one grid with one explicit controller --------------
|
| 147 |
const c = await view(id, 150, 40);
|
| 148 |
await sleep(400);
|
| 149 |
c.resize(150, 40);
|
|
|
|
| 156 |
check('both viewers are told the same grid',
|
| 157 |
!!bGrid && !!cGrid && bGrid.cols === cGrid.cols && bGrid.rows === cGrid.rows,
|
| 158 |
bGrid && cGrid ? `${bGrid.cols}x${bGrid.rows} vs ${cGrid.cols}x${cGrid.rows}` : 'missing grid frame');
|
| 159 |
+
check('grid follows the controller, not the smallest viewer', !!bGrid && bGrid.cols === 40 && bGrid.rows === 20,
|
| 160 |
bGrid && `${bGrid.cols}x${bGrid.rows}`);
|
| 161 |
+
check('viewer roles are explicit', !!bGrid && bGrid.controller === true && bGrid.viewers === 2
|
| 162 |
+
&& cGrid?.controller === false && cGrid.viewers === 2);
|
| 163 |
+
c.type('echo ignored-watcher-input\r');
|
| 164 |
+
await sleep(500);
|
| 165 |
+
check('watcher input is ignored', !b.bytes.includes('ignored-watcher-input'));
|
| 166 |
+
c.claim();
|
| 167 |
+
await sleep(700);
|
| 168 |
+
const claimed = c.lastFrame('grid');
|
| 169 |
+
check('a watcher can explicitly claim control', !!claimed && claimed.controller === true
|
| 170 |
+
&& claimed.cols === 150 && claimed.rows === 40,
|
| 171 |
+
claimed && `${claimed.cols}x${claimed.rows} controller=${claimed.controller}`);
|
| 172 |
c.type('echo seen-by-both\r');
|
| 173 |
await sleep(1100);
|
| 174 |
check('both viewers see the same live output',
|
| 175 |
b.bytes.includes('seen-by-both') && c.bytes.includes('seen-by-both'));
|
| 176 |
|
| 177 |
+
b.resize(60, 20);
|
| 178 |
+
await sleep(500);
|
| 179 |
+
const stays = c.lastFrame('grid');
|
| 180 |
+
check('a watcher resize cannot disturb the controller', !!stays && stays.cols === 150 && stays.rows === 40,
|
| 181 |
+
stays && `${stays.cols}x${stays.rows}`);
|
| 182 |
b.close();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
// --- stopping is explicit ------------------------------------------------
|
| 185 |
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
|
|
|
| 197 |
srv.kill('SIGTERM');
|
| 198 |
await sleep(600);
|
| 199 |
srv.kill('SIGKILL');
|
| 200 |
+
try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch {}
|
| 201 |
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
| 202 |
process.exit(failures ? 1 : 0);
|
| 203 |
}
|
server/resize.test.mjs
CHANGED
|
@@ -1,35 +1,19 @@
|
|
| 1 |
-
//
|
| 2 |
//
|
| 3 |
-
//
|
| 4 |
-
//
|
| 5 |
-
//
|
| 6 |
-
//
|
| 7 |
-
// * REFLOW. Narrowing rewraps the outgoing screen — a 119-column row becomes
|
| 8 |
-
// two rows at 110 — and the excess scrolls up into scrollback, where the
|
| 9 |
-
// TUI's repaint of that same screen leaves it stranded as a copy. One copy
|
| 10 |
-
// per resize, so one per zoom click. Fixed by clearing the screen before the
|
| 11 |
-
// reflow and carrying it across by hand.
|
| 12 |
-
// * a resize STORM — ResizeObserver fires per animation frame while a window
|
| 13 |
-
// is dragged, and every tick used to resize the PTY, so one drag paid the
|
| 14 |
-
// above dozens of times. Fixed by coalescing.
|
| 15 |
-
// * the browser reflowing ITSELF (fit.fit()) before the server confirmed, so
|
| 16 |
-
// its scrollback was a byte log wrapped at mixed widths while the grid held
|
| 17 |
-
// a properly reflowed one. Fixed by making the browser ask, not act.
|
| 18 |
-
//
|
| 19 |
-
// Every duplication check has teeth: AM_RESIZE_CARRY=0 restores plain reflow and
|
| 20 |
-
// the zoom checks fail (63 duplicated tokens in the grid, 101 in the browser).
|
| 21 |
-
//
|
| 22 |
-
// fixtures/repaint-tui.mjs stands in for an agent TUI and repaints exactly the
|
| 23 |
-
// way Claude Code does, so this costs no agent tokens.
|
| 24 |
// node resize.test.mjs
|
|
|
|
|
|
|
| 25 |
import { spawn } from 'node:child_process';
|
| 26 |
import path from 'node:path';
|
| 27 |
import { fileURLToPath } from 'node:url';
|
| 28 |
import { WebSocket } from 'ws';
|
| 29 |
|
| 30 |
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 31 |
-
const DATA_DIR = path.join(
|
| 32 |
-
const FIXTURE = path.join(HERE, 'fixtures', 'repaint-tui.mjs');
|
| 33 |
const PORT = 7895;
|
| 34 |
const CTRL = '\x00\x00AM:';
|
| 35 |
const base = `http://localhost:${PORT}`;
|
|
@@ -39,7 +23,15 @@ const check = (name, ok, detail = '') => {
|
|
| 39 |
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
| 40 |
if (!ok) failures++;
|
| 41 |
};
|
| 42 |
-
const sleep = (ms) => new Promise((
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
const srv = spawn('node', ['src/index.js'], {
|
| 45 |
cwd: HERE,
|
|
@@ -47,65 +39,58 @@ const srv = spawn('node', ['src/index.js'], {
|
|
| 47 |
stdio: ['ignore', 'pipe', 'pipe'],
|
| 48 |
});
|
| 49 |
let log = '';
|
| 50 |
-
srv.stdout.on('data', (
|
| 51 |
-
srv.stderr.on('data', (
|
| 52 |
|
| 53 |
-
// The browser's own emulator, so a viewer can be checked the way a user sees it
|
| 54 |
-
// rather than only through the grid. Optional: it is a devDependency, and the
|
| 55 |
-
// suite still runs (minus those checks) in a production install.
|
| 56 |
let Headless = null;
|
| 57 |
try { Headless = (await import('@xterm/headless')).default.Terminal; } catch {}
|
| 58 |
|
| 59 |
function view(id, cols, rows, mirror = false) {
|
| 60 |
return new Promise((resolve) => {
|
| 61 |
const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
|
| 62 |
-
// A stand-in for TerminalPane, mirroring its message handling EXACTLY — the
|
| 63 |
-
// point is to catch the ordering hazard in it: xterm's write is asynchronous,
|
| 64 |
-
// so a resize applied outside the write callback can overtake the bytes that
|
| 65 |
-
// were sent before it.
|
| 66 |
const term = mirror && Headless
|
| 67 |
? new Headless({ cols, rows, scrollback: 20000, allowProposedApi: true })
|
| 68 |
: null;
|
| 69 |
const v = {
|
| 70 |
bytes: '', frames: [], term,
|
| 71 |
-
type: (
|
| 72 |
-
resize: (
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
close: () => { try { ws.close(); } catch {} },
|
| 76 |
-
// Everything the viewer has: scrollback above the screen, then the screen.
|
| 77 |
screenText: async () => {
|
| 78 |
if (!term) return '';
|
| 79 |
-
await new Promise((
|
| 80 |
-
const buf = term.buffer.active;
|
| 81 |
-
const out = [];
|
| 82 |
-
for (let i = 0; i < buf.length; i++) out.push(buf.getLine(i)?.translateToString(true) ?? '');
|
| 83 |
-
return out.join('\n');
|
| 84 |
-
},
|
| 85 |
-
// Just the visible screen — what the app is displaying right now.
|
| 86 |
-
viewportText: async () => {
|
| 87 |
-
if (!term) return '';
|
| 88 |
-
await new Promise((r) => term.write('', r));
|
| 89 |
const buf = term.buffer.active;
|
| 90 |
-
const
|
| 91 |
-
for (let i =
|
| 92 |
-
return
|
| 93 |
},
|
| 94 |
};
|
| 95 |
ws.on('message', (data) => {
|
| 96 |
-
const
|
| 97 |
-
if (
|
| 98 |
-
let
|
| 99 |
-
try {
|
| 100 |
-
v.frames.push(
|
| 101 |
-
if (term && (
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
| 106 |
} else {
|
| 107 |
-
v.bytes +=
|
| 108 |
-
if (term) term.write(
|
| 109 |
}
|
| 110 |
});
|
| 111 |
ws.on('open', () => resolve(v));
|
|
@@ -113,18 +98,6 @@ function view(id, cols, rows, mirror = false) {
|
|
| 113 |
});
|
| 114 |
}
|
| 115 |
|
| 116 |
-
/** Tokens the fixture prints are unique, so a repeat is a duplicated line. */
|
| 117 |
-
function duplicateTokens(text) {
|
| 118 |
-
const counts = new Map();
|
| 119 |
-
for (const m of text.matchAll(/\b\d{3}\.\d{2}\b/g)) counts.set(m[0], (counts.get(m[0]) || 0) + 1);
|
| 120 |
-
return [...counts.values()].filter((n) => n > 1).length;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
const gridText = async (id) => {
|
| 124 |
-
const r = await (await fetch(`${base}/api/agents/${id}/tail?lines=500`)).json();
|
| 125 |
-
return typeof r === 'string' ? r : (r.text || '');
|
| 126 |
-
};
|
| 127 |
-
|
| 128 |
async function session(name) {
|
| 129 |
const created = await (await fetch(`${base}/api/sessions`, {
|
| 130 |
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
@@ -134,350 +107,145 @@ async function session(name) {
|
|
| 134 |
return created.id;
|
| 135 |
}
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
const
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
await
|
| 145 |
-
|
| 146 |
-
}
|
| 147 |
|
| 148 |
try {
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
for (let i = 0; i < 400; i++) {
|
| 154 |
-
try { if ((await fetch(`${base}/api/health`)).ok) { up = true; break; } } catch {}
|
| 155 |
-
await sleep(250);
|
| 156 |
-
}
|
| 157 |
-
if (!up) throw new Error(`server never came up:\n${log.slice(-800)}`);
|
| 158 |
|
| 159 |
-
//
|
|
|
|
| 160 |
{
|
| 161 |
-
const
|
| 162 |
-
const
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
check('
|
| 172 |
-
check('
|
|
|
|
| 173 |
v.close();
|
| 174 |
-
await
|
| 175 |
}
|
| 176 |
|
| 177 |
-
//
|
| 178 |
{
|
| 179 |
-
const
|
| 180 |
-
|
|
|
|
| 181 |
for (let i = 0; i < 16; i++) {
|
| 182 |
v.resize(120 - i * 2, 40 - i);
|
| 183 |
-
await sleep(
|
| 184 |
}
|
| 185 |
v.resize(88, 24);
|
| 186 |
-
await
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
check('a drag does not multiply scrollback', dupes <= 12, `${dupes} duplicated tokens`);
|
| 190 |
-
check('the grid settles at the final size', (await gridText(id)).includes('[fixture 88x24]'));
|
| 191 |
-
// Every viewer must be told the size that was actually applied, and the
|
| 192 |
-
// coalescing must not swallow the last one.
|
| 193 |
-
const last = v.lastFrame('grid');
|
| 194 |
-
check('the last grid frame matches the final size', !!last && last.cols === 88 && last.rows === 24,
|
| 195 |
-
last && `${last.cols}x${last.rows}`);
|
| 196 |
-
check('the storm is coalesced into few PTY resizes', v.countFrames('grid') <= 4,
|
| 197 |
-
`${v.countFrames('grid')} grid frames`);
|
| 198 |
-
v.close();
|
| 199 |
-
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
// --- the browser is repainted from the grid after a resize ---------------
|
| 203 |
-
// An app that ignores SIGWINCH (a bash prompt, an agent sitting idle) would
|
| 204 |
-
// otherwise leave the viewer showing its own reflow of a byte log, which is
|
| 205 |
-
// exactly where the two disagree.
|
| 206 |
-
{
|
| 207 |
-
const { id, v } = await paintedFixture('resize repaint');
|
| 208 |
-
const beforeBytes = v.bytes.length;
|
| 209 |
-
v.resize(70, 26);
|
| 210 |
-
await sleep(1200);
|
| 211 |
-
const sent = v.bytes.slice(beforeBytes);
|
| 212 |
-
check('a settled resize repaints the viewer from the grid', sent.includes('\x1b[2J'),
|
| 213 |
-
`${sent.length}B after resize`);
|
| 214 |
-
// The size must arrive BEFORE the bytes that assume it.
|
| 215 |
-
check('the grid frame precedes the repaint',
|
| 216 |
-
v.frames.some((f) => f.t === 'grid' && f.cols === 70 && f.rows === 26));
|
| 217 |
v.close();
|
| 218 |
-
await
|
| 219 |
}
|
| 220 |
|
| 221 |
-
//
|
| 222 |
-
//
|
| 223 |
-
// scrollback clean is clearing the screen before the reflow (nothing left to
|
| 224 |
-
// rewrap into history) and carrying it across by hand instead.
|
| 225 |
{
|
| 226 |
-
const
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
await
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
check('
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
}
|
| 245 |
-
|
| 246 |
-
// --- what the BROWSER ends up holding ------------------------------------
|
| 247 |
-
// The grid being clean is only half the fix: the duplication a user sees is in
|
| 248 |
-
// xterm's buffer, which reflows harder than libghostty does. This drives the
|
| 249 |
-
// real emulator through the real protocol.
|
| 250 |
-
if (!Headless) {
|
| 251 |
-
console.log('SKIP browser-side checks (@xterm/headless not installed)');
|
| 252 |
-
} else {
|
| 253 |
-
const { id, v } = await paintedFixture('resize browser', 120, 40, true);
|
| 254 |
-
v.resize(110, 36);
|
| 255 |
-
await sleep(900);
|
| 256 |
-
const learned = duplicateTokens(await v.screenText());
|
| 257 |
-
for (const [c, r] of [[100, 32], [90, 28], [80, 24], [90, 28], [100, 32], [110, 36]]) {
|
| 258 |
-
v.resize(c, r);
|
| 259 |
-
await sleep(400);
|
| 260 |
-
}
|
| 261 |
-
await sleep(1200);
|
| 262 |
-
const seen = await v.screenText();
|
| 263 |
-
check("zooming leaves no duplicates in the browser's own buffer", duplicateTokens(seen) <= learned,
|
| 264 |
-
`${learned} before, ${duplicateTokens(seen)} after six zoom steps`);
|
| 265 |
-
check('the browser ends at the grid size', v.term.cols === 110 && v.term.rows === 36,
|
| 266 |
-
`${v.term.cols}x${v.term.rows}`);
|
| 267 |
-
// Ordering: if resize overtook the repaint, the screen would be torn or stale.
|
| 268 |
-
check('the browser shows the fixture at the final size', seen.includes('[fixture 110x36]'));
|
| 269 |
-
v.close();
|
| 270 |
-
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
// --- scrolling back still reaches everything -----------------------------
|
| 274 |
-
// The first version of the carry cleared the screen and dropped the rows that
|
| 275 |
-
// no longer fitted, so every zoom-in quietly ate history: a 40->24 shrink took
|
| 276 |
-
// 16 lines with it and the pane could no longer scroll all the way up. Rows
|
| 277 |
-
// that fall off the top have to be ARCHIVED, not discarded.
|
| 278 |
-
if (!Headless) {
|
| 279 |
-
console.log('SKIP scrollback checks (@xterm/headless not installed)');
|
| 280 |
-
} else {
|
| 281 |
-
const id = await session('resize history');
|
| 282 |
-
const v = await view(id, 120, 40, true);
|
| 283 |
-
v.resize(120, 40);
|
| 284 |
-
await sleep(900);
|
| 285 |
-
// Teach the host that this session repaints, so the carry path is the one
|
| 286 |
-
// under test, then leave a real log behind — that is what must survive.
|
| 287 |
-
v.type(`node ${FIXTURE}\r`);
|
| 288 |
-
await sleep(1500);
|
| 289 |
-
v.resize(118, 38);
|
| 290 |
-
await sleep(800);
|
| 291 |
-
v.type('\x03');
|
| 292 |
-
await sleep(800);
|
| 293 |
-
// The trailing blank lines are load-bearing. Bash redraws its prompt on every
|
| 294 |
-
// SIGWINCH and, when that prompt is wrapped, draws over the rows above it —
|
| 295 |
-
// verified identical with AM_RESIZE_CARRY=0, so it is bash's doing and not the
|
| 296 |
-
// resize path's. Without the padding this test would be asserting something no
|
| 297 |
-
// terminal delivers.
|
| 298 |
-
v.type('for i in $(seq 1 200); do echo "hist-$i"; done; echo; echo; echo\r');
|
| 299 |
-
await sleep(2000);
|
| 300 |
-
|
| 301 |
-
const missing = (text) => {
|
| 302 |
-
const gone = [];
|
| 303 |
-
for (let i = 1; i <= 200; i++) if (!new RegExp(`hist-${i}(?!\\d)`).test(text)) gone.push(i);
|
| 304 |
-
return gone;
|
| 305 |
-
};
|
| 306 |
-
const before = missing(await v.screenText());
|
| 307 |
-
check('the log is complete before zooming', before.length === 0, `${before.length} lines missing`);
|
| 308 |
-
|
| 309 |
-
for (const [c, r] of [[100, 30], [90, 24], [100, 30], [110, 34], [120, 40], [90, 24]]) {
|
| 310 |
-
v.resize(c, r);
|
| 311 |
-
await sleep(400);
|
| 312 |
-
}
|
| 313 |
-
await sleep(1200);
|
| 314 |
-
// The grid is the authority and has to be exact.
|
| 315 |
-
const gridGone = missing(await gridText(id));
|
| 316 |
-
check('zooming loses no scrollback in the grid', gridGone.length === 0,
|
| 317 |
-
gridGone.length ? `${gridGone.length} lines gone, e.g. hist-${gridGone.slice(0, 5).join(', hist-')}` : '');
|
| 318 |
-
// xterm's own reflow drops a couple of lines over a cycle this violent no
|
| 319 |
-
// matter what we do — AM_RESIZE_CARRY=0 loses three in the same run, from the
|
| 320 |
-
// same region — so this is a bound, not a promise. A reattach repaints the
|
| 321 |
-
// pane from the grid, which is why the grid check above is the strict one.
|
| 322 |
-
const after = missing(await v.screenText());
|
| 323 |
-
check('zooming loses no more browser scrollback than plain reflow does', after.length <= 4,
|
| 324 |
-
after.length ? `${after.length} lines gone, e.g. hist-${after.slice(0, 5).join(', hist-')}` : 'none');
|
| 325 |
-
v.close();
|
| 326 |
-
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 327 |
-
}
|
| 328 |
-
|
| 329 |
-
// --- zooming OUT gives back the top, it does not copy it -----------------
|
| 330 |
-
// Growing the screen pulls history back down out of scrollback to fill the new
|
| 331 |
-
// rows. The carry used to paint the outgoing screen over exactly those rows, so
|
| 332 |
-
// zooming out destroyed the history it had just recovered and left the old
|
| 333 |
-
// screen stranded above the app's repaint: "I can't scroll all the way to the
|
| 334 |
-
// top, and zooming out further duplicates at the top."
|
| 335 |
-
if (!Headless) {
|
| 336 |
-
console.log('SKIP zoom-out checks (@xterm/headless not installed)');
|
| 337 |
-
} else {
|
| 338 |
-
const id = await session('resize zoom out');
|
| 339 |
-
const v = await view(id, 120, 40, true);
|
| 340 |
-
v.resize(120, 40);
|
| 341 |
-
await sleep(900);
|
| 342 |
-
v.type(`node ${FIXTURE}\r`);
|
| 343 |
-
await sleep(1500);
|
| 344 |
-
v.type('\x03');
|
| 345 |
-
await sleep(800);
|
| 346 |
-
v.type('for i in $(seq 1 120); do echo "out-$i"; done; echo; echo; echo\r');
|
| 347 |
-
await sleep(2000);
|
| 348 |
-
|
| 349 |
-
const count = (text, n) => (text.match(new RegExp(`out-${n}(?!\\d)`, 'g')) || []).length;
|
| 350 |
-
// Zoom in, then back out past where it started.
|
| 351 |
-
for (const [c, r] of [[100, 26], [90, 20], [100, 26], [110, 34], [120, 44]]) {
|
| 352 |
-
v.resize(c, r);
|
| 353 |
-
await sleep(500);
|
| 354 |
-
}
|
| 355 |
-
await sleep(1200);
|
| 356 |
-
const seen = await v.screenText();
|
| 357 |
-
const grid = await gridText(id);
|
| 358 |
-
const lost = [];
|
| 359 |
-
const doubled = [];
|
| 360 |
-
for (let i = 1; i <= 120; i++) {
|
| 361 |
-
if (count(grid, i) === 0) lost.push(i);
|
| 362 |
-
if (count(grid, i) > 1) doubled.push(i);
|
| 363 |
}
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
check('zooming out copies nothing to the top', doubled.length === 0,
|
| 367 |
-
doubled.length ? `${doubled.length} doubled, e.g. out-${doubled.slice(0, 5).join(', out-')}` : '');
|
| 368 |
-
const browserDoubled = [];
|
| 369 |
-
for (let i = 1; i <= 120; i++) if (count(seen, i) > 1) browserDoubled.push(i);
|
| 370 |
-
check('and copies nothing in the browser either', browserDoubled.length === 0,
|
| 371 |
-
browserDoubled.length ? `${browserDoubled.length} doubled, e.g. out-${browserDoubled.slice(0, 5).join(', out-')}` : '');
|
| 372 |
-
v.close();
|
| 373 |
-
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 374 |
}
|
| 375 |
|
| 376 |
-
//
|
| 377 |
-
//
|
| 378 |
-
// ours to fix: an agent renders the tail of its conversation, narrowing wraps
|
| 379 |
-
// those lines, and the frame becomes taller than the screen. Printing it scrolls
|
| 380 |
-
// the overflow into scrollback, where it is a copy of what the frame also shows.
|
| 381 |
-
// Any terminal does this; measured here at 251 duplicated tokens with the carry
|
| 382 |
-
// on and 371 with AM_RESIZE_CARRY=0, so what we can prevent, we do.
|
| 383 |
-
//
|
| 384 |
-
// The copies are one per print: the app painted an overflowing frame three times
|
| 385 |
-
// here (once on launch, once per resize) and rows appear up to three times, with
|
| 386 |
-
// the carry on and off alike. What the carry removes is OUR share — the rewrapped
|
| 387 |
-
// copy reflow would archive on top — which is the whole 371-vs-251 difference.
|
| 388 |
-
// The bound below is what catches that share coming back.
|
| 389 |
-
if (!Headless) {
|
| 390 |
-
console.log('SKIP overflow checks (@xterm/headless not installed)');
|
| 391 |
-
} else {
|
| 392 |
-
const id = await session('resize overflow');
|
| 393 |
-
const v = await view(id, 150, 40, true);
|
| 394 |
-
v.resize(150, 40);
|
| 395 |
-
await sleep(900);
|
| 396 |
-
v.type(`FIXED_LINES=30 node ${FIXTURE}\r`);
|
| 397 |
-
await sleep(2000);
|
| 398 |
-
v.resize(148, 38); // teaches host.repaints = true
|
| 399 |
-
await sleep(1000);
|
| 400 |
-
v.resize(100, 26); // 100% -> 150%: wraps the frame past the screen
|
| 401 |
-
await sleep(2000);
|
| 402 |
-
|
| 403 |
-
const buffer = await v.screenText();
|
| 404 |
-
const counts = new Map();
|
| 405 |
-
for (const m of buffer.matchAll(/\b\d{3}\.\d{2}\b/g)) counts.set(m[0], (counts.get(m[0]) || 0) + 1);
|
| 406 |
-
const values = [...counts.values()];
|
| 407 |
-
const dupes = values.filter((n) => n > 1).length;
|
| 408 |
-
const most = Math.max(0, ...values);
|
| 409 |
-
// 251 with the carry, 371 without it. The gap is the rewrap copy, and 300 sits
|
| 410 |
-
// in it: this fails if the resize path starts archiving a screen again.
|
| 411 |
-
check('an overflowing frame duplicates only what the app itself printed', dupes <= 300,
|
| 412 |
-
`${dupes} duplicated tokens, up to ${most} copies each (app printed 3 frames)`);
|
| 413 |
-
v.close();
|
| 414 |
-
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 415 |
-
}
|
| 416 |
-
|
| 417 |
-
// --- the screen survives a resize the app ignores ------------------------
|
| 418 |
-
// Clearing before the reflow is only safe because the screen is re-painted
|
| 419 |
-
// afterwards. An app that never answers SIGWINCH is the case that proves it:
|
| 420 |
-
// here the fixture teaches the host that this session repaints, then exits, so
|
| 421 |
-
// the shell prompt below it gets the carry treatment while redrawing nothing.
|
| 422 |
{
|
| 423 |
-
const
|
| 424 |
-
|
| 425 |
-
await sleep(
|
| 426 |
-
|
| 427 |
-
await sleep(
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
check('
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
}
|
| 441 |
|
| 442 |
-
//
|
| 443 |
-
// Nothing repaints it, so reflow is the only thing preserving it: the clear
|
| 444 |
-
// must stay off until an app proves it repaints.
|
| 445 |
{
|
| 446 |
-
const id = await session('
|
| 447 |
-
const v = await view(id,
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
await sleep(
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
const text = await gridText(id);
|
| 455 |
-
check('a shell keeps its scrolled-off output across a resize', text.includes('log-line-5'),
|
| 456 |
-
text.includes('log-line-55') ? '' : 'lost recent lines too');
|
| 457 |
-
v.close();
|
| 458 |
-
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
// --- a viewer that only re-asks for the same size costs nothing ----------
|
| 462 |
-
// Tab focus and unrelated layout changes call resync() constantly.
|
| 463 |
-
{
|
| 464 |
-
const { id, v } = await paintedFixture('resize noop');
|
| 465 |
-
const framesBefore = v.countFrames('grid');
|
| 466 |
-
for (let i = 0; i < 5; i++) { v.resize(120, 40); await sleep(120); }
|
| 467 |
-
await sleep(600);
|
| 468 |
-
check('re-requesting the current size changes nothing', v.countFrames('grid') === framesBefore,
|
| 469 |
-
`${v.countFrames('grid') - framesBefore} extra grid frames`);
|
| 470 |
-
check('no duplication from no-op resyncs', duplicateTokens(await gridText(id)) === 0);
|
| 471 |
v.close();
|
| 472 |
-
await
|
| 473 |
}
|
| 474 |
} catch (err) {
|
| 475 |
check('no exceptions', false, String(err && err.message ? err.message : err));
|
| 476 |
-
console.log('--- server log tail ---\n' + log.slice(-
|
| 477 |
} finally {
|
| 478 |
srv.kill('SIGTERM');
|
| 479 |
-
await sleep(
|
| 480 |
srv.kill('SIGKILL');
|
|
|
|
| 481 |
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
| 482 |
process.exit(failures ? 1 : 0);
|
| 483 |
}
|
|
|
|
| 1 |
+
// Invariants for the server-held terminal model.
|
| 2 |
//
|
| 3 |
+
// These tests intentionally avoid classifying foreground applications or
|
| 4 |
+
// counting "acceptable" duplicate rows. The contract is smaller and firmer:
|
| 5 |
+
// Ghostty owns retained state, a resize is ordered ordinary reflow, and exactly
|
| 6 |
+
// one viewer controls input and PTY geometry. Full serialization is attach-only.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
// node resize.test.mjs
|
| 8 |
+
import fs from 'node:fs';
|
| 9 |
+
import os from 'node:os';
|
| 10 |
import { spawn } from 'node:child_process';
|
| 11 |
import path from 'node:path';
|
| 12 |
import { fileURLToPath } from 'node:url';
|
| 13 |
import { WebSocket } from 'ws';
|
| 14 |
|
| 15 |
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 16 |
+
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-resize-'));
|
|
|
|
| 17 |
const PORT = 7895;
|
| 18 |
const CTRL = '\x00\x00AM:';
|
| 19 |
const base = `http://localhost:${PORT}`;
|
|
|
|
| 23 |
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
| 24 |
if (!ok) failures++;
|
| 25 |
};
|
| 26 |
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 27 |
+
const waitFor = async (fn, timeout = 8000) => {
|
| 28 |
+
const until = Date.now() + timeout;
|
| 29 |
+
while (Date.now() < until) {
|
| 30 |
+
if (await fn()) return true;
|
| 31 |
+
await sleep(100);
|
| 32 |
+
}
|
| 33 |
+
return false;
|
| 34 |
+
};
|
| 35 |
|
| 36 |
const srv = spawn('node', ['src/index.js'], {
|
| 37 |
cwd: HERE,
|
|
|
|
| 39 |
stdio: ['ignore', 'pipe', 'pipe'],
|
| 40 |
});
|
| 41 |
let log = '';
|
| 42 |
+
srv.stdout.on('data', (data) => { log += data; });
|
| 43 |
+
srv.stderr.on('data', (data) => { log += data; });
|
| 44 |
|
|
|
|
|
|
|
|
|
|
| 45 |
let Headless = null;
|
| 46 |
try { Headless = (await import('@xterm/headless')).default.Terminal; } catch {}
|
| 47 |
|
| 48 |
function view(id, cols, rows, mirror = false) {
|
| 49 |
return new Promise((resolve) => {
|
| 50 |
const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
const term = mirror && Headless
|
| 52 |
? new Headless({ cols, rows, scrollback: 20000, allowProposedApi: true })
|
| 53 |
: null;
|
| 54 |
const v = {
|
| 55 |
bytes: '', frames: [], term,
|
| 56 |
+
type: (data) => ws.send(JSON.stringify({ t: 'i', d: data })),
|
| 57 |
+
resize: (nextCols, nextRows) => ws.send(JSON.stringify({ t: 'r', cols: nextCols, rows: nextRows })),
|
| 58 |
+
claim: () => ws.send(JSON.stringify({ t: 'claim' })),
|
| 59 |
+
lastFrame: (type) => [...v.frames].reverse().find((frame) => frame.t === type) || null,
|
| 60 |
+
countFrames: (type) => v.frames.filter((frame) => frame.t === type).length,
|
| 61 |
+
open: () => ws.readyState === WebSocket.OPEN,
|
| 62 |
close: () => { try { ws.close(); } catch {} },
|
|
|
|
| 63 |
screenText: async () => {
|
| 64 |
if (!term) return '';
|
| 65 |
+
await new Promise((done) => term.write('', done));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
const buf = term.buffer.active;
|
| 67 |
+
const lines = [];
|
| 68 |
+
for (let i = 0; i < buf.length; i++) lines.push(buf.getLine(i)?.translateToString(true) ?? '');
|
| 69 |
+
return lines.join('\n');
|
| 70 |
},
|
| 71 |
};
|
| 72 |
ws.on('message', (data) => {
|
| 73 |
+
const text = data.toString('utf8');
|
| 74 |
+
if (text.startsWith(CTRL)) {
|
| 75 |
+
let frame;
|
| 76 |
+
try { frame = JSON.parse(text.slice(CTRL.length)); } catch { return; }
|
| 77 |
+
v.frames.push(frame);
|
| 78 |
+
if (term && (frame.t === 'grid' || frame.t === 'restore')) {
|
| 79 |
+
const applyGrid = () => {
|
| 80 |
+
if (frame.reset) { term.reset(); term.clear(); }
|
| 81 |
+
if (frame.cols > 0 && frame.rows > 0
|
| 82 |
+
&& (term.cols !== frame.cols || term.rows !== frame.rows)) {
|
| 83 |
+
term.resize(frame.cols, frame.rows);
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
const geometryChanged = frame.cols > 0 && frame.rows > 0
|
| 87 |
+
&& (term.cols !== frame.cols || term.rows !== frame.rows);
|
| 88 |
+
if (frame.reset || geometryChanged) term.write('', applyGrid);
|
| 89 |
+
else applyGrid();
|
| 90 |
}
|
| 91 |
} else {
|
| 92 |
+
v.bytes += text;
|
| 93 |
+
if (term) term.write(text);
|
| 94 |
}
|
| 95 |
});
|
| 96 |
ws.on('open', () => resolve(v));
|
|
|
|
| 98 |
});
|
| 99 |
}
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
async function session(name) {
|
| 102 |
const created = await (await fetch(`${base}/api/sessions`, {
|
| 103 |
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
|
|
| 107 |
return created.id;
|
| 108 |
}
|
| 109 |
|
| 110 |
+
const gridText = async (id, lines = 2000) => {
|
| 111 |
+
const response = await fetch(`${base}/api/agents/${id}/tail?lines=${lines}`);
|
| 112 |
+
const body = await response.json();
|
| 113 |
+
return typeof body === 'string' ? body : (body.text || body.tail || '');
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const stop = async (id) => {
|
| 117 |
+
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
|
| 118 |
+
await sleep(250);
|
| 119 |
+
};
|
| 120 |
|
| 121 |
try {
|
| 122 |
+
const up = await waitFor(async () => {
|
| 123 |
+
try { return (await fetch(`${base}/api/health`)).ok; } catch { return false; }
|
| 124 |
+
}, 100_000);
|
| 125 |
+
if (!up) throw new Error(`server never came up:\n${log.slice(-1200)}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
// A long logical line must survive ordinary Ghostty reflow. The former
|
| 128 |
+
// screen-carry path cropped the right side before rebuilding a narrower grid.
|
| 129 |
{
|
| 130 |
+
const id = await session('long line reflow');
|
| 131 |
+
const v = await view(id, 110, 24, true);
|
| 132 |
+
await sleep(700);
|
| 133 |
+
v.type(`printf 'LONG-%s-END-SENTINEL\\n' "$(printf 'x%.0s' {1..90})"\r`);
|
| 134 |
+
const printed = await waitFor(async () => (await gridText(id)).includes('END-SENTINEL'));
|
| 135 |
+
check('long line is present before resize', printed);
|
| 136 |
+
v.resize(60, 24);
|
| 137 |
+
const resized = await waitFor(() => v.lastFrame('grid')?.cols === 60);
|
| 138 |
+
check('resize settles at the requested geometry', resized, JSON.stringify(v.lastFrame('grid')));
|
| 139 |
+
const after = await gridText(id);
|
| 140 |
+
check('narrowing does not crop the right side of a logical line', after.includes('END-SENTINEL'));
|
| 141 |
+
check('resize does not retransmit all history', v.lastFrame('grid')?.reset === false);
|
| 142 |
+
if (Headless) check('the ordered viewer reflow also retains the line', (await v.screenText()).includes('END-SENTINEL'));
|
| 143 |
v.close();
|
| 144 |
+
await stop(id);
|
| 145 |
}
|
| 146 |
|
| 147 |
+
// A drag should issue one final resize, not one SIGWINCH per animation frame.
|
| 148 |
{
|
| 149 |
+
const id = await session('resize storm');
|
| 150 |
+
const v = await view(id, 120, 40);
|
| 151 |
+
await sleep(400);
|
| 152 |
for (let i = 0; i < 16; i++) {
|
| 153 |
v.resize(120 - i * 2, 40 - i);
|
| 154 |
+
await sleep(25);
|
| 155 |
}
|
| 156 |
v.resize(88, 24);
|
| 157 |
+
const settled = await waitFor(() => v.lastFrame('grid')?.cols === 88 && v.lastFrame('grid')?.rows === 24);
|
| 158 |
+
check('resize storm settles at its final size', settled, JSON.stringify(v.lastFrame('grid')));
|
| 159 |
+
check('resize storm is coalesced', v.countFrames('grid') <= 3, `${v.countFrames('grid')} grid frames`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
v.close();
|
| 161 |
+
await stop(id);
|
| 162 |
}
|
| 163 |
|
| 164 |
+
// Reattachment is serialized from Ghostty state, not from a 256 KiB suffix
|
| 165 |
+
// of raw PTY traffic. Both the oldest and newest retained rows must return.
|
|
|
|
|
|
|
| 166 |
{
|
| 167 |
+
const id = await session('full canonical restore');
|
| 168 |
+
const a = await view(id, 110, 30);
|
| 169 |
+
await sleep(600);
|
| 170 |
+
a.type(`for i in $(seq 1 4500); do printf 'RESTORE-%04d-%070d\\n' "$i" "$i"; done\r`);
|
| 171 |
+
const complete = await waitFor(async () => (await gridText(id, 5000)).includes('RESTORE-4500-'), 20_000);
|
| 172 |
+
check('large history finishes printing', complete);
|
| 173 |
+
a.close();
|
| 174 |
+
await sleep(300);
|
| 175 |
+
const b = await view(id, 110, 30, true);
|
| 176 |
+
const restored = await waitFor(() => b.bytes.includes('RESTORE-4500-'), 12_000);
|
| 177 |
+
check('reattach receives the canonical restore', restored && !!b.lastFrame('restore'));
|
| 178 |
+
check('restore is not capped at the old raw replay limit', b.bytes.length > 262_144, `${b.bytes.length} bytes`);
|
| 179 |
+
check('restore includes the oldest retained output', b.bytes.includes('RESTORE-0001-'));
|
| 180 |
+
check('restore includes the newest retained output', b.bytes.includes('RESTORE-4500-'));
|
| 181 |
+
if (Headless) {
|
| 182 |
+
const rendered = await b.screenText();
|
| 183 |
+
check('a fresh emulator can scroll to the oldest restored output', rendered.includes('RESTORE-0001-'));
|
| 184 |
+
check('a fresh emulator contains the newest restored output', rendered.includes('RESTORE-4500-'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
+
b.close();
|
| 187 |
+
await stop(id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
}
|
| 189 |
|
| 190 |
+
// One viewer owns both input and size. A watcher has no effect until an
|
| 191 |
+
// explicit claim, after which the old controller becomes inert.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
{
|
| 193 |
+
const id = await session('controller lease');
|
| 194 |
+
const a = await view(id, 120, 35);
|
| 195 |
+
await sleep(400);
|
| 196 |
+
const b = await view(id, 60, 20);
|
| 197 |
+
await sleep(400);
|
| 198 |
+
check('first viewer is the controller', a.lastFrame('grid')?.controller === true
|
| 199 |
+
&& b.lastFrame('restore')?.controller === false);
|
| 200 |
+
b.resize(50, 15);
|
| 201 |
+
await sleep(400);
|
| 202 |
+
check('watcher size cannot shrink the session', b.lastFrame('restore')?.cols === 120
|
| 203 |
+
&& !b.frames.some((frame) => frame.t === 'grid' && frame.cols === 50));
|
| 204 |
+
b.type('echo WATCHER-MUST-NOT-RUN\r');
|
| 205 |
+
await sleep(400);
|
| 206 |
+
check('watcher input is rejected by the server', !a.bytes.includes('WATCHER-MUST-NOT-RUN'));
|
| 207 |
+
|
| 208 |
+
b.claim();
|
| 209 |
+
const claimed = await waitFor(() => b.lastFrame('grid')?.controller === true
|
| 210 |
+
&& b.lastFrame('grid')?.cols === 50 && b.lastFrame('grid')?.rows === 15);
|
| 211 |
+
check('watcher can claim input and geometry together', claimed, JSON.stringify(b.lastFrame('grid')));
|
| 212 |
+
b.type('echo CONTROLLER-RAN\r');
|
| 213 |
+
check('new controller input reaches every viewer', await waitFor(() => a.bytes.includes('CONTROLLER-RAN')
|
| 214 |
+
&& b.bytes.includes('CONTROLLER-RAN')));
|
| 215 |
+
a.resize(140, 45);
|
| 216 |
+
await sleep(400);
|
| 217 |
+
check('old controller becomes an inert watcher', b.lastFrame('grid')?.cols === 50
|
| 218 |
+
&& b.lastFrame('grid')?.rows === 15);
|
| 219 |
+
|
| 220 |
+
b.close();
|
| 221 |
+
const handed = await waitFor(() => a.lastFrame('grid')?.controller === true
|
| 222 |
+
&& a.lastFrame('grid')?.cols === 140 && a.lastFrame('grid')?.rows === 45);
|
| 223 |
+
check('controller lease passes to a remaining viewer on disconnect', handed, JSON.stringify(a.lastFrame('grid')));
|
| 224 |
+
a.close();
|
| 225 |
+
await stop(id);
|
| 226 |
}
|
| 227 |
|
| 228 |
+
// Focus and unrelated layout events may re-report the same preference.
|
|
|
|
|
|
|
| 229 |
{
|
| 230 |
+
const id = await session('no-op size');
|
| 231 |
+
const v = await view(id, 80, 24);
|
| 232 |
+
await sleep(300);
|
| 233 |
+
const before = v.countFrames('grid');
|
| 234 |
+
for (let i = 0; i < 5; i++) v.resize(80, 24);
|
| 235 |
+
await sleep(500);
|
| 236 |
+
check('re-reporting the current size performs no reset', v.countFrames('grid') === before,
|
| 237 |
+
`${v.countFrames('grid') - before} extra frames`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
v.close();
|
| 239 |
+
await stop(id);
|
| 240 |
}
|
| 241 |
} catch (err) {
|
| 242 |
check('no exceptions', false, String(err && err.message ? err.message : err));
|
| 243 |
+
console.log('--- server log tail ---\n' + log.slice(-1600));
|
| 244 |
} finally {
|
| 245 |
srv.kill('SIGTERM');
|
| 246 |
+
await sleep(500);
|
| 247 |
srv.kill('SIGKILL');
|
| 248 |
+
try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch {}
|
| 249 |
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
| 250 |
process.exit(failures ? 1 : 0);
|
| 251 |
}
|
server/src/demo.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
// Demo mode: a pure view filter. Activating snapshots the session and group IDs
|
| 2 |
// that exist right now and hides them from the sidebar/overview, so the Space
|
| 3 |
// reads like a fresh install (empty workspace + welcome). Nothing is deleted —
|
| 4 |
-
//
|
| 5 |
// created *after* activation shows through (it isn't in the snapshot).
|
| 6 |
// Deactivating clears the snapshot and every real session reappears untouched.
|
| 7 |
import fs from 'fs';
|
|
|
|
| 1 |
// Demo mode: a pure view filter. Activating snapshots the session and group IDs
|
| 2 |
// that exist right now and hides them from the sidebar/overview, so the Space
|
| 3 |
// reads like a fresh install (empty workspace + welcome). Nothing is deleted —
|
| 4 |
+
// live sessions keep running, logins and secrets stay valid — and anything
|
| 5 |
// created *after* activation shows through (it isn't in the snapshot).
|
| 6 |
// Deactivating clears the snapshot and every real session reappears untouched.
|
| 7 |
import fs from 'fs';
|
server/src/index.js
CHANGED
|
@@ -259,8 +259,8 @@ async function deliver(session, text, from) {
|
|
| 259 |
}
|
| 260 |
|
| 261 |
// Type a prompt into a session's terminal from the Overview — no pane needed.
|
| 262 |
-
// If the agent is stopped,
|
| 263 |
-
//
|
| 264 |
app.post('/api/sessions/:id/input', async (req, res) => {
|
| 265 |
const s = store.get(req.params.id);
|
| 266 |
if (!s) return res.status(404).json({ error: 'not found' });
|
|
@@ -278,9 +278,8 @@ app.post('/api/sessions/:id/input', async (req, res) => {
|
|
| 278 |
// ---------- agent-to-agent API (/api/agents) ----------
|
| 279 |
// Agents coordinate through the same primitives the operator drives from the
|
| 280 |
// Overview: read the roster, watch a pane, send a prompt, wait, launch a peer,
|
| 281 |
-
// stop one. This
|
| 282 |
-
//
|
| 283 |
-
// attributed, and stable enough to document in the environment skill.
|
| 284 |
//
|
| 285 |
// The etiquette (check state before interrupting, don't ping-pong, don't stop
|
| 286 |
// someone unasked, don't spawn armies) is TAUGHT in that skill rather than
|
|
@@ -984,7 +983,7 @@ Hermes — alongside plain shells and a file browser.
|
|
| 984 |
## What persists (and what doesn't)
|
| 985 |
- \`/data\` is **durable storage** (a mounted bucket). Files under \`/data/workspaces/…\` and \`/data/home/…\` survive restarts and sleep.
|
| 986 |
- **Empty directories are not persisted** — only files. If a folder must exist, keep a file in it.
|
| 987 |
-
- Sessions are
|
| 988 |
- Exception: OpenClaw runs with its own \`$HOME\` on local disk for filesystem compatibility; that state is backed up to the bucket every minute.
|
| 989 |
|
| 990 |
## You may not be alone
|
|
@@ -2153,35 +2152,30 @@ wss.on('connection', (ws, req) => {
|
|
| 2153 |
if (d.length) ws.send(d);
|
| 2154 |
});
|
| 2155 |
handle.onExit(() => {
|
| 2156 |
-
// Only ONE reason to close now: the agent process itself exited.
|
| 2157 |
-
//
|
| 2158 |
-
//
|
| 2159 |
-
// or we would respawn the agent in a loop.
|
| 2160 |
if (ws.readyState === ws.OPEN) {
|
| 2161 |
try { ws.close(4000, 'exited'); } catch { ws.close(); }
|
| 2162 |
}
|
| 2163 |
});
|
| 2164 |
-
//
|
| 2165 |
-
//
|
| 2166 |
-
// `
|
| 2167 |
-
|
| 2168 |
-
// copy of the screen that ours deliberately did not.
|
| 2169 |
-
handle.onGrid((cols_, rows_, shared, viewers, clear) => {
|
| 2170 |
if (ws.readyState !== ws.OPEN) return;
|
| 2171 |
-
try { ws.send(TERM_CTRL + JSON.stringify({ t: 'grid', cols: cols_, rows: rows_,
|
| 2172 |
});
|
| 2173 |
|
| 2174 |
-
//
|
| 2175 |
-
//
|
| 2176 |
-
// agent's TUI — which is why this works even while it sits idle.
|
| 2177 |
const restore = handle.restore();
|
| 2178 |
if (restore) {
|
| 2179 |
try {
|
| 2180 |
ws.send(TERM_CTRL + JSON.stringify({
|
| 2181 |
t: 'restore', cols: restore.cols, rows: restore.rows,
|
| 2182 |
-
viewers: restore.viewers,
|
| 2183 |
}));
|
| 2184 |
-
if (restore.replay) ws.send(restore.replay);
|
| 2185 |
ws.send(restore.ansi);
|
| 2186 |
} catch {}
|
| 2187 |
}
|
|
@@ -2191,8 +2185,7 @@ wss.on('connection', (ws, req) => {
|
|
| 2191 |
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
| 2192 |
if (msg.t === 'i') handle.write(msg.d);
|
| 2193 |
else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
|
| 2194 |
-
|
| 2195 |
-
// attach), so a selection is local and needs no round trip to tmux.
|
| 2196 |
});
|
| 2197 |
|
| 2198 |
// Detaching a viewer, NOT stopping the session.
|
|
|
|
| 259 |
}
|
| 260 |
|
| 261 |
// Type a prompt into a session's terminal from the Overview — no pane needed.
|
| 262 |
+
// If the agent is stopped, start its backend PTY and give the resumed CLI a
|
| 263 |
+
// moment to boot before the keystrokes land.
|
| 264 |
app.post('/api/sessions/:id/input', async (req, res) => {
|
| 265 |
const s = store.get(req.params.id);
|
| 266 |
if (!s) return res.status(404).json({ error: 'not found' });
|
|
|
|
| 278 |
// ---------- agent-to-agent API (/api/agents) ----------
|
| 279 |
// Agents coordinate through the same primitives the operator drives from the
|
| 280 |
// Overview: read the roster, watch a pane, send a prompt, wait, launch a peer,
|
| 281 |
+
// stop one. This makes container-local coordination legible, attributed, and
|
| 282 |
+
// stable enough to document in the environment skill.
|
|
|
|
| 283 |
//
|
| 284 |
// The etiquette (check state before interrupting, don't ping-pong, don't stop
|
| 285 |
// someone unasked, don't spawn armies) is TAUGHT in that skill rather than
|
|
|
|
| 983 |
## What persists (and what doesn't)
|
| 984 |
- \`/data\` is **durable storage** (a mounted bucket). Files under \`/data/workspaces/…\` and \`/data/home/…\` survive restarts and sleep.
|
| 985 |
- **Empty directories are not persisted** — only files. If a folder must exist, keep a file in it.
|
| 986 |
+
- Sessions are held by the Agent Manager backend and keep running when the browser disconnects. A backend restart or Space sleep ends live processes; retained workspace files still persist.
|
| 987 |
- Exception: OpenClaw runs with its own \`$HOME\` on local disk for filesystem compatibility; that state is backed up to the bucket every minute.
|
| 988 |
|
| 989 |
## You may not be alone
|
|
|
|
| 2152 |
if (d.length) ws.send(d);
|
| 2153 |
});
|
| 2154 |
handle.onExit(() => {
|
| 2155 |
+
// Only ONE reason to close now: the agent process itself exited. A second
|
| 2156 |
+
// viewer does not detach the first; it starts as a watcher instead. Still no
|
| 2157 |
+
// auto-reconnect on 4000, or we would respawn the agent in a loop.
|
|
|
|
| 2158 |
if (ws.readyState === ws.OPEN) {
|
| 2159 |
try { ws.close(4000, 'exited'); } catch { ws.close(); }
|
| 2160 |
}
|
| 2161 |
});
|
| 2162 |
+
// A session has one canonical grid and one viewer controls its dimensions.
|
| 2163 |
+
// Watchers still report their preferred size so taking control is immediate.
|
| 2164 |
+
// `reset` means an authoritative Ghostty snapshot follows this frame.
|
| 2165 |
+
handle.onGrid((cols_, rows_, controller, viewers, reset) => {
|
|
|
|
|
|
|
| 2166 |
if (ws.readyState !== ws.OPEN) return;
|
| 2167 |
+
try { ws.send(TERM_CTRL + JSON.stringify({ t: 'grid', cols: cols_, rows: rows_, controller, viewers, reset })); } catch {}
|
| 2168 |
});
|
| 2169 |
|
| 2170 |
+
// Ghostty owns the durable terminal model. Reattachment receives one canonical
|
| 2171 |
+
// serialization of its retained scrollback and current styled screen.
|
|
|
|
| 2172 |
const restore = handle.restore();
|
| 2173 |
if (restore) {
|
| 2174 |
try {
|
| 2175 |
ws.send(TERM_CTRL + JSON.stringify({
|
| 2176 |
t: 'restore', cols: restore.cols, rows: restore.rows,
|
| 2177 |
+
viewers: restore.viewers, controller: restore.controller, reset: true,
|
| 2178 |
}));
|
|
|
|
| 2179 |
ws.send(restore.ansi);
|
| 2180 |
} catch {}
|
| 2181 |
}
|
|
|
|
| 2185 |
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
| 2186 |
if (msg.t === 'i') handle.write(msg.d);
|
| 2187 |
else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
|
| 2188 |
+
else if (msg.t === 'claim') handle.claim();
|
|
|
|
| 2189 |
});
|
| 2190 |
|
| 2191 |
// Detaching a viewer, NOT stopping the session.
|
server/src/runner.js
CHANGED
|
@@ -6,7 +6,7 @@ import { remoteState, setPaused } from './remote.js';
|
|
| 6 |
import { cliById, WORKSPACES_DIR, isRemote } from './config.js';
|
| 7 |
import { update, list } from './sessions.js';
|
| 8 |
import { captureOpencodeSession } from './traces.js';
|
| 9 |
-
import { buildPaletteIndex,
|
| 10 |
|
| 11 |
// libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
|
| 12 |
// is guarded so a platform without a prebuilt still boots and says so, rather
|
|
@@ -40,10 +40,10 @@ const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
|
|
| 40 |
// Every session is one PTY held by THIS process, with a libghostty-vt terminal
|
| 41 |
// fed from its output. That terminal is the authoritative screen, so:
|
| 42 |
//
|
| 43 |
-
// * a browser attaching gets
|
| 44 |
// instead of asking tmux to redraw and hoping the agent's TUI cooperates;
|
| 45 |
-
// * several browsers can watch
|
| 46 |
-
// one-device-at-a-time
|
| 47 |
// * agent state is a property read rather than a `tmux capture-pane`
|
| 48 |
// subprocess per session per poll.
|
| 49 |
//
|
|
@@ -55,28 +55,20 @@ const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
|
|
| 55 |
const BUSY_SECS = 4;
|
| 56 |
// Re-rendering the grid to text on every chunk during a burst is wasteful.
|
| 57 |
const SAMPLE_THROTTLE_MS = 250;
|
| 58 |
-
//
|
| 59 |
-
//
|
| 60 |
-
//
|
| 61 |
-
const
|
| 62 |
-
const
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
| 68 |
const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
|
| 69 |
-
// Escape hatch for carryScreen: with AM_RESIZE_CARRY=0 a resize reflows the way a
|
| 70 |
-
// plain terminal does, duplication and all. Kept because carrying the screen by
|
| 71 |
-
// hand is the one part of a resize that makes an assumption about the app.
|
| 72 |
-
const RESIZE_CARRY = process.env.AM_RESIZE_CARRY !== '0';
|
| 73 |
-
// How long an app gets to answer SIGWINCH before the rows a shrink pushed off the
|
| 74 |
-
// screen are treated as history rather than as a copy (see settleArchive).
|
| 75 |
-
// Generous on purpose: an agent TUI that debounces SIGWINCH must not be mistaken
|
| 76 |
-
// for one that ignored it, and waiting costs nothing a user can see because the
|
| 77 |
-
// rows in question are off-screen either way. A repaint that arrives sooner ends
|
| 78 |
-
// the wait immediately.
|
| 79 |
-
const ARCHIVE_SETTLE_MS = Number(process.env.AM_RESIZE_ARCHIVE_MS || 700);
|
| 80 |
|
| 81 |
const hosts = new Map(); // session id -> host
|
| 82 |
|
|
@@ -149,220 +141,51 @@ export function peek(id) {
|
|
| 149 |
|
| 150 |
// ---------- the shared grid ----------
|
| 151 |
//
|
| 152 |
-
//
|
| 153 |
-
//
|
| 154 |
-
//
|
| 155 |
-
//
|
| 156 |
-
//
|
| 157 |
|
| 158 |
function effectiveGrid(host) {
|
| 159 |
-
|
| 160 |
-
let cols = Infinity;
|
| 161 |
-
let rows = Infinity;
|
| 162 |
-
for (const s of host.sizes.values()) {
|
| 163 |
-
cols = Math.min(cols, s.cols);
|
| 164 |
-
rows = Math.min(rows, s.rows);
|
| 165 |
-
}
|
| 166 |
-
return { cols, rows };
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
/**
|
| 170 |
-
* Re-frame a snapshot for a different geometry, so it can be painted into one.
|
| 171 |
-
*
|
| 172 |
-
* BOTTOM-anchored, in both directions, because that is what the emulator does on
|
| 173 |
-
* its own. Growing the screen pulls history back DOWN out of scrollback to fill
|
| 174 |
-
* the new rows from the top (measured: 12 rows -> 30 brought 18 lines back), so a
|
| 175 |
-
* top-anchored paint lands right on top of them — which destroyed the history and
|
| 176 |
-
* left the old screen stranded above the app's fresh repaint. Anchoring at the
|
| 177 |
-
* bottom puts the carried screen exactly where the emulator already put it, with
|
| 178 |
-
* the recovered history above it untouched.
|
| 179 |
-
*
|
| 180 |
-
* On a shrink the rows that fall off the top are not in here at all —
|
| 181 |
-
* settleArchive decides what becomes of those.
|
| 182 |
-
*/
|
| 183 |
-
function fitSnapshot(snap, cols, rows) {
|
| 184 |
-
const shift = snap.rows - rows; // > 0 shrinking, < 0 growing
|
| 185 |
-
return {
|
| 186 |
-
cols,
|
| 187 |
-
rows,
|
| 188 |
-
isAltScreen: snap.isAltScreen,
|
| 189 |
-
cursorRow: Math.max(0, Math.min(rows - 1, snap.cursorRow - shift)),
|
| 190 |
-
cursorCol: Math.max(0, Math.min(cols - 1, snap.cursorCol)),
|
| 191 |
-
cells: (snap.cells || [])
|
| 192 |
-
.filter((c) => c.row - shift >= 0 && c.row - shift < rows && c.col < cols)
|
| 193 |
-
.map((c) => (shift ? { ...c, row: c.row - shift } : c)),
|
| 194 |
-
};
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
/**
|
| 198 |
-
* Carry the current screen across a resize by hand, instead of letting reflow do
|
| 199 |
-
* it — the whole reason a resize duplicated scrollback.
|
| 200 |
-
*
|
| 201 |
-
* Reflow is not wrong, it is just redundant here: narrowing rewraps every row of
|
| 202 |
-
* the outgoing screen (a 119-column row becomes two at 110), which scrolls the
|
| 203 |
-
* excess up into scrollback — and then the app repaints that same screen, so the
|
| 204 |
-
* rewrapped copy is left above it forever. Six zoom clicks, six copies.
|
| 205 |
-
*
|
| 206 |
-
* So the screen is cleared BEFORE the reflow, which leaves it nothing to archive,
|
| 207 |
-
* and re-painted after: snapshotToAnsi positions every row absolutely and emits
|
| 208 |
-
* no newline, so the paint itself cannot wrap or scroll either.
|
| 209 |
-
*
|
| 210 |
-
* The rows that no longer fit cannot be settled here, which took two tries to
|
| 211 |
-
* see. Dropping them ate real history — a 40->24 shrink lost 16 lines a user
|
| 212 |
-
* could previously scroll back to. Archiving them brought the duplication
|
| 213 |
-
* straight back, because for a repainting TUI those rows ARE the screen it is
|
| 214 |
-
* about to reprint. Whether they are redundant depends on what the app does
|
| 215 |
-
* NEXT, so they are handed to settleArchive to decide once that is known.
|
| 216 |
-
*
|
| 217 |
-
* Returns { pre, ansi, dropped }: bytes for before the resize and after it (both
|
| 218 |
-
* of which every viewer needs too) plus the rows in limbo. Null when reflow
|
| 219 |
-
* should be left alone (see host.repaints).
|
| 220 |
-
*/
|
| 221 |
-
function carryScreen(host, cols, rows) {
|
| 222 |
-
if (!RESIZE_CARRY || !host.repaints) return null;
|
| 223 |
-
let snap;
|
| 224 |
-
let ansi;
|
| 225 |
-
try {
|
| 226 |
-
snap = host.vt.snapshot({ includeCells: true });
|
| 227 |
-
// Rows only, no erase: on a grow the emulator has already refilled the top of
|
| 228 |
-
// the screen from scrollback, and that history has to survive this paint.
|
| 229 |
-
ansi = snapshotToRows(fitSnapshot(snap, cols, rows));
|
| 230 |
-
} catch { return null; }
|
| 231 |
-
|
| 232 |
-
const drop = Math.max(0, snap.rows - rows);
|
| 233 |
-
const pre = '\x1b[0m\x1b[H\x1b[2J';
|
| 234 |
-
try { host.vt.feed(pre); } catch { return null; }
|
| 235 |
-
return { pre, ansi, dropped: drop ? rowsToAnsi(snap, 0, drop) : [] };
|
| 236 |
-
}
|
| 237 |
-
|
| 238 |
-
/**
|
| 239 |
-
* Put lines into scrollback without disturbing the screen.
|
| 240 |
-
*
|
| 241 |
-
* Printing is the only way in — there is no API for "append to history" — so the
|
| 242 |
-
* lines are printed at the top and then pushed off it, and the screen is painted
|
| 243 |
-
* back from the grid afterwards. Costs one repaint, which is why it only happens
|
| 244 |
-
* when nothing else repainted.
|
| 245 |
-
*/
|
| 246 |
-
function archiveLines(host, lines) {
|
| 247 |
-
let restore;
|
| 248 |
-
try { restore = snapshotToAnsi(host.vt.snapshot({ includeCells: true })); } catch { return; }
|
| 249 |
-
let bytes = '\x1b[0m\x1b[H\x1b[2J\x1b[1;1H' + lines.join('\r\n');
|
| 250 |
-
// A newline on the LAST row is what moves a row into scrollback, and it moves
|
| 251 |
-
// the top row — not the one just printed. Enough of them to clear the screen
|
| 252 |
-
// archives every line, whether they all fitted on it or not.
|
| 253 |
-
bytes += `\x1b[0m\x1b[${host.rows};1H` + '\r\n'.repeat(Math.min(lines.length, host.rows));
|
| 254 |
-
bytes += restore;
|
| 255 |
-
try { host.vt.feed(bytes); } catch { return; }
|
| 256 |
-
for (const sub of host.subs) sub.onData(bytes);
|
| 257 |
}
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
* reprinting its screen, so those rows are about to appear again and archiving
|
| 264 |
-
* them is what filled the scrollback with duplicates. Silence means they were the
|
| 265 |
-
* only copy — a shell's log, a TUI that exited, an agent sitting idle — and
|
| 266 |
-
* dropping them is what stopped a pane from scrolling all the way up.
|
| 267 |
-
*
|
| 268 |
-
* Deciding afterwards is the point: at resize time this is not yet knowable, and
|
| 269 |
-
* both guesses are wrong for somebody. Waiting costs nothing visible — the rows in
|
| 270 |
-
* question are off-screen either way — so the window is generous rather than tight,
|
| 271 |
-
* because an agent TUI that debounces SIGWINCH must not be mistaken for one that
|
| 272 |
-
* ignored it. A repaint that does arrive cancels this immediately (see onData).
|
| 273 |
-
*
|
| 274 |
-
* Only a session that stayed silent through TWO resizes stops being treated as
|
| 275 |
-
* repainting: one slow frame would otherwise drop a Claude pane onto the plain
|
| 276 |
-
* reflow path for good, which is the duplication this all started with.
|
| 277 |
-
*/
|
| 278 |
-
function settleArchive(host) {
|
| 279 |
-
host.archiveTimer = null;
|
| 280 |
-
const pending = host.pendingArchive;
|
| 281 |
-
host.pendingArchive = null;
|
| 282 |
-
if (!pending) return;
|
| 283 |
-
host.silentResizes += 1;
|
| 284 |
-
if (host.silentResizes >= 2) host.repaints = false;
|
| 285 |
-
if (pending.length) archiveLines(host, pending);
|
| 286 |
}
|
| 287 |
|
| 288 |
/**
|
| 289 |
* Move the session to the size its viewers imply.
|
| 290 |
*
|
| 291 |
-
*
|
| 292 |
-
*
|
| 293 |
-
*
|
| 294 |
-
*
|
| 295 |
-
*
|
| 296 |
-
* browser's own reflow of its byte log is guaranteed to disagree with us, and an
|
| 297 |
-
* app that doesn't repaint on SIGWINCH at all (a bash prompt, an agent sitting
|
| 298 |
-
* idle) would otherwise leave that disagreement on screen until it next drew
|
| 299 |
-
* something. Repainting from the grid costs one screen of bytes and makes the
|
| 300 |
-
* pane authoritative again — the same argument as the repaint on attach.
|
| 301 |
*/
|
| 302 |
function applyGrid(host) {
|
| 303 |
const { cols, rows } = effectiveGrid(host);
|
| 304 |
if (cols === host.cols && rows === host.rows) return false;
|
| 305 |
-
// Only a screen that gets SMALLER in some dimension archives anything: rows are
|
| 306 |
-
// pushed off the top, or a narrower width rewraps them until they overflow. A
|
| 307 |
-
// screen that only grows takes rows back out of scrollback instead, and needs no
|
| 308 |
-
// help doing it — interfering there is what painted over the history a zoom-out
|
| 309 |
-
// had just recovered. So growing is left entirely alone, right down to not
|
| 310 |
-
// repainting: each emulator recovers its own rows, and the app's own repaint (or
|
| 311 |
-
// the next attach) settles any difference.
|
| 312 |
-
const shrinking = cols < host.cols || rows < host.rows;
|
| 313 |
host.cols = cols;
|
| 314 |
host.rows = rows;
|
| 315 |
-
const carried = shrinking ? carryScreen(host, cols, rows) : null;
|
| 316 |
-
// Viewers get the erase BEFORE they resize, in the same order the grid saw it,
|
| 317 |
-
// so their emulators skip the same reflow ours did.
|
| 318 |
-
if (carried) for (const sub of host.subs) sub.onData(carried.pre);
|
| 319 |
try { host.vt.resize(cols, rows); } catch {}
|
| 320 |
try { host.pty.resize(cols, rows); } catch {}
|
| 321 |
-
host
|
| 322 |
-
host.resizeBytes = 0;
|
| 323 |
-
if (carried) {
|
| 324 |
-
// Painted straight away, not deferred. Holding it back until the app had its
|
| 325 |
-
// say was tried, on the theory that an app reprinting its own frame makes our
|
| 326 |
-
// copy redundant — measured, it changes nothing, because such an app erases
|
| 327 |
-
// the screen before it prints. All deferring bought was a blank pane.
|
| 328 |
-
try { host.vt.feed(carried.ansi); } catch {}
|
| 329 |
-
for (const sub of host.subs) sub.onData(carried.ansi);
|
| 330 |
-
// Oldest first: a second resize before the verdict lands adds to the same
|
| 331 |
-
// batch rather than replacing it.
|
| 332 |
-
host.pendingArchive = [...(host.pendingArchive || []), ...carried.dropped];
|
| 333 |
-
if (host.archiveTimer) clearTimeout(host.archiveTimer);
|
| 334 |
-
host.archiveTimer = setTimeout(() => settleArchive(host), ARCHIVE_SETTLE_MS);
|
| 335 |
-
if (host.archiveTimer.unref) host.archiveTimer.unref();
|
| 336 |
-
}
|
| 337 |
-
let ansi = null;
|
| 338 |
-
if (!carried && shrinking) { try { ansi = snapshotToAnsi(host.vt.snapshot({ includeCells: true })); } catch {} }
|
| 339 |
-
for (const sub of host.subs) {
|
| 340 |
-
// `carried` doubles as the flag: the viewer's own emulator has to skip its
|
| 341 |
-
// reflow exactly when we skipped ours, or it archives what we did not — and it
|
| 342 |
-
// must not resize until the bytes above have been parsed at the OLD size.
|
| 343 |
-
sub.onGrid(cols, rows, host.subs.size > 1, host.subs.size, !!carried);
|
| 344 |
-
if (ansi) sub.onData(ansi);
|
| 345 |
-
}
|
| 346 |
return true;
|
| 347 |
}
|
| 348 |
|
| 349 |
/**
|
| 350 |
-
* Apply the grid once
|
| 351 |
-
*
|
| 352 |
-
* phone) is still answered with the size that actually applies — a client that
|
| 353 |
-
* hears nothing back would sit there drawing into a geometry it doesn't have.
|
| 354 |
*/
|
| 355 |
-
function scheduleGrid(host
|
| 356 |
-
if (sub) host.pendingSizes.add(sub);
|
| 357 |
if (host.gridTimer) clearTimeout(host.gridTimer);
|
| 358 |
host.gridTimer = setTimeout(() => {
|
| 359 |
host.gridTimer = null;
|
| 360 |
-
|
| 361 |
-
host.pendingSizes.clear();
|
| 362 |
-
if (applyGrid(host)) return; // applyGrid told everyone
|
| 363 |
-
for (const s of pending) {
|
| 364 |
-
if (host.subs.has(s)) s.onGrid(host.cols, host.rows, host.subs.size > 1, host.subs.size);
|
| 365 |
-
}
|
| 366 |
}, RESIZE_SETTLE_MS);
|
| 367 |
if (host.gridTimer.unref) host.gridTimer.unref();
|
| 368 |
}
|
|
@@ -855,33 +678,16 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 855 |
const term = pty.spawn('bash', ['-lc', full], {
|
| 856 |
name: 'xterm-256color', cols, rows, cwd: workdir, env,
|
| 857 |
});
|
| 858 |
-
const vt = ghostty.createTerminal({ cols, rows, scrollbackLimit:
|
| 859 |
-
const isShell = (cliById(session.cli) || cliById('shell')).id === 'shell';
|
| 860 |
-
|
| 861 |
const host = {
|
| 862 |
id: session.id,
|
| 863 |
pty: term,
|
| 864 |
vt,
|
| 865 |
cols,
|
| 866 |
rows,
|
| 867 |
-
// Does this app reprint its screen after SIGWINCH? That decides whether the
|
| 868 |
-
// reflow on resize archives a duplicate of the screen or archives the log
|
| 869 |
-
// (see carryScreen). Every agent TUI repaints; a shell prompt does not, and
|
| 870 |
-
// its rewrapped scrollback is the real thing, not a copy. The guess is
|
| 871 |
-
// corrected below, because `vim` or a hand-typed `claude` breaks it.
|
| 872 |
-
repaints: !isShell,
|
| 873 |
-
resizedAt: 0,
|
| 874 |
-
resizeBytes: 0,
|
| 875 |
-
pendingArchive: null,
|
| 876 |
-
archiveTimer: null,
|
| 877 |
-
silentResizes: 0,
|
| 878 |
subs: new Set(),
|
| 879 |
-
|
| 880 |
-
pendingSizes: new Set(), // subs whose request hasn't been answered yet
|
| 881 |
gridTimer: null,
|
| 882 |
-
history: [],
|
| 883 |
-
historyBytes: 0,
|
| 884 |
-
historyDropped: false,
|
| 885 |
startedAt: Date.now(),
|
| 886 |
screenChangedAt: Date.now(),
|
| 887 |
bells: 0,
|
|
@@ -890,27 +696,6 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 890 |
term.onData((chunk) => {
|
| 891 |
try { vt.feed(chunk); } catch (e) { console.error('[runner] vt.feed', e && e.message); }
|
| 892 |
|
| 893 |
-
// A screenful arriving after a resize means the app answered SIGWINCH by
|
| 894 |
-
// reprinting its screen. That settles the question settleArchive was waiting on
|
| 895 |
-
// — the rows it holds are about to be printed again, so archiving them would
|
| 896 |
-
// duplicate them — and it settles it NOW, whenever the frame happens to arrive,
|
| 897 |
-
// instead of when a timer says so.
|
| 898 |
-
if (host.resizedAt) {
|
| 899 |
-
if (Date.now() - host.resizedAt > ARCHIVE_SETTLE_MS + 150) host.resizedAt = 0;
|
| 900 |
-
else {
|
| 901 |
-
host.resizeBytes += chunk.length;
|
| 902 |
-
// A quarter screen of bytes is far more than a shell prompt redrawing
|
| 903 |
-
// itself and far less than a TUI frame.
|
| 904 |
-
if (host.resizeBytes > (host.cols * host.rows) / 4) {
|
| 905 |
-
host.resizedAt = 0;
|
| 906 |
-
host.repaints = true; // also catches `vim` or a hand-typed `claude` in a shell pane
|
| 907 |
-
host.silentResizes = 0;
|
| 908 |
-
host.pendingArchive = null; // a copy after all
|
| 909 |
-
if (host.archiveTimer) { clearTimeout(host.archiveTimer); host.archiveTimer = null; }
|
| 910 |
-
}
|
| 911 |
-
}
|
| 912 |
-
}
|
| 913 |
-
|
| 914 |
// State detection rides the feed path: the grid is already current, so there
|
| 915 |
// is nothing to poll and no subprocess to spawn.
|
| 916 |
sampleScreen(host);
|
|
@@ -918,20 +703,12 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 918 |
if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); }
|
| 919 |
}
|
| 920 |
|
| 921 |
-
host.history.push(chunk);
|
| 922 |
-
host.historyBytes += chunk.length;
|
| 923 |
-
while (host.historyBytes > REPLAY_BYTES && host.history.length > 1) {
|
| 924 |
-
host.historyBytes -= host.history.shift().length;
|
| 925 |
-
host.historyDropped = true;
|
| 926 |
-
}
|
| 927 |
-
|
| 928 |
for (const sub of host.subs) sub.onData(chunk);
|
| 929 |
});
|
| 930 |
|
| 931 |
term.onExit(() => {
|
| 932 |
hosts.delete(session.id);
|
| 933 |
if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
|
| 934 |
-
if (host.archiveTimer) { clearTimeout(host.archiveTimer); host.archiveTimer = null; }
|
| 935 |
try { vt.dispose(); } catch {}
|
| 936 |
for (const sub of host.subs) sub.onExit();
|
| 937 |
host.subs.clear();
|
|
@@ -945,25 +722,14 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 945 |
return true;
|
| 946 |
}
|
| 947 |
|
| 948 |
-
/**
|
| 949 |
-
* The bytes to replay for scrollback. A ring that has wrapped almost certainly
|
| 950 |
-
* starts mid-escape-sequence, so drop everything before the first newline — the
|
| 951 |
-
* repaint that follows is the authority for the visible screen either way.
|
| 952 |
-
*/
|
| 953 |
-
function replayBytes(host) {
|
| 954 |
-
const joined = host.history.join('');
|
| 955 |
-
if (!host.historyDropped) return joined;
|
| 956 |
-
const nl = joined.indexOf('\n');
|
| 957 |
-
return nl >= 0 ? joined.slice(nl + 1) : joined;
|
| 958 |
-
}
|
| 959 |
-
|
| 960 |
/**
|
| 961 |
* Subscribe a viewer to a session, starting it if needed.
|
| 962 |
*
|
| 963 |
* Unlike the tmux version this does NOT spawn anything per viewer, and
|
| 964 |
* `handle.kill()` only unsubscribes — closing a tab must never stop an agent.
|
| 965 |
-
* `handle.restore()` returns
|
| 966 |
-
*
|
|
|
|
| 967 |
*/
|
| 968 |
export function attach(session, cols, rows) {
|
| 969 |
ensureRunning(session, cols, rows);
|
|
@@ -974,48 +740,64 @@ export function attach(session, cols, rows) {
|
|
| 974 |
onData: () => {},
|
| 975 |
onExit: () => {},
|
| 976 |
onGrid: () => {},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 977 |
};
|
| 978 |
host.subs.add(sub);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
|
| 980 |
return {
|
| 981 |
onData: (cb) => { sub.onData = (d) => { try { cb(d); } catch {} }; },
|
| 982 |
onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; },
|
| 983 |
-
onGrid: (cb) => { sub.onGrid = (c, r,
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
resize: (c, r) => {
|
| 988 |
if (!Number.isFinite(c) || !Number.isFinite(r)) return;
|
| 989 |
const want = {
|
| 990 |
cols: Math.max(20, Math.min(400, Math.round(c))),
|
| 991 |
rows: Math.max(5, Math.min(200, Math.round(r))),
|
| 992 |
};
|
| 993 |
-
const had =
|
| 994 |
-
|
| 995 |
-
// A resync that changes nothing (tab focus, an unrelated pane opening) must
|
| 996 |
-
// not schedule anything — but the FIRST request from a viewer is always
|
| 997 |
-
// worth answering, so it learns the grid it joined.
|
| 998 |
if (had && had.cols === want.cols && had.rows === want.rows) return;
|
| 999 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1000 |
},
|
| 1001 |
restore: () => {
|
| 1002 |
let snap;
|
| 1003 |
-
try { snap = host.vt.snapshot({ includeCells: true }); } catch { return null; }
|
| 1004 |
return {
|
| 1005 |
-
|
| 1006 |
-
ansi: snapshotToAnsi(snap),
|
| 1007 |
cols: snap.cols,
|
| 1008 |
rows: snap.rows,
|
| 1009 |
viewers: host.subs.size,
|
| 1010 |
-
|
| 1011 |
};
|
| 1012 |
},
|
| 1013 |
// Detach this viewer only. The session, its grid and its scrollback stay.
|
| 1014 |
kill: () => {
|
|
|
|
| 1015 |
host.subs.delete(sub);
|
| 1016 |
-
host.
|
| 1017 |
-
|
| 1018 |
-
scheduleGrid(host);
|
| 1019 |
},
|
| 1020 |
};
|
| 1021 |
}
|
|
|
|
| 6 |
import { cliById, WORKSPACES_DIR, isRemote } from './config.js';
|
| 7 |
import { update, list } from './sessions.js';
|
| 8 |
import { captureOpencodeSession } from './traces.js';
|
| 9 |
+
import { buildPaletteIndex, snapshotToRestoreAnsi } from './snapshot.js';
|
| 10 |
|
| 11 |
// libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
|
| 12 |
// is guarded so a platform without a prebuilt still boots and says so, rather
|
|
|
|
| 40 |
// Every session is one PTY held by THIS process, with a libghostty-vt terminal
|
| 41 |
// fed from its output. That terminal is the authoritative screen, so:
|
| 42 |
//
|
| 43 |
+
// * a browser attaching gets canonical history plus a snapshot repaint,
|
| 44 |
// instead of asking tmux to redraw and hoping the agent's TUI cooperates;
|
| 45 |
+
// * several browsers can watch the same session, with one explicit input/size
|
| 46 |
+
// controller instead of a one-device-at-a-time disconnect handover;
|
| 47 |
// * agent state is a property read rather than a `tmux capture-pane`
|
| 48 |
// subprocess per session per poll.
|
| 49 |
//
|
|
|
|
| 55 |
const BUSY_SECS = 4;
|
| 56 |
// Re-rendering the grid to text on every chunk during a burst is wasteful.
|
| 57 |
const SAMPLE_THROTTLE_MS = 250;
|
| 58 |
+
// Despite the Node wrapper's `scrollbackLimit` name, Ghostty's native option is
|
| 59 |
+
// a byte budget. Passing a line count such as 20,000 retains only a small native
|
| 60 |
+
// allocation (about 700 ordinary rows). Keep the unit explicit at our boundary.
|
| 61 |
+
const DEFAULT_SCROLLBACK_BYTES = 64 * 1024 * 1024;
|
| 62 |
+
const configuredScrollback = process.env.AM_SCROLLBACK_BYTES === undefined
|
| 63 |
+
? Number.NaN
|
| 64 |
+
: Number(process.env.AM_SCROLLBACK_BYTES);
|
| 65 |
+
const SCROLLBACK_BYTES = Number.isFinite(configuredScrollback) && configuredScrollback >= 0
|
| 66 |
+
? configuredScrollback
|
| 67 |
+
: DEFAULT_SCROLLBACK_BYTES;
|
| 68 |
+
// A layout resize is only worth acting on once it stops changing. ResizeObserver
|
| 69 |
+
// fires per animation frame while a window is dragged; coalescing that burst
|
| 70 |
+
// avoids repeatedly reflowing the terminal and repeatedly sending SIGWINCH.
|
| 71 |
const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
const hosts = new Map(); // session id -> host
|
| 74 |
|
|
|
|
| 141 |
|
| 142 |
// ---------- the shared grid ----------
|
| 143 |
//
|
| 144 |
+
// A PTY has exactly one geometry. One attached viewer therefore holds a size
|
| 145 |
+
// lease (the controller); every other viewer watches the same grid without
|
| 146 |
+
// changing it. A watcher can take the lease through an explicit interaction.
|
| 147 |
+
// This prevents a phone or background tab from resizing a desktop session merely
|
| 148 |
+
// by connecting.
|
| 149 |
|
| 150 |
function effectiveGrid(host) {
|
| 151 |
+
return host.controller?.want || { cols: host.cols, rows: host.rows };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
|
| 154 |
+
function notifyGrid(host, reset = false) {
|
| 155 |
+
for (const sub of host.subs) {
|
| 156 |
+
sub.onGrid(host.cols, host.rows, host.controller === sub, host.subs.size, reset);
|
| 157 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
/**
|
| 161 |
* Move the session to the size its viewers imply.
|
| 162 |
*
|
| 163 |
+
* Ghostty performs ordinary terminal reflow. We deliberately do not clear,
|
| 164 |
+
* classify, carry, or synthesize history around it: those policies cannot know
|
| 165 |
+
* whether the foreground app will repaint and can destroy real output. Viewers
|
| 166 |
+
* receive the confirmed geometry before subsequent PTY output and perform the
|
| 167 |
+
* same ordinary reflow; full history serialization is reserved for attachment.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
*/
|
| 169 |
function applyGrid(host) {
|
| 170 |
const { cols, rows } = effectiveGrid(host);
|
| 171 |
if (cols === host.cols && rows === host.rows) return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
host.cols = cols;
|
| 173 |
host.rows = rows;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
try { host.vt.resize(cols, rows); } catch {}
|
| 175 |
try { host.pty.resize(cols, rows); } catch {}
|
| 176 |
+
notifyGrid(host, false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
return true;
|
| 178 |
}
|
| 179 |
|
| 180 |
/**
|
| 181 |
+
* Apply the controller's preferred grid once requests stop arriving. If a
|
| 182 |
+
* pending request clamps back to the current size, report that canonical size.
|
|
|
|
|
|
|
| 183 |
*/
|
| 184 |
+
function scheduleGrid(host) {
|
|
|
|
| 185 |
if (host.gridTimer) clearTimeout(host.gridTimer);
|
| 186 |
host.gridTimer = setTimeout(() => {
|
| 187 |
host.gridTimer = null;
|
| 188 |
+
if (!applyGrid(host)) notifyGrid(host, false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
}, RESIZE_SETTLE_MS);
|
| 190 |
if (host.gridTimer.unref) host.gridTimer.unref();
|
| 191 |
}
|
|
|
|
| 678 |
const term = pty.spawn('bash', ['-lc', full], {
|
| 679 |
name: 'xterm-256color', cols, rows, cwd: workdir, env,
|
| 680 |
});
|
| 681 |
+
const vt = ghostty.createTerminal({ cols, rows, scrollbackLimit: SCROLLBACK_BYTES });
|
|
|
|
|
|
|
| 682 |
const host = {
|
| 683 |
id: session.id,
|
| 684 |
pty: term,
|
| 685 |
vt,
|
| 686 |
cols,
|
| 687 |
rows,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
subs: new Set(),
|
| 689 |
+
controller: null,
|
|
|
|
| 690 |
gridTimer: null,
|
|
|
|
|
|
|
|
|
|
| 691 |
startedAt: Date.now(),
|
| 692 |
screenChangedAt: Date.now(),
|
| 693 |
bells: 0,
|
|
|
|
| 696 |
term.onData((chunk) => {
|
| 697 |
try { vt.feed(chunk); } catch (e) { console.error('[runner] vt.feed', e && e.message); }
|
| 698 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
// State detection rides the feed path: the grid is already current, so there
|
| 700 |
// is nothing to poll and no subprocess to spawn.
|
| 701 |
sampleScreen(host);
|
|
|
|
| 703 |
if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); }
|
| 704 |
}
|
| 705 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
for (const sub of host.subs) sub.onData(chunk);
|
| 707 |
});
|
| 708 |
|
| 709 |
term.onExit(() => {
|
| 710 |
hosts.delete(session.id);
|
| 711 |
if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
|
|
|
|
| 712 |
try { vt.dispose(); } catch {}
|
| 713 |
for (const sub of host.subs) sub.onExit();
|
| 714 |
host.subs.clear();
|
|
|
|
| 722 |
return true;
|
| 723 |
}
|
| 724 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
/**
|
| 726 |
* Subscribe a viewer to a session, starting it if needed.
|
| 727 |
*
|
| 728 |
* Unlike the tmux version this does NOT spawn anything per viewer, and
|
| 729 |
* `handle.kill()` only unsubscribes — closing a tab must never stop an agent.
|
| 730 |
+
* `handle.restore()` returns a canonical snapshot: Ghostty's complete plain-text
|
| 731 |
+
* history followed by a styled visible-screen repaint. It never replays an
|
| 732 |
+
* arbitrary suffix of old PTY bytes at a new geometry.
|
| 733 |
*/
|
| 734 |
export function attach(session, cols, rows) {
|
| 735 |
ensureRunning(session, cols, rows);
|
|
|
|
| 740 |
onData: () => {},
|
| 741 |
onExit: () => {},
|
| 742 |
onGrid: () => {},
|
| 743 |
+
want: {
|
| 744 |
+
cols: Math.max(20, Math.min(400, Math.round(Number.isFinite(cols) ? cols : host.cols))),
|
| 745 |
+
rows: Math.max(5, Math.min(200, Math.round(Number.isFinite(rows) ? rows : host.rows))),
|
| 746 |
+
},
|
| 747 |
};
|
| 748 |
host.subs.add(sub);
|
| 749 |
+
if (!host.controller) host.controller = sub;
|
| 750 |
+
// Existing viewers need to know that the session is now shared. The new
|
| 751 |
+
// viewer receives the same role/count in its restore frame below.
|
| 752 |
+
notifyGrid(host, false);
|
| 753 |
+
if (host.controller === sub && (sub.want.cols !== host.cols || sub.want.rows !== host.rows)) scheduleGrid(host);
|
| 754 |
|
| 755 |
return {
|
| 756 |
onData: (cb) => { sub.onData = (d) => { try { cb(d); } catch {} }; },
|
| 757 |
onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; },
|
| 758 |
+
onGrid: (cb) => { sub.onGrid = (c, r, controller, viewers, reset) => { try { cb(c, r, controller, viewers, reset); } catch {} }; },
|
| 759 |
+
// Input and terminal-query responses are accepted from one emulator only.
|
| 760 |
+
write: (d) => {
|
| 761 |
+
if (host.controller !== sub) return;
|
| 762 |
+
try { host.pty.write(d); } catch {}
|
| 763 |
+
},
|
| 764 |
+
// Every viewer remembers what it can display, but only the current
|
| 765 |
+
// controller's request changes the PTY.
|
| 766 |
resize: (c, r) => {
|
| 767 |
if (!Number.isFinite(c) || !Number.isFinite(r)) return;
|
| 768 |
const want = {
|
| 769 |
cols: Math.max(20, Math.min(400, Math.round(c))),
|
| 770 |
rows: Math.max(5, Math.min(200, Math.round(r))),
|
| 771 |
};
|
| 772 |
+
const had = sub.want;
|
| 773 |
+
sub.want = want;
|
|
|
|
|
|
|
|
|
|
| 774 |
if (had && had.cols === want.cols && had.rows === want.rows) return;
|
| 775 |
+
if (host.controller === sub) scheduleGrid(host);
|
| 776 |
+
},
|
| 777 |
+
claim: () => {
|
| 778 |
+
if (!host.subs.has(sub) || host.controller === sub) return;
|
| 779 |
+
host.controller = sub;
|
| 780 |
+
notifyGrid(host, false);
|
| 781 |
+
scheduleGrid(host);
|
| 782 |
},
|
| 783 |
restore: () => {
|
| 784 |
let snap;
|
| 785 |
+
try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return null; }
|
| 786 |
return {
|
| 787 |
+
ansi: snapshotToRestoreAnsi(snap),
|
|
|
|
| 788 |
cols: snap.cols,
|
| 789 |
rows: snap.rows,
|
| 790 |
viewers: host.subs.size,
|
| 791 |
+
controller: host.controller === sub,
|
| 792 |
};
|
| 793 |
},
|
| 794 |
// Detach this viewer only. The session, its grid and its scrollback stay.
|
| 795 |
kill: () => {
|
| 796 |
+
const controlled = host.controller === sub;
|
| 797 |
host.subs.delete(sub);
|
| 798 |
+
if (controlled) host.controller = host.subs.values().next().value || null;
|
| 799 |
+
notifyGrid(host, false);
|
| 800 |
+
if (controlled && host.controller) scheduleGrid(host);
|
| 801 |
},
|
| 802 |
};
|
| 803 |
}
|
server/src/snapshot.js
CHANGED
|
@@ -116,48 +116,12 @@ function renderRow(cells, cols) {
|
|
| 116 |
return out;
|
| 117 |
}
|
| 118 |
|
| 119 |
-
/**
|
| 120 |
-
* Rows `from`..`to` as styled lines, ready to be PRINTED rather than placed —
|
| 121 |
-
* for putting rows into scrollback, where a line has to be written and scrolled
|
| 122 |
-
* off rather than positioned. Blank rows are kept as empty strings so the
|
| 123 |
-
* archived block keeps its shape.
|
| 124 |
-
*/
|
| 125 |
-
export function rowsToAnsi(snap, from, to) {
|
| 126 |
-
const grid = cellGrid(snap);
|
| 127 |
-
const out = [];
|
| 128 |
-
for (let row = Math.max(0, from); row < Math.min(snap.rows, to); row++) {
|
| 129 |
-
out.push(renderRow(grid[row], snap.cols));
|
| 130 |
-
}
|
| 131 |
-
return out;
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
/**
|
| 135 |
-
* The rows placed absolutely, with NO erase and no buffer switch — for painting
|
| 136 |
-
* into a screen whose other rows must survive.
|
| 137 |
-
*
|
| 138 |
-
* That is the case after a resize: growing pulls history back down out of
|
| 139 |
-
* scrollback into the top rows, and erasing before painting would throw exactly
|
| 140 |
-
* that away. The caller erased before the resize instead, so everything this does
|
| 141 |
-
* not cover is already blank.
|
| 142 |
-
*/
|
| 143 |
-
export function snapshotToRows(snap) {
|
| 144 |
-
const grid = cellGrid(snap);
|
| 145 |
-
let out = '\x1b[?25l\x1b[0m';
|
| 146 |
-
for (let row = 0; row < snap.rows; row++) {
|
| 147 |
-
const line = renderRow(grid[row], snap.cols);
|
| 148 |
-
if (line) out += `\x1b[${row + 1};1H` + line;
|
| 149 |
-
}
|
| 150 |
-
out += `\x1b[0m\x1b[${snap.cursorRow + 1};${snap.cursorCol + 1}H\x1b[?25h`;
|
| 151 |
-
return out;
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
export function snapshotToAnsi(snap) {
|
| 155 |
const grid = cellGrid(snap);
|
| 156 |
|
| 157 |
-
// Normalise the buffer first
|
| 158 |
-
//
|
| 159 |
-
//
|
| 160 |
-
// and hide the cursor so the repaint doesn't strobe across the screen.
|
| 161 |
let out = snap.isAltScreen ? '\x1b[?1049h' : '\x1b[?1049l';
|
| 162 |
out += '\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
|
| 163 |
|
|
@@ -170,3 +134,27 @@ export function snapshotToAnsi(snap) {
|
|
| 170 |
out += '\x1b[?25h';
|
| 171 |
return out;
|
| 172 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
return out;
|
| 117 |
}
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
export function snapshotToAnsi(snap) {
|
| 120 |
const grid = cellGrid(snap);
|
| 121 |
|
| 122 |
+
// Normalise the receiver's active buffer first. Reset attributes so nothing
|
| 123 |
+
// leaks in from what was showing, and hide the cursor so the repaint doesn't
|
| 124 |
+
// strobe across the screen.
|
|
|
|
| 125 |
let out = snap.isAltScreen ? '\x1b[?1049h' : '\x1b[?1049l';
|
| 126 |
out += '\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
|
| 127 |
|
|
|
|
| 134 |
out += '\x1b[?25h';
|
| 135 |
return out;
|
| 136 |
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Rebuild a fresh viewer from Ghostty's canonical state.
|
| 140 |
+
*
|
| 141 |
+
* The binding currently exposes styled cells only for the visible screen, while
|
| 142 |
+
* scrollback is plain text. That is still a much stronger restore boundary than
|
| 143 |
+
* replaying a truncated raw PTY byte stream: it includes every retained history
|
| 144 |
+
* row, is already reflowed to the current geometry, and cannot begin halfway
|
| 145 |
+
* through an escape sequence.
|
| 146 |
+
*
|
| 147 |
+
* A line becomes scrollback only after it leaves the visible grid. Print the
|
| 148 |
+
* history on the primary screen, then scroll the remaining visible history rows
|
| 149 |
+
* off before painting the authoritative current screen.
|
| 150 |
+
*/
|
| 151 |
+
export function snapshotToRestoreAnsi(snap) {
|
| 152 |
+
const history = (snap.scrollbackLines || []).map((line) => line.text || '');
|
| 153 |
+
let out = '\x1b[?1049l\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
|
| 154 |
+
if (history.length) {
|
| 155 |
+
out += history.join('\r\n');
|
| 156 |
+
out += `\x1b[${snap.rows};1H` + '\r\n'.repeat(Math.min(history.length, snap.rows));
|
| 157 |
+
}
|
| 158 |
+
out += snapshotToAnsi(snap);
|
| 159 |
+
return out;
|
| 160 |
+
}
|
web/src/components/TerminalPane.tsx
CHANGED
|
@@ -31,29 +31,21 @@ const THEMES: Record<'light' | 'dark', ITheme> = {
|
|
| 31 |
},
|
| 32 |
};
|
| 33 |
|
| 34 |
-
type ConnState = 'connecting' | 'connected' | 'closed' | 'exited'
|
| 35 |
|
| 36 |
// Close code the server uses when the session's process exited for real (vs a
|
| 37 |
// transient drop). The client must NOT auto-reconnect on this, or it would
|
| 38 |
// respawn the agent in a loop and trample an in-progress login flow.
|
| 39 |
const EXIT_CODE = 4000;
|
| 40 |
|
| 41 |
-
// Close code for "another device took this session over" (tmux attaches with
|
| 42 |
-
// -D, so only one client drives a session at a time). The session is still
|
| 43 |
-
// running: we must not reconnect on a timer, or this pane and the phone would
|
| 44 |
-
// pull the session back and forth. We wait for the user to come back here.
|
| 45 |
-
const HANDOFF_CODE = 4001;
|
| 46 |
-
|
| 47 |
function workspaceLabel(p: string | null) {
|
| 48 |
const rel = (p || '').replace(/^\.\/?/, '').replace(/^\/+|\/+$/g, '');
|
| 49 |
return rel ? `workspace/${rel}/` : 'workspace/';
|
| 50 |
}
|
| 51 |
|
| 52 |
// Serialize the current selection, joining soft-wrapped rows instead of
|
| 53 |
-
// emitting a newline per visual row.
|
| 54 |
-
//
|
| 55 |
-
// default getSelection() would break with '\n'. We rejoin a row to the next
|
| 56 |
-
// when xterm flags it wrapped OR it's filled to the last column (the tmux case).
|
| 57 |
function selectionText(term: Terminal): string {
|
| 58 |
try {
|
| 59 |
const pos = term.getSelectionPosition?.();
|
|
@@ -96,15 +88,6 @@ function selectionText(term: Terminal): string {
|
|
| 96 |
// socket with this leading NUL sentinel, which real pty output never begins with.
|
| 97 |
const MODE_CTRL = '\x00\x00AM:';
|
| 98 |
|
| 99 |
-
// OSC 52 used to arrive constantly, because tmux emitted it for every mouse
|
| 100 |
-
// selection in copy mode — hence a gate that only let a write through right
|
| 101 |
-
// after a real Cmd/Ctrl+C. With tmux gone the only source is the AGENT itself
|
| 102 |
-
// deliberately writing the clipboard, and nothing grants that permission
|
| 103 |
-
// automatically: the text is stashed and the user's next Cmd+C copies it.
|
| 104 |
-
function consumeOsc52CopyPermission(): boolean {
|
| 105 |
-
return false;
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
function legacyCopy(text: string): boolean {
|
| 109 |
const prev = document.activeElement as HTMLElement | null;
|
| 110 |
try {
|
|
@@ -162,22 +145,19 @@ export default function TerminalPane({
|
|
| 162 |
}) {
|
| 163 |
const hostRef = useRef<HTMLDivElement>(null);
|
| 164 |
const termRef = useRef<Terminal | null>(null);
|
| 165 |
-
const
|
| 166 |
const reconnectRef = useRef<() => void>(() => {});
|
| 167 |
-
|
| 168 |
-
const resumeRef = useRef<() => void>(() => {});
|
| 169 |
// Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
|
| 170 |
const sendKeyRef = useRef<(d: string) => void>(() => {});
|
| 171 |
const [conn, setConn] = useState<ConnState>('connecting');
|
| 172 |
-
// "starting…" cover while the CLI boots into an empty pane (
|
| 173 |
// Space can take seconds). Hidden only when the screen actually SHOWS
|
| 174 |
-
// something — byte counts lie
|
| 175 |
-
//
|
| 176 |
const [booting, setBooting] = useState(true);
|
| 177 |
-
const [
|
| 178 |
-
|
| 179 |
-
// compromise, which is worth showing rather than leaving as a mystery.
|
| 180 |
-
const [viewers, setViewers] = useState(0);
|
| 181 |
const [editing, setEditing] = useState(false);
|
| 182 |
const [draft, setDraft] = useState(session.name);
|
| 183 |
// Fallback paste sheet: shown only when we can't read the clipboard directly.
|
|
@@ -223,30 +203,27 @@ export default function TerminalPane({
|
|
| 223 |
cursorBlink: true,
|
| 224 |
scrollback: 20000,
|
| 225 |
theme: THEMES[theme],
|
| 226 |
-
// Let users make a
|
| 227 |
-
//
|
| 228 |
macOptionClickForcesSelection: true,
|
| 229 |
});
|
| 230 |
const fit = new FitAddon();
|
| 231 |
term.loadAddon(fit);
|
| 232 |
|
| 233 |
let lastSelection = '';
|
| 234 |
-
let
|
| 235 |
-
let
|
| 236 |
-
let tmuxSelectionAt = 0;
|
| 237 |
|
| 238 |
-
//
|
| 239 |
-
//
|
|
|
|
| 240 |
const clipboardProvider = {
|
| 241 |
readText: (sel: string) => (sel !== 'p' && navigator.clipboard?.readText ? navigator.clipboard.readText().catch(() => '') : ''),
|
| 242 |
writeText: (sel: string, data: string) => {
|
| 243 |
-
const allowed = sel !== 'p' && consumeOsc52CopyPermission();
|
| 244 |
if (sel !== 'p') {
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
tmuxSelectionPending = true;
|
| 248 |
}
|
| 249 |
-
if (allowed) copyText(data);
|
| 250 |
},
|
| 251 |
};
|
| 252 |
// addon-clipboard@0.1.0 has a (base64, provider) runtime constructor but
|
|
@@ -259,104 +236,90 @@ export default function TerminalPane({
|
|
| 259 |
// synchronously — deferring (setTimeout) would break execCommand's gesture.
|
| 260 |
const selSub = term.onSelectionChange(() => {
|
| 261 |
lastSelection = term.hasSelection() ? selectionText(term) : '';
|
| 262 |
-
if (lastSelection) tmuxSelectionPending = false;
|
| 263 |
});
|
| 264 |
const copySelection = () => {
|
| 265 |
const text = term.hasSelection() ? selectionText(term) : lastSelection;
|
| 266 |
if (text) copyText(text);
|
| 267 |
};
|
| 268 |
-
const copyTmuxSelection = (e?: ClipboardEvent): boolean => {
|
| 269 |
-
const fresh = tmuxSelectionText && Date.now() - tmuxSelectionAt < 120_000;
|
| 270 |
-
if (!tmuxSelectionPending || !fresh) return false;
|
| 271 |
-
if (e?.clipboardData) {
|
| 272 |
-
e.clipboardData.setData('text/plain', tmuxSelectionText);
|
| 273 |
-
e.preventDefault();
|
| 274 |
-
e.stopPropagation();
|
| 275 |
-
} else {
|
| 276 |
-
copyText(tmuxSelectionText);
|
| 277 |
-
}
|
| 278 |
-
return true;
|
| 279 |
-
};
|
| 280 |
const host = hostRef.current!;
|
| 281 |
-
let mouseDragStart: { x: number; y: number } | null = null;
|
| 282 |
-
let mouseDragged = false;
|
| 283 |
-
const onPointerDown = (e: PointerEvent) => {
|
| 284 |
-
takeBack(); // clicking into a handed-off pane means "I'm working here now"
|
| 285 |
-
if (e.pointerType !== 'mouse' || e.button !== 0) return;
|
| 286 |
-
mouseDragStart = { x: e.clientX, y: e.clientY };
|
| 287 |
-
mouseDragged = false;
|
| 288 |
-
};
|
| 289 |
-
const onPointerMove = (e: PointerEvent) => {
|
| 290 |
-
if (!mouseDragStart || (e.buttons & 1) === 0) return;
|
| 291 |
-
if (Math.abs(e.clientX - mouseDragStart.x) > 3 || Math.abs(e.clientY - mouseDragStart.y) > 3) mouseDragged = true;
|
| 292 |
-
};
|
| 293 |
-
const onPointerUp = (e: PointerEvent) => {
|
| 294 |
-
if (e.pointerType !== 'mouse' || e.button !== 0) return;
|
| 295 |
-
if (mouseDragged && !term.hasSelection()) {
|
| 296 |
-
tmuxSelectionPending = true;
|
| 297 |
-
} else if (!term.hasSelection()) {
|
| 298 |
-
tmuxSelectionPending = false;
|
| 299 |
-
}
|
| 300 |
-
mouseDragStart = null;
|
| 301 |
-
mouseDragged = false;
|
| 302 |
-
};
|
| 303 |
-
const onClick = (e: MouseEvent) => {
|
| 304 |
-
if (e.detail >= 2 && !term.hasSelection()) {
|
| 305 |
-
tmuxSelectionPending = true;
|
| 306 |
-
}
|
| 307 |
-
};
|
| 308 |
-
host.addEventListener('pointerdown', onPointerDown, true);
|
| 309 |
-
host.addEventListener('pointermove', onPointerMove, true);
|
| 310 |
-
host.addEventListener('pointerup', onPointerUp, true);
|
| 311 |
-
host.addEventListener('click', onClick, true);
|
| 312 |
-
|
| 313 |
-
const onDocumentPointerDown = (e: PointerEvent) => {
|
| 314 |
-
if (!host.contains(e.target as Node)) tmuxSelectionPending = false;
|
| 315 |
-
};
|
| 316 |
const onCopy = (e: ClipboardEvent) => {
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
};
|
| 319 |
-
document.addEventListener('pointerdown', onDocumentPointerDown, true);
|
| 320 |
document.addEventListener('copy', onCopy, true);
|
| 321 |
|
| 322 |
let ws: WebSocket | null = null;
|
| 323 |
const send = (o: unknown) => {
|
| 324 |
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o));
|
| 325 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
// The mobile key-bar sends control sequences the on-screen keyboard can't.
|
| 327 |
-
sendKeyRef.current = (d: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
-
// ⌘/Ctrl+C copies
|
| 330 |
-
//
|
| 331 |
-
// it follows this key gesture. Paste is left to xterm's native handler so
|
| 332 |
-
// bracketed-paste framing is preserved.
|
| 333 |
term.attachCustomKeyEventHandler((e) => {
|
| 334 |
if (e.type !== 'keydown') return true;
|
| 335 |
if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && term.hasSelection()) {
|
| 336 |
copySelection();
|
| 337 |
term.clearSelection();
|
| 338 |
-
tmuxSelectionPending = false;
|
| 339 |
return false;
|
| 340 |
}
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && tmuxSelectionPending) {
|
| 344 |
-
copyTmuxSelection();
|
| 345 |
return false;
|
| 346 |
}
|
| 347 |
-
|
| 348 |
-
if (e.key === 'Escape') tmuxSelectionPending = false;
|
| 349 |
return true;
|
| 350 |
});
|
| 351 |
// The only local fit: this terminal is still empty, so there is no buffer to
|
| 352 |
// reflow, and it gives the initial size we open the socket with. Every later
|
| 353 |
// size change goes through resync() as a REQUEST — see there.
|
| 354 |
try { fit.fit(); } catch { /* layout not ready yet */ }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
// Re-measure once the webfont is ready (glyph width changes vs the fallback).
|
| 356 |
-
document.fonts?.ready.then(() => resync());
|
| 357 |
|
| 358 |
let closedByUs = false;
|
| 359 |
-
let handedOff = false;
|
| 360 |
let retry: ReturnType<typeof setTimeout> | null = null;
|
| 361 |
// Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
|
| 362 |
// every second by every open pane. Reset once a connection succeeds.
|
|
@@ -387,6 +350,8 @@ export default function TerminalPane({
|
|
| 387 |
setBooting(false);
|
| 388 |
};
|
| 389 |
const connect = () => {
|
|
|
|
|
|
|
| 390 |
setConn('connecting');
|
| 391 |
setBooting(true);
|
| 392 |
bootLive = true;
|
|
@@ -408,24 +373,28 @@ export default function TerminalPane({
|
|
| 408 |
if (typeof d === 'string' && d.startsWith(MODE_CTRL)) {
|
| 409 |
try {
|
| 410 |
const m = JSON.parse(d.slice(MODE_CTRL.length));
|
| 411 |
-
if (m.t === '
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
}
|
| 430 |
} catch { /* ignore */ }
|
| 431 |
return;
|
|
@@ -442,12 +411,9 @@ export default function TerminalPane({
|
|
| 442 |
ws.onclose = (e) => {
|
| 443 |
// A real process exit: stop here and let the user relaunch. Anything
|
| 444 |
// else is a transient drop (sleep/wake, network) → auto-reconnect and
|
| 445 |
-
// reattach to the still-running
|
| 446 |
endBoot();
|
| 447 |
if (e.code === EXIT_CODE) { setConn('exited'); return; }
|
| 448 |
-
// Another device took over: stay detached (no retry timer) until the
|
| 449 |
-
// user deliberately comes back to this pane — see takeBack().
|
| 450 |
-
if (e.code === HANDOFF_CODE) { handedOff = true; setConn('handedoff'); return; }
|
| 451 |
setConn('closed');
|
| 452 |
if (!closedByUs) {
|
| 453 |
retry = setTimeout(connect, retryDelay);
|
|
@@ -457,40 +423,24 @@ export default function TerminalPane({
|
|
| 457 |
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
|
| 458 |
};
|
| 459 |
// Manual restart: clear the dead run's screen so the content probe watches
|
| 460 |
-
// the
|
| 461 |
reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
|
| 462 |
|
| 463 |
-
//
|
| 464 |
-
//
|
| 465 |
-
//
|
| 466 |
-
// down keeps the session while it sits in the background. Reattaching sizes
|
| 467 |
-
// tmux to this device, which is the point: each device gets a window that
|
| 468 |
-
// fits it, at the cost of one reflow per handover instead of per glance.
|
| 469 |
-
const takeBack = () => {
|
| 470 |
-
if (!handedOff || document.hidden) return false;
|
| 471 |
-
handedOff = false;
|
| 472 |
-
connect();
|
| 473 |
-
return true;
|
| 474 |
-
};
|
| 475 |
-
resumeRef.current = () => { takeBack(); };
|
| 476 |
-
|
| 477 |
-
// Measure this pane and REQUEST that size. Deliberately does not call
|
| 478 |
-
// fit.fit(): resizing ourselves reflows our buffer against a geometry the
|
| 479 |
-
// session may never adopt (the grid follows the smallest viewer), and the
|
| 480 |
-
// in-flight bytes were written for the size we just left — so the screen is
|
| 481 |
-
// drawn twice, at two widths, and the difference lands in the scrollback.
|
| 482 |
-
// The server owns the grid; we conform when it tells us what it applied.
|
| 483 |
let resyncTimer: ReturnType<typeof setTimeout> | null = null;
|
| 484 |
const requestSize = () => {
|
| 485 |
-
if (handedOff) return;
|
| 486 |
-
// A collapsed or hidden pane measures as a couple of cells, and the grid
|
| 487 |
-
// follows the SMALLEST viewer — so asking from one would shrink the session
|
| 488 |
-
// for everybody actually looking at it.
|
| 489 |
const box = hostRef.current;
|
| 490 |
if (!box || box.clientWidth < 40 || box.clientHeight < 40) return;
|
| 491 |
try {
|
| 492 |
const d = fit.proposeDimensions();
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
} catch { /* layout not ready */ }
|
| 495 |
};
|
| 496 |
// ResizeObserver fires every frame while a window is dragged or the sidebar
|
|
@@ -499,30 +449,27 @@ export default function TerminalPane({
|
|
| 499 |
if (resyncTimer) clearTimeout(resyncTimer);
|
| 500 |
resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
|
| 501 |
};
|
| 502 |
-
|
| 503 |
-
// re-sync the size if we still hold it.
|
| 504 |
-
const onReturn = () => { if (!takeBack()) resync(); };
|
| 505 |
const onVisible = () => { if (!document.hidden) onReturn(); };
|
| 506 |
-
resyncRef.current = resync; // so the zoom control can refit
|
| 507 |
-
|
| 508 |
// Typing means the user sees enough to interact — drop the boot cover.
|
| 509 |
// Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
|
| 510 |
// replies to the TUI's terminal queries (DA/CPR), which arrive instantly
|
| 511 |
// on attach and must not count as "the user typed".
|
| 512 |
-
const keySub = term.onKey(() => endBoot());
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
const ro = new ResizeObserver(resync);
|
| 515 |
ro.observe(hostRef.current!);
|
| 516 |
window.addEventListener('focus', onReturn);
|
| 517 |
document.addEventListener('visibilitychange', onVisible);
|
| 518 |
|
| 519 |
-
// Touch scrolling
|
| 520 |
-
// that
|
| 521 |
-
// phones. Convert drags into wheel steps: SGR mouse-wheel sequences when
|
| 522 |
-
// the app tracks the mouse (tmux scrolls its history), local scrollLines
|
| 523 |
-
// otherwise.
|
| 524 |
let touchY: number | null = null;
|
| 525 |
-
const onTouchStart = (e: TouchEvent) => {
|
| 526 |
const onTouchMove = (e: TouchEvent) => {
|
| 527 |
if (touchY == null) return;
|
| 528 |
const y = e.touches[0].clientY;
|
|
@@ -534,7 +481,11 @@ export default function TerminalPane({
|
|
| 534 |
try { tracking = ((term as unknown as { modes?: { mouseTrackingMode?: string } }).modes?.mouseTrackingMode ?? 'none') !== 'none'; } catch { /* older xterm */ }
|
| 535 |
const btn = steps > 0 ? 64 : 65; // drag down reveals earlier output = wheel up
|
| 536 |
for (let i = 0; i < Math.abs(steps); i++) {
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
else term.scrollLines(steps > 0 ? -1 : 1);
|
| 539 |
}
|
| 540 |
}
|
|
@@ -553,12 +504,10 @@ export default function TerminalPane({
|
|
| 553 |
if (bootTimer) clearTimeout(bootTimer);
|
| 554 |
if (bootCheck) clearTimeout(bootCheck);
|
| 555 |
if (resyncTimer) clearTimeout(resyncTimer);
|
|
|
|
| 556 |
ro.disconnect();
|
| 557 |
host.removeEventListener('pointerdown', onPointerDown, true);
|
| 558 |
-
host.removeEventListener('
|
| 559 |
-
host.removeEventListener('pointerup', onPointerUp, true);
|
| 560 |
-
host.removeEventListener('click', onClick, true);
|
| 561 |
-
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
|
| 562 |
document.removeEventListener('copy', onCopy, true);
|
| 563 |
host.removeEventListener('touchstart', onTouchStart);
|
| 564 |
host.removeEventListener('touchmove', onTouchMove);
|
|
@@ -571,6 +520,7 @@ export default function TerminalPane({
|
|
| 571 |
try { ws?.close(); } catch { /* ignore */ }
|
| 572 |
term.dispose();
|
| 573 |
termRef.current = null;
|
|
|
|
| 574 |
};
|
| 575 |
}, [session.id]);
|
| 576 |
|
|
@@ -579,12 +529,13 @@ export default function TerminalPane({
|
|
| 579 |
if (termRef.current) termRef.current.options.theme = THEMES[theme];
|
| 580 |
}, [theme]);
|
| 581 |
|
| 582 |
-
// Zoom
|
|
|
|
| 583 |
useEffect(() => {
|
| 584 |
const t = termRef.current;
|
| 585 |
if (!t) return;
|
| 586 |
t.options.fontSize = Math.round((13 * zoom) / 100);
|
| 587 |
-
|
| 588 |
}, [zoom]);
|
| 589 |
|
| 590 |
// Move keyboard focus into the terminal whenever this pane becomes the active
|
|
@@ -631,6 +582,11 @@ export default function TerminalPane({
|
|
| 631 |
<span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
|
| 632 |
)}
|
| 633 |
<div className="ph-right">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
<span className="ph-path" title={pathLabel}>{pathLabel}</span>
|
| 635 |
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
|
| 636 |
</div>
|
|
@@ -638,9 +594,6 @@ export default function TerminalPane({
|
|
| 638 |
<div className="term-host">
|
| 639 |
<div className="term-fill" ref={hostRef} />
|
| 640 |
</div>
|
| 641 |
-
{copyMode && (
|
| 642 |
-
<div className="term-copy-hint mono">copy mode · scroll to read, press Esc or type to return</div>
|
| 643 |
-
)}
|
| 644 |
{isMobile && conn === 'connected' && (
|
| 645 |
// Control keys the phone keyboard lacks — needed for TUI menus (model
|
| 646 |
// pickers, etc.). preventDefault keeps terminal focus so the keyboard
|
|
@@ -699,21 +652,7 @@ export default function TerminalPane({
|
|
| 699 |
<button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
|
| 700 |
</div>
|
| 701 |
)}
|
| 702 |
-
{conn ==
|
| 703 |
-
// The session is running elsewhere, not stopped — say so, and make the
|
| 704 |
-
// way back obvious (clicking the terminal works too).
|
| 705 |
-
<div className="term-exit mono">
|
| 706 |
-
<div className="tx-row">
|
| 707 |
-
<span>open on another device · still running</span>
|
| 708 |
-
<button
|
| 709 |
-
className="tx-btn"
|
| 710 |
-
onMouseDown={(e) => e.stopPropagation()}
|
| 711 |
-
onClick={(e) => { e.stopPropagation(); resumeRef.current(); }}
|
| 712 |
-
><RefreshGlyph /> resume here</button>
|
| 713 |
-
</div>
|
| 714 |
-
</div>
|
| 715 |
-
)}
|
| 716 |
-
{booting && conn !== 'exited' && conn !== 'handedoff' && (
|
| 717 |
<div className="term-boot mono">
|
| 718 |
{conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
|
| 719 |
</div>
|
|
|
|
| 31 |
},
|
| 32 |
};
|
| 33 |
|
| 34 |
+
type ConnState = 'connecting' | 'connected' | 'closed' | 'exited';
|
| 35 |
|
| 36 |
// Close code the server uses when the session's process exited for real (vs a
|
| 37 |
// transient drop). The client must NOT auto-reconnect on this, or it would
|
| 38 |
// respawn the agent in a loop and trample an in-progress login flow.
|
| 39 |
const EXIT_CODE = 4000;
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
function workspaceLabel(p: string | null) {
|
| 42 |
const rel = (p || '').replace(/^\.\/?/, '').replace(/^\/+|\/+$/g, '');
|
| 43 |
return rel ? `workspace/${rel}/` : 'workspace/';
|
| 44 |
}
|
| 45 |
|
| 46 |
// Serialize the current selection, joining soft-wrapped rows instead of
|
| 47 |
+
// emitting a newline per visual row. Some full-width rows painted with cursor
|
| 48 |
+
// movement lack xterm's wrap flag, so a filled last cell is a second signal.
|
|
|
|
|
|
|
| 49 |
function selectionText(term: Terminal): string {
|
| 50 |
try {
|
| 51 |
const pos = term.getSelectionPosition?.();
|
|
|
|
| 88 |
// socket with this leading NUL sentinel, which real pty output never begins with.
|
| 89 |
const MODE_CTRL = '\x00\x00AM:';
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
function legacyCopy(text: string): boolean {
|
| 92 |
const prev = document.activeElement as HTMLElement | null;
|
| 93 |
try {
|
|
|
|
| 145 |
}) {
|
| 146 |
const hostRef = useRef<HTMLDivElement>(null);
|
| 147 |
const termRef = useRef<Terminal | null>(null);
|
| 148 |
+
const localLayoutRef = useRef<() => void>(() => {});
|
| 149 |
const reconnectRef = useRef<() => void>(() => {});
|
| 150 |
+
const controllerRef = useRef(false);
|
|
|
|
| 151 |
// Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
|
| 152 |
const sendKeyRef = useRef<(d: string) => void>(() => {});
|
| 153 |
const [conn, setConn] = useState<ConnState>('connecting');
|
| 154 |
+
// "starting…" cover while the CLI boots into an empty pane (starting on the
|
| 155 |
// Space can take seconds). Hidden only when the screen actually SHOWS
|
| 156 |
+
// something — byte counts lie because a blank-screen repaint can already be
|
| 157 |
+
// kilobytes of escape sequences.
|
| 158 |
const [booting, setBooting] = useState(true);
|
| 159 |
+
const [controller, setController] = useState(false);
|
| 160 |
+
const [viewers, setViewers] = useState(1);
|
|
|
|
|
|
|
| 161 |
const [editing, setEditing] = useState(false);
|
| 162 |
const [draft, setDraft] = useState(session.name);
|
| 163 |
// Fallback paste sheet: shown only when we can't read the clipboard directly.
|
|
|
|
| 203 |
cursorBlink: true,
|
| 204 |
scrollback: 20000,
|
| 205 |
theme: THEMES[theme],
|
| 206 |
+
// Let users make a local selection even when an agent TUI has grabbed
|
| 207 |
+
// the mouse: ⌥-drag on macOS, Shift-drag elsewhere.
|
| 208 |
macOptionClickForcesSelection: true,
|
| 209 |
});
|
| 210 |
const fit = new FitAddon();
|
| 211 |
term.loadAddon(fit);
|
| 212 |
|
| 213 |
let lastSelection = '';
|
| 214 |
+
let osc52Text = '';
|
| 215 |
+
let osc52At = 0;
|
|
|
|
| 216 |
|
| 217 |
+
// An agent may deliberately emit OSC 52. Browsers generally reject an
|
| 218 |
+
// unsolicited clipboard write, so retain it briefly and let Cmd/C perform
|
| 219 |
+
// the write inside a real user gesture.
|
| 220 |
const clipboardProvider = {
|
| 221 |
readText: (sel: string) => (sel !== 'p' && navigator.clipboard?.readText ? navigator.clipboard.readText().catch(() => '') : ''),
|
| 222 |
writeText: (sel: string, data: string) => {
|
|
|
|
| 223 |
if (sel !== 'p') {
|
| 224 |
+
osc52Text = data;
|
| 225 |
+
osc52At = Date.now();
|
|
|
|
| 226 |
}
|
|
|
|
| 227 |
},
|
| 228 |
};
|
| 229 |
// addon-clipboard@0.1.0 has a (base64, provider) runtime constructor but
|
|
|
|
| 236 |
// synchronously — deferring (setTimeout) would break execCommand's gesture.
|
| 237 |
const selSub = term.onSelectionChange(() => {
|
| 238 |
lastSelection = term.hasSelection() ? selectionText(term) : '';
|
|
|
|
| 239 |
});
|
| 240 |
const copySelection = () => {
|
| 241 |
const text = term.hasSelection() ? selectionText(term) : lastSelection;
|
| 242 |
if (text) copyText(text);
|
| 243 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
const host = hostRef.current!;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
const onCopy = (e: ClipboardEvent) => {
|
| 246 |
+
if (!term.hasSelection()) return;
|
| 247 |
+
const text = selectionText(term);
|
| 248 |
+
if (!text || !e.clipboardData) return;
|
| 249 |
+
e.clipboardData.setData('text/plain', text);
|
| 250 |
+
e.preventDefault();
|
| 251 |
};
|
|
|
|
| 252 |
document.addEventListener('copy', onCopy, true);
|
| 253 |
|
| 254 |
let ws: WebSocket | null = null;
|
| 255 |
const send = (o: unknown) => {
|
| 256 |
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o));
|
| 257 |
};
|
| 258 |
+
const claimControl = () => {
|
| 259 |
+
if (controllerRef.current) return;
|
| 260 |
+
// Optimistic locally so the input event following this gesture is not
|
| 261 |
+
// dropped. WebSocket ordering guarantees claim reaches the server first.
|
| 262 |
+
controllerRef.current = true;
|
| 263 |
+
setController(true);
|
| 264 |
+
send({ t: 'claim' });
|
| 265 |
+
};
|
| 266 |
+
const onPointerDown = (e: PointerEvent) => {
|
| 267 |
+
// A touch may only be inspecting local scrollback. Claim lazily below if
|
| 268 |
+
// the gesture actually needs to drive an application's mouse mode.
|
| 269 |
+
if (e.pointerType !== 'touch') claimControl();
|
| 270 |
+
};
|
| 271 |
+
const onPaste = () => claimControl();
|
| 272 |
+
host.addEventListener('pointerdown', onPointerDown, true);
|
| 273 |
+
host.addEventListener('paste', onPaste, true);
|
| 274 |
+
|
| 275 |
// The mobile key-bar sends control sequences the on-screen keyboard can't.
|
| 276 |
+
sendKeyRef.current = (d: string) => {
|
| 277 |
+
claimControl();
|
| 278 |
+
send({ t: 'i', d });
|
| 279 |
+
endBoot();
|
| 280 |
+
};
|
| 281 |
|
| 282 |
+
// ⌘/Ctrl+C copies a local selection. Without one, Ctrl+C remains SIGINT;
|
| 283 |
+
// Cmd+C may accept a recent OSC 52 payload deliberately emitted by the app.
|
|
|
|
|
|
|
| 284 |
term.attachCustomKeyEventHandler((e) => {
|
| 285 |
if (e.type !== 'keydown') return true;
|
| 286 |
if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && term.hasSelection()) {
|
| 287 |
copySelection();
|
| 288 |
term.clearSelection();
|
|
|
|
| 289 |
return false;
|
| 290 |
}
|
| 291 |
+
if (e.metaKey && (e.key === 'c' || e.key === 'C')) {
|
| 292 |
+
if (osc52Text && Date.now() - osc52At < 120_000) copyText(osc52Text);
|
|
|
|
|
|
|
| 293 |
return false;
|
| 294 |
}
|
| 295 |
+
claimControl();
|
|
|
|
| 296 |
return true;
|
| 297 |
});
|
| 298 |
// The only local fit: this terminal is still empty, so there is no buffer to
|
| 299 |
// reflow, and it gives the initial size we open the socket with. Every later
|
| 300 |
// size change goes through resync() as a REQUEST — see there.
|
| 301 |
try { fit.fit(); } catch { /* layout not ready yet */ }
|
| 302 |
+
let layoutFrame = 0;
|
| 303 |
+
const syncLocalLayout = () => {
|
| 304 |
+
cancelAnimationFrame(layoutFrame);
|
| 305 |
+
layoutFrame = requestAnimationFrame(() => {
|
| 306 |
+
const box = hostRef.current;
|
| 307 |
+
const root = box?.querySelector<HTMLElement>('.xterm');
|
| 308 |
+
const screen = box?.querySelector<HTMLElement>('.xterm-screen');
|
| 309 |
+
if (!box || !root || !screen) return;
|
| 310 |
+
const style = getComputedStyle(box);
|
| 311 |
+
const innerWidth = box.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
| 312 |
+
const innerHeight = box.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
| 313 |
+
root.style.width = `${Math.max(innerWidth, Math.ceil(screen.getBoundingClientRect().width))}px`;
|
| 314 |
+
root.style.height = `${Math.max(innerHeight, Math.ceil(screen.getBoundingClientRect().height))}px`;
|
| 315 |
+
});
|
| 316 |
+
};
|
| 317 |
+
localLayoutRef.current = syncLocalLayout;
|
| 318 |
+
syncLocalLayout();
|
| 319 |
// Re-measure once the webfont is ready (glyph width changes vs the fallback).
|
| 320 |
+
document.fonts?.ready.then(() => { resync(); syncLocalLayout(); });
|
| 321 |
|
| 322 |
let closedByUs = false;
|
|
|
|
| 323 |
let retry: ReturnType<typeof setTimeout> | null = null;
|
| 324 |
// Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
|
| 325 |
// every second by every open pane. Reset once a connection succeeds.
|
|
|
|
| 350 |
setBooting(false);
|
| 351 |
};
|
| 352 |
const connect = () => {
|
| 353 |
+
controllerRef.current = false;
|
| 354 |
+
setController(false);
|
| 355 |
setConn('connecting');
|
| 356 |
setBooting(true);
|
| 357 |
bootLive = true;
|
|
|
|
| 373 |
if (typeof d === 'string' && d.startsWith(MODE_CTRL)) {
|
| 374 |
try {
|
| 375 |
const m = JSON.parse(d.slice(MODE_CTRL.length));
|
| 376 |
+
if (m.t === 'grid' || m.t === 'restore') {
|
| 377 |
+
controllerRef.current = !!m.controller;
|
| 378 |
+
setController(!!m.controller);
|
| 379 |
+
setViewers(Math.max(1, Number(m.viewers) || 1));
|
| 380 |
+
const applyGrid = () => {
|
| 381 |
+
try {
|
| 382 |
+
if (m.reset) {
|
| 383 |
+
term.reset();
|
| 384 |
+
term.clear();
|
| 385 |
+
}
|
| 386 |
+
if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
|
| 387 |
+
term.resize(m.cols, m.rows);
|
| 388 |
+
}
|
| 389 |
+
syncLocalLayout();
|
| 390 |
+
} catch { /* ignore */ }
|
| 391 |
+
};
|
| 392 |
+
// Writes are asynchronous. A queued empty write is a barrier so
|
| 393 |
+
// resize/reset cannot reinterpret bytes from the preceding grid.
|
| 394 |
+
const geometryChanged = m.cols > 0 && m.rows > 0
|
| 395 |
+
&& (term.cols !== m.cols || term.rows !== m.rows);
|
| 396 |
+
if (m.reset || geometryChanged) term.write('', applyGrid);
|
| 397 |
+
else applyGrid();
|
| 398 |
}
|
| 399 |
} catch { /* ignore */ }
|
| 400 |
return;
|
|
|
|
| 411 |
ws.onclose = (e) => {
|
| 412 |
// A real process exit: stop here and let the user relaunch. Anything
|
| 413 |
// else is a transient drop (sleep/wake, network) → auto-reconnect and
|
| 414 |
+
// reattach to the still-running backend session.
|
| 415 |
endBoot();
|
| 416 |
if (e.code === EXIT_CODE) { setConn('exited'); return; }
|
|
|
|
|
|
|
|
|
|
| 417 |
setConn('closed');
|
| 418 |
if (!closedByUs) {
|
| 419 |
retry = setTimeout(connect, retryDelay);
|
|
|
|
| 423 |
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
|
| 424 |
};
|
| 425 |
// Manual restart: clear the dead run's screen so the content probe watches
|
| 426 |
+
// the new process paint, not leftovers.
|
| 427 |
reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
|
| 428 |
|
| 429 |
+
// Report the pane's preferred size without locally fitting its terminal.
|
| 430 |
+
// Only the current controller's preference changes the canonical grid;
|
| 431 |
+
// watchers retain theirs for a future claim.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
let resyncTimer: ReturnType<typeof setTimeout> | null = null;
|
| 433 |
const requestSize = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
const box = hostRef.current;
|
| 435 |
if (!box || box.clientWidth < 40 || box.clientHeight < 40) return;
|
| 436 |
try {
|
| 437 |
const d = fit.proposeDimensions();
|
| 438 |
+
// Zoom is local presentation, not PTY geometry. Normalize the measured
|
| 439 |
+
// cells back to the 13px canonical font before sending a preference.
|
| 440 |
+
const scale = (Number(term.options.fontSize) || 13) / 13;
|
| 441 |
+
const cols = d ? Math.max(1, Math.floor(d.cols * scale)) : 0;
|
| 442 |
+
const rows = d ? Math.max(1, Math.floor(d.rows * scale)) : 0;
|
| 443 |
+
if (cols > 0 && rows > 0) send({ t: 'r', cols, rows });
|
| 444 |
} catch { /* layout not ready */ }
|
| 445 |
};
|
| 446 |
// ResizeObserver fires every frame while a window is dragged or the sidebar
|
|
|
|
| 449 |
if (resyncTimer) clearTimeout(resyncTimer);
|
| 450 |
resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
|
| 451 |
};
|
| 452 |
+
const onReturn = () => resync();
|
|
|
|
|
|
|
| 453 |
const onVisible = () => { if (!document.hidden) onReturn(); };
|
|
|
|
|
|
|
| 454 |
// Typing means the user sees enough to interact — drop the boot cover.
|
| 455 |
// Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
|
| 456 |
// replies to the TUI's terminal queries (DA/CPR), which arrive instantly
|
| 457 |
// on attach and must not count as "the user typed".
|
| 458 |
+
const keySub = term.onKey(() => { claimControl(); endBoot(); });
|
| 459 |
+
// Watchers render output but do not answer terminal queries. This prevents
|
| 460 |
+
// N browser emulators from injecting N DA/CPR responses into one PTY.
|
| 461 |
+
const dataSub = term.onData((d) => {
|
| 462 |
+
if (controllerRef.current) send({ t: 'i', d });
|
| 463 |
+
});
|
| 464 |
const ro = new ResizeObserver(resync);
|
| 465 |
ro.observe(hostRef.current!);
|
| 466 |
window.addEventListener('focus', onReturn);
|
| 467 |
document.addEventListener('visibilitychange', onVisible);
|
| 468 |
|
| 469 |
+
// Touch scrolling prefers browser-retained history. At the live bottom an
|
| 470 |
+
// app that tracks the mouse receives SGR wheel events from the controller.
|
|
|
|
|
|
|
|
|
|
| 471 |
let touchY: number | null = null;
|
| 472 |
+
const onTouchStart = (e: TouchEvent) => { touchY = e.touches[0].clientY; };
|
| 473 |
const onTouchMove = (e: TouchEvent) => {
|
| 474 |
if (touchY == null) return;
|
| 475 |
const y = e.touches[0].clientY;
|
|
|
|
| 481 |
try { tracking = ((term as unknown as { modes?: { mouseTrackingMode?: string } }).modes?.mouseTrackingMode ?? 'none') !== 'none'; } catch { /* older xterm */ }
|
| 482 |
const btn = steps > 0 ? 64 : 65; // drag down reveals earlier output = wheel up
|
| 483 |
for (let i = 0; i < Math.abs(steps); i++) {
|
| 484 |
+
const inHistory = term.buffer.active.viewportY < term.buffer.active.baseY;
|
| 485 |
+
if (tracking && !inHistory) {
|
| 486 |
+
claimControl();
|
| 487 |
+
send({ t: 'i', d: `\x1b[<${btn};${Math.max(1, Math.floor(term.cols / 2))};${Math.max(1, Math.floor(term.rows / 2))}M` });
|
| 488 |
+
}
|
| 489 |
else term.scrollLines(steps > 0 ? -1 : 1);
|
| 490 |
}
|
| 491 |
}
|
|
|
|
| 504 |
if (bootTimer) clearTimeout(bootTimer);
|
| 505 |
if (bootCheck) clearTimeout(bootCheck);
|
| 506 |
if (resyncTimer) clearTimeout(resyncTimer);
|
| 507 |
+
cancelAnimationFrame(layoutFrame);
|
| 508 |
ro.disconnect();
|
| 509 |
host.removeEventListener('pointerdown', onPointerDown, true);
|
| 510 |
+
host.removeEventListener('paste', onPaste, true);
|
|
|
|
|
|
|
|
|
|
| 511 |
document.removeEventListener('copy', onCopy, true);
|
| 512 |
host.removeEventListener('touchstart', onTouchStart);
|
| 513 |
host.removeEventListener('touchmove', onTouchMove);
|
|
|
|
| 520 |
try { ws?.close(); } catch { /* ignore */ }
|
| 521 |
term.dispose();
|
| 522 |
termRef.current = null;
|
| 523 |
+
localLayoutRef.current = () => {};
|
| 524 |
};
|
| 525 |
}, [session.id]);
|
| 526 |
|
|
|
|
| 529 |
if (termRef.current) termRef.current.options.theme = THEMES[theme];
|
| 530 |
}, [theme]);
|
| 531 |
|
| 532 |
+
// Zoom changes only this viewer. The canonical terminal rows/columns stay
|
| 533 |
+
// fixed, and the host becomes a two-dimensional viewport when cells grow.
|
| 534 |
useEffect(() => {
|
| 535 |
const t = termRef.current;
|
| 536 |
if (!t) return;
|
| 537 |
t.options.fontSize = Math.round((13 * zoom) / 100);
|
| 538 |
+
localLayoutRef.current();
|
| 539 |
}, [zoom]);
|
| 540 |
|
| 541 |
// Move keyboard focus into the terminal whenever this pane becomes the active
|
|
|
|
| 582 |
<span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
|
| 583 |
)}
|
| 584 |
<div className="ph-right">
|
| 585 |
+
{viewers > 1 && (
|
| 586 |
+
<span className={`ph-role${controller ? ' controller' : ''}`} title={controller ? 'This pane controls terminal input and size' : 'Interact with the terminal to take control'}>
|
| 587 |
+
{controller ? `${viewers} viewers` : 'watching'}
|
| 588 |
+
</span>
|
| 589 |
+
)}
|
| 590 |
<span className="ph-path" title={pathLabel}>{pathLabel}</span>
|
| 591 |
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
|
| 592 |
</div>
|
|
|
|
| 594 |
<div className="term-host">
|
| 595 |
<div className="term-fill" ref={hostRef} />
|
| 596 |
</div>
|
|
|
|
|
|
|
|
|
|
| 597 |
{isMobile && conn === 'connected' && (
|
| 598 |
// Control keys the phone keyboard lacks — needed for TUI menus (model
|
| 599 |
// pickers, etc.). preventDefault keeps terminal focus so the keyboard
|
|
|
|
| 652 |
<button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
|
| 653 |
</div>
|
| 654 |
)}
|
| 655 |
+
{booting && conn !== 'exited' && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
<div className="term-boot mono">
|
| 657 |
{conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
|
| 658 |
</div>
|
web/src/styles.css
CHANGED
|
@@ -489,13 +489,6 @@ body {
|
|
| 489 |
/* terminal boot cover ("connecting… / starting claude…") */
|
| 490 |
.term-boot { position: absolute; inset: 0; top: 34px; display: flex; align-items: center; justify-content: center; background: var(--term-bg); color: var(--muted); font-size: 12.5px; z-index: 4; pointer-events: none; }
|
| 491 |
.term-boot .et-cursor { height: 13px; width: 7px; margin-left: 7px; }
|
| 492 |
-
/* copy-mode hint: a translucent banner just under the pane header while tmux is
|
| 493 |
-
in copy/scrollback mode, so a "dead" keyboard makes sense */
|
| 494 |
-
.term-copy-hint { position: absolute; top: 40px; left: 50%; transform: translateX(-50%); z-index: 5; pointer-events: none;
|
| 495 |
-
padding: 3px 11px; border-radius: 999px; font-size: 11px; letter-spacing: 0.01em;
|
| 496 |
-
color: var(--accent-fg); background: color-mix(in srgb, var(--accent) 82%, transparent);
|
| 497 |
-
box-shadow: 0 2px 10px rgb(0 0 0 / 0.25); animation: rise-in 0.14s ease-out; white-space: nowrap; }
|
| 498 |
-
|
| 499 |
/* terminal "process exited" overlay */
|
| 500 |
/* Non-blocking banner so the agent's last output (e.g. a login error) stays readable. */
|
| 501 |
/* stopped state: quiet line centered in the pane, in the boot-cover's voice */
|
|
@@ -552,6 +545,8 @@ body {
|
|
| 552 |
.pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
|
| 553 |
.pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; text-align: center; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: text; }
|
| 554 |
.pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; }
|
|
|
|
|
|
|
| 555 |
.pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
|
| 556 |
.pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
|
| 557 |
.pane-head .ph-close { justify-self: end; }
|
|
@@ -563,14 +558,12 @@ body {
|
|
| 563 |
.slot.focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 4px 18px -10px rgba(0, 0, 0, 0.35); }
|
| 564 |
.slot.focused .pane-head { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
|
| 565 |
|
| 566 |
-
.term-host { flex: 1; min-height: 0; padding: 10px 8px 8px 12px; background: var(--term-bg); }
|
| 567 |
-
/* The
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
very last row, where Claude leaves it blank. The frame keeps the padding; this
|
| 573 |
-
filler is the exact box the grid is measured against. */
|
| 574 |
.term-fill { height: 100%; min-height: 0; }
|
| 575 |
/* mobile control-key bar: the keys a phone keyboard lacks, pinned below the
|
| 576 |
terminal so it rides just above the on-screen keyboard */
|
|
@@ -595,7 +588,7 @@ body {
|
|
| 595 |
.term-paste .tp-input { width: 100%; box-sizing: border-box; resize: none; padding: 6px 8px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
|
| 596 |
background: var(--panel-2); color: var(--text); font-size: 13px; line-height: 1.35; }
|
| 597 |
.term-paste .tp-input:focus { outline: none; border-color: var(--accent); }
|
| 598 |
-
.term-host .xterm { height: 100%; }
|
| 599 |
.xterm .xterm-viewport { scrollbar-width: none; }
|
| 600 |
.xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
| 601 |
|
|
|
|
| 489 |
/* terminal boot cover ("connecting… / starting claude…") */
|
| 490 |
.term-boot { position: absolute; inset: 0; top: 34px; display: flex; align-items: center; justify-content: center; background: var(--term-bg); color: var(--muted); font-size: 12.5px; z-index: 4; pointer-events: none; }
|
| 491 |
.term-boot .et-cursor { height: 13px; width: 7px; margin-left: 7px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
/* terminal "process exited" overlay */
|
| 493 |
/* Non-blocking banner so the agent's last output (e.g. a login error) stays readable. */
|
| 494 |
/* stopped state: quiet line centered in the pane, in the boot-cover's voice */
|
|
|
|
| 545 |
.pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
|
| 546 |
.pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; text-align: center; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: text; }
|
| 547 |
.pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; }
|
| 548 |
+
.pane-head .ph-role { flex: none; padding: 2px 6px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-family: var(--font-mono); font-size: 9.5px; line-height: 1.2; }
|
| 549 |
+
.pane-head .ph-role.controller { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
|
| 550 |
.pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
|
| 551 |
.pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
|
| 552 |
.pane-head .ph-close { justify-self: end; }
|
|
|
|
| 558 |
.slot.focused { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 4px 18px -10px rgba(0, 0, 0, 0.35); }
|
| 559 |
.slot.focused .pane-head { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
|
| 560 |
|
| 561 |
+
.term-host { flex: 1; min-height: 0; padding: 10px 8px 8px 12px; background: var(--term-bg); overflow: auto; overscroll-behavior: contain; }
|
| 562 |
+
/* The grid is measured against this filler, so it must stay unpadded: FitAddon
|
| 563 |
+
reads the PARENT's computed height and does NOT subtract its padding, so a
|
| 564 |
+
padded parent made the grid count a row that doesn't fit and the bottom line
|
| 565 |
+
rendered sliced (most visible with Codex, which paints its status line on the
|
| 566 |
+
very last row). The padding lives on .xterm, which FitAddon does subtract. */
|
|
|
|
|
|
|
| 567 |
.term-fill { height: 100%; min-height: 0; }
|
| 568 |
/* mobile control-key bar: the keys a phone keyboard lacks, pinned below the
|
| 569 |
terminal so it rides just above the on-screen keyboard */
|
|
|
|
| 588 |
.term-paste .tp-input { width: 100%; box-sizing: border-box; resize: none; padding: 6px 8px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
|
| 589 |
background: var(--panel-2); color: var(--text); font-size: 13px; line-height: 1.35; }
|
| 590 |
.term-paste .tp-input:focus { outline: none; border-color: var(--accent); }
|
| 591 |
+
.term-host .xterm { min-width: 100%; min-height: 100%; }
|
| 592 |
.xterm .xterm-viewport { scrollbar-width: none; }
|
| 593 |
.xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
| 594 |
|