Skip to content

Commit 3bb88d5

Browse files
committed
fix(desktop): answer the bundled ConPTY's startup query so Windows shells start at once
The bundled ConPTY sends a device-attributes query as it starts and holds the shell's output for three seconds when nothing answers, which an agent's shell (no panel) never does. The session answers that first query as xterm.js would and keeps it from the panel, so it is answered once.
1 parent 40841c9 commit 3bb88d5

2 files changed

Lines changed: 67 additions & 7 deletions

File tree

‎apps/desktop/src/main/terminal/session.test.ts‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -293,19 +293,57 @@ describe('TerminalSession command lifecycle', () => {
293293
describe('TerminalSession on Windows', () => {
294294
const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007`
295295

296-
function windowsSession(): TerminalSession {
296+
function sessionOn(
297+
platform: NodeJS.Platform,
298+
onData: (terminalId: string, data: string) => void = () => {}
299+
): TerminalSession {
297300
return TerminalSession.create({
298301
terminalId: 'terminal-windows',
299302
cwd: 'C:\\Users\\me',
300303
cols: 80,
301304
rows: 24,
302-
platform: 'win32',
303-
callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} },
305+
platform,
306+
callbacks: { onData, onState: () => {}, onCommand: () => {}, onExit: () => {} },
304307
})
305308
}
306309

310+
const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c'
311+
312+
it("answers ConPTY's startup query once, which otherwise holds the shell back for 3 s", async () => {
313+
vi.useFakeTimers()
314+
let painted = ''
315+
const session = sessionOn('win32', (_id, data) => {
316+
painted += data
317+
})
318+
try {
319+
ptyStub.dataHandler?.('\u001b[1t')
320+
ptyStub.dataHandler?.('\u001b[c\u001b[?1004h')
321+
// A program's own query later on is the panel's to answer.
322+
ptyStub.dataHandler?.('\u001b[c')
323+
await vi.advanceTimersByTimeAsync(50)
324+
325+
expect(ptyStub.writes.filter((write) => write === DEVICE_ATTRIBUTES_REPLY)).toHaveLength(1)
326+
expect(painted).toBe('\u001b[1t\u001b[?1004h\u001b[c')
327+
} finally {
328+
session.dispose()
329+
}
330+
})
331+
332+
it('leaves the startup query of a macOS shell to the panel', async () => {
333+
vi.useFakeTimers()
334+
const session = sessionOn('darwin')
335+
try {
336+
ptyStub.dataHandler?.('\u001b[c')
337+
await vi.advanceTimersByTimeAsync(50)
338+
339+
expect(ptyStub.writes).not.toContain(DEVICE_ATTRIBUTES_REPLY)
340+
} finally {
341+
session.dispose()
342+
}
343+
})
344+
307345
it('starts the shell without the POSIX login flag, which Windows shells reject', () => {
308-
const session = windowsSession()
346+
const session = sessionOn('win32')
309347
try {
310348
expect(ptyStub.args).not.toContain('-l')
311349
} finally {
@@ -315,7 +353,7 @@ describe('TerminalSession on Windows', () => {
315353

316354
it('ends the shell for a terminating signal, which node-pty refuses to send there', () => {
317355
ptyStub.signalsSupported = false
318-
const session = windowsSession()
356+
const session = sessionOn('win32')
319357
try {
320358
session.kill('SIGTERM')
321359
expect(ptyStub.ended).toBe(true)
@@ -325,7 +363,7 @@ describe('TerminalSession on Windows', () => {
325363
})
326364

327365
it('names the tab after the last folder of a Windows path', () => {
328-
const session = windowsSession()
366+
const session = sessionOn('win32')
329367
try {
330368
ptyStub.dataHandler?.(marker('P;Cwd=D:\\\\work\\\\project'))
331369
expect(session.tabState(true).title).toBe('project')

‎apps/desktop/src/main/terminal/session.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,15 @@ const ALT_SCREEN_ENTER = '\u001b[?1049h'
220220
/** Restoring the normal screen: the full-screen program has quit. */
221221
const ALT_SCREEN_EXIT = '\u001b[?1049l'
222222

223+
/**
224+
* The bundled ConPTY asks the terminal for its device attributes as it starts and holds the
225+
* shell's first output until it hears back, for three seconds when nothing answers. The session
226+
* answers that one query itself, as xterm.js would, and keeps it from the panel so the reply is
227+
* never sent twice; an agent's shell has no panel to answer it at all.
228+
*/
229+
const DEVICE_ATTRIBUTES_QUERY = '\u001b[c'
230+
const DEVICE_ATTRIBUTES_REPLY = '\u001b[?1;2c'
231+
223232
/**
224233
* Scrollback the on-demand emulator keeps while rendering a read.
225234
*
@@ -356,6 +365,8 @@ export class TerminalSession {
356365
private startupBegunAt: number | null = null
357366
private readonly spawnedAt = Date.now()
358367
private altScreen = false
368+
/** Whether ConPTY's startup query is still to be answered. */
369+
private conptyQueryPending: boolean
359370
private foregroundCommand: string | null = null
360371
private foregroundToolCallId: string | null = null
361372
/**
@@ -390,6 +401,7 @@ export class TerminalSession {
390401
this.integrationDir = integrationDir
391402
this.shellName = shellName
392403
this.parser = new ShellIntegrationParser(nonce)
404+
this.conptyQueryPending = platform === 'win32'
393405

394406
this.pty.onData((chunk) => this.handleData(chunk))
395407
this.pty.onExit(() => this.handleExit())
@@ -865,7 +877,7 @@ export class TerminalSession {
865877
}
866878

867879
private handleData(chunk: string): void {
868-
const { text, markers } = this.parser.parse(chunk)
880+
const { text, markers } = this.parser.parse(this.answerConptyQuery(chunk))
869881

870882
for (const marker of markers) {
871883
this.applyMarker(marker)
@@ -890,6 +902,16 @@ export class TerminalSession {
890902
}
891903
}
892904

905+
/** Answers and removes ConPTY's startup device-attributes query, the first time it appears. */
906+
private answerConptyQuery(chunk: string): string {
907+
if (!this.conptyQueryPending) return chunk
908+
const index = chunk.indexOf(DEVICE_ATTRIBUTES_QUERY)
909+
if (index === -1) return chunk
910+
this.conptyQueryPending = false
911+
this.pty.write(DEVICE_ATTRIBUTES_REPLY)
912+
return chunk.slice(0, index) + chunk.slice(index + DEVICE_ATTRIBUTES_QUERY.length)
913+
}
914+
893915
private applyMarker(
894916
marker: ReturnType<ShellIntegrationParser['parse']>['markers'][number]
895917
): void {

0 commit comments

Comments
 (0)