|
34 | 34 |
|
35 | 35 | const COMMAND_OUTPUT_LIMIT = 10_000 |
36 | 36 | const promptIdentifier = '@36261@' |
| 37 | +const cwdIdentifier = '@76593@' |
37 | 38 |
|
38 | 39 | type PersistentProcess = |
39 | 40 | | { |
@@ -339,8 +340,122 @@ export const runTerminalCommand = async ( |
339 | 340 |
|
340 | 341 | const echoLinePattern = new RegExp(`${promptIdentifier}[^\n]*\n`, 'g') |
341 | 342 | const commandDonePattern = new RegExp( |
342 | | - `^${promptIdentifier}(.*)${promptIdentifier}[\\s\\S]*${promptIdentifier}` |
| 343 | + `^${cwdIdentifier}(.*)${cwdIdentifier}[\\s\\S]*${promptIdentifier}` |
343 | 344 | ) |
| 345 | +/** |
| 346 | + * Executes a single command in a PTY process and returns the result when complete. |
| 347 | + * |
| 348 | + * This function handles the low-level details of running a command in a pseudo-terminal, |
| 349 | + * including parsing the output to separate command echoes from actual output, detecting |
| 350 | + * command completion, and extracting the exit code and final working directory. |
| 351 | + * |
| 352 | + * @param ptyProcess - The IPty instance to execute the command in |
| 353 | + * @param command - The shell command to execute |
| 354 | + * @param cwd - The working directory to change to before executing the command (relative to project root) |
| 355 | + * @param onChunk - Callback function called for each chunk of output as it's received |
| 356 | + * |
| 357 | + * @returns Promise that resolves with: |
| 358 | + * - `commandOutput`: The complete output from the command (excluding echo lines) |
| 359 | + * - `finalCwd`: The working directory after command execution |
| 360 | + * - `exitCode`: The command's exit code (0 for success, non-zero for failure, null if os is Windows) |
| 361 | + * |
| 362 | + * @example |
| 363 | + * ```typescript |
| 364 | + * const result = await runSinglePtyCommand( |
| 365 | + * ptyProcess, |
| 366 | + * 'ls -la', |
| 367 | + * '.', |
| 368 | + * (chunk) => console.log('Output chunk:', chunk) |
| 369 | + * ); |
| 370 | + * console.log('Exit code:', result.exitCode); |
| 371 | + * console.log('Final directory:', result.finalCwd); |
| 372 | + * ``` |
| 373 | + * |
| 374 | + * @internal This is a low-level utility function used by other terminal command runners. |
| 375 | + * It handles platform-specific differences between Windows and Unix-like systems. |
| 376 | + * |
| 377 | + * The function works by: |
| 378 | + * 1. Setting up a data listener on the PTY process |
| 379 | + * 2. Filtering out command echo lines (the command being typed) |
| 380 | + * 3. Detecting command completion markers (cwdIdentifier and promptIdentifier) |
| 381 | + * 4. Parsing exit codes from the shell's status messages |
| 382 | + * 5. Extracting the final working directory from the output |
| 383 | + */ |
| 384 | +function runSinglePtyCommand( |
| 385 | + ptyProcess: IPty, |
| 386 | + command: string, |
| 387 | + cwd: string, |
| 388 | + onChunk: (data: string) => void |
| 389 | +): Promise<{ |
| 390 | + commandOutput: string |
| 391 | + finalCwd: string |
| 392 | + exitCode: number | null |
| 393 | +}> { |
| 394 | + const isWindows = os.platform() === 'win32' |
| 395 | + let commandOutput = '' |
| 396 | + let buffer = promptIdentifier |
| 397 | + let echoLinesRemaining = isWindows ? 1 : command.split('\n').length |
| 398 | + |
| 399 | + const resultPromise = new Promise<{ |
| 400 | + commandOutput: string |
| 401 | + finalCwd: string |
| 402 | + exitCode: number | null |
| 403 | + }>((resolve) => { |
| 404 | + const dataDisposable = ptyProcess.onData((data: string) => { |
| 405 | + buffer += data |
| 406 | + const suffix = suffixPrefixOverlap(buffer, promptIdentifier) |
| 407 | + let toProcess = buffer.slice(0, buffer.length - suffix.length) |
| 408 | + buffer = suffix |
| 409 | + |
| 410 | + const matches = toProcess.match(echoLinePattern) |
| 411 | + if (matches) { |
| 412 | + for (let i = 0; i < matches.length && echoLinesRemaining > 0; i++) { |
| 413 | + echoLinesRemaining = Math.max(echoLinesRemaining - 1, 0) |
| 414 | + // Process normal output line |
| 415 | + toProcess = toProcess.replace(echoLinePattern, '') |
| 416 | + } |
| 417 | + } |
| 418 | + |
| 419 | + const indexOfPromptIdentifier = toProcess.indexOf(cwdIdentifier) |
| 420 | + if (indexOfPromptIdentifier !== -1) { |
| 421 | + buffer = toProcess.slice(indexOfPromptIdentifier) + buffer |
| 422 | + toProcess = toProcess.slice(0, indexOfPromptIdentifier) |
| 423 | + } |
| 424 | + |
| 425 | + onChunk(toProcess) |
| 426 | + commandOutput += toProcess |
| 427 | + |
| 428 | + const commandDone = buffer.match(commandDonePattern) |
| 429 | + if (commandDone && echoLinesRemaining === 0) { |
| 430 | + // Command is done |
| 431 | + dataDisposable.dispose() |
| 432 | + |
| 433 | + const exitCode = buffer.includes('Command completed') |
| 434 | + ? 0 |
| 435 | + : (() => { |
| 436 | + const match = buffer.match( |
| 437 | + /Command failed with exit code (\d+)\./ |
| 438 | + ) |
| 439 | + return match ? parseInt(match[1]) : null |
| 440 | + })() |
| 441 | + |
| 442 | + const newWorkingDirectory = commandDone[1] |
| 443 | + |
| 444 | + resolve({ commandOutput, finalCwd: newWorkingDirectory, exitCode }) |
| 445 | + } |
| 446 | + }) |
| 447 | + }) |
| 448 | + |
| 449 | + // Write the command |
| 450 | + const cdCommand = `cd ${path.resolve(getProjectRoot(), cwd)}` |
| 451 | + const commandWithCheck = isWindows |
| 452 | + ? `${cdCommand} & ${command} & echo ${cwdIdentifier}%cd%${cwdIdentifier}` |
| 453 | + : `${cdCommand}; ${command}; ec=$?; printf "${cwdIdentifier}$(pwd)${cwdIdentifier}"; if [ $ec -eq 0 ]; then printf "Command completed."; else printf "Command failed with exit code $ec."; fi` |
| 454 | + ptyProcess.write(`${commandWithCheck}\r\n`) |
| 455 | + |
| 456 | + return resultPromise |
| 457 | +} |
| 458 | + |
344 | 459 | export const runCommandPty = ( |
345 | 460 | persistentProcess: PersistentProcess & { |
346 | 461 | type: 'pty' |
@@ -404,114 +519,82 @@ export const runCommandPty = ( |
404 | 519 |
|
405 | 520 | persistentProcess.timerId = timer |
406 | 521 |
|
407 | | - const dataDisposable = ptyProcess.onData((data: string) => { |
408 | | - buffer += data |
409 | | - const suffix = suffixPrefixOverlap(buffer, promptIdentifier) |
410 | | - let toProcess = buffer.slice(0, buffer.length - suffix.length) |
411 | | - buffer = suffix |
412 | | - |
413 | | - const matches = toProcess.match(echoLinePattern) |
414 | | - if (matches) { |
415 | | - for (let i = 0; i < matches.length && echoLinesRemaining > 0; i++) { |
416 | | - echoLinesRemaining = Math.max(echoLinesRemaining - 1, 0) |
417 | | - // Process normal output line |
418 | | - toProcess = toProcess.replace(echoLinePattern, '') |
419 | | - } |
420 | | - } |
| 522 | + runSinglePtyCommand(ptyProcess, command, cwd, (data: string) => { |
| 523 | + commandOutput += data |
| 524 | + process.stdout.write(data) |
| 525 | + }).then(async ({ finalCwd: newWorkingDirectory, exitCode }) => { |
| 526 | + const statusMessage = |
| 527 | + exitCode === null |
| 528 | + ? '' |
| 529 | + : exitCode === 0 |
| 530 | + ? 'Complete' |
| 531 | + : `Failed with exit code: ${exitCode}` |
421 | 532 |
|
422 | | - const indexOfPromptIdentifier = toProcess.indexOf(promptIdentifier) |
423 | | - if (indexOfPromptIdentifier !== -1) { |
424 | | - buffer = toProcess.slice(indexOfPromptIdentifier) + buffer |
425 | | - toProcess = toProcess.slice(0, indexOfPromptIdentifier) |
| 533 | + if (timer) { |
| 534 | + clearTimeout(timer) |
426 | 535 | } |
427 | | - |
428 | | - process.stdout.write(toProcess) |
429 | | - commandOutput += toProcess |
430 | | - |
431 | | - const commandDone = buffer.match(commandDonePattern) |
432 | | - if (commandDone && echoLinesRemaining === 0) { |
433 | | - // Command is done |
434 | | - if (timer) { |
435 | | - clearTimeout(timer) |
436 | | - } |
437 | | - dataDisposable.dispose() |
438 | | - |
439 | | - const exitCode = buffer.includes('Command completed') |
440 | | - ? 0 |
441 | | - : (() => { |
442 | | - const match = buffer.match(/Command failed with exit code (\d+)\./) |
443 | | - return match ? parseInt(match[1]) : null |
444 | | - })() |
445 | | - const statusMessage = buffer.includes('Command completed') |
446 | | - ? 'Complete' |
447 | | - : `Failed with exit code: ${exitCode}` |
448 | | - |
449 | | - const newWorkingDirectory = commandDone[1] |
450 | | - if (mode === 'assistant') { |
451 | | - ptyProcess.write(`cd ${getWorkingDirectory()}\r\n`) |
452 | | - |
453 | | - resolve({ |
454 | | - result: formatResult( |
455 | | - command, |
456 | | - commandOutput, |
457 | | - `cwd: ${path.resolve(projectRoot, cwd)}\n\n${statusMessage}` |
458 | | - ), |
459 | | - stdout: commandOutput, |
460 | | - exitCode, |
461 | | - }) |
462 | | - return |
463 | | - } |
464 | | - |
465 | | - let outsideProject = false |
466 | | - const currentWorkingDirectory = getWorkingDirectory() |
467 | | - let finalCwd = currentWorkingDirectory |
468 | | - if (newWorkingDirectory !== currentWorkingDirectory) { |
469 | | - trackEvent(AnalyticsEvent.CHANGE_DIRECTORY, { |
470 | | - from: currentWorkingDirectory, |
471 | | - to: newWorkingDirectory, |
472 | | - isSubdir: isSubdir(currentWorkingDirectory, newWorkingDirectory), |
473 | | - }) |
474 | | - if (path.relative(projectRoot, newWorkingDirectory).startsWith('..')) { |
475 | | - outsideProject = true |
476 | | - console.log(` |
477 | | -Unable to cd outside of the project root (${projectRoot}) |
478 | | - |
479 | | -If you want to change the project root: |
480 | | -1. Exit Codebuff (type "exit") |
481 | | -2. Navigate into the target directory (type "cd ${newWorkingDirectory}") |
482 | | -3. Restart Codebuff`) |
483 | | - ptyProcess.write(`cd ${currentWorkingDirectory}\r\n`) |
484 | | - } else { |
485 | | - setWorkingDirectory(newWorkingDirectory) |
486 | | - finalCwd = newWorkingDirectory |
487 | | - } |
488 | | - } |
489 | | - |
| 536 | + if (mode === 'assistant') { |
490 | 537 | resolve({ |
491 | 538 | result: formatResult( |
492 | 539 | command, |
493 | 540 | commandOutput, |
494 | 541 | buildArray([ |
495 | | - `cwd: ${currentWorkingDirectory}`, |
496 | | - `${statusMessage}\n`, |
497 | | - outsideProject && |
498 | | - `Detected final cwd outside project root. Reset cwd to ${currentWorkingDirectory}`, |
499 | | - `Final **user** cwd: ${finalCwd} (Assistant's cwd is still project root)`, |
500 | | - ]).join('\n') |
| 542 | + `cwd: ${path.resolve(projectRoot, cwd)}`, |
| 543 | + statusMessage, |
| 544 | + ]).join('\n\n') |
501 | 545 | ), |
502 | 546 | stdout: commandOutput, |
503 | 547 | exitCode, |
504 | 548 | }) |
505 | | - return |
506 | 549 | } |
| 550 | + let outsideProject = false |
| 551 | + const currentWorkingDirectory = getWorkingDirectory() |
| 552 | + let finalCwd = currentWorkingDirectory |
| 553 | + if (newWorkingDirectory !== currentWorkingDirectory) { |
| 554 | + trackEvent(AnalyticsEvent.CHANGE_DIRECTORY, { |
| 555 | + from: currentWorkingDirectory, |
| 556 | + to: newWorkingDirectory, |
| 557 | + isSubdir: isSubdir(currentWorkingDirectory, newWorkingDirectory), |
| 558 | + }) |
| 559 | + if (path.relative(projectRoot, newWorkingDirectory).startsWith('..')) { |
| 560 | + outsideProject = true |
| 561 | + console.log(` |
| 562 | +Unable to cd outside of the project root (${projectRoot}) |
| 563 | + |
| 564 | +If you want to change the project root: |
| 565 | +1. Exit Codebuff (type "exit") |
| 566 | +2. Navigate into the target directory (type "cd ${newWorkingDirectory}") |
| 567 | +3. Restart Codebuff`) |
| 568 | + await runSinglePtyCommand( |
| 569 | + ptyProcess, |
| 570 | + `cd ${currentWorkingDirectory}`, |
| 571 | + '.', |
| 572 | + () => {} |
| 573 | + ) |
| 574 | + } else { |
| 575 | + setWorkingDirectory(newWorkingDirectory) |
| 576 | + finalCwd = newWorkingDirectory |
| 577 | + } |
| 578 | + } |
| 579 | + |
| 580 | + resolve({ |
| 581 | + result: formatResult( |
| 582 | + command, |
| 583 | + commandOutput, |
| 584 | + buildArray([ |
| 585 | + `cwd: ${currentWorkingDirectory}`, |
| 586 | + `${statusMessage}\n`, |
| 587 | + outsideProject && |
| 588 | + `Detected final cwd outside project root. Reset cwd to ${currentWorkingDirectory}`, |
| 589 | + `Final **user** cwd: ${finalCwd} (Assistant's cwd is still project root)`, |
| 590 | + ]).join('\n') |
| 591 | + ), |
| 592 | + stdout: commandOutput, |
| 593 | + exitCode, |
| 594 | + }) |
507 | 595 | }) |
508 | 596 |
|
509 | | - // Write the command |
510 | | - const cdCommand = `cd ${path.resolve(projectRoot, cwd)}` |
511 | | - const commandWithCheck = isWindows |
512 | | - ? `${cdCommand} & ${command} & echo ${promptIdentifier}%cd%${promptIdentifier}` |
513 | | - : `${cdCommand}; ${command}; ec=$?; printf "${promptIdentifier}$(pwd)${promptIdentifier}"; if [ $ec -eq 0 ]; then printf "Command completed."; else printf "Command failed with exit code $ec."; fi` |
514 | | - ptyProcess.write(`${commandWithCheck}\r`) |
| 597 | + return |
515 | 598 | } |
516 | 599 |
|
517 | 600 | const runCommandChildProcess = ( |
|
0 commit comments