Spaces:
Running
Running
Merge pull request #24 from huggingface/feat/libghostty-session-model
Browse files- Dockerfile +5 -3
- README.md +25 -9
- server/fixtures/ink-tui.mjs +63 -0
- server/fixtures/repaint-tui.mjs +62 -0
- server/migration.test.mjs +203 -0
- server/mobile.test.mjs +612 -0
- server/package-lock.json +100 -0
- server/package.json +14 -3
- server/resize.test.mjs +623 -0
- server/src/config.js +13 -10
- server/src/demo.js +1 -1
- server/src/history-store.js +134 -0
- server/src/index.js +54 -66
- server/src/runner.js +837 -223
- server/src/snapshot.js +281 -0
- tmux.conf +0 -163
- web/src/App.tsx +274 -41
- web/src/components/SettingsView.tsx +2 -2
- web/src/components/TerminalPane.tsx +371 -198
- web/src/styles.css +36 -20
Dockerfile
CHANGED
|
@@ -10,7 +10,9 @@ RUN npm run build
|
|
| 10 |
# Node 22+: required by OpenClaw (22.19+); everything else is version-agnostic.
|
| 11 |
FROM node:22-bookworm AS runtime
|
| 12 |
|
| 13 |
-
# System deps:
|
|
|
|
|
|
|
| 14 |
# ripgrep (used by the coding CLIs), curl/ca-certs — plus everyday QoL tools
|
| 15 |
# agents and humans reach for (jq/htop/sqlite3/editors/media, fonts so headless
|
| 16 |
# Chromium screenshots don't render tofu).
|
|
@@ -118,14 +120,14 @@ RUN [ -x /home/node/.local/bin/hermes ] \
|
|
| 118 |
|| true
|
| 119 |
USER node
|
| 120 |
|
| 121 |
-
# App code + built frontend + runtime config (
|
| 122 |
COPY --chown=node:node server/ server/
|
| 123 |
# scripts/ is not developer-only: share.js runs scripts/share-session.mjs as a
|
| 124 |
# child process to build a share bundle off the event loop, so it must ship.
|
| 125 |
COPY --chown=node:node scripts/ scripts/
|
| 126 |
COPY --chown=node:node --from=web /web/dist /app/public
|
| 127 |
COPY --chown=node:node entrypoint.sh /app/entrypoint.sh
|
| 128 |
-
COPY --chown=node:node
|
| 129 |
|
| 130 |
ENV PORT=7860 \
|
| 131 |
DATA_DIR=/data \
|
|
|
|
| 10 |
# Node 22+: required by OpenClaw (22.19+); everything else is version-agnostic.
|
| 11 |
FROM node:22-bookworm AS runtime
|
| 12 |
|
| 13 |
+
# System deps: git, build tools (node-pty native build),
|
| 14 |
+
# tmux is still installed for AGENTS to use if they want it — the app itself no
|
| 15 |
+
# longer runs sessions through it (see server/src/runner.js),
|
| 16 |
# ripgrep (used by the coding CLIs), curl/ca-certs — plus everyday QoL tools
|
| 17 |
# agents and humans reach for (jq/htop/sqlite3/editors/media, fonts so headless
|
| 18 |
# Chromium screenshots don't render tofu).
|
|
|
|
| 120 |
|| true
|
| 121 |
USER node
|
| 122 |
|
| 123 |
+
# App code + built frontend + runtime config (prompt rcfile).
|
| 124 |
COPY --chown=node:node server/ server/
|
| 125 |
# scripts/ is not developer-only: share.js runs scripts/share-session.mjs as a
|
| 126 |
# child process to build a share bundle off the event loop, so it must ship.
|
| 127 |
COPY --chown=node:node scripts/ scripts/
|
| 128 |
COPY --chown=node:node --from=web /web/dist /app/public
|
| 129 |
COPY --chown=node:node entrypoint.sh /app/entrypoint.sh
|
| 130 |
+
COPY --chown=node:node session.bashrc /app/
|
| 131 |
|
| 132 |
ENV PORT=7860 \
|
| 133 |
DATA_DIR=/data \
|
README.md
CHANGED
|
@@ -18,8 +18,9 @@ file browser, all in your browser. Each agent runs in a workspace folder you
|
|
| 18 |
pick at creation (defaulting to where the last agent was created) — names are
|
| 19 |
just labels, independent of folders, and several agents can share a folder.
|
| 20 |
Groups are visual: they organize the sidebar and tile agents side by side.
|
| 21 |
-
Sessions
|
| 22 |
-
|
|
|
|
| 23 |
`SKILL.md` files to every agent, and a **Usage** page shows tokens/cost and your
|
| 24 |
5-hour / weekly quota.
|
| 25 |
|
|
@@ -117,14 +118,28 @@ and `CODEX_HOME` under `/data/state`).
|
|
| 117 |
browser (xterm.js panes)
|
| 118 |
└── WebSocket /ws?session=<id> one connection per visible pane
|
| 119 |
└── Node backend (Express + ws)
|
| 120 |
-
└── node-pty
|
| 121 |
└── claude | codex | gemini | opencode | hermes | bash
|
| 122 |
```
|
| 123 |
|
| 124 |
-
Each agent is a
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
sessions are pinned to a per-session conversation id at creation; Codex sessions
|
| 129 |
are pinned right after first launch (the id is captured from the rollout file
|
| 130 |
Codex creates) — so agents sharing a folder never resume each other's
|
|
@@ -136,7 +151,8 @@ conversations.
|
|
| 136 |
|---|---|---|
|
| 137 |
| `PORT` | `7860` | HTTP + WS port (HF `app_port`) |
|
| 138 |
| `DATA_DIR` | `/data` | Durable root (mounted private Storage Bucket) |
|
| 139 |
-
| `
|
|
|
|
| 140 |
| `ANTHROPIC_API_KEY` | — | Claude Code / opencode / Hermes (Space **secret**) |
|
| 141 |
| `OPENAI_API_KEY` / `CODEX_API_KEY` | — | Codex (Space secret) |
|
| 142 |
| `GEMINI_API_KEY` | — | Gemini CLI (Space secret) |
|
|
@@ -147,7 +163,7 @@ Logging in interactively inside a terminal works too — credentials are written
|
|
| 147 |
## Local development
|
| 148 |
|
| 149 |
```bash
|
| 150 |
-
# backend (
|
| 151 |
cd server && npm install && npm run dev
|
| 152 |
|
| 153 |
# frontend (proxies /api and /ws to the backend on :7860)
|
|
|
|
| 18 |
pick at creation (defaulting to where the last agent was created) — names are
|
| 19 |
just labels, independent of folders, and several agents can share a folder.
|
| 20 |
Groups are visual: they organize the sidebar and tile agents side by side.
|
| 21 |
+
Sessions live in the
|
| 22 |
+
backend, so they survive disconnects and can be watched from several devices at
|
| 23 |
+
once. A **Skills** page distributes reusable
|
| 24 |
`SKILL.md` files to every agent, and a **Usage** page shows tokens/cost and your
|
| 25 |
5-hour / weekly quota.
|
| 26 |
|
|
|
|
| 118 |
browser (xterm.js panes)
|
| 119 |
└── WebSocket /ws?session=<id> one connection per visible pane
|
| 120 |
└── Node backend (Express + ws)
|
| 121 |
+
└── node-pty per agent + a libghostty-vt grid ← the live screen
|
| 122 |
└── claude | codex | gemini | opencode | hermes | bash
|
| 123 |
```
|
| 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) |
|
|
|
|
| 163 |
## Local development
|
| 164 |
|
| 165 |
```bash
|
| 166 |
+
# backend (needs node >= 20.19 for libghostty-vt; only the Shell CLI works offline)
|
| 167 |
cd server && npm install && npm run dev
|
| 168 |
|
| 169 |
# frontend (proxies /api and /ws to the backend on :7860)
|
server/fixtures/ink-tui.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// A stand-in for an agent TUI built the way Claude Code is: Ink (React) on the
|
| 2 |
+
// PRIMARY screen, re-rendering its whole frame when the terminal resizes.
|
| 3 |
+
//
|
| 4 |
+
// This matters because Ink does not repaint by absolute positioning the way
|
| 5 |
+
// fixtures/repaint-tui.mjs does. It PRINTS its frame — cursor up by the previous
|
| 6 |
+
// frame's height, erase downward, print the lines — so a frame taller than the
|
| 7 |
+
// screen scrolls the excess into scrollback, and a frame whose height changed
|
| 8 |
+
// (which is exactly what a width change does, by rewrapping) erases the wrong
|
| 9 |
+
// region and leaves the difference behind. That is the artifact a real agent pane
|
| 10 |
+
// shows on zoom, and no hand-written repaint reproduces it.
|
| 11 |
+
//
|
| 12 |
+
// Every token is unique (`LLL.CC`), so a token appearing twice in the grid is a
|
| 13 |
+
// real duplicate and not two identical filler lines.
|
| 14 |
+
//
|
| 15 |
+
// NOT wired into resize.test.mjs, and ink is deliberately NOT a dependency:
|
| 16 |
+
// installing it here pruned @coder/libghostty-vt-node's native prebuild and broke
|
| 17 |
+
// every session until `npm install` put it back. Run it out of tree instead —
|
| 18 |
+
//
|
| 19 |
+
// mkdir /tmp/inkbox && cd /tmp/inkbox
|
| 20 |
+
// npm init -y && npm i ink react
|
| 21 |
+
// cp .../fixtures/ink-tui.mjs . && node ink-tui.mjs
|
| 22 |
+
//
|
| 23 |
+
// The finding it produced is recorded where it matters: Ink erases its previous
|
| 24 |
+
// frame by height before reprinting, so an Ink app leaves no duplicates on resize.
|
| 25 |
+
// The artifacts a real agent pane shows come from a frame that OVERFLOWS the
|
| 26 |
+
// screen, which fixtures/repaint-tui.mjs reproduces with FIXED_LINES set.
|
| 27 |
+
import React from 'react';
|
| 28 |
+
import { render, Box, Text, useStdout } from 'ink';
|
| 29 |
+
|
| 30 |
+
const TRANSCRIPT = Array.from({ length: 60 }, (_, i) =>
|
| 31 |
+
Array.from({ length: 22 }, (_, j) => `${String(i + 1).padStart(3, '0')}.${String(j).padStart(2, '0')}`).join(' '));
|
| 32 |
+
|
| 33 |
+
function App() {
|
| 34 |
+
const { stdout } = useStdout();
|
| 35 |
+
const [size, setSize] = React.useState({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
|
| 36 |
+
React.useEffect(() => {
|
| 37 |
+
const onResize = () => setSize({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
|
| 38 |
+
stdout.on('resize', onResize);
|
| 39 |
+
return () => stdout.off('resize', onResize);
|
| 40 |
+
}, [stdout]);
|
| 41 |
+
|
| 42 |
+
// A fixed number of transcript lines, NOT trimmed to fit — the important part.
|
| 43 |
+
// An agent renders the tail of its conversation, so narrowing the pane wraps
|
| 44 |
+
// those lines and the frame becomes TALLER than the screen. Printing it then
|
| 45 |
+
// scrolls the overflow into scrollback, which is where the duplicates a user
|
| 46 |
+
// sees after zooming in actually come from.
|
| 47 |
+
const KEEP = 30;
|
| 48 |
+
return React.createElement(
|
| 49 |
+
Box,
|
| 50 |
+
{ flexDirection: 'column' },
|
| 51 |
+
...TRANSCRIPT.slice(-KEEP).map((line, i) => React.createElement(Text, { key: i }, line)),
|
| 52 |
+
React.createElement(
|
| 53 |
+
Box,
|
| 54 |
+
{ borderStyle: 'round', paddingX: 1 },
|
| 55 |
+
React.createElement(Text, null, `[ink ${size.cols}x${size.rows}]`),
|
| 56 |
+
),
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
render(React.createElement(App));
|
| 61 |
+
// Stay up like an agent waiting for input, so a resize has something to answer.
|
| 62 |
+
process.stdin.resume();
|
| 63 |
+
setInterval(() => {}, 1 << 30);
|
server/fixtures/repaint-tui.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// A stand-in for an agent TUI, for the resize tests. Repaints the way Claude
|
| 2 |
+
// Code does (measured from a live pane): hide the cursor, home, erase every
|
| 3 |
+
// visible row with `\x1b[2K\x1b[1B`, home again, then print the tail of its
|
| 4 |
+
// transcript wrapped to the CURRENT width — no screen clear, no scrollback
|
| 5 |
+
// clear, no alternate screen. That is what makes a resize duplicate content:
|
| 6 |
+
// rows the emulator pushed into scrollback while shrinking are printed again.
|
| 7 |
+
//
|
| 8 |
+
// Every token is unique (`LLL.CC`), so a token appearing twice in the grid is a
|
| 9 |
+
// real duplicate and not two identical filler lines.
|
| 10 |
+
import fs from 'node:fs';
|
| 11 |
+
import path from 'node:path';
|
| 12 |
+
|
| 13 |
+
const COLS = () => process.stdout.columns || 80;
|
| 14 |
+
const ROWS = () => process.stdout.rows || 24;
|
| 15 |
+
|
| 16 |
+
const TRANSCRIPT = Array.from({ length: 60 }, (_, i) =>
|
| 17 |
+
Array.from({ length: 22 }, (_, j) => `${String(i + 1).padStart(3, '0')}.${String(j).padStart(2, '0')}`).join(' '));
|
| 18 |
+
|
| 19 |
+
// With FIXED_LINES set, print that many transcript lines whatever the size —
|
| 20 |
+
// which is what an agent showing the tail of a conversation does. Narrowing the
|
| 21 |
+
// pane then WRAPS those lines, so the printed frame becomes taller than the screen
|
| 22 |
+
// and printing it scrolls the overflow into scrollback. That overflow is a copy of
|
| 23 |
+
// what the frame also shows, and it is the artifact a real agent pane leaves
|
| 24 |
+
// behind on zoom. Without the variable, the frame is trimmed to fit and nothing
|
| 25 |
+
// scrolls.
|
| 26 |
+
const FIXED = Number(process.env.FIXED_LINES || 0);
|
| 27 |
+
const HISTORY = Number(process.env.HISTORY_LINES || 0);
|
| 28 |
+
|
| 29 |
+
function paint(limit = null) {
|
| 30 |
+
let out = '\x1b[?25l\x1b[H';
|
| 31 |
+
for (let r = 0; r < ROWS(); r++) out += '\x1b[2K\x1b[1B';
|
| 32 |
+
out += '\x1b[H';
|
| 33 |
+
// Print enough of the transcript to fill the screen at this width — the tail,
|
| 34 |
+
// like a TUI showing the most recent output above its input box.
|
| 35 |
+
const perLine = Math.max(1, Math.ceil(TRANSCRIPT[0].length / COLS()));
|
| 36 |
+
const fit = Math.max(1, Math.floor((ROWS() - 2) / perLine));
|
| 37 |
+
out += TRANSCRIPT.slice(-(limit ?? (FIXED || fit)))
|
| 38 |
+
.map((line, index) => `\x1b[38;5;${31 + (index % 6)}m${line}\x1b[0m`)
|
| 39 |
+
.join('\r\n');
|
| 40 |
+
out += `\r\n[fixture ${COLS()}x${ROWS()}]\x1b[?25h`;
|
| 41 |
+
process.stdout.write(out);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
process.stdout.on('resize', paint);
|
| 45 |
+
process.stdin.resume();
|
| 46 |
+
if (HISTORY > 0) {
|
| 47 |
+
process.stdout.write(Array.from({ length: HISTORY }, (_, i) =>
|
| 48 |
+
`history-${String(i + 1).padStart(4, '0')}`).join('\r\n') + '\r\n');
|
| 49 |
+
}
|
| 50 |
+
const resumeDelay = Number(process.env.DELAY_RESUME_MS || 0);
|
| 51 |
+
const launchMarker = path.join(process.cwd(), '.repaint-fixture-launched');
|
| 52 |
+
if (resumeDelay > 0 && fs.existsSync(launchMarker)) {
|
| 53 |
+
// Claude can pause after the first part of its resumed screen. A startup
|
| 54 |
+
// capture must span that gap; otherwise the remainder is mistaken for new
|
| 55 |
+
// terminal history and duplicates the persisted transcript.
|
| 56 |
+
paint(1);
|
| 57 |
+
setTimeout(paint, resumeDelay);
|
| 58 |
+
} else {
|
| 59 |
+
if (resumeDelay > 0) fs.writeFileSync(launchMarker, '1');
|
| 60 |
+
paint();
|
| 61 |
+
}
|
| 62 |
+
setInterval(() => {}, 1 << 30);
|
server/migration.test.mjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:';
|
| 14 |
+
const base = `http://localhost:${PORT}`;
|
| 15 |
+
|
| 16 |
+
let failures = 0;
|
| 17 |
+
const check = (name, ok, detail = '') => {
|
| 18 |
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
| 19 |
+
if (!ok) failures++;
|
| 20 |
+
};
|
| 21 |
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
| 22 |
+
|
| 23 |
+
const srv = spawn('node', ['src/index.js'], {
|
| 24 |
+
env: { ...process.env, PORT: String(PORT), DATA_DIR, AM_BASHRC: '/nonexistent' },
|
| 25 |
+
stdio: ['ignore', 'pipe', 'pipe'],
|
| 26 |
+
});
|
| 27 |
+
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/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}`);
|
| 35 |
+
const v = {
|
| 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 {} },
|
| 43 |
+
};
|
| 44 |
+
ws.on('message', (data) => {
|
| 45 |
+
const s = data.toString('utf8');
|
| 46 |
+
if (s.startsWith(CTRL)) { try { v.frames.push(JSON.parse(s.slice(CTRL.length))); } catch {} }
|
| 47 |
+
else v.bytes += s;
|
| 48 |
+
});
|
| 49 |
+
ws.on('close', (code) => v.closes.push(code));
|
| 50 |
+
ws.on('open', () => resolve(v));
|
| 51 |
+
ws.on('error', () => resolve(v));
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const stateOf = async (id) => {
|
| 56 |
+
const tree = await (await fetch(`${base}/api/tree`)).json();
|
| 57 |
+
return (tree.sessions || []).find((s) => s.id === id) || null;
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
try {
|
| 61 |
+
let up = false;
|
| 62 |
+
// Generous: importing the dependency tree off a cold FUSE-mounted workspace can
|
| 63 |
+
// take half a minute (express alone measured 32s), and a boot timeout looks
|
| 64 |
+
// exactly like a broken server.
|
| 65 |
+
for (let i = 0; i < 400; i++) {
|
| 66 |
+
try { const r = await fetch(`${base}/api/health`); if (r.ok) { up = true; break; } } catch {}
|
| 67 |
+
await sleep(250);
|
| 68 |
+
}
|
| 69 |
+
check('server boots without tmux', up);
|
| 70 |
+
if (!up) { console.log(bootLog.slice(-1500)); throw new Error('no boot'); }
|
| 71 |
+
|
| 72 |
+
const health = await (await fetch(`${base}/api/health`)).json();
|
| 73 |
+
check('health reports the libghostty engine', health.engine === 'libghostty' && health.ghostty === true,
|
| 74 |
+
health.ghosttyError || `engine=${health.engine} ghostty=${health.ghostty}`);
|
| 75 |
+
|
| 76 |
+
const created = await (await fetch(`${base}/api/sessions`, {
|
| 77 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 78 |
+
body: JSON.stringify({ cli: 'shell', name: 'mig test' }),
|
| 79 |
+
})).json();
|
| 80 |
+
check('session created', !!created.id, created.id || JSON.stringify(created).slice(0, 140));
|
| 81 |
+
const id = created.id;
|
| 82 |
+
if (!id) throw new Error('no session');
|
| 83 |
+
|
| 84 |
+
// --- attach, run something, verify output ---------------------------------
|
| 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'),
|
| 92 |
+
`${a.bytes.length}B received`);
|
| 93 |
+
|
| 94 |
+
// --- closing the browser must NOT kill the session ------------------------
|
| 95 |
+
a.close();
|
| 96 |
+
await sleep(700);
|
| 97 |
+
const survived = await stateOf(id);
|
| 98 |
+
check('session survives the viewer closing', !!survived && survived.state !== 'stopped',
|
| 99 |
+
survived ? `state=${survived.state}` : 'missing from tree');
|
| 100 |
+
|
| 101 |
+
// --- work continues with nobody attached ---------------------------------
|
| 102 |
+
await fetch(`${base}/api/sessions/${id}/input`, {
|
| 103 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 104 |
+
body: JSON.stringify({ text: 'echo ran-while-detached' }),
|
| 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]'));
|
| 117 |
+
|
| 118 |
+
// --- state comes from the grid, no subprocess ----------------------------
|
| 119 |
+
b.type('for i in 1 2 3 4 5 6 7 8; do echo busy $i; sleep 0.4; done\r');
|
| 120 |
+
await sleep(1700);
|
| 121 |
+
const busy = await stateOf(id);
|
| 122 |
+
check('a busy session reads as working', busy && busy.state === 'working', busy && busy.state);
|
| 123 |
+
// Poll rather than guess: the loop above takes ~3.2s and the working
|
| 124 |
+
// threshold is 4s of unchanged text, so a fixed sleep races the transition.
|
| 125 |
+
let calm = null;
|
| 126 |
+
for (let i = 0; i < 30; i++) {
|
| 127 |
+
calm = await stateOf(id);
|
| 128 |
+
if (calm && calm.state === 'idle') break;
|
| 129 |
+
await sleep(500);
|
| 130 |
+
}
|
| 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));
|
| 139 |
+
check('agent tail endpoint answers from the grid', tailRes.ok && tailText.length > 0,
|
| 140 |
+
`${tailText.length} chars`);
|
| 141 |
+
check('agent tail includes screen AND scrollback',
|
| 142 |
+
tailText.includes('hello-from-viewer-a') && tailText.includes('busy 8'),
|
| 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);
|
| 150 |
+
await sleep(500);
|
| 151 |
+
b.resize(40, 20);
|
| 152 |
+
await sleep(900);
|
| 153 |
+
check('both viewers stay connected (no handover)', b.open() && c.open());
|
| 154 |
+
const bGrid = b.lastFrame('grid');
|
| 155 |
+
const cGrid = c.lastFrame('grid');
|
| 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' });
|
| 186 |
+
await sleep(1000);
|
| 187 |
+
check('explicit stop closes viewers with the exit code', c.closes.includes(4000),
|
| 188 |
+
`codes=${JSON.stringify(c.closes)}`);
|
| 189 |
+
const after = await stateOf(id);
|
| 190 |
+
check('stopped session reports stopped', after && after.state === 'stopped', after && after.state);
|
| 191 |
+
|
| 192 |
+
await fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
|
| 193 |
+
} catch (err) {
|
| 194 |
+
check('no exceptions', false, String(err && err.message ? err.message : err));
|
| 195 |
+
console.log('--- server log tail ---\n' + bootLog.slice(-1200));
|
| 196 |
+
} finally {
|
| 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/mobile.test.mjs
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Browser-level mobile terminal invariants.
|
| 2 |
+
//
|
| 3 |
+
// This deliberately starts with a desktop controller so opening the same
|
| 4 |
+
// session on a phone exercises the watcher -> controller handoff. Chromium's
|
| 5 |
+
// visual viewport is replaced with a controllable EventTarget so the keyboard
|
| 6 |
+
// test covers both viewport height and iOS's non-zero offsetTop.
|
| 7 |
+
//
|
| 8 |
+
// npm run test:mobile
|
| 9 |
+
import fs from 'node:fs';
|
| 10 |
+
import os from 'node:os';
|
| 11 |
+
import path from 'node:path';
|
| 12 |
+
import { spawn, spawnSync } from 'node:child_process';
|
| 13 |
+
import { fileURLToPath } from 'node:url';
|
| 14 |
+
import { chromium } from 'playwright';
|
| 15 |
+
import { WebSocket } from 'ws';
|
| 16 |
+
|
| 17 |
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 18 |
+
const ROOT = path.dirname(HERE);
|
| 19 |
+
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-mobile-'));
|
| 20 |
+
const PUBLIC_DIR = process.env.MOBILE_PUBLIC_DIR || path.join(DATA_DIR, 'public');
|
| 21 |
+
const API = 'http://127.0.0.1:7896';
|
| 22 |
+
const WEB = API;
|
| 23 |
+
const CTRL = '\x00\x00AM:';
|
| 24 |
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 25 |
+
|
| 26 |
+
let failures = 0;
|
| 27 |
+
const check = (name, ok, detail = '') => {
|
| 28 |
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
| 29 |
+
if (!ok) failures++;
|
| 30 |
+
};
|
| 31 |
+
const waitFor = async (fn, timeout = 15_000) => {
|
| 32 |
+
const until = Date.now() + timeout;
|
| 33 |
+
while (Date.now() < until) {
|
| 34 |
+
try { if (await fn()) return true; } catch {}
|
| 35 |
+
await sleep(100);
|
| 36 |
+
}
|
| 37 |
+
return false;
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
if (!process.env.MOBILE_PUBLIC_DIR) {
|
| 41 |
+
const build = spawnSync('npm', ['run', 'build', '--', '--outDir', PUBLIC_DIR], {
|
| 42 |
+
cwd: path.join(ROOT, 'web'), encoding: 'utf8',
|
| 43 |
+
});
|
| 44 |
+
if (build.status !== 0) {
|
| 45 |
+
throw new Error(`mobile test web build failed:\n${build.stdout}\n${build.stderr}`);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const backend = spawn('node', ['src/index.js'], {
|
| 50 |
+
cwd: HERE,
|
| 51 |
+
// Do not inherit the production Space hostname: this backend is deliberately
|
| 52 |
+
// reached through localhost, and the origin guard should validate it as such.
|
| 53 |
+
env: {
|
| 54 |
+
...process.env,
|
| 55 |
+
PORT: '7896', DATA_DIR, PUBLIC_DIR, AM_BASHRC: '/nonexistent', SPACE_HOST: '',
|
| 56 |
+
},
|
| 57 |
+
stdio: ['ignore', 'pipe', 'pipe'],
|
| 58 |
+
});
|
| 59 |
+
let logs = '';
|
| 60 |
+
for (const child of [backend]) {
|
| 61 |
+
child.stdout.on('data', (data) => { logs += data; });
|
| 62 |
+
child.stderr.on('data', (data) => { logs += data; });
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
let browser;
|
| 66 |
+
let desktop;
|
| 67 |
+
let id;
|
| 68 |
+
let secondId;
|
| 69 |
+
try {
|
| 70 |
+
const ready = await waitFor(async () => {
|
| 71 |
+
return fetch(`${API}/api/health`).then((r) => r.ok).catch(() => false);
|
| 72 |
+
}, 60_000);
|
| 73 |
+
if (!ready) throw new Error(`test servers did not start:\n${logs.slice(-2000)}`);
|
| 74 |
+
await fetch(`${API}/api/welcome/seen`, { method: 'POST' });
|
| 75 |
+
const created = await (await fetch(`${API}/api/sessions`, {
|
| 76 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 77 |
+
body: JSON.stringify({ cli: 'shell', name: 'mobile-terminal-e2e', path: '.' }),
|
| 78 |
+
})).json();
|
| 79 |
+
id = created.id;
|
| 80 |
+
if (!id) throw new Error(`session creation failed: ${JSON.stringify(created)}`);
|
| 81 |
+
const second = await (await fetch(`${API}/api/sessions`, {
|
| 82 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 83 |
+
body: JSON.stringify({ cli: 'shell', name: 'mobile-terminal-second', path: '.' }),
|
| 84 |
+
})).json();
|
| 85 |
+
secondId = second.id;
|
| 86 |
+
if (!secondId) throw new Error(`second session creation failed: ${JSON.stringify(second)}`);
|
| 87 |
+
|
| 88 |
+
const desktopFrames = [];
|
| 89 |
+
desktop = new WebSocket(`ws://127.0.0.1:7896/ws?session=${id}&cols=140&rows=45`);
|
| 90 |
+
desktop.on('message', (raw) => {
|
| 91 |
+
const text = raw.toString();
|
| 92 |
+
if (text.startsWith(CTRL)) desktopFrames.push(JSON.parse(text.slice(CTRL.length)));
|
| 93 |
+
});
|
| 94 |
+
await new Promise((resolve, reject) => {
|
| 95 |
+
desktop.once('open', resolve);
|
| 96 |
+
desktop.once('error', reject);
|
| 97 |
+
});
|
| 98 |
+
await sleep(400);
|
| 99 |
+
desktop.send(JSON.stringify({
|
| 100 |
+
t: 'i',
|
| 101 |
+
d: "for i in $(seq 1 220); do printf 'MOBILE-HISTORY-%04d\\n' \"$i\"; done; printf '\\033[?1000h\\033[?1006h'\r",
|
| 102 |
+
}));
|
| 103 |
+
const historyReady = await waitFor(async () => {
|
| 104 |
+
const body = await (await fetch(`${API}/api/agents/${id}/tail?lines=400`)).json();
|
| 105 |
+
return body.text?.includes('MOBILE-HISTORY-0220');
|
| 106 |
+
});
|
| 107 |
+
if (!historyReady) throw new Error('history did not reach the terminal');
|
| 108 |
+
|
| 109 |
+
browser = await chromium.launch({ headless: true });
|
| 110 |
+
const context = await browser.newContext({
|
| 111 |
+
viewport: { width: 375, height: 667 },
|
| 112 |
+
screen: { width: 375, height: 667 },
|
| 113 |
+
deviceScaleFactor: 2,
|
| 114 |
+
hasTouch: true,
|
| 115 |
+
isMobile: true,
|
| 116 |
+
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
|
| 117 |
+
});
|
| 118 |
+
await context.addInitScript(() => {
|
| 119 |
+
const nativeWebSocket = window.WebSocket;
|
| 120 |
+
window.__terminalSockets = [];
|
| 121 |
+
class RecordedWebSocket extends nativeWebSocket {
|
| 122 |
+
constructor(url, protocols) {
|
| 123 |
+
super(url, protocols);
|
| 124 |
+
if (String(url).includes('/ws?session=')) {
|
| 125 |
+
const record = { url: String(url), sent: [], events: [] };
|
| 126 |
+
window.__terminalSockets.push(record);
|
| 127 |
+
this.addEventListener('open', () => record.events.push({ type: 'open' }));
|
| 128 |
+
this.addEventListener('close', (event) => record.events.push({
|
| 129 |
+
type: 'close', code: event.code, reason: event.reason,
|
| 130 |
+
}));
|
| 131 |
+
this.addEventListener('error', () => record.events.push({ type: 'error' }));
|
| 132 |
+
this.addEventListener('message', (event) => {
|
| 133 |
+
record.events.push({ type: 'message', dataType: typeof event.data });
|
| 134 |
+
if (typeof event.data === 'string' && event.data.startsWith('\x00\x00AM:')) {
|
| 135 |
+
try { record.lastFrame = JSON.parse(event.data.slice(5)); } catch {}
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
const nativeSend = this.send;
|
| 139 |
+
this.send = (data) => {
|
| 140 |
+
try { record.sent.push(JSON.parse(String(data))); } catch {}
|
| 141 |
+
return nativeSend.call(this, data);
|
| 142 |
+
};
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
class OfflineWebSocket extends EventTarget {
|
| 147 |
+
static CONNECTING = 0;
|
| 148 |
+
static OPEN = 1;
|
| 149 |
+
static CLOSING = 2;
|
| 150 |
+
static CLOSED = 3;
|
| 151 |
+
readyState = OfflineWebSocket.CONNECTING;
|
| 152 |
+
binaryType = 'blob';
|
| 153 |
+
send() {}
|
| 154 |
+
close() { this.readyState = OfflineWebSocket.CLOSED; }
|
| 155 |
+
}
|
| 156 |
+
let offline = false;
|
| 157 |
+
try { offline = localStorage.getItem('__am_test_offline_sockets') === '1'; } catch {}
|
| 158 |
+
window.WebSocket = offline ? OfflineWebSocket : RecordedWebSocket;
|
| 159 |
+
|
| 160 |
+
const viewport = new EventTarget();
|
| 161 |
+
Object.assign(viewport, {
|
| 162 |
+
width: 375, height: 667, offsetLeft: 0, offsetTop: 0,
|
| 163 |
+
pageLeft: 0, pageTop: 0, scale: 1,
|
| 164 |
+
});
|
| 165 |
+
Object.defineProperty(window, 'visualViewport', {
|
| 166 |
+
configurable: true, value: viewport,
|
| 167 |
+
});
|
| 168 |
+
const virtualKeyboard = new EventTarget();
|
| 169 |
+
virtualKeyboard.boundingRect = new DOMRect(0, 667, 375, 0);
|
| 170 |
+
Object.defineProperty(navigator, 'virtualKeyboard', {
|
| 171 |
+
configurable: true, value: virtualKeyboard,
|
| 172 |
+
});
|
| 173 |
+
window.__setVirtualKeyboard = (top, height, width = viewport.width) => {
|
| 174 |
+
virtualKeyboard.boundingRect = new DOMRect(0, top, width, height);
|
| 175 |
+
virtualKeyboard.dispatchEvent(new Event('geometrychange'));
|
| 176 |
+
};
|
| 177 |
+
window.__setVisualViewport = (height, offsetTop, width = viewport.width) => {
|
| 178 |
+
viewport.width = width;
|
| 179 |
+
viewport.height = height;
|
| 180 |
+
viewport.offsetTop = offsetTop;
|
| 181 |
+
viewport.pageTop = offsetTop;
|
| 182 |
+
viewport.dispatchEvent(new Event('resize'));
|
| 183 |
+
viewport.dispatchEvent(new Event('scroll'));
|
| 184 |
+
};
|
| 185 |
+
// Safari can fire with the final height but a stale zero offset, then update
|
| 186 |
+
// offsetTop without another event. Focus stabilization must catch that.
|
| 187 |
+
window.__setVisualViewportLate = (height, offsetTop, delay = 80) => {
|
| 188 |
+
viewport.height = height;
|
| 189 |
+
viewport.offsetTop = 0;
|
| 190 |
+
viewport.pageTop = 0;
|
| 191 |
+
viewport.dispatchEvent(new Event('resize'));
|
| 192 |
+
setTimeout(() => {
|
| 193 |
+
viewport.offsetTop = offsetTop;
|
| 194 |
+
viewport.pageTop = offsetTop;
|
| 195 |
+
}, delay);
|
| 196 |
+
};
|
| 197 |
+
});
|
| 198 |
+
const page = await context.newPage();
|
| 199 |
+
await page.goto(WEB, { waitUntil: 'domcontentloaded' });
|
| 200 |
+
await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
|
| 201 |
+
await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
|
| 202 |
+
|
| 203 |
+
const latestGrid = () => [...desktopFrames].reverse().find((frame) =>
|
| 204 |
+
frame.t === 'grid' || frame.t === 'restore');
|
| 205 |
+
const mobileClaimed = await waitFor(() => desktopFrames.some((frame) =>
|
| 206 |
+
frame.t === 'grid' && frame.controller === false && frame.cols < 60));
|
| 207 |
+
const initialSocket = await page.evaluate(() => window.__terminalSockets.at(-1));
|
| 208 |
+
check('opening a session on mobile claims geometry before resizing',
|
| 209 |
+
mobileClaimed && initialSocket?.sent?.some((message) => message.t === 'claim'),
|
| 210 |
+
JSON.stringify({
|
| 211 |
+
mobileClaimed,
|
| 212 |
+
id,
|
| 213 |
+
mobileUrl: initialSocket?.url,
|
| 214 |
+
mobileFrame: initialSocket?.lastFrame,
|
| 215 |
+
mobileEvents: initialSocket?.events,
|
| 216 |
+
sent: initialSocket?.sent,
|
| 217 |
+
desktopState: desktop.readyState,
|
| 218 |
+
desktopLast: latestGrid(),
|
| 219 |
+
}));
|
| 220 |
+
|
| 221 |
+
const initialFit = await page.evaluate(() => {
|
| 222 |
+
const host = document.querySelector('.tile-terminal:not(.tile-cached) .term-host').getBoundingClientRect();
|
| 223 |
+
const screen = document.querySelector('.tile-terminal:not(.tile-cached) .xterm-screen').getBoundingClientRect();
|
| 224 |
+
return { host: { width: host.width, height: host.height }, screen: { width: screen.width, height: screen.height } };
|
| 225 |
+
});
|
| 226 |
+
check('the initial mobile terminal fits its panel in both dimensions',
|
| 227 |
+
initialFit.screen.width <= initialFit.host.width + 1
|
| 228 |
+
&& initialFit.screen.height <= initialFit.host.height + 1,
|
| 229 |
+
JSON.stringify(initialFit));
|
| 230 |
+
|
| 231 |
+
// Switching to another session and back must retain the original xterm and
|
| 232 |
+
// WebSocket. A mobile Back alone was never sufficient to catch this: it only
|
| 233 |
+
// hides the whole stage without changing activeRef.
|
| 234 |
+
const firstSocketUrl = initialSocket.url;
|
| 235 |
+
await page.getByTitle('Back to list').click();
|
| 236 |
+
await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-second' }).first().click();
|
| 237 |
+
await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
|
| 238 |
+
const secondOpened = await waitFor(() => page.evaluate(() => window.__terminalSockets.length === 2));
|
| 239 |
+
await page.getByTitle('Back to list').click();
|
| 240 |
+
await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
|
| 241 |
+
await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
|
| 242 |
+
await sleep(250);
|
| 243 |
+
const retained = await page.evaluate((url) => {
|
| 244 |
+
const matching = window.__terminalSockets.filter((socket) => socket.url === url);
|
| 245 |
+
const cachedTiles = [...document.querySelectorAll('.tile-terminal')];
|
| 246 |
+
return {
|
| 247 |
+
matching: matching.length,
|
| 248 |
+
closed: matching.flatMap((socket) => socket.events).some((event) => event.type === 'close'),
|
| 249 |
+
terminals: document.querySelectorAll('.tile-terminal .xterm').length,
|
| 250 |
+
hidden: cachedTiles.filter((tile) => getComputedStyle(tile).display === 'none').length,
|
| 251 |
+
};
|
| 252 |
+
}, firstSocketUrl);
|
| 253 |
+
check('switching sessions reuses the original terminal and socket',
|
| 254 |
+
secondOpened && retained.matching === 1 && !retained.closed
|
| 255 |
+
&& retained.terminals === 2 && retained.hidden === 1,
|
| 256 |
+
JSON.stringify({ secondOpened, retained }));
|
| 257 |
+
const hiddenMessagesBeforeZoom = await page.evaluate((sessionId) => {
|
| 258 |
+
const socket = window.__terminalSockets.find((item) => item.url.includes(`session=${sessionId}`));
|
| 259 |
+
return socket?.sent?.length ?? -1;
|
| 260 |
+
}, secondId);
|
| 261 |
+
await page.getByTitle('Zoom in').click();
|
| 262 |
+
await page.getByTitle('Zoom out').click();
|
| 263 |
+
await sleep(250);
|
| 264 |
+
const hiddenMessagesAfterZoom = await page.evaluate((sessionId) => {
|
| 265 |
+
const socket = window.__terminalSockets.find((item) => item.url.includes(`session=${sessionId}`));
|
| 266 |
+
return socket?.sent?.length ?? -1;
|
| 267 |
+
}, secondId);
|
| 268 |
+
check('zooming the active pane does not claim or resize a hidden cached pane',
|
| 269 |
+
hiddenMessagesBeforeZoom >= 0 && hiddenMessagesAfterZoom === hiddenMessagesBeforeZoom,
|
| 270 |
+
JSON.stringify({ hiddenMessagesBeforeZoom, hiddenMessagesAfterZoom }));
|
| 271 |
+
|
| 272 |
+
// If the initial-claim assertion failed, take control through the existing
|
| 273 |
+
// explicit zoom path so scrolling and keyboard assertions remain diagnostic.
|
| 274 |
+
if (!mobileClaimed) {
|
| 275 |
+
await page.getByTitle('Zoom in').click();
|
| 276 |
+
await page.getByTitle('Zoom out').click();
|
| 277 |
+
await waitFor(() => desktopFrames.some((frame) => frame.controller === false));
|
| 278 |
+
}
|
| 279 |
+
await sleep(500);
|
| 280 |
+
|
| 281 |
+
const portraitGrid = latestGrid();
|
| 282 |
+
await page.setViewportSize({ width: 667, height: 375 });
|
| 283 |
+
await page.evaluate(() => window.__setVisualViewport(375, 0, 667));
|
| 284 |
+
const landscapeResized = await waitFor(() => {
|
| 285 |
+
const frame = latestGrid();
|
| 286 |
+
return frame?.cols > portraitGrid.cols && frame?.rows < portraitGrid.rows;
|
| 287 |
+
});
|
| 288 |
+
const landscapeFit = await page.evaluate(() => {
|
| 289 |
+
const host = document.querySelector('.tile-terminal:not(.tile-cached) .term-host').getBoundingClientRect();
|
| 290 |
+
const screen = document.querySelector('.tile-terminal:not(.tile-cached) .xterm-screen').getBoundingClientRect();
|
| 291 |
+
return { host: { width: host.width, height: host.height }, screen: { width: screen.width, height: screen.height } };
|
| 292 |
+
});
|
| 293 |
+
check('orientation changes refit mobile rows and columns',
|
| 294 |
+
landscapeResized && landscapeFit.screen.width <= landscapeFit.host.width + 1
|
| 295 |
+
&& landscapeFit.screen.height <= landscapeFit.host.height + 1,
|
| 296 |
+
JSON.stringify({ landscapeResized, portraitGrid, landscape: latestGrid(), landscapeFit }));
|
| 297 |
+
await page.setViewportSize({ width: 375, height: 667 });
|
| 298 |
+
await page.evaluate(() => window.__setVisualViewport(667, 0, 375));
|
| 299 |
+
await waitFor(() => {
|
| 300 |
+
const frame = latestGrid();
|
| 301 |
+
return frame?.cols === portraitGrid.cols && frame?.rows === portraitGrid.rows;
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
await waitFor(() => page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) =>
|
| 305 |
+
node.scrollHeight > node.clientHeight));
|
| 306 |
+
const returnedToBottom = await waitFor(() => page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) =>
|
| 307 |
+
Math.abs(node.scrollTop - (node.scrollHeight - node.clientHeight)) <= 1));
|
| 308 |
+
check('returning to portrait keeps a live terminal anchored at the bottom', returnedToBottom);
|
| 309 |
+
|
| 310 |
+
const beforeScroll = await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => ({
|
| 311 |
+
top: node.scrollTop, max: node.scrollHeight - node.clientHeight,
|
| 312 |
+
area: node.querySelector('.xterm-scroll-area')?.getBoundingClientRect().height || 0,
|
| 313 |
+
}));
|
| 314 |
+
const messagesBeforeScroll = await page.evaluate(() =>
|
| 315 |
+
window.__terminalSockets.reduce((count, socket) => count + socket.sent.length, 0));
|
| 316 |
+
await page.evaluate((sessionId) => {
|
| 317 |
+
const original = Storage.prototype.setItem;
|
| 318 |
+
window.__previewWritesDuringGesture = 0;
|
| 319 |
+
Storage.prototype.setItem = function instrumentPreviewWrite(key, value) {
|
| 320 |
+
if (key === `am-terminal-preview:${sessionId}`) window.__previewWritesDuringGesture++;
|
| 321 |
+
return original.call(this, key, value);
|
| 322 |
+
};
|
| 323 |
+
}, id);
|
| 324 |
+
const hostBox = await page.locator('.tile-terminal:not(.tile-cached) .term-host').boundingBox();
|
| 325 |
+
const x = hostBox.x + hostBox.width / 2;
|
| 326 |
+
const y = hostBox.y + Math.min(120, hostBox.height / 3);
|
| 327 |
+
const dragDistance = 96;
|
| 328 |
+
const dragSteps = 16;
|
| 329 |
+
await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchstart', {
|
| 330 |
+
touches: [{ identifier: 1, clientX: x, clientY: y }],
|
| 331 |
+
});
|
| 332 |
+
for (let step = 1; step <= dragSteps; step++) {
|
| 333 |
+
await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchmove', {
|
| 334 |
+
touches: [{
|
| 335 |
+
identifier: 1, clientX: x, clientY: y + (dragDistance * step / dragSteps),
|
| 336 |
+
}],
|
| 337 |
+
});
|
| 338 |
+
// Keep the gesture active beyond the preview debounce. A throttle would
|
| 339 |
+
// write synchronously during this loop; a true debounce must stay silent.
|
| 340 |
+
await sleep(60);
|
| 341 |
+
}
|
| 342 |
+
const writesBeforeTouchEnd = await page.evaluate(() => window.__previewWritesDuringGesture);
|
| 343 |
+
await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchend', { touches: [] });
|
| 344 |
+
await sleep(800);
|
| 345 |
+
const historyTop = await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => node.scrollTop);
|
| 346 |
+
const writesAfterTouchEnd = await page.evaluate(() => window.__previewWritesDuringGesture);
|
| 347 |
+
const messagesAfterScroll = await page.evaluate(() =>
|
| 348 |
+
window.__terminalSockets.reduce((count, socket) => count + socket.sent.length, 0));
|
| 349 |
+
const scrolledPixels = beforeScroll.top - historyTop;
|
| 350 |
+
check('small mobile touch moves track the finger through mouse-mode history',
|
| 351 |
+
beforeScroll.max > 0
|
| 352 |
+
&& scrolledPixels >= dragDistance * 0.8
|
| 353 |
+
&& scrolledPixels <= dragDistance * 1.2,
|
| 354 |
+
JSON.stringify({ beforeScroll, historyTop, scrolledPixels, dragDistance }));
|
| 355 |
+
check('terminal preview persistence waits until the touch gesture is idle',
|
| 356 |
+
writesBeforeTouchEnd === 0 && writesAfterTouchEnd >= 1,
|
| 357 |
+
JSON.stringify({ writesBeforeTouchEnd, writesAfterTouchEnd }));
|
| 358 |
+
check('local history scrolling sends no PTY control or resize messages',
|
| 359 |
+
messagesAfterScroll === messagesBeforeScroll,
|
| 360 |
+
JSON.stringify({ messagesBeforeScroll, messagesAfterScroll }));
|
| 361 |
+
|
| 362 |
+
await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchstart', {
|
| 363 |
+
touches: [{ identifier: 2, clientX: x, clientY: y + dragDistance }],
|
| 364 |
+
});
|
| 365 |
+
for (let step = 1; step <= dragSteps; step++) {
|
| 366 |
+
await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchmove', {
|
| 367 |
+
touches: [{
|
| 368 |
+
identifier: 2, clientX: x,
|
| 369 |
+
clientY: y + dragDistance - (dragDistance * step / dragSteps),
|
| 370 |
+
}],
|
| 371 |
+
});
|
| 372 |
+
}
|
| 373 |
+
await page.locator('.tile-terminal:not(.tile-cached) .term-host').dispatchEvent('touchend', { touches: [] });
|
| 374 |
+
await sleep(100);
|
| 375 |
+
const returnedScroll = await page.locator('.tile-terminal:not(.tile-cached) .xterm-viewport').evaluate((node) => ({
|
| 376 |
+
top: node.scrollTop, max: node.scrollHeight - node.clientHeight,
|
| 377 |
+
}));
|
| 378 |
+
check('the reverse touch drag returns to the live bottom',
|
| 379 |
+
Math.abs(returnedScroll.top - returnedScroll.max) < 2,
|
| 380 |
+
JSON.stringify(returnedScroll));
|
| 381 |
+
|
| 382 |
+
const fullKeyboardGrid = latestGrid();
|
| 383 |
+
await page.locator('.tile-terminal:not(.tile-cached) .term-host').click();
|
| 384 |
+
const mobileInputAnchor = await page.evaluate(() => {
|
| 385 |
+
const host = document.querySelector('.tile-terminal:not(.tile-cached) .term-host').getBoundingClientRect();
|
| 386 |
+
const node = document.querySelector('.tile-terminal:not(.tile-cached) .xterm-helper-textarea');
|
| 387 |
+
const input = node.getBoundingClientRect();
|
| 388 |
+
return {
|
| 389 |
+
ok: input.width >= 1 && input.height >= 1
|
| 390 |
+
&& input.top >= host.bottom - 5 && input.left > host.left && input.right < host.right,
|
| 391 |
+
host: { top: host.top, right: host.right, bottom: host.bottom, left: host.left },
|
| 392 |
+
input: { top: input.top, right: input.right, bottom: input.bottom, left: input.left,
|
| 393 |
+
width: input.width, height: input.height },
|
| 394 |
+
style: node.getAttribute('style'),
|
| 395 |
+
};
|
| 396 |
+
});
|
| 397 |
+
check('the focused xterm input is anchored at the mobile terminal bottom',
|
| 398 |
+
mobileInputAnchor.ok, JSON.stringify(mobileInputAnchor));
|
| 399 |
+
|
| 400 |
+
// A direct .hf.space app receives real viewport geometry on the affected
|
| 401 |
+
// devices. Even if a test browser emits no change, focus alone must not
|
| 402 |
+
// invoke the Hub-iframe fallback outside an iframe.
|
| 403 |
+
await sleep(650);
|
| 404 |
+
const directNoSignalLayout = await page.locator('.app').evaluate((node) => ({
|
| 405 |
+
height: node.getBoundingClientRect().height,
|
| 406 |
+
strategy: document.documentElement.dataset.keyboardLayout ?? null,
|
| 407 |
+
}));
|
| 408 |
+
check('direct app does not guess a keyboard height without a browser signal',
|
| 409 |
+
Math.abs(directNoSignalLayout.height - 667) < 1 && directNoSignalLayout.strategy === null,
|
| 410 |
+
JSON.stringify(directNoSignalLayout));
|
| 411 |
+
|
| 412 |
+
// Embedded Chromium can leave the child visual viewport unchanged but expose
|
| 413 |
+
// the OSK rectangle. Ensure that independent signal clips the app and PTY.
|
| 414 |
+
await page.evaluate(() => window.__setVirtualKeyboard(430, 237));
|
| 415 |
+
const geometryResized = await waitFor(() => latestGrid()?.rows < fullKeyboardGrid.rows);
|
| 416 |
+
const geometryLayout = await page.locator('.app').evaluate((node) => {
|
| 417 |
+
const box = node.getBoundingClientRect();
|
| 418 |
+
const keybar = document.querySelector('.tile-terminal:not(.tile-cached) .term-keybar')?.getBoundingClientRect();
|
| 419 |
+
return { top: box.top, bottom: box.bottom, height: box.height, keybarBottom: keybar?.bottom ?? null };
|
| 420 |
+
});
|
| 421 |
+
check('keyboard geometry resizes an embedded mobile terminal without viewport changes',
|
| 422 |
+
geometryResized && Math.abs(geometryLayout.height - 430) < 1
|
| 423 |
+
&& geometryLayout.keybarBottom <= 430,
|
| 424 |
+
JSON.stringify({ geometryResized, geometryLayout, grid: latestGrid() }));
|
| 425 |
+
await page.evaluate(() => window.__setVirtualKeyboard(667, 0));
|
| 426 |
+
const geometryClosed = await waitFor(() => latestGrid()?.rows >= fullKeyboardGrid.rows);
|
| 427 |
+
check('clearing embedded keyboard geometry restores the terminal', geometryClosed,
|
| 428 |
+
JSON.stringify(latestGrid()));
|
| 429 |
+
|
| 430 |
+
const beforeKeyboardGrid = latestGrid();
|
| 431 |
+
await page.evaluate(() => window.__setVisualViewportLate(360, 118));
|
| 432 |
+
const keyboardResized = await waitFor(() => {
|
| 433 |
+
const frame = latestGrid();
|
| 434 |
+
return frame?.controller === false && frame?.rows < beforeKeyboardGrid.rows;
|
| 435 |
+
});
|
| 436 |
+
const keyboardLayout = await page.evaluate(() => {
|
| 437 |
+
const rect = (selector) => {
|
| 438 |
+
const node = document.querySelector(selector);
|
| 439 |
+
if (!node) return null;
|
| 440 |
+
const box = node.getBoundingClientRect();
|
| 441 |
+
return {
|
| 442 |
+
top: box.top, right: box.right, bottom: box.bottom, left: box.left,
|
| 443 |
+
width: box.width, height: box.height,
|
| 444 |
+
};
|
| 445 |
+
};
|
| 446 |
+
const app = rect('.app');
|
| 447 |
+
const host = rect('.tile-terminal:not(.tile-cached) .term-host');
|
| 448 |
+
const keybar = rect('.tile-terminal:not(.tile-cached) .term-keybar');
|
| 449 |
+
return {
|
| 450 |
+
app,
|
| 451 |
+
hostBottom: host?.bottom ?? null,
|
| 452 |
+
keybarBottom: keybar?.bottom ?? null,
|
| 453 |
+
vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
|
| 454 |
+
vvtop: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
|
| 455 |
+
pageScroll: window.scrollY,
|
| 456 |
+
};
|
| 457 |
+
});
|
| 458 |
+
check('the app follows the keyboard-shrunken visual viewport',
|
| 459 |
+
Math.abs(keyboardLayout.app.top - 118) < 1
|
| 460 |
+
&& Math.abs(keyboardLayout.app.height - 360) < 1
|
| 461 |
+
&& Math.abs(keyboardLayout.app.bottom - 478) < 1
|
| 462 |
+
&& Math.abs(keyboardLayout.app.left) < 1
|
| 463 |
+
&& Math.abs(keyboardLayout.app.width - 375) < 1,
|
| 464 |
+
JSON.stringify(keyboardLayout));
|
| 465 |
+
check('terminal input controls remain above the keyboard',
|
| 466 |
+
keyboardResized && keyboardLayout.hostBottom <= 478 && keyboardLayout.keybarBottom <= 478,
|
| 467 |
+
JSON.stringify({ keyboardResized, beforeKeyboardGrid, after: latestGrid(), keyboardLayout }));
|
| 468 |
+
|
| 469 |
+
await page.evaluate(() => window.__setVisualViewport(667, 0));
|
| 470 |
+
const keyboardClosed = await waitFor(() => latestGrid()?.rows >= beforeKeyboardGrid.rows);
|
| 471 |
+
const restoredLayout = await page.locator('.app').evaluate((node) => {
|
| 472 |
+
const rect = node.getBoundingClientRect();
|
| 473 |
+
return { top: rect.top, height: rect.height };
|
| 474 |
+
});
|
| 475 |
+
check('closing the keyboard restores viewport and terminal geometry',
|
| 476 |
+
keyboardClosed && Math.abs(restoredLayout.top) < 1 && Math.abs(restoredLayout.height - 667) < 1,
|
| 477 |
+
JSON.stringify({ keyboardClosed, restoredLayout, grid: latestGrid() }));
|
| 478 |
+
|
| 479 |
+
// The huggingface.co Space page is a cross-origin wrapper around the actual
|
| 480 |
+
// app iframe. Mobile Safari does not propagate its keyboard viewport into
|
| 481 |
+
// that child, so verify the focus-derived fallback with every geometry signal
|
| 482 |
+
// deliberately left unchanged.
|
| 483 |
+
const embeddedPage = await context.newPage();
|
| 484 |
+
await embeddedPage.setContent(
|
| 485 |
+
`<style>html,body{margin:0}iframe{display:block;width:375px;height:667px;border:0}</style>`
|
| 486 |
+
+ `<iframe src="${WEB}"></iframe>`,
|
| 487 |
+
);
|
| 488 |
+
const embeddedReady = await waitFor(() => embeddedPage.frames().some((frame) =>
|
| 489 |
+
frame !== embeddedPage.mainFrame() && frame.url().startsWith(WEB)));
|
| 490 |
+
const embeddedFrame = embeddedPage.frames().find((frame) =>
|
| 491 |
+
frame !== embeddedPage.mainFrame() && frame.url().startsWith(WEB));
|
| 492 |
+
if (!embeddedReady || !embeddedFrame) throw new Error('embedded app frame did not load');
|
| 493 |
+
await embeddedFrame.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
|
| 494 |
+
await embeddedFrame.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
|
| 495 |
+
await embeddedFrame.locator('.tile-terminal:not(.tile-cached) .term-host').click();
|
| 496 |
+
const embeddedFallback = await waitFor(() => embeddedFrame.evaluate(() =>
|
| 497 |
+
document.documentElement.dataset.keyboardLayout === 'focus-fallback'));
|
| 498 |
+
const embeddedLayout = await embeddedFrame.locator('.app').evaluate((node) => {
|
| 499 |
+
const app = node.getBoundingClientRect();
|
| 500 |
+
const keybar = document.querySelector('.tile-terminal:not(.tile-cached) .term-keybar')
|
| 501 |
+
?.getBoundingClientRect();
|
| 502 |
+
return {
|
| 503 |
+
height: app.height,
|
| 504 |
+
bottom: app.bottom,
|
| 505 |
+
keybarBottom: keybar?.bottom ?? null,
|
| 506 |
+
strategy: document.documentElement.dataset.keyboardLayout ?? null,
|
| 507 |
+
visualViewportHeight: window.visualViewport?.height ?? null,
|
| 508 |
+
innerHeight: window.innerHeight,
|
| 509 |
+
};
|
| 510 |
+
});
|
| 511 |
+
check('Hub iframe uses a focus fallback when keyboard geometry is unavailable',
|
| 512 |
+
embeddedFallback
|
| 513 |
+
&& Math.abs(embeddedLayout.height - 360) < 1
|
| 514 |
+
&& embeddedLayout.keybarBottom <= embeddedLayout.bottom
|
| 515 |
+
&& embeddedLayout.visualViewportHeight === 667
|
| 516 |
+
&& embeddedLayout.innerHeight === 667,
|
| 517 |
+
JSON.stringify(embeddedLayout));
|
| 518 |
+
await embeddedFrame.locator('.xterm-helper-textarea').evaluate((node) => node.blur());
|
| 519 |
+
const embeddedRestored = await waitFor(() => embeddedFrame.evaluate(() => {
|
| 520 |
+
const app = document.querySelector('.app')?.getBoundingClientRect();
|
| 521 |
+
return !document.documentElement.dataset.keyboardLayout && Math.abs((app?.height ?? 0) - 667) < 1;
|
| 522 |
+
}));
|
| 523 |
+
check('blurring an embedded prompt restores the full iframe height', embeddedRestored);
|
| 524 |
+
await embeddedPage.close();
|
| 525 |
+
|
| 526 |
+
// A compact last-frame preview should survive a full page reload and remain
|
| 527 |
+
// available while the backend/Space is unavailable. This deliberately blocks
|
| 528 |
+
// terminal sockets after reload, so success cannot come from a fast restore.
|
| 529 |
+
const previewSaved = await waitFor(() => page.evaluate((sessionId) => {
|
| 530 |
+
try {
|
| 531 |
+
const saved = JSON.parse(localStorage.getItem(`am-terminal-preview:${sessionId}`) || 'null');
|
| 532 |
+
return saved?.rows?.some((line) => String(line).includes('MOBILE-HISTORY-0220'));
|
| 533 |
+
} catch { return false; }
|
| 534 |
+
}, id), 5_000);
|
| 535 |
+
await page.evaluate(() => localStorage.setItem('__am_test_offline_sockets', '1'));
|
| 536 |
+
await page.reload({ waitUntil: 'domcontentloaded' });
|
| 537 |
+
await page.locator('.sidebar .row').filter({ hasText: 'mobile-terminal-e2e' }).first().click();
|
| 538 |
+
const previewVisible = await page.locator('.term-preview').filter({ hasText: 'MOBILE-HISTORY-0220' })
|
| 539 |
+
.isVisible().catch(() => false);
|
| 540 |
+
check('the last terminal view survives reload while the backend is unavailable',
|
| 541 |
+
previewSaved && previewVisible, JSON.stringify({ previewSaved, previewVisible }));
|
| 542 |
+
|
| 543 |
+
await context.close();
|
| 544 |
+
|
| 545 |
+
// The pane deck is shared by desktop group layouts too. Put the two existing
|
| 546 |
+
// sessions in one group and verify explicit grid placement plus persistence
|
| 547 |
+
// across Overview and Settings (both used to tear terminal panes down).
|
| 548 |
+
const group = await (await fetch(`${API}/api/groups`, {
|
| 549 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 550 |
+
body: JSON.stringify({ name: 'cache-layout-group' }),
|
| 551 |
+
})).json();
|
| 552 |
+
for (const sessionId of [id, secondId]) {
|
| 553 |
+
await fetch(`${API}/api/move`, {
|
| 554 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 555 |
+
body: JSON.stringify({ ref: `s:${sessionId}`, to: { kind: 'into', groupId: group.id } }),
|
| 556 |
+
});
|
| 557 |
+
}
|
| 558 |
+
const desktopContext = await browser.newContext({ viewport: { width: 1200, height: 800 } });
|
| 559 |
+
const desktopPage = await desktopContext.newPage();
|
| 560 |
+
await desktopPage.goto(WEB, { waitUntil: 'domcontentloaded' });
|
| 561 |
+
await desktopPage.locator('.row.group-head').filter({ hasText: 'cache-layout-group' }).click();
|
| 562 |
+
const groupTerms = desktopPage.locator('.tile-terminal:not(.tile-cached) .xterm');
|
| 563 |
+
await groupTerms.first().waitFor({ state: 'visible' });
|
| 564 |
+
const groupReady = await waitFor(() => groupTerms.count().then((count) => count === 2));
|
| 565 |
+
const groupLayout = await desktopPage.locator('.tile-terminal:not(.tile-cached)').evaluateAll((tiles) =>
|
| 566 |
+
tiles.map((tile) => {
|
| 567 |
+
const box = tile.getBoundingClientRect();
|
| 568 |
+
tile.setAttribute('data-cache-probe', 'retained');
|
| 569 |
+
return { left: box.left, top: box.top, width: box.width, height: box.height };
|
| 570 |
+
}));
|
| 571 |
+
check('retained terminals occupy the desktop group grid', groupReady
|
| 572 |
+
&& groupLayout.length === 2 && Math.abs(groupLayout[0].top - groupLayout[1].top) < 1
|
| 573 |
+
&& groupLayout[1].left > groupLayout[0].left + groupLayout[0].width,
|
| 574 |
+
JSON.stringify(groupLayout));
|
| 575 |
+
|
| 576 |
+
await desktopPage.locator('.ov-row').click();
|
| 577 |
+
const overviewRetained = await desktopPage.locator('.tile-terminal[data-cache-probe="retained"]')
|
| 578 |
+
.count() === 2;
|
| 579 |
+
await desktopPage.locator('.row.group-head').filter({ hasText: 'cache-layout-group' }).click();
|
| 580 |
+
const groupRestored = await waitFor(() => desktopPage
|
| 581 |
+
.locator('.tile-terminal:not(.tile-cached)[data-cache-probe="retained"]')
|
| 582 |
+
.count().then((count) => count === 2));
|
| 583 |
+
check('Overview hides group terminals without recreating them', overviewRetained && groupRestored,
|
| 584 |
+
JSON.stringify({ overviewRetained, groupRestored }));
|
| 585 |
+
|
| 586 |
+
await desktopPage.getByTitle('Settings').click();
|
| 587 |
+
await desktopPage.locator('.app.settings').waitFor({ state: 'visible' });
|
| 588 |
+
const settingsRetained = await desktopPage
|
| 589 |
+
.locator('.app.app-suspended .tile-terminal[data-cache-probe="retained"] .xterm').count() === 2;
|
| 590 |
+
await desktopPage.locator('.app.settings').getByTitle('Back').click();
|
| 591 |
+
const settingsRestored = await waitFor(() => desktopPage
|
| 592 |
+
.locator('.tile-terminal:not(.tile-cached)[data-cache-probe="retained"] .xterm')
|
| 593 |
+
.count().then((count) => count === 2));
|
| 594 |
+
check('Settings hides group terminals without recreating them', settingsRetained && settingsRestored,
|
| 595 |
+
JSON.stringify({ settingsRetained, settingsRestored }));
|
| 596 |
+
await desktopContext.close();
|
| 597 |
+
} catch (error) {
|
| 598 |
+
check('mobile browser test completes', false, String(error?.stack || error));
|
| 599 |
+
console.log(logs.slice(-3000));
|
| 600 |
+
} finally {
|
| 601 |
+
try { desktop?.close(); } catch {}
|
| 602 |
+
try { await browser?.close(); } catch {}
|
| 603 |
+
if (id) await fetch(`${API}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
|
| 604 |
+
if (secondId) await fetch(`${API}/api/sessions/${secondId}/stop`, { method: 'POST' }).catch(() => {});
|
| 605 |
+
backend.kill('SIGTERM');
|
| 606 |
+
await sleep(400);
|
| 607 |
+
backend.kill('SIGKILL');
|
| 608 |
+
try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch {}
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
| 612 |
+
process.exit(failures ? 1 : 0);
|
server/package-lock.json
CHANGED
|
@@ -7,13 +7,55 @@
|
|
| 7 |
"": {
|
| 8 |
"name": "agent-manager-server",
|
| 9 |
"version": "0.1.0",
|
|
|
|
| 10 |
"dependencies": {
|
|
|
|
| 11 |
"express": "^4.19.2",
|
| 12 |
"node-pty": "^1.0.0",
|
| 13 |
"web-push": "^3.6.7",
|
| 14 |
"ws": "^8.18.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"node_modules/accepts": {
|
| 18 |
"version": "1.3.8",
|
| 19 |
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
|
@@ -357,6 +399,21 @@
|
|
| 357 |
"node": ">= 0.6"
|
| 358 |
}
|
| 359 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
"node_modules/function-bind": {
|
| 361 |
"version": "1.1.2",
|
| 362 |
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
@@ -657,6 +714,17 @@
|
|
| 657 |
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
| 658 |
"license": "MIT"
|
| 659 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
"node_modules/node-pty": {
|
| 661 |
"version": "1.1.0",
|
| 662 |
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
|
@@ -706,6 +774,38 @@
|
|
| 706 |
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
| 707 |
"license": "MIT"
|
| 708 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
"node_modules/proxy-addr": {
|
| 710 |
"version": "2.0.7",
|
| 711 |
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
|
|
|
| 7 |
"": {
|
| 8 |
"name": "agent-manager-server",
|
| 9 |
"version": "0.1.0",
|
| 10 |
+
"license": "Apache-2.0",
|
| 11 |
"dependencies": {
|
| 12 |
+
"@coder/libghostty-vt-node": "0.1.0-beta.0",
|
| 13 |
"express": "^4.19.2",
|
| 14 |
"node-pty": "^1.0.0",
|
| 15 |
"web-push": "^3.6.7",
|
| 16 |
"ws": "^8.18.0"
|
| 17 |
+
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"@xterm/headless": "^6.0.0",
|
| 20 |
+
"playwright": "^1.62.1"
|
| 21 |
+
},
|
| 22 |
+
"engines": {
|
| 23 |
+
"node": ">=20.19"
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
"node_modules/@coder/libghostty-vt-node": {
|
| 27 |
+
"version": "0.1.0-beta.0",
|
| 28 |
+
"resolved": "https://registry.npmjs.org/@coder/libghostty-vt-node/-/libghostty-vt-node-0.1.0-beta.0.tgz",
|
| 29 |
+
"integrity": "sha512-m0KqgBfEzD7G6DIFWe3OSGoa0muDHneXDKHDVz8sJdSYl2N8HN9W642H3LSw2YDkw9VuxxkbHqXdN98akkqwxQ==",
|
| 30 |
+
"hasInstallScript": true,
|
| 31 |
+
"license": "MIT",
|
| 32 |
+
"dependencies": {
|
| 33 |
+
"node-addon-api": "^8.3.1",
|
| 34 |
+
"node-gyp-build": "^4.8.4"
|
| 35 |
+
},
|
| 36 |
+
"engines": {
|
| 37 |
+
"node": ">=20.19"
|
| 38 |
}
|
| 39 |
},
|
| 40 |
+
"node_modules/@coder/libghostty-vt-node/node_modules/node-addon-api": {
|
| 41 |
+
"version": "8.9.0",
|
| 42 |
+
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
| 43 |
+
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
|
| 44 |
+
"license": "MIT",
|
| 45 |
+
"engines": {
|
| 46 |
+
"node": "^18 || ^20 || >= 21"
|
| 47 |
+
}
|
| 48 |
+
},
|
| 49 |
+
"node_modules/@xterm/headless": {
|
| 50 |
+
"version": "6.0.0",
|
| 51 |
+
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
| 52 |
+
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
|
| 53 |
+
"dev": true,
|
| 54 |
+
"license": "MIT",
|
| 55 |
+
"workspaces": [
|
| 56 |
+
"addons/*"
|
| 57 |
+
]
|
| 58 |
+
},
|
| 59 |
"node_modules/accepts": {
|
| 60 |
"version": "1.3.8",
|
| 61 |
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
|
|
|
| 399 |
"node": ">= 0.6"
|
| 400 |
}
|
| 401 |
},
|
| 402 |
+
"node_modules/fsevents": {
|
| 403 |
+
"version": "2.3.2",
|
| 404 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 405 |
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
| 406 |
+
"dev": true,
|
| 407 |
+
"hasInstallScript": true,
|
| 408 |
+
"license": "MIT",
|
| 409 |
+
"optional": true,
|
| 410 |
+
"os": [
|
| 411 |
+
"darwin"
|
| 412 |
+
],
|
| 413 |
+
"engines": {
|
| 414 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 415 |
+
}
|
| 416 |
+
},
|
| 417 |
"node_modules/function-bind": {
|
| 418 |
"version": "1.1.2",
|
| 419 |
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
|
|
| 714 |
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
| 715 |
"license": "MIT"
|
| 716 |
},
|
| 717 |
+
"node_modules/node-gyp-build": {
|
| 718 |
+
"version": "4.8.4",
|
| 719 |
+
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
| 720 |
+
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
| 721 |
+
"license": "MIT",
|
| 722 |
+
"bin": {
|
| 723 |
+
"node-gyp-build": "bin.js",
|
| 724 |
+
"node-gyp-build-optional": "optional.js",
|
| 725 |
+
"node-gyp-build-test": "build-test.js"
|
| 726 |
+
}
|
| 727 |
+
},
|
| 728 |
"node_modules/node-pty": {
|
| 729 |
"version": "1.1.0",
|
| 730 |
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
|
|
|
| 774 |
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
| 775 |
"license": "MIT"
|
| 776 |
},
|
| 777 |
+
"node_modules/playwright": {
|
| 778 |
+
"version": "1.62.1",
|
| 779 |
+
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
| 780 |
+
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
| 781 |
+
"dev": true,
|
| 782 |
+
"license": "Apache-2.0",
|
| 783 |
+
"dependencies": {
|
| 784 |
+
"playwright-core": "1.62.1"
|
| 785 |
+
},
|
| 786 |
+
"bin": {
|
| 787 |
+
"playwright": "cli.js"
|
| 788 |
+
},
|
| 789 |
+
"engines": {
|
| 790 |
+
"node": ">=20"
|
| 791 |
+
},
|
| 792 |
+
"optionalDependencies": {
|
| 793 |
+
"fsevents": "2.3.2"
|
| 794 |
+
}
|
| 795 |
+
},
|
| 796 |
+
"node_modules/playwright-core": {
|
| 797 |
+
"version": "1.62.1",
|
| 798 |
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
| 799 |
+
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
| 800 |
+
"dev": true,
|
| 801 |
+
"license": "Apache-2.0",
|
| 802 |
+
"bin": {
|
| 803 |
+
"playwright-core": "cli.js"
|
| 804 |
+
},
|
| 805 |
+
"engines": {
|
| 806 |
+
"node": ">=20"
|
| 807 |
+
}
|
| 808 |
+
},
|
| 809 |
"node_modules/proxy-addr": {
|
| 810 |
"version": "2.0.7",
|
| 811 |
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
server/package.json
CHANGED
|
@@ -2,20 +2,31 @@
|
|
| 2 |
"name": "agent-manager-server",
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
-
"description": "Agent Manager backend (Express + ws + node-pty +
|
| 6 |
"license": "Apache-2.0",
|
| 7 |
-
"repository": {
|
|
|
|
|
|
|
|
|
|
| 8 |
"type": "module",
|
| 9 |
"main": "src/index.js",
|
| 10 |
"scripts": {
|
| 11 |
"start": "node src/index.js",
|
| 12 |
"dev": "node --watch src/index.js",
|
| 13 |
-
"test": "node test/repin.test.mjs"
|
|
|
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
"dependencies": {
|
|
|
|
| 16 |
"express": "^4.19.2",
|
| 17 |
"node-pty": "^1.0.0",
|
| 18 |
"web-push": "^3.6.7",
|
| 19 |
"ws": "^8.18.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
}
|
|
|
|
| 2 |
"name": "agent-manager-server",
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
+
"description": "Agent Manager backend (Express + ws + node-pty + libghostty-vt)",
|
| 6 |
"license": "Apache-2.0",
|
| 7 |
+
"repository": {
|
| 8 |
+
"type": "git",
|
| 9 |
+
"url": "https://github.com/huggingface/agent-manager"
|
| 10 |
+
},
|
| 11 |
"type": "module",
|
| 12 |
"main": "src/index.js",
|
| 13 |
"scripts": {
|
| 14 |
"start": "node src/index.js",
|
| 15 |
"dev": "node --watch src/index.js",
|
| 16 |
+
"test": "node test/repin.test.mjs && node migration.test.mjs && node resize.test.mjs"
|
| 17 |
+
},
|
| 18 |
+
"engines": {
|
| 19 |
+
"node": ">=20.19"
|
| 20 |
},
|
| 21 |
"dependencies": {
|
| 22 |
+
"@coder/libghostty-vt-node": "0.1.0-beta.0",
|
| 23 |
"express": "^4.19.2",
|
| 24 |
"node-pty": "^1.0.0",
|
| 25 |
"web-push": "^3.6.7",
|
| 26 |
"ws": "^8.18.0"
|
| 27 |
+
},
|
| 28 |
+
"devDependencies": {
|
| 29 |
+
"@xterm/headless": "^6.0.0",
|
| 30 |
+
"playwright": "^1.62.1"
|
| 31 |
}
|
| 32 |
}
|
server/resize.test.mjs
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Invariants for the server-held terminal model.
|
| 2 |
+
//
|
| 3 |
+
// The contract is explicit rather than heuristic: shell output uses ordinary
|
| 4 |
+
// reflow, while known primary-screen agent TUIs settle their SIGWINCH repaint in
|
| 5 |
+
// a scratch terminal so presentation bytes never become history. Exactly one
|
| 6 |
+
// viewer controls input and PTY geometry; Ghostty remains the durable authority.
|
| 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 |
+
import {
|
| 15 |
+
TERMINAL_HISTORY_VERSION, createTerminalHistoryCheckpoint, loadTerminalHistory,
|
| 16 |
+
traceHistoryLines,
|
| 17 |
+
} from './src/history-store.js';
|
| 18 |
+
import { mergeRepaintArchive, repaintArchiveView } from './src/runner.js';
|
| 19 |
+
import { snapshotToRestoreAnsi, styledTerminalRows } from './src/snapshot.js';
|
| 20 |
+
|
| 21 |
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 22 |
+
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-resize-'));
|
| 23 |
+
const FIXTURE = path.join(HERE, 'fixtures', 'repaint-tui.mjs');
|
| 24 |
+
const PORT = 7895;
|
| 25 |
+
const CTRL = '\x00\x00AM:';
|
| 26 |
+
const base = `http://localhost:${PORT}`;
|
| 27 |
+
|
| 28 |
+
let failures = 0;
|
| 29 |
+
const check = (name, ok, detail = '') => {
|
| 30 |
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
| 31 |
+
if (!ok) failures++;
|
| 32 |
+
};
|
| 33 |
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 34 |
+
const waitFor = async (fn, timeout = 8000) => {
|
| 35 |
+
const until = Date.now() + timeout;
|
| 36 |
+
while (Date.now() < until) {
|
| 37 |
+
if (await fn()) return true;
|
| 38 |
+
await sleep(100);
|
| 39 |
+
}
|
| 40 |
+
return false;
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
const srv = spawn('node', ['src/index.js'], {
|
| 44 |
+
cwd: HERE,
|
| 45 |
+
env: {
|
| 46 |
+
...process.env,
|
| 47 |
+
PORT: String(PORT),
|
| 48 |
+
DATA_DIR,
|
| 49 |
+
AM_BASHRC: '/nonexistent',
|
| 50 |
+
AM_HISTORY_SAVE_MS: '50',
|
| 51 |
+
AM_STARTUP_CAPTURE_IDLE_MS: '700',
|
| 52 |
+
AM_STARTUP_CAPTURE_MAX_MS: '3000',
|
| 53 |
+
AM_TEST_REPAINT_CMD: `env FIXED_LINES=30 HISTORY_LINES=2105 DELAY_RESUME_MS=400 ${JSON.stringify(process.execPath)} ${JSON.stringify(FIXTURE)}`,
|
| 54 |
+
},
|
| 55 |
+
stdio: ['ignore', 'pipe', 'pipe'],
|
| 56 |
+
});
|
| 57 |
+
let log = '';
|
| 58 |
+
srv.stdout.on('data', (data) => { log += data; });
|
| 59 |
+
srv.stderr.on('data', (data) => { log += data; });
|
| 60 |
+
|
| 61 |
+
let Headless = null;
|
| 62 |
+
try { Headless = (await import('@xterm/headless')).default.Terminal; } catch {}
|
| 63 |
+
|
| 64 |
+
function view(id, cols, rows, mirror = false) {
|
| 65 |
+
return new Promise((resolve) => {
|
| 66 |
+
const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${id}&cols=${cols}&rows=${rows}`);
|
| 67 |
+
const term = mirror && Headless
|
| 68 |
+
? new Headless({ cols, rows, scrollback: 20000, allowProposedApi: true })
|
| 69 |
+
: null;
|
| 70 |
+
const v = {
|
| 71 |
+
bytes: '', frames: [], events: [], term,
|
| 72 |
+
type: (data) => ws.send(JSON.stringify({ t: 'i', d: data })),
|
| 73 |
+
resize: (nextCols, nextRows) => ws.send(JSON.stringify({ t: 'r', cols: nextCols, rows: nextRows })),
|
| 74 |
+
claim: () => ws.send(JSON.stringify({ t: 'claim' })),
|
| 75 |
+
lastFrame: (type) => [...v.frames].reverse().find((frame) => frame.t === type) || null,
|
| 76 |
+
countFrames: (type) => v.frames.filter((frame) => frame.t === type).length,
|
| 77 |
+
open: () => ws.readyState === WebSocket.OPEN,
|
| 78 |
+
close: () => { try { ws.close(); } catch {} },
|
| 79 |
+
screenText: async () => {
|
| 80 |
+
if (!term) return '';
|
| 81 |
+
await new Promise((done) => term.write('', done));
|
| 82 |
+
const buf = term.buffer.active;
|
| 83 |
+
let text = '';
|
| 84 |
+
for (let i = 0; i < buf.length; i++) {
|
| 85 |
+
const line = buf.getLine(i);
|
| 86 |
+
const row = line?.translateToString(true) ?? '';
|
| 87 |
+
// Snapshot restores use absolute cursor painting, which cannot carry
|
| 88 |
+
// xterm's private isWrapped bit. A full row is nevertheless a visual
|
| 89 |
+
// wrap and must be joined before checking transcript completeness.
|
| 90 |
+
if (i > 0 && !line?.isWrapped) {
|
| 91 |
+
const previous = buf.getLine(i - 1)?.translateToString(true) ?? '';
|
| 92 |
+
if (previous.length < term.cols) text += '\n';
|
| 93 |
+
}
|
| 94 |
+
text += row;
|
| 95 |
+
}
|
| 96 |
+
return text;
|
| 97 |
+
},
|
| 98 |
+
scrollsBack: async () => {
|
| 99 |
+
if (!term) return false;
|
| 100 |
+
await new Promise((done) => term.write('', done));
|
| 101 |
+
const buf = term.buffer.active;
|
| 102 |
+
const bottom = buf.baseY;
|
| 103 |
+
term.scrollToTop();
|
| 104 |
+
const top = buf.viewportY;
|
| 105 |
+
term.scrollToBottom();
|
| 106 |
+
return bottom > 0 && top < bottom && buf.viewportY === bottom;
|
| 107 |
+
},
|
| 108 |
+
bufferState: async () => {
|
| 109 |
+
if (!term) return null;
|
| 110 |
+
await new Promise((done) => term.write('', done));
|
| 111 |
+
const buf = term.buffer.active;
|
| 112 |
+
const initialViewport = buf.viewportY;
|
| 113 |
+
let coloredHistoryCells = 0;
|
| 114 |
+
for (let y = 0; y < buf.baseY; y++) {
|
| 115 |
+
const line = buf.getLine(y);
|
| 116 |
+
if (!line) continue;
|
| 117 |
+
for (let x = 0; x < line.length; x++) {
|
| 118 |
+
const cell = line.getCell(x);
|
| 119 |
+
if (cell?.getChars() && (!cell.isFgDefault() || !cell.isBgDefault())) {
|
| 120 |
+
coloredHistoryCells++;
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
term.scrollToTop();
|
| 125 |
+
const top = buf.viewportY;
|
| 126 |
+
term.scrollToBottom();
|
| 127 |
+
return {
|
| 128 |
+
historyRows: buf.baseY,
|
| 129 |
+
initialViewport,
|
| 130 |
+
atBottom: initialViewport === buf.baseY,
|
| 131 |
+
canReachBothEnds: buf.baseY > 0 && top === 0 && buf.viewportY === buf.baseY,
|
| 132 |
+
coloredHistoryCells,
|
| 133 |
+
};
|
| 134 |
+
},
|
| 135 |
+
};
|
| 136 |
+
ws.on('message', (data) => {
|
| 137 |
+
const text = data.toString('utf8');
|
| 138 |
+
if (text.startsWith(CTRL)) {
|
| 139 |
+
let frame;
|
| 140 |
+
try { frame = JSON.parse(text.slice(CTRL.length)); } catch { return; }
|
| 141 |
+
v.frames.push(frame);
|
| 142 |
+
v.events.push({ type: 'frame', frame });
|
| 143 |
+
if (term && (frame.t === 'grid' || frame.t === 'restore')) {
|
| 144 |
+
const applyGrid = () => {
|
| 145 |
+
if (frame.reset) { term.reset(); term.clear(); }
|
| 146 |
+
if (frame.cols > 0 && frame.rows > 0
|
| 147 |
+
&& (term.cols !== frame.cols || term.rows !== frame.rows)) {
|
| 148 |
+
term.resize(frame.cols, frame.rows);
|
| 149 |
+
}
|
| 150 |
+
};
|
| 151 |
+
const geometryChanged = frame.cols > 0 && frame.rows > 0
|
| 152 |
+
&& (term.cols !== frame.cols || term.rows !== frame.rows);
|
| 153 |
+
if (frame.reset || geometryChanged) term.write('', applyGrid);
|
| 154 |
+
else applyGrid();
|
| 155 |
+
}
|
| 156 |
+
} else {
|
| 157 |
+
v.bytes += text;
|
| 158 |
+
v.events.push({ type: 'data', text });
|
| 159 |
+
if (term) term.write(text);
|
| 160 |
+
}
|
| 161 |
+
});
|
| 162 |
+
ws.on('open', () => resolve(v));
|
| 163 |
+
ws.on('error', () => resolve(v));
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
async function session(name, cli = 'shell') {
|
| 168 |
+
const created = await (await fetch(`${base}/api/sessions`, {
|
| 169 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 170 |
+
body: JSON.stringify({ cli, name }),
|
| 171 |
+
})).json();
|
| 172 |
+
if (!created.id) throw new Error(`no session: ${JSON.stringify(created).slice(0, 200)}`);
|
| 173 |
+
return created.id;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const gridText = async (id, lines = 2000) => {
|
| 177 |
+
const response = await fetch(`${base}/api/agents/${id}/tail?lines=${lines}`);
|
| 178 |
+
const body = await response.json();
|
| 179 |
+
return typeof body === 'string' ? body : (body.text || body.tail || '');
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
/** Every fixture token is unique, so repeats prove a frame entered history twice. */
|
| 183 |
+
function duplicateTokens(text) {
|
| 184 |
+
const counts = new Map();
|
| 185 |
+
for (const match of text.matchAll(/\b\d{3}\.\d{2}\b/g)) {
|
| 186 |
+
counts.set(match[0], (counts.get(match[0]) || 0) + 1);
|
| 187 |
+
}
|
| 188 |
+
return [...counts.values()].filter((count) => count > 1).length;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
const uniqueTokens = (text) => new Set(text.match(/\b\d{3}\.\d{2}\b/g) || []).size;
|
| 192 |
+
|
| 193 |
+
const stop = async (id) => {
|
| 194 |
+
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
|
| 195 |
+
await sleep(250);
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
try {
|
| 199 |
+
{
|
| 200 |
+
const directory = fs.mkdtempSync(path.join(DATA_DIR, 'history-schema-'));
|
| 201 |
+
const legacyFile = path.join(directory, 'legacy.json');
|
| 202 |
+
fs.writeFileSync(legacyFile, JSON.stringify({ version: 1, cols: 80, lines: ['old'] }));
|
| 203 |
+
check('legacy checkpoints remain readable for unaffected shell sessions',
|
| 204 |
+
loadTerminalHistory(directory, 'legacy')?.version === 1);
|
| 205 |
+
|
| 206 |
+
const checkpoint = createTerminalHistoryCheckpoint({
|
| 207 |
+
directory, id: 'current', delayMs: 1,
|
| 208 |
+
snapshot: () => ({
|
| 209 |
+
cols: 90, scrollbackLines: [{ text: 'clean', ansi: '\x1b[38;5;2mclean\x1b[0m' }],
|
| 210 |
+
}),
|
| 211 |
+
blocked: () => false,
|
| 212 |
+
});
|
| 213 |
+
checkpoint.flush();
|
| 214 |
+
const wrote = await waitFor(() => loadTerminalHistory(directory, 'current')?.version
|
| 215 |
+
=== TERMINAL_HISTORY_VERSION);
|
| 216 |
+
check('new checkpoints use the clean startup generation', wrote);
|
| 217 |
+
check('checkpoints retain validated scrollback styling',
|
| 218 |
+
loadTerminalHistory(directory, 'current')?.lines[0]?.ansi
|
| 219 |
+
=== '\x1b[38;5;2mclean\x1b[0m');
|
| 220 |
+
|
| 221 |
+
const styled = styledTerminalRows({
|
| 222 |
+
cols: 20, rows: 1,
|
| 223 |
+
scrollbackLines: [{ row: 0, text: 'red history' }],
|
| 224 |
+
visibleLines: [{ row: 0, text: 'live' }],
|
| 225 |
+
}, '<div style="font-family: monospace; white-space: pre;"><div style="display: inline;color: var(--vt-palette-1);">red history</div>\nlive</div>');
|
| 226 |
+
const restore = snapshotToRestoreAnsi({
|
| 227 |
+
cols: 20, rows: 1, cursorRow: 0, cursorCol: 0, cells: [],
|
| 228 |
+
scrollbackLines: styled.slice(0, 1),
|
| 229 |
+
});
|
| 230 |
+
check('Ghostty HTML scrollback colors convert back to ANSI',
|
| 231 |
+
styled[0].ansi?.includes('\x1b[38;5;1m')
|
| 232 |
+
&& restore.includes('\x1b[38;5;1mred history'));
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
{
|
| 236 |
+
const recovered = traceHistoryLines({ turns: [
|
| 237 |
+
{ role: 'user', blocks: [{ type: 'text', text: 'older prompt' }] },
|
| 238 |
+
{ role: 'assistant', kind: 'final', blocks: [{ type: 'text', text: 'older answer' }] },
|
| 239 |
+
{ role: 'user', blocks: [{ type: 'text', text: 'live prompt' }] },
|
| 240 |
+
{ role: 'assistant', kind: 'final', blocks: [{ type: 'text', text: 'live answer' }] },
|
| 241 |
+
] });
|
| 242 |
+
const text = recovered.map((line) => line.text).join('\n');
|
| 243 |
+
check('trace recovery seeds only turns older than the live viewport',
|
| 244 |
+
text.includes('older prompt') && text.includes('older answer')
|
| 245 |
+
&& !text.includes('live prompt') && !text.includes('live answer'));
|
| 246 |
+
|
| 247 |
+
const alreadyVisible = traceHistoryLines({ turns: [
|
| 248 |
+
{ role: 'user', blocks: [{ type: 'text', text: 'hi there' }] },
|
| 249 |
+
{ role: 'assistant', kind: 'final', blocks: [{ type: 'text', text: 'Hey!' }] },
|
| 250 |
+
{ role: 'user', blocks: [{ type: 'text', text: 'live prompt' }] },
|
| 251 |
+
] }, '❯ hi there\n\n● Hey!');
|
| 252 |
+
check('trace recovery does not duplicate short turns replayed by the agent',
|
| 253 |
+
alreadyVisible.length === 0);
|
| 254 |
+
|
| 255 |
+
const archiveSeed = 'older unique turn\n❯ hi there\n\n● Hey!\n';
|
| 256 |
+
const wide = 'Claude welcome frame\nolder unique turn\n❯ hi there\n\n● Hey!\nnewer live turn\n';
|
| 257 |
+
const archive = mergeRepaintArchive(archiveSeed, wide);
|
| 258 |
+
check('a wide repaint merges recovered turns into one full archive',
|
| 259 |
+
archive === wide && archive.split('❯ hi there').length - 1 === 1);
|
| 260 |
+
check('a wide repaint needs no duplicate scrollback',
|
| 261 |
+
repaintArchiveView(archive, wide).history.length === 0);
|
| 262 |
+
const narrow = 'newer live turn\n';
|
| 263 |
+
const narrowView = repaintArchiveView(archive, narrow);
|
| 264 |
+
const narrowHistory = narrowView.history.map((line) => line.text).join('\n');
|
| 265 |
+
check('a narrow repaint restores the archive prefix above the viewport',
|
| 266 |
+
narrowHistory.includes('Claude welcome frame') && narrowHistory.includes('❯ hi there'));
|
| 267 |
+
check('a repaint replaces a volatile old footer after its transcript overlap',
|
| 268 |
+
repaintArchiveView('deep history\nshared transcript\n[old 160x40]\n',
|
| 269 |
+
'shared transcript\n[new 90x24]\n').archive
|
| 270 |
+
=== 'deep history\nshared transcript\n[new 90x24]\n');
|
| 271 |
+
const poem = '❯ write me a poem about silicon\n\n● Silicon\n\n'
|
| 272 |
+
+ 'Second most common thing underfoot, plain as the beach you forget while you walk it —\n'
|
| 273 |
+
+ 'Someone thought to melt it, draw it out into a single perfect column, one lattice unbroken.\n';
|
| 274 |
+
const volatilePrefix = repaintArchiveView(
|
| 275 |
+
`older unique turn\n${poem}✻ Worked for 8s\n[old footer]\n`,
|
| 276 |
+
`✻ Worked for 2s\n${poem}✻ Worked for 8s\n[new footer]\n`,
|
| 277 |
+
).archive;
|
| 278 |
+
check('a volatile leading status cannot anchor after and duplicate the repainted turn',
|
| 279 |
+
volatilePrefix.split('❯ write me a poem about silicon').length - 1 === 1
|
| 280 |
+
&& volatilePrefix.includes('[new footer]') && !volatilePrefix.includes('[old footer]'));
|
| 281 |
+
check('zooming wide again exposes the archive without growing it',
|
| 282 |
+
repaintArchiveView(narrowView.archive, wide).archive === wide);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
const up = await waitFor(async () => {
|
| 286 |
+
try { return (await fetch(`${base}/api/health`)).ok; } catch { return false; }
|
| 287 |
+
}, 100_000);
|
| 288 |
+
if (!up) throw new Error(`server never came up:\n${log.slice(-1200)}`);
|
| 289 |
+
|
| 290 |
+
// A long logical line must survive ordinary Ghostty reflow. The former
|
| 291 |
+
// screen-carry path cropped the right side before rebuilding a narrower grid.
|
| 292 |
+
{
|
| 293 |
+
const id = await session('long line reflow');
|
| 294 |
+
const v = await view(id, 110, 24, true);
|
| 295 |
+
await sleep(700);
|
| 296 |
+
v.type(`printf 'LONG-%s-END-SENTINEL\\n' "$(printf 'x%.0s' {1..90})"\r`);
|
| 297 |
+
const printed = await waitFor(async () => (await gridText(id)).includes('END-SENTINEL'));
|
| 298 |
+
check('long line is present before resize', printed);
|
| 299 |
+
v.resize(60, 24);
|
| 300 |
+
const resized = await waitFor(() => v.lastFrame('grid')?.cols === 60);
|
| 301 |
+
check('resize settles at the requested geometry', resized, JSON.stringify(v.lastFrame('grid')));
|
| 302 |
+
const after = await gridText(id);
|
| 303 |
+
check('narrowing does not crop the right side of a logical line', after.includes('END-SENTINEL'));
|
| 304 |
+
check('resize does not retransmit all history', v.lastFrame('grid')?.reset === false);
|
| 305 |
+
if (Headless) check('the ordered viewer reflow also retains the line', (await v.screenText()).includes('END-SENTINEL'));
|
| 306 |
+
v.close();
|
| 307 |
+
await stop(id);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// The geometry frame must precede output triggered by SIGWINCH. Otherwise a
|
| 311 |
+
// TUI's repaint is interpreted at the old size and only reflowed afterward,
|
| 312 |
+
// which can leave duplicate or displaced rows in a browser emulator.
|
| 313 |
+
{
|
| 314 |
+
const id = await session('ordered repaint');
|
| 315 |
+
const v = await view(id, 100, 30, true);
|
| 316 |
+
await sleep(500);
|
| 317 |
+
v.type(`trap 'printf "\\033[2J\\033[HRESIZE-AT-%sx%s\\n" "$COLUMNS" "$LINES"' WINCH; printf 'TRAP-READY\\n'\r`);
|
| 318 |
+
await waitFor(() => v.bytes.includes('TRAP-READY'));
|
| 319 |
+
v.events.length = 0;
|
| 320 |
+
v.resize(73, 21);
|
| 321 |
+
const repainted = await waitFor(() => v.events.some((event) => event.type === 'data'
|
| 322 |
+
&& event.text.includes('RESIZE-AT-73x21')));
|
| 323 |
+
const gridAt = v.events.findIndex((event) => event.type === 'frame'
|
| 324 |
+
&& event.frame.t === 'grid' && event.frame.cols === 73 && event.frame.rows === 21);
|
| 325 |
+
const repaintAt = v.events.findIndex((event) => event.type === 'data'
|
| 326 |
+
&& event.text.includes('RESIZE-AT-73x21'));
|
| 327 |
+
check('SIGWINCH repaint arrives after the confirmed grid', repainted && gridAt >= 0 && gridAt < repaintAt,
|
| 328 |
+
`grid event ${gridAt}, repaint event ${repaintAt}`);
|
| 329 |
+
if (Headless) {
|
| 330 |
+
const screen = await v.screenText();
|
| 331 |
+
check('ordered repaint is rendered once', screen.split('RESIZE-AT-73x21').length - 1 === 1);
|
| 332 |
+
}
|
| 333 |
+
v.close();
|
| 334 |
+
await stop(id);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Claude-style primary-screen TUIs print a complete frame on every SIGWINCH.
|
| 338 |
+
// Some frames are taller than the pane; streaming those repaint bytes would
|
| 339 |
+
// archive another welcome/conversation copy on every zoom step. Agent resizes
|
| 340 |
+
// therefore settle in a scratch terminal and commit only the final screen.
|
| 341 |
+
{
|
| 342 |
+
const id = await session('captured agent repaint', 'test-repaint');
|
| 343 |
+
const v = await view(id, 150, 40, true);
|
| 344 |
+
const painted = await waitFor(async () => (await gridText(id)).includes('[fixture 150x40]'));
|
| 345 |
+
check('Claude-style fixture paints its initial frame', painted);
|
| 346 |
+
const beforeGrid = duplicateTokens(await gridText(id));
|
| 347 |
+
const initialGridText = await gridText(id);
|
| 348 |
+
const initialBrowserText = Headless ? await v.screenText() : '';
|
| 349 |
+
const beforeBrowser = Headless ? duplicateTokens(initialBrowserText) : 0;
|
| 350 |
+
const initialGridTokens = uniqueTokens(initialGridText);
|
| 351 |
+
const initialBrowserTokens = Headless ? uniqueTokens(initialBrowserText) : 0;
|
| 352 |
+
check('fixture starts without duplicate history', beforeGrid === 0 && beforeBrowser === 0,
|
| 353 |
+
`grid=${beforeGrid}, browser=${beforeBrowser}`);
|
| 354 |
+
if (Headless) {
|
| 355 |
+
check('fixture starts with deep history', initialBrowserText.includes('history-0001'));
|
| 356 |
+
check('browser viewport can scroll through that history', await v.scrollsBack());
|
| 357 |
+
const state = await v.bufferState();
|
| 358 |
+
check('fixture starts at the live bottom', state?.atBottom, JSON.stringify(state));
|
| 359 |
+
check('fixture starts with styled history', state?.coloredHistoryCells > 0,
|
| 360 |
+
JSON.stringify(state));
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
for (const [cols, rows] of [[120, 32], [90, 24], [120, 32], [150, 40]]) {
|
| 364 |
+
v.resize(cols, rows);
|
| 365 |
+
const settled = await waitFor(() => v.lastFrame('grid')?.cols === cols
|
| 366 |
+
&& v.lastFrame('grid')?.rows === rows);
|
| 367 |
+
check(`agent repaint settles at ${cols}x${rows}`, settled, JSON.stringify(v.lastFrame('grid')));
|
| 368 |
+
await sleep(150);
|
| 369 |
+
const stepGridText = await gridText(id);
|
| 370 |
+
check(`canonical history stays unique at ${cols}x${rows}`, duplicateTokens(stepGridText) === beforeGrid,
|
| 371 |
+
`${duplicateTokens(stepGridText)} duplicate tokens`);
|
| 372 |
+
if (Headless) {
|
| 373 |
+
const stepBrowserText = await v.screenText();
|
| 374 |
+
check(`browser history stays unique at ${cols}x${rows}`, duplicateTokens(stepBrowserText) === beforeBrowser,
|
| 375 |
+
`${duplicateTokens(stepBrowserText)} duplicate tokens`);
|
| 376 |
+
check(`browser history stays complete at ${cols}x${rows}`,
|
| 377 |
+
uniqueTokens(stepBrowserText) === initialBrowserTokens,
|
| 378 |
+
`${initialBrowserTokens} before, ${uniqueTokens(stepBrowserText)} after`);
|
| 379 |
+
check(`browser deep history survives at ${cols}x${rows}`,
|
| 380 |
+
stepBrowserText.includes('history-0001'));
|
| 381 |
+
const state = await v.bufferState();
|
| 382 |
+
check(`browser reaches both history ends at ${cols}x${rows}`,
|
| 383 |
+
state?.canReachBothEnds, JSON.stringify(state));
|
| 384 |
+
check(`browser remains at the live bottom at ${cols}x${rows}`,
|
| 385 |
+
state?.atBottom, JSON.stringify(state));
|
| 386 |
+
check(`styled history survives at ${cols}x${rows}`,
|
| 387 |
+
state?.coloredHistoryCells > 0, JSON.stringify(state));
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
const finalGridText = await gridText(id);
|
| 392 |
+
const afterGrid = duplicateTokens(finalGridText);
|
| 393 |
+
check('zoom round-trip adds no duplicate frame to canonical history', afterGrid === beforeGrid,
|
| 394 |
+
`${beforeGrid} before, ${afterGrid} after`);
|
| 395 |
+
check('zoom round-trip retains canonical history', uniqueTokens(finalGridText) === initialGridTokens,
|
| 396 |
+
`${initialGridTokens} before, ${uniqueTokens(finalGridText)} after`);
|
| 397 |
+
if (Headless) {
|
| 398 |
+
const finalBrowserText = await v.screenText();
|
| 399 |
+
const afterBrowser = duplicateTokens(finalBrowserText);
|
| 400 |
+
check('zoom round-trip adds no duplicate frame to browser history', afterBrowser === beforeBrowser,
|
| 401 |
+
`${beforeBrowser} before, ${afterBrowser} after`);
|
| 402 |
+
check('zoom round-trip retains browser history', uniqueTokens(finalBrowserText) === initialBrowserTokens,
|
| 403 |
+
`${initialBrowserTokens} before, ${uniqueTokens(finalBrowserText)} after`);
|
| 404 |
+
}
|
| 405 |
+
v.close();
|
| 406 |
+
await sleep(250);
|
| 407 |
+
const reattached = await view(id, 150, 40, true);
|
| 408 |
+
const restored = await waitFor(() => reattached.lastFrame('restore')?.cols === 150);
|
| 409 |
+
const restoredText = Headless ? await reattached.screenText() : '';
|
| 410 |
+
check('reattach remains free of resize-generated frames', restored
|
| 411 |
+
&& (!Headless || duplicateTokens(restoredText) === beforeGrid),
|
| 412 |
+
Headless ? `${duplicateTokens(restoredText)} duplicate tokens` : '');
|
| 413 |
+
check('reattach retains complete and deep history', restored
|
| 414 |
+
&& (!Headless || (uniqueTokens(restoredText) === initialBrowserTokens
|
| 415 |
+
&& restoredText.includes('history-0001'))));
|
| 416 |
+
if (Headless) {
|
| 417 |
+
const state = await reattached.bufferState();
|
| 418 |
+
check('reattach reaches both history ends and starts at bottom',
|
| 419 |
+
state?.canReachBothEnds && state?.atBottom, JSON.stringify(state));
|
| 420 |
+
check('reattach retains styled history', state?.coloredHistoryCells > 0,
|
| 421 |
+
JSON.stringify(state));
|
| 422 |
+
}
|
| 423 |
+
reattached.close();
|
| 424 |
+
await stop(id);
|
| 425 |
+
|
| 426 |
+
// Stopping the PTY models a backend restart: a new host must load its
|
| 427 |
+
// durable checkpoint before the resumed TUI paints, without duplicating
|
| 428 |
+
// either the old frame or its welcome banner.
|
| 429 |
+
const checkpoint = path.join(DATA_DIR, 'state', 'terminal-history', `${id}.json`);
|
| 430 |
+
check('terminal history is checkpointed durably', await waitFor(() => fs.existsSync(checkpoint)));
|
| 431 |
+
// A mobile pane opens its socket with xterm's provisional 80x24 geometry,
|
| 432 |
+
// then reports the measured phone grid as soon as layout settles. Exercise
|
| 433 |
+
// that change while the resumed TUI is still repainting, not only after the
|
| 434 |
+
// startup transaction has committed.
|
| 435 |
+
const restarted = await view(id, 80, 24, true);
|
| 436 |
+
restarted.resize(40, 28);
|
| 437 |
+
const resumed = await waitFor(async () => (await gridText(id)).includes('[fixture 40x28]'));
|
| 438 |
+
const startupCommitted = await waitFor(() => restarted.frames.some((frame) =>
|
| 439 |
+
frame.t === 'grid' && frame.reset));
|
| 440 |
+
await sleep(350);
|
| 441 |
+
const restartedText = Headless ? await restarted.screenText() : '';
|
| 442 |
+
const restartDuplicates = Headless ? duplicateTokens(restartedText) : 0;
|
| 443 |
+
check('host restart restores deep scrollback', resumed
|
| 444 |
+
&& (!Headless || restartedText.includes('history-0001')));
|
| 445 |
+
check('delayed startup repaint commits as one transaction', startupCommitted);
|
| 446 |
+
check('host restart does not duplicate delayed repaint content', !Headless
|
| 447 |
+
|| restartDuplicates === beforeBrowser,
|
| 448 |
+
Headless ? `${restartDuplicates} duplicate tokens` : '');
|
| 449 |
+
if (Headless) {
|
| 450 |
+
check('host restart retains exact transcript cardinality',
|
| 451 |
+
uniqueTokens(restartedText) === initialBrowserTokens,
|
| 452 |
+
`${initialBrowserTokens} before, ${uniqueTokens(restartedText)} after`);
|
| 453 |
+
const state = await restarted.bufferState();
|
| 454 |
+
check('restored viewport reaches both ends and starts at bottom',
|
| 455 |
+
state?.canReachBothEnds && state?.atBottom, JSON.stringify(state));
|
| 456 |
+
check('host restart retains styled history', state?.coloredHistoryCells > 0,
|
| 457 |
+
JSON.stringify(state));
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
// Exercise the actual failure combination: after a delayed restart, a
|
| 461 |
+
// second viewer takes the geometry lease, zooms narrow, then zooms wide.
|
| 462 |
+
// Every transition must preserve the same transcript and scroll model.
|
| 463 |
+
const takeover = await view(id, 110, 28, true);
|
| 464 |
+
await sleep(150);
|
| 465 |
+
takeover.claim();
|
| 466 |
+
const claimed = await waitFor(() => takeover.lastFrame('grid')?.controller === true
|
| 467 |
+
&& takeover.lastFrame('grid')?.cols === 110 && takeover.lastFrame('grid')?.rows === 28);
|
| 468 |
+
check('post-restart watcher claims the narrow zoom geometry', claimed,
|
| 469 |
+
JSON.stringify(takeover.lastFrame('grid')));
|
| 470 |
+
await sleep(150);
|
| 471 |
+
if (Headless) {
|
| 472 |
+
const narrowText = await takeover.screenText();
|
| 473 |
+
const state = await takeover.bufferState();
|
| 474 |
+
check('post-restart narrow zoom keeps one complete transcript',
|
| 475 |
+
duplicateTokens(narrowText) === beforeBrowser
|
| 476 |
+
&& uniqueTokens(narrowText) === initialBrowserTokens,
|
| 477 |
+
`${duplicateTokens(narrowText)} duplicates, ${uniqueTokens(narrowText)} tokens`);
|
| 478 |
+
check('post-restart narrow zoom keeps scroll and styling',
|
| 479 |
+
state?.canReachBothEnds && state?.atBottom && state?.coloredHistoryCells > 0,
|
| 480 |
+
JSON.stringify(state));
|
| 481 |
+
}
|
| 482 |
+
takeover.resize(150, 40);
|
| 483 |
+
const widened = await waitFor(() => takeover.lastFrame('grid')?.cols === 150
|
| 484 |
+
&& takeover.lastFrame('grid')?.rows === 40);
|
| 485 |
+
check('post-restart controller zooms wide again', widened,
|
| 486 |
+
JSON.stringify(takeover.lastFrame('grid')));
|
| 487 |
+
await sleep(150);
|
| 488 |
+
if (Headless) {
|
| 489 |
+
const wideText = await takeover.screenText();
|
| 490 |
+
const state = await takeover.bufferState();
|
| 491 |
+
check('post-restart wide zoom keeps one complete transcript',
|
| 492 |
+
duplicateTokens(wideText) === beforeBrowser
|
| 493 |
+
&& uniqueTokens(wideText) === initialBrowserTokens,
|
| 494 |
+
`${duplicateTokens(wideText)} duplicates, ${uniqueTokens(wideText)} tokens`);
|
| 495 |
+
check('post-restart wide zoom keeps scroll and styling',
|
| 496 |
+
state?.canReachBothEnds && state?.atBottom && state?.coloredHistoryCells > 0,
|
| 497 |
+
JSON.stringify(state));
|
| 498 |
+
}
|
| 499 |
+
takeover.close();
|
| 500 |
+
restarted.close();
|
| 501 |
+
await stop(id);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// A drag should issue one final resize, not one SIGWINCH per animation frame.
|
| 505 |
+
{
|
| 506 |
+
const id = await session('resize storm');
|
| 507 |
+
const v = await view(id, 120, 40);
|
| 508 |
+
await sleep(400);
|
| 509 |
+
for (let i = 0; i < 16; i++) {
|
| 510 |
+
v.resize(120 - i * 2, 40 - i);
|
| 511 |
+
await sleep(25);
|
| 512 |
+
}
|
| 513 |
+
v.resize(88, 24);
|
| 514 |
+
const settled = await waitFor(() => v.lastFrame('grid')?.cols === 88 && v.lastFrame('grid')?.rows === 24);
|
| 515 |
+
check('resize storm settles at its final size', settled, JSON.stringify(v.lastFrame('grid')));
|
| 516 |
+
check('resize storm is coalesced', v.countFrames('grid') <= 3, `${v.countFrames('grid')} grid frames`);
|
| 517 |
+
v.close();
|
| 518 |
+
await stop(id);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
// Low zoom on a large/high-DPI panel can legitimately exceed the former
|
| 522 |
+
// 400x200 cap. The safety bound must not create dead space in that range.
|
| 523 |
+
{
|
| 524 |
+
const id = await session('large panel');
|
| 525 |
+
const v = await view(id, 120, 40);
|
| 526 |
+
await sleep(300);
|
| 527 |
+
v.resize(640, 300);
|
| 528 |
+
const expanded = await waitFor(() => v.lastFrame('grid')?.cols === 640
|
| 529 |
+
&& v.lastFrame('grid')?.rows === 300);
|
| 530 |
+
check('large low-zoom panel is not clipped to the legacy grid cap', expanded,
|
| 531 |
+
JSON.stringify(v.lastFrame('grid')));
|
| 532 |
+
v.close();
|
| 533 |
+
await stop(id);
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
// Reattachment is serialized from Ghostty state, not from a 256 KiB suffix
|
| 537 |
+
// of raw PTY traffic. Both the oldest and newest retained rows must return.
|
| 538 |
+
{
|
| 539 |
+
const id = await session('full canonical restore');
|
| 540 |
+
const a = await view(id, 110, 30);
|
| 541 |
+
await sleep(600);
|
| 542 |
+
a.type(`for i in $(seq 1 4500); do printf 'RESTORE-%04d-%070d\\n' "$i" "$i"; done\r`);
|
| 543 |
+
const complete = await waitFor(async () => (await gridText(id, 5000)).includes('RESTORE-4500-'), 20_000);
|
| 544 |
+
check('large history finishes printing', complete);
|
| 545 |
+
a.close();
|
| 546 |
+
await sleep(300);
|
| 547 |
+
const b = await view(id, 110, 30, true);
|
| 548 |
+
const restored = await waitFor(() => b.bytes.includes('RESTORE-4500-'), 12_000);
|
| 549 |
+
check('reattach receives the canonical restore', restored && !!b.lastFrame('restore'));
|
| 550 |
+
check('restore is not capped at the old raw replay limit', b.bytes.length > 262_144, `${b.bytes.length} bytes`);
|
| 551 |
+
check('restore includes the oldest retained output', b.bytes.includes('RESTORE-0001-'));
|
| 552 |
+
check('restore includes the newest retained output', b.bytes.includes('RESTORE-4500-'));
|
| 553 |
+
if (Headless) {
|
| 554 |
+
const rendered = await b.screenText();
|
| 555 |
+
check('a fresh emulator can scroll to the oldest restored output', rendered.includes('RESTORE-0001-'));
|
| 556 |
+
check('a fresh emulator contains the newest restored output', rendered.includes('RESTORE-4500-'));
|
| 557 |
+
}
|
| 558 |
+
b.close();
|
| 559 |
+
await stop(id);
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
// One viewer owns both input and size. A watcher has no effect until an
|
| 563 |
+
// explicit claim, after which the old controller becomes inert.
|
| 564 |
+
{
|
| 565 |
+
const id = await session('controller lease');
|
| 566 |
+
const a = await view(id, 120, 35);
|
| 567 |
+
await sleep(400);
|
| 568 |
+
const b = await view(id, 60, 20);
|
| 569 |
+
await sleep(400);
|
| 570 |
+
check('first viewer is the controller', a.lastFrame('grid')?.controller === true
|
| 571 |
+
&& b.lastFrame('restore')?.controller === false);
|
| 572 |
+
b.resize(50, 15);
|
| 573 |
+
await sleep(400);
|
| 574 |
+
check('watcher size cannot shrink the session', b.lastFrame('restore')?.cols === 120
|
| 575 |
+
&& !b.frames.some((frame) => frame.t === 'grid' && frame.cols === 50));
|
| 576 |
+
b.type('echo WATCHER-MUST-NOT-RUN\r');
|
| 577 |
+
await sleep(400);
|
| 578 |
+
check('watcher input is rejected by the server', !a.bytes.includes('WATCHER-MUST-NOT-RUN'));
|
| 579 |
+
|
| 580 |
+
b.claim();
|
| 581 |
+
const claimed = await waitFor(() => b.lastFrame('grid')?.controller === true
|
| 582 |
+
&& b.lastFrame('grid')?.cols === 50 && b.lastFrame('grid')?.rows === 15);
|
| 583 |
+
check('watcher can claim input and geometry together', claimed, JSON.stringify(b.lastFrame('grid')));
|
| 584 |
+
b.type('echo CONTROLLER-RAN\r');
|
| 585 |
+
check('new controller input reaches every viewer', await waitFor(() => a.bytes.includes('CONTROLLER-RAN')
|
| 586 |
+
&& b.bytes.includes('CONTROLLER-RAN')));
|
| 587 |
+
a.resize(140, 45);
|
| 588 |
+
await sleep(400);
|
| 589 |
+
check('old controller becomes an inert watcher', b.lastFrame('grid')?.cols === 50
|
| 590 |
+
&& b.lastFrame('grid')?.rows === 15);
|
| 591 |
+
|
| 592 |
+
b.close();
|
| 593 |
+
const handed = await waitFor(() => a.lastFrame('grid')?.controller === true
|
| 594 |
+
&& a.lastFrame('grid')?.cols === 140 && a.lastFrame('grid')?.rows === 45);
|
| 595 |
+
check('controller lease passes to a remaining viewer on disconnect', handed, JSON.stringify(a.lastFrame('grid')));
|
| 596 |
+
a.close();
|
| 597 |
+
await stop(id);
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
// Focus and unrelated layout events may re-report the same preference.
|
| 601 |
+
{
|
| 602 |
+
const id = await session('no-op size');
|
| 603 |
+
const v = await view(id, 80, 24);
|
| 604 |
+
await sleep(300);
|
| 605 |
+
const before = v.countFrames('grid');
|
| 606 |
+
for (let i = 0; i < 5; i++) v.resize(80, 24);
|
| 607 |
+
await sleep(500);
|
| 608 |
+
check('re-reporting the current size performs no reset', v.countFrames('grid') === before,
|
| 609 |
+
`${v.countFrames('grid') - before} extra frames`);
|
| 610 |
+
v.close();
|
| 611 |
+
await stop(id);
|
| 612 |
+
}
|
| 613 |
+
} catch (err) {
|
| 614 |
+
check('no exceptions', false, String(err && err.message ? err.message : err));
|
| 615 |
+
console.log('--- server log tail ---\n' + log.slice(-1600));
|
| 616 |
+
} finally {
|
| 617 |
+
srv.kill('SIGTERM');
|
| 618 |
+
await sleep(500);
|
| 619 |
+
srv.kill('SIGKILL');
|
| 620 |
+
try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch {}
|
| 621 |
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
| 622 |
+
process.exit(failures ? 1 : 0);
|
| 623 |
+
}
|
server/src/config.js
CHANGED
|
@@ -46,16 +46,15 @@ export function refreshVersions() {
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
? process.env.USE_TMUX === '1'
|
| 52 |
-
: TMUX_AVAILABLE;
|
| 53 |
|
| 54 |
/**
|
| 55 |
* CLI catalog.
|
| 56 |
* - `bin` : binary checked on PATH to report availability
|
| 57 |
* - `run` : command run inside the session shell on first launch
|
| 58 |
* - `cont` : command used to resume a prior conversation (null = no resume)
|
|
|
|
| 59 |
*/
|
| 60 |
// One setup story for every agent; only the accepted secret names differ.
|
| 61 |
const setupHint = (...keys) =>
|
|
@@ -81,23 +80,27 @@ export const CLIS = [
|
|
| 81 |
// nothing to launch HERE (that is the point), but unlike files/trace it holds
|
| 82 |
// a conversation, so it gets a light and a digest like any other agent.
|
| 83 |
{ id: 'remote', label: 'Remote agent', bin: null, color: '#5ec2e0', run: null, cont: null },
|
| 84 |
-
{ id: 'claude', label: 'Claude Code', bin: 'claude', color: '#d97757', run: 'claude', cont: 'claude --continue',
|
| 85 |
withPrompt: (q) => `claude ${q}`,
|
| 86 |
setup: setupHint('ANTHROPIC_API_KEY') },
|
| 87 |
-
{ id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last',
|
| 88 |
withPrompt: (q) => `codex ${q}`,
|
| 89 |
setup: setupHint('OPENAI_API_KEY') },
|
| 90 |
-
{ id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null,
|
| 91 |
withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
|
| 92 |
setup: setupHint('GEMINI_API_KEY') },
|
| 93 |
-
{ id: 'opencode', label: 'opencode', bin: 'opencode', color: '#8a93a0', run: 'opencode', cont: 'opencode --continue',
|
| 94 |
withPrompt: (q) => `opencode --prompt ${q}`,
|
| 95 |
setup: setupHint('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY') },
|
| 96 |
-
{ id: 'hermes', label: 'Hermes', bin: 'hermes', color: '#a78bfa', run: 'hermes', cont: 'hermes -c',
|
| 97 |
setup: setupHint('HF_TOKEN', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'NOUS_API_KEY') },
|
| 98 |
// `chat` = TUI in --local mode: embedded agent, no gateway/daemon needed.
|
| 99 |
-
{ id: 'openclaw', label: 'OpenClaw', bin: 'openclaw', color: '#c83636', run: 'openclaw chat', cont: null,
|
| 100 |
setup: setupHint('ANTHROPIC_API_KEY') },
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
];
|
| 102 |
|
| 103 |
export function cliById(id) {
|
|
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
| 49 |
+
// tmux is no longer part of the terminal path: sessions are PTYs held by this
|
| 50 |
+
// process with a libghostty-vt grid as the authoritative screen (see runner.js).
|
|
|
|
|
|
|
| 51 |
|
| 52 |
/**
|
| 53 |
* CLI catalog.
|
| 54 |
* - `bin` : binary checked on PATH to report availability
|
| 55 |
* - `run` : command run inside the session shell on first launch
|
| 56 |
* - `cont` : command used to resume a prior conversation (null = no resume)
|
| 57 |
+
* - `resizeMode: 'repaint'`: primary-screen TUI redraws its frame on SIGWINCH
|
| 58 |
*/
|
| 59 |
// One setup story for every agent; only the accepted secret names differ.
|
| 60 |
const setupHint = (...keys) =>
|
|
|
|
| 80 |
// nothing to launch HERE (that is the point), but unlike files/trace it holds
|
| 81 |
// a conversation, so it gets a light and a digest like any other agent.
|
| 82 |
{ id: 'remote', label: 'Remote agent', bin: null, color: '#5ec2e0', run: null, cont: null },
|
| 83 |
+
{ id: 'claude', label: 'Claude Code', bin: 'claude', color: '#d97757', run: 'claude', cont: 'claude --continue', resizeMode: 'repaint',
|
| 84 |
withPrompt: (q) => `claude ${q}`,
|
| 85 |
setup: setupHint('ANTHROPIC_API_KEY') },
|
| 86 |
+
{ id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last', resizeMode: 'repaint',
|
| 87 |
withPrompt: (q) => `codex ${q}`,
|
| 88 |
setup: setupHint('OPENAI_API_KEY') },
|
| 89 |
+
{ id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, resizeMode: 'repaint',
|
| 90 |
withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
|
| 91 |
setup: setupHint('GEMINI_API_KEY') },
|
| 92 |
+
{ id: 'opencode', label: 'opencode', bin: 'opencode', color: '#8a93a0', run: 'opencode', cont: 'opencode --continue', resizeMode: 'repaint',
|
| 93 |
withPrompt: (q) => `opencode --prompt ${q}`,
|
| 94 |
setup: setupHint('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY') },
|
| 95 |
+
{ id: 'hermes', label: 'Hermes', bin: 'hermes', color: '#a78bfa', run: 'hermes', cont: 'hermes -c', resizeMode: 'repaint',
|
| 96 |
setup: setupHint('HF_TOKEN', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'NOUS_API_KEY') },
|
| 97 |
// `chat` = TUI in --local mode: embedded agent, no gateway/daemon needed.
|
| 98 |
+
{ id: 'openclaw', label: 'OpenClaw', bin: 'openclaw', color: '#c83636', run: 'openclaw chat', cont: null, resizeMode: 'repaint',
|
| 99 |
setup: setupHint('ANTHROPIC_API_KEY') },
|
| 100 |
+
// Test-only primary-screen repaint fixture. Absent from every normal runtime.
|
| 101 |
+
...(process.env.AM_TEST_REPAINT_CMD
|
| 102 |
+
? [{ id: 'test-repaint', label: 'Repaint fixture', bin: 'bash', color: '#8aa0ad', run: process.env.AM_TEST_REPAINT_CMD, cont: null, resizeMode: 'repaint' }]
|
| 103 |
+
: []),
|
| 104 |
];
|
| 105 |
|
| 106 |
export function cliById(id) {
|
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/history-store.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
|
| 4 |
+
const fileFor = (directory, id) => path.join(
|
| 5 |
+
directory, `${String(id).replace(/[^a-zA-Z0-9._-]/g, '_')}.json`,
|
| 6 |
+
);
|
| 7 |
+
|
| 8 |
+
export const TERMINAL_HISTORY_VERSION = 8;
|
| 9 |
+
|
| 10 |
+
const validAnsi = (ansi, text) => typeof ansi === 'string'
|
| 11 |
+
&& ansi.replace(/\x1b\[[0-9;]*m/g, '') === text;
|
| 12 |
+
|
| 13 |
+
/** Load a plain-text Ghostty scrollback checkpoint, ignoring old/bad schemas. */
|
| 14 |
+
export function loadTerminalHistory(directory, id) {
|
| 15 |
+
try {
|
| 16 |
+
const body = fs.readFileSync(fileFor(directory, id), 'utf8');
|
| 17 |
+
const saved = JSON.parse(body);
|
| 18 |
+
if (![1, 2, 3, 4, 5, 6, 7, TERMINAL_HISTORY_VERSION].includes(saved?.version)
|
| 19 |
+
|| !Number.isFinite(saved.cols) || !Array.isArray(saved.lines)) return null;
|
| 20 |
+
const lines = saved.lines.flatMap((line) => {
|
| 21 |
+
if (typeof line === 'string') return [{ text: line }];
|
| 22 |
+
if (!line || typeof line.text !== 'string') return [];
|
| 23 |
+
return [validAnsi(line.ansi, line.text)
|
| 24 |
+
? { text: line.text, ansi: line.ansi } : { text: line.text }];
|
| 25 |
+
});
|
| 26 |
+
return lines.length ? {
|
| 27 |
+
version: saved.version, cols: Math.max(1, Math.round(saved.cols)), lines, body,
|
| 28 |
+
} : null;
|
| 29 |
+
} catch { return null; }
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Debounced, atomic checkpoint writer for one held terminal.
|
| 34 |
+
*
|
| 35 |
+
* `snapshot` is deliberately injected: storage knows nothing about Ghostty or
|
| 36 |
+
* session hosts. `blocked` keeps an in-flight repaint transaction from saving
|
| 37 |
+
* a pre-commit grid. At most one write is active; a newer snapshot supersedes
|
| 38 |
+
* any queued one.
|
| 39 |
+
*/
|
| 40 |
+
export function createTerminalHistoryCheckpoint({
|
| 41 |
+
directory, id, delayMs, snapshot, blocked, persistedBody = null,
|
| 42 |
+
}) {
|
| 43 |
+
let timer = null;
|
| 44 |
+
let pending = null;
|
| 45 |
+
let writing = false;
|
| 46 |
+
let lastBody = persistedBody;
|
| 47 |
+
|
| 48 |
+
const writePending = () => {
|
| 49 |
+
if (writing || !pending) return;
|
| 50 |
+
const next = pending;
|
| 51 |
+
pending = null;
|
| 52 |
+
if (next === lastBody) return;
|
| 53 |
+
writing = true;
|
| 54 |
+
const target = fileFor(directory, id);
|
| 55 |
+
const temporary = `${target}.${process.pid}.tmp`;
|
| 56 |
+
fs.promises.mkdir(directory, { recursive: true })
|
| 57 |
+
.then(() => fs.promises.writeFile(temporary, next))
|
| 58 |
+
.then(() => fs.promises.rename(temporary, target))
|
| 59 |
+
.then(() => { lastBody = next; })
|
| 60 |
+
.catch((error) => {
|
| 61 |
+
console.error('[runner] history checkpoint', error && error.message);
|
| 62 |
+
fs.promises.unlink(temporary).catch(() => {});
|
| 63 |
+
})
|
| 64 |
+
.finally(() => {
|
| 65 |
+
writing = false;
|
| 66 |
+
if (pending) writePending();
|
| 67 |
+
});
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const schedule = () => {
|
| 71 |
+
if (timer) return;
|
| 72 |
+
timer = setTimeout(flush, delayMs);
|
| 73 |
+
if (timer.unref) timer.unref();
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const flush = () => {
|
| 77 |
+
if (timer) { clearTimeout(timer); timer = null; }
|
| 78 |
+
if (blocked()) { schedule(); return; }
|
| 79 |
+
let snap;
|
| 80 |
+
try { snap = snapshot(); } catch { return; }
|
| 81 |
+
const lines = (snap.scrollbackLines || []).map((line) => {
|
| 82 |
+
const text = line.text || '';
|
| 83 |
+
return validAnsi(line.ansi, text) ? { text, ansi: line.ansi } : text;
|
| 84 |
+
});
|
| 85 |
+
pending = JSON.stringify({ version: TERMINAL_HISTORY_VERSION, cols: snap.cols, lines });
|
| 86 |
+
writePending();
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
return { schedule, flush };
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const normalText = (text) => String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Render earlier conversational turns as plain terminal history.
|
| 96 |
+
*
|
| 97 |
+
* The latest user turn is deliberately omitted: the resumed TUI owns that
|
| 98 |
+
* viewport. Messages already visible in the live grid are omitted as well, so
|
| 99 |
+
* trace recovery cannot create a second copy of content Claude did repaint.
|
| 100 |
+
*/
|
| 101 |
+
export function traceHistoryLines(page, currentText = '', maxChars = 1024 * 1024) {
|
| 102 |
+
const turns = Array.isArray(page?.turns) ? page.turns : [];
|
| 103 |
+
let latestUser = -1;
|
| 104 |
+
for (let i = turns.length - 1; i >= 0; i--) {
|
| 105 |
+
if (turns[i]?.role === 'user') { latestUser = i; break; }
|
| 106 |
+
}
|
| 107 |
+
const current = normalText(currentText);
|
| 108 |
+
const currentLines = new Set(String(currentText || '').split(/\r?\n/)
|
| 109 |
+
.map(normalText).filter(Boolean));
|
| 110 |
+
const lines = [];
|
| 111 |
+
let chars = 0;
|
| 112 |
+
for (const turn of turns.slice(0, latestUser < 0 ? turns.length : latestUser)) {
|
| 113 |
+
if (turn?.role !== 'user' && turn?.role !== 'assistant') continue;
|
| 114 |
+
if (turn.role === 'assistant' && turn.kind && turn.kind !== 'final') continue;
|
| 115 |
+
const text = (turn.blocks || [])
|
| 116 |
+
.filter((block) => block?.type === 'text')
|
| 117 |
+
.map((block) => String(block.text || ''))
|
| 118 |
+
.join('\n')
|
| 119 |
+
.trim();
|
| 120 |
+
if (!text) continue;
|
| 121 |
+
const rendered = `${turn.role === 'user' ? '❯' : '●'} ${text}`;
|
| 122 |
+
// Prefix matching needs a minimum length to avoid accidental prose hits,
|
| 123 |
+
// but short prompts are safe to match as complete marked terminal rows.
|
| 124 |
+
if (!rendered.includes('\n') && currentLines.has(normalText(rendered))) continue;
|
| 125 |
+
const normalized = normalText(text);
|
| 126 |
+
const probe = normalized.slice(0, 80);
|
| 127 |
+
if (probe.length >= 12 && current.includes(probe)) continue;
|
| 128 |
+
const next = [...rendered.split('\n'), ''];
|
| 129 |
+
for (const line of next) { lines.push(line); chars += line.length + 1; }
|
| 130 |
+
while (chars > maxChars && lines.length) chars -= lines.shift().length + 1;
|
| 131 |
+
}
|
| 132 |
+
while (lines.at(-1) === '') lines.pop();
|
| 133 |
+
return lines.map((text) => ({ text }));
|
| 134 |
+
}
|
server/src/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import crypto from 'node:crypto';
|
|
| 8 |
import express from 'express';
|
| 9 |
import { WebSocketServer } from 'ws';
|
| 10 |
import {
|
| 11 |
-
PORT, PUBLIC_DIR, DATA_DIR, WORKSPACES_DIR, SKILLS_DIR,
|
| 12 |
ensureDirs, cliCatalog, cliById, slugify, workspacePath, refreshVersions, PASSIVE_CLIS, isRemote,
|
| 13 |
} from './config.js';
|
| 14 |
import * as remote from './remote.js';
|
|
@@ -16,7 +16,12 @@ import * as store from './sessions.js';
|
|
| 16 |
import * as groups from './groups.js';
|
| 17 |
import * as order from './order.js';
|
| 18 |
import * as demo from './demo.js';
|
| 19 |
-
import { attach, agentInfo, deriveState, stop, ensureRunning, sendInput,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
import { buildUsage } from './usage.js';
|
| 21 |
import { buildTraces, traceDigests, digestFor, traceLocation, readTrace, readTraceBundle, readTraceByPath, traceHarnessOf } from './traces.js';
|
| 22 |
import { initPush, publicKey, deviceCount, addSubscription, removeSubscription, sendToAll } from './push.js';
|
|
@@ -130,6 +135,12 @@ await Promise.race([
|
|
| 130 |
// terminal. An unhandled rejection from an async route, or an error emitted by
|
| 131 |
// a dropped socket, would otherwise take the whole thing down. Log and keep
|
| 132 |
// running instead of exiting.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
|
| 134 |
process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
|
| 135 |
|
|
@@ -150,7 +161,7 @@ app.use((req, res, next) => {
|
|
| 150 |
app.get('/api/visibility', (_req, res) => res.json(visibility()));
|
| 151 |
|
| 152 |
app.get('/api/health', (_req, res) =>
|
| 153 |
-
res.json({ ok: true,
|
| 154 |
|
| 155 |
app.get('/api/clis', (_req, res) => res.json(cliCatalog()));
|
| 156 |
|
|
@@ -248,8 +259,8 @@ async function deliver(session, text, from) {
|
|
| 248 |
}
|
| 249 |
|
| 250 |
// Type a prompt into a session's terminal from the Overview — no pane needed.
|
| 251 |
-
// If the agent is stopped,
|
| 252 |
-
//
|
| 253 |
app.post('/api/sessions/:id/input', async (req, res) => {
|
| 254 |
const s = store.get(req.params.id);
|
| 255 |
if (!s) return res.status(404).json({ error: 'not found' });
|
|
@@ -267,9 +278,8 @@ app.post('/api/sessions/:id/input', async (req, res) => {
|
|
| 267 |
// ---------- agent-to-agent API (/api/agents) ----------
|
| 268 |
// Agents coordinate through the same primitives the operator drives from the
|
| 269 |
// Overview: read the roster, watch a pane, send a prompt, wait, launch a peer,
|
| 270 |
-
// stop one. This
|
| 271 |
-
//
|
| 272 |
-
// attributed, and stable enough to document in the environment skill.
|
| 273 |
//
|
| 274 |
// The etiquette (check state before interrupting, don't ping-pong, don't stop
|
| 275 |
// someone unasked, don't spawn armies) is TAUGHT in that skill rather than
|
|
@@ -707,7 +717,8 @@ app.get('/api/info', (_req, res) => res.json({
|
|
| 707 |
home: process.env.HOME || null,
|
| 708 |
spaceId: process.env.SPACE_ID || null,
|
| 709 |
spaceHost: process.env.SPACE_HOST || null,
|
| 710 |
-
|
|
|
|
| 711 |
locked: isPublic(),
|
| 712 |
lockReason: visibility().reason,
|
| 713 |
lockBucket: visibility().bucket,
|
|
@@ -972,7 +983,7 @@ Hermes — alongside plain shells and a file browser.
|
|
| 972 |
## What persists (and what doesn't)
|
| 973 |
- \`/data\` is **durable storage** (a mounted bucket). Files under \`/data/workspaces/…\` and \`/data/home/…\` survive restarts and sleep.
|
| 974 |
- **Empty directories are not persisted** — only files. If a folder must exist, keep a file in it.
|
| 975 |
-
- Sessions are
|
| 976 |
- Exception: OpenClaw runs with its own \`$HOME\` on local disk for filesystem compatibility; that state is backed up to the bucket every minute.
|
| 977 |
|
| 978 |
## You may not be alone
|
|
@@ -2136,77 +2147,54 @@ wss.on('connection', (ws, req) => {
|
|
| 2136 |
return;
|
| 2137 |
}
|
| 2138 |
|
| 2139 |
-
// tmux prints a bare "[exited]" line as its parting output — strip it (the
|
| 2140 |
-
// client shows a styled stopped-state instead) but pass everything else.
|
| 2141 |
-
const stripExit = (d) => (typeof d === 'string' ? d.replace(/(\r?\n)?\[exited\](\r?\n)?$/, '') : d);
|
| 2142 |
handle.onData((d) => {
|
| 2143 |
if (ws.readyState !== ws.OPEN) return;
|
| 2144 |
-
|
| 2145 |
-
if (out.length) ws.send(out);
|
| 2146 |
});
|
| 2147 |
handle.onExit(() => {
|
|
|
|
|
|
|
|
|
|
| 2148 |
if (ws.readyState === ws.OPEN) {
|
| 2149 |
-
|
| 2150 |
-
// must react differently to each:
|
| 2151 |
-
// 4001 = another device attached and tmux's -D detached us. The session
|
| 2152 |
-
// is alive and now belongs to that device, so this client goes
|
| 2153 |
-
// quiet instead of reconnecting (that would be a tug-of-war).
|
| 2154 |
-
// 4000 = the agent process itself exited. Do NOT auto-reconnect (it
|
| 2155 |
-
// would respawn the agent in a loop); offer a Restart instead.
|
| 2156 |
-
const handedOff = USE_TMUX && isRunning(session.id);
|
| 2157 |
-
try {
|
| 2158 |
-
ws.close(handedOff ? 4001 : 4000, handedOff ? 'handedoff' : 'exited');
|
| 2159 |
-
} catch { ws.close(); }
|
| 2160 |
}
|
| 2161 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2163 |
ws.on('message', (raw) => {
|
| 2164 |
let msg;
|
| 2165 |
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
| 2166 |
if (msg.t === 'i') handle.write(msg.d);
|
| 2167 |
else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
|
| 2168 |
-
else if (msg.t === '
|
| 2169 |
-
const osc52 = copySelection(session.id);
|
| 2170 |
-
if (osc52 && ws.readyState === ws.OPEN) ws.send(osc52);
|
| 2171 |
-
}
|
| 2172 |
});
|
| 2173 |
|
| 2174 |
-
//
|
| 2175 |
-
|
| 2176 |
-
|
| 2177 |
-
|
| 2178 |
-
|
| 2179 |
-
|
| 2180 |
-
|
| 2181 |
-
|
| 2182 |
-
|
| 2183 |
-
|
| 2184 |
-
|
| 2185 |
-
const lastMode = new Map(); // sessionId -> boolean
|
| 2186 |
-
let modeTimer = null;
|
| 2187 |
-
function registerModeClient(id, ws) {
|
| 2188 |
-
if (!USE_TMUX) return;
|
| 2189 |
-
if (!modeClients.has(id)) modeClients.set(id, new Set());
|
| 2190 |
-
modeClients.get(id).add(ws);
|
| 2191 |
-
// Send current state immediately so a client attaching mid-copy-mode is right.
|
| 2192 |
-
try { ws.send(MODE_CTRL + JSON.stringify({ t: 'mode', copy: !!lastMode.get(id) })); } catch {}
|
| 2193 |
-
if (!modeTimer) modeTimer = setInterval(pollModes, 350);
|
| 2194 |
-
}
|
| 2195 |
-
function unregisterModeClient(id, ws) {
|
| 2196 |
-
const set = modeClients.get(id);
|
| 2197 |
-
if (set) { set.delete(ws); if (!set.size) { modeClients.delete(id); lastMode.delete(id); } }
|
| 2198 |
-
if (!modeClients.size && modeTimer) { clearInterval(modeTimer); modeTimer = null; }
|
| 2199 |
-
}
|
| 2200 |
-
function pollModes() {
|
| 2201 |
-
const modes = paneModes();
|
| 2202 |
-
for (const [id, set] of modeClients) {
|
| 2203 |
-
const now = !!modes[id];
|
| 2204 |
-
if (lastMode.get(id) === now) continue;
|
| 2205 |
-
lastMode.set(id, now);
|
| 2206 |
-
const frame = MODE_CTRL + JSON.stringify({ t: 'mode', copy: now });
|
| 2207 |
-
for (const ws of set) if (ws.readyState === ws.OPEN) { try { ws.send(frame); } catch {} }
|
| 2208 |
}
|
| 2209 |
-
|
|
|
|
|
|
|
|
|
|
| 2210 |
|
| 2211 |
generateEnvSkill(loadSecretNotes()); // keep the environment skill current on boot
|
| 2212 |
|
|
@@ -2223,7 +2211,7 @@ setTimeout(() => { traceDigests().catch(() => {}); }, 4000);
|
|
| 2223 |
startWatchdog();
|
| 2224 |
|
| 2225 |
server.listen(PORT, () => {
|
| 2226 |
-
console.log(`Agent Manager :${PORT}
|
| 2227 |
console.log('⚠ No authentication: this app trusts whoever can reach it.');
|
| 2228 |
console.log(' Keep this Space PRIVATE — a public instance gives anyone a shell + your logged-in agents.');
|
| 2229 |
});
|
|
|
|
| 8 |
import express from 'express';
|
| 9 |
import { WebSocketServer } from 'ws';
|
| 10 |
import {
|
| 11 |
+
PORT, PUBLIC_DIR, DATA_DIR, WORKSPACES_DIR, SKILLS_DIR,
|
| 12 |
ensureDirs, cliCatalog, cliById, slugify, workspacePath, refreshVersions, PASSIVE_CLIS, isRemote,
|
| 13 |
} from './config.js';
|
| 14 |
import * as remote from './remote.js';
|
|
|
|
| 16 |
import * as groups from './groups.js';
|
| 17 |
import * as order from './order.js';
|
| 18 |
import * as demo from './demo.js';
|
| 19 |
+
import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError } from './runner.js';
|
| 20 |
+
|
| 21 |
+
// Control frames ride the terminal socket behind a leading NUL pair, which real
|
| 22 |
+
// PTY output never begins with. Same sentinel the old copy-mode hint used, so the
|
| 23 |
+
// frontend's framing is unchanged.
|
| 24 |
+
const TERM_CTRL = '\x00\x00AM:';
|
| 25 |
import { buildUsage } from './usage.js';
|
| 26 |
import { buildTraces, traceDigests, digestFor, traceLocation, readTrace, readTraceBundle, readTraceByPath, traceHarnessOf } from './traces.js';
|
| 27 |
import { initPush, publicKey, deviceCount, addSubscription, removeSubscription, sendToAll } from './push.js';
|
|
|
|
| 135 |
// terminal. An unhandled rejection from an async route, or an error emitted by
|
| 136 |
// a dropped socket, would otherwise take the whole thing down. Log and keep
|
| 137 |
// running instead of exiting.
|
| 138 |
+
// No tmux to outlive us any more: kill the PTYs we hold on the way out so a
|
| 139 |
+
// restart can't leave orphaned agents writing into the workspace.
|
| 140 |
+
for (const sig of ['SIGTERM', 'SIGINT']) {
|
| 141 |
+
process.on(sig, () => { try { stopAll(); } catch {} process.exit(0); });
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
|
| 145 |
process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
|
| 146 |
|
|
|
|
| 161 |
app.get('/api/visibility', (_req, res) => res.json(visibility()));
|
| 162 |
|
| 163 |
app.get('/api/health', (_req, res) =>
|
| 164 |
+
res.json({ ok: true, engine: 'libghostty', ghostty: ghosttyReady(), ghosttyError }));
|
| 165 |
|
| 166 |
app.get('/api/clis', (_req, res) => res.json(cliCatalog()));
|
| 167 |
|
|
|
|
| 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
|
|
|
|
| 717 |
home: process.env.HOME || null,
|
| 718 |
spaceId: process.env.SPACE_ID || null,
|
| 719 |
spaceHost: process.env.SPACE_HOST || null,
|
| 720 |
+
engine: 'libghostty',
|
| 721 |
+
ghostty: ghosttyReady(),
|
| 722 |
locked: isPublic(),
|
| 723 |
lockReason: visibility().reason,
|
| 724 |
lockBucket: visibility().bucket,
|
|
|
|
| 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
|
|
|
|
| 2147 |
return;
|
| 2148 |
}
|
| 2149 |
|
|
|
|
|
|
|
|
|
|
| 2150 |
handle.onData((d) => {
|
| 2151 |
if (ws.readyState !== ws.OPEN) return;
|
| 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 |
+
// Register input before sending the initial restore. Browsers may issue a
|
| 2171 |
+
// claim/resize from `onopen`, while this function is still serializing that
|
| 2172 |
+
// restore. Installing the listener afterwards left a small window where the
|
| 2173 |
+
// first mobile geometry request was silently lost.
|
| 2174 |
ws.on('message', (raw) => {
|
| 2175 |
let msg;
|
| 2176 |
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
| 2177 |
if (msg.t === 'i') handle.write(msg.d);
|
| 2178 |
else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
|
| 2179 |
+
else if (msg.t === 'claim') handle.claim();
|
|
|
|
|
|
|
|
|
|
| 2180 |
});
|
| 2181 |
|
| 2182 |
+
// Ghostty owns the durable terminal model. Reattachment receives one canonical
|
| 2183 |
+
// serialization of its retained scrollback and current styled screen.
|
| 2184 |
+
const restore = handle.restore();
|
| 2185 |
+
if (restore) {
|
| 2186 |
+
try {
|
| 2187 |
+
ws.send(TERM_CTRL + JSON.stringify({
|
| 2188 |
+
t: 'restore', cols: restore.cols, rows: restore.rows,
|
| 2189 |
+
viewers: restore.viewers, controller: restore.controller, reset: true,
|
| 2190 |
+
}));
|
| 2191 |
+
ws.send(restore.ansi);
|
| 2192 |
+
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2193 |
}
|
| 2194 |
+
|
| 2195 |
+
// Detaching a viewer, NOT stopping the session.
|
| 2196 |
+
ws.on('close', () => handle.kill());
|
| 2197 |
+
});
|
| 2198 |
|
| 2199 |
generateEnvSkill(loadSecretNotes()); // keep the environment skill current on boot
|
| 2200 |
|
|
|
|
| 2211 |
startWatchdog();
|
| 2212 |
|
| 2213 |
server.listen(PORT, () => {
|
| 2214 |
+
console.log(`Agent Manager :${PORT} engine=libghostty${ghosttyReady() ? '' : ' (UNAVAILABLE)'} data=${DATA_DIR}`);
|
| 2215 |
console.log('⚠ No authentication: this app trusts whoever can reach it.');
|
| 2216 |
console.log(' Keep this Space PRIVATE — a public instance gives anyone a shell + your logged-in agents.');
|
| 2217 |
});
|
server/src/runner.js
CHANGED
|
@@ -1,12 +1,31 @@
|
|
| 1 |
import os from 'node:os';
|
| 2 |
import path from 'node:path';
|
| 3 |
import pty from 'node-pty';
|
| 4 |
-
import { execFileSync } from 'node:child_process';
|
| 5 |
import fs from 'node:fs';
|
| 6 |
-
import { USE_TMUX, cliById, WORKSPACES_DIR, isRemote } from './config.js';
|
| 7 |
-
import { update, list } from './sessions.js';
|
| 8 |
-
import { captureOpencodeSession } from './traces.js';
|
| 9 |
import { remoteState, setPaused } from './remote.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const TERM_ENV = {
|
| 12 |
...process.env,
|
|
@@ -16,18 +35,74 @@ const TERM_ENV = {
|
|
| 16 |
};
|
| 17 |
|
| 18 |
const BASHRC = process.env.AM_BASHRC || '/app/session.bashrc';
|
| 19 |
-
const TMUX_CONF = process.env.TMUX_CONF || '/app/tmux.conf';
|
| 20 |
const AM_USER = process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || os.userInfo().username || 'user';
|
| 21 |
|
| 22 |
// Interactive bash that loads our prompt rcfile (bash ignores a missing rcfile,
|
| 23 |
// so this is safe in local dev where /app/session.bashrc doesn't exist).
|
| 24 |
const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
//
|
| 29 |
const BUSY_SECS = 4;
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
function djb2(s) {
|
| 33 |
let h = 5381;
|
|
@@ -35,108 +110,46 @@ function djb2(s) {
|
|
| 35 |
return h;
|
| 36 |
}
|
| 37 |
|
| 38 |
-
// In direct-PTY (no-tmux) mode we track live handles to report running status.
|
| 39 |
-
const live = new Map(); // id -> Set(handle)
|
| 40 |
-
const track = (id, h) => {
|
| 41 |
-
if (!live.has(id)) live.set(id, new Set());
|
| 42 |
-
live.get(id).add(h);
|
| 43 |
-
};
|
| 44 |
-
const untrack = (id, h) => {
|
| 45 |
-
const s = live.get(id);
|
| 46 |
-
if (s) {
|
| 47 |
-
s.delete(h);
|
| 48 |
-
if (!s.size) live.delete(id);
|
| 49 |
-
}
|
| 50 |
-
};
|
| 51 |
-
|
| 52 |
export function isRunning(id) {
|
| 53 |
-
|
| 54 |
-
try {
|
| 55 |
-
execFileSync('tmux', ['has-session', '-t', tmuxName(id)], { stdio: 'ignore' });
|
| 56 |
-
return true;
|
| 57 |
-
} catch {
|
| 58 |
-
return false;
|
| 59 |
-
}
|
| 60 |
-
}
|
| 61 |
-
return live.has(id);
|
| 62 |
}
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
const name = line.slice(sp + 1);
|
| 80 |
-
if (!name.startsWith('am-')) continue;
|
| 81 |
-
const id = name.slice(3);
|
| 82 |
-
modes[id] = modes[id] || line.slice(0, sp) === '1';
|
| 83 |
}
|
| 84 |
-
return modes;
|
| 85 |
}
|
| 86 |
|
| 87 |
/**
|
| 88 |
-
*
|
| 89 |
-
*
|
| 90 |
-
*
|
| 91 |
-
* elapsed-time counters (their text changes). Returns Map id -> { age } where
|
| 92 |
-
* age is seconds since the pane text last changed.
|
| 93 |
*/
|
| 94 |
-
let infoMemo = { ts: 0, map: new Map() };
|
| 95 |
-
|
| 96 |
export function agentInfo() {
|
| 97 |
-
// The sweep shells out to tmux once per session, synchronously. Memoize it
|
| 98 |
-
// briefly so N browser tabs polling /api/tree don't multiply that cost.
|
| 99 |
-
if (Date.now() - infoMemo.ts < 1500) return infoMemo.map;
|
| 100 |
const map = new Map();
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
// stderr is dropped on every tmux call that already handles its own
|
| 106 |
-
// failure: with no server running (fresh container, or after the last
|
| 107 |
-
// session ends) tmux prints "error connecting to /tmp/tmux-1000/default"
|
| 108 |
-
// and the catch below treats that as "no sessions" — so the message is
|
| 109 |
-
// pure noise that made a healthy Space look broken in the log.
|
| 110 |
-
list = execFileSync('tmux', ['list-sessions', '-F', '#{session_name}'],
|
| 111 |
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
| 112 |
-
} catch {
|
| 113 |
-
paneSig.clear();
|
| 114 |
-
return map; // no server / no sessions
|
| 115 |
-
}
|
| 116 |
-
const now = Math.floor(Date.now() / 1000);
|
| 117 |
-
const live = new Set();
|
| 118 |
-
for (const name of list.split('\n')) {
|
| 119 |
-
if (!name.startsWith('am-')) continue;
|
| 120 |
-
const id = name.slice(3);
|
| 121 |
-
live.add(id);
|
| 122 |
-
let text = '';
|
| 123 |
-
try {
|
| 124 |
-
text = execFileSync('tmux', ['capture-pane', '-p', '-t', name],
|
| 125 |
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
| 126 |
-
} catch {}
|
| 127 |
-
const sig = djb2(text);
|
| 128 |
-
const prev = paneSig.get(id);
|
| 129 |
-
const changedAt = !prev || prev.sig !== sig ? now : prev.changedAt;
|
| 130 |
-
paneSig.set(id, { sig, changedAt });
|
| 131 |
-
map.set(id, { age: now - changedAt });
|
| 132 |
}
|
| 133 |
-
for (const id of [...paneSig.keys()]) if (!live.has(id)) paneSig.delete(id);
|
| 134 |
return map;
|
| 135 |
}
|
| 136 |
|
| 137 |
/**
|
| 138 |
* Map activity + the session's known CLI into a UI state:
|
| 139 |
-
* working —
|
| 140 |
* waiting — agent alive but its screen is static → it's your turn
|
| 141 |
* idle — a plain shell sitting at its prompt
|
| 142 |
* stopped — no live session
|
|
@@ -151,6 +164,492 @@ export function deriveState(session, info) {
|
|
| 151 |
return session.cli === 'shell' ? 'idle' : 'waiting';
|
| 152 |
}
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
// ---------- Codex conversation pinning ----------
|
| 155 |
// Codex picks its own conversation id at launch and doesn't accept one up
|
| 156 |
// front — but it announces the pick immediately: a rollout file named
|
|
@@ -598,18 +1097,28 @@ function commandFor(session) {
|
|
| 598 |
}
|
| 599 |
|
| 600 |
// Other agents: resume when one likely exists, else a fresh launch. `exec` so
|
| 601 |
-
// the agent is the
|
| 602 |
-
//
|
| 603 |
if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
|
| 604 |
if (q0 && cli.withPrompt) return `exec ${cli.withPrompt(q0)}`;
|
| 605 |
return `exec ${cli.run}`;
|
| 606 |
}
|
| 607 |
|
| 608 |
-
/**
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
// The recorded workspace-relative path ('' = the workspaces root itself).
|
| 614 |
// If the folder was deleted or moved, mkdir simply recreates it empty — no
|
| 615 |
// tracking, no magic.
|
|
@@ -617,160 +1126,265 @@ export function attach(session, cols, rows) {
|
|
| 617 |
const workdir = path.join(WORKSPACES_DIR, folder);
|
| 618 |
fs.mkdirSync(workdir, { recursive: true });
|
| 619 |
const full = commandFor(session);
|
|
|
|
| 620 |
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
|
|
|
| 647 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
|
|
|
|
|
|
|
| 649 |
if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
|
| 650 |
if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
|
| 651 |
if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
|
| 652 |
if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
|
| 653 |
-
|
| 654 |
-
const handle = {
|
| 655 |
-
onData: (cb) => term.onData(cb),
|
| 656 |
-
onExit: (cb) => term.onExit(cb),
|
| 657 |
-
write: (d) => { try { term.write(d); } catch {} },
|
| 658 |
-
resize: (c, r) => { try { term.resize(c, r); } catch {} },
|
| 659 |
-
kill: () => { try { term.kill(); } catch {} },
|
| 660 |
-
};
|
| 661 |
-
track(session.id, handle);
|
| 662 |
-
term.onExit(() => untrack(session.id, handle));
|
| 663 |
-
return handle;
|
| 664 |
}
|
| 665 |
|
| 666 |
/**
|
| 667 |
-
*
|
| 668 |
-
*
|
| 669 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
*/
|
| 671 |
-
export function
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
if (
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
}
|
| 699 |
|
| 700 |
/** Type a line into the session's terminal (works with no browser attached). */
|
| 701 |
export async function sendInput(id, text) {
|
|
|
|
|
|
|
| 702 |
// Multi-line prompts go in as a bracketed paste so the CLI's composer treats
|
| 703 |
// the inner newlines as soft line breaks instead of submitting early.
|
| 704 |
const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
|
| 705 |
// The Enter must arrive as its OWN keypress: TUIs (codex) detect rapid input
|
| 706 |
// bursts as a paste, and a CR inside the burst becomes a newline in the
|
| 707 |
// composer instead of a submit. A short gap breaks the burst.
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
await gap();
|
| 712 |
-
execFileSync('tmux', ['send-keys', '-t', tmuxName(id), 'Enter'], { stdio: 'ignore' });
|
| 713 |
-
return;
|
| 714 |
-
}
|
| 715 |
-
const set = live.get(id);
|
| 716 |
-
if (!set || !set.size) throw new Error('session is not running');
|
| 717 |
-
const h = set.values().next().value;
|
| 718 |
-
h.write(payload);
|
| 719 |
-
await gap();
|
| 720 |
-
h.write('\r');
|
| 721 |
}
|
| 722 |
|
| 723 |
/**
|
| 724 |
* The session's rendered screen plus `lines` of scrollback above it — what a
|
| 725 |
* human would see in the pane. Used by the agent API so one agent can watch
|
| 726 |
* another's progress instead of spending a turn asking.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
*/
|
| 728 |
export function capturePane(id, lines = 80) {
|
| 729 |
-
|
|
|
|
| 730 |
const n = Math.max(0, Math.min(2000, lines));
|
| 731 |
-
let
|
| 732 |
-
try {
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
//
|
| 737 |
-
|
| 738 |
-
// scrollback, so the caller decides whether to trim the visible screen too.
|
| 739 |
-
return out.replace(/\s+$/, '');
|
| 740 |
-
}
|
| 741 |
-
|
| 742 |
-
/** Return the last mouse selection for this session as a browser-consumable OSC 52. */
|
| 743 |
-
export function copySelection(id) {
|
| 744 |
-
if (!USE_TMUX) return null;
|
| 745 |
-
const bufferName = `am-copy-${tmuxName(id)}`;
|
| 746 |
-
let text = '';
|
| 747 |
-
try {
|
| 748 |
-
text = execFileSync('tmux', ['save-buffer', '-b', bufferName, '-'],
|
| 749 |
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: TERM_ENV });
|
| 750 |
-
} catch {
|
| 751 |
-
try {
|
| 752 |
-
text = execFileSync('tmux', ['save-buffer', '-'],
|
| 753 |
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: TERM_ENV });
|
| 754 |
-
} catch {
|
| 755 |
-
return null;
|
| 756 |
-
}
|
| 757 |
-
}
|
| 758 |
-
if (!text) return null;
|
| 759 |
-
return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
|
| 760 |
}
|
| 761 |
|
| 762 |
-
/** Stop a session entirely (kills the
|
| 763 |
export function stop(id) {
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
|
|
|
| 775 |
}
|
| 776 |
}
|
|
|
|
| 1 |
import os from 'node:os';
|
| 2 |
import path from 'node:path';
|
| 3 |
import pty from 'node-pty';
|
|
|
|
| 4 |
import fs from 'node:fs';
|
|
|
|
|
|
|
|
|
|
| 5 |
import { remoteState, setPaused } from './remote.js';
|
| 6 |
+
import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
|
| 7 |
+
import { update, list } from './sessions.js';
|
| 8 |
+
import { captureOpencodeSession, readTrace } from './traces.js';
|
| 9 |
+
import {
|
| 10 |
+
buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns,
|
| 11 |
+
} from './snapshot.js';
|
| 12 |
+
import {
|
| 13 |
+
createTerminalHistoryCheckpoint, loadTerminalHistory, TERMINAL_HISTORY_VERSION,
|
| 14 |
+
traceHistoryLines,
|
| 15 |
+
} from './history-store.js';
|
| 16 |
+
|
| 17 |
+
// libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
|
| 18 |
+
// is guarded so a platform without a prebuilt still boots and says so, rather
|
| 19 |
+
// than taking the whole app down.
|
| 20 |
+
let ghostty = null;
|
| 21 |
+
export let ghosttyError = null;
|
| 22 |
+
try {
|
| 23 |
+
ghostty = await import('@coder/libghostty-vt-node');
|
| 24 |
+
buildPaletteIndex(ghostty.createTerminal);
|
| 25 |
+
} catch (err) {
|
| 26 |
+
ghosttyError = String(err && err.message ? err.message : err);
|
| 27 |
+
console.error('[runner] libghostty-vt unavailable:', ghosttyError);
|
| 28 |
+
}
|
| 29 |
|
| 30 |
const TERM_ENV = {
|
| 31 |
...process.env,
|
|
|
|
| 35 |
};
|
| 36 |
|
| 37 |
const BASHRC = process.env.AM_BASHRC || '/app/session.bashrc';
|
|
|
|
| 38 |
const AM_USER = process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || os.userInfo().username || 'user';
|
| 39 |
|
| 40 |
// Interactive bash that loads our prompt rcfile (bash ignores a missing rcfile,
|
| 41 |
// so this is safe in local dev where /app/session.bashrc doesn't exist).
|
| 42 |
const bashLaunch = `exec bash --rcfile ${BASHRC} -i`;
|
| 43 |
|
| 44 |
+
// ---------- session hosts (what replaced tmux) ----------
|
| 45 |
+
//
|
| 46 |
+
// Every session is one PTY held by THIS process, with a libghostty-vt terminal
|
| 47 |
+
// fed from its output. That terminal is the authoritative screen, so:
|
| 48 |
+
//
|
| 49 |
+
// * a browser attaching gets canonical history plus a snapshot repaint,
|
| 50 |
+
// instead of asking tmux to redraw and hoping the agent's TUI cooperates;
|
| 51 |
+
// * several browsers can watch the same session, with one explicit input/size
|
| 52 |
+
// controller instead of a one-device-at-a-time disconnect handover;
|
| 53 |
+
// * agent state is a property read rather than a `tmux capture-pane`
|
| 54 |
+
// subprocess per session per poll.
|
| 55 |
+
//
|
| 56 |
+
// PTYs still end with this process, but canonical scrollback is checkpointed to
|
| 57 |
+
// durable storage below and rejoined with the CLI's resumed screen on relaunch.
|
| 58 |
|
| 59 |
+
// Rendered text unchanged for this long means the agent isn't working.
|
| 60 |
const BUSY_SECS = 4;
|
| 61 |
+
// Re-rendering the grid to text on every chunk during a burst is wasteful.
|
| 62 |
+
const SAMPLE_THROTTLE_MS = 250;
|
| 63 |
+
// Despite the Node wrapper's `scrollbackLimit` name, Ghostty's native option is
|
| 64 |
+
// a byte budget. Passing a line count such as 20,000 retains only a small native
|
| 65 |
+
// allocation (about 700 ordinary rows). Keep the unit explicit at our boundary.
|
| 66 |
+
const DEFAULT_SCROLLBACK_BYTES = 64 * 1024 * 1024;
|
| 67 |
+
const configuredScrollback = process.env.AM_SCROLLBACK_BYTES === undefined
|
| 68 |
+
? Number.NaN
|
| 69 |
+
: Number(process.env.AM_SCROLLBACK_BYTES);
|
| 70 |
+
const SCROLLBACK_BYTES = Number.isFinite(configuredScrollback) && configuredScrollback >= 0
|
| 71 |
+
? configuredScrollback
|
| 72 |
+
: DEFAULT_SCROLLBACK_BYTES;
|
| 73 |
+
// A layout resize is only worth acting on once it stops changing. ResizeObserver
|
| 74 |
+
// fires per animation frame while a window is dragged; coalescing that burst
|
| 75 |
+
// avoids repeatedly reflowing the terminal and repeatedly sending SIGWINCH.
|
| 76 |
+
const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
|
| 77 |
+
// Primary-screen agent TUIs redraw their whole frame after SIGWINCH. Let that
|
| 78 |
+
// burst settle in a scratch emulator, then merge its final frame once; streaming
|
| 79 |
+
// the raw redraw would turn every overflow row into duplicate history.
|
| 80 |
+
const RESIZE_CAPTURE_IDLE_MS = Number(process.env.AM_RESIZE_CAPTURE_IDLE_MS || 80);
|
| 81 |
+
const RESIZE_CAPTURE_MAX_MS = Number(process.env.AM_RESIZE_CAPTURE_MAX_MS || 900);
|
| 82 |
+
// A resumed agent paints through the same primary-screen protocol as a resize,
|
| 83 |
+
// but may pause for seconds between its welcome frame and replayed transcript.
|
| 84 |
+
// Keep that transaction distinct from the deliberately short resize debounce:
|
| 85 |
+
// committing its first chunk makes every later chunk look like new history.
|
| 86 |
+
const STARTUP_CAPTURE_IDLE_MS = Number(process.env.AM_STARTUP_CAPTURE_IDLE_MS || 5000);
|
| 87 |
+
const STARTUP_CAPTURE_MAX_MS = Number(process.env.AM_STARTUP_CAPTURE_MAX_MS || 20000);
|
| 88 |
+
// Unlike the PTY process, /data survives a Space rebuild. Checkpoint canonical
|
| 89 |
+
// scrollback there so deploys and sleeps do not turn a resumed agent into a
|
| 90 |
+
// terminal with only its freshly repainted viewport.
|
| 91 |
+
const HISTORY_SAVE_MS = Number(process.env.AM_HISTORY_SAVE_MS || 5000);
|
| 92 |
+
const HISTORY_DIR = path.join(STATE_DIR, 'terminal-history');
|
| 93 |
+
// Claude's resume stream can pause between its welcome frame and replayed
|
| 94 |
+
// conversation. Treat that as one startup transaction; hydrating during the
|
| 95 |
+
// pause would seed a turn that Claude is about to print itself.
|
| 96 |
+
const TRACE_HYDRATE_IDLE_MS = Number(process.env.AM_TRACE_HYDRATE_IDLE_MS || 5000);
|
| 97 |
+
const TRACE_HYDRATE_MIN_MS = Number(process.env.AM_TRACE_HYDRATE_MIN_MS || 3000);
|
| 98 |
+
// Bound untrusted WebSocket geometry without imposing the old 400x200 ceiling,
|
| 99 |
+
// which left visible dead space on high-DPI displays at low zoom levels.
|
| 100 |
+
const MIN_COLS = 20;
|
| 101 |
+
const MIN_ROWS = 5;
|
| 102 |
+
const MAX_COLS = 1000;
|
| 103 |
+
const MAX_ROWS = 500;
|
| 104 |
+
|
| 105 |
+
const hosts = new Map(); // session id -> host
|
| 106 |
|
| 107 |
function djb2(s) {
|
| 108 |
let h = 5381;
|
|
|
|
| 110 |
return h;
|
| 111 |
}
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
export function isRunning(id) {
|
| 114 |
+
return hosts.has(id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
}
|
| 116 |
|
| 117 |
+
export function ghosttyReady() {
|
| 118 |
+
return !!ghostty;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/** Sample the grid's rendered text and record when it last changed. */
|
| 122 |
+
function sampleScreen(host) {
|
| 123 |
+
const now = Date.now();
|
| 124 |
+
if (host.lastSampleAt && now - host.lastSampleAt < SAMPLE_THROTTLE_MS) return;
|
| 125 |
+
host.lastSampleAt = now;
|
| 126 |
+
let text = '';
|
| 127 |
+
try { text = host.vt.getVisibleText(); } catch { return; }
|
| 128 |
+
const sig = djb2(text);
|
| 129 |
+
if (host.screenSig !== sig) {
|
| 130 |
+
host.screenSig = sig;
|
| 131 |
+
host.screenChangedAt = now;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
|
|
|
| 133 |
}
|
| 134 |
|
| 135 |
/**
|
| 136 |
+
* Activity per session, keyed like the old tmux sweep so callers don't change.
|
| 137 |
+
* No subprocess and no memo: the grid is already current, so this is a map build
|
| 138 |
+
* over held sessions.
|
|
|
|
|
|
|
| 139 |
*/
|
|
|
|
|
|
|
| 140 |
export function agentInfo() {
|
|
|
|
|
|
|
|
|
|
| 141 |
const map = new Map();
|
| 142 |
+
const now = Date.now();
|
| 143 |
+
for (const [id, host] of hosts) {
|
| 144 |
+
const changedAt = host.screenChangedAt || host.startedAt;
|
| 145 |
+
map.set(id, { age: Math.round((now - changedAt) / 1000), bells: host.bells || 0 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
|
|
|
| 147 |
return map;
|
| 148 |
}
|
| 149 |
|
| 150 |
/**
|
| 151 |
* Map activity + the session's known CLI into a UI state:
|
| 152 |
+
* working — screen text is actively changing (thinking / streaming / a command)
|
| 153 |
* waiting — agent alive but its screen is static → it's your turn
|
| 154 |
* idle — a plain shell sitting at its prompt
|
| 155 |
* stopped — no live session
|
|
|
|
| 164 |
return session.cli === 'shell' ? 'idle' : 'waiting';
|
| 165 |
}
|
| 166 |
|
| 167 |
+
/** The visible screen as text, with no browser attached. */
|
| 168 |
+
export function peek(id) {
|
| 169 |
+
const host = hosts.get(id);
|
| 170 |
+
if (!host) return null;
|
| 171 |
+
try { return host.vt.getVisibleText(); } catch { return null; }
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// ---------- the shared grid ----------
|
| 175 |
+
//
|
| 176 |
+
// A PTY has exactly one geometry. One attached viewer therefore holds a size
|
| 177 |
+
// lease (the controller); every other viewer watches the same grid without
|
| 178 |
+
// changing it. A watcher can take the lease through an explicit interaction.
|
| 179 |
+
// This prevents a phone or background tab from resizing a desktop session merely
|
| 180 |
+
// by connecting.
|
| 181 |
+
|
| 182 |
+
function effectiveGrid(host) {
|
| 183 |
+
return host.controller?.want || { cols: host.cols, rows: host.rows };
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function preferredGrid(cols, rows, fallback) {
|
| 187 |
+
const c = Number.isFinite(cols) ? Math.round(cols) : fallback.cols;
|
| 188 |
+
const r = Number.isFinite(rows) ? Math.round(rows) : fallback.rows;
|
| 189 |
+
return {
|
| 190 |
+
cols: Math.max(MIN_COLS, Math.min(MAX_COLS, c)),
|
| 191 |
+
rows: Math.max(MIN_ROWS, Math.min(MAX_ROWS, r)),
|
| 192 |
+
};
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function notifyGrid(host, reset = false) {
|
| 196 |
+
for (const sub of host.subs) {
|
| 197 |
+
sub.onGrid(host.cols, host.rows, host.controller === sub, host.subs.size, reset);
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function clearCaptureTimers(txn) {
|
| 202 |
+
if (txn.idleTimer) clearTimeout(txn.idleTimer);
|
| 203 |
+
if (txn.maxTimer) clearTimeout(txn.maxTimer);
|
| 204 |
+
txn.idleTimer = null;
|
| 205 |
+
txn.maxTimer = null;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function logicalText(lines, cols) {
|
| 209 |
+
let text = '';
|
| 210 |
+
for (let line = 0; line < lines.length; line++) {
|
| 211 |
+
const row = lines[line].text || '';
|
| 212 |
+
text += row;
|
| 213 |
+
// A full terminal row is a soft wrap. Do not insert whitespace: Claude's
|
| 214 |
+
// words can be split at an arbitrary column between snapshots. A shorter
|
| 215 |
+
// row ended with a real line break, which must remain part of the overlap.
|
| 216 |
+
if (textColumns(row) < cols) {
|
| 217 |
+
text += '\n';
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
return text;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function logicalHistory(text) {
|
| 224 |
+
if (!text) return [];
|
| 225 |
+
const lines = text.split('\n');
|
| 226 |
+
if (lines.at(-1) === '') lines.pop();
|
| 227 |
+
return lines.map((line) => ({ text: line }));
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function visibleRows(vt) {
|
| 231 |
+
try { return vt.getVisibleText().split('\n').map((text) => ({ text })); } catch { return []; }
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function longestPrefixOccurrence(pattern, text) {
|
| 235 |
+
const prefix = new Array(pattern.length).fill(0);
|
| 236 |
+
for (let i = 1, matched = 0; i < pattern.length; i++) {
|
| 237 |
+
while (matched && pattern[i] !== pattern[matched]) matched = prefix[matched - 1];
|
| 238 |
+
if (pattern[i] === pattern[matched]) matched++;
|
| 239 |
+
prefix[i] = matched;
|
| 240 |
+
}
|
| 241 |
+
let matched = 0, best = 0, bestEnd = -1;
|
| 242 |
+
for (let i = 0; i < text.length; i++) {
|
| 243 |
+
while (matched && text[i] !== pattern[matched]) matched = prefix[matched - 1];
|
| 244 |
+
if (text[i] === pattern[matched]) matched++;
|
| 245 |
+
if (matched > best) { best = matched; bestEnd = i; }
|
| 246 |
+
if (matched === pattern.length) matched = prefix[matched - 1];
|
| 247 |
+
}
|
| 248 |
+
return { length: best, end: bestEnd };
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/**
|
| 252 |
+
* Merge a primary-screen repaint into the fullest transcript seen so far.
|
| 253 |
+
*
|
| 254 |
+
* A growing pane can reveal an archived suffix after a fresh welcome/header,
|
| 255 |
+
* so the overlap may occur inside the repaint rather than at its first byte.
|
| 256 |
+
* Searching the reversed strings finds the longest archive suffix anywhere in
|
| 257 |
+
* the repaint in linear time. The repaint prefix is inserted before that
|
| 258 |
+
* suffix, preserving a header without retaining two copies of the turns.
|
| 259 |
+
*/
|
| 260 |
+
export function mergeRepaintArchive(base, repaint) {
|
| 261 |
+
if (!base) return repaint;
|
| 262 |
+
if (!repaint) return base;
|
| 263 |
+
if (!base.trim()) return repaint;
|
| 264 |
+
if (!repaint.trim()) return base;
|
| 265 |
+
const reverse = (text) => {
|
| 266 |
+
let result = '';
|
| 267 |
+
for (let i = text.length - 1; i >= 0; i--) result += text[i];
|
| 268 |
+
return result;
|
| 269 |
+
};
|
| 270 |
+
// Shape 1: an archive suffix is revealed after a freshly painted header.
|
| 271 |
+
const suffix = longestPrefixOccurrence(
|
| 272 |
+
reverse(base.slice(-repaint.length)), reverse(repaint),
|
| 273 |
+
);
|
| 274 |
+
// Shape 2: the repaint starts in the archive, then replaces its volatile
|
| 275 |
+
// footer (dimensions, status line, prompt chrome) with the new one.
|
| 276 |
+
const prefix = longestPrefixOccurrence(repaint.slice(0, base.length), base);
|
| 277 |
+
const minimum = Math.min(12, base.trim().length, repaint.trim().length);
|
| 278 |
+
if (Math.max(suffix.length, prefix.length) < minimum) return base + repaint;
|
| 279 |
+
if (prefix.length > suffix.length) {
|
| 280 |
+
const baseOffset = prefix.end - prefix.length + 1;
|
| 281 |
+
return base.slice(0, baseOffset) + repaint;
|
| 282 |
+
}
|
| 283 |
+
const repaintOffset = repaint.length - 1 - suffix.end;
|
| 284 |
+
return repaint.slice(0, repaintOffset) + base
|
| 285 |
+
+ repaint.slice(repaintOffset + suffix.length);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
/**
|
| 289 |
+
* Replace the archive's old visible tail with a new repaint.
|
| 290 |
+
*
|
| 291 |
+
* Resize output is presentation, never appended terminal output. Match the
|
| 292 |
+
* longest prefix of the new visible grid inside the archive; everything before
|
| 293 |
+
* that point is the hidden prefix, and the new grid replaces everything after
|
| 294 |
+
* it (including dimension-dependent status/footer text).
|
| 295 |
+
*/
|
| 296 |
+
export function repaintArchiveView(archive, visible, fallbackHistory = '') {
|
| 297 |
+
if (!visible) return { archive, history: logicalHistory(archive) };
|
| 298 |
+
const leading = visible.match(/^(?:[ \t]*\n)*/)?.[0].length || 0;
|
| 299 |
+
const candidate = visible.slice(leading);
|
| 300 |
+
let match = candidate ? longestPrefixOccurrence(candidate, archive) : { length: 0, end: -1 };
|
| 301 |
+
|
| 302 |
+
// A few shared characters are not a safe repaint boundary in a substantial
|
| 303 |
+
// screen. Claude's randomized status lines, for example, all begin with
|
| 304 |
+
// "Worked for ". Anchoring a fresh screen there can retain the old turn that
|
| 305 |
+
// precedes a later status line, then append that same turn again. Require a
|
| 306 |
+
// meaningful overlap for long screens, while still accepting complete short
|
| 307 |
+
// prompts in small panes.
|
| 308 |
+
const archiveContentLength = archive.trim().length;
|
| 309 |
+
const confidence = (tail) => Math.min(
|
| 310 |
+
64,
|
| 311 |
+
archiveContentLength,
|
| 312 |
+
Math.max(12, Math.floor(tail.trim().length / 2)),
|
| 313 |
+
);
|
| 314 |
+
let minimum = confidence(candidate);
|
| 315 |
+
|
| 316 |
+
// A repaint may start with one volatile row before its stable transcript.
|
| 317 |
+
// If the first row offers only a weak match, advance by logical lines until
|
| 318 |
+
// the first substantial overlap is found. The visible grid is bounded to 500
|
| 319 |
+
// rows, and the cap keeps a maliciously fragmented screen from repeatedly
|
| 320 |
+
// scanning a large archive.
|
| 321 |
+
if (match.length < minimum) {
|
| 322 |
+
let offset = 0;
|
| 323 |
+
for (let attempts = 0; attempts < 32;) {
|
| 324 |
+
const newline = candidate.indexOf('\n', offset);
|
| 325 |
+
if (newline < 0) break;
|
| 326 |
+
offset = newline + 1;
|
| 327 |
+
const tail = candidate.slice(offset);
|
| 328 |
+
if (!tail.trim()) break;
|
| 329 |
+
const nextBreak = tail.indexOf('\n');
|
| 330 |
+
const firstLine = tail.slice(0, nextBreak < 0 ? undefined : nextBreak);
|
| 331 |
+
if (firstLine.trim().length < 12) continue;
|
| 332 |
+
attempts++;
|
| 333 |
+
const next = longestPrefixOccurrence(tail, archive);
|
| 334 |
+
const nextMinimum = confidence(tail);
|
| 335 |
+
if (next.length >= nextMinimum) {
|
| 336 |
+
match = next;
|
| 337 |
+
minimum = nextMinimum;
|
| 338 |
+
break;
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
let history = fallbackHistory;
|
| 343 |
+
if (match.length >= minimum && minimum > 0) {
|
| 344 |
+
const archiveOffset = match.end - match.length + 1;
|
| 345 |
+
history = archive.slice(0, archiveOffset);
|
| 346 |
+
}
|
| 347 |
+
return { archive: history + visible, history: logicalHistory(history) };
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
function terminalArchive(vt, snap) {
|
| 351 |
+
return logicalText([...(snap.scrollbackLines || []), ...visibleRows(vt)], snap.cols);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
const MAX_HISTORY_STYLES = 50_000;
|
| 355 |
+
|
| 356 |
+
function learnHistoryStyles(host, vt, snap) {
|
| 357 |
+
if (!host.historyStyles || typeof vt.formatHtml !== 'function') return;
|
| 358 |
+
const visibleHasStyle = (snap.cells || []).some((cell) => cell.bold || cell.italic
|
| 359 |
+
|| cell.underline || cell.foreground || cell.background);
|
| 360 |
+
if (!visibleHasStyle && host.historyStyles.size === 0) return;
|
| 361 |
+
let styled;
|
| 362 |
+
try { styled = styledSnapshotLines(snap, vt.formatHtml()); } catch { return; }
|
| 363 |
+
for (const line of [...styled.rows, ...styled.logical]) {
|
| 364 |
+
if (!line.text || !line.ansi) continue;
|
| 365 |
+
// Refresh insertion order so frequently repainted transcript rows survive
|
| 366 |
+
// the bounded cache while old one-off status lines age out.
|
| 367 |
+
host.historyStyles.delete(line.text);
|
| 368 |
+
host.historyStyles.set(line.text, line.ansi);
|
| 369 |
+
}
|
| 370 |
+
while (host.historyStyles.size > MAX_HISTORY_STYLES) {
|
| 371 |
+
host.historyStyles.delete(host.historyStyles.keys().next().value);
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
function withHistoryStyles(host, lines) {
|
| 376 |
+
return (lines || []).map((line) => {
|
| 377 |
+
const ansi = line.ansi || host.historyStyles?.get(line.text || '');
|
| 378 |
+
return ansi ? { ...line, ansi } : line;
|
| 379 |
+
});
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
function styledSnapshot(host, vt, snap) {
|
| 383 |
+
learnHistoryStyles(host, vt, snap);
|
| 384 |
+
return { ...snap, scrollbackLines: withHistoryStyles(host, snap.scrollbackLines) };
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/**
|
| 388 |
+
* Commit one captured agent repaint without duplicating its overflow rows.
|
| 389 |
+
*
|
| 390 |
+
* The pre-resize scrollback boundary and final repaint are merged once, then
|
| 391 |
+
* both the server terminal and every viewer are restored from that same state.
|
| 392 |
+
* Shell sessions never use this path.
|
| 393 |
+
*/
|
| 394 |
+
function finishCapturedGrid(host, txn) {
|
| 395 |
+
if (host.resizeCapture !== txn) return;
|
| 396 |
+
host.resizeCapture = null;
|
| 397 |
+
clearCaptureTimers(txn);
|
| 398 |
+
|
| 399 |
+
let snap = null;
|
| 400 |
+
let replacement = null;
|
| 401 |
+
let committedArchive = null;
|
| 402 |
+
if (txn.sawData) {
|
| 403 |
+
try { snap = txn.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch {}
|
| 404 |
+
if (snap) {
|
| 405 |
+
try {
|
| 406 |
+
learnHistoryStyles(host, txn.vt, snap);
|
| 407 |
+
const visible = visibleRows(txn.vt);
|
| 408 |
+
const visibleText = logicalText(visible, txn.cols);
|
| 409 |
+
const view = repaintArchiveView(txn.archive, visibleText, txn.fallbackHistory);
|
| 410 |
+
committedArchive = view.archive;
|
| 411 |
+
replacement = ghostty.createTerminal({
|
| 412 |
+
cols: txn.cols,
|
| 413 |
+
rows: txn.rows,
|
| 414 |
+
scrollbackLimit: SCROLLBACK_BYTES,
|
| 415 |
+
});
|
| 416 |
+
replacement.feed(snapshotToRestoreAnsi({
|
| 417 |
+
...snap, scrollbackLines: withHistoryStyles(host, view.history),
|
| 418 |
+
}));
|
| 419 |
+
} catch (error) {
|
| 420 |
+
console.error('[runner] resize capture commit', error && error.message);
|
| 421 |
+
try { replacement?.dispose(); } catch {}
|
| 422 |
+
replacement = null;
|
| 423 |
+
snap = null;
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
try {
|
| 429 |
+
if (!snap) {
|
| 430 |
+
// The foreground process ignored SIGWINCH. Ordinary reflow is the only
|
| 431 |
+
// faithful outcome because no repaint exists to replace the old screen.
|
| 432 |
+
host.vt.resize(txn.cols, txn.rows);
|
| 433 |
+
host.cols = txn.cols;
|
| 434 |
+
host.rows = txn.rows;
|
| 435 |
+
host.repaintArchive = null;
|
| 436 |
+
notifyGrid(host, false);
|
| 437 |
+
} else {
|
| 438 |
+
const previous = host.vt;
|
| 439 |
+
host.vt = replacement;
|
| 440 |
+
host.cols = txn.cols;
|
| 441 |
+
host.rows = txn.rows;
|
| 442 |
+
// The archive retains the full transcript even when a wide grid exposes
|
| 443 |
+
// all of it and therefore needs no scrollback. A later narrow repaint can
|
| 444 |
+
// derive the hidden prefix again instead of losing the oldest rows.
|
| 445 |
+
host.repaintArchive = committedArchive;
|
| 446 |
+
// Reset and restore one canonical snapshot. Keeping a browser's old
|
| 447 |
+
// scrollback while painting only the new viewport makes the two models
|
| 448 |
+
// diverge after a narrow zoom, even when the server history is correct.
|
| 449 |
+
notifyGrid(host, true);
|
| 450 |
+
const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
|
| 451 |
+
const ansi = snapshotToRestoreAnsi(styledSnapshot(host, host.vt, committed));
|
| 452 |
+
for (const sub of host.subs) sub.onData(ansi);
|
| 453 |
+
try { previous.dispose(); } catch {}
|
| 454 |
+
sampleScreen(host);
|
| 455 |
+
}
|
| 456 |
+
} finally {
|
| 457 |
+
try { txn.vt.dispose(); } catch {}
|
| 458 |
+
}
|
| 459 |
+
host.historyCheckpoint.schedule();
|
| 460 |
+
|
| 461 |
+
// A newer controller preference may have arrived while the repaint settled.
|
| 462 |
+
const next = effectiveGrid(host);
|
| 463 |
+
if (next.cols !== host.cols || next.rows !== host.rows) scheduleGrid(host);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
function armCapturedGrid(host, txn) {
|
| 467 |
+
if (txn.idleTimer) clearTimeout(txn.idleTimer);
|
| 468 |
+
txn.idleTimer = setTimeout(() => finishCapturedGrid(host, txn), txn.idleMs);
|
| 469 |
+
if (txn.idleTimer.unref) txn.idleTimer.unref();
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
/** Start or supersede the bounded repaint transaction for an agent TUI. */
|
| 473 |
+
function startCapturedGrid(host, cols, rows, seed = null, resizePty = true) {
|
| 474 |
+
const previous = host.resizeCapture;
|
| 475 |
+
// A viewer can report its measured geometry before the resumed CLI emits
|
| 476 |
+
// its first byte. That resize is still part of startup, so claim the durable
|
| 477 |
+
// seed here rather than leaving it for a later output chunk.
|
| 478 |
+
const pendingStartup = !seed && !previous ? host.startupHistory : null;
|
| 479 |
+
if (pendingStartup) {
|
| 480 |
+
seed = {
|
| 481 |
+
history: pendingStartup.lines,
|
| 482 |
+
historyCols: pendingStartup.cols,
|
| 483 |
+
logical: true,
|
| 484 |
+
};
|
| 485 |
+
}
|
| 486 |
+
// Resizing during a startup capture supersedes its scratch grid, but must not
|
| 487 |
+
// downgrade its long idle window to the ordinary resize debounce. Otherwise
|
| 488 |
+
// a delayed resume repaint is committed later as duplicate terminal output.
|
| 489 |
+
const startupCapture = !!seed || !!previous?.startupCapture;
|
| 490 |
+
let archive = seed?.history
|
| 491 |
+
? (seed.logical
|
| 492 |
+
? `${seed.history.map((line) => line.text || '').join('\n')}\n`
|
| 493 |
+
: logicalText(seed.history, seed.historyCols || host.cols))
|
| 494 |
+
: host.repaintArchive;
|
| 495 |
+
let fallbackHistory = seed?.history ? archive : null;
|
| 496 |
+
if (previous) {
|
| 497 |
+
archive = previous.archive;
|
| 498 |
+
fallbackHistory = previous.fallbackHistory;
|
| 499 |
+
host.resizeCapture = null;
|
| 500 |
+
clearCaptureTimers(previous);
|
| 501 |
+
try { previous.vt.dispose(); } catch {}
|
| 502 |
+
}
|
| 503 |
+
try {
|
| 504 |
+
const source = host.vt.snapshot({ includeScrollback: true });
|
| 505 |
+
if (archive == null) {
|
| 506 |
+
archive = terminalArchive(host.vt, source);
|
| 507 |
+
}
|
| 508 |
+
if (fallbackHistory == null) {
|
| 509 |
+
fallbackHistory = logicalText(source.scrollbackLines || [], source.cols);
|
| 510 |
+
}
|
| 511 |
+
} catch { return false; }
|
| 512 |
+
|
| 513 |
+
let scratch;
|
| 514 |
+
try {
|
| 515 |
+
scratch = ghostty.createTerminal({ cols, rows, scrollbackLimit: 4 * 1024 * 1024 });
|
| 516 |
+
} catch (error) {
|
| 517 |
+
console.error('[runner] resize capture setup', error && error.message);
|
| 518 |
+
try { scratch?.dispose(); } catch {}
|
| 519 |
+
return false;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
const txn = {
|
| 523 |
+
vt: scratch,
|
| 524 |
+
cols,
|
| 525 |
+
rows,
|
| 526 |
+
archive: archive || '',
|
| 527 |
+
fallbackHistory: fallbackHistory || '',
|
| 528 |
+
sawData: false,
|
| 529 |
+
startupCapture,
|
| 530 |
+
idleMs: startupCapture ? STARTUP_CAPTURE_IDLE_MS : RESIZE_CAPTURE_IDLE_MS,
|
| 531 |
+
maxMs: startupCapture ? STARTUP_CAPTURE_MAX_MS : RESIZE_CAPTURE_MAX_MS,
|
| 532 |
+
idleTimer: null,
|
| 533 |
+
maxTimer: null,
|
| 534 |
+
};
|
| 535 |
+
host.resizeCapture = txn;
|
| 536 |
+
if (pendingStartup) host.startupHistory = null;
|
| 537 |
+
if (resizePty) { try { host.pty.resize(cols, rows); } catch {} }
|
| 538 |
+
txn.maxTimer = setTimeout(() => finishCapturedGrid(host, txn), txn.maxMs);
|
| 539 |
+
if (txn.maxTimer.unref) txn.maxTimer.unref();
|
| 540 |
+
return true;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
/**
|
| 544 |
+
* Move the session to the size its viewers imply.
|
| 545 |
+
*
|
| 546 |
+
* Shells use ordinary reflow: every row is real output. Known agent TUIs on the
|
| 547 |
+
* primary screen instead use a bounded scratch transaction because their full
|
| 548 |
+
* SIGWINCH redraw is presentation, not new history. Alternate-screen programs
|
| 549 |
+
* are already isolated from primary scrollback and use ordinary resize.
|
| 550 |
+
*/
|
| 551 |
+
function applyGrid(host) {
|
| 552 |
+
const { cols, rows } = effectiveGrid(host);
|
| 553 |
+
if (!host.resizeCapture && cols === host.cols && rows === host.rows) return false;
|
| 554 |
+
if (host.captureResize) {
|
| 555 |
+
let alt = false;
|
| 556 |
+
try { alt = !!host.vt.snapshot().isAltScreen; } catch {}
|
| 557 |
+
if (!alt && startCapturedGrid(host, cols, rows)) return true;
|
| 558 |
+
}
|
| 559 |
+
host.cols = cols;
|
| 560 |
+
host.rows = rows;
|
| 561 |
+
try { host.vt.resize(cols, rows); } catch {}
|
| 562 |
+
// Put the geometry frame on every viewer's ordered WebSocket stream before
|
| 563 |
+
// SIGWINCH can make the foreground application repaint at the new size.
|
| 564 |
+
// Otherwise those repaint bytes may be interpreted using the old grid and
|
| 565 |
+
// leave duplicated or displaced rows in the browser emulator.
|
| 566 |
+
notifyGrid(host, false);
|
| 567 |
+
try { host.pty.resize(cols, rows); } catch {}
|
| 568 |
+
return true;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
/**
|
| 572 |
+
* Apply the controller's preferred grid once requests stop arriving. If a
|
| 573 |
+
* pending request clamps back to the current size, report that canonical size.
|
| 574 |
+
*/
|
| 575 |
+
function scheduleGrid(host) {
|
| 576 |
+
if (host.gridTimer) clearTimeout(host.gridTimer);
|
| 577 |
+
host.gridTimer = setTimeout(() => {
|
| 578 |
+
host.gridTimer = null;
|
| 579 |
+
if (!applyGrid(host)) notifyGrid(host, false);
|
| 580 |
+
}, RESIZE_SETTLE_MS);
|
| 581 |
+
if (host.gridTimer.unref) host.gridTimer.unref();
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
function armTraceHydration(host) {
|
| 585 |
+
if (!host.traceHistoryPage || host.traceHistoryTimer) return;
|
| 586 |
+
const readyAt = Math.max(
|
| 587 |
+
host.startedAt + TRACE_HYDRATE_MIN_MS,
|
| 588 |
+
(host.lastOutputAt || host.startedAt) + TRACE_HYDRATE_IDLE_MS,
|
| 589 |
+
host.resizeCapture ? Date.now() + TRACE_HYDRATE_IDLE_MS : 0,
|
| 590 |
+
);
|
| 591 |
+
host.traceHistoryTimer = setTimeout(() => {
|
| 592 |
+
host.traceHistoryTimer = null;
|
| 593 |
+
if (hosts.get(host.id) !== host || !host.traceHistoryPage) return;
|
| 594 |
+
if (host.resizeCapture || Date.now() < readyAt) { armTraceHydration(host); return; }
|
| 595 |
+
|
| 596 |
+
let snap;
|
| 597 |
+
try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return; }
|
| 598 |
+
const visible = visibleRows(host.vt);
|
| 599 |
+
const visibleText = logicalText(visible, snap.cols);
|
| 600 |
+
const currentText = visible.map((line) => line.text || '').join('\n');
|
| 601 |
+
const recovered = traceHistoryLines(host.traceHistoryPage, currentText);
|
| 602 |
+
host.traceHistoryPage = null;
|
| 603 |
+
learnHistoryStyles(host, host.vt, snap);
|
| 604 |
+
|
| 605 |
+
let replacement = null;
|
| 606 |
+
try {
|
| 607 |
+
// The trace supplies missing older turns while the settled startup grid
|
| 608 |
+
// supplies the welcome and the newest live turn. Merge their overlap
|
| 609 |
+
// into one archive rather than appending either presentation verbatim.
|
| 610 |
+
const startup = terminalArchive(host.vt, snap);
|
| 611 |
+
const archive = mergeRepaintArchive(logicalText(recovered, snap.cols), startup);
|
| 612 |
+
const view = repaintArchiveView(archive, visibleText);
|
| 613 |
+
replacement = ghostty.createTerminal({
|
| 614 |
+
cols: host.cols, rows: host.rows, scrollbackLimit: SCROLLBACK_BYTES,
|
| 615 |
+
});
|
| 616 |
+
replacement.feed(snapshotToRestoreAnsi({
|
| 617 |
+
...snap,
|
| 618 |
+
scrollbackLines: withHistoryStyles(host, view.history),
|
| 619 |
+
}));
|
| 620 |
+
const previous = host.vt;
|
| 621 |
+
host.vt = replacement;
|
| 622 |
+
host.repaintArchive = view.archive;
|
| 623 |
+
notifyGrid(host, true);
|
| 624 |
+
const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
|
| 625 |
+
const ansi = snapshotToRestoreAnsi(styledSnapshot(host, host.vt, committed));
|
| 626 |
+
for (const sub of host.subs) sub.onData(ansi);
|
| 627 |
+
try { previous.dispose(); } catch {}
|
| 628 |
+
sampleScreen(host);
|
| 629 |
+
host.historyCheckpoint.schedule();
|
| 630 |
+
} catch (error) {
|
| 631 |
+
console.error('[runner] trace history restore', error && error.message);
|
| 632 |
+
try { replacement?.dispose(); } catch {}
|
| 633 |
+
}
|
| 634 |
+
}, Math.max(0, readyAt - Date.now()));
|
| 635 |
+
if (host.traceHistoryTimer.unref) host.traceHistoryTimer.unref();
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
async function hydrateTraceHistory(session, host) {
|
| 639 |
+
try {
|
| 640 |
+
let page = await readTrace(session, { offset: 0, limit: 500 });
|
| 641 |
+
if (page.total > page.turns.length) {
|
| 642 |
+
page = await readTrace(session, { offset: Math.max(0, page.total - 500), limit: 500 });
|
| 643 |
+
}
|
| 644 |
+
if (hosts.get(host.id) !== host) return;
|
| 645 |
+
host.traceHistoryPage = page;
|
| 646 |
+
armTraceHydration(host);
|
| 647 |
+
} catch {
|
| 648 |
+
// A new/onboarding session may not have a trace yet; live output remains
|
| 649 |
+
// authoritative and its first checkpoint will become the durable seed.
|
| 650 |
+
}
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
// ---------- Codex conversation pinning ----------
|
| 654 |
// Codex picks its own conversation id at launch and doesn't accept one up
|
| 655 |
// front — but it announces the pick immediately: a rollout file named
|
|
|
|
| 1097 |
}
|
| 1098 |
|
| 1099 |
// Other agents: resume when one likely exists, else a fresh launch. `exec` so
|
| 1100 |
+
// the agent is the PTY's foreground process; when it exits the session ends —
|
| 1101 |
+
// a clear "done" signal — and the fallback preserves that.
|
| 1102 |
if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
|
| 1103 |
if (q0 && cli.withPrompt) return `exec ${cli.withPrompt(q0)}`;
|
| 1104 |
return `exec ${cli.run}`;
|
| 1105 |
}
|
| 1106 |
|
| 1107 |
+
/**
|
| 1108 |
+
* Start the session's PTY and its grid, without any browser attached. Returns
|
| 1109 |
+
* true if it had to spawn. This is now the ONLY way a session starts, so the
|
| 1110 |
+
* Overview reply box waking a stopped agent and a browser opening a pane take
|
| 1111 |
+
* exactly the same path.
|
| 1112 |
+
*/
|
| 1113 |
+
export function ensureRunning(session, cols = 120, rows = 34) {
|
| 1114 |
+
// Nothing to start: a remote agent starts itself, on its own machine. Both
|
| 1115 |
+
// callers guard this too; keep the refusal here so no future one can spawn
|
| 1116 |
+
// a PTY for a pane that can never use it.
|
| 1117 |
+
if (isRemote(session.cli)) throw new Error('a remote agent runs on its own machine — nothing to start here');
|
| 1118 |
+
const existing = hosts.get(session.id);
|
| 1119 |
+
if (existing) return false;
|
| 1120 |
+
if (!ghostty) throw new Error(`libghostty-vt unavailable: ${ghosttyError}`);
|
| 1121 |
+
|
| 1122 |
// The recorded workspace-relative path ('' = the workspaces root itself).
|
| 1123 |
// If the folder was deleted or moved, mkdir simply recreates it empty — no
|
| 1124 |
// tracking, no magic.
|
|
|
|
| 1126 |
const workdir = path.join(WORKSPACES_DIR, folder);
|
| 1127 |
fs.mkdirSync(workdir, { recursive: true });
|
| 1128 |
const full = commandFor(session);
|
| 1129 |
+
const captureResize = cliById(session.cli)?.resizeMode === 'repaint';
|
| 1130 |
|
| 1131 |
+
const env = {
|
| 1132 |
+
...TERM_ENV,
|
| 1133 |
+
AM_SESSION: folder,
|
| 1134 |
+
AM_NAME: session.name,
|
| 1135 |
+
AM_ID: session.id,
|
| 1136 |
+
AM_USER,
|
| 1137 |
+
AM_ROOT: WORKSPACES_DIR, // prompt shows $PWD relative to this
|
| 1138 |
+
};
|
| 1139 |
+
const term = pty.spawn('bash', ['-lc', full], {
|
| 1140 |
+
name: 'xterm-256color', cols, rows, cwd: workdir, env,
|
| 1141 |
+
});
|
| 1142 |
+
const vt = ghostty.createTerminal({ cols, rows, scrollbackLimit: SCROLLBACK_BYTES });
|
| 1143 |
+
const loadedHistory = loadTerminalHistory(HISTORY_DIR, session.id);
|
| 1144 |
+
// Older agent checkpoints may contain startup repaint frames or a turn that
|
| 1145 |
+
// trace hydration raced with Claude's own replay. Rebuild those once from
|
| 1146 |
+
// the trace. Shell history never used that path and remains safe to restore.
|
| 1147 |
+
const persistedHistory = captureResize
|
| 1148 |
+
&& loadedHistory?.version < TERMINAL_HISTORY_VERSION ? null : loadedHistory;
|
| 1149 |
+
if (persistedHistory) {
|
| 1150 |
+
try {
|
| 1151 |
+
vt.feed(snapshotToRestoreAnsi({
|
| 1152 |
+
cols, rows, cursorRow: 0, cursorCol: 0, isAltScreen: false, cells: [],
|
| 1153 |
+
scrollbackLines: persistedHistory.lines,
|
| 1154 |
+
}));
|
| 1155 |
+
} catch (error) {
|
| 1156 |
+
console.error('[runner] history restore', error && error.message);
|
| 1157 |
+
}
|
| 1158 |
}
|
| 1159 |
+
const host = {
|
| 1160 |
+
id: session.id,
|
| 1161 |
+
pty: term,
|
| 1162 |
+
vt,
|
| 1163 |
+
cols,
|
| 1164 |
+
rows,
|
| 1165 |
+
captureResize,
|
| 1166 |
+
resizeCapture: null,
|
| 1167 |
+
repaintArchive: null,
|
| 1168 |
+
historyStyles: new Map((persistedHistory?.lines || [])
|
| 1169 |
+
.filter((line) => line.text && line.ansi).map((line) => [line.text, line.ansi])),
|
| 1170 |
+
startupHistory: captureResize ? persistedHistory : null,
|
| 1171 |
+
historyCheckpoint: null,
|
| 1172 |
+
traceHistoryPage: null,
|
| 1173 |
+
traceHistoryTimer: null,
|
| 1174 |
+
subs: new Set(),
|
| 1175 |
+
controller: null,
|
| 1176 |
+
gridTimer: null,
|
| 1177 |
+
startedAt: Date.now(),
|
| 1178 |
+
lastOutputAt: Date.now(),
|
| 1179 |
+
screenChangedAt: Date.now(),
|
| 1180 |
+
bells: 0,
|
| 1181 |
+
};
|
| 1182 |
+
host.historyCheckpoint = createTerminalHistoryCheckpoint({
|
| 1183 |
+
directory: HISTORY_DIR,
|
| 1184 |
+
id: host.id,
|
| 1185 |
+
delayMs: HISTORY_SAVE_MS,
|
| 1186 |
+
snapshot: () => {
|
| 1187 |
+
const snap = host.vt.snapshot({ includeScrollback: true });
|
| 1188 |
+
learnHistoryStyles(host, host.vt, snap);
|
| 1189 |
+
if (!captureResize) {
|
| 1190 |
+
return { ...snap, scrollbackLines: withHistoryStyles(host, snap.scrollbackLines) };
|
| 1191 |
+
}
|
| 1192 |
+
const archive = host.repaintArchive ?? terminalArchive(host.vt, snap);
|
| 1193 |
+
return {
|
| 1194 |
+
cols: host.cols,
|
| 1195 |
+
scrollbackLines: withHistoryStyles(host, logicalHistory(archive)),
|
| 1196 |
+
};
|
| 1197 |
+
},
|
| 1198 |
+
blocked: () => !!host.resizeCapture,
|
| 1199 |
+
persistedBody: persistedHistory?.body || null,
|
| 1200 |
+
});
|
| 1201 |
+
|
| 1202 |
+
term.onData((chunk) => {
|
| 1203 |
+
host.lastOutputAt = Date.now();
|
| 1204 |
+
if (host.traceHistoryTimer) {
|
| 1205 |
+
clearTimeout(host.traceHistoryTimer);
|
| 1206 |
+
host.traceHistoryTimer = null;
|
| 1207 |
+
}
|
| 1208 |
+
if (host.traceHistoryPage) armTraceHydration(host);
|
| 1209 |
+
let txn = host.resizeCapture;
|
| 1210 |
+
if (!txn && host.startupHistory) {
|
| 1211 |
+
startCapturedGrid(host, host.cols, host.rows, null, false);
|
| 1212 |
+
txn = host.resizeCapture;
|
| 1213 |
+
}
|
| 1214 |
+
if (txn) {
|
| 1215 |
+
try { txn.vt.feed(chunk); } catch (e) { console.error('[runner] resize capture', e && e.message); }
|
| 1216 |
+
txn.sawData = true;
|
| 1217 |
+
for (let i = 0; i < chunk.length; i++) {
|
| 1218 |
+
if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); }
|
| 1219 |
+
}
|
| 1220 |
+
armCapturedGrid(host, txn);
|
| 1221 |
+
return;
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
// This is real output outside a resize transaction. It may have advanced
|
| 1225 |
+
// scrollback, so the next transaction takes a fresh canonical boundary.
|
| 1226 |
+
host.repaintArchive = null;
|
| 1227 |
+
try { host.vt.feed(chunk); } catch (e) { console.error('[runner] vt.feed', e && e.message); }
|
| 1228 |
+
host.historyCheckpoint.schedule();
|
| 1229 |
+
|
| 1230 |
+
// State detection rides the feed path: the grid is already current, so there
|
| 1231 |
+
// is nothing to poll and no subprocess to spawn.
|
| 1232 |
+
sampleScreen(host);
|
| 1233 |
+
for (let i = 0; i < chunk.length; i++) {
|
| 1234 |
+
if (chunk.charCodeAt(i) === 7) { host.bells++; host.lastBellAt = Date.now(); }
|
| 1235 |
+
}
|
| 1236 |
+
|
| 1237 |
+
for (const sub of host.subs) sub.onData(chunk);
|
| 1238 |
+
});
|
| 1239 |
+
|
| 1240 |
+
term.onExit(() => {
|
| 1241 |
+
hosts.delete(session.id);
|
| 1242 |
+
if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
|
| 1243 |
+
if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
|
| 1244 |
+
if (host.resizeCapture) {
|
| 1245 |
+
clearCaptureTimers(host.resizeCapture);
|
| 1246 |
+
try { host.resizeCapture.vt.dispose(); } catch {}
|
| 1247 |
+
host.resizeCapture = null;
|
| 1248 |
+
}
|
| 1249 |
+
host.historyCheckpoint.flush();
|
| 1250 |
+
try { host.vt.dispose(); } catch {}
|
| 1251 |
+
for (const sub of host.subs) sub.onExit();
|
| 1252 |
+
host.subs.clear();
|
| 1253 |
+
});
|
| 1254 |
|
| 1255 |
+
hosts.set(session.id, host);
|
| 1256 |
+
if (!persistedHistory && captureResize) hydrateTraceHistory(session, host);
|
| 1257 |
if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
|
| 1258 |
if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
|
| 1259 |
if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
|
| 1260 |
if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
|
| 1261 |
+
return true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
}
|
| 1263 |
|
| 1264 |
/**
|
| 1265 |
+
* Subscribe a viewer to a session, starting it if needed.
|
| 1266 |
+
*
|
| 1267 |
+
* Unlike the tmux version this does NOT spawn anything per viewer, and
|
| 1268 |
+
* `handle.kill()` only unsubscribes — closing a tab must never stop an agent.
|
| 1269 |
+
* `handle.restore()` returns a canonical snapshot: Ghostty's complete plain-text
|
| 1270 |
+
* history followed by a styled visible-screen repaint. It never replays an
|
| 1271 |
+
* arbitrary suffix of old PTY bytes at a new geometry.
|
| 1272 |
*/
|
| 1273 |
+
export function attach(session, cols, rows) {
|
| 1274 |
+
ensureRunning(session, cols, rows);
|
| 1275 |
+
const host = hosts.get(session.id);
|
| 1276 |
+
if (!host) throw new Error('session failed to start');
|
| 1277 |
+
|
| 1278 |
+
const sub = {
|
| 1279 |
+
onData: () => {},
|
| 1280 |
+
onExit: () => {},
|
| 1281 |
+
onGrid: () => {},
|
| 1282 |
+
want: preferredGrid(cols, rows, host),
|
| 1283 |
+
};
|
| 1284 |
+
host.subs.add(sub);
|
| 1285 |
+
if (!host.controller) host.controller = sub;
|
| 1286 |
+
// Existing viewers need to know that the session is now shared. The new
|
| 1287 |
+
// viewer receives the same role/count in its restore frame below.
|
| 1288 |
+
notifyGrid(host, false);
|
| 1289 |
+
if (host.controller === sub && (sub.want.cols !== host.cols || sub.want.rows !== host.rows)) scheduleGrid(host);
|
| 1290 |
+
|
| 1291 |
+
return {
|
| 1292 |
+
onData: (cb) => { sub.onData = (d) => { try { cb(d); } catch {} }; },
|
| 1293 |
+
onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; },
|
| 1294 |
+
onGrid: (cb) => { sub.onGrid = (c, r, controller, viewers, reset) => { try { cb(c, r, controller, viewers, reset); } catch {} }; },
|
| 1295 |
+
// Input and terminal-query responses are accepted from one emulator only.
|
| 1296 |
+
write: (d) => {
|
| 1297 |
+
if (host.controller !== sub) return;
|
| 1298 |
+
try { host.pty.write(d); } catch {}
|
| 1299 |
+
},
|
| 1300 |
+
// Every viewer remembers what it can display, but only the current
|
| 1301 |
+
// controller's request changes the PTY.
|
| 1302 |
+
resize: (c, r) => {
|
| 1303 |
+
if (!Number.isFinite(c) || !Number.isFinite(r)) return;
|
| 1304 |
+
const want = preferredGrid(c, r, host);
|
| 1305 |
+
const had = sub.want;
|
| 1306 |
+
sub.want = want;
|
| 1307 |
+
if (had && had.cols === want.cols && had.rows === want.rows) return;
|
| 1308 |
+
if (host.controller === sub) scheduleGrid(host);
|
| 1309 |
+
},
|
| 1310 |
+
claim: () => {
|
| 1311 |
+
if (!host.subs.has(sub) || host.controller === sub) return;
|
| 1312 |
+
host.controller = sub;
|
| 1313 |
+
notifyGrid(host, false);
|
| 1314 |
+
scheduleGrid(host);
|
| 1315 |
+
},
|
| 1316 |
+
restore: () => {
|
| 1317 |
+
let snap;
|
| 1318 |
+
try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return null; }
|
| 1319 |
+
return {
|
| 1320 |
+
ansi: snapshotToRestoreAnsi(styledSnapshot(host, host.vt, snap)),
|
| 1321 |
+
cols: snap.cols,
|
| 1322 |
+
rows: snap.rows,
|
| 1323 |
+
viewers: host.subs.size,
|
| 1324 |
+
controller: host.controller === sub,
|
| 1325 |
+
};
|
| 1326 |
+
},
|
| 1327 |
+
// Detach this viewer only. The session, its grid and its scrollback stay.
|
| 1328 |
+
kill: () => {
|
| 1329 |
+
const controlled = host.controller === sub;
|
| 1330 |
+
host.subs.delete(sub);
|
| 1331 |
+
if (controlled) host.controller = host.subs.values().next().value || null;
|
| 1332 |
+
notifyGrid(host, false);
|
| 1333 |
+
if (controlled && host.controller) scheduleGrid(host);
|
| 1334 |
+
},
|
| 1335 |
+
};
|
| 1336 |
}
|
| 1337 |
|
| 1338 |
/** Type a line into the session's terminal (works with no browser attached). */
|
| 1339 |
export async function sendInput(id, text) {
|
| 1340 |
+
const host = hosts.get(id);
|
| 1341 |
+
if (!host) throw new Error('session is not running');
|
| 1342 |
// Multi-line prompts go in as a bracketed paste so the CLI's composer treats
|
| 1343 |
// the inner newlines as soft line breaks instead of submitting early.
|
| 1344 |
const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
|
| 1345 |
// The Enter must arrive as its OWN keypress: TUIs (codex) detect rapid input
|
| 1346 |
// bursts as a paste, and a CR inside the burst becomes a newline in the
|
| 1347 |
// composer instead of a submit. A short gap breaks the burst.
|
| 1348 |
+
host.pty.write(payload);
|
| 1349 |
+
await new Promise((r) => setTimeout(r, 300));
|
| 1350 |
+
host.pty.write('\r');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1351 |
}
|
| 1352 |
|
| 1353 |
/**
|
| 1354 |
* The session's rendered screen plus `lines` of scrollback above it — what a
|
| 1355 |
* human would see in the pane. Used by the agent API so one agent can watch
|
| 1356 |
* another's progress instead of spending a turn asking.
|
| 1357 |
+
*
|
| 1358 |
+
* Reads the grid we already hold, so it costs no subprocess. snapshot() returns
|
| 1359 |
+
* history as plain text, which is exactly what the tmux `capture-pane -p` this
|
| 1360 |
+
* replaced produced, so callers see the same shape.
|
| 1361 |
*/
|
| 1362 |
export function capturePane(id, lines = 80) {
|
| 1363 |
+
const host = hosts.get(id);
|
| 1364 |
+
if (!host) return null; // stopped
|
| 1365 |
const n = Math.max(0, Math.min(2000, lines));
|
| 1366 |
+
let snap;
|
| 1367 |
+
try { snap = host.vt.snapshot({ includeScrollback: n > 0 }); } catch { return null; }
|
| 1368 |
+
const history = (snap.scrollbackLines || []).slice(-n).map((l) => l.text);
|
| 1369 |
+
const visible = (snap.visibleLines || []).map((l) => l.text);
|
| 1370 |
+
// Trailing blank rows are padding, not content: a short screen should not
|
| 1371 |
+
// arrive as 50 lines of nothing.
|
| 1372 |
+
return [...history, ...visible].join('\n').replace(/\s+$/, '');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1373 |
}
|
| 1374 |
|
| 1375 |
+
/** Stop a session entirely (kills the process; viewers get an exit close code). */
|
| 1376 |
export function stop(id) {
|
| 1377 |
+
const host = hosts.get(id);
|
| 1378 |
+
if (!host) return;
|
| 1379 |
+
try { host.pty.kill(); } catch {}
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
/**
|
| 1383 |
+
* Kill every session. Without tmux nothing outlives this process, so a clean
|
| 1384 |
+
* shutdown should not leave orphaned PTYs behind holding the workspace.
|
| 1385 |
+
*/
|
| 1386 |
+
export function stopAll() {
|
| 1387 |
+
for (const host of hosts.values()) {
|
| 1388 |
+
try { host.pty.kill(); } catch {}
|
| 1389 |
}
|
| 1390 |
}
|
server/src/snapshot.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Turn a libghostty-vt snapshot into an ANSI repaint.
|
| 3 |
+
*
|
| 4 |
+
* Deliberately renderer-agnostic: the output is just bytes, so the same restore
|
| 5 |
+
* works for xterm.js today and anything else that speaks VT later. That
|
| 6 |
+
* portability is the argument for holding the grid server-side: the browser
|
| 7 |
+
* stays a dumb renderer, and a reattaching client is repainted from our own
|
| 8 |
+
* grid instead of asking the agent's TUI to redraw itself.
|
| 9 |
+
*
|
| 10 |
+
* snapshot() omits blank cells entirely, so gaps are painted as spaces.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* snapshot() resolves palette colours to hex against libghostty's OWN default
|
| 15 |
+
* palette, so blindly emitting truecolor would repaint a restored screen in
|
| 16 |
+
* ghostty's greens and yellows instead of the theme the browser renders live
|
| 17 |
+
* with. Probe the library for its palette once, then emit indexed SGR whenever a
|
| 18 |
+
* colour came from it — the client re-maps those through its own theme, and only
|
| 19 |
+
* genuinely truecolor cells stay pinned.
|
| 20 |
+
*/
|
| 21 |
+
let paletteIndex = null;
|
| 22 |
+
|
| 23 |
+
export function buildPaletteIndex(createTerminal) {
|
| 24 |
+
if (paletteIndex) return paletteIndex;
|
| 25 |
+
const map = new Map();
|
| 26 |
+
try {
|
| 27 |
+
const probe = createTerminal({ cols: 16, rows: 16, scrollbackLimit: 0 });
|
| 28 |
+
let out = '';
|
| 29 |
+
for (let i = 0; i < 256; i++) {
|
| 30 |
+
if (i % 16 === 0 && i > 0) out += '\r\n';
|
| 31 |
+
out += `\x1b[38;5;${i}mX`;
|
| 32 |
+
}
|
| 33 |
+
probe.feed(`${out}\x1b[0m`);
|
| 34 |
+
for (const cell of probe.snapshot({ includeCells: true }).cells || []) {
|
| 35 |
+
const idx = cell.row * 16 + cell.col;
|
| 36 |
+
// A cell with no explicit foreground rendered as the default, which has no
|
| 37 |
+
// stable index to map back to — leave those to truecolor.
|
| 38 |
+
if (idx < 256 && cell.foreground && !map.has(cell.foreground)) map.set(cell.foreground, idx);
|
| 39 |
+
}
|
| 40 |
+
probe.dispose();
|
| 41 |
+
} catch {
|
| 42 |
+
/* fall through to truecolor-only */
|
| 43 |
+
}
|
| 44 |
+
paletteIndex = map;
|
| 45 |
+
return map;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const hexToRgb = (hex) => {
|
| 49 |
+
const h = hex.replace('#', '');
|
| 50 |
+
if (h.length !== 6) return null;
|
| 51 |
+
const n = Number.parseInt(h, 16);
|
| 52 |
+
if (Number.isNaN(n)) return null;
|
| 53 |
+
return `${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}`;
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
function colorSgr(hex, layer) {
|
| 57 |
+
const idx = paletteIndex && paletteIndex.get(hex);
|
| 58 |
+
if (idx !== undefined) return `\x1b[${layer};5;${idx}m`;
|
| 59 |
+
const rgb = hexToRgb(hex);
|
| 60 |
+
return rgb ? `\x1b[${layer};2;${rgb}m` : '';
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function sgrFor(cell) {
|
| 64 |
+
let out = '';
|
| 65 |
+
if (cell.bold) out += '\x1b[1m';
|
| 66 |
+
if (cell.italic) out += '\x1b[3m';
|
| 67 |
+
if (cell.underline) out += '\x1b[4m';
|
| 68 |
+
if (cell.foreground) out += colorSgr(cell.foreground, 38);
|
| 69 |
+
if (cell.background) out += colorSgr(cell.background, 48);
|
| 70 |
+
return out;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/** Cells indexed by row, since snapshot() returns them as a flat sparse list. */
|
| 74 |
+
function cellGrid(snap) {
|
| 75 |
+
const grid = Array.from({ length: snap.rows }, () => new Array(snap.cols).fill(null));
|
| 76 |
+
for (const cell of snap.cells || []) {
|
| 77 |
+
if (cell.row >= 0 && cell.row < snap.rows && cell.col >= 0 && cell.col < snap.cols) {
|
| 78 |
+
grid[cell.row][cell.col] = cell;
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
return grid;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* One row as styled text, with no cursor positioning. Everything after the last
|
| 86 |
+
* drawn cell is dropped: trailing spaces are the bulk of a mostly-empty agent
|
| 87 |
+
* screen, and the caller has either just erased the line or is about to end it.
|
| 88 |
+
*/
|
| 89 |
+
function renderRow(cells, cols) {
|
| 90 |
+
let last = -1;
|
| 91 |
+
for (let col = cols - 1; col >= 0; col--) {
|
| 92 |
+
if (cells[col] && (cells[col].text !== ' ' || cells[col].background)) { last = col; break; }
|
| 93 |
+
}
|
| 94 |
+
if (last < 0) return '';
|
| 95 |
+
|
| 96 |
+
let out = '';
|
| 97 |
+
let style = '';
|
| 98 |
+
let col = 0;
|
| 99 |
+
while (col <= last) {
|
| 100 |
+
const cell = cells[col];
|
| 101 |
+
if (!cell) {
|
| 102 |
+
if (style) { out += '\x1b[0m'; style = ''; }
|
| 103 |
+
out += ' ';
|
| 104 |
+
col += 1;
|
| 105 |
+
continue;
|
| 106 |
+
}
|
| 107 |
+
const next = sgrFor(cell);
|
| 108 |
+
if (next !== style) {
|
| 109 |
+
out += '\x1b[0m' + next;
|
| 110 |
+
style = next;
|
| 111 |
+
}
|
| 112 |
+
out += cell.text;
|
| 113 |
+
col += Math.max(1, cell.width || 1);
|
| 114 |
+
}
|
| 115 |
+
if (style) out += '\x1b[0m';
|
| 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 |
+
|
| 128 |
+
for (let row = 0; row < snap.rows; row++) {
|
| 129 |
+
const line = renderRow(grid[row], snap.cols);
|
| 130 |
+
if (line) out += `\x1b[${row + 1};1H` + line;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
out += `\x1b[0m\x1b[${snap.cursorRow + 1};${snap.cursorCol + 1}H`;
|
| 134 |
+
out += '\x1b[?25h';
|
| 135 |
+
return out;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Snapshot history is plain terminal text. Count its display columns closely
|
| 139 |
+
// enough to know how many visual rows it occupies when restored at a different
|
| 140 |
+
// width; using string.length loses wrapped rows (and then the screen repaint
|
| 141 |
+
// overwrites them). Combining marks are zero-width, common CJK/emoji ranges are
|
| 142 |
+
// two columns, and everything else is one.
|
| 143 |
+
export function textColumns(text) {
|
| 144 |
+
let width = 0;
|
| 145 |
+
for (const char of text) {
|
| 146 |
+
const cp = char.codePointAt(0);
|
| 147 |
+
if (cp === 0x200d || cp === 0xfe0e || cp === 0xfe0f || /\p{Mark}/u.test(char)) continue;
|
| 148 |
+
const wide = cp >= 0x1100 && (
|
| 149 |
+
cp <= 0x115f || cp === 0x2329 || cp === 0x232a
|
| 150 |
+
|| (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f)
|
| 151 |
+
|| (cp >= 0xac00 && cp <= 0xd7a3)
|
| 152 |
+
|| (cp >= 0xf900 && cp <= 0xfaff)
|
| 153 |
+
|| (cp >= 0xfe10 && cp <= 0xfe19)
|
| 154 |
+
|| (cp >= 0xfe30 && cp <= 0xfe6f)
|
| 155 |
+
|| (cp >= 0xff00 && cp <= 0xff60)
|
| 156 |
+
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
| 157 |
+
|| (cp >= 0x1f300 && cp <= 0x1faff)
|
| 158 |
+
|| (cp >= 0x20000 && cp <= 0x3fffd)
|
| 159 |
+
);
|
| 160 |
+
width += wide ? 2 : 1;
|
| 161 |
+
}
|
| 162 |
+
return width;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function htmlEntities(text) {
|
| 166 |
+
return text
|
| 167 |
+
.replace(/&#x([0-9a-f]+);/gi, (_, value) => String.fromCodePoint(Number.parseInt(value, 16)))
|
| 168 |
+
.replace(/&#(\d+);/g, (_, value) => String.fromCodePoint(Number.parseInt(value, 10)))
|
| 169 |
+
.replace(/</g, '<').replace(/>/g, '>')
|
| 170 |
+
.replace(/"/g, '"').replace(/'|'/g, "'")
|
| 171 |
+
.replace(/&/g, '&');
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function cssColor(style, property, layer) {
|
| 175 |
+
const escaped = property.replace('-', '\\-');
|
| 176 |
+
const palette = style.match(new RegExp(`(?:^|;)${escaped}: var\\(--vt-palette-(\\d+)\\)`));
|
| 177 |
+
if (palette) return `${layer};5;${palette[1]}`;
|
| 178 |
+
const rgb = style.match(new RegExp(`(?:^|;)${escaped}: rgb\\((\\d+), (\\d+), (\\d+)\\)`));
|
| 179 |
+
return rgb ? `${layer};2;${rgb[1]};${rgb[2]};${rgb[3]}` : '';
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function htmlStyleSgr(style) {
|
| 183 |
+
const codes = [];
|
| 184 |
+
const foreground = cssColor(style, 'color', 38);
|
| 185 |
+
const background = cssColor(style, 'background-color', 48);
|
| 186 |
+
if (foreground) codes.push(foreground);
|
| 187 |
+
if (background) codes.push(background);
|
| 188 |
+
if (style.includes('font-weight: bold')) codes.push('1');
|
| 189 |
+
if (style.includes('opacity: 0.5')) codes.push('2');
|
| 190 |
+
if (style.includes('font-style: italic')) codes.push('3');
|
| 191 |
+
if (/text-decoration-line:[^;]*underline/.test(style)) codes.push('4');
|
| 192 |
+
if (/text-decoration-line:[^;]*blink/.test(style)) codes.push('5');
|
| 193 |
+
if (style.includes('filter: invert(100%)')) codes.push('7');
|
| 194 |
+
if (/text-decoration-line:[^;]*line-through/.test(style)) codes.push('9');
|
| 195 |
+
if (/text-decoration-line:[^;]*overline/.test(style)) codes.push('53');
|
| 196 |
+
return codes.length ? `\x1b[${codes.join(';')}m` : '';
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
const hasAnsiStyle = (text) => text.replace(/\x1b\[0m/g, '').includes('\x1b[');
|
| 200 |
+
|
| 201 |
+
/** Convert Ghostty's deterministic debug HTML into one ANSI string per visual row. */
|
| 202 |
+
function htmlAnsiRows(html) {
|
| 203 |
+
if (typeof html !== 'string') return [];
|
| 204 |
+
const first = html.indexOf('>');
|
| 205 |
+
const last = html.lastIndexOf('</div>');
|
| 206 |
+
if (first < 0 || last <= first) return [];
|
| 207 |
+
const inner = html.slice(first + 1, last);
|
| 208 |
+
const ansi = inner
|
| 209 |
+
.replace(/<div style="([^"]*)">/g, (_, style) => htmlStyleSgr(style))
|
| 210 |
+
.replace(/<\/div>/g, '\x1b[0m');
|
| 211 |
+
return htmlEntities(ansi).split('\n');
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/** Styled visual rows for the complete primary grid, including scrollback. */
|
| 215 |
+
export function styledTerminalRows(snap, html) {
|
| 216 |
+
const plain = [...(snap.scrollbackLines || []), ...(snap.visibleLines || [])];
|
| 217 |
+
const ansi = htmlAnsiRows(html);
|
| 218 |
+
while (ansi.length < plain.length && !(plain[ansi.length]?.text || '')) ansi.push('');
|
| 219 |
+
if (ansi.length !== plain.length) return plain.map((line) => ({ text: line.text || '' }));
|
| 220 |
+
return plain.map((line, index) => {
|
| 221 |
+
const text = line.text || '';
|
| 222 |
+
const rendered = ansi[index] || '';
|
| 223 |
+
return hasAnsiStyle(rendered) ? { text, ansi: `${rendered}\x1b[0m` } : { text };
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function joinStyledRows(rows, cols) {
|
| 228 |
+
const lines = [];
|
| 229 |
+
let text = '';
|
| 230 |
+
let ansi = '';
|
| 231 |
+
for (const row of rows) {
|
| 232 |
+
text += row.text || '';
|
| 233 |
+
ansi += row.ansi || row.text || '';
|
| 234 |
+
if (textColumns(row.text || '') < cols) {
|
| 235 |
+
lines.push(hasAnsiStyle(ansi) ? { text, ansi: `${ansi}\x1b[0m` } : { text });
|
| 236 |
+
text = '';
|
| 237 |
+
ansi = '';
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
if (text || ansi) lines.push(hasAnsiStyle(ansi) ? { text, ansi: `${ansi}\x1b[0m` } : { text });
|
| 241 |
+
return lines;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
/** Styled logical lines, joining visual rows that were soft-wrapped. */
|
| 245 |
+
export function styledLogicalLines(snap, html) {
|
| 246 |
+
return joinStyledRows(styledTerminalRows(snap, html), snap.cols);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/** Parse Ghostty's formatted state once when both visual and logical keys are needed. */
|
| 250 |
+
export function styledSnapshotLines(snap, html) {
|
| 251 |
+
const rows = styledTerminalRows(snap, html);
|
| 252 |
+
return { rows, logical: joinStyledRows(rows, snap.cols) };
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/**
|
| 256 |
+
* Rebuild a fresh viewer from Ghostty's canonical state.
|
| 257 |
+
*
|
| 258 |
+
* The binding currently exposes styled cells only for the visible screen, while
|
| 259 |
+
* scrollback is plain text. That is still a much stronger restore boundary than
|
| 260 |
+
* replaying a truncated raw PTY byte stream: it includes every retained history
|
| 261 |
+
* row, is already reflowed to the current geometry, and cannot begin halfway
|
| 262 |
+
* through an escape sequence.
|
| 263 |
+
*
|
| 264 |
+
* A line becomes scrollback only after it leaves the visible grid. Print the
|
| 265 |
+
* history on the primary screen, then scroll the remaining visible history rows
|
| 266 |
+
* off before painting the authoritative current screen.
|
| 267 |
+
*/
|
| 268 |
+
export function snapshotToRestoreAnsi(snap) {
|
| 269 |
+
const history = (snap.scrollbackLines || []).map((line) => ({
|
| 270 |
+
text: line.text || '', ansi: typeof line.ansi === 'string' ? line.ansi : '',
|
| 271 |
+
}));
|
| 272 |
+
let out = '\x1b[?1049l\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
|
| 273 |
+
if (history.length) {
|
| 274 |
+
out += history.map((line) => line.ansi || line.text).join('\r\n');
|
| 275 |
+
const visualRows = history.reduce((total, line) =>
|
| 276 |
+
total + Math.max(1, Math.ceil(textColumns(line.text) / Math.max(1, snap.cols))), 0);
|
| 277 |
+
out += `\x1b[${snap.rows};1H` + '\r\n'.repeat(Math.min(visualRows, snap.rows));
|
| 278 |
+
}
|
| 279 |
+
out += snapshotToAnsi(snap);
|
| 280 |
+
return out;
|
| 281 |
+
}
|
tmux.conf
DELETED
|
@@ -1,163 +0,0 @@
|
|
| 1 |
-
# Agent Manager tmux config (passed via `tmux -f`).
|
| 2 |
-
set -g status off # hide the status bar at the bottom
|
| 3 |
-
# Mouse ON: tmux only enables mouse reporting on the client when this is on, so
|
| 4 |
-
# it's required to forward the wheel to apps that request the mouse (Claude Code
|
| 5 |
-
# scrolls its own transcript) and to scroll tmux history in plain shells. The
|
| 6 |
-
# cost is that a plain drag goes to tmux copy-mode; Cmd+C in the frontend asks
|
| 7 |
-
# tmux to copy that selection explicitly.
|
| 8 |
-
set -g mouse on
|
| 9 |
-
set -g history-limit 100000 # deep scrollback
|
| 10 |
-
set -g window-size latest # size the window to the active client
|
| 11 |
-
set -g destroy-unattached off # keep sessions alive when no client attached
|
| 12 |
-
set -g escape-time 10
|
| 13 |
-
set -g mode-style "fg=#0e1217,bg=#5fd0d8"
|
| 14 |
-
# tmux still uses OSC 52 as the transport for explicit browser Cmd+C copies.
|
| 15 |
-
set -g set-clipboard on
|
| 16 |
-
set -as terminal-features ',*:clipboard'
|
| 17 |
-
# Mouse selection should not copy to the system clipboard by itself. Keep the
|
| 18 |
-
# highlight active, but store the selected text for the browser's later Cmd+C.
|
| 19 |
-
bind -T copy-mode MouseDown1Pane send-keys -X cancel
|
| 20 |
-
bind -T copy-mode-vi MouseDown1Pane send-keys -X cancel
|
| 21 |
-
bind -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
|
| 22 |
-
bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
|
| 23 |
-
bind -T copy-mode DoubleClick1Pane select-pane \; send-keys -X select-word \; run-shell -d 0.3 \; send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
|
| 24 |
-
bind -T copy-mode-vi DoubleClick1Pane select-pane \; send-keys -X select-word \; run-shell -d 0.3 \; send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
|
| 25 |
-
bind -T copy-mode TripleClick1Pane select-pane \; send-keys -X select-line \; run-shell -d 0.3 \; send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
|
| 26 |
-
bind -T copy-mode-vi TripleClick1Pane select-pane \; send-keys -X select-line \; run-shell -d 0.3 \; send-keys -X copy-pipe-no-clear "tmux load-buffer -b am-copy-#{session_name} -"
|
| 27 |
-
bind -T copy-mode C-c send-keys -X copy-pipe-no-clear
|
| 28 |
-
bind -T copy-mode-vi C-c send-keys -X copy-pipe-no-clear
|
| 29 |
-
|
| 30 |
-
# Typing beats modes: users scroll into copy mode (mouse wheel) and then
|
| 31 |
-
# wonder why the keyboard is dead. Any printable key a command or prompt
|
| 32 |
-
# would start with cancels copy mode and lands in the pane, so typing just
|
| 33 |
-
# works. Scrolling keys (wheel/PgUp/arrows), Space (begin selection), q and
|
| 34 |
-
# Escape keep their copy-mode roles; Cmd+C copies via the C-c binding above.
|
| 35 |
-
# The frontend shows a "copy mode" overlay while a pane is in this mode.
|
| 36 |
-
bind -T copy-mode a { send-keys -X cancel ; send-keys -- 'a' }
|
| 37 |
-
bind -T copy-mode-vi a { send-keys -X cancel ; send-keys -- 'a' }
|
| 38 |
-
bind -T copy-mode b { send-keys -X cancel ; send-keys -- 'b' }
|
| 39 |
-
bind -T copy-mode-vi b { send-keys -X cancel ; send-keys -- 'b' }
|
| 40 |
-
bind -T copy-mode c { send-keys -X cancel ; send-keys -- 'c' }
|
| 41 |
-
bind -T copy-mode-vi c { send-keys -X cancel ; send-keys -- 'c' }
|
| 42 |
-
bind -T copy-mode d { send-keys -X cancel ; send-keys -- 'd' }
|
| 43 |
-
bind -T copy-mode-vi d { send-keys -X cancel ; send-keys -- 'd' }
|
| 44 |
-
bind -T copy-mode e { send-keys -X cancel ; send-keys -- 'e' }
|
| 45 |
-
bind -T copy-mode-vi e { send-keys -X cancel ; send-keys -- 'e' }
|
| 46 |
-
bind -T copy-mode f { send-keys -X cancel ; send-keys -- 'f' }
|
| 47 |
-
bind -T copy-mode-vi f { send-keys -X cancel ; send-keys -- 'f' }
|
| 48 |
-
bind -T copy-mode g { send-keys -X cancel ; send-keys -- 'g' }
|
| 49 |
-
bind -T copy-mode-vi g { send-keys -X cancel ; send-keys -- 'g' }
|
| 50 |
-
bind -T copy-mode h { send-keys -X cancel ; send-keys -- 'h' }
|
| 51 |
-
bind -T copy-mode-vi h { send-keys -X cancel ; send-keys -- 'h' }
|
| 52 |
-
bind -T copy-mode i { send-keys -X cancel ; send-keys -- 'i' }
|
| 53 |
-
bind -T copy-mode-vi i { send-keys -X cancel ; send-keys -- 'i' }
|
| 54 |
-
bind -T copy-mode j { send-keys -X cancel ; send-keys -- 'j' }
|
| 55 |
-
bind -T copy-mode-vi j { send-keys -X cancel ; send-keys -- 'j' }
|
| 56 |
-
bind -T copy-mode k { send-keys -X cancel ; send-keys -- 'k' }
|
| 57 |
-
bind -T copy-mode-vi k { send-keys -X cancel ; send-keys -- 'k' }
|
| 58 |
-
bind -T copy-mode l { send-keys -X cancel ; send-keys -- 'l' }
|
| 59 |
-
bind -T copy-mode-vi l { send-keys -X cancel ; send-keys -- 'l' }
|
| 60 |
-
bind -T copy-mode m { send-keys -X cancel ; send-keys -- 'm' }
|
| 61 |
-
bind -T copy-mode-vi m { send-keys -X cancel ; send-keys -- 'm' }
|
| 62 |
-
bind -T copy-mode n { send-keys -X cancel ; send-keys -- 'n' }
|
| 63 |
-
bind -T copy-mode-vi n { send-keys -X cancel ; send-keys -- 'n' }
|
| 64 |
-
bind -T copy-mode o { send-keys -X cancel ; send-keys -- 'o' }
|
| 65 |
-
bind -T copy-mode-vi o { send-keys -X cancel ; send-keys -- 'o' }
|
| 66 |
-
bind -T copy-mode p { send-keys -X cancel ; send-keys -- 'p' }
|
| 67 |
-
bind -T copy-mode-vi p { send-keys -X cancel ; send-keys -- 'p' }
|
| 68 |
-
bind -T copy-mode q { send-keys -X cancel ; send-keys -- 'q' }
|
| 69 |
-
bind -T copy-mode-vi q { send-keys -X cancel ; send-keys -- 'q' }
|
| 70 |
-
bind -T copy-mode r { send-keys -X cancel ; send-keys -- 'r' }
|
| 71 |
-
bind -T copy-mode-vi r { send-keys -X cancel ; send-keys -- 'r' }
|
| 72 |
-
bind -T copy-mode s { send-keys -X cancel ; send-keys -- 's' }
|
| 73 |
-
bind -T copy-mode-vi s { send-keys -X cancel ; send-keys -- 's' }
|
| 74 |
-
bind -T copy-mode t { send-keys -X cancel ; send-keys -- 't' }
|
| 75 |
-
bind -T copy-mode-vi t { send-keys -X cancel ; send-keys -- 't' }
|
| 76 |
-
bind -T copy-mode u { send-keys -X cancel ; send-keys -- 'u' }
|
| 77 |
-
bind -T copy-mode-vi u { send-keys -X cancel ; send-keys -- 'u' }
|
| 78 |
-
bind -T copy-mode v { send-keys -X cancel ; send-keys -- 'v' }
|
| 79 |
-
bind -T copy-mode-vi v { send-keys -X cancel ; send-keys -- 'v' }
|
| 80 |
-
bind -T copy-mode w { send-keys -X cancel ; send-keys -- 'w' }
|
| 81 |
-
bind -T copy-mode-vi w { send-keys -X cancel ; send-keys -- 'w' }
|
| 82 |
-
bind -T copy-mode x { send-keys -X cancel ; send-keys -- 'x' }
|
| 83 |
-
bind -T copy-mode-vi x { send-keys -X cancel ; send-keys -- 'x' }
|
| 84 |
-
bind -T copy-mode y { send-keys -X cancel ; send-keys -- 'y' }
|
| 85 |
-
bind -T copy-mode-vi y { send-keys -X cancel ; send-keys -- 'y' }
|
| 86 |
-
bind -T copy-mode z { send-keys -X cancel ; send-keys -- 'z' }
|
| 87 |
-
bind -T copy-mode-vi z { send-keys -X cancel ; send-keys -- 'z' }
|
| 88 |
-
bind -T copy-mode A { send-keys -X cancel ; send-keys -- 'A' }
|
| 89 |
-
bind -T copy-mode-vi A { send-keys -X cancel ; send-keys -- 'A' }
|
| 90 |
-
bind -T copy-mode B { send-keys -X cancel ; send-keys -- 'B' }
|
| 91 |
-
bind -T copy-mode-vi B { send-keys -X cancel ; send-keys -- 'B' }
|
| 92 |
-
bind -T copy-mode C { send-keys -X cancel ; send-keys -- 'C' }
|
| 93 |
-
bind -T copy-mode-vi C { send-keys -X cancel ; send-keys -- 'C' }
|
| 94 |
-
bind -T copy-mode D { send-keys -X cancel ; send-keys -- 'D' }
|
| 95 |
-
bind -T copy-mode-vi D { send-keys -X cancel ; send-keys -- 'D' }
|
| 96 |
-
bind -T copy-mode E { send-keys -X cancel ; send-keys -- 'E' }
|
| 97 |
-
bind -T copy-mode-vi E { send-keys -X cancel ; send-keys -- 'E' }
|
| 98 |
-
bind -T copy-mode F { send-keys -X cancel ; send-keys -- 'F' }
|
| 99 |
-
bind -T copy-mode-vi F { send-keys -X cancel ; send-keys -- 'F' }
|
| 100 |
-
bind -T copy-mode G { send-keys -X cancel ; send-keys -- 'G' }
|
| 101 |
-
bind -T copy-mode-vi G { send-keys -X cancel ; send-keys -- 'G' }
|
| 102 |
-
bind -T copy-mode H { send-keys -X cancel ; send-keys -- 'H' }
|
| 103 |
-
bind -T copy-mode-vi H { send-keys -X cancel ; send-keys -- 'H' }
|
| 104 |
-
bind -T copy-mode I { send-keys -X cancel ; send-keys -- 'I' }
|
| 105 |
-
bind -T copy-mode-vi I { send-keys -X cancel ; send-keys -- 'I' }
|
| 106 |
-
bind -T copy-mode J { send-keys -X cancel ; send-keys -- 'J' }
|
| 107 |
-
bind -T copy-mode-vi J { send-keys -X cancel ; send-keys -- 'J' }
|
| 108 |
-
bind -T copy-mode K { send-keys -X cancel ; send-keys -- 'K' }
|
| 109 |
-
bind -T copy-mode-vi K { send-keys -X cancel ; send-keys -- 'K' }
|
| 110 |
-
bind -T copy-mode L { send-keys -X cancel ; send-keys -- 'L' }
|
| 111 |
-
bind -T copy-mode-vi L { send-keys -X cancel ; send-keys -- 'L' }
|
| 112 |
-
bind -T copy-mode M { send-keys -X cancel ; send-keys -- 'M' }
|
| 113 |
-
bind -T copy-mode-vi M { send-keys -X cancel ; send-keys -- 'M' }
|
| 114 |
-
bind -T copy-mode N { send-keys -X cancel ; send-keys -- 'N' }
|
| 115 |
-
bind -T copy-mode-vi N { send-keys -X cancel ; send-keys -- 'N' }
|
| 116 |
-
bind -T copy-mode O { send-keys -X cancel ; send-keys -- 'O' }
|
| 117 |
-
bind -T copy-mode-vi O { send-keys -X cancel ; send-keys -- 'O' }
|
| 118 |
-
bind -T copy-mode P { send-keys -X cancel ; send-keys -- 'P' }
|
| 119 |
-
bind -T copy-mode-vi P { send-keys -X cancel ; send-keys -- 'P' }
|
| 120 |
-
bind -T copy-mode Q { send-keys -X cancel ; send-keys -- 'Q' }
|
| 121 |
-
bind -T copy-mode-vi Q { send-keys -X cancel ; send-keys -- 'Q' }
|
| 122 |
-
bind -T copy-mode R { send-keys -X cancel ; send-keys -- 'R' }
|
| 123 |
-
bind -T copy-mode-vi R { send-keys -X cancel ; send-keys -- 'R' }
|
| 124 |
-
bind -T copy-mode S { send-keys -X cancel ; send-keys -- 'S' }
|
| 125 |
-
bind -T copy-mode-vi S { send-keys -X cancel ; send-keys -- 'S' }
|
| 126 |
-
bind -T copy-mode T { send-keys -X cancel ; send-keys -- 'T' }
|
| 127 |
-
bind -T copy-mode-vi T { send-keys -X cancel ; send-keys -- 'T' }
|
| 128 |
-
bind -T copy-mode U { send-keys -X cancel ; send-keys -- 'U' }
|
| 129 |
-
bind -T copy-mode-vi U { send-keys -X cancel ; send-keys -- 'U' }
|
| 130 |
-
bind -T copy-mode V { send-keys -X cancel ; send-keys -- 'V' }
|
| 131 |
-
bind -T copy-mode-vi V { send-keys -X cancel ; send-keys -- 'V' }
|
| 132 |
-
bind -T copy-mode W { send-keys -X cancel ; send-keys -- 'W' }
|
| 133 |
-
bind -T copy-mode-vi W { send-keys -X cancel ; send-keys -- 'W' }
|
| 134 |
-
bind -T copy-mode X { send-keys -X cancel ; send-keys -- 'X' }
|
| 135 |
-
bind -T copy-mode-vi X { send-keys -X cancel ; send-keys -- 'X' }
|
| 136 |
-
bind -T copy-mode Y { send-keys -X cancel ; send-keys -- 'Y' }
|
| 137 |
-
bind -T copy-mode-vi Y { send-keys -X cancel ; send-keys -- 'Y' }
|
| 138 |
-
bind -T copy-mode Z { send-keys -X cancel ; send-keys -- 'Z' }
|
| 139 |
-
bind -T copy-mode-vi Z { send-keys -X cancel ; send-keys -- 'Z' }
|
| 140 |
-
bind -T copy-mode 0 { send-keys -X cancel ; send-keys -- '0' }
|
| 141 |
-
bind -T copy-mode-vi 0 { send-keys -X cancel ; send-keys -- '0' }
|
| 142 |
-
bind -T copy-mode 1 { send-keys -X cancel ; send-keys -- '1' }
|
| 143 |
-
bind -T copy-mode-vi 1 { send-keys -X cancel ; send-keys -- '1' }
|
| 144 |
-
bind -T copy-mode 2 { send-keys -X cancel ; send-keys -- '2' }
|
| 145 |
-
bind -T copy-mode-vi 2 { send-keys -X cancel ; send-keys -- '2' }
|
| 146 |
-
bind -T copy-mode 3 { send-keys -X cancel ; send-keys -- '3' }
|
| 147 |
-
bind -T copy-mode-vi 3 { send-keys -X cancel ; send-keys -- '3' }
|
| 148 |
-
bind -T copy-mode 4 { send-keys -X cancel ; send-keys -- '4' }
|
| 149 |
-
bind -T copy-mode-vi 4 { send-keys -X cancel ; send-keys -- '4' }
|
| 150 |
-
bind -T copy-mode 5 { send-keys -X cancel ; send-keys -- '5' }
|
| 151 |
-
bind -T copy-mode-vi 5 { send-keys -X cancel ; send-keys -- '5' }
|
| 152 |
-
bind -T copy-mode 6 { send-keys -X cancel ; send-keys -- '6' }
|
| 153 |
-
bind -T copy-mode-vi 6 { send-keys -X cancel ; send-keys -- '6' }
|
| 154 |
-
bind -T copy-mode 7 { send-keys -X cancel ; send-keys -- '7' }
|
| 155 |
-
bind -T copy-mode-vi 7 { send-keys -X cancel ; send-keys -- '7' }
|
| 156 |
-
bind -T copy-mode 8 { send-keys -X cancel ; send-keys -- '8' }
|
| 157 |
-
bind -T copy-mode-vi 8 { send-keys -X cancel ; send-keys -- '8' }
|
| 158 |
-
bind -T copy-mode 9 { send-keys -X cancel ; send-keys -- '9' }
|
| 159 |
-
bind -T copy-mode-vi 9 { send-keys -X cancel ; send-keys -- '9' }
|
| 160 |
-
bind -T copy-mode Enter { send-keys -X cancel ; send-keys Enter }
|
| 161 |
-
bind -T copy-mode-vi Enter { send-keys -X cancel ; send-keys Enter }
|
| 162 |
-
bind -T copy-mode BSpace { send-keys -X cancel ; send-keys BSpace }
|
| 163 |
-
bind -T copy-mode-vi BSpace { send-keys -X cancel ; send-keys BSpace }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/App.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import Locked from './components/Locked';
|
|
| 14 |
import Welcome from './components/Welcome';
|
| 15 |
import * as api from './api';
|
| 16 |
import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
|
| 17 |
-
import { isPassive } from './types';
|
| 18 |
import { GridGlyph, ListGlyph } from './components/icons';
|
| 19 |
|
| 20 |
// Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
|
|
@@ -40,6 +40,7 @@ function autoGrid(n: number): GridSpec {
|
|
| 40 |
|
| 41 |
type SettingsPage = 'general' | 'usage' | 'skills';
|
| 42 |
const ROOT_PATH = '.';
|
|
|
|
| 43 |
const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
|
| 44 |
|
| 45 |
function initialTheme(): 'light' | 'dark' {
|
|
@@ -103,15 +104,188 @@ export default function App() {
|
|
| 103 |
|
| 104 |
// Track the visual viewport so the mobile layout can sit above the on-screen
|
| 105 |
// keyboard (which shrinks visualViewport but not the layout viewport on iOS).
|
| 106 |
-
//
|
|
|
|
|
|
|
|
|
|
| 107 |
useEffect(() => {
|
| 108 |
const vv = window.visualViewport;
|
| 109 |
-
|
| 110 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
apply();
|
| 112 |
-
vv.addEventListener('resize',
|
| 113 |
-
vv.addEventListener('scroll',
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
}, []);
|
| 116 |
|
| 117 |
// Refresh info while locked so flipping the Space to Private unlocks the UI
|
|
@@ -256,6 +430,38 @@ export default function App() {
|
|
| 256 |
const visibleIds = visibleSessions.map((s) => s.id).join(',');
|
| 257 |
const showZoom = visibleSessions.length > 0;
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
// Pane rearrangement (drag a pane header onto another tile).
|
| 260 |
const [paneDrag, setPaneDrag] = useState(false);
|
| 261 |
const [overTile, setOverTile] = useState<number | null>(null);
|
|
@@ -474,21 +680,60 @@ export default function App() {
|
|
| 474 |
const slotCount = activeGroup ? g.cols * g.rows : sessions.length;
|
| 475 |
const slots = Array.from({ length: slotCount }, (_, i) => sessions[i] ?? null);
|
| 476 |
const canDrag = !!activeGroup && groupSessions.length > 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
// A sidebar session hovering a full grid still needs somewhere to land:
|
| 478 |
// offer a ghost strip appended below the tiles.
|
| 479 |
const ghost = sessionDragActive && !!activeGroup;
|
| 480 |
return (
|
| 481 |
<div
|
| 482 |
-
className={`tiles${paneDrag ? ' pane-dragging' : ''}${sessionDragActive && activeGroup ? ' session-dragging' : ''}`}
|
| 483 |
style={{ gridTemplateColumns: `repeat(${g.cols}, 1fr)`, gridTemplateRows: `repeat(${g.rows}, minmax(0, 1fr))` }}
|
| 484 |
onDragOver={activeGroup ? allowDrop : undefined}
|
| 485 |
onDragLeave={() => setDropMain(false)}
|
| 486 |
onDrop={activeGroup ? onDropMain : undefined}
|
| 487 |
>
|
| 488 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
<div
|
| 490 |
-
key={s ? s.id : `empty-${i}`}
|
| 491 |
className={`tile${s ? '' : ' tile-empty'}${(paneDrag || sessionDragActive) && overTile === i ? ' tile-over' : ''}`}
|
|
|
|
| 492 |
{...(activeGroup ? tileDnd(i, !!s) : {})}
|
| 493 |
>
|
| 494 |
{s && (s.cli === 'files' ? (
|
|
@@ -512,34 +757,19 @@ export default function App() {
|
|
| 512 |
onRename={(name) => renameSession(s.id, name)}
|
| 513 |
onClose={() => closePane(s.id)}
|
| 514 |
/>
|
| 515 |
-
) : s.cli === 'trace' ? (
|
| 516 |
-
<TracePane
|
| 517 |
-
session={s}
|
| 518 |
-
zoom={zoom}
|
| 519 |
-
focused={visibleSessions.length > 1 && s.id === focusedId}
|
| 520 |
-
dragId={canDrag ? `p:${s.id}` : undefined}
|
| 521 |
-
onDragActive={setPaneDrag}
|
| 522 |
-
onFocus={() => setFocusedId(s.id)}
|
| 523 |
-
onClose={() => closePane(s.id)}
|
| 524 |
-
/>
|
| 525 |
) : (
|
| 526 |
-
<
|
| 527 |
session={s}
|
| 528 |
-
cli={cliMap[s.cli]}
|
| 529 |
-
theme={theme}
|
| 530 |
zoom={zoom}
|
| 531 |
focused={visibleSessions.length > 1 && s.id === focusedId}
|
| 532 |
-
active={s.id === focusedId}
|
| 533 |
dragId={canDrag ? `p:${s.id}` : undefined}
|
| 534 |
-
isMobile={isMobile}
|
| 535 |
onDragActive={setPaneDrag}
|
| 536 |
onFocus={() => setFocusedId(s.id)}
|
| 537 |
-
onRename={(name) => renameSession(s.id, name)}
|
| 538 |
onClose={() => closePane(s.id)}
|
| 539 |
/>
|
| 540 |
))}
|
| 541 |
</div>
|
| 542 |
-
))}
|
| 543 |
{ghost && (
|
| 544 |
<div
|
| 545 |
className={`tile tile-ghost${overTile === slots.length ? ' tile-over' : ''}`}
|
|
@@ -554,8 +784,9 @@ export default function App() {
|
|
| 554 |
|
| 555 |
if (info?.locked) return <Locked spaceId={info.spaceId} reason={info.lockReason} bucket={info.lockBucket} />;
|
| 556 |
|
| 557 |
-
|
| 558 |
-
|
|
|
|
| 559 |
<SettingsView
|
| 560 |
page={settingsPage}
|
| 561 |
onPage={setSettingsPage}
|
|
@@ -568,11 +799,8 @@ export default function App() {
|
|
| 568 |
demoMode={!!info?.demoMode}
|
| 569 |
onToggleDemo={toggleDemo}
|
| 570 |
/>
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
return (
|
| 575 |
-
<div className={`app${isMobile ? (mobileStage ? ' m-stage' : ' m-home') : ''}`}>
|
| 576 |
{showWelcome && <Welcome onClose={dismissWelcome} />}
|
| 577 |
{toast && <div className="toast mono" role="alert">{toast}</div>}
|
| 578 |
{shareId && sessById[shareId] && (
|
|
@@ -658,6 +886,14 @@ export default function App() {
|
|
| 658 |
</div>
|
| 659 |
)}
|
| 660 |
<div className="stage">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
{activeRef === 'overview' ? (
|
| 662 |
<Overview
|
| 663 |
clis={clis}
|
|
@@ -674,8 +910,7 @@ export default function App() {
|
|
| 674 |
openSession(sid, g?.id);
|
| 675 |
}}
|
| 676 |
/>
|
| 677 |
-
) : activeGroup ? (
|
| 678 |
-
groupSessions.length === 0 ? (
|
| 679 |
<div
|
| 680 |
className={`empty-group${dropMain ? ' drop-over' : ''}`}
|
| 681 |
onDragOver={allowDrop}
|
|
@@ -688,17 +923,14 @@ export default function App() {
|
|
| 688 |
<div className="dropline">drop an agent here to add it to this group</div>
|
| 689 |
</div>
|
| 690 |
</div>
|
| 691 |
-
|
| 692 |
-
) : activeSingle ? (
|
| 693 |
-
renderTiles([activeSingle], { cols: 1, rows: 1 })
|
| 694 |
-
) : (
|
| 695 |
<div className="empty-group">
|
| 696 |
<EmptyPrompt
|
| 697 |
path="workspaces"
|
| 698 |
hints={['create an agent or a group from the sidebar', 'drag an agent onto another to group them']}
|
| 699 |
/>
|
| 700 |
</div>
|
| 701 |
-
)}
|
| 702 |
</div>
|
| 703 |
{activeRef === 'overview' && (
|
| 704 |
<div className="zoombar ov-bar">
|
|
@@ -735,5 +967,6 @@ export default function App() {
|
|
| 735 |
)}
|
| 736 |
</div>
|
| 737 |
</div>
|
|
|
|
| 738 |
);
|
| 739 |
}
|
|
|
|
| 14 |
import Welcome from './components/Welcome';
|
| 15 |
import * as api from './api';
|
| 16 |
import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
|
| 17 |
+
import { isPassive, isRemote } from './types';
|
| 18 |
import { GridGlyph, ListGlyph } from './components/icons';
|
| 19 |
|
| 20 |
// Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane).
|
|
|
|
| 40 |
|
| 41 |
type SettingsPage = 'general' | 'usage' | 'skills';
|
| 42 |
const ROOT_PATH = '.';
|
| 43 |
+
const WARM_TERMINAL_LIMIT = 12;
|
| 44 |
const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
|
| 45 |
|
| 46 |
function initialTheme(): 'light' | 'dark' {
|
|
|
|
| 104 |
|
| 105 |
// Track the visual viewport so the mobile layout can sit above the on-screen
|
| 106 |
// keyboard (which shrinks visualViewport but not the layout viewport on iOS).
|
| 107 |
+
// The CSS variables pin the app to that viewport's exact rectangle. The Hub
|
| 108 |
+
// page embeds the app in a cross-origin iframe; mobile Safari leaves that
|
| 109 |
+
// child viewport unchanged when its keyboard opens. In that one no-signal
|
| 110 |
+
// case, fall back to a conservative focus-derived visible height.
|
| 111 |
useEffect(() => {
|
| 112 |
const vv = window.visualViewport;
|
| 113 |
+
type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
|
| 114 |
+
const keyboard = (navigator as Navigator & { virtualKeyboard?: VirtualKeyboardLike }).virtualKeyboard;
|
| 115 |
+
type ViewportBaseline = {
|
| 116 |
+
width: number;
|
| 117 |
+
height: number;
|
| 118 |
+
top: number;
|
| 119 |
+
innerHeight: number;
|
| 120 |
+
};
|
| 121 |
+
const root = document.documentElement;
|
| 122 |
+
const keyboardSignalThreshold = 80;
|
| 123 |
+
const embedded = window.self !== window.top;
|
| 124 |
+
let focusedInput: Element | null = null;
|
| 125 |
+
let focusBaseline: ViewportBaseline | null = null;
|
| 126 |
+
let focusFallback = false;
|
| 127 |
+
let focusFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
| 128 |
+
|
| 129 |
+
const acceptsKeyboardInput = (target: Element | null): target is HTMLElement => {
|
| 130 |
+
if (!(target instanceof HTMLElement)) return false;
|
| 131 |
+
if (target.isContentEditable) return true;
|
| 132 |
+
if (target instanceof HTMLTextAreaElement) return !target.readOnly && !target.disabled;
|
| 133 |
+
if (!(target instanceof HTMLInputElement) || target.readOnly || target.disabled) return false;
|
| 134 |
+
return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']
|
| 135 |
+
.includes(target.type);
|
| 136 |
+
};
|
| 137 |
+
const embeddedTouchLayout = () => embedded
|
| 138 |
+
&& window.matchMedia('(max-width: 720px)').matches
|
| 139 |
+
&& (navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches);
|
| 140 |
+
const captureViewport = (): ViewportBaseline => ({
|
| 141 |
+
width: vv?.width ?? document.documentElement.clientWidth,
|
| 142 |
+
height: vv?.height ?? window.innerHeight,
|
| 143 |
+
top: vv?.offsetTop ?? 0,
|
| 144 |
+
innerHeight: window.innerHeight,
|
| 145 |
+
});
|
| 146 |
+
const hasKeyboardGeometry = () => {
|
| 147 |
+
if (!focusBaseline) return false;
|
| 148 |
+
const keyboardHeight = keyboard?.boundingRect?.height ?? 0;
|
| 149 |
+
const visualHeight = vv?.height ?? window.innerHeight;
|
| 150 |
+
const visualShrink = focusBaseline.height - visualHeight;
|
| 151 |
+
const layoutShrink = focusBaseline.innerHeight - window.innerHeight;
|
| 152 |
+
return keyboardHeight >= keyboardSignalThreshold
|
| 153 |
+
|| visualShrink >= keyboardSignalThreshold
|
| 154 |
+
|| layoutShrink >= keyboardSignalThreshold;
|
| 155 |
+
};
|
| 156 |
+
const apply = () => {
|
| 157 |
+
const keyboardRect = keyboard?.boundingRect;
|
| 158 |
+
const left = vv?.offsetLeft ?? 0;
|
| 159 |
+
const top = vv?.offsetTop ?? 0;
|
| 160 |
+
const width = vv?.width ?? document.documentElement.clientWidth;
|
| 161 |
+
let height = vv?.height ?? window.innerHeight;
|
| 162 |
+
// Chromium can expose keyboard geometry even when an embedded document's
|
| 163 |
+
// visual viewport is unchanged. Never grow the visual viewport from it;
|
| 164 |
+
// only clip an actually overlapping keyboard.
|
| 165 |
+
if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
|
| 166 |
+
height = Math.min(height, keyboardRect.top - top);
|
| 167 |
+
}
|
| 168 |
+
if (hasKeyboardGeometry()) focusFallback = false;
|
| 169 |
+
if (focusFallback && focusBaseline && acceptsKeyboardInput(document.activeElement)) {
|
| 170 |
+
// The parent page owns the real visual viewport, but cross-origin frame
|
| 171 |
+
// isolation prevents us from reading it. A phone keyboard typically
|
| 172 |
+
// consumes roughly the lower half; 54% visible keeps the xterm prompt
|
| 173 |
+
// above it without disturbing direct-app browsers with real geometry.
|
| 174 |
+
const visibleRatio = focusBaseline.width > focusBaseline.height ? 0.48 : 0.54;
|
| 175 |
+
height = Math.min(height, Math.round(focusBaseline.height * visibleRatio));
|
| 176 |
+
root.dataset.keyboardLayout = 'focus-fallback';
|
| 177 |
+
} else if (hasKeyboardGeometry()) {
|
| 178 |
+
root.dataset.keyboardLayout = 'browser-geometry';
|
| 179 |
+
} else {
|
| 180 |
+
delete root.dataset.keyboardLayout;
|
| 181 |
+
}
|
| 182 |
+
root.style.setProperty('--vvw', `${Math.round(width)}px`);
|
| 183 |
+
root.style.setProperty('--vvh', `${Math.round(height)}px`);
|
| 184 |
+
root.style.setProperty('--vv-top', `${Math.round(top)}px`);
|
| 185 |
+
root.style.setProperty('--vv-left', `${Math.round(left)}px`);
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
// WebKit may dispatch the keyboard viewport event before offsetTop has its
|
| 189 |
+
// final value, then correct the property without another event. Re-read at
|
| 190 |
+
// the end of the event turn and while the focus animation settles.
|
| 191 |
+
const settleTimers = new Set<ReturnType<typeof setTimeout>>();
|
| 192 |
+
const focusTimers = new Set<ReturnType<typeof setTimeout>>();
|
| 193 |
+
const onViewportChange = () => {
|
| 194 |
+
apply();
|
| 195 |
+
for (const timer of settleTimers) clearTimeout(timer);
|
| 196 |
+
settleTimers.clear();
|
| 197 |
+
// One delayed read is still racy: WebKit can update offsetTop immediately
|
| 198 |
+
// after it fires. Sample the short animation tail without depending on a
|
| 199 |
+
// second browser event.
|
| 200 |
+
for (const delay of [50, 150, 300, 500]) {
|
| 201 |
+
const timer = setTimeout(() => { settleTimers.delete(timer); apply(); }, delay);
|
| 202 |
+
settleTimers.add(timer);
|
| 203 |
+
}
|
| 204 |
+
};
|
| 205 |
+
const stabilizeFocus = () => {
|
| 206 |
+
for (const timer of focusTimers) clearTimeout(timer);
|
| 207 |
+
focusTimers.clear();
|
| 208 |
+
for (const delay of [0, 50, 150, 300, 500, 800]) {
|
| 209 |
+
const timer = setTimeout(() => { focusTimers.delete(timer); apply(); }, delay);
|
| 210 |
+
focusTimers.add(timer);
|
| 211 |
+
}
|
| 212 |
+
};
|
| 213 |
+
const scheduleEmbeddedFallback = () => {
|
| 214 |
+
if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
|
| 215 |
+
focusFallbackTimer = null;
|
| 216 |
+
if (!embeddedTouchLayout() || !acceptsKeyboardInput(document.activeElement)) return;
|
| 217 |
+
focusFallbackTimer = setTimeout(() => {
|
| 218 |
+
focusFallbackTimer = null;
|
| 219 |
+
if (document.activeElement === focusedInput && !hasKeyboardGeometry()) {
|
| 220 |
+
focusFallback = true;
|
| 221 |
+
apply();
|
| 222 |
+
}
|
| 223 |
+
}, 500);
|
| 224 |
+
};
|
| 225 |
+
const onFocusIn = (event: FocusEvent) => {
|
| 226 |
+
const target = event.target instanceof Element ? event.target : null;
|
| 227 |
+
if (acceptsKeyboardInput(target)) {
|
| 228 |
+
focusedInput = target;
|
| 229 |
+
focusBaseline = captureViewport();
|
| 230 |
+
focusFallback = false;
|
| 231 |
+
stabilizeFocus();
|
| 232 |
+
scheduleEmbeddedFallback();
|
| 233 |
+
return;
|
| 234 |
+
}
|
| 235 |
+
stabilizeFocus();
|
| 236 |
+
};
|
| 237 |
+
const onFocusOut = () => {
|
| 238 |
+
if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
|
| 239 |
+
focusFallbackTimer = null;
|
| 240 |
+
const timer = setTimeout(() => {
|
| 241 |
+
focusTimers.delete(timer);
|
| 242 |
+
if (!acceptsKeyboardInput(document.activeElement)) {
|
| 243 |
+
focusedInput = null;
|
| 244 |
+
focusBaseline = null;
|
| 245 |
+
focusFallback = false;
|
| 246 |
+
apply();
|
| 247 |
+
}
|
| 248 |
+
}, 0);
|
| 249 |
+
focusTimers.add(timer);
|
| 250 |
+
};
|
| 251 |
+
const onOrientationChange = () => {
|
| 252 |
+
if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
|
| 253 |
+
focusFallbackTimer = null;
|
| 254 |
+
focusFallback = false;
|
| 255 |
+
if (acceptsKeyboardInput(document.activeElement)) {
|
| 256 |
+
focusedInput = document.activeElement;
|
| 257 |
+
focusBaseline = captureViewport();
|
| 258 |
+
}
|
| 259 |
+
stabilizeFocus();
|
| 260 |
+
scheduleEmbeddedFallback();
|
| 261 |
+
};
|
| 262 |
apply();
|
| 263 |
+
vv?.addEventListener('resize', onViewportChange);
|
| 264 |
+
vv?.addEventListener('scroll', onViewportChange);
|
| 265 |
+
vv?.addEventListener('scrollend', onViewportChange);
|
| 266 |
+
keyboard?.addEventListener('geometrychange', onViewportChange);
|
| 267 |
+
window.addEventListener('resize', onViewportChange);
|
| 268 |
+
window.addEventListener('orientationchange', onOrientationChange);
|
| 269 |
+
document.addEventListener('focusin', onFocusIn);
|
| 270 |
+
document.addEventListener('focusout', onFocusOut);
|
| 271 |
+
return () => {
|
| 272 |
+
for (const timer of settleTimers) clearTimeout(timer);
|
| 273 |
+
for (const timer of focusTimers) clearTimeout(timer);
|
| 274 |
+
if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
|
| 275 |
+
vv?.removeEventListener('resize', onViewportChange);
|
| 276 |
+
vv?.removeEventListener('scroll', onViewportChange);
|
| 277 |
+
vv?.removeEventListener('scrollend', onViewportChange);
|
| 278 |
+
keyboard?.removeEventListener('geometrychange', onViewportChange);
|
| 279 |
+
window.removeEventListener('resize', onViewportChange);
|
| 280 |
+
window.removeEventListener('orientationchange', onOrientationChange);
|
| 281 |
+
document.removeEventListener('focusin', onFocusIn);
|
| 282 |
+
document.removeEventListener('focusout', onFocusOut);
|
| 283 |
+
root.style.removeProperty('--vvw');
|
| 284 |
+
root.style.removeProperty('--vvh');
|
| 285 |
+
root.style.removeProperty('--vv-top');
|
| 286 |
+
root.style.removeProperty('--vv-left');
|
| 287 |
+
delete root.dataset.keyboardLayout;
|
| 288 |
+
};
|
| 289 |
}, []);
|
| 290 |
|
| 291 |
// Refresh info while locked so flipping the Space to Private unlocks the UI
|
|
|
|
| 430 |
const visibleIds = visibleSessions.map((s) => s.id).join(',');
|
| 431 |
const showZoom = visibleSessions.length > 0;
|
| 432 |
|
| 433 |
+
// Keep a small working set of terminal panes alive across navigation. The
|
| 434 |
+
// backend session already survives a viewer disconnect; retaining xterm and
|
| 435 |
+
// its socket as well makes switching back genuinely instant and preserves the
|
| 436 |
+
// exact local scroll/selection state. This is deliberately bounded: mounting
|
| 437 |
+
// every session would start stopped agents and turn a large sidebar into many
|
| 438 |
+
// live WebSockets and 20k-line browser buffers.
|
| 439 |
+
const visibleTerminalIds = (settingsOpen || (isMobile && !mobileStage) ? [] : visibleSessions)
|
| 440 |
+
.filter((s) => !isPassive(s.cli) && !isRemote(s.cli))
|
| 441 |
+
.map((s) => s.id);
|
| 442 |
+
const visibleTerminalKey = visibleTerminalIds.join(',');
|
| 443 |
+
const sessionIdsKey = tree.sessions.map((s) => s.id).join(',');
|
| 444 |
+
const [warmTerminalIds, setWarmTerminalIds] = useState<string[]>([]);
|
| 445 |
+
useEffect(() => {
|
| 446 |
+
const valid = new Set(tree.sessions
|
| 447 |
+
.filter((s) => !isPassive(s.cli) && !isRemote(s.cli))
|
| 448 |
+
.map((s) => s.id));
|
| 449 |
+
setWarmTerminalIds((previous) => {
|
| 450 |
+
const next = [...visibleTerminalIds, ...previous]
|
| 451 |
+
.filter((id, i, all) => valid.has(id) && all.indexOf(id) === i)
|
| 452 |
+
.slice(0, Math.max(WARM_TERMINAL_LIMIT, visibleTerminalIds.length));
|
| 453 |
+
return next.length === previous.length && next.every((id, i) => id === previous[i])
|
| 454 |
+
? previous : next;
|
| 455 |
+
});
|
| 456 |
+
// Stable string keys avoid running this state update on every tree poll.
|
| 457 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 458 |
+
}, [visibleTerminalKey, sessionIdsKey]);
|
| 459 |
+
// Include newly selected panes on the navigation render itself; the effect
|
| 460 |
+
// above retains them for later renders and applies the LRU bound.
|
| 461 |
+
const retainedTerminalIds = [...visibleTerminalIds, ...warmTerminalIds]
|
| 462 |
+
.filter((id, i, all) => !!sessById[id] && all.indexOf(id) === i)
|
| 463 |
+
.slice(0, Math.max(WARM_TERMINAL_LIMIT, visibleTerminalIds.length));
|
| 464 |
+
|
| 465 |
// Pane rearrangement (drag a pane header onto another tile).
|
| 466 |
const [paneDrag, setPaneDrag] = useState(false);
|
| 467 |
const [overTile, setOverTile] = useState<number | null>(null);
|
|
|
|
| 680 |
const slotCount = activeGroup ? g.cols * g.rows : sessions.length;
|
| 681 |
const slots = Array.from({ length: slotCount }, (_, i) => sessions[i] ?? null);
|
| 682 |
const canDrag = !!activeGroup && groupSessions.length > 1;
|
| 683 |
+
const slotStyle = (i: number) => ({
|
| 684 |
+
gridColumn: (i % g.cols) + 1,
|
| 685 |
+
gridRow: Math.floor(i / g.cols) + 1,
|
| 686 |
+
});
|
| 687 |
+
const slotByTerminal = new Map(slots
|
| 688 |
+
.map((s, i) => [s, i] as const)
|
| 689 |
+
.filter(([s]) => s && !isPassive(s.cli) && !isRemote(s.cli))
|
| 690 |
+
.map(([s, i]) => [s!.id, i]));
|
| 691 |
+
const deckVisible = !settingsOpen && sessions.length > 0 && (!isMobile || mobileStage);
|
| 692 |
// A sidebar session hovering a full grid still needs somewhere to land:
|
| 693 |
// offer a ghost strip appended below the tiles.
|
| 694 |
const ghost = sessionDragActive && !!activeGroup;
|
| 695 |
return (
|
| 696 |
<div
|
| 697 |
+
className={`tiles pane-deck${sessions.length ? '' : ' deck-hidden'}${paneDrag ? ' pane-dragging' : ''}${sessionDragActive && activeGroup ? ' session-dragging' : ''}`}
|
| 698 |
style={{ gridTemplateColumns: `repeat(${g.cols}, 1fr)`, gridTemplateRows: `repeat(${g.rows}, minmax(0, 1fr))` }}
|
| 699 |
onDragOver={activeGroup ? allowDrop : undefined}
|
| 700 |
onDragLeave={() => setDropMain(false)}
|
| 701 |
onDrop={activeGroup ? onDropMain : undefined}
|
| 702 |
>
|
| 703 |
+
{retainedTerminalIds.map((id) => {
|
| 704 |
+
const s = sessById[id];
|
| 705 |
+
if (!s) return null;
|
| 706 |
+
const slot = slotByTerminal.get(id);
|
| 707 |
+
const shown = slot !== undefined;
|
| 708 |
+
return (
|
| 709 |
+
<div
|
| 710 |
+
key={`terminal-${id}`}
|
| 711 |
+
className={`tile tile-terminal${shown ? '' : ' tile-cached'}`}
|
| 712 |
+
style={shown ? slotStyle(slot) : undefined}
|
| 713 |
+
{...(shown && activeGroup ? tileDnd(slot, true) : {})}
|
| 714 |
+
>
|
| 715 |
+
<TerminalPane
|
| 716 |
+
session={s}
|
| 717 |
+
cli={cliMap[s.cli]}
|
| 718 |
+
theme={theme}
|
| 719 |
+
zoom={zoom}
|
| 720 |
+
focused={shown && sessions.length > 1 && s.id === focusedId}
|
| 721 |
+
active={shown && deckVisible && s.id === focusedId}
|
| 722 |
+
dragId={shown && canDrag ? `p:${s.id}` : undefined}
|
| 723 |
+
isMobile={isMobile}
|
| 724 |
+
onDragActive={setPaneDrag}
|
| 725 |
+
onFocus={() => setFocusedId(s.id)}
|
| 726 |
+
onRename={(name) => renameSession(s.id, name)}
|
| 727 |
+
onClose={() => closePane(s.id)}
|
| 728 |
+
/>
|
| 729 |
+
</div>
|
| 730 |
+
);
|
| 731 |
+
})}
|
| 732 |
+
{slots.map((s, i) => (s && !isPassive(s.cli) && !isRemote(s.cli) ? null : (
|
| 733 |
<div
|
| 734 |
+
key={s ? `passive-${s.id}` : `empty-${i}`}
|
| 735 |
className={`tile${s ? '' : ' tile-empty'}${(paneDrag || sessionDragActive) && overTile === i ? ' tile-over' : ''}`}
|
| 736 |
+
style={slotStyle(i)}
|
| 737 |
{...(activeGroup ? tileDnd(i, !!s) : {})}
|
| 738 |
>
|
| 739 |
{s && (s.cli === 'files' ? (
|
|
|
|
| 757 |
onRename={(name) => renameSession(s.id, name)}
|
| 758 |
onClose={() => closePane(s.id)}
|
| 759 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
) : (
|
| 761 |
+
<TracePane
|
| 762 |
session={s}
|
|
|
|
|
|
|
| 763 |
zoom={zoom}
|
| 764 |
focused={visibleSessions.length > 1 && s.id === focusedId}
|
|
|
|
| 765 |
dragId={canDrag ? `p:${s.id}` : undefined}
|
|
|
|
| 766 |
onDragActive={setPaneDrag}
|
| 767 |
onFocus={() => setFocusedId(s.id)}
|
|
|
|
| 768 |
onClose={() => closePane(s.id)}
|
| 769 |
/>
|
| 770 |
))}
|
| 771 |
</div>
|
| 772 |
+
)))}
|
| 773 |
{ghost && (
|
| 774 |
<div
|
| 775 |
className={`tile tile-ghost${overTile === slots.length ? ' tile-over' : ''}`}
|
|
|
|
| 784 |
|
| 785 |
if (info?.locked) return <Locked spaceId={info.spaceId} reason={info.lockReason} bucket={info.lockBucket} />;
|
| 786 |
|
| 787 |
+
return (
|
| 788 |
+
<>
|
| 789 |
+
{settingsOpen && (
|
| 790 |
<SettingsView
|
| 791 |
page={settingsPage}
|
| 792 |
onPage={setSettingsPage}
|
|
|
|
| 799 |
demoMode={!!info?.demoMode}
|
| 800 |
onToggleDemo={toggleDemo}
|
| 801 |
/>
|
| 802 |
+
)}
|
| 803 |
+
<div className={`app${settingsOpen ? ' app-suspended' : ''}${isMobile ? (mobileStage ? ' m-stage' : ' m-home') : ''}`}>
|
|
|
|
|
|
|
|
|
|
| 804 |
{showWelcome && <Welcome onClose={dismissWelcome} />}
|
| 805 |
{toast && <div className="toast mono" role="alert">{toast}</div>}
|
| 806 |
{shareId && sessById[shareId] && (
|
|
|
|
| 886 |
</div>
|
| 887 |
)}
|
| 888 |
<div className="stage">
|
| 889 |
+
{renderTiles(
|
| 890 |
+
isMobile && !mobileStage
|
| 891 |
+
? []
|
| 892 |
+
: activeGroup && groupSessions.length > 0
|
| 893 |
+
? pageSessions
|
| 894 |
+
: activeSingle ? [activeSingle] : [],
|
| 895 |
+
activeGroup && groupSessions.length > 0 ? grid : { cols: 1, rows: 1 },
|
| 896 |
+
)}
|
| 897 |
{activeRef === 'overview' ? (
|
| 898 |
<Overview
|
| 899 |
clis={clis}
|
|
|
|
| 910 |
openSession(sid, g?.id);
|
| 911 |
}}
|
| 912 |
/>
|
| 913 |
+
) : activeGroup && groupSessions.length === 0 ? (
|
|
|
|
| 914 |
<div
|
| 915 |
className={`empty-group${dropMain ? ' drop-over' : ''}`}
|
| 916 |
onDragOver={allowDrop}
|
|
|
|
| 923 |
<div className="dropline">drop an agent here to add it to this group</div>
|
| 924 |
</div>
|
| 925 |
</div>
|
| 926 |
+
) : !activeGroup && !activeSingle ? (
|
|
|
|
|
|
|
|
|
|
| 927 |
<div className="empty-group">
|
| 928 |
<EmptyPrompt
|
| 929 |
path="workspaces"
|
| 930 |
hints={['create an agent or a group from the sidebar', 'drag an agent onto another to group them']}
|
| 931 |
/>
|
| 932 |
</div>
|
| 933 |
+
) : null}
|
| 934 |
</div>
|
| 935 |
{activeRef === 'overview' && (
|
| 936 |
<div className="zoombar ov-bar">
|
|
|
|
| 967 |
)}
|
| 968 |
</div>
|
| 969 |
</div>
|
| 970 |
+
</>
|
| 971 |
);
|
| 972 |
}
|
web/src/components/SettingsView.tsx
CHANGED
|
@@ -13,7 +13,7 @@ const PAGES: { id: Page; label: string }[] = [
|
|
| 13 |
{ id: 'skills', label: 'Skills' },
|
| 14 |
];
|
| 15 |
|
| 16 |
-
interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null;
|
| 17 |
|
| 18 |
function urlBase64ToUint8Array(base64: string) {
|
| 19 |
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
|
@@ -446,7 +446,7 @@ export default function SettingsView({
|
|
| 446 |
<div><span>Space</span><b>{info?.spaceId || '—'}</b></div>
|
| 447 |
<div><span>Durable storage</span><b className="mono">{info?.dataDir || '—'}</b></div>
|
| 448 |
<div><span>Home</span><b className="mono">{info?.home || '—'}</b></div>
|
| 449 |
-
<div><span>
|
| 450 |
</div>
|
| 451 |
|
| 452 |
</div>
|
|
|
|
| 13 |
{ id: 'skills', label: 'Skills' },
|
| 14 |
];
|
| 15 |
|
| 16 |
+
interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; engine?: string; ghostty?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
|
| 17 |
|
| 18 |
function urlBase64ToUint8Array(base64: string) {
|
| 19 |
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
|
|
|
| 446 |
<div><span>Space</span><b>{info?.spaceId || '—'}</b></div>
|
| 447 |
<div><span>Durable storage</span><b className="mono">{info?.dataDir || '—'}</b></div>
|
| 448 |
<div><span>Home</span><b className="mono">{info?.home || '—'}</b></div>
|
| 449 |
+
<div><span>terminal</span><b>{info?.ghostty ? 'libghostty' : 'unavailable'}</b></div>
|
| 450 |
</div>
|
| 451 |
|
| 452 |
</div>
|
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?.();
|
|
@@ -92,20 +84,56 @@ function selectionText(term: Terminal): string {
|
|
| 92 |
// and flush on the user's next click, so the copy lands when they return.
|
| 93 |
// It must run synchronously inside the triggering event — deferring to a
|
| 94 |
// setTimeout loses the activation that execCommand needs.
|
| 95 |
-
|
| 96 |
-
//
|
| 97 |
-
// with this leading NUL sentinel, which real pty output never begins with.
|
| 98 |
const MODE_CTRL = '\x00\x00AM:';
|
| 99 |
-
let osc52CopyAllowedUntil = 0;
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
|
| 111 |
function legacyCopy(text: string): boolean {
|
|
@@ -164,20 +192,24 @@ export default function TerminalPane({
|
|
| 164 |
onClose: () => void;
|
| 165 |
}) {
|
| 166 |
const hostRef = useRef<HTMLDivElement>(null);
|
|
|
|
| 167 |
const termRef = useRef<Terminal | null>(null);
|
| 168 |
const resyncRef = useRef<() => void>(() => {});
|
|
|
|
| 169 |
const reconnectRef = useRef<() => void>(() => {});
|
| 170 |
-
|
| 171 |
-
const
|
|
|
|
| 172 |
// Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
|
| 173 |
const sendKeyRef = useRef<(d: string) => void>(() => {});
|
| 174 |
const [conn, setConn] = useState<ConnState>('connecting');
|
| 175 |
-
// "starting…" cover while the CLI boots into an empty pane (
|
| 176 |
// Space can take seconds). Hidden only when the screen actually SHOWS
|
| 177 |
-
// something — byte counts lie
|
| 178 |
-
//
|
| 179 |
const [booting, setBooting] = useState(true);
|
| 180 |
-
const [
|
|
|
|
| 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.
|
|
@@ -219,34 +251,33 @@ export default function TerminalPane({
|
|
| 219 |
useEffect(() => {
|
| 220 |
const term = new Terminal({
|
| 221 |
fontFamily: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', monospace",
|
| 222 |
-
|
|
|
|
|
|
|
| 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
|
|
@@ -254,107 +285,122 @@ export default function TerminalPane({
|
|
| 254 |
term.loadAddon(new (ClipboardAddon as unknown as new (b: Base64, p: typeof clipboardProvider) => ClipboardAddon)(new Base64(), clipboardProvider));
|
| 255 |
term.open(hostRef.current!);
|
| 256 |
termRef.current = term;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
// Track the committed local xterm selection so Cmd/Ctrl+C can copy it
|
| 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 |
-
if (
|
| 342 |
-
if (
|
| 343 |
-
allowNextOsc52Copy();
|
| 344 |
-
send({ t: 'copy' });
|
| 345 |
-
}
|
| 346 |
return false;
|
| 347 |
}
|
| 348 |
-
|
| 349 |
-
if (e.key === 'Escape') tmuxSelectionPending = false;
|
| 350 |
return true;
|
| 351 |
});
|
|
|
|
|
|
|
|
|
|
| 352 |
try { fit.fit(); } catch { /* layout not ready yet */ }
|
| 353 |
// Re-measure once the webfont is ready (glyph width changes vs the fallback).
|
| 354 |
-
document.fonts?.ready.then(() =>
|
| 355 |
|
| 356 |
let closedByUs = false;
|
| 357 |
-
let handedOff = false;
|
| 358 |
let retry: ReturnType<typeof setTimeout> | null = null;
|
| 359 |
// Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
|
| 360 |
// every second by every open pane. Reset once a connection succeeds.
|
|
@@ -385,11 +431,17 @@ export default function TerminalPane({
|
|
| 385 |
setBooting(false);
|
| 386 |
};
|
| 387 |
const connect = () => {
|
|
|
|
|
|
|
| 388 |
setConn('connecting');
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
if (bootTimer) clearTimeout(bootTimer);
|
| 392 |
-
bootTimer = setTimeout(endBoot, 20_000);
|
| 393 |
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
| 394 |
const url = `${proto}://${location.host}/ws?session=${encodeURIComponent(session.id)}&cols=${term.cols}&rows=${term.rows}`;
|
| 395 |
ws = new WebSocket(url);
|
|
@@ -397,18 +449,56 @@ export default function TerminalPane({
|
|
| 397 |
ws.onopen = () => {
|
| 398 |
setConn('connected');
|
| 399 |
retryDelay = 1200;
|
| 400 |
-
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
| 402 |
};
|
| 403 |
ws.onmessage = (e) => {
|
| 404 |
const d = typeof e.data === 'string' ? e.data : new Uint8Array(e.data as ArrayBuffer);
|
| 405 |
-
// Control frame
|
| 406 |
-
//
|
| 407 |
if (typeof d === 'string' && d.startsWith(MODE_CTRL)) {
|
| 408 |
-
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
return;
|
| 410 |
}
|
| 411 |
-
term.write(d);
|
| 412 |
// Probe shortly after each burst (throttled; write() is async).
|
| 413 |
if (bootLive && !bootCheck) {
|
| 414 |
bootCheck = setTimeout(() => {
|
|
@@ -420,12 +510,9 @@ export default function TerminalPane({
|
|
| 420 |
ws.onclose = (e) => {
|
| 421 |
// A real process exit: stop here and let the user relaunch. Anything
|
| 422 |
// else is a transient drop (sleep/wake, network) → auto-reconnect and
|
| 423 |
-
// reattach to the still-running
|
| 424 |
endBoot();
|
| 425 |
if (e.code === EXIT_CODE) { setConn('exited'); return; }
|
| 426 |
-
// Another device took over: stay detached (no retry timer) until the
|
| 427 |
-
// user deliberately comes back to this pane — see takeBack().
|
| 428 |
-
if (e.code === HANDOFF_CODE) { handedOff = true; setConn('handedoff'); return; }
|
| 429 |
setConn('closed');
|
| 430 |
if (!closedByUs) {
|
| 431 |
retry = setTimeout(connect, retryDelay);
|
|
@@ -435,77 +522,149 @@ export default function TerminalPane({
|
|
| 435 |
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
|
| 436 |
};
|
| 437 |
// Manual restart: clear the dead run's screen so the content probe watches
|
| 438 |
-
// the
|
| 439 |
reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
|
| 440 |
|
| 441 |
-
//
|
| 442 |
-
//
|
| 443 |
-
//
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
| 452 |
};
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
// Re-fit and tell the server our size. The attached client owns the tmux
|
| 456 |
-
// window, so this keeps it fitting this device exactly instead of leaving
|
| 457 |
-
// tmux's "dots" filler. Never reconnects: a layout change (another pane
|
| 458 |
-
// opening, the sidebar toggling) is not a reason to pull a session off the
|
| 459 |
-
// device the user is actually holding.
|
| 460 |
const resync = () => {
|
| 461 |
-
|
| 462 |
-
|
|
|
|
| 463 |
};
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
const onReturn = () => { if (!takeBack()) resync(); };
|
| 467 |
const onVisible = () => { if (!document.hidden) onReturn(); };
|
| 468 |
-
resyncRef.current = resync; // so the zoom control can refit
|
| 469 |
-
|
| 470 |
// Typing means the user sees enough to interact — drop the boot cover.
|
| 471 |
// Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
|
| 472 |
// replies to the TUI's terminal queries (DA/CPR), which arrive instantly
|
| 473 |
// on attach and must not count as "the user typed".
|
| 474 |
-
const keySub = term.onKey(() => endBoot());
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
const ro = new ResizeObserver(resync);
|
| 477 |
ro.observe(hostRef.current!);
|
| 478 |
window.addEventListener('focus', onReturn);
|
| 479 |
document.addEventListener('visibilitychange', onVisible);
|
| 480 |
|
| 481 |
-
//
|
| 482 |
-
//
|
| 483 |
-
//
|
| 484 |
-
//
|
| 485 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
let touchY: number | null = null;
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
const onTouchMove = (e: TouchEvent) => {
|
| 489 |
-
if (touchY == null) return;
|
| 490 |
const y = e.touches[0].clientY;
|
| 491 |
-
const
|
| 492 |
-
|
| 493 |
-
if (
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
| 502 |
}
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
};
|
| 505 |
-
const onTouchEnd = () => {
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
|
| 510 |
connect();
|
| 511 |
|
|
@@ -514,24 +673,28 @@ export default function TerminalPane({
|
|
| 514 |
if (retry) clearTimeout(retry);
|
| 515 |
if (bootTimer) clearTimeout(bootTimer);
|
| 516 |
if (bootCheck) clearTimeout(bootCheck);
|
|
|
|
|
|
|
| 517 |
ro.disconnect();
|
| 518 |
host.removeEventListener('pointerdown', onPointerDown, true);
|
| 519 |
-
host.removeEventListener('
|
| 520 |
-
host.removeEventListener('pointerup', onPointerUp, true);
|
| 521 |
-
host.removeEventListener('click', onClick, true);
|
| 522 |
-
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
|
| 523 |
document.removeEventListener('copy', onCopy, true);
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
| 527 |
window.removeEventListener('focus', onReturn);
|
| 528 |
document.removeEventListener('visibilitychange', onVisible);
|
| 529 |
dataSub.dispose();
|
| 530 |
keySub.dispose();
|
|
|
|
| 531 |
selSub.dispose();
|
| 532 |
try { ws?.close(); } catch { /* ignore */ }
|
| 533 |
term.dispose();
|
| 534 |
termRef.current = null;
|
|
|
|
|
|
|
| 535 |
};
|
| 536 |
}, [session.id]);
|
| 537 |
|
|
@@ -540,19 +703,35 @@ export default function TerminalPane({
|
|
| 540 |
if (termRef.current) termRef.current.options.theme = THEMES[theme];
|
| 541 |
}, [theme]);
|
| 542 |
|
| 543 |
-
//
|
|
|
|
|
|
|
| 544 |
useEffect(() => {
|
| 545 |
const t = termRef.current;
|
| 546 |
if (!t) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
t.options.fontSize = Math.round((13 * zoom) / 100);
|
| 548 |
-
resyncRef.current();
|
| 549 |
-
}, [zoom]);
|
| 550 |
|
| 551 |
// Move keyboard focus into the terminal whenever this pane becomes the active
|
| 552 |
// one (e.g. selected from the sidebar, or newly created).
|
| 553 |
useEffect(() => {
|
| 554 |
if (!active) return;
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
return () => clearTimeout(t);
|
| 557 |
}, [active]);
|
| 558 |
|
|
@@ -592,16 +771,18 @@ export default function TerminalPane({
|
|
| 592 |
<span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
|
| 593 |
)}
|
| 594 |
<div className="ph-right">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
<span className="ph-path" title={pathLabel}>{pathLabel}</span>
|
| 596 |
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
|
| 597 |
</div>
|
| 598 |
</div>
|
| 599 |
-
<div className="term-host">
|
| 600 |
<div className="term-fill" ref={hostRef} />
|
| 601 |
</div>
|
| 602 |
-
{copyMode && (
|
| 603 |
-
<div className="term-copy-hint mono">copy mode · scroll to read, press Esc or type to return</div>
|
| 604 |
-
)}
|
| 605 |
{isMobile && conn === 'connected' && (
|
| 606 |
// Control keys the phone keyboard lacks — needed for TUI menus (model
|
| 607 |
// pickers, etc.). preventDefault keeps terminal focus so the keyboard
|
|
@@ -660,21 +841,13 @@ export default function TerminalPane({
|
|
| 660 |
<button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
|
| 661 |
</div>
|
| 662 |
)}
|
| 663 |
-
{conn ==
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
<div className="tx-row">
|
| 668 |
-
<span>open on another device · still running</span>
|
| 669 |
-
<button
|
| 670 |
-
className="tx-btn"
|
| 671 |
-
onMouseDown={(e) => e.stopPropagation()}
|
| 672 |
-
onClick={(e) => { e.stopPropagation(); resumeRef.current(); }}
|
| 673 |
-
><RefreshGlyph /> resume here</button>
|
| 674 |
-
</div>
|
| 675 |
</div>
|
| 676 |
)}
|
| 677 |
-
{booting &&
|
| 678 |
<div className="term-boot mono">
|
| 679 |
{conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
|
| 680 |
</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?.();
|
|
|
|
| 84 |
// and flush on the user's next click, so the copy lands when they return.
|
| 85 |
// It must run synchronously inside the triggering event — deferring to a
|
| 86 |
// setTimeout loses the activation that execCommand needs.
|
| 87 |
+
// Server → client control frames (restore, shared-grid size) ride the terminal
|
| 88 |
+
// socket with this leading NUL sentinel, which real pty output never begins with.
|
|
|
|
| 89 |
const MODE_CTRL = '\x00\x00AM:';
|
|
|
|
| 90 |
|
| 91 |
+
type TerminalPreview = { version: 1; at: number; cols: number; rows: string[] };
|
| 92 |
+
const PREVIEW_PREFIX = 'am-terminal-preview:';
|
| 93 |
+
const PREVIEW_INDEX = 'am-terminal-preview-index';
|
| 94 |
+
const MAX_PREVIEWS = 12;
|
| 95 |
+
|
| 96 |
+
function loadTerminalPreview(id: string): TerminalPreview | null {
|
| 97 |
+
try {
|
| 98 |
+
const parsed = JSON.parse(localStorage.getItem(`${PREVIEW_PREFIX}${id}`) || 'null');
|
| 99 |
+
if (parsed?.version !== 1 || !Array.isArray(parsed.rows)
|
| 100 |
+
|| !parsed.rows.some((line: unknown) => typeof line === 'string' && line.trim())) return null;
|
| 101 |
+
return {
|
| 102 |
+
version: 1,
|
| 103 |
+
at: Number(parsed.at) || 0,
|
| 104 |
+
cols: Math.max(1, Number(parsed.cols) || 80),
|
| 105 |
+
rows: parsed.rows.filter((line: unknown) => typeof line === 'string').slice(0, 80),
|
| 106 |
+
};
|
| 107 |
+
} catch { return null; }
|
| 108 |
}
|
| 109 |
|
| 110 |
+
// Keep only a compact rendering of the viewport, never the full 20k-line
|
| 111 |
+
// scrollback. It is a loading preview, not a second terminal state model.
|
| 112 |
+
function saveTerminalPreview(id: string, term: Terminal) {
|
| 113 |
+
try {
|
| 114 |
+
const buffer = term.buffer.active;
|
| 115 |
+
const rowCount = Math.min(term.rows, 80);
|
| 116 |
+
const colCount = Math.min(term.cols, 240);
|
| 117 |
+
const rows: string[] = [];
|
| 118 |
+
for (let y = 0; y < rowCount; y++) {
|
| 119 |
+
const line = buffer.getLine(buffer.viewportY + y);
|
| 120 |
+
rows.push((line?.translateToString(false, 0, colCount) || '').replace(/[ \t]+$/, ''));
|
| 121 |
+
}
|
| 122 |
+
if (!rows.some((line) => line.trim())) return;
|
| 123 |
+
const saved: TerminalPreview = { version: 1, at: Date.now(), cols: term.cols, rows };
|
| 124 |
+
localStorage.setItem(`${PREVIEW_PREFIX}${id}`, JSON.stringify(saved));
|
| 125 |
+
|
| 126 |
+
let previous: string[] = [];
|
| 127 |
+
try { previous = JSON.parse(localStorage.getItem(PREVIEW_INDEX) || '[]'); } catch {}
|
| 128 |
+
const next = [id, ...previous].filter((value, i, all) =>
|
| 129 |
+
typeof value === 'string' && all.indexOf(value) === i).slice(0, MAX_PREVIEWS);
|
| 130 |
+
localStorage.setItem(PREVIEW_INDEX, JSON.stringify(next));
|
| 131 |
+
for (const stale of previous) {
|
| 132 |
+
if (typeof stale === 'string' && !next.includes(stale)) {
|
| 133 |
+
localStorage.removeItem(`${PREVIEW_PREFIX}${stale}`);
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
} catch { /* storage may be disabled or full; previews are best-effort */ }
|
| 137 |
}
|
| 138 |
|
| 139 |
function legacyCopy(text: string): boolean {
|
|
|
|
| 192 |
onClose: () => void;
|
| 193 |
}) {
|
| 194 |
const hostRef = useRef<HTMLDivElement>(null);
|
| 195 |
+
const frameRef = useRef<HTMLDivElement>(null);
|
| 196 |
const termRef = useRef<Terminal | null>(null);
|
| 197 |
const resyncRef = useRef<() => void>(() => {});
|
| 198 |
+
const claimRef = useRef<() => void>(() => {});
|
| 199 |
const reconnectRef = useRef<() => void>(() => {});
|
| 200 |
+
const controllerRef = useRef(false);
|
| 201 |
+
const previousZoomRef = useRef(zoom);
|
| 202 |
+
const [preview] = useState<TerminalPreview | null>(() => loadTerminalPreview(session.id));
|
| 203 |
// Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
|
| 204 |
const sendKeyRef = useRef<(d: string) => void>(() => {});
|
| 205 |
const [conn, setConn] = useState<ConnState>('connecting');
|
| 206 |
+
// "starting…" cover while the CLI boots into an empty pane (starting on the
|
| 207 |
// Space can take seconds). Hidden only when the screen actually SHOWS
|
| 208 |
+
// something — byte counts lie because a blank-screen repaint can already be
|
| 209 |
+
// kilobytes of escape sequences.
|
| 210 |
const [booting, setBooting] = useState(true);
|
| 211 |
+
const [controller, setController] = useState(false);
|
| 212 |
+
const [viewers, setViewers] = useState(1);
|
| 213 |
const [editing, setEditing] = useState(false);
|
| 214 |
const [draft, setDraft] = useState(session.name);
|
| 215 |
// Fallback paste sheet: shown only when we can't read the clipboard directly.
|
|
|
|
| 251 |
useEffect(() => {
|
| 252 |
const term = new Terminal({
|
| 253 |
fontFamily: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', monospace",
|
| 254 |
+
// Start at the requested zoom so attachment does not briefly create a
|
| 255 |
+
// 100% grid and then force an avoidable reflow as the session boots.
|
| 256 |
+
fontSize: Math.round((13 * zoom) / 100),
|
| 257 |
cursorBlink: true,
|
| 258 |
scrollback: 20000,
|
| 259 |
theme: THEMES[theme],
|
| 260 |
+
// Let users make a local selection even when an agent TUI has grabbed
|
| 261 |
+
// the mouse: ⌥-drag on macOS, Shift-drag elsewhere.
|
| 262 |
macOptionClickForcesSelection: true,
|
| 263 |
});
|
| 264 |
const fit = new FitAddon();
|
| 265 |
term.loadAddon(fit);
|
| 266 |
|
| 267 |
let lastSelection = '';
|
| 268 |
+
let osc52Text = '';
|
| 269 |
+
let osc52At = 0;
|
|
|
|
| 270 |
|
| 271 |
+
// An agent may deliberately emit OSC 52. Browsers generally reject an
|
| 272 |
+
// unsolicited clipboard write, so retain it briefly and let Cmd/C perform
|
| 273 |
+
// the write inside a real user gesture.
|
| 274 |
const clipboardProvider = {
|
| 275 |
readText: (sel: string) => (sel !== 'p' && navigator.clipboard?.readText ? navigator.clipboard.readText().catch(() => '') : ''),
|
| 276 |
writeText: (sel: string, data: string) => {
|
|
|
|
| 277 |
if (sel !== 'p') {
|
| 278 |
+
osc52Text = data;
|
| 279 |
+
osc52At = Date.now();
|
|
|
|
| 280 |
}
|
|
|
|
| 281 |
},
|
| 282 |
};
|
| 283 |
// addon-clipboard@0.1.0 has a (base64, provider) runtime constructor but
|
|
|
|
| 285 |
term.loadAddon(new (ClipboardAddon as unknown as new (b: Base64, p: typeof clipboardProvider) => ClipboardAddon)(new Base64(), clipboardProvider));
|
| 286 |
term.open(hostRef.current!);
|
| 287 |
termRef.current = term;
|
| 288 |
+
const host = hostRef.current!;
|
| 289 |
+
|
| 290 |
+
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
| 291 |
+
const persistPreview = () => {
|
| 292 |
+
if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
|
| 293 |
+
saveTerminalPreview(session.id, term);
|
| 294 |
+
};
|
| 295 |
+
const schedulePreview = () => {
|
| 296 |
+
// Preview serialization reads every visible buffer row and writes
|
| 297 |
+
// localStorage synchronously. Debounce until output/scrolling is idle so
|
| 298 |
+
// it never lands in the middle of a mobile drag.
|
| 299 |
+
if (previewTimer) clearTimeout(previewTimer);
|
| 300 |
+
previewTimer = setTimeout(persistPreview, 700);
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
+
// xterm deliberately parks its real textarea far off-screen. That is fine
|
| 304 |
+
// with a hardware keyboard, but a mobile browser (especially a Space inside
|
| 305 |
+
// a cross-origin iframe) has no visible focus target to pan above the OSK.
|
| 306 |
+
// Keep the still-transparent 1px input at the bottom of the terminal so the
|
| 307 |
+
// browser's native focused-element avoidance can cross the iframe boundary.
|
| 308 |
+
const mobileInput = isMobile
|
| 309 |
+
? host.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
|
| 310 |
+
: null;
|
| 311 |
+
const anchorMobileInput = () => {
|
| 312 |
+
if (!mobileInput) return;
|
| 313 |
+
// xterm rewrites left/top whenever its cursor moves. Custom properties
|
| 314 |
+
// survive those writes and feed the mobile !important rules in CSS.
|
| 315 |
+
mobileInput.style.setProperty('--am-input-left', `${Math.max(1, Math.round(host.clientWidth / 2))}px`);
|
| 316 |
+
mobileInput.style.setProperty('--am-input-top', `${Math.max(1, host.clientHeight - 12)}px`);
|
| 317 |
+
};
|
| 318 |
+
anchorMobileInput();
|
| 319 |
+
|
| 320 |
+
// Track the user's semantic scroll state independently of the viewport's
|
| 321 |
+
// pixel scrollTop. During a row-count change xterm can transiently report
|
| 322 |
+
// the old pixel position against the new scroll height.
|
| 323 |
+
let followingBottom = true;
|
| 324 |
+
const scrollSub = term.onScroll(() => {
|
| 325 |
+
followingBottom = term.buffer.active.viewportY >= term.buffer.active.baseY;
|
| 326 |
+
schedulePreview();
|
| 327 |
+
});
|
| 328 |
|
| 329 |
// Track the committed local xterm selection so Cmd/Ctrl+C can copy it
|
| 330 |
// synchronously — deferring (setTimeout) would break execCommand's gesture.
|
| 331 |
const selSub = term.onSelectionChange(() => {
|
| 332 |
lastSelection = term.hasSelection() ? selectionText(term) : '';
|
|
|
|
| 333 |
});
|
| 334 |
const copySelection = () => {
|
| 335 |
const text = term.hasSelection() ? selectionText(term) : lastSelection;
|
| 336 |
if (text) copyText(text);
|
| 337 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
const onCopy = (e: ClipboardEvent) => {
|
| 339 |
+
if (!term.hasSelection()) return;
|
| 340 |
+
const text = selectionText(term);
|
| 341 |
+
if (!text || !e.clipboardData) return;
|
| 342 |
+
e.clipboardData.setData('text/plain', text);
|
| 343 |
+
e.preventDefault();
|
| 344 |
};
|
|
|
|
| 345 |
document.addEventListener('copy', onCopy, true);
|
| 346 |
|
| 347 |
let ws: WebSocket | null = null;
|
| 348 |
const send = (o: unknown) => {
|
| 349 |
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o));
|
| 350 |
};
|
| 351 |
+
const claimControl = () => {
|
| 352 |
+
if (controllerRef.current) return;
|
| 353 |
+
// Activation can precede WebSocket.open when a pane is first mounted.
|
| 354 |
+
// Do not consume that claim locally: onopen will retry it once it can
|
| 355 |
+
// actually reach the server.
|
| 356 |
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
| 357 |
+
// Optimistic locally so the input event following this gesture is not
|
| 358 |
+
// dropped. WebSocket ordering guarantees claim reaches the server first.
|
| 359 |
+
controllerRef.current = true;
|
| 360 |
+
setController(true);
|
| 361 |
+
send({ t: 'claim' });
|
| 362 |
+
};
|
| 363 |
+
claimRef.current = claimControl;
|
| 364 |
+
const onPointerDown = (e: PointerEvent) => {
|
| 365 |
+
// A touch may only be inspecting local scrollback. Claim lazily below if
|
| 366 |
+
// the gesture actually needs to drive an application's mouse mode.
|
| 367 |
+
if (e.pointerType !== 'touch') claimControl();
|
| 368 |
+
};
|
| 369 |
+
const onPaste = () => claimControl();
|
| 370 |
+
host.addEventListener('pointerdown', onPointerDown, true);
|
| 371 |
+
host.addEventListener('paste', onPaste, true);
|
| 372 |
+
|
| 373 |
// The mobile key-bar sends control sequences the on-screen keyboard can't.
|
| 374 |
+
sendKeyRef.current = (d: string) => {
|
| 375 |
+
claimControl();
|
| 376 |
+
send({ t: 'i', d });
|
| 377 |
+
endBoot();
|
| 378 |
+
};
|
| 379 |
|
| 380 |
+
// ⌘/Ctrl+C copies a local selection. Without one, Ctrl+C remains SIGINT;
|
| 381 |
+
// Cmd+C may accept a recent OSC 52 payload deliberately emitted by the app.
|
|
|
|
|
|
|
| 382 |
term.attachCustomKeyEventHandler((e) => {
|
| 383 |
if (e.type !== 'keydown') return true;
|
| 384 |
if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && term.hasSelection()) {
|
| 385 |
copySelection();
|
| 386 |
term.clearSelection();
|
|
|
|
| 387 |
return false;
|
| 388 |
}
|
| 389 |
+
if (e.metaKey && (e.key === 'c' || e.key === 'C')) {
|
| 390 |
+
if (osc52Text && Date.now() - osc52At < 120_000) copyText(osc52Text);
|
|
|
|
|
|
|
|
|
|
| 391 |
return false;
|
| 392 |
}
|
| 393 |
+
claimControl();
|
|
|
|
| 394 |
return true;
|
| 395 |
});
|
| 396 |
+
// The only local fit: this terminal is still empty, so there is no buffer to
|
| 397 |
+
// reflow, and it gives the initial size we open the socket with. Every later
|
| 398 |
+
// size change goes through resync() as a REQUEST — see there.
|
| 399 |
try { fit.fit(); } catch { /* layout not ready yet */ }
|
| 400 |
// Re-measure once the webfont is ready (glyph width changes vs the fallback).
|
| 401 |
+
document.fonts?.ready.then(() => resync());
|
| 402 |
|
| 403 |
let closedByUs = false;
|
|
|
|
| 404 |
let retry: ReturnType<typeof setTimeout> | null = null;
|
| 405 |
// Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
|
| 406 |
// every second by every open pane. Reset once a connection succeeds.
|
|
|
|
| 431 |
setBooting(false);
|
| 432 |
};
|
| 433 |
const connect = () => {
|
| 434 |
+
controllerRef.current = false;
|
| 435 |
+
setController(false);
|
| 436 |
setConn('connecting');
|
| 437 |
+
// A reconnect keeps the already-rendered xterm visible. On a fresh page,
|
| 438 |
+
// `booting` instead exposes the saved preview until canonical restore has
|
| 439 |
+
// painted real content.
|
| 440 |
+
const needsCover = !screenHasContent();
|
| 441 |
+
setBooting(needsCover);
|
| 442 |
+
bootLive = needsCover;
|
| 443 |
if (bootTimer) clearTimeout(bootTimer);
|
| 444 |
+
bootTimer = needsCover ? setTimeout(endBoot, 20_000) : null;
|
| 445 |
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
| 446 |
const url = `${proto}://${location.host}/ws?session=${encodeURIComponent(session.id)}&cols=${term.cols}&rows=${term.rows}`;
|
| 447 |
ws = new WebSocket(url);
|
|
|
|
| 449 |
ws.onopen = () => {
|
| 450 |
setConn('connected');
|
| 451 |
retryDelay = 1200;
|
| 452 |
+
// Selecting a terminal is an explicit foreground action on mobile.
|
| 453 |
+
// Claim before reporting its fit so an already-open desktop does not
|
| 454 |
+
// leave the phone rendering a clipped desktop-sized canonical grid.
|
| 455 |
+
if (isMobile && !document.hidden) claimControl();
|
| 456 |
+
requestSize();
|
| 457 |
};
|
| 458 |
ws.onmessage = (e) => {
|
| 459 |
const d = typeof e.data === 'string' ? e.data : new Uint8Array(e.data as ArrayBuffer);
|
| 460 |
+
// Control frame: a leading NUL sentinel that raw pty output never
|
| 461 |
+
// produces — handle it instead of writing to the term.
|
| 462 |
if (typeof d === 'string' && d.startsWith(MODE_CTRL)) {
|
| 463 |
+
try {
|
| 464 |
+
const m = JSON.parse(d.slice(MODE_CTRL.length));
|
| 465 |
+
if (m.t === 'grid' || m.t === 'restore') {
|
| 466 |
+
controllerRef.current = !!m.controller;
|
| 467 |
+
setController(!!m.controller);
|
| 468 |
+
setViewers(Math.max(1, Number(m.viewers) || 1));
|
| 469 |
+
const applyGrid = () => {
|
| 470 |
+
try {
|
| 471 |
+
// xterm can retain the old pixel scrollTop when the viewport
|
| 472 |
+
// gains rows (notably landscape -> portrait), which leaves a
|
| 473 |
+
// formerly-live view stranded in history. Preserve the
|
| 474 |
+
// semantic bottom anchor without disturbing someone who is
|
| 475 |
+
// deliberately reading scrollback.
|
| 476 |
+
const wasAtBottom = m.reset || followingBottom;
|
| 477 |
+
if (m.reset) {
|
| 478 |
+
term.reset();
|
| 479 |
+
term.clear();
|
| 480 |
+
}
|
| 481 |
+
if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
|
| 482 |
+
term.resize(m.cols, m.rows);
|
| 483 |
+
}
|
| 484 |
+
if (wasAtBottom) {
|
| 485 |
+
term.scrollToBottom();
|
| 486 |
+
followingBottom = true;
|
| 487 |
+
}
|
| 488 |
+
schedulePreview();
|
| 489 |
+
} catch { /* ignore */ }
|
| 490 |
+
};
|
| 491 |
+
// Writes are asynchronous. A queued empty write is a barrier so
|
| 492 |
+
// resize/reset cannot reinterpret bytes from the preceding grid.
|
| 493 |
+
const geometryChanged = m.cols > 0 && m.rows > 0
|
| 494 |
+
&& (term.cols !== m.cols || term.rows !== m.rows);
|
| 495 |
+
if (m.reset || geometryChanged) term.write('', applyGrid);
|
| 496 |
+
else applyGrid();
|
| 497 |
+
}
|
| 498 |
+
} catch { /* ignore */ }
|
| 499 |
return;
|
| 500 |
}
|
| 501 |
+
term.write(d, schedulePreview);
|
| 502 |
// Probe shortly after each burst (throttled; write() is async).
|
| 503 |
if (bootLive && !bootCheck) {
|
| 504 |
bootCheck = setTimeout(() => {
|
|
|
|
| 510 |
ws.onclose = (e) => {
|
| 511 |
// A real process exit: stop here and let the user relaunch. Anything
|
| 512 |
// else is a transient drop (sleep/wake, network) → auto-reconnect and
|
| 513 |
+
// reattach to the still-running backend session.
|
| 514 |
endBoot();
|
| 515 |
if (e.code === EXIT_CODE) { setConn('exited'); return; }
|
|
|
|
|
|
|
|
|
|
| 516 |
setConn('closed');
|
| 517 |
if (!closedByUs) {
|
| 518 |
retry = setTimeout(connect, retryDelay);
|
|
|
|
| 522 |
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
|
| 523 |
};
|
| 524 |
// Manual restart: clear the dead run's screen so the content probe watches
|
| 525 |
+
// the new process paint, not leftovers.
|
| 526 |
reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
|
| 527 |
|
| 528 |
+
// Report the pane's preferred size without locally fitting its terminal.
|
| 529 |
+
// Only the current controller's preference changes the canonical grid;
|
| 530 |
+
// watchers retain theirs for a future claim.
|
| 531 |
+
let resyncTimer: ReturnType<typeof setTimeout> | null = null;
|
| 532 |
+
const requestSize = () => {
|
| 533 |
+
const box = hostRef.current;
|
| 534 |
+
if (!box || box.clientWidth < 40 || box.clientHeight < 40) return;
|
| 535 |
+
try {
|
| 536 |
+
const d = fit.proposeDimensions();
|
| 537 |
+
const cols = d ? Math.max(1, d.cols) : 0;
|
| 538 |
+
const rows = d ? Math.max(1, d.rows) : 0;
|
| 539 |
+
if (cols > 0 && rows > 0) send({ t: 'r', cols, rows });
|
| 540 |
+
} catch { /* layout not ready */ }
|
| 541 |
};
|
| 542 |
+
// ResizeObserver fires every frame while a window is dragged or the sidebar
|
| 543 |
+
// animates. Ask once, when it stops.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
const resync = () => {
|
| 545 |
+
anchorMobileInput();
|
| 546 |
+
if (resyncTimer) clearTimeout(resyncTimer);
|
| 547 |
+
resyncTimer = setTimeout(() => { resyncTimer = null; requestSize(); }, 80);
|
| 548 |
};
|
| 549 |
+
resyncRef.current = resync;
|
| 550 |
+
const onReturn = () => resync();
|
|
|
|
| 551 |
const onVisible = () => { if (!document.hidden) onReturn(); };
|
|
|
|
|
|
|
| 552 |
// Typing means the user sees enough to interact — drop the boot cover.
|
| 553 |
// Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
|
| 554 |
// replies to the TUI's terminal queries (DA/CPR), which arrive instantly
|
| 555 |
// on attach and must not count as "the user typed".
|
| 556 |
+
const keySub = term.onKey(() => { claimControl(); endBoot(); });
|
| 557 |
+
// Watchers render output but do not answer terminal queries. This prevents
|
| 558 |
+
// N browser emulators from injecting N DA/CPR responses into one PTY.
|
| 559 |
+
const dataSub = term.onData((d) => {
|
| 560 |
+
if (controllerRef.current) send({ t: 'i', d });
|
| 561 |
+
});
|
| 562 |
const ro = new ResizeObserver(resync);
|
| 563 |
ro.observe(hostRef.current!);
|
| 564 |
window.addEventListener('focus', onReturn);
|
| 565 |
document.addEventListener('visibilitychange', onVisible);
|
| 566 |
|
| 567 |
+
// A phone drag always navigates browser-retained terminal history. Passing
|
| 568 |
+
// it to an agent's mouse mode at the live bottom makes Claude consume the
|
| 569 |
+
// gesture without moving xterm's viewport, leaving no reliable way back
|
| 570 |
+
// through history on touch-only devices. xterm 5.5 registers no touch
|
| 571 |
+
// listeners of its own (verified against the bundled lib) and .term-host
|
| 572 |
+
// sets touch-action:none on mobile, so neither xterm nor the browser will
|
| 573 |
+
// pan: this is the only touch scrolling a phone has, in every pane.
|
| 574 |
+
//
|
| 575 |
+
// Convert the drag to whole rows and carry the remainder in `residual`.
|
| 576 |
+
// Quantising each event to a fixed notch instead silently drops whatever
|
| 577 |
+
// does not fill one — a 96px drag moved the view 68px — and that shortfall
|
| 578 |
+
// is what reads as lag, because the text trails the finger by design.
|
| 579 |
+
// scrollLines moves ydisp, the authority the viewport follows.
|
| 580 |
+
// The frame, not the inner measurement box: .term-host is what carries
|
| 581 |
+
// touch-action:none and what the user actually drags, and it stays the
|
| 582 |
+
// gesture target however the box inside it is nested.
|
| 583 |
+
const frame = frameRef.current ?? host;
|
| 584 |
+
const viewport = host.querySelector<HTMLElement>('.xterm-viewport');
|
| 585 |
let touchY: number | null = null;
|
| 586 |
+
let residual = 0; // px of gesture not yet worth a whole row
|
| 587 |
+
let glideFrame = 0;
|
| 588 |
+
let velocity = 0; // px/ms, signed like deltaY (positive scrolls down)
|
| 589 |
+
let lastMoveAt = 0;
|
| 590 |
+
// The scroll area spans every line in the buffer, so its height over that
|
| 591 |
+
// line count is the row height under whichever renderer is attached.
|
| 592 |
+
const scrollByPixels = (px: number) => {
|
| 593 |
+
const lines = term.buffer.active.length;
|
| 594 |
+
const cell = viewport && lines ? viewport.scrollHeight / lines : 0;
|
| 595 |
+
if (!cell) return 0;
|
| 596 |
+
residual += px;
|
| 597 |
+
const rows = Math.trunc(residual / cell);
|
| 598 |
+
if (!rows) return 0;
|
| 599 |
+
residual -= rows * cell;
|
| 600 |
+
term.scrollLines(rows);
|
| 601 |
+
return rows;
|
| 602 |
+
};
|
| 603 |
+
const stopGlide = () => { if (glideFrame) { cancelAnimationFrame(glideFrame); glideFrame = 0; } };
|
| 604 |
+
const onTouchStart = (e: TouchEvent) => {
|
| 605 |
+
stopGlide(); // a new touch takes over from any coasting
|
| 606 |
+
velocity = 0;
|
| 607 |
+
residual = 0;
|
| 608 |
+
touchY = e.touches[0].clientY;
|
| 609 |
+
lastMoveAt = e.timeStamp;
|
| 610 |
+
};
|
| 611 |
const onTouchMove = (e: TouchEvent) => {
|
| 612 |
+
if (touchY == null || !e.touches.length) return;
|
| 613 |
const y = e.touches[0].clientY;
|
| 614 |
+
const deltaY = touchY - y;
|
| 615 |
+
touchY = y;
|
| 616 |
+
if (deltaY === 0) return;
|
| 617 |
+
const dt = e.timeStamp - lastMoveAt;
|
| 618 |
+
lastMoveAt = e.timeStamp;
|
| 619 |
+
// Weight the newest sample heavily: what matters is the speed at release,
|
| 620 |
+
// and a touch stream is noisy. Ignore a stale gap (a paused finger) and
|
| 621 |
+
// anything faster than a touch stream can legitimately be — a single
|
| 622 |
+
// coalesced jump over a 2ms gap is not a 48px/ms flick, and dividing by
|
| 623 |
+
// it would launch the glide clean through the buffer. A hand tops out
|
| 624 |
+
// around 4px/ms; real events arrive 8-16ms apart.
|
| 625 |
+
if (dt >= 4 && dt < 100) {
|
| 626 |
+
const sample = Math.max(-4, Math.min(4, deltaY / dt));
|
| 627 |
+
velocity = velocity * 0.3 + sample * 0.7;
|
| 628 |
}
|
| 629 |
+
scrollByPixels(deltaY);
|
| 630 |
+
if (e.cancelable) e.preventDefault(); // keep the page from rubber-banding
|
| 631 |
+
// This listener runs in capture before xterm's listener on the same host.
|
| 632 |
+
// stopPropagation alone would still allow that second handler.
|
| 633 |
+
e.stopImmediatePropagation();
|
| 634 |
};
|
| 635 |
+
const onTouchEnd = () => {
|
| 636 |
+
touchY = null;
|
| 637 |
+
const v0 = velocity;
|
| 638 |
+
velocity = 0;
|
| 639 |
+
// Only a flick coasts. A slow, deliberate drag through history must land
|
| 640 |
+
// exactly where the finger left it — drifting past the line someone was
|
| 641 |
+
// reading is worse than having no momentum at all. 0.4px/ms is about
|
| 642 |
+
// 400px/s: far above a careful drag, far below a real flick.
|
| 643 |
+
if (!viewport || Math.abs(v0) < 0.4) return;
|
| 644 |
+
let v = v0;
|
| 645 |
+
let previous = 0;
|
| 646 |
+
const glide = (now: number) => {
|
| 647 |
+
// Clamp the step so a dropped frame does not teleport the viewport.
|
| 648 |
+
const frame = previous ? Math.min(now - previous, 50) : 16;
|
| 649 |
+
previous = now;
|
| 650 |
+
const before = term.buffer.active.viewportY;
|
| 651 |
+
const rows = scrollByPixels(v * frame);
|
| 652 |
+
v *= 0.95 ** (frame / 16); // ~5% per 60Hz frame, frame-rate neutral
|
| 653 |
+
// Stop at a standstill, or the moment a requested row does not move the
|
| 654 |
+
// view: that is either end of the buffer, and coasting into it drags.
|
| 655 |
+
if (Math.abs(v) < 0.02 || (rows !== 0 && term.buffer.active.viewportY === before)) {
|
| 656 |
+
glideFrame = 0;
|
| 657 |
+
return;
|
| 658 |
+
}
|
| 659 |
+
glideFrame = requestAnimationFrame(glide);
|
| 660 |
+
};
|
| 661 |
+
glideFrame = requestAnimationFrame(glide);
|
| 662 |
+
};
|
| 663 |
+
const onTouchCancel = () => { touchY = null; velocity = 0; stopGlide(); };
|
| 664 |
+
frame.addEventListener('touchstart', onTouchStart, { passive: true });
|
| 665 |
+
frame.addEventListener('touchmove', onTouchMove, { passive: false, capture: true });
|
| 666 |
+
frame.addEventListener('touchend', onTouchEnd);
|
| 667 |
+
frame.addEventListener('touchcancel', onTouchCancel);
|
| 668 |
|
| 669 |
connect();
|
| 670 |
|
|
|
|
| 673 |
if (retry) clearTimeout(retry);
|
| 674 |
if (bootTimer) clearTimeout(bootTimer);
|
| 675 |
if (bootCheck) clearTimeout(bootCheck);
|
| 676 |
+
if (resyncTimer) clearTimeout(resyncTimer);
|
| 677 |
+
persistPreview();
|
| 678 |
ro.disconnect();
|
| 679 |
host.removeEventListener('pointerdown', onPointerDown, true);
|
| 680 |
+
host.removeEventListener('paste', onPaste, true);
|
|
|
|
|
|
|
|
|
|
| 681 |
document.removeEventListener('copy', onCopy, true);
|
| 682 |
+
frame.removeEventListener('touchstart', onTouchStart);
|
| 683 |
+
frame.removeEventListener('touchmove', onTouchMove, true);
|
| 684 |
+
frame.removeEventListener('touchend', onTouchEnd);
|
| 685 |
+
frame.removeEventListener('touchcancel', onTouchCancel);
|
| 686 |
+
stopGlide();
|
| 687 |
window.removeEventListener('focus', onReturn);
|
| 688 |
document.removeEventListener('visibilitychange', onVisible);
|
| 689 |
dataSub.dispose();
|
| 690 |
keySub.dispose();
|
| 691 |
+
scrollSub.dispose();
|
| 692 |
selSub.dispose();
|
| 693 |
try { ws?.close(); } catch { /* ignore */ }
|
| 694 |
term.dispose();
|
| 695 |
termRef.current = null;
|
| 696 |
+
resyncRef.current = () => {};
|
| 697 |
+
claimRef.current = () => {};
|
| 698 |
};
|
| 699 |
}, [session.id]);
|
| 700 |
|
|
|
|
| 703 |
if (termRef.current) termRef.current.options.theme = THEMES[theme];
|
| 704 |
}, [theme]);
|
| 705 |
|
| 706 |
+
// Font size and PTY geometry move together. The controller asks the server
|
| 707 |
+
// for the grid that actually fits this pane; the confirmed grid then resizes
|
| 708 |
+
// every viewer in order with the PTY output stream.
|
| 709 |
useEffect(() => {
|
| 710 |
const t = termRef.current;
|
| 711 |
if (!t) return;
|
| 712 |
+
const changed = previousZoomRef.current !== zoom;
|
| 713 |
+
previousZoomRef.current = zoom;
|
| 714 |
+
// Zoom is an explicit interaction outside the terminal element. If this
|
| 715 |
+
// pane is a watcher, take the geometry lease before reporting its newly
|
| 716 |
+
// fitted size; otherwise only the local glyphs grow and the canonical grid
|
| 717 |
+
// remains too large, clipping the bottom/right of the pane.
|
| 718 |
+
if (changed && active) claimRef.current();
|
| 719 |
t.options.fontSize = Math.round((13 * zoom) / 100);
|
| 720 |
+
if (active) resyncRef.current();
|
| 721 |
+
}, [zoom, active]);
|
| 722 |
|
| 723 |
// Move keyboard focus into the terminal whenever this pane becomes the active
|
| 724 |
// one (e.g. selected from the sidebar, or newly created).
|
| 725 |
useEffect(() => {
|
| 726 |
if (!active) return;
|
| 727 |
+
// A retained pane may have spent time under display:none. Reclaim and
|
| 728 |
+
// remeasure only after its grid cell has layout again; hidden panes never
|
| 729 |
+
// get to resize the canonical PTY.
|
| 730 |
+
const t = setTimeout(() => {
|
| 731 |
+
claimRef.current();
|
| 732 |
+
resyncRef.current();
|
| 733 |
+
termRef.current?.focus();
|
| 734 |
+
}, 0);
|
| 735 |
return () => clearTimeout(t);
|
| 736 |
}, [active]);
|
| 737 |
|
|
|
|
| 771 |
<span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
|
| 772 |
)}
|
| 773 |
<div className="ph-right">
|
| 774 |
+
{viewers > 1 && (
|
| 775 |
+
<span className={`ph-role${controller ? ' controller' : ''}`} title={controller ? 'This pane controls terminal input and size' : 'Interact with the terminal to take control'}>
|
| 776 |
+
{controller ? `${viewers} viewers` : 'watching'}
|
| 777 |
+
</span>
|
| 778 |
+
)}
|
| 779 |
<span className="ph-path" title={pathLabel}>{pathLabel}</span>
|
| 780 |
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
|
| 781 |
</div>
|
| 782 |
</div>
|
| 783 |
+
<div className="term-host" ref={frameRef}>
|
| 784 |
<div className="term-fill" ref={hostRef} />
|
| 785 |
</div>
|
|
|
|
|
|
|
|
|
|
| 786 |
{isMobile && conn === 'connected' && (
|
| 787 |
// Control keys the phone keyboard lacks — needed for TUI menus (model
|
| 788 |
// pickers, etc.). preventDefault keeps terminal focus so the keyboard
|
|
|
|
| 841 |
<button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
|
| 842 |
</div>
|
| 843 |
)}
|
| 844 |
+
{booting && preview && conn !== 'exited' && (
|
| 845 |
+
<div className="term-preview mono" aria-label="Restoring terminal">
|
| 846 |
+
<pre style={{ fontSize: `${Math.round((13 * zoom) / 100)}px` }}>{preview.rows.join('\n')}</pre>
|
| 847 |
+
<span>restoring last view…</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 848 |
</div>
|
| 849 |
)}
|
| 850 |
+
{booting && !preview && conn !== 'exited' && (
|
| 851 |
<div className="term-boot mono">
|
| 852 |
{conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
|
| 853 |
</div>
|
web/src/styles.css
CHANGED
|
@@ -67,6 +67,7 @@ body {
|
|
| 67 |
.mono { font-family: var(--font-mono); }
|
| 68 |
|
| 69 |
.app { display: flex; height: 100%; }
|
|
|
|
| 70 |
.sidebar { width: 282px; flex: none; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
| 71 |
.main { flex: 1; min-width: 0; padding: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
| 72 |
.stage { flex: 1; min-height: 0; }
|
|
@@ -440,6 +441,7 @@ body {
|
|
| 440 |
|
| 441 |
/* main: tiles */
|
| 442 |
.tiles { height: 100%; display: grid; gap: 12px; }
|
|
|
|
| 443 |
.tiles.drop-over { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: var(--r-xl); }
|
| 444 |
.tile { display: flex; min-width: 0; min-height: 0; border-radius: var(--r-xl); }
|
| 445 |
.tile > .slot { flex: 1; min-width: 0; min-height: 0; }
|
|
@@ -489,13 +491,9 @@ 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 |
-
|
| 493 |
-
|
| 494 |
-
.term-
|
| 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 +550,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 +563,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;
|
| 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 +593,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 |
|
|
@@ -1039,10 +1037,28 @@ a.btn-ghost { text-decoration: none; }
|
|
| 1039 |
|
| 1040 |
/* phones: two full-screen views — the sidebar list, or the selected pane */
|
| 1041 |
@media (max-width: 720px) {
|
| 1042 |
-
/*
|
| 1043 |
-
when the on-screen keyboard opens it shrinks the app so the terminal
|
| 1044 |
-
control bar stay above the keyboard instead of hiding behind it. */
|
| 1045 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1046 |
.app.m-home .main { display: none; }
|
| 1047 |
.app.m-home .sidebar { width: 100%; border-right: none; }
|
| 1048 |
.app.m-stage .sidebar { display: none; }
|
|
|
|
| 67 |
.mono { font-family: var(--font-mono); }
|
| 68 |
|
| 69 |
.app { display: flex; height: 100%; }
|
| 70 |
+
.app.app-suspended { display: none; }
|
| 71 |
.sidebar { width: 282px; flex: none; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
| 72 |
.main { flex: 1; min-width: 0; padding: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
| 73 |
.stage { flex: 1; min-height: 0; }
|
|
|
|
| 441 |
|
| 442 |
/* main: tiles */
|
| 443 |
.tiles { height: 100%; display: grid; gap: 12px; }
|
| 444 |
+
.pane-deck.deck-hidden, .tile.tile-cached { display: none; }
|
| 445 |
.tiles.drop-over { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: var(--r-xl); }
|
| 446 |
.tile { display: flex; min-width: 0; min-height: 0; border-radius: var(--r-xl); }
|
| 447 |
.tile > .slot { flex: 1; min-width: 0; min-height: 0; }
|
|
|
|
| 491 |
/* terminal boot cover ("connecting… / starting claude…") */
|
| 492 |
.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; }
|
| 493 |
.term-boot .et-cursor { height: 13px; width: 7px; margin-left: 7px; }
|
| 494 |
+
.term-preview { position: absolute; inset: 34px 0 0; z-index: 4; overflow: hidden; background: var(--term-bg); color: var(--text); pointer-events: none; }
|
| 495 |
+
.term-preview pre { height: 100%; margin: 0; padding: 10px 8px 8px 12px; overflow: hidden; font: inherit; line-height: 1.15; white-space: pre; }
|
| 496 |
+
.term-preview > span { position: absolute; right: 9px; bottom: 7px; padding: 3px 6px; border: 1px solid var(--border); border-radius: var(--r-sm); background: color-mix(in srgb, var(--term-bg) 88%, transparent); color: var(--muted); font-size: 10px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
/* terminal "process exited" overlay */
|
| 498 |
/* Non-blocking banner so the agent's last output (e.g. a login error) stays readable. */
|
| 499 |
/* stopped state: quiet line centered in the pane, in the boot-cover's voice */
|
|
|
|
| 550 |
.pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; }
|
| 551 |
.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; }
|
| 552 |
.pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; }
|
| 553 |
+
.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; }
|
| 554 |
+
.pane-head .ph-role.controller { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
|
| 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 |
.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; background: var(--term-bg); overflow: hidden; }
|
| 567 |
+
/* The grid is measured against this filler, so it must stay unpadded: FitAddon
|
| 568 |
+
reads the PARENT's computed height and does NOT subtract its padding, so a
|
| 569 |
+
padded parent made the grid count a row that doesn't fit and the bottom line
|
| 570 |
+
rendered sliced (most visible with Codex, which paints its status line on the
|
| 571 |
+
very last row). The padding lives on .xterm, which FitAddon does subtract. */
|
|
|
|
|
|
|
| 572 |
.term-fill { height: 100%; min-height: 0; }
|
| 573 |
/* mobile control-key bar: the keys a phone keyboard lacks, pinned below the
|
| 574 |
terminal so it rides just above the on-screen keyboard */
|
|
|
|
| 593 |
.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);
|
| 594 |
background: var(--panel-2); color: var(--text); font-size: 13px; line-height: 1.35; }
|
| 595 |
.term-paste .tp-input:focus { outline: none; border-color: var(--accent); }
|
| 596 |
+
.term-host .xterm { width: 100%; height: 100%; padding: 10px 8px 8px 12px; }
|
| 597 |
.xterm .xterm-viewport { scrollbar-width: none; }
|
| 598 |
.xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
| 599 |
|
|
|
|
| 1037 |
|
| 1038 |
/* phones: two full-screen views — the sidebar list, or the selected pane */
|
| 1039 |
@media (max-width: 720px) {
|
| 1040 |
+
/* Follow the VISUAL viewport rectangle (set from window.visualViewport):
|
| 1041 |
+
when the on-screen keyboard opens it shrinks/moves the app so the terminal
|
| 1042 |
+
and control bar stay above the keyboard instead of hiding behind it. */
|
| 1043 |
+
html, body, #root { overflow: hidden; overscroll-behavior: none; }
|
| 1044 |
+
.app {
|
| 1045 |
+
position: fixed;
|
| 1046 |
+
top: var(--vv-top, 0px);
|
| 1047 |
+
left: var(--vv-left, 0px);
|
| 1048 |
+
width: var(--vvw, 100%);
|
| 1049 |
+
height: var(--vvh, 100dvh);
|
| 1050 |
+
overflow: hidden;
|
| 1051 |
+
}
|
| 1052 |
+
.term-host { touch-action: none; overscroll-behavior: contain; }
|
| 1053 |
+
/* Keep xterm's transparent real input at the visible prompt edge. xterm
|
| 1054 |
+
normally parks/moves it for desktop IME behavior; on phones this element
|
| 1055 |
+
is also the browser's only clue about what must remain above the OSK. */
|
| 1056 |
+
.term-host .xterm-helper-textarea {
|
| 1057 |
+
left: var(--am-input-left, 50%) !important;
|
| 1058 |
+
top: var(--am-input-top, calc(100% - 2px)) !important;
|
| 1059 |
+
width: 1px !important;
|
| 1060 |
+
height: 1px !important;
|
| 1061 |
+
}
|
| 1062 |
.app.m-home .main { display: none; }
|
| 1063 |
.app.m-home .sidebar { width: 100%; border-right: none; }
|
| 1064 |
.app.m-stage .sidebar { display: none; }
|