diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md index abe94a80..3d3afaa6 100644 --- a/.changeset/multiline-input-paste.md +++ b/.changeset/multiline-input-paste.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`, respawn prompts) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so `codeman agent send` and the TUI keep refusing or joining multi-line input. +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn now joins the lines of a multi-line update or kickstart prompt with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 41f7f490..33e7c277 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -503,6 +503,19 @@ function formatRemainingSeconds(ms: number): number { return Math.max(0, Math.ceil(ms / 1000)); } +/** + * A respawn prompt as the one line it is sent as: every run of line breaks, with the + * whitespace around it, becomes a single space; a single-line prompt is returned as + * is. The config may hold several lines (the settings fields are textareas), but the + * steps run unattended through `writeViaMux`, and the check that re-presses a stranded + * Enter cannot recognise a multi-line paste in the composer. Cron rejects multi-line + * prompts for the same reason; respawn joins them instead, so a saved multi-line + * config is still sent rather than refused. + */ +export function singleLineRespawnPrompt(prompt: string): string { + return prompt.replace(/\s*[\r\n]+\s*/g, ' '); +} + /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) @@ -1621,7 +1634,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { if (this._state === 'stopped') return; - const prompt = this.config.kickstartPrompt!; + const prompt = singleLineRespawnPrompt(this.config.kickstartPrompt!); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI // COD-51: stop() may have run during the await; re-check before reviving the @@ -2826,13 +2839,13 @@ export class RespawnController extends EventEmitter { // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config const statusBlock = this.session.ralphTracker?.lastStatusBlock; - let updatePrompt = this.config.updatePrompt; + let updatePrompt = singleLineRespawnPrompt(this.config.updatePrompt); if (statusBlock?.recommendation) { - // Append RECOMMENDATION to the update prompt (single-line: the submit check cannot - // recognise a multi-line paste in the composer) + // Append RECOMMENDATION to the update prompt, on the same single line + // (see singleLineRespawnPrompt) const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200); - updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`; + updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`; this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); } diff --git a/test/respawn-single-line-prompt.test.ts b/test/respawn-single-line-prompt.test.ts new file mode 100644 index 00000000..f976e2d5 --- /dev/null +++ b/test/respawn-single-line-prompt.test.ts @@ -0,0 +1,93 @@ +/** + * Respawn prompts go out as ONE line, whatever the config holds. + * + * The update and kickstart prompts are textareas in the settings, so a saved config + * can hold several lines. `TmuxManager.sendInput()` used to strip every `\n`, which + * ran the lines together; it now pastes multi-line text as one block. Respawn runs + * unattended, and the check that re-presses a stranded Enter cannot recognise a + * multi-line paste in the composer, so the controller joins the lines with a space + * before the write instead of leaning on the mux to drop them. + * + * Strategy: drive the step methods directly against a MockSession and read what + * reached `writeViaMux`. No real tmux. + * + * Port: N/A (no server / no real tmux) + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', async (orig) => { + const actual = await orig(); + return { ...actual, exec: vi.fn((_cmd: string, cb?: (e: Error | null, o: string) => void) => cb?.(null, '')) }; +}); + +import { RespawnController, singleLineRespawnPrompt } from '../src/respawn-controller.js'; +import { Session } from '../src/session.js'; +import { MockSession } from './mocks/index.js'; + +describe('singleLineRespawnPrompt', () => { + it('joins lines with one space, folding the whitespace around each break', () => { + expect(singleLineRespawnPrompt('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe( + 'update the docs then CLAUDE.md and continue' + ); + }); + + it('returns a single-line prompt unchanged', () => { + expect(singleLineRespawnPrompt(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); + }); +}); + +describe('respawn steps send a multi-line prompt as one line', () => { + let controller: RespawnController | null = null; + + afterEach(() => controller?.stop()); + + /** Run one step method and return what it wrote through the mux. */ + async function written( + config: { updatePrompt?: string; kickstartPrompt?: string }, + step: 'sendUpdateDocs' | 'sendKickstart', + recommendation?: string + ): Promise { + const session = new MockSession(); + if (recommendation !== undefined) { + (session as unknown as { ralphTracker: unknown }).ralphTracker = { lastStatusBlock: { recommendation } }; + } + controller = new RespawnController(session as unknown as Session, { + interStepDelayMs: 10, + completionConfirmMs: 10, + noOutputTimeoutMs: 500, + aiIdleCheckEnabled: false, + ...config, + }); + (controller as never as { _state: string })._state = 'watching'; + (controller as never as Record void>)[step](); + // The step schedules its write on the step-delay timer. + await new Promise((r) => setTimeout(r, 40)); + return session.writeBuffer; + } + + it('update prompt', async () => { + const writes = await written( + { updatePrompt: 'summarise progress\nin CLAUDE.md\n\nthen continue' }, + 'sendUpdateDocs' + ); + expect(writes).toEqual(['summarise progress in CLAUDE.md then continue\r']); + }); + + it('update prompt with a recommendation appended', async () => { + const writes = await written({ updatePrompt: 'summarise\nprogress' }, 'sendUpdateDocs', 'fix the\nlogin test'); + expect(writes).toEqual(["summarise progress (Claude's last recommendation: fix the login test)\r"]); + }); + + it('kickstart prompt', async () => { + const writes = await written( + { kickstartPrompt: 'read @fix_plan.md\r\ncontinue with the next task' }, + 'sendKickstart' + ); + expect(writes).toEqual(['read @fix_plan.md continue with the next task\r']); + }); + + it('a single-line prompt is sent as before', async () => { + const writes = await written({ updatePrompt: 'update all the docs and CLAUDE.md' }, 'sendUpdateDocs'); + expect(writes).toEqual(['update all the docs and CLAUDE.md\r']); + }); +});