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
51 changes: 51 additions & 0 deletions apps/desktop/src/main/terminal-host/session-shell-ready.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,57 @@ describe("Session shell-ready: kill/exit before readiness", () => {
});
});

/** Wait for the emulator write queue to drain (uses setImmediate internally). */
function waitForEmulatorFlush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 50));
}

describe("Session shell-ready: DA1 query response forwarding (#3028)", () => {
it("forwards headless emulator DA1 response to subprocess during pending state", async () => {
// Fish shell sends DA1 (ESC[c) at startup to detect terminal capabilities.
// The headless emulator generates a response (ESC[?1;2c) via xterm.js.
// This response MUST be forwarded to the subprocess even during the
// "pending" shell-ready state, otherwise fish waits 10s and then
// disables optional features like cursor shape and reflow detection.
const { session, proc } = createTestSession("/usr/local/bin/fish");
spawnAndReady(session, proc);

// Simulate PTY output containing a DA1 query from fish.
// When the headless emulator processes this, xterm.js generates
// a DA1 response via its onData callback.
sendData(proc, "\x1b[c");

// The emulator write queue processes via setImmediate, so we need
// to let the event loop tick for xterm to process the query.
await waitForEmulatorFlush();

// The emulator's DA1 response should have been forwarded to the
// subprocess (written to stdin) even though shell is still pending.
const writes = getWrittenData(proc);

// The response should contain a DA1 reply (ESC[?...c format)
expect(writes.length).toBeGreaterThan(0);
const da1Response = writes.join("");
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ESC in terminal protocol data
expect(da1Response).toMatch(/\x1b\[\?[\d;]+c/);
});

it("forwards DSR response to subprocess during pending state", async () => {
const { session, proc } = createTestSession("/bin/zsh");
spawnAndReady(session, proc);

// DSR (Device Status Report): ESC[5n → ESC[0n (terminal OK)
sendData(proc, "\x1b[5n");

await waitForEmulatorFlush();

const writes = getWrittenData(proc);
expect(writes.length).toBeGreaterThan(0);
const response = writes.join("");
expect(response).toContain("\x1b[0n");
});
});

describe("Session shell-ready: supported shells", () => {
for (const shell of [
"/bin/zsh",
Expand Down
15 changes: 9 additions & 6 deletions apps/desktop/src/main/terminal-host/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,18 +224,21 @@ export class Session {
// Set initial CWD
this.emulator.setCwd(options.cwd);

// The headless emulator responds to terminal queries (e.g. DA)
// when no renderer client is attached. During shell init we drop
// these — they'd land in the pre-ready stdin queue and appear as
// typed text like "?62;4;9;22c" once flushed. After a client
// attaches the renderer's xterm handles all terminal queries.
// The headless emulator responds to terminal queries (e.g. DA1,
// DSR) when no renderer client is attached. These responses must
// be forwarded to the subprocess immediately — even during shell
// init — because shells like fish send DA1 at startup and wait
// up to 10 seconds for a reply before disabling optional features.
// Unlike renderer-generated responses (which go through write()
// and are correctly dropped during init to avoid appearing as
// typed text), headless emulator responses are written directly
// to the PTY and consumed by the shell as protocol data.
this.emulator.onData((data) => {
if (
this.attachedClients.size === 0 &&
this.subprocess &&
this.subprocessReady
) {
if (this.shellReadyState === "pending") return;
this.sendWriteToSubprocess(data);
}
});
Expand Down
Loading