mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Merge pull request #670: fix(input): keep the line breaks of a multi-line mux prompt, pasted as one block
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b67018b318
39 files changed
+618
-135
No files matched your search
+8
-6
@@ -129,16 +129,18 @@ export function sendPromptFromArgs(words: readonly string[]): { text: string } |
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. The composer
|
||||
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
|
||||
* DEL) reaches the pane as a keypress. None of that is a prompt.
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. A line break is
|
||||
* refused for the reason below; everything else below 0x20 (and DEL) reaches the pane
|
||||
* as a keypress. None of that is a prompt.
|
||||
*/
|
||||
export function inputRefusal(text: string): string | undefined {
|
||||
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <id> ""` is never a prompt)';
|
||||
// The composer is one line: the server strips newlines, which silently joins the
|
||||
// lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
|
||||
// The server pastes a multi-line prompt as one block, but the check that re-presses
|
||||
// a stranded Enter cannot reliably recognise that block in the composer, so a wait
|
||||
// could burn its timeout on a prompt that never went out. A tab reaches the pane as
|
||||
// a keypress (claude: mode toggle).
|
||||
if (/[\n\r\t]/.test(text)) {
|
||||
return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
|
||||
return 'refusing: input must be a single line (a multi-line paste cannot be confirmed as submitted) — join them yourself, or write a file into the workspace and send its path';
|
||||
}
|
||||
// C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
|
||||
@@ -97,7 +97,7 @@ const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev'];
|
||||
/** Sync blocklist for the create/update workingDir gate (no settings extras). */
|
||||
const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES];
|
||||
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||
/** Prompt delivery is single-line only: the submit check cannot reliably recognise a multi-line paste. */
|
||||
const HAS_NEWLINE = /[\r\n]/;
|
||||
|
||||
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
|
||||
@@ -536,11 +536,11 @@ export class CronService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the prompt text and enforces the single-line constraint: prompt
|
||||
* delivery rides writeViaMux/PTY writes where a newline is Enter, so a
|
||||
* multi-line prompt would be silently corrupted (typed mode fuses lines,
|
||||
* paste mode submits the first line and dribbles the rest in as separate
|
||||
* messages). Rather than mangle an unattended agent's instructions, fail the
|
||||
* Resolves the prompt text and enforces the single-line constraint: a job
|
||||
* fires unattended, and the submit check that re-presses an ignored Enter
|
||||
* cannot reliably recognise a multi-line paste in the composer (typed mode), so a
|
||||
* stranded prompt would go unnoticed; in paste mode the newlines reach the
|
||||
* CLI as keys. Rather than risk an unattended agent's instructions, fail the
|
||||
* run with a clear error. A prompt FILE may end with trailing newline(s)
|
||||
* (every editor writes one) — those are stripped before the check.
|
||||
*/
|
||||
|
||||
@@ -49,7 +49,13 @@ import { Session } from './session.js';
|
||||
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
|
||||
import { AiPlanChecker } from './ai-plan-checker.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||||
import {
|
||||
BufferAccumulator,
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
assertNever,
|
||||
CleanupManager,
|
||||
toSingleLine,
|
||||
} from './utils/index.js';
|
||||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||
import {
|
||||
isCompletionMessage,
|
||||
@@ -1621,7 +1627,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.config.interStepDelayMs,
|
||||
async () => {
|
||||
if (this._state === 'stopped') return;
|
||||
const prompt = this.config.kickstartPrompt!;
|
||||
const prompt = toSingleLine(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,12 +2832,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 = toSingleLine(this.config.updatePrompt);
|
||||
|
||||
if (statusBlock?.recommendation) {
|
||||
// Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines)
|
||||
// Append RECOMMENDATION to the update prompt, on the same single line
|
||||
// (see toSingleLine)
|
||||
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)}...`);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { toSingleLine } from './utils/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
@@ -408,7 +409,11 @@ export class SessionAutoOps extends EventEmitter {
|
||||
);
|
||||
|
||||
const action = async () => {
|
||||
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||
// One line: this fires unattended, and a multi-line focus prompt would go out
|
||||
// as a multi-line paste (see toSingleLine).
|
||||
const compactCmd = this._autoCompactPrompt
|
||||
? `/compact ${toSingleLine(this._autoCompactPrompt)}\r`
|
||||
: '/compact\r';
|
||||
await this.callbacks.writeCommand(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
|
||||
+45
-21
@@ -3882,7 +3882,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
|
||||
* so Codex cannot fold the Enter into a paste burst; a single character is typed
|
||||
* with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys)
|
||||
* - Text without `\r` is typed literally with `send-keys -l` (no key interpretation)
|
||||
* - Single-line text without `\r` is typed literally with `send-keys -l` (no key
|
||||
* interpretation)
|
||||
* - A `\n` inside the text is kept (`\r\n` loses only its `\r`), and multi-line text
|
||||
* always goes in as ONE bracketed paste, with or without a following Enter: inside
|
||||
* the paste markers a CLI that enabled bracketed paste receives the line breaks as
|
||||
* pasted text, where typed as keys each one could submit a line on its own
|
||||
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
|
||||
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
|
||||
*/
|
||||
@@ -3895,8 +3900,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TEST MODE: No-op — don't send input to real tmux sessions
|
||||
if (IS_TEST_MODE) {
|
||||
// TEST MODE: No-op — don't send input to real tmux sessions. The gate lives in
|
||||
// inputRunner() so a test subclass can record what WOULD reach tmux.
|
||||
const run = this.inputRunner();
|
||||
if (!run) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3911,7 +3918,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
try {
|
||||
const hasCarriageReturn = input.includes('\r');
|
||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||||
// Leading and trailing blank lines are dropped, so neither `"\nfix it\r"` nor
|
||||
// `" \nfix it\r"` pastes an empty first line (nor `"fix it\n\r"` an empty last
|
||||
// one). The leading match ends at a line break, so the first real line keeps its
|
||||
// indentation.
|
||||
const textPart = input
|
||||
.replace(/\r/g, '')
|
||||
.replace(/^\s*\n/, '')
|
||||
.trimEnd();
|
||||
|
||||
if (textPart && hasCarriageReturn) {
|
||||
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
|
||||
@@ -3923,31 +3937,29 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// selection dialog ignores a pasted `3` and Enter then confirms the
|
||||
// highlighted option (see submitsAsKeys).
|
||||
if (submitsAsKeys(textPart)) {
|
||||
await execAsync(
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
await run(
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
|
||||
);
|
||||
} else {
|
||||
await pasteText(
|
||||
(cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }),
|
||||
buildPasteTextCommands(this.tmux(), session.muxName, textPart)
|
||||
);
|
||||
await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
|
||||
} else if (textPart.includes('\n')) {
|
||||
// Multi-line text, no Enter: pasted, because typed as keys every `\n` would
|
||||
// reach the pane as a key the CLI may read as submit. A pane that has not
|
||||
// enabled bracketed paste (POSIX sh, macOS /bin/bash 3.2) still gets every line
|
||||
// break as a CR, so there every line but the last runs; before line breaks were
|
||||
// kept, the joined text was typed and nothing ran.
|
||||
await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart));
|
||||
} else if (textPart) {
|
||||
// Text only, no Enter
|
||||
await execAsync(
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
await run(
|
||||
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`
|
||||
);
|
||||
} else if (hasCarriageReturn) {
|
||||
// Enter only
|
||||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -3957,6 +3969,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How {@link sendInput} reaches tmux, or null where it must not (vitest). Split
|
||||
* out like {@link readPaneRows}: with the test-mode gate at the top of sendInput,
|
||||
* no test could see which commands a submit actually sends.
|
||||
*/
|
||||
protected inputRunner(): ((cmd: string) => Promise<unknown>) | null {
|
||||
if (IS_TEST_MODE) return null;
|
||||
return (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
// ========== Pane Methods (for Agent Team teammate panes) ==========
|
||||
|
||||
/**
|
||||
@@ -4062,7 +4084,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
/**
|
||||
* Send input to a specific pane within a tmux session.
|
||||
* Uses the same literal text approach as sendInput() but targets a specific pane.
|
||||
* Types the text literally with `send-keys -l`, line breaks stripped, then Enter
|
||||
* when the input carried a `\r`. Unlike sendInput(), it neither pastes nor keeps
|
||||
* the line breaks of multi-line text.
|
||||
*/
|
||||
sendInputToPane(muxName: string, paneTarget: string, input: string): boolean {
|
||||
if (IS_TEST_MODE) return true;
|
||||
|
||||
@@ -704,8 +704,9 @@ export class TuiClient {
|
||||
*
|
||||
* Two hard rules from CLAUDE.md, both enforced here so no caller can get them
|
||||
* wrong: the payload must END with `\r` or the server never issues Enter and
|
||||
* the text sits unsubmitted, and embedded newlines are stripped rather than
|
||||
* sent (multi-line input breaks Ink, and the server would join the lines).
|
||||
* the text sits unsubmitted, and embedded newlines become spaces rather than
|
||||
* being sent (the server would paste them as one block, which its submit
|
||||
* check cannot reliably recognise in the composer).
|
||||
* The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a
|
||||
* dropped connection cannot type the prompt twice.
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,7 @@ export { MAX_SESSION_TOKENS } from './token-validation.js';
|
||||
export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { toSingleLine } from './single-line.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @fileoverview Collapse a prompt onto the one line an unattended sender writes.
|
||||
*
|
||||
* @module utils/single-line
|
||||
*/
|
||||
|
||||
/**
|
||||
* A prompt as one line: each line is trimmed, blank lines are dropped, and the rest
|
||||
* are joined with a single space; a prompt without a line break is returned as is.
|
||||
*
|
||||
* For the senders that run unattended through `writeViaMux` (respawn's update and
|
||||
* kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block,
|
||||
* and the check that re-presses a stranded Enter cannot reliably recognise a
|
||||
* multi-line paste in the composer, so nobody would notice a prompt left unsubmitted.
|
||||
* Cron rejects multi-line prompts for the same reason; these senders join them
|
||||
* instead, so a saved multi-line setting is still sent rather than refused.
|
||||
*
|
||||
* Split and trim, which is linear, rather than one regex replace that folds the
|
||||
* whitespace around each break: that regex backtracks quadratically over a long
|
||||
* whitespace run (50,000 spaces took over a second).
|
||||
*/
|
||||
export function toSingleLine(prompt: string): string {
|
||||
if (!/[\r\n]/.test(prompt)) return prompt;
|
||||
return prompt
|
||||
.split(/[\r\n]+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
+1
-1
@@ -70,7 +70,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
|
||||
@@ -3938,8 +3938,8 @@ class CodemanApp {
|
||||
// Both transports refuse a frame over the server's limit (issue #484), and a
|
||||
// refused frame used to sit at the head of the durable queue for good. So an
|
||||
// oversized paste goes out as several in-limit frames, delivered in seq order
|
||||
// as one contiguous stream. A mux write is line-oriented (it strips newlines
|
||||
// and sends Enter on its own), so it is never split: refuse it instead.
|
||||
// as one contiguous stream. A mux write is one paste that sends Enter on its
|
||||
// own, so it is never split: refuse it instead.
|
||||
const limit = window.CodemanInputLimit;
|
||||
if (limit && input.length > limit.FRAME_MAX_CHARS) {
|
||||
if (useMux || input.length > limit.PASTE_MAX_CHARS) {
|
||||
|
||||
@@ -1432,9 +1432,10 @@ const KeyboardAccessoryBar = {
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
// single-line draft can make the trip: the prompt transport is single-line
|
||||
// and strips embedded newlines, so a multi-line draft would arrive mangled
|
||||
// and instead stays parked as a draft (the dot on the Compose key shows it).
|
||||
// single-line draft can make the trip: the terminal prompt is one line and
|
||||
// its text reaches the PTY as typed input, where an embedded newline is a
|
||||
// key, so a multi-line draft would arrive mangled and instead stays parked
|
||||
// as a draft (the dot on the Compose key shows it).
|
||||
const text = textarea.value;
|
||||
if (!text || text.includes('\n')) {
|
||||
close({ focusTerminal: true });
|
||||
|
||||
+1
-1
@@ -1905,7 +1905,7 @@ export const ScheduledRunSchema = z.object({
|
||||
/** 'HH:MM' 24-hour time. */
|
||||
const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)');
|
||||
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */
|
||||
/** Prompt delivery is single-line only (the submit check cannot reliably recognise a multi-line paste) — reject newlines outright. */
|
||||
const noNewlines = (v: string) => !/[\r\n]/.test(v);
|
||||
|
||||
/** Shared field shape for creating/updating a scheduled job. */
|
||||
|
||||
Reference in new issue
Block a user