diff --git a/src/main/providers/startup-line-typed-length.live-shell.test.ts b/src/main/providers/startup-line-typed-length.live-shell.test.ts new file mode 100644 index 00000000000..8f6c9106354 --- /dev/null +++ b/src/main/providers/startup-line-typed-length.live-shell.test.ts @@ -0,0 +1,148 @@ +/** + * Real-zsh proof for the typed launch lines `startup-line-prompt-carry.ts` lets a prompt ride: a + * multi-line line whose every line fits the per-line budget, up to the whole-line budget, reaches + * zsh intact through Orca's own wrapper, ready marker and startup write. + * + * Why a slow user config too: the write is released 1.5 s after spawn even without the marker, and + * then lands while the terminal is still line-buffered, where macOS keeps at most MAX_CANON bytes of + * one line. The per-line budget is what survives that; a single 1.1 KB line did not. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + createShellStartupOutputScanState, + scanShellStartupOutput +} from '../shell-startup-output-scanner' +import { selectShellStartupFeatures } from '../shell-startup-features' +import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' +import { + TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES, + ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES +} from '../../shared/startup-line-prompt-carry' +import { + restoreUserDataPathAfterEach, + setTestUserDataPath +} from './local-pty-shell-ready-test-harness' + +const ZSH_PATH = + process.platform === 'win32' + ? '' + : (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim() + +/** Lines of prompt text, each `perLine` bytes. */ +function promptLines(lineCount: number, perLine: number): string { + const words = 'Fix the failing checks on this branch, then explain what changed and why.' + return Array.from({ length: lineCount }, (_unused, index) => { + let line = `L${index}:` + while (line.length < perLine) { + line += ` ${words}` + } + return line.slice(0, perLine) + }).join('\n') +} + +let home = '' + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-typed-line-home-')) + setTestUserDataPath(mkdtempSync(join(tmpdir(), 'orca-typed-line-ud-'))) +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) +}) + +restoreUserDataPathAfterEach() + +/** Types `printf '%s' '' > out` the way a local pane types a launch line; returns what ran. */ +async function typeIntoZsh(text: string, userConfigSeconds: number): Promise { + if (userConfigSeconds > 0) { + writeFileSync(join(home, '.zshrc'), `sleep ${userConfigSeconds}\n`) + } + const out = join(home, 'out.txt') + const done = join(home, 'done.txt') + const { ensureShellReadyWrappers } = await import('./local-pty-shell-ready-wrapper-generation') + const { getShellLaunchConfig } = await import('./local-pty-shell-ready') + const { writeStartupCommandWhenShellReady, STARTUP_COMMAND_READY_MAX_WAIT_MS } = + await import('./local-pty-shell-ready-startup-command') + ensureShellReadyWrappers() + const env: Record = { PATH: '/usr/bin:/bin', HOME: home } + const launch = getShellLaunchConfig( + ZSH_PATH, + selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: true, + waitsForShellReady: true, + emitsStartupIdentity: false + }) + ) + expect(launch.supportsReadyMarker).toBe(true) + const proc = pty.spawn(ZSH_PATH, launch.args ?? [], { + name: 'xterm-256color', + cols: 200, + rows: 40, + cwd: home, + // Why ORCA_ORIG_ZDOTDIR: the user's config is read from the sandbox home, not the real one. + env: { ...env, ...launch.env, ORCA_ORIG_ZDOTDIR: home, TERM: 'xterm-256color' } + }) + const scan = createShellStartupOutputScanState() + let resolveReady: ((signal: { postMarkerBytesObserved: boolean }) => void) | null = null + const ready = new Promise<{ postMarkerBytesObserved: boolean }>((resolve) => { + resolveReady = resolve + }) + // The provider's cap (local-pty-shell-readiness-session.ts): a later marker releases the write. + const cap = setTimeout(() => { + resolveReady?.({ postMarkerBytesObserved: false }) + resolveReady = null + }, STARTUP_COMMAND_READY_MAX_WAIT_MS) + proc.onData((data) => { + if (resolveReady && scanShellStartupOutput(scan, data).ready) { + resolveReady({ postMarkerBytesObserved: true }) + resolveReady = null + } + }) + writeStartupCommandWhenShellReady( + ready, + proc, + `printf '%s' '${text}' > '${out}'; printf ok > '${done}'`, + () => {}, + { + bracketedPasteSafe: isBracketedPasteSafeShell({ + shellName: 'zsh', + waitsForShellReady: launch.supportsReadyMarker === true + }) + } + ) + const deadline = Date.now() + 20_000 + userConfigSeconds * 1000 + while (!existsSync(done) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + clearTimeout(cap) + const ran = existsSync(out) ? readFileSync(out, 'utf8') : null + const exited = new Promise((resolve) => proc.onExit(() => resolve())) + proc.kill() + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2000))]) + return ran +} + +describe.skipIf(!ZSH_PATH)('a multi-line launch line typed into zsh', () => { + // Lines at the per-line budget, as many as the whole-line budget leaves room for. + const perLine = TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES - 1 + const text = promptLines( + Math.floor((ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES - 200) / (perLine + 1)), + perLine + ) + + it('arrives whole through the ready barrier', async () => { + expect(await typeIntoZsh(text, 0)).toBe(text) + }, 60_000) + + it('arrives whole when the user config outlasts the barrier and the write lands early', async () => { + expect(await typeIntoZsh(text, 3)).toBe(text) + }, 60_000) +}) diff --git a/src/main/runtime/agent-launch-typed-line-shell.test.ts b/src/main/runtime/agent-launch-typed-line-shell.test.ts new file mode 100644 index 00000000000..1ef35b85292 --- /dev/null +++ b/src/main/runtime/agent-launch-typed-line-shell.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' + +describe('naming the shell a local launch line is typed into', () => { + it('takes the request’s shell first, then the setting, then SHELL, as the spawn does', () => { + const base = { isRemote: false, platform: 'darwin' as const, envShell: '/bin/bash' } + expect( + nameLocalTypedLineShell({ + ...base, + shellOverride: '/opt/homebrew/bin/fish', + defaultShellSetting: '/bin/zsh' + }) + ).toBe('fish') + expect(nameLocalTypedLineShell({ ...base, defaultShellSetting: ' /bin/zsh ' })).toBe('zsh') + expect(nameLocalTypedLineShell(base)).toBe('bash') + expect(nameLocalTypedLineShell({ ...base, envShell: '' })).toBe('zsh') + }) + + it('names none for a remote host, whose relay picks its own login shell', () => { + expect( + nameLocalTypedLineShell({ isRemote: true, platform: 'darwin', envShell: '/bin/zsh' }) + ).toBeUndefined() + }) + + it('names none on Windows, where the pane may be cmd, PowerShell, Git Bash or WSL', () => { + expect( + nameLocalTypedLineShell({ isRemote: false, platform: 'win32', envShell: '/bin/zsh' }) + ).toBeUndefined() + }) +}) diff --git a/src/main/runtime/agent-launch-typed-line-shell.ts b/src/main/runtime/agent-launch-typed-line-shell.ts new file mode 100644 index 00000000000..6a7f83c048b --- /dev/null +++ b/src/main/runtime/agent-launch-typed-line-shell.ts @@ -0,0 +1,27 @@ +import { basename } from 'node:path' + +/** + * The shell a local pane's startup line gets typed into, named before the spawn the way the spawn + * picks it: the request's shell, the default-shell setting, then `SHELL` + * (`ipc/pty/runtime/spawn-preflight.ts`, then the local or daemon launch plan). + * + * Undefined where the host cannot name it: a remote host, whose relay picks its own login shell, + * and Windows, whose pane may be cmd, PowerShell, Git Bash or a WSL distro. + */ +export function nameLocalTypedLineShell(args: { + isRemote: boolean + shellOverride?: string + defaultShellSetting?: string + platform?: NodeJS.Platform + envShell?: string +}): string | undefined { + if (args.isRemote || (args.platform ?? process.platform) === 'win32') { + return undefined + } + const shellPath = + args.shellOverride?.trim() || + args.defaultShellSetting?.trim() || + (args.envShell ?? process.env.SHELL) || + '/bin/zsh' + return basename(shellPath).toLowerCase() +} diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index c6b7370a30a..cdf6cd16dbb 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -17,7 +17,10 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -function runtimeWithAgentLaunch(): { +// The shell the host names for a local line; bash takes no multi-line line, zsh does. +function runtimeWithAgentLaunch( + options: { terminalDefaultShell?: string; connectionId?: string } = {} +): { runtime: OrcaRuntimeService spawn: ReturnType } { @@ -27,11 +30,13 @@ function runtimeWithAgentLaunch(): { store: { getSettings: () => Record } resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise } - internal.store = { getSettings: () => ({}) } + internal.store = { + getSettings: () => ({ terminalDefaultShell: options.terminalDefaultShell ?? '/bin/bash' }) + } vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ id: 'wt-1', path: '/repo/app', - connectionId: null, + connectionId: options.connectionId ?? null, repo: null, folderWorkspace: null }) @@ -83,6 +88,37 @@ describe('a terminal create that is handed a launch prompt', () => { expect(onStartupPromptCarry).toHaveBeenCalledWith(false) }) + it('carries a short-lined multi-line prompt on a local zsh line, so the agent starts with it', async () => { + const { runtime, spawn } = runtimeWithAgentLaunch({ terminalDefaultShell: '/bin/zsh' }) + const onStartupPromptCarry = vi.fn() + + await runtime.createTerminal('id:wt-1', { + startupAgent: 'claude', + startupPrompt: 'summarize the diff\nthen list the risks', + onStartupPromptCarry + }) + + expect(spawnedCommand(spawn)).toContain('summarize the diff\nthen list the risks') + expect(onStartupPromptCarry).toHaveBeenCalledWith(true) + }) + + it('starts clean on a remote host, whose shell this host cannot name', async () => { + const { runtime, spawn } = runtimeWithAgentLaunch({ + terminalDefaultShell: '/bin/zsh', + connectionId: 'ssh-1' + }) + const onStartupPromptCarry = vi.fn() + + await runtime.createTerminal('id:wt-1', { + startupAgent: 'claude', + startupPrompt: 'summarize the diff\nthen list the risks', + onStartupPromptCarry + }) + + expect(spawnedCommand(spawn)).not.toContain('summarize') + expect(onStartupPromptCarry).toHaveBeenCalledWith(false) + }) + it('starts Hermes clean instead of refusing when its env budget cannot hold the prompt', async () => { const { runtime, spawn } = runtimeWithAgentLaunch() const onStartupPromptCarry = vi.fn() diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 2ec280f056b..415a1b82964 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -21,6 +21,7 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' +import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' @@ -232,7 +233,14 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith windowsShellOverride: opts.shellOverride, sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) }), - opts.startupPrompt ?? '' + opts.startupPrompt ?? '', + nameLocalTypedLineShell({ + isRemote, + ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), + ...(settings.terminalDefaultShell + ? { defaultShellSetting: settings.terminalDefaultShell } + : {}) + }) ) if (!startupPlan) { // Why: an explicit agent that yields no plan would otherwise spawn a bare diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 43fe59b34e1..a39e7797583 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -86,6 +86,8 @@ export type RuntimeStore = { agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] + // Read by the launch-line carry rule to name the shell a local line is typed into. + terminalDefaultShell?: GlobalSettings['terminalDefaultShell'] floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] terminalCopyTrimsGutter?: GlobalSettings['terminalCopyTrimsGutter'] diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 9d90ef4900f..fe82665de5d 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -109,10 +109,10 @@ describe('buildWorktreeStartupForAgent host resolution', () => { }) describe('buildWorktreeStartupForAgent prompt carry', () => { - const build = (onPromptCarry?: (carried: boolean) => void) => + const build = (onPromptCarry?: (carried: boolean) => void, terminalDefaultShell = '/bin/bash') => buildWorktreeStartupForAgent({ repo: makeRepo({}), - settings, + settings: Object.assign({}, settings, { terminalDefaultShell }), agent: 'claude', prompt: 'summarize the diff\nthen list the risks', getLaunchPlatform: () => 'linux', @@ -129,6 +129,14 @@ describe('buildWorktreeStartupForAgent prompt carry', () => { expect(onPromptCarry).toHaveBeenCalledWith(false) }) + it('carries a short-lined multi-line prompt on a local zsh line, as main typed it', () => { + const onPromptCarry = vi.fn() + const result = build(onPromptCarry, '/bin/zsh') + + expect(result.startup.command).toContain('summarize the diff\nthen list the risks') + expect(onPromptCarry).toHaveBeenCalledWith(true) + }) + it('keeps folding the prompt for a caller that delivers nothing afterwards', () => { // `orca worktree create --prompt` has no post-start paste of its own for an argv agent. expect(build().startup.command).toContain('summarize the diff') diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 23069c0be03..139679ba705 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -10,6 +10,7 @@ import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selectio import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' +import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -152,7 +153,16 @@ export function buildWorktreeStartupForAgent( const prompt = environment.prompt ?? '' let startupPlan: ReturnType if (environment.onPromptCarry) { - const offered = planStartupWithPromptCandidate(planInputs, prompt) + const offered = planStartupWithPromptCandidate( + planInputs, + prompt, + nameLocalTypedLineShell({ + isRemote: repoIsRemote(repo), + ...(settings.terminalDefaultShell + ? { defaultShellSetting: settings.terminalDefaultShell } + : {}) + }) + ) startupPlan = offered.plan if (startupPlan && prompt.trim()) { environment.onPromptCarry(offered.promptCarried) diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts index c897edddc87..0d1b9293200 100644 --- a/src/shared/startup-line-prompt-carry.test.ts +++ b/src/shared/startup-line-prompt-carry.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { hasControlByte, planStartupWithPromptCandidate, - TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES, + ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES } from './startup-line-prompt-carry' import type { TuiAgent } from './tui-agent' import { @@ -10,14 +11,26 @@ import { RUNTIME_CAPABILITIES } from './protocol-version' -function offer(agent: TuiAgent, prompt: string, extra: { cmdOverride?: string } = {}) { +function offer( + agent: TuiAgent, + prompt: string, + extra: { cmdOverride?: string; shellName?: string | undefined } = {} +) { return planStartupWithPromptCandidate( { agent, cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {}, platform: 'darwin' }, - prompt + prompt, + extra.shellName + ) +} + +/** `count` prompt lines of `bytes` each, as a multi-line source-control prompt is. */ +function linesOf(count: number, bytes: number): string { + return Array.from({ length: count }, (_unused, index) => `${index}:`.padEnd(bytes, 'x')).join( + '\n' ) } @@ -119,6 +132,49 @@ describe('whether a launch prompt rides the typed startup line', () => { }) }) +// Pinned live by startup-line-typed-length.live-shell.test.ts; the refusals below were measured on +// macOS through the same write: a 1.1 KB single line lost to zsh's late write, every multi-line line +// lost to bash 3.2, and to fish whenever its config outlasted the ready barrier. +describe('a multi-line prompt typed into a shell the host names', () => { + it('rides a zsh line as main typed it when each line is short, so the agent starts with it', () => { + const prompt = linesOf(21, 100) + const { plan, promptCarried } = offer('claude', prompt, { shellName: 'zsh' }) + expect(promptCarried).toBe(true) + expect(plan?.launchCommand).toContain(prompt) + }) + + it('keeps a zsh line off the launch command when any one line exceeds the per-line budget', () => { + const prompt = `${linesOf(3, 100)}\n${'y'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)}` + expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + }) + + it('keeps a zsh line off the launch command past the whole-line budget', () => { + const prompt = linesOf(Math.ceil(ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES / 400) + 1, 400) + expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + }) + + it('keeps a long single line off a zsh launch command, which a late write truncates', () => { + const prompt = promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1) + expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + }) + + it.each([['bash'], ['fish'], ['pwsh'], [undefined]])( + 'never types a multi-line line into %s', + (shellName) => { + const { plan, promptCarried } = offer('claude', linesOf(3, 50), { shellName }) + expect(promptCarried).toBe(false) + expect(plan?.launchCommand).not.toContain('0:') + } + ) + + it.each([ + ['TAB', 'see\tthis\nnext line'], + ['CR', 'first\r\nsecond'] + ])('never types a %s-bearing multi-line prompt into zsh', (_label, prompt) => { + expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + }) +}) + describe('the capability clients gate a prompted launch on', () => { it('is advertised by every host that applies the typed-line rule', () => { // An older host folds any prompt into the typed line, so its absence is the gate. diff --git a/src/shared/startup-line-prompt-carry.ts b/src/shared/startup-line-prompt-carry.ts index 94335922233..65e70e2d11d 100644 --- a/src/shared/startup-line-prompt-carry.ts +++ b/src/shared/startup-line-prompt-carry.ts @@ -8,6 +8,11 @@ * byte as a key, a canonical-mode write truncates a line * past MAX_CANON (1024 on macOS), and cmd caps a line at 8191. Decided here, where the line exists, * so the answer is a fact about what was built rather than a prediction of it. + * + * `startup-line-typed-length.live-shell.test.ts` types lines through Orca's own ready barrier and + * submission into real shells, including one whose config outlasts the barrier's 1.5 s cap so the + * line lands while the terminal is still line-buffered. Only zsh read a multi-line line whole in + * both cases, and only while each of its lines stayed short; a long single line was lost there. */ import { TUI_AGENT_CONFIG } from './tui-agent-config' @@ -15,9 +20,12 @@ import { buildAgentStartupPlan, type AgentStartupPlan } from './tui-agent-startu import type { TuiAgent } from './tui-agent' /** Half of macOS MAX_CANON: a single typed line this long survived every canonical-mode write - * measured, and 1 KiB did not. */ + * measured, and 1 KiB did not. Also the cap on each line of a multi-line one. */ export const TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES = 512 +/** A multi-line line typed into zsh: the largest measured intact behind a late write (8 KiB). */ +export const ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES = 8192 + const encoder = new TextEncoder() function typedLineBytes(line: string): number { @@ -27,6 +35,8 @@ function typedLineBytes(line: string): number { export function startupLineCarriesPrompt(args: { agent: TuiAgent withPrompt: AgentStartupPlan | null + /** The shell the line is typed into, when the host can name it before the spawn. */ + shellName?: string }): boolean { const { withPrompt } = args if (!withPrompt || withPrompt.followupPrompt !== null) { @@ -38,7 +48,25 @@ export function startupLineCarriesPrompt(args: { return true } const line = withPrompt.launchCommand - return !hasControlByte(line) && typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + if (!hasControlByte(line)) { + return typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + } + return args.shellName === 'zsh' && zshReadsMultiLineWhole(line) +} + +/** + * zsh takes a multi-line line as one bracketed paste, and a write that beats its line editor still + * reaches it line by line, so each line must fit the terminal's line buffer. bash 3.2 has no + * bracketed paste, and fish drops a paste written before its reader is up. + */ +function zshReadsMultiLineWhole(line: string): boolean { + const lines = line.split('\n') + return ( + lines.every( + (part) => + !hasControlByte(part) && typedLineBytes(part) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + ) && typedLineBytes(line) <= ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES + ) } /** Any C0 byte or DEL, not just CR/LF: no quoter escapes them, and a single-line command is written @@ -64,11 +92,18 @@ type StartupPlanInputs = Omit< */ export function planStartupWithPromptCandidate( inputs: StartupPlanInputs, - prompt: string + prompt: string, + shellName?: string ): { plan: AgentStartupPlan | null; promptCarried: boolean } { if (prompt.trim()) { const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true }) - if (startupLineCarriesPrompt({ agent: inputs.agent, withPrompt })) { + if ( + startupLineCarriesPrompt({ + agent: inputs.agent, + withPrompt, + ...(shellName ? { shellName } : {}) + }) + ) { return { plan: withPrompt, promptCarried: true } } }