Improve mobile terminal input, selection, and resizing (#1607)
* Improve mobile terminal typing and selection * Address mobile terminal review feedback * Remove dead native terminal input branch * Replay bracketed paste in terminal preamble * Stop per-key terminal resize claims * Stabilize terminal resize browser test * Use tracked terminal mode for native paste * Restore terminal toolbar focus handling * Adapt native terminal to active visibility state * Stabilize native mobile terminal interactions * Add native terminal fallback boundary * test(app): expect explicit terminal resize reclaim * fix(app): stabilize terminal buffer coordinates * fix(app): restrict terminal resize ownership * fix(terminal): gate restored input modes * fix(app): recover stalled terminal WebViews * chore(app): remove obsolete terminal harnesses * fix(app): make terminal resize claims explicit * test(app): stabilize native terminal device flows * test(app): track absolute scrollback coordinates * fix(app): isolate terminal WebView streams * fix(terminal): arbitrate size ownership across clients The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it. * test(server): make worker resize assertion portable Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
import { waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
|
||||
interface TerminalSize {
|
||||
rows: number | null;
|
||||
@@ -24,8 +24,12 @@ async function readXtermSize(page: Page): Promise<TerminalSize> {
|
||||
// The PTY's own view of its size, reported by an `stty size` loop running in the
|
||||
// shell. This needs no focus or click, so it observes whether the daemon-side PTY
|
||||
// actually received the resize frame after the split.
|
||||
async function readLatestPtySize(page: Page): Promise<TerminalSize | null> {
|
||||
const text = await getTerminalBufferText(page);
|
||||
async function readLatestPtySize(
|
||||
harness: TerminalE2EHarness,
|
||||
terminalId: string,
|
||||
): Promise<TerminalSize | null> {
|
||||
const capture = await harness.client.captureTerminal(terminalId, { stripAnsi: true });
|
||||
const text = capture.lines.join("\n");
|
||||
const matches = [...text.matchAll(/PTYSIZE (\d+) (\d+)/g)];
|
||||
const last = matches.at(-1);
|
||||
if (!last) {
|
||||
@@ -42,16 +46,20 @@ async function readXtermRows(page: Page): Promise<number | null> {
|
||||
return (await readXtermSize(page)).rows;
|
||||
}
|
||||
|
||||
async function ptyRowsMatchXtermRows(page: Page): Promise<boolean> {
|
||||
async function ptyRowsMatchXtermRows(
|
||||
page: Page,
|
||||
harness: TerminalE2EHarness,
|
||||
terminalId: string,
|
||||
): Promise<boolean> {
|
||||
const xterm = await readXtermSize(page);
|
||||
const pty = await readLatestPtySize(page);
|
||||
const pty = await readLatestPtySize(harness, terminalId);
|
||||
return pty?.rows === xterm.rows;
|
||||
}
|
||||
|
||||
async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness): Promise<void> {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await withTerminalInApp(page, harness, { name: "split-resize" }, async () => {
|
||||
await withTerminalInApp(page, harness, { name: "split-resize" }, async (terminalInstance) => {
|
||||
await harness.setupPrompt(page);
|
||||
|
||||
const terminal = harness.terminalSurface(page);
|
||||
@@ -65,7 +73,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
|
||||
await waitForTerminalContent(page, hasPtySizeReport, 10_000);
|
||||
|
||||
const beforeXterm = await readXtermSize(page);
|
||||
const beforePty = await readLatestPtySize(page);
|
||||
const beforePty = await readLatestPtySize(harness, terminalInstance.id);
|
||||
expect(beforePty, "the PTY should report its size before splitting").not.toBeNull();
|
||||
expect(beforeXterm.rows, "xterm should report its row count before splitting").not.toBeNull();
|
||||
expect(beforePty?.rows, "while focused, the PTY size should already match the xterm size").toBe(
|
||||
@@ -89,7 +97,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
|
||||
// new pane. Today it stays stuck at the pre-split size until the terminal is
|
||||
// clicked back into focus. This poll fails without the fix.
|
||||
await expect
|
||||
.poll(() => ptyRowsMatchXtermRows(page), {
|
||||
.poll(() => ptyRowsMatchXtermRows(page, harness, terminalInstance.id), {
|
||||
message: "the PTY rows should match the resized terminal after split-down",
|
||||
timeout: 8_000,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { getTerminalBufferText } from "../support/helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
@@ -90,6 +89,14 @@ function parseLatestSttySize(bufferText: string): RenderedTerminalSize | null {
|
||||
return match?.[1] && match[2] ? { rows: Number(match[1]), cols: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
async function captureTerminalText(
|
||||
harness: TerminalE2EHarness,
|
||||
terminalId: string,
|
||||
): Promise<string> {
|
||||
const capture = await harness.client.captureTerminal(terminalId, { stripAnsi: true });
|
||||
return capture.lines.join("\n");
|
||||
}
|
||||
|
||||
test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
@@ -140,7 +147,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
data: 'echo "S0=$(stty size)="\n',
|
||||
});
|
||||
await expect
|
||||
.poll(async () => getTerminalBufferText(page), { timeout: 15_000 })
|
||||
.poll(async () => captureTerminalText(harness, newTerminalId), { timeout: 15_000 })
|
||||
.toMatch(/S0=24 80=/);
|
||||
|
||||
// ...and comes back.
|
||||
@@ -161,7 +168,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
type: "input",
|
||||
data: `echo "S${probe++}=$(stty size)="\n`,
|
||||
});
|
||||
return parseLatestSttySize(await getTerminalBufferText(page));
|
||||
return parseLatestSttySize(await captureTerminalText(harness, newTerminalId));
|
||||
},
|
||||
{ timeout: 15_000, intervals: [100, 250, 500] },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
context platform=android timeout=90000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 45000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "role=\"button\" label=\"New terminal\"" 10000
|
||||
press "role=\"button\" label=\"New terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "echo __AD_OUTPUT_OK__" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_OUTPUT_OK__" 10000
|
||||
close
|
||||
@@ -0,0 +1,13 @@
|
||||
context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_OUTPUT_OK__" 10000
|
||||
close
|
||||
@@ -0,0 +1,10 @@
|
||||
context platform=ios timeout=90000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"terminal-surface\"" 30000
|
||||
press "id=\"terminal-surface\""
|
||||
type "echo __AD_IOS_HARDWARE_OK__" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "__AD_IOS_HARDWARE_OK__" 10000
|
||||
close
|
||||
@@ -0,0 +1,19 @@
|
||||
context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"terminal-surface\"" 10000
|
||||
press "id=\"terminal-surface\""
|
||||
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "id=\"terminal-row-1\" label=\"AD_SCROLL_MARKER\"" 10000
|
||||
gesture pan 200 380 0 28 600
|
||||
wait "id=\"terminal-row-3\" label=\"AD_SCROLL_MARKER\"" 10000
|
||||
gesture pan 200 380 0 28 600
|
||||
wait "id=\"terminal-row-5\" label=\"AD_SCROLL_MARKER\"" 10000
|
||||
gesture pan 200 380 0 28 600
|
||||
wait "id=\"terminal-row-7\" label=\"AD_SCROLL_MARKER\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,21 @@
|
||||
context platform=android timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
wait "role=\"button\" label=\"New terminal\"" 10000
|
||||
press "role=\"button\" label=\"New terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "PASEO_TARGET" 10000
|
||||
longpress "label=\"PASEO_TARGET\"" 550
|
||||
wait "id=\"terminal-copy\"" 10000
|
||||
is visible "id=\"terminal-copy\""
|
||||
gesture pan 532 346 400 0 700
|
||||
is visible "id=\"terminal-copy\""
|
||||
gesture pan 532 346 -400 0 700
|
||||
is visible "id=\"terminal-copy\""
|
||||
close
|
||||
@@ -0,0 +1,20 @@
|
||||
context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
|
||||
press "id=\"terminal-key-enter\""
|
||||
wait "text" "PASEO_TARGET" 10000
|
||||
longpress "label=\"PASEO_TARGET\"" 550
|
||||
wait "id=\"terminal-copy\"" 10000
|
||||
is visible "id=\"terminal-copy\""
|
||||
gesture pan 198 167 170 0 700
|
||||
is visible "id=\"terminal-copy\""
|
||||
gesture pan 198 167 -170 0 700
|
||||
is visible "id=\"terminal-copy\""
|
||||
close
|
||||
@@ -160,8 +160,14 @@ export interface SeedDaemonClient {
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
sendTerminalInput(
|
||||
terminalId: string,
|
||||
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||
message:
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" },
|
||||
): void;
|
||||
captureTerminal(
|
||||
terminalId: string,
|
||||
options?: { start?: number; end?: number; stripAnsi?: boolean },
|
||||
): Promise<{ terminalId: string; lines: string[]; totalLines: number; requestId: string }>;
|
||||
onTerminalStreamEvent(
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
|
||||
Reference in New Issue
Block a user