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
4 changes: 4 additions & 0 deletions changelog.d/phone-input-reclaims-terminal-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed
- A phone showing a pane's terminal no longer types into a grid another device sized: a tap into
the input line, its Send or Enter, and a key-bar tap take the pane back to the phone's size
before anything is sent, as a tap on the grid already did.
55 changes: 53 additions & 2 deletions scripts/chat-size-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,8 @@ if (import.meta.main) {
try {
browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox", "--accept-lang=en-US"] });
const origin = `http://127.0.0.1:${server.port}`;
if (process.env.CHAT_SIZE_CASE) await checkInactiveAttachLifecycle(browser, origin);
if (process.env.CHAT_SIZE_CASE === "reclaim") await checkFocusedWindowReclaimsTerminalSize(browser, origin);
else if (process.env.CHAT_SIZE_CASE) await checkInactiveAttachLifecycle(browser, origin);
else {
await checkChatKeepsTerminalSize(browser, origin);
await checkPaneSwitchKeepsTerminalSize(browser, origin);
Expand Down Expand Up @@ -816,7 +817,7 @@ export async function checkFocusedWindowReclaimsTerminalSize(browser: Browser, o
await attached(desktop);
const desktopSize = await askedGrid(desktop);
await sizeBecomes(desktopSize, "the desktop took the pane at its own size");
const phone = await openRecording(browser, contexts, origin, paneId, { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }, settings, false, inFront);
const phone = await openRecording(browser, contexts, origin, paneId, { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }, { ...settings, terminalInputMode: "line" }, false, inFront);
await attached(phone);
const phoneSize = await askedGrid(phone);
await sizeBecomes(phoneSize, "the phone took the pane at its own size");
Expand All @@ -834,6 +835,56 @@ export async function checkFocusedWindowReclaimsTerminalSize(browser: Browser, o
await desktop.keyboard.press("Shift");
await sizeBecomes(desktopSize, "a key in the desktop terminal");
console.log("PASS a window that kept its focus takes the pane back on a click or a key in its terminal");

// The phone's own input sits outside its grid: the input line, its Send and Enter and the key
// bar. Each takes the pane back to the phone's grid, and the resize goes out before what it sends.
const [phoneRows, phoneCols] = phoneSize.split(" ").map(Number);
const outFrom = async (page: Page, from: number) => (await framesOf(page)).slice(from).filter((f) => f.dir === "out");
const resizedBefore = async (what: string, from: number, sends: readonly string[]) => {
const deadline = Date.now() + 10_000;
let out: Frame[] = [];
while (Date.now() < deadline) {
out = await outFrom(phone, from);
if (out.some((f) => sends.includes(f.type))) break;
await Bun.sleep(50);
}
const sent = out.findIndex((f) => sends.includes(f.type));
assert.ok(sent >= 0, `${what}: the phone sent none of ${sends.join("/")}: ${JSON.stringify(out)}`);
const resized = out.findIndex((f) => f.type === "resize" && f.cols === phoneCols && f.rows === phoneRows);
assert.ok(resized >= 0 && resized < sent, `${what}: the phone's own grid goes out before its input: ${JSON.stringify(out)}`);
await sizeBecomes(phoneSize, what);
};
// focus into the line: a tap there is the user on the phone, before a key is typed
let mark = (await framesOf(phone)).length;
await phone.locator(".terminal-input-text").tap();
const focusDeadline = Date.now() + 10_000;
while (Date.now() < focusDeadline) {
if ((await outFrom(phone, mark)).some((f) => f.type === "resize")) break;
await Bun.sleep(50);
}
assert.ok((await outFrom(phone, mark)).some((f) => f.type === "resize" && f.cols === phoneCols && f.rows === phoneRows), "a tap into the input line takes the pane back to the phone's grid");
await sizeBecomes(phoneSize, "a tap into the input line");
// the line keeps its focus from here on: no focus event helps the steps below
await desktop.keyboard.press("Shift");
await sizeBecomes(desktopSize, "a key in the desktop terminal, the phone's line focused");
await phone.keyboard.type("true");
mark = (await framesOf(phone)).length;
await phone.keyboard.press("Enter");
// the line's Enter follows its text after the server's gap: the size is asked once the line is in
await phone.waitForFunction(() => (document.querySelector(".terminal-input-text") as HTMLTextAreaElement | null)?.value === "", undefined, { timeout: 10_000 });
await resizedBefore("a line sent from the phone's input line", mark, ["submit", "input"]);
await desktop.keyboard.press("Shift");
await sizeBecomes(desktopSize, "a key in the desktop terminal, before the key bar");
mark = (await framesOf(phone)).length;
// Ctrl+C, not Esc: the shell would read Esc and the size command's first letter as one Meta key
await phone.locator(".key-bar").getByRole("button", { name: "Control C" }).tap();
await resizedBefore("a key-bar tap on the phone", mark, ["input", "keys"]);
await desktop.keyboard.press("Shift");
await sizeBecomes(desktopSize, "a key in the desktop terminal, before the empty line's Enter");
mark = (await framesOf(phone)).length;
await phone.locator(".terminal-input-send").tap();
await resizedBefore("Enter from the phone's empty input line", mark, ["input", "keys"]);
console.log("PASS the phone's input line, its Enter and the key bar take the pane back before they send");
} finally {
for (const context of contexts) await context.close().catch(() => undefined);
if (workspace) await workspaceClose(workspace).catch(() => undefined);
Expand Down
2 changes: 1 addition & 1 deletion src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ React 19 + xterm.js browser client. The render-state machine, xterm lifecycle an
| Terminal view/lifecycle | components/PaneTerminal.tsx + TerminalViewportTools.tsx + lib/terminalViewport.ts | one xterm per pane mount; `term.reset()` + re-attach on paneId change; `active` gates every automatic focus (including async uploads and prompt answers), and inactive siblings never take focus; theme + font size applied through `term.options` (no remount) and re-fit; the grid re-fits when the lens returns to terminal; ended / reconnecting pills only under the terminal lens (ChatView says them inline); viewport tools draw only native ranges validated against xterm's current cells, never reconstructed history. Automatic `jump:false` searches require an explicit response carrying `matches` and `scroll`, and no read-only/legacy bridge gets background search POSTs. The separate scrollbar calls herdr's shared `pane.scroll`; xterm remains at zero history. |
| Roles / observe mode | components/PaneTerminal.tsx | NO UI control — App connects as `interact` and never asks for another role; `role-ack` still applies the consequences if one arrives: stdin gate (`disableStdin`), `pane-geometry` adoption, force-refit + resize when returning to interact |
| Held-input draft | components/PaneTerminal.tsx + lib/draft.ts | input during a disconnect accumulates in a draft (printable only); the review banner offers Send/Discard after reconnect |
| Geometry/fit | components/PaneTerminal.tsx | fit on attach, ResizeObserver on the host, `focus`/`visibilitychange` refit, and a click or key in the terminal while another device's grid holds the pty (`reclaim`: a window that kept its focus gets no focus event) — all SKIPPED while observing; only a tab in use (`inUse`: visible AND `document.hasFocus()`) resizes the pty from the observer, the refit, `role-ack`, a font load or the lens return; out of use, a pane attach (a reload, the next pane after one closed) goes with `keep_size`, and blur or hidden marks `keepSize` so a background reconnect adopts the grid: a window left behind another app must not take the pane from a phone or herdr's own TUI; with `settings.releasePaneAway` on (off by default), after `RELEASE_AFTER_MS` out of use (from a blur, hidden, or a pane attach made out of use) it also detaches (`released`), and a touch screen (`coarse`) that goes hidden does so at once whatever the setting, since a phone's page can freeze before the timer runs, because herdr holds a pane at an attach's size until that attach ends and its TUI cannot take it back, and watches the pane instead (`socket.watch`, feature "watch": a read-only view at this grid that leaves the pane's size alone, written as it comes; a grid change starts it again; banner "View only…"); a server without the feature, or a `watch-end`, leaves it paused on its last screen. It attaches again on focus or a pointerdown (unwatch first); a released tab moving to another pane watches that one; it keeps the pane while a message it queued waits (`queueWaits`), and never releases a mirrored pane or an embedded page (the site's demo). `HerdrSocket.resize` sends nothing for a pane it is not attached to: the server would resize the holder's attach; single-finger touch drag becomes synthetic line-mode wheel events, one per `HERDR_MOUSE_SCROLL_LINES` rows of finger travel gathered over the gesture, plus one at touchend when at least half a step is left (`finalStep`) (lib/wheelSteps.ts, shared with a selecting drag's wheel; xterm sends one report per wheel event whatever its delta) (listeners `passive: false` only there) |
| Geometry/fit | components/PaneTerminal.tsx | fit on attach, ResizeObserver on the host, `focus`/`visibilitychange` refit, and a click or key in the terminal while another device's grid holds the pty (`reclaim`: a window that kept its focus gets no focus event), and likewise the input line's focus, its Send or Enter and a key-bar tap, before they send (`reassertIfStale`) — all SKIPPED while observing; only a tab in use (`inUse`: visible AND `document.hasFocus()`) resizes the pty from the observer, the refit, `role-ack`, a font load or the lens return; out of use, a pane attach (a reload, the next pane after one closed) goes with `keep_size`, and blur or hidden marks `keepSize` so a background reconnect adopts the grid: a window left behind another app must not take the pane from a phone or herdr's own TUI; with `settings.releasePaneAway` on (off by default), after `RELEASE_AFTER_MS` out of use (from a blur, hidden, or a pane attach made out of use) it also detaches (`released`), and a touch screen (`coarse`) that goes hidden does so at once whatever the setting, since a phone's page can freeze before the timer runs, because herdr holds a pane at an attach's size until that attach ends and its TUI cannot take it back, and watches the pane instead (`socket.watch`, feature "watch": a read-only view at this grid that leaves the pane's size alone, written as it comes; a grid change starts it again; banner "View only…"); a server without the feature, or a `watch-end`, leaves it paused on its last screen. It attaches again on focus or a pointerdown (unwatch first); a released tab moving to another pane watches that one; it keeps the pane while a message it queued waits (`queueWaits`), and never releases a mirrored pane or an embedded page (the site's demo). `HerdrSocket.resize` sends nothing for a pane it is not attached to: the server would resize the holder's attach; single-finger touch drag becomes synthetic line-mode wheel events, one per `HERDR_MOUSE_SCROLL_LINES` rows of finger travel gathered over the gesture, plus one at touchend when at least half a step is left (`finalStep`) (lib/wheelSteps.ts, shared with a selecting drag's wheel; xterm sends one report per wheel event whatever its delta) (listeners `passive: false` only there) |
| Key bar | components/KeyBar.tsx + KeyBarSettings.tsx + lib/keyBar.ts + lib/keys.ts | `settings.keyBarItems` owns the complete ordered row; absent legacy records migrate from `keyBarExtras`, explicit empty lists stay empty. Ordinary keys inherit held modifiers; configured combinations are exact. Taps go through `term.input()` into the guarded onData path. PaneTerminal owns sticky Ctrl/Alt/Shift, clearing state on pane/lens change, disconnect or modifier removal; hidden while observing |
| Chat composer | components/Composer.tsx + lib/compose.ts + lib/mentions.ts + lib/modelName.ts + PendingMessages.tsx | the model pill shows a name only for an id `modelLabel` matches whole, else the id as received in mono: never add a guessed name; Enter or Mod+Enter sends per `settings.enterSends`, IME Enter never sends; `/` commands cache 60s per pane, `@` files debounce 150ms; one Send/Stop control for every agent, no Queue button/menu; working Send uses `socket.submit` queue delivery only with `pending-input`, otherwise keeps the draft; pending rows offer an explicit Send now action on server IDs, retained uncertain text never retries; Stop is Escape via `onAbort`, also from the box's own Escape while a draft shows Send in its place |
| Chat lens | components/ChatView.tsx + lib/conversationRefresh.ts + Markdown.tsx (lib/markdown.ts) + lib/workBlocks.ts + lib/toolVerbs.ts + PromptCard.tsx | `splitTurn` folds tools/thinking/narration into one `.work-block` and honors explicit Codex commentary/final phases; a row is verb + object by exact tool id (`toolVerb`), the id kept in its title and opened detail; duration = `end_ts − ts`; only mounted visible Chat subscribes to `conversation-watch` on its attached machine-bound socket; `conversation-changed` uses a serial coordinator with push reads at least 2s apart, a visible-only 10s backstop (2s while `conversationPushes` is false: an older bridge or a released pane) and immediate initial/send/visibility/reconnect refresh; a visible scrollback stand-in is invalidated by the pane's `pty-data` through a ref listener (`onTerminalOutput`), never by React state per frame; newest and older pages share the coordinator; a send refresh preserves history/scroll, a pane change resets; ChatView polls `/api/pane/prompt` every 2s whatever the pane's status (a menu can wait while herdr reports idle: Codex's trust check, pi's dialogs, Claude Code's `/model` list) and owns the prompt; PromptCard answers through `/api/pane/prompt/answer` (409 → re-read) and is drawn through a portal into PaneTerminal's `.prompt-dock`, over the input card and outside the transcript |
Expand Down
25 changes: 21 additions & 4 deletions src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ export function PaneTerminal({
const fixedGridRef = useRef(false);
// the pty's grid as the server last said it for this pane (pane-geometry), whoever set it
const sharedGridRef = useRef<{ cols: number; rows: number } | null>(null);
// takes the pty back to this grid when another device left it at its own (the effect below sets it)
const reassertIfStaleRef = useRef<() => void>(() => undefined);
// out of use, the tab let go of its pane (no attach) so herdr's own TUI has it back; it attaches
// again when the user is back. The ref is what the socket handlers and the pane switch read
const releasedRef = useRef(false);
Expand Down Expand Up @@ -1654,13 +1656,21 @@ export function PaneTerminal({
// Only input meant for the terminal itself: a press or key in the find bar, on the scrollbar
// or the key bar, a right-click for the pane menu, a held key's repeats or an IME composition
// must not resize the pty under the other device.
const reclaim = (event: Event): void => {
// A phone's own input paths sit outside the grid (the input line, its Send and Enter, a key the
// key bar sends, not a press on it), so they call this before they send: the reply is then
// drawn for the grid that shows it.
const reassertIfStale = (): void => {
const shared = sharedGridRef.current;
if (!shared || releasedRef.current || !inUse() || !(event.target instanceof Node) || !term.element?.contains(event.target)) return;
if (!shared || releasedRef.current || !inUse()) return;
if (shared.cols !== term.cols || shared.rows !== term.rows) refit();
};
reassertIfStaleRef.current = reassertIfStale;
const reclaim = (event: Event): void => {
if (!(event.target instanceof Node) || !term.element?.contains(event.target)) return;
// an IME's first key comes before compositionstart, with isComposing still false (keyCode 229)
if (event instanceof KeyboardEvent && (event.repeat || event.isComposing || event.keyCode === 229 || composingRef.current || compositionCommitPendingRef.current)) return;
if (event instanceof PointerEvent && event.button !== 0) return;
if (shared.cols !== term.cols || shared.rows !== term.rows) refit();
reassertIfStale();
};
// a click or a tap is the user here, whatever the window says about its focus
const onPointer = (event: PointerEvent): void => {
Expand Down Expand Up @@ -1704,6 +1714,7 @@ export function PaneTerminal({
window.removeEventListener("blur", leave);
window.removeEventListener("pointerdown", onPointer, { capture: true });
window.removeEventListener("keydown", reclaim, { capture: true });
reassertIfStaleRef.current = () => undefined;
document.removeEventListener("visibilitychange", onVisibility);
onModifiedEnter.dispose();
onCommandBackspace.dispose();
Expand Down Expand Up @@ -1948,6 +1959,7 @@ export function PaneTerminal({
const input = (modified ? navigationSequence(item.key, active) : null)
?? keyBarInputSequence(item.key, term.modes.applicationCursorKeysMode);
if (input === null) return;
reassertIfStaleRef.current();
barKeyRef.current = { key: item.key, chord };
try { term.input(input); } finally { barKeyRef.current = null; }
// with the input line, the keyboard belongs to it: a key tap must not move it to the grid
Expand Down Expand Up @@ -2054,6 +2066,7 @@ export function PaneTerminal({
if (!term || !socket || pane === null || secretRef.current !== null) return false;
const message = composerMessage(text);
const payload = message.includes("\n") ? composerPayload(text, term.modes.bracketedPasteMode) : message;
reassertIfStaleRef.current();
const sent = socket.submit(pane, message, payload, true);
if (sent === null) return false;
const releaseSubmit = submitRetention?.retain(pane);
Expand All @@ -2064,10 +2077,14 @@ export function PaneTerminal({
});
}, [submitRetention]);

// a tap into the input line is the user here: the grid is theirs again before they type
const reassertGrid = useCallback(() => { reassertIfStaleRef.current(); }, []);

const pressEnter = useCallback((): boolean => {
const socket = socketRef.current;
const pane = paneRef.current;
if (!socket || pane === null || !socket.connected) return false;
reassertIfStaleRef.current();
const sent = hasModifiers(modifiersRef.current)
? socket.sendKeys(pane, [terminalChord("Enter", modifiersRef.current)!]) : socket.sendInput(pane, "\r");
termRef.current?.scrollToBottom();
Expand Down Expand Up @@ -2668,7 +2685,7 @@ export function PaneTerminal({
slot in the chat lens too (a box-less slot there, TerminalInput.css), so switching lenses
keeps it mounted: a remount would drop what was typed and a send still on its way */}
{((!chatView && lineSlot) || secretInput) && <div ref={setInputSlot} className={`terminal-input-slot${!chatView && lineSlot ? "" : " is-empty"}`}>
{secretInput ?? (!chatView && lineSlot && !secretActive && <TerminalInput key={paneId} active={active} owner={paneStorageId(machineId, paneId)} onComposing={setLineComposing} connected={connected && !held} onSend={sendTerminalLine} onEnter={pressEnter} />)}
{secretInput ?? (!chatView && lineSlot && !secretActive && <TerminalInput key={paneId} active={active} owner={paneStorageId(machineId, paneId)} onComposing={setLineComposing} connected={connected && !held} onSend={sendTerminalLine} onEnter={pressEnter} onFocus={reassertGrid} />)}
</div>}
{presented && paneId !== null && !secretActive && !observing && !chatView && <KeyBar disabled={composing || !connected || !inputReady || held || ended} onKey={pressKey}
holdScope={`${paneStorageId(machineId, paneId)}:${view}`}
Expand Down
Loading
Loading