From 2173cd3db2d39700f07a605a71dc62e15360a54e Mon Sep 17 00:00:00 2001 From: yuzifu Date: Fri, 15 May 2026 14:05:19 +0800 Subject: [PATCH 1/8] fix(terminal): separate prompt after unterminated command output Add a display-layer prompt line break handler so recognized shell prompts move to the next visual line when the final command output line is not newline terminated. Also add a terminal setting to toggle the behavior, sync support, i18n copy, and focused tests for prompt insertion. --- application/i18n/locales/en.ts | 3 + application/i18n/locales/zh-CN.ts | 3 + application/syncPayload.ts | 2 +- components/Terminal.tsx | 10 ++ .../settings/tabs/SettingsTerminalTab.tsx | 7 + .../runtime/createTerminalSessionStarters.ts | 29 +++- .../terminal/runtime/createXTermRuntime.ts | 7 + .../terminal/runtime/promptLineBreak.test.ts | 39 +++++ .../terminal/runtime/promptLineBreak.ts | 155 ++++++++++++++++++ domain/models.ts | 6 + 10 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 components/terminal/runtime/promptLineBreak.test.ts create mode 100644 components/terminal/runtime/promptLineBreak.ts diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 40ff77574..57d21794d 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -323,6 +323,9 @@ const en: Messages = { 'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing', 'settings.terminal.behavior.preserveSelectionOnInput.desc': 'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.', + 'settings.terminal.behavior.forcePromptNewLine': 'Prompt on a new line', + 'settings.terminal.behavior.forcePromptNewLine.desc': + 'When the final line of command output is not terminated by a newline, move the recognized shell prompt to the next visual line.', 'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard', 'settings.terminal.behavior.osc52Clipboard.desc': 'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index 23dbe90f4..bd35a6569 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1459,6 +1459,9 @@ const zhCN: Messages = { 'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区', 'settings.terminal.behavior.preserveSelectionOnInput.desc': '键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。', + 'settings.terminal.behavior.forcePromptNewLine': '提示符另起一行', + 'settings.terminal.behavior.forcePromptNewLine.desc': + '当命令输出的最后一行未以换行符结束时,将识别到的 shell 提示符移动到下一行显示。', 'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板', 'settings.terminal.behavior.osc52Clipboard.desc': '允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。', diff --git a/application/syncPayload.ts b/application/syncPayload.ts index 3bb540c07..219e74c05 100644 --- a/application/syncPayload.ts +++ b/application/syncPayload.ts @@ -160,7 +160,7 @@ const SYNCABLE_TERMINAL_KEYS = [ 'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators', 'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules', 'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback', - 'preserveSelectionOnInput', 'osc52Clipboard', 'showServerStats', + 'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'serverStatsRefreshInterval', 'rendererType', 'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu', 'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions', diff --git a/components/Terminal.tsx b/components/Terminal.tsx index f2c9732e5..359cb9c03 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -54,6 +54,11 @@ import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer"; import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters"; import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime"; import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference"; +import { + createPromptLineBreakState, + markPromptLineBreakCommandPending, + type PromptLineBreakState, +} from "./terminal/runtime/promptLineBreak"; import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus"; import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport"; import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance"; @@ -287,6 +292,7 @@ const TerminalComponent: React.FC = ({ const terminalDataCapturedRef = useRef(false); const onTerminalDataCaptureRef = useRef(onTerminalDataCapture); const commandBufferRef = useRef(""); + const promptLineBreakStateRef = useRef(createPromptLineBreakState()); const [hasMouseTracking, setHasMouseTracking] = useState(false); const mouseTrackingRef = useRef(false); const serialLineBufferRef = useRef(""); @@ -480,6 +486,7 @@ const TerminalComponent: React.FC = ({ const cmd = commandBufferRef.current.trim(); if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId); commandBufferRef.current = ""; + markPromptLineBreakCommandPending(promptLineBreakStateRef); } else if (ch === "\x15") { // Ctrl+U: clear line — reset command buffer (fuzzy match sends this) commandBufferRef.current = ""; @@ -811,6 +818,7 @@ const TerminalComponent: React.FC = ({ fitAddonRef, serializeAddonRef, pendingAuthRef, + promptLineBreakStateRef, updateStatus, setStatus, setError, @@ -863,6 +871,7 @@ const TerminalComponent: React.FC = ({ setShowLogs(false); setIsCancelling(false); setIsDisconnectedDialogDismissed(false); + promptLineBreakStateRef.current = createPromptLineBreakState(); const boot = async () => { try { @@ -887,6 +896,7 @@ const TerminalComponent: React.FC = ({ statusRef, onCommandExecuted, commandBufferRef, + promptLineBreakStateRef, setIsSearchOpen, // Serial-specific options serialLocalEcho: serialConfig?.localEcho, diff --git a/components/settings/tabs/SettingsTerminalTab.tsx b/components/settings/tabs/SettingsTerminalTab.tsx index feb850689..510678116 100644 --- a/components/settings/tabs/SettingsTerminalTab.tsx +++ b/components/settings/tabs/SettingsTerminalTab.tsx @@ -890,6 +890,13 @@ export default function SettingsTerminalTab(props: { updateTerminalSetting("preserveSelectionOnInput", v)} /> + + updateTerminalSetting("forcePromptNewLine", v)} /> + + ; serializeAddonRef: RefObject; pendingAuthRef: RefObject; + promptLineBreakStateRef?: RefObject; updateStatus: (next: TerminalSession["status"]) => void; setStatus: Dispatch>; @@ -210,17 +217,34 @@ const writeSessionData = ( term: XTerm, data: string, ) => { - const displayData = prepareTerminalDataForUserPasteDisplay(term, data); const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings; + const forcePromptNewLine = settings?.forcePromptNewLine ?? true; + if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) { + ctx.promptLineBreakStateRef.current.pendingCommand = false; + ctx.promptLineBreakStateRef.current.suppressNextPromptCache = false; + } + const displayData = prepareTerminalDataForPromptLineBreak( + term, + prepareTerminalDataForUserPasteDisplay(term, data), + ctx.promptLineBreakStateRef?.current, + forcePromptNewLine, + ); + const syncPrompt = () => { + if (forcePromptNewLine) { + syncPromptLineBreakState(term, ctx.promptLineBreakStateRef?.current); + } + }; if (!shouldScrollOnTerminalOutput(settings)) { term.write(displayData, () => { clearPasteResidualAfterTerminalWrite(term); + syncPrompt(); }); return; } term.write(displayData, () => { clearPasteResidualAfterTerminalWrite(term); + syncPrompt(); handleTerminalOutputAutoScroll(ctx, term); }); }; @@ -311,6 +335,9 @@ const scheduleStartupCommand = ( ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`, { automated: true, }); + if (!ctx.noAutoRun) { + markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef); + } onSettled?.(); if (!ctx.noAutoRun && ctx.onCommandExecuted) { ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId); diff --git a/components/terminal/runtime/createXTermRuntime.ts b/components/terminal/runtime/createXTermRuntime.ts index cd9af162a..1e9c1baa0 100644 --- a/components/terminal/runtime/createXTermRuntime.ts +++ b/components/terminal/runtime/createXTermRuntime.ts @@ -48,6 +48,10 @@ import { pasteTextIntoTerminal, shouldSuppressTerminalInputScrollForUserPaste, } from "./terminalUserPaste"; +import { + markPromptLineBreakCommandPending, + type PromptLineBreakState, +} from "./promptLineBreak"; import type { Host, KeyBinding, @@ -108,6 +112,7 @@ export type CreateXTermRuntimeContext = { sessionId: string, ) => void; commandBufferRef: RefObject; + promptLineBreakStateRef?: RefObject; setIsSearchOpen: Dispatch>; // Serial-specific options @@ -503,6 +508,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime const cmd = snippet.command.trim(); if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId); ctx.commandBufferRef.current = ""; + markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef); } return false; } @@ -666,6 +672,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime const cmd = ctx.commandBufferRef.current.trim(); if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId); ctx.commandBufferRef.current = ""; + markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef); } else if (data === "\x7f" || data === "\b") { ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1); } else if (data === "\x03") { diff --git a/components/terminal/runtime/promptLineBreak.test.ts b/components/terminal/runtime/promptLineBreak.test.ts new file mode 100644 index 000000000..3cb55f981 --- /dev/null +++ b/components/terminal/runtime/promptLineBreak.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { insertPromptLineBreakBeforePrompt } from "./promptLineBreak"; + +test("inserts a visual line break before a prompt after an unterminated final output line", () => { + assert.equal( + insertPromptLineBreakBeforePrompt("hello$ ", "$ ", 0), + "hello\r\n$ ", + ); +}); + +test("inserts at the start of a prompt chunk when previous output left the cursor mid-line", () => { + assert.equal( + insertPromptLineBreakBeforePrompt("$ ", "$ ", 5), + "\r\n$ ", + ); +}); + +test("does not insert when the output already ends with a line break", () => { + assert.equal( + insertPromptLineBreakBeforePrompt("hello\r\n$ ", "$ ", 0), + "hello\r\n$ ", + ); +}); + +test("keeps prompt ANSI styling on the prompt side of the inserted line break", () => { + assert.equal( + insertPromptLineBreakBeforePrompt("hello\x1b[32m$ \x1b[0m", "$ ", 0), + "hello\r\n\x1b[32m$ \x1b[0m", + ); +}); + +test("does not insert for non-prompt output", () => { + assert.equal( + insertPromptLineBreakBeforePrompt("hello> ", "$ ", 0), + "hello> ", + ); +}); diff --git a/components/terminal/runtime/promptLineBreak.ts b/components/terminal/runtime/promptLineBreak.ts new file mode 100644 index 000000000..d78fdb70a --- /dev/null +++ b/components/terminal/runtime/promptLineBreak.ts @@ -0,0 +1,155 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; +import type { RefObject } from "react"; +import { detectPrompt } from "../autocomplete/promptDetector"; + +export type PromptLineBreakState = { + lastPromptText: string; + pendingCommand: boolean; + suppressNextPromptCache: boolean; +}; + +type VisibleTextMap = { + text: string; + rawStartByTextIndex: number[]; +}; + +const ESC = "\x1b"; +const BEL = "\x07"; + +const isCsiFinalByte = (char: string): boolean => { + const code = char.charCodeAt(0); + return code >= 0x40 && code <= 0x7e; +}; + +const mapVisibleText = (data: string): VisibleTextMap => { + let text = ""; + const rawStartByTextIndex: number[] = []; + let nextVisibleSegmentStart = 0; + + const appendVisible = (index: number, char: string) => { + rawStartByTextIndex.push(nextVisibleSegmentStart); + text += char; + nextVisibleSegmentStart = index + char.length; + }; + + for (let index = 0; index < data.length; index += 1) { + const char = data[index]; + if (char !== ESC) { + appendVisible(index, char); + continue; + } + + const nextChar = data[index + 1]; + if (nextChar === "[") { + index += 2; + while (index < data.length && !isCsiFinalByte(data[index])) { + index += 1; + } + continue; + } + + if (nextChar === "]") { + index += 2; + while (index < data.length) { + if (data[index] === BEL) break; + if (data[index] === ESC && data[index + 1] === "\\") { + index += 1; + break; + } + index += 1; + } + continue; + } + + if (nextChar) { + index += 1; + } + } + + return { text, rawStartByTextIndex }; +}; + +const endsWithLineBreak = (text: string): boolean => { + const last = text[text.length - 1]; + return last === "\n" || last === "\r"; +}; + +const getCursorX = (term: XTerm): number => { + try { + return term.buffer.active.cursorX; + } catch { + return 0; + } +}; + +export function createPromptLineBreakState(): PromptLineBreakState { + return { + lastPromptText: "", + pendingCommand: false, + suppressNextPromptCache: false, + }; +} + +export function markPromptLineBreakCommandPending( + stateRef?: RefObject, +): void { + if (!stateRef?.current) return; + stateRef.current.pendingCommand = true; + stateRef.current.suppressNextPromptCache = false; +} + +export function insertPromptLineBreakBeforePrompt( + data: string, + promptText: string, + cursorXBeforeWrite: number, +): string { + if (!data || !promptText) return data; + + const mapped = mapVisibleText(data); + if (!mapped.text.endsWith(promptText)) return data; + + const promptTextStart = mapped.text.length - promptText.length; + const prefixText = mapped.text.slice(0, promptTextStart); + if (prefixText.length === 0 && cursorXBeforeWrite <= 0) return data; + if (prefixText.length > 0 && endsWithLineBreak(prefixText)) return data; + + const promptRawStart = mapped.rawStartByTextIndex[promptTextStart] ?? 0; + return `${data.slice(0, promptRawStart)}\r\n${data.slice(promptRawStart)}`; +} + +export function prepareTerminalDataForPromptLineBreak( + term: XTerm, + data: string, + state: PromptLineBreakState | undefined, + enabled: boolean, +): string { + if (!enabled || !state?.pendingCommand || !state.lastPromptText) return data; + + const cursorXBeforeWrite = getCursorX(term); + const nextData = insertPromptLineBreakBeforePrompt( + data, + state.lastPromptText, + cursorXBeforeWrite, + ); + state.suppressNextPromptCache = nextData === data && cursorXBeforeWrite > 0; + return nextData; +} + +export function syncPromptLineBreakState(term: XTerm, state?: PromptLineBreakState): void { + if (!state) return; + + const prompt = detectPrompt(term); + if (!prompt.isAtPrompt || prompt.userInput.length > 0) return; + + if (state.pendingCommand && state.suppressNextPromptCache) { + state.suppressNextPromptCache = false; + state.pendingCommand = false; + return; + } + + if (!state.pendingCommand || state.lastPromptText) { + state.lastPromptText = prompt.promptText; + } + state.suppressNextPromptCache = false; + state.pendingCommand = false; +} diff --git a/domain/models.ts b/domain/models.ts index 9a3cda1c3..ee3f75d26 100755 --- a/domain/models.ts +++ b/domain/models.ts @@ -544,6 +544,11 @@ export interface TerminalSettings { // on input; this opt-in toggle restores the selection right after. preserveSelectionOnInput: boolean; + // When the final visible output line from a command is not terminated by a + // newline, move a recognized shell prompt to the next visual line. This is + // display-only; raw session logs keep the original byte stream. + forcePromptNewLine: boolean; + // Clipboard osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read @@ -715,6 +720,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { disableBracketedPaste: false, // Bracketed paste enabled by default clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing + forcePromptNewLine: true, // Keep the next shell prompt visually separated from unterminated final output lines osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default rendererType: 'auto', // Auto-detect best renderer based on hardware autocompleteEnabled: true, // Autocomplete enabled by default From 469af75dfe4307b60340e39bcb94ac4542de6b80 Mon Sep 17 00:00:00 2001 From: yuzifu Date: Fri, 15 May 2026 14:30:58 +0800 Subject: [PATCH 2/8] fix review issue --- .../terminal/runtime/promptLineBreak.test.ts | 75 ++++++++++++++++++- .../terminal/runtime/promptLineBreak.ts | 8 +- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/components/terminal/runtime/promptLineBreak.test.ts b/components/terminal/runtime/promptLineBreak.test.ts index 3cb55f981..7ad60437b 100644 --- a/components/terminal/runtime/promptLineBreak.test.ts +++ b/components/terminal/runtime/promptLineBreak.test.ts @@ -1,7 +1,33 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { insertPromptLineBreakBeforePrompt } from "./promptLineBreak"; +import { + createPromptLineBreakState, + insertPromptLineBreakBeforePrompt, + prepareTerminalDataForPromptLineBreak, + syncPromptLineBreakState, +} from "./promptLineBreak"; + +function createFakeTerm(lineText = "", cursorX = lineText.length) { + return { + buffer: { + active: { + cursorX, + cursorY: 0, + baseY: 0, + getLine(line: number) { + if (line !== 0) return undefined; + return { + isWrapped: false, + translateToString() { + return lineText; + }, + }; + }, + }, + }, + }; +} test("inserts a visual line break before a prompt after an unterminated final output line", () => { assert.equal( @@ -37,3 +63,50 @@ test("does not insert for non-prompt output", () => { "hello> ", ); }); + +test("refreshes cached prompt when a changed prompt arrives after a line break in the same chunk", () => { + const state = createPromptLineBreakState(); + state.lastPromptText = "old$ "; + state.pendingCommand = true; + const termBeforeWrite = createFakeTerm("old$ cd /tmp", 12); + + assert.equal( + prepareTerminalDataForPromptLineBreak( + termBeforeWrite as never, + "\r\nnew$ ", + state, + true, + ), + "\r\nnew$ ", + ); + assert.equal(state.suppressNextPromptCache, false); + + syncPromptLineBreakState(createFakeTerm("new$ ") as never, state); + + assert.equal(state.lastPromptText, "new$ "); + assert.equal(state.pendingCommand, false); +}); + +test("does not refresh cached prompt from an unchanged mid-line write without a line reset", () => { + const state = createPromptLineBreakState(); + state.lastPromptText = "old$ "; + state.pendingCommand = true; + const termBeforeWrite = createFakeTerm("old$ run", 8); + + assert.equal( + prepareTerminalDataForPromptLineBreak( + termBeforeWrite as never, + "outputnew$ ", + state, + true, + ), + "outputnew$ ", + ); + assert.equal(state.suppressNextPromptCache, true); + + syncPromptLineBreakState(createFakeTerm("outputnew$ ") as never, state); + + assert.equal(state.lastPromptText, "old$ "); + assert.equal(state.pendingCommand, false); + assert.equal(state.suppressNextPromptCache, false); +}); diff --git a/components/terminal/runtime/promptLineBreak.ts b/components/terminal/runtime/promptLineBreak.ts index d78fdb70a..cca9e4c5b 100644 --- a/components/terminal/runtime/promptLineBreak.ts +++ b/components/terminal/runtime/promptLineBreak.ts @@ -74,6 +74,9 @@ const endsWithLineBreak = (text: string): boolean => { return last === "\n" || last === "\r"; }; +const containsLineReset = (text: string): boolean => + text.includes("\n") || text.includes("\r"); + const getCursorX = (term: XTerm): number => { try { return term.buffer.active.cursorX; @@ -131,7 +134,10 @@ export function prepareTerminalDataForPromptLineBreak( state.lastPromptText, cursorXBeforeWrite, ); - state.suppressNextPromptCache = nextData === data && cursorXBeforeWrite > 0; + state.suppressNextPromptCache = + nextData === data && + cursorXBeforeWrite > 0 && + !containsLineReset(mapVisibleText(data).text); return nextData; } From ab57b6142a9a0b3542083efb385f29bb8c625de7 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 18 May 2026 17:31:55 +0800 Subject: [PATCH 3/8] Fix prompt cache initialization --- components/terminal/runtime/promptLineBreak.test.ts | 11 +++++++++++ components/terminal/runtime/promptLineBreak.ts | 4 +--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/components/terminal/runtime/promptLineBreak.test.ts b/components/terminal/runtime/promptLineBreak.test.ts index 7ad60437b..930e732cf 100644 --- a/components/terminal/runtime/promptLineBreak.test.ts +++ b/components/terminal/runtime/promptLineBreak.test.ts @@ -87,6 +87,17 @@ test("refreshes cached prompt when a changed prompt arrives after a line break i assert.equal(state.pendingCommand, false); }); +test("caches the first valid prompt even when a command is already pending", () => { + const state = createPromptLineBreakState(); + state.pendingCommand = true; + + syncPromptLineBreakState(createFakeTerm("$ ") as never, state); + + assert.equal(state.lastPromptText, "$ "); + assert.equal(state.pendingCommand, false); + assert.equal(state.suppressNextPromptCache, false); +}); + test("does not refresh cached prompt from an unchanged mid-line write without a line reset", () => { const state = createPromptLineBreakState(); state.lastPromptText = "old$ "; diff --git a/components/terminal/runtime/promptLineBreak.ts b/components/terminal/runtime/promptLineBreak.ts index cca9e4c5b..a5341208b 100644 --- a/components/terminal/runtime/promptLineBreak.ts +++ b/components/terminal/runtime/promptLineBreak.ts @@ -153,9 +153,7 @@ export function syncPromptLineBreakState(term: XTerm, state?: PromptLineBreakSta return; } - if (!state.pendingCommand || state.lastPromptText) { - state.lastPromptText = prompt.promptText; - } + state.lastPromptText = prompt.promptText; state.suppressNextPromptCache = false; state.pendingCommand = false; } From b0e8906e32c465057cb22d4c546f1065730b37f4 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 18 May 2026 17:42:47 +0800 Subject: [PATCH 4/8] Serialize terminal output writes for prompt breaks --- .../createTerminalSessionStarters.test.ts | 112 ++++++++++++++++++ .../runtime/createTerminalSessionStarters.ts | 101 +++++++++++----- 2 files changed, 181 insertions(+), 32 deletions(-) diff --git a/components/terminal/runtime/createTerminalSessionStarters.test.ts b/components/terminal/runtime/createTerminalSessionStarters.test.ts index a8546c36d..85f123fa6 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.test.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createTerminalSessionStarters, getMissingChainHostIds } from "./createTerminalSessionStarters"; +import { createPromptLineBreakState } from "./promptLineBreak"; import { pasteTextIntoTerminal } from "./terminalUserPaste"; const noop = () => undefined; @@ -187,6 +188,117 @@ test("local session captures paste cleanup writes in terminal log data", async ( assert.deepEqual(capturedLogData, ["line 3 with enough content", "\x1b[K"]); }); +test("session data waits for prior terminal writes before evaluating prompt line breaks", async () => { + const writes: string[] = []; + const writeCallbacks: Array<() => void> = []; + let onData: ((data: string) => void) | null = null; + let cursorX = 0; + let lineText = ""; + + const terminalBackend = { + backendAvailable: () => true, + telnetAvailable: () => true, + moshAvailable: () => true, + localAvailable: () => true, + serialAvailable: () => true, + execAvailable: () => true, + startSSHSession: async () => "ssh-session", + startTelnetSession: async () => "telnet-session", + startMoshSession: async () => "mosh-session", + startLocalSession: async () => "local-session", + startSerialSession: async () => "serial-session", + execCommand: async () => ({}), + onSessionData: (_id: string, cb: (data: string) => void) => { + onData = cb; + return noop; + }, + onSessionExit: () => noop, + onChainProgress: () => noop, + writeToSession: noop, + resizeSession: noop, + }; + + const promptState = createPromptLineBreakState(); + promptState.lastPromptText = "$ "; + promptState.pendingCommand = true; + + const ctx = { + host: { + id: "local-host", + label: "Local", + hostname: "local", + username: "", + protocol: "local", + }, + keys: [], + resolvedChainHosts: [], + sessionId: "session-1", + terminalSettings: { forcePromptNewLine: true }, + terminalBackend, + promptLineBreakStateRef: { current: promptState }, + sessionRef: { current: null }, + hasConnectedRef: { current: false }, + hasRunStartupCommandRef: { current: false }, + disposeDataRef: { current: null }, + disposeExitRef: { current: null }, + fitAddonRef: { current: null }, + serializeAddonRef: { current: null }, + pendingAuthRef: { current: null }, + updateStatus: noop, + setStatus: noop, + setError: noop, + setNeedsAuth: noop, + setAuthRetryMessage: noop, + setAuthPassword: noop, + setProgressLogs: noop, + setProgressValue: noop, + setChainProgress: noop, + }; + + const term = { + get buffer() { + return { + active: { + get cursorX() { + return cursorX; + }, + cursorY: 0, + baseY: 0, + getLine(line: number) { + if (line !== 0) return undefined; + return { + isWrapped: false, + translateToString() { + return lineText; + }, + }; + }, + }, + }; + }, + write: (data: string, callback?: () => void) => { + writes.push(data); + if (callback) writeCallbacks.push(callback); + }, + writeln: noop, + scrollToBottom: noop, + }; + + await createTerminalSessionStarters(ctx as never).startLocal(term as never); + + assert.notEqual(onData, null); + onData?.("hello"); + onData?.("$ "); + + assert.deepEqual(writes, ["hello"]); + + cursorX = 5; + lineText = "hello"; + writeCallbacks.shift()?.(); + + assert.deepEqual(writes, ["hello", "\r\n$ "]); +}); + test("startSSH allows jump hosts that use reference key files with unavailable saved passphrases", async () => { let capturedOptions: Record | null = null; let error = ""; diff --git a/components/terminal/runtime/createTerminalSessionStarters.ts b/components/terminal/runtime/createTerminalSessionStarters.ts index 5be58b4f6..7ac6ddb3b 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.ts @@ -222,47 +222,84 @@ const writeTerminalLine = ( term.writeln(data); }; +type TerminalWriteQueue = { + writing: boolean; + pending: Array<() => void>; +}; + +const terminalWriteQueues = new WeakMap(); + +const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => { + const next = queue.pending.shift(); + if (!next) { + queue.writing = false; + terminalWriteQueues.delete(term); + return; + } + + queue.writing = true; + next(); +}; + +const enqueueTerminalWrite = ( + term: XTerm, + write: (done: () => void) => void, +) => { + let queue = terminalWriteQueues.get(term); + if (!queue) { + queue = { writing: false, pending: [] }; + terminalWriteQueues.set(term, queue); + } + + queue.pending.push(() => { + write(() => scheduleNextTerminalWrite(term, queue)); + }); + + if (!queue.writing) { + scheduleNextTerminalWrite(term, queue); + } +}; + const writeSessionData = ( ctx: TerminalSessionStartersContext, term: XTerm, data: string, ) => { - const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings; - const forcePromptNewLine = settings?.forcePromptNewLine ?? true; - if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) { - ctx.promptLineBreakStateRef.current.pendingCommand = false; - ctx.promptLineBreakStateRef.current.suppressNextPromptCache = false; - } - const displayData = prepareTerminalDataForPromptLineBreak( - term, - prepareTerminalDataForUserPasteDisplay(term, data), - ctx.promptLineBreakStateRef?.current, - forcePromptNewLine, - ); - ctx.onTerminalLogData?.(displayData); - const clearPasteResidualAndCapture = () => { - const cleanupData = clearPasteResidualAfterTerminalWrite(term); - if (cleanupData) { - ctx.onTerminalLogData?.(cleanupData); - } - }; - const syncPrompt = () => { - if (forcePromptNewLine) { - syncPromptLineBreakState(term, ctx.promptLineBreakStateRef?.current); + enqueueTerminalWrite(term, (done) => { + const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings; + const forcePromptNewLine = settings?.forcePromptNewLine ?? true; + if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) { + ctx.promptLineBreakStateRef.current.pendingCommand = false; + ctx.promptLineBreakStateRef.current.suppressNextPromptCache = false; } - }; - if (!shouldScrollOnTerminalOutput(settings)) { - term.write(displayData, () => { + const displayData = prepareTerminalDataForPromptLineBreak( + term, + prepareTerminalDataForUserPasteDisplay(term, data), + ctx.promptLineBreakStateRef?.current, + forcePromptNewLine, + ); + ctx.onTerminalLogData?.(displayData); + const clearPasteResidualAndCapture = () => { + const cleanupData = clearPasteResidualAfterTerminalWrite(term); + if (cleanupData) { + ctx.onTerminalLogData?.(cleanupData); + } + }; + const syncPrompt = () => { + if (forcePromptNewLine) { + syncPromptLineBreakState(term, ctx.promptLineBreakStateRef?.current); + } + }; + const afterWrite = () => { clearPasteResidualAndCapture(); syncPrompt(); - }); - return; - } + if (shouldScrollOnTerminalOutput(settings)) { + handleTerminalOutputAutoScroll(ctx, term); + } + done(); + }; - term.write(displayData, () => { - clearPasteResidualAndCapture(); - syncPrompt(); - handleTerminalOutputAutoScroll(ctx, term); + term.write(displayData, afterWrite); }); }; From 2878a6b5a5ad26c12e0fba833384a3494643b2d6 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 18 May 2026 17:53:03 +0800 Subject: [PATCH 5/8] Keep terminal status lines ordered with output --- .../createTerminalSessionStarters.test.ts | 102 +++++++++++++++++- .../runtime/createTerminalSessionStarters.ts | 21 ++-- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/components/terminal/runtime/createTerminalSessionStarters.test.ts b/components/terminal/runtime/createTerminalSessionStarters.test.ts index 85f123fa6..dfb8ff6c7 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.test.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.test.ts @@ -26,7 +26,7 @@ test("getMissingChainHostIds reports unresolved jump hosts", () => { test("startSerial captures direct connected banner in terminal log data", async () => { const capturedLogData: string[] = []; - const writtenLines: string[] = []; + const writtenData: string[] = []; const terminalBackend = { backendAvailable: () => true, @@ -92,15 +92,18 @@ test("startSerial captures direct connected banner in terminal log data", async const term = { cols: 120, rows: 32, - write: noop, - writeln: (data: string) => writtenLines.push(data), + write: (data: string, callback?: () => void) => { + writtenData.push(data); + callback?.(); + }, + writeln: noop, scrollToBottom: noop, }; await createTerminalSessionStarters(ctx as never).startSerial(term as never); const banner = "[Connected to COM3 at 9600 baud]"; - assert.deepEqual(writtenLines, [banner]); + assert.deepEqual(writtenData, [`${banner}\r\n`]); assert.deepEqual(capturedLogData, [`${banner}\r\n`]); }); @@ -299,6 +302,97 @@ test("session data waits for prior terminal writes before evaluating prompt line assert.deepEqual(writes, ["hello", "\r\n$ "]); }); +test("local session exit text waits for pending terminal output writes", async () => { + const writes: string[] = []; + const writeCallbacks: Array<() => void> = []; + let onData: ((data: string) => void) | null = null; + let onExit: ((evt: { reason?: "closed" }) => void) | null = null; + + const terminalBackend = { + backendAvailable: () => true, + telnetAvailable: () => true, + moshAvailable: () => true, + localAvailable: () => true, + serialAvailable: () => true, + execAvailable: () => true, + startSSHSession: async () => "ssh-session", + startTelnetSession: async () => "telnet-session", + startMoshSession: async () => "mosh-session", + startLocalSession: async () => "local-session", + startSerialSession: async () => "serial-session", + execCommand: async () => ({}), + onSessionData: (_id: string, cb: (data: string) => void) => { + onData = cb; + return noop; + }, + onSessionExit: (_id: string, cb: (evt: { reason?: "closed" }) => void) => { + onExit = cb; + return noop; + }, + onChainProgress: () => noop, + writeToSession: noop, + resizeSession: noop, + }; + + const ctx = { + host: { + id: "local-host", + label: "Local", + hostname: "local", + username: "", + protocol: "local", + }, + keys: [], + resolvedChainHosts: [], + sessionId: "session-1", + terminalSettings: {}, + terminalBackend, + sessionRef: { current: null }, + hasConnectedRef: { current: false }, + hasRunStartupCommandRef: { current: false }, + disposeDataRef: { current: null }, + disposeExitRef: { current: null }, + fitAddonRef: { current: null }, + serializeAddonRef: { current: null }, + pendingAuthRef: { current: null }, + updateStatus: noop, + setStatus: noop, + setError: noop, + setNeedsAuth: noop, + setAuthRetryMessage: noop, + setAuthPassword: noop, + setProgressLogs: noop, + setProgressValue: noop, + setChainProgress: noop, + }; + + const term = { + cols: 20, + rows: 4, + write: (data: string, callback?: () => void) => { + writes.push(data); + if (callback) writeCallbacks.push(callback); + }, + writeln: (data: string) => { + writes.push(`${data}\r\n`); + }, + scrollToBottom: noop, + }; + + await createTerminalSessionStarters(ctx as never).startLocal(term as never); + + assert.notEqual(onData, null); + assert.notEqual(onExit, null); + onData?.("partial output"); + onExit?.({ reason: "closed" }); + + assert.deepEqual(writes, ["partial output"]); + + writeCallbacks.shift()?.(); + + assert.deepEqual(writes, ["partial output", "\r\n[session closed]\r\n"]); +}); + test("startSSH allows jump hosts that use reference key files with unavailable saved passphrases", async () => { let capturedOptions: Record | null = null; let error = ""; diff --git a/components/terminal/runtime/createTerminalSessionStarters.ts b/components/terminal/runtime/createTerminalSessionStarters.ts index 7ac6ddb3b..588638d4b 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.ts @@ -213,15 +213,6 @@ const handleTerminalOutputAutoScroll = ( term.scrollToBottom(); }; -const writeTerminalLine = ( - ctx: TerminalSessionStartersContext, - term: XTerm, - data: string, -) => { - ctx.onTerminalLogData?.(`${data}\r\n`); - term.writeln(data); -}; - type TerminalWriteQueue = { writing: boolean; pending: Array<() => void>; @@ -260,6 +251,18 @@ const enqueueTerminalWrite = ( } }; +const writeTerminalLine = ( + ctx: TerminalSessionStartersContext, + term: XTerm, + data: string, +) => { + enqueueTerminalWrite(term, (done) => { + const lineData = `${data}\r\n`; + ctx.onTerminalLogData?.(lineData); + term.write(lineData, done); + }); +}; + const writeSessionData = ( ctx: TerminalSessionStartersContext, term: XTerm, From d860bae4c31486f63bba03a5699373e8c3a9be6d Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 18 May 2026 18:06:07 +0800 Subject: [PATCH 6/8] Fix prompt arming without command callback --- application/i18n/locales/ru.ts | 3 ++ .../runtime/createXTermRuntime.test.ts | 25 +++++++++++++++ .../terminal/runtime/createXTermRuntime.ts | 16 +++------- .../runtime/terminalCommandExecution.ts | 31 +++++++++++++++++++ 4 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 components/terminal/runtime/createXTermRuntime.test.ts create mode 100644 components/terminal/runtime/terminalCommandExecution.ts diff --git a/application/i18n/locales/ru.ts b/application/i18n/locales/ru.ts index 1e4dbe7f6..9c3480464 100644 --- a/application/i18n/locales/ru.ts +++ b/application/i18n/locales/ru.ts @@ -323,6 +323,9 @@ const ru: Messages = { 'settings.terminal.behavior.preserveSelectionOnInput': 'Сохранять выделение при вводе', 'settings.terminal.behavior.preserveSelectionOnInput.desc': 'Не сбрасывать выделенный мышью текст при вводе. Это удобно, например, чтобы выделить путь и вставить его после префикса команды вроде `sz `.', + 'settings.terminal.behavior.forcePromptNewLine': 'Переносить приглашение на новую строку', + 'settings.terminal.behavior.forcePromptNewLine.desc': + 'Если последняя строка вывода команды не завершена переводом строки, переносить распознанное приглашение оболочки на следующую визуальную строку.', 'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52', 'settings.terminal.behavior.osc52Clipboard.desc': 'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.', diff --git a/components/terminal/runtime/createXTermRuntime.test.ts b/components/terminal/runtime/createXTermRuntime.test.ts new file mode 100644 index 000000000..f9f4cfcf0 --- /dev/null +++ b/components/terminal/runtime/createXTermRuntime.test.ts @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { recordTerminalCommandExecution } from "./terminalCommandExecution"; +import { createPromptLineBreakState } from "./promptLineBreak"; + +test("command execution arms prompt line break even without command history callback", () => { + const promptState = createPromptLineBreakState(); + const commandBufferRef = { current: "echo ok" }; + + recordTerminalCommandExecution("echo ok", { + host: { + id: "host-1", + label: "Host", + hostname: "example.test", + username: "alice", + }, + sessionId: "session-1", + commandBufferRef, + promptLineBreakStateRef: { current: promptState }, + }); + + assert.equal(commandBufferRef.current, ""); + assert.equal(promptState.pendingCommand, true); +}); diff --git a/components/terminal/runtime/createXTermRuntime.ts b/components/terminal/runtime/createXTermRuntime.ts index 151b9e733..405b48a46 100644 --- a/components/terminal/runtime/createXTermRuntime.ts +++ b/components/terminal/runtime/createXTermRuntime.ts @@ -50,9 +50,9 @@ import { shouldSuppressTerminalInputScrollForUserPaste, } from "./terminalUserPaste"; import { - markPromptLineBreakCommandPending, type PromptLineBreakState, } from "./promptLineBreak"; +import { recordTerminalCommandExecution } from "./terminalCommandExecution"; import type { Host, KeyBinding, @@ -513,11 +513,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime // where each \n executes an intermediate command (#814 P2). ctx.onAutocompleteInput?.(snippetData); ctx.terminalBackend.writeToSession(id, snippetData); - if (!snippet.noAutoRun && ctx.onCommandExecuted) { - const cmd = snippet.command.trim(); - if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId); - ctx.commandBufferRef.current = ""; - markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef); + if (!snippet.noAutoRun) { + recordTerminalCommandExecution(snippet.command, ctx); } return false; } @@ -688,12 +685,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime // Notify autocomplete of input ctx.onAutocompleteInput?.(data); - if (ctx.statusRef.current === "connected" && ctx.onCommandExecuted) { + if (ctx.statusRef.current === "connected") { if (data === "\r" || data === "\n") { - const cmd = ctx.commandBufferRef.current.trim(); - if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId); - ctx.commandBufferRef.current = ""; - markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef); + recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx); } else if (data === "\x7f" || data === "\b") { ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1); } else if (data === "\x03") { diff --git a/components/terminal/runtime/terminalCommandExecution.ts b/components/terminal/runtime/terminalCommandExecution.ts new file mode 100644 index 000000000..606798bb6 --- /dev/null +++ b/components/terminal/runtime/terminalCommandExecution.ts @@ -0,0 +1,31 @@ +import type { RefObject } from "react"; +import type { Host } from "../../../types"; +import { + markPromptLineBreakCommandPending, + type PromptLineBreakState, +} from "./promptLineBreak"; + +type TerminalCommandExecutionContext = { + host: Pick; + sessionId: string; + onCommandExecuted?: ( + command: string, + hostId: string, + hostLabel: string, + sessionId: string, + ) => void; + commandBufferRef: RefObject; + promptLineBreakStateRef?: RefObject; +}; + +export const recordTerminalCommandExecution = ( + command: string, + ctx: TerminalCommandExecutionContext, +) => { + const cmd = command.trim(); + if (cmd) { + ctx.onCommandExecuted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId); + } + ctx.commandBufferRef.current = ""; + markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef); +}; From 1e160e00ac65c126b776f382912122e948a3bbf2 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 18 May 2026 18:14:10 +0800 Subject: [PATCH 7/8] Keep prompt display breaks out of session logs --- .../createTerminalSessionStarters.test.ts | 112 ++++++++++++++++++ .../runtime/createTerminalSessionStarters.ts | 5 +- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/components/terminal/runtime/createTerminalSessionStarters.test.ts b/components/terminal/runtime/createTerminalSessionStarters.test.ts index dfb8ff6c7..525fb721d 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.test.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.test.ts @@ -302,6 +302,118 @@ test("session data waits for prior terminal writes before evaluating prompt line assert.deepEqual(writes, ["hello", "\r\n$ "]); }); +test("prompt line break display insertion does not mutate captured session log data", async () => { + const writes: string[] = []; + const capturedLogData: string[] = []; + const writeCallbacks: Array<() => void> = []; + let onData: ((data: string) => void) | null = null; + let cursorX = 0; + let lineText = ""; + + const terminalBackend = { + backendAvailable: () => true, + telnetAvailable: () => true, + moshAvailable: () => true, + localAvailable: () => true, + serialAvailable: () => true, + execAvailable: () => true, + startSSHSession: async () => "ssh-session", + startTelnetSession: async () => "telnet-session", + startMoshSession: async () => "mosh-session", + startLocalSession: async () => "local-session", + startSerialSession: async () => "serial-session", + execCommand: async () => ({}), + onSessionData: (_id: string, cb: (data: string) => void) => { + onData = cb; + return noop; + }, + onSessionExit: () => noop, + onChainProgress: () => noop, + writeToSession: noop, + resizeSession: noop, + }; + + const promptState = createPromptLineBreakState(); + promptState.lastPromptText = "$ "; + promptState.pendingCommand = true; + + const ctx = { + host: { + id: "local-host", + label: "Local", + hostname: "local", + username: "", + protocol: "local", + }, + keys: [], + resolvedChainHosts: [], + sessionId: "session-1", + terminalSettings: { forcePromptNewLine: true }, + terminalBackend, + promptLineBreakStateRef: { current: promptState }, + sessionRef: { current: null }, + hasConnectedRef: { current: false }, + hasRunStartupCommandRef: { current: false }, + disposeDataRef: { current: null }, + disposeExitRef: { current: null }, + fitAddonRef: { current: null }, + serializeAddonRef: { current: null }, + pendingAuthRef: { current: null }, + updateStatus: noop, + setStatus: noop, + setError: noop, + setNeedsAuth: noop, + setAuthRetryMessage: noop, + setAuthPassword: noop, + setProgressLogs: noop, + setProgressValue: noop, + setChainProgress: noop, + onTerminalLogData: (data: string) => capturedLogData.push(data), + }; + + const term = { + get buffer() { + return { + active: { + get cursorX() { + return cursorX; + }, + cursorY: 0, + baseY: 0, + getLine(line: number) { + if (line !== 0) return undefined; + return { + isWrapped: false, + translateToString() { + return lineText; + }, + }; + }, + }, + }; + }, + write: (data: string, callback?: () => void) => { + writes.push(data); + if (callback) writeCallbacks.push(callback); + }, + writeln: noop, + scrollToBottom: noop, + }; + + await createTerminalSessionStarters(ctx as never).startLocal(term as never); + + assert.notEqual(onData, null); + onData?.("hello"); + onData?.("$ "); + + cursorX = 5; + lineText = "hello"; + writeCallbacks.shift()?.(); + + assert.deepEqual(writes, ["hello", "\r\n$ "]); + assert.deepEqual(capturedLogData, ["hello", "$ "]); +}); + test("local session exit text waits for pending terminal output writes", async () => { const writes: string[] = []; const writeCallbacks: Array<() => void> = []; diff --git a/components/terminal/runtime/createTerminalSessionStarters.ts b/components/terminal/runtime/createTerminalSessionStarters.ts index 588638d4b..c39cd7163 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.ts @@ -275,13 +275,14 @@ const writeSessionData = ( ctx.promptLineBreakStateRef.current.pendingCommand = false; ctx.promptLineBreakStateRef.current.suppressNextPromptCache = false; } + const pasteDisplayData = prepareTerminalDataForUserPasteDisplay(term, data); const displayData = prepareTerminalDataForPromptLineBreak( term, - prepareTerminalDataForUserPasteDisplay(term, data), + pasteDisplayData, ctx.promptLineBreakStateRef?.current, forcePromptNewLine, ); - ctx.onTerminalLogData?.(displayData); + ctx.onTerminalLogData?.(pasteDisplayData); const clearPasteResidualAndCapture = () => { const cleanupData = clearPasteResidualAfterTerminalWrite(term); if (cleanupData) { From bfa29202449d0bd5a1d395779b83b706d2b1ad86 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Mon, 18 May 2026 18:21:30 +0800 Subject: [PATCH 8/8] Avoid prompt breaks for output suffix matches --- .../terminal/runtime/promptLineBreak.test.ts | 38 +++++++++++++++++-- .../terminal/runtime/promptLineBreak.ts | 17 +++++++-- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/components/terminal/runtime/promptLineBreak.test.ts b/components/terminal/runtime/promptLineBreak.test.ts index 930e732cf..9f6c8f5a8 100644 --- a/components/terminal/runtime/promptLineBreak.test.ts +++ b/components/terminal/runtime/promptLineBreak.test.ts @@ -29,10 +29,10 @@ function createFakeTerm(lineText = "", cursorX = lineText.length) { }; } -test("inserts a visual line break before a prompt after an unterminated final output line", () => { +test("does not insert before prompt-like suffixes in a larger output chunk", () => { assert.equal( insertPromptLineBreakBeforePrompt("hello$ ", "$ ", 0), - "hello\r\n$ ", + "hello$ ", ); }); @@ -52,8 +52,8 @@ test("does not insert when the output already ends with a line break", () => { test("keeps prompt ANSI styling on the prompt side of the inserted line break", () => { assert.equal( - insertPromptLineBreakBeforePrompt("hello\x1b[32m$ \x1b[0m", "$ ", 0), - "hello\r\n\x1b[32m$ \x1b[0m", + insertPromptLineBreakBeforePrompt("\x1b[32m$ \x1b[0m", "$ ", 5), + "\r\n\x1b[32m$ \x1b[0m", ); }); @@ -64,6 +64,36 @@ test("does not insert for non-prompt output", () => { ); }); +test("does not insert for output chunks that only end with the cached prompt text", () => { + assert.equal( + insertPromptLineBreakBeforePrompt("total $ ", "$ ", 0), + "total $ ", + ); +}); + +test("does not refresh cached prompt from output that only ends with the prompt text", () => { + const state = createPromptLineBreakState(); + state.lastPromptText = "$ "; + state.pendingCommand = true; + + assert.equal( + prepareTerminalDataForPromptLineBreak( + createFakeTerm("", 0) as never, + "total $ ", + state, + true, + ), + "total $ ", + ); + assert.equal(state.suppressNextPromptCache, true); + + syncPromptLineBreakState(createFakeTerm("total $ ") as never, state); + + assert.equal(state.lastPromptText, "$ "); + assert.equal(state.pendingCommand, false); + assert.equal(state.suppressNextPromptCache, false); +}); + test("refreshes cached prompt when a changed prompt arrives after a line break in the same chunk", () => { const state = createPromptLineBreakState(); state.lastPromptText = "old$ "; diff --git a/components/terminal/runtime/promptLineBreak.ts b/components/terminal/runtime/promptLineBreak.ts index a5341208b..b3c1f2250 100644 --- a/components/terminal/runtime/promptLineBreak.ts +++ b/components/terminal/runtime/promptLineBreak.ts @@ -77,6 +77,15 @@ const endsWithLineBreak = (text: string): boolean => { const containsLineReset = (text: string): boolean => text.includes("\n") || text.includes("\r"); +const hasAmbiguousPromptSuffix = (data: string, promptText: string): boolean => { + const mapped = mapVisibleText(data); + if (!mapped.text.endsWith(promptText)) return false; + + const promptTextStart = mapped.text.length - promptText.length; + const prefixText = mapped.text.slice(0, promptTextStart); + return prefixText.length > 0 && !endsWithLineBreak(prefixText); +}; + const getCursorX = (term: XTerm): number => { try { return term.buffer.active.cursorX; @@ -114,7 +123,7 @@ export function insertPromptLineBreakBeforePrompt( const promptTextStart = mapped.text.length - promptText.length; const prefixText = mapped.text.slice(0, promptTextStart); if (prefixText.length === 0 && cursorXBeforeWrite <= 0) return data; - if (prefixText.length > 0 && endsWithLineBreak(prefixText)) return data; + if (prefixText.length > 0) return data; const promptRawStart = mapped.rawStartByTextIndex[promptTextStart] ?? 0; return `${data.slice(0, promptRawStart)}\r\n${data.slice(promptRawStart)}`; @@ -134,10 +143,12 @@ export function prepareTerminalDataForPromptLineBreak( state.lastPromptText, cursorXBeforeWrite, ); + const visibleText = mapVisibleText(data).text; state.suppressNextPromptCache = nextData === data && - cursorXBeforeWrite > 0 && - !containsLineReset(mapVisibleText(data).text); + (cursorXBeforeWrite > 0 || + hasAmbiguousPromptSuffix(data, state.lastPromptText)) && + !containsLineReset(visibleText); return nextData; }