Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 65 additions & 15 deletions crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,13 @@
// `session.pty_resize` on every paint when nothing actually
// changed (ResizeObserver fires generously).
lastReportedSize: { cols: 0, rows: 0 },
// Web PTY ownership is explicit and short-lived. Passive browser
// layout churn should not steal the OS PTY size from an active TUI,
// but recent web interaction should keep xterm and the real PTY in
// sync, including row-only changes.
webPtyEngagedUntil: 0,
ptyResizeTimer: null,
pendingPtyResize: null,
// Some PTY apps repaint after SIGWINCH by emitting a large redraw
// after the browser-side fit already returned. If the user was
// following bottom when we sent the resize, keep following briefly
Expand All @@ -1259,6 +1266,8 @@
};

const LARGE_TEXT_PASTE_CHARS = 16 * 1024;
const WEB_PTY_ENGAGEMENT_MS = 3000;
const WEB_PTY_RESIZE_DEBOUNCE_MS = 120;

const miniMatrixActivity = {
intensity: 0,
Expand Down Expand Up @@ -2047,7 +2056,7 @@
// the daemon's active-wins policy. Reset the dedup so this
// refit always fires `pty_resize`.
state.lastReportedSize = { cols: 0, rows: 0 };
refitTerminal();
refitTerminal({ claim: true, immediate: true });
state.term.focus();
}

