diff --git a/src/core/jobs.ts b/src/core/jobs.ts index 1c469bd..0bd6aef 100644 --- a/src/core/jobs.ts +++ b/src/core/jobs.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { mkdir } from "node:fs/promises"; import { incompatibleShellReason, type ShellDialect } from "../config.js"; import type { TmuxClient } from "../tmux/client.js"; -import type { Job } from "../types.js"; +import type { Job, PaneState } from "../types.js"; import { jobLogPath, readLogTail, @@ -138,6 +138,10 @@ export function scrubOutput(lines: string[]): string[] { /** Finished jobs retained for late read/wait lookups before pruning. */ const MAX_FINISHED_JOBS = 100; +/** How long launch waits for a fresh pane's shell to print its prompt. */ +const SHELL_READY_TIMEOUT_MS = 2000; +const SHELL_READY_POLL_MS = 25; + /** * How much of a job's log tail to scan for the exit sentinel. The sentinel * prints at exit, so it sits within the last few lines of the stream — but a @@ -201,7 +205,7 @@ export class JobManager { command: string, forcedDialect: ShellDialect | null, ): Promise { - const state = await this.client.paneState(paneId); + const state = await this.awaitShellReady(paneId); // An unrecognized foreground command is not fatal — it is usually a // wrapper or a program the pane is running — and posix stays the safe // default. A shell that is known to be unable to evaluate the sentinel @@ -245,6 +249,31 @@ export class JobManager { return job; } + /** + * A brand-new pane reports a completely blank screen — cursor at origin, + * no history — until its shell prints the first prompt. Typing the command + * before that still works (the pty buffers input), but the pty's canonical + * echo then precedes the prompt, which arrives later and glues itself to + * the job's first output line (`sh-5.3$ 1`). That pollutes reads and log + * parsing, and the prompt lands inside the job's output slice because the + * baseline was taken before it printed. Wait for the prompt; a pane that + * stays blank past the cap proceeds anyway (some shells print nothing). + */ + private async awaitShellReady(paneId: string): Promise { + let state = await this.client.paneState(paneId); + const deadline = Date.now() + SHELL_READY_TIMEOUT_MS; + while ( + state.historySize === 0 && + state.cursorY === 0 && + state.cursorX === 0 && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, SHELL_READY_POLL_MS)); + state = await this.client.paneState(paneId); + } + return state; + } + /** * Open the pane's output pipe onto this job's log file. Best-effort: a * failure (unwritable state dir, tmux hiccup) returns null and the run diff --git a/src/tmux/formats.ts b/src/tmux/formats.ts index 6069139..254bec5 100644 --- a/src/tmux/formats.ts +++ b/src/tmux/formats.ts @@ -73,6 +73,7 @@ export const PANE_STATE_FORMAT = [ "#{history_size}", "#{history_limit}", "#{cursor_y}", + "#{cursor_x}", "#{pane_height}", "#{pane_current_command}", "#{pane_current_path}", @@ -80,7 +81,7 @@ export const PANE_STATE_FORMAT = [ export function parsePaneState(line: string): PaneState { const parts = line.replace(/\n$/, "").split(SEP); - if (parts.length < 6) { + if (parts.length < 7) { throw new Error(`unexpected pane state line: ${JSON.stringify(line)}`); } const field = (index: number): string => parts[index] ?? ""; @@ -88,10 +89,11 @@ export function parsePaneState(line: string): PaneState { historySize: Number.parseInt(field(0), 10), historyLimit: Number.parseInt(field(1), 10), cursorY: Number.parseInt(field(2), 10), - paneHeight: Number.parseInt(field(3), 10), - currentCommand: field(4), + cursorX: Number.parseInt(field(3), 10), + paneHeight: Number.parseInt(field(4), 10), + currentCommand: field(5), // pane_current_path may itself contain tabs in pathological cases; rejoin. - currentPath: parts.slice(5).join(SEP), + currentPath: parts.slice(6).join(SEP), }; } diff --git a/src/types.ts b/src/types.ts index 1790b94..818fecc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export interface PaneState { historySize: number; historyLimit: number; cursorY: number; + cursorX: number; paneHeight: number; currentCommand: string; currentPath: string; diff --git a/test/unit/formats.test.ts b/test/unit/formats.test.ts index 1d1815d..5e939df 100644 --- a/test/unit/formats.test.ts +++ b/test/unit/formats.test.ts @@ -10,11 +10,12 @@ import { describe("parsePaneState", () => { test("parses a tab-separated state line", () => { - const state = parsePaneState("1042\t2000\t12\t50\tzsh\t/home/tom/code\n"); + const state = parsePaneState("1042\t2000\t12\t7\t50\tzsh\t/home/tom/code\n"); expect(state).toEqual({ historySize: 1042, historyLimit: 2000, cursorY: 12, + cursorX: 7, paneHeight: 50, currentCommand: "zsh", currentPath: "/home/tom/code", @@ -22,7 +23,7 @@ describe("parsePaneState", () => { }); test("rejoins paths containing tabs", () => { - const state = parsePaneState("0\t2000\t0\t50\tbash\t/tmp/a\tb"); + const state = parsePaneState("0\t2000\t0\t7\t50\tbash\t/tmp/a\tb"); expect(state.currentPath).toBe("/tmp/a\tb"); }); diff --git a/test/unit/jobs.test.ts b/test/unit/jobs.test.ts index b23cfbc..b14d931 100644 --- a/test/unit/jobs.test.ts +++ b/test/unit/jobs.test.ts @@ -138,6 +138,7 @@ function stubClient(currentCommand = "sh"): TmuxClient { historySize: 0, historyLimit: 2000, cursorY: 0, + cursorX: 8, paneHeight: 30, currentCommand, currentPath: "/proj", @@ -166,6 +167,30 @@ describe("JobManager", () => { expect(sent).toMatch(/^cmd \| tee log; /); }); + test("launch waits for a fresh pane's first prompt before typing", async () => { + // A pane created moments ago reports a fully blank screen until the + // shell prints its prompt; typing early makes the late prompt glue to + // the job's first output line (github flake sidemux-pbg). + const blank = { + historySize: 0, + historyLimit: 2000, + cursorY: 0, + cursorX: 0, + paneHeight: 30, + currentCommand: "sh", + currentPath: "/proj", + }; + const ready = { ...blank, cursorX: 8 }; + const states = [blank, blank, ready]; + const client = stubClient(); + vi.mocked(client.paneState).mockImplementation( + async () => states.shift() ?? ready, + ); + await new JobManager(client).launch("%1", "echo hi", "posix"); + expect(client.paneState).toHaveBeenCalledTimes(3); + expect(client.sendLiteral).toHaveBeenCalledOnce(); + }); + test("launch refuses a pane running a shell that cannot report exit codes", async () => { // tcsh parses `$?` as a variable-existence test, so the sentinel never // prints and the job would hang until its timeout. Failing here is the @@ -274,6 +299,7 @@ describe("JobManager per-job log files", () => { historySize: 0, historyLimit: 2000, cursorY: 0, + cursorX: 8, paneHeight: 30, currentCommand: "sh", currentPath: "/proj",