Skip to content

Commit 9fa4316

Browse files
committed
fix(desktop): type the agent's next command only once the prompt is drawn
Git Bash drops input typed while it is still switching from a native program back to its prompt, so a command run straight after a stopped one vanished. bash now reports its prompt's end (OSC 633 B, drawn by readline at the end of PS1), as PowerShell already did, and a run into a shell that reports it waits for the prompt, at most 2 s. The bash init file also sets PROMPT_COMMAND before its DEBUG trap, so that assignment is no longer announced as a command at startup.
1 parent 3680790 commit 9fa4316

5 files changed

Lines changed: 119 additions & 3 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,6 +1390,7 @@ export class TerminalService {
13901390
if (!session.hasShellIntegration) {
13911391
await this.awaitShellStartup(session, latch)
13921392
}
1393+
await session.waitForPromptDrawn(latch.signal)
13931394
if (session.isBusy) {
13941395
throw new TerminalError(
13951396
'BUSY',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ vi.mock('@/main/terminal/session', async () => {
184184
if (signal === 'SIGINT' && state.interruptible) finishRun(130)
185185
}),
186186
waitForShellIntegration: async () => {},
187+
waitForPromptDrawn: async () => {},
187188
/** Stands in for the user running `exit` or pressing Ctrl-D. */
188189
exit: () => {
189190
state.disposed = true

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,68 @@ describe('TerminalSession command lifecycle', () => {
290290
})
291291
})
292292

293+
describe('waiting for the prompt before typing a command', () => {
294+
const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007`
295+
296+
function promptSession(): TerminalSession {
297+
return TerminalSession.create({
298+
terminalId: 'terminal-prompt-drawn',
299+
cwd: '/tmp',
300+
cols: 80,
301+
rows: 24,
302+
callbacks: { onData: () => {}, onState: () => {}, onCommand: () => {}, onExit: () => {} },
303+
})
304+
}
305+
306+
async function settledAfter(promise: Promise<unknown>, ms: number): Promise<boolean> {
307+
let settled = false
308+
void promise.then(() => {
309+
settled = true
310+
})
311+
await vi.advanceTimersByTimeAsync(ms)
312+
return settled
313+
}
314+
315+
it('waits, in a shell that reports it, until the next prompt is drawn', async () => {
316+
vi.useFakeTimers()
317+
const session = promptSession()
318+
try {
319+
ptyStub.dataHandler?.(`${marker('A')}$ ${marker('B')}`)
320+
// The last command just finished: its prompt is not drawn yet.
321+
ptyStub.dataHandler?.(`${marker('D;0')}${marker('A')}`)
322+
const drawn = session.waitForPromptDrawn()
323+
324+
expect(await settledAfter(drawn, 500)).toBe(false)
325+
ptyStub.dataHandler?.(`$ ${marker('B')}`)
326+
expect(await settledAfter(drawn, 0)).toBe(true)
327+
} finally {
328+
session.dispose()
329+
}
330+
})
331+
332+
it('never waits on a shell that does not report its prompt', async () => {
333+
vi.useFakeTimers()
334+
const session = promptSession()
335+
try {
336+
ptyStub.dataHandler?.(`${marker('D;0')}${marker('A')}`)
337+
expect(await settledAfter(session.waitForPromptDrawn(), 0)).toBe(true)
338+
} finally {
339+
session.dispose()
340+
}
341+
})
342+
343+
it('types anyway once a prompt that reports itself is slow to come', async () => {
344+
vi.useFakeTimers()
345+
const session = promptSession()
346+
try {
347+
ptyStub.dataHandler?.(`${marker('A')}$ ${marker('B')}${marker('A')}`)
348+
expect(await settledAfter(session.waitForPromptDrawn(), 2_100)).toBe(true)
349+
} finally {
350+
session.dispose()
351+
}
352+
})
353+
})
354+
293355
describe('TerminalSession on Windows', () => {
294356
const marker = (body: string) => `\u001b]633;${body};test-nonce\u0007`
295357

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ const KEYSTROKE_GAP_MS = 150
246246
*/
247247
const KEYSTROKE_SETTLE_MAX_MS = 1_000
248248

249+
/** Cap on waiting for a shell's prompt to be drawn before typing into it anyway. */
250+
const PROMPT_DRAW_MAX_MS = 2_000
251+
249252
interface PendingCommand {
250253
command: string
251254
toolCallId: string
@@ -374,8 +377,12 @@ export class TerminalSession {
374377
private pendingCommand: PendingCommand | null = null
375378
/** Command line reported by the shell but not yet bracketed by output-start. */
376379
private announcedCommand: string | null = null
377-
/** Pending shell-readiness waits, rechecked whenever the shell's startup moves on. */
380+
/** Pending readiness and prompt waits, rechecked whenever startup moves on or a prompt is drawn. */
378381
private readonly readinessWaiters = new Set<() => void>()
382+
/** Whether the shell says when its prompt is drawn (`B`); bash and PowerShell do, zsh does not. */
383+
private reportsPromptEnd = false
384+
/** Whether the prompt is drawn and reading input, as far as the shell has said. */
385+
private promptDrawn = false
379386

380387
private constructor(
381388
options: TerminalSessionOptions,
@@ -600,6 +607,31 @@ export class TerminalSession {
600607
return now - this.startupBegunAt >= bounds.startingMs ? 'starting' : null
601608
}
602609

610+
/**
611+
* Resolves once the prompt is drawn and reading input, for a shell that reports it; at once for
612+
* one that does not. Git Bash drops what is typed while it is still switching from a native
613+
* program back to its prompt, so a command typed straight after the last one finished can vanish.
614+
* Also resolves after {@link PROMPT_DRAW_MAX_MS}, on exit, or when `signal` aborts.
615+
*/
616+
waitForPromptDrawn(signal?: AbortSignal): Promise<void> {
617+
return new Promise((resolve) => {
618+
let timer: NodeJS.Timeout | null = null
619+
const done = () => {
620+
if (timer) clearTimeout(timer)
621+
this.readinessWaiters.delete(check)
622+
signal?.removeEventListener('abort', done)
623+
resolve()
624+
}
625+
const check = () => {
626+
if (this.promptDrawn || !this.reportsPromptEnd || this.disposed) done()
627+
}
628+
timer = setTimeout(done, PROMPT_DRAW_MAX_MS)
629+
this.readinessWaiters.add(check)
630+
signal?.addEventListener('abort', done)
631+
check()
632+
})
633+
}
634+
603635
private notifyReadinessWaiters(): void {
604636
for (const check of [...this.readinessWaiters]) check()
605637
}
@@ -901,7 +933,13 @@ export class TerminalSession {
901933
this.notifyReadinessWaiters()
902934
}
903935
break
936+
case 'prompt-end':
937+
this.reportsPromptEnd = true
938+
this.promptDrawn = true
939+
this.notifyReadinessWaiters()
940+
break
904941
case 'prompt-start':
942+
this.promptDrawn = false
905943
if (!this.shellIntegration) {
906944
this.shellIntegration = true
907945
this.emitState()
@@ -912,6 +950,7 @@ export class TerminalSession {
912950
this.announcedCommand = marker.command
913951
break
914952
case 'output-start': {
953+
this.promptDrawn = false
915954
if (this.pendingCommand) {
916955
this.pendingCommand.capturing = true
917956
break

‎apps/desktop/src/main/terminal/shell-integration.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export function createNonce(): string {
4545

4646
/**
4747
* Marker kinds we act on. `A` (prompt start) doubles as the "integration is
48-
* live" signal; `C`/`D` bracket a command's output; `E` reports the exact
48+
* live" signal; `B` (prompt end) says the prompt is drawn and reading input;
49+
* `C`/`D` bracket a command's output; `E` reports the exact
4950
* command line; `P` tracks the working directory across `cd`. `SimStartup` is
5051
* ours, not VS Code's: the shell sends it before running the user's startup
5152
* files, so a shell that is still starting can be told from one we never
@@ -54,6 +55,7 @@ export function createNonce(): string {
5455
export type ShellMarker =
5556
| { kind: 'startup' }
5657
| { kind: 'prompt-start' }
58+
| { kind: 'prompt-end' }
5759
| { kind: 'command-line'; command: string }
5860
| { kind: 'output-start' }
5961
| { kind: 'output-end'; exitCode: number }
@@ -145,6 +147,8 @@ export class ShellIntegrationParser {
145147
return { kind: 'startup' }
146148
case 'A':
147149
return { kind: 'prompt-start' }
150+
case 'B':
151+
return { kind: 'prompt-end' }
148152
case 'C':
149153
return { kind: 'output-start' }
150154
case 'D': {
@@ -310,8 +314,17 @@ __sim_precmd() {
310314
return $st
311315
}
312316
317+
# The prompt-end marker rides at the end of PS1, so it is drawn by readline itself, once the
318+
# prompt is up and reading. Appended after the user's PROMPT_COMMAND, which may rebuild PS1.
319+
__sim_prompt_end() {
320+
local st=$? marker="\\[\\e]633;B;\${__sim_nonce}\\a\\]"
321+
case "$PS1" in *"$marker") ;; *) PS1="$PS1$marker" ;; esac
322+
return $st
323+
}
324+
325+
# Set before the trap, so assigning it is not reported as a command.
326+
PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}; __sim_prompt_end"
313327
trap '__sim_preexec' DEBUG
314-
PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
315328
`
316329
)
317330
return rcPath

0 commit comments

Comments
 (0)