Expand Down Expand Up @@ -2212,6 +2221,7 @@
// input round-trips cleanly.
state.term.onData((s) => {
if (state.mode !== "terminal" || !state.currentId) return;
claimTerminalGeometryNow();
const data = encodeBase64Utf8(s);
rpc("session.pty_input", { session_id: state.currentId, data })
.catch((e) => console.error("pty_input failed:", e));
Expand All @@ -2227,12 +2237,27 @@

// Tapping the terminal area focuses xterm's helper textarea, which
// is what summons the soft keyboard on iOS/Android.
terminalEl.addEventListener("click", () => state.term && state.term.focus());
terminalEl.addEventListener("click", () => {
noteWebPtyEngagement();
refitTerminal({ claim: true, immediate: true });
state.term && state.term.focus();
});
}

function noteWebPtyEngagement() {
state.webPtyEngagedUntil = performance.now() + WEB_PTY_ENGAGEMENT_MS;
}

function refitTerminal() {
function webPtyRecentlyEngaged() {
return performance.now() <= state.webPtyEngagedUntil;
}

function refitTerminal(options = {}) {
if (!state.term || !state.fitAddon) return;
if (state.mode !== "terminal") return;
const claim = !!options.claim;
const immediate = !!options.immediate;
if (claim) noteWebPtyEngagement();
const wasAtBottom = terminalIsAtBottom();
if (!fitTerminalPreservingScroll(wasAtBottom)) return;
const cols = state.term.cols;
Expand All @@ -2244,24 +2269,47 @@
if (!colsChanged && !rowsChanged) {
return;
}
if (!colsChanged) {
// Height-only changes are common when the phone's native keyboard or
// the web virtual keyboard appears. Resizing local xterm rows is enough
// for the browser viewport; sending SIGWINCH to codex/claude/zarvis here
// makes those full-screen PTY apps repaint their transcript, which looks
// like history replaying from the beginning and can leave scrollback at
// the top after repeated keyboard toggles.
if (!claim && !webPtyRecentlyEngaged()) {
// Passive browser layout churn (background tab, phone keyboard,
// viewport settle) should refit local xterm but must not steal the
// single OS PTY geometry from an active TUI. Once the web terminal
// is clicked/typed into, recent engagement lets row-only changes
// claim the PTY too, keeping full-screen apps like codex aligned.
state.resizeFollowBottomUntil = 0;
return;
}
state.lastReportedSize = { cols, rows };
if (state.currentId) {
if (wasAtBottom) state.resizeFollowBottomUntil = performance.now() + 1500;
rpc("session.pty_resize", { session_id: state.currentId, cols, rows })
.catch((e) => console.warn("pty_resize failed:", e));
if (immediate) {
sendTerminalPtyResize(cols, rows, wasAtBottom);
} else {
scheduleTerminalPtyResize(cols, rows, wasAtBottom);
}
}

function scheduleTerminalPtyResize(cols, rows, wasAtBottom) {
state.pendingPtyResize = { sessionId: state.currentId, cols, rows, wasAtBottom };
state.lastReportedSize = { cols, rows };
if (state.ptyResizeTimer) clearTimeout(state.ptyResizeTimer);
state.ptyResizeTimer = setTimeout(() => {
state.ptyResizeTimer = null;
const pending = state.pendingPtyResize;
state.pendingPtyResize = null;
if (!pending) return;
sendTerminalPtyResize(pending.cols, pending.rows, pending.wasAtBottom, pending.sessionId);
}, WEB_PTY_RESIZE_DEBOUNCE_MS);
}

function sendTerminalPtyResize(cols, rows, wasAtBottom, sessionId = state.currentId) {
state.lastReportedSize = { cols, rows };
if (!sessionId || sessionId !== state.currentId) return;
if (wasAtBottom) state.resizeFollowBottomUntil = performance.now() + 1500;
rpc("session.pty_resize", { session_id: sessionId, cols, rows })
.catch((e) => console.warn("pty_resize failed:", e));
}

function claimTerminalGeometryNow() {
refitTerminal({ claim: true, immediate: true });
}

function terminalIsAtBottom() {
const active = state.term && state.term.buffer && state.term.buffer.active;
return !!active && active.viewportY >= active.baseY;
Expand Down Expand Up @@ -2500,6 +2548,7 @@
* the user typed on-screen. */
function sendPtyBytes(s) {
if (!state.currentId || state.mode !== "terminal") return;
claimTerminalGeometryNow();
const data = encodeBase64Utf8(s);
rpc("session.pty_input", { session_id: state.currentId, data })
.catch((e) => console.warn("vkbd pty_input failed:", e));
Expand Down Expand Up @@ -3221,6 +3270,7 @@
resetInputHeight();
const pty = currentSessionHasPty();
if (pty) {
if (state.mode === "terminal") claimTerminalGeometryNow();
const payload = enter ? text + "\r" : text;
const data = encodeBase64Utf8(payload);
try {
Expand Down
52 changes: 49 additions & 3 deletions crates/e2e/tests/web_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,11 @@ async fn web_client_loads_and_websocket_connects() {
const oldCurrent = state.currentId;
const oldSize = state.lastReportedSize;
const oldFollow = state.resizeFollowBottomUntil;
const oldEngaged = state.webPtyEngagedUntil;
const oldTimer = state.ptyResizeTimer;
const oldPending = state.pendingPtyResize;
const oldRpc = rpc;
if (state.ptyResizeTimer) clearTimeout(state.ptyResizeTimer);
const calls = [];
rpc = async (method, params) => {
calls.push({ method, params });
Expand Down Expand Up @@ -591,7 +595,7 @@ async fn web_client_loads_and_websocket_connects() {
bottom.baseY = 80;
},
};
refitTerminal();
refitTerminal({ claim: true, immediate: true });
renderEvent({ type: 'pty', data: btoa('resize repaint') });
await new Promise((resolve) => requestAnimationFrame(resolve));

Expand Down Expand Up @@ -623,6 +627,7 @@ async fn web_client_loads_and_websocket_connects() {

const rowOnly = { viewportY: 20, baseY: 20 };
state.lastReportedSize = { cols: 100, rows: 40 };
state.webPtyEngagedUntil = 0;
state.resizeFollowBottomUntil = 0;
state.term = {
cols: 100,
Expand All @@ -645,12 +650,40 @@ async fn web_client_loads_and_websocket_connects() {
refitTerminal();
await new Promise((resolve) => requestAnimationFrame(resolve));

const activeRowOnly = { viewportY: 20, baseY: 20 };
state.lastReportedSize = { cols: 100, rows: 40 };
state.resizeFollowBottomUntil = 0;
state.term = {
cols: 100,
rows: 25,
buffer: { active: activeRowOnly },
scrollToBottom: () => {
calls.push('active-row-only-bottom');
activeRowOnly.viewportY = activeRowOnly.baseY;
},
write: (_chunk, cb) => {
calls.push('active-row-only-write');
if (cb) cb();
},
};
state.fitAddon = {
fit: () => {
calls.push('fit-active-row-only');
},
};
refitTerminal({ claim: true, immediate: true });
await new Promise((resolve) => requestAnimationFrame(resolve));

state.term = oldTerm;
state.fitAddon = oldFit;
state.mode = oldMode;
state.currentId = oldCurrent;
state.lastReportedSize = oldSize;
state.resizeFollowBottomUntil = oldFollow;
state.webPtyEngagedUntil = oldEngaged;
if (state.ptyResizeTimer) clearTimeout(state.ptyResizeTimer);
state.ptyResizeTimer = oldTimer;
state.pendingPtyResize = oldPending;
rpc = oldRpc;
return { calls, bottom, scrolledUp };
})()
Expand Down Expand Up @@ -679,13 +712,26 @@ async fn web_client_loads_and_websocket_connects() {
.any(|v| v.as_str() == Some("unexpected-bottom")),
"refit incorrectly forced a manually-scrolled terminal to bottom: {fit_scroll:?}"
);
let row_resize_count = calls
.iter()
.filter(|v| {
v["method"].as_str() == Some("session.pty_resize")
&& v["params"]["cols"].as_i64() == Some(100)
&& v["params"]["rows"].as_i64() == Some(25)
})
.count();
assert_eq!(
row_resize_count, 1,
"only the engaged row-only fit should send pty_resize: {fit_scroll:?}"
);
assert!(
!calls.iter().any(|v| {
calls.iter().any(|v| {
v["method"].as_str() == Some("session.pty_resize")
&& v["params"]["session_id"].as_str() == Some("s-fit")
&& v["params"]["cols"].as_i64() == Some(100)
&& v["params"]["rows"].as_i64() == Some(25)
}),
"row-only terminal fits should not send pty_resize/SIGWINCH: {fit_scroll:?}"
"engaged row-only terminal fits should claim matching PTY size: {fit_scroll:?}"
);
assert_eq!(
fit_scroll["bottom"]["viewportY"],
Expand Down
Loading