diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index d45d145b..8f7062ec 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/ru.ts b/application/i18n/locales/ru.ts index 1e4dbe7f..9c348046 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/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index 6b1a8e39..6e5bfd5e 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1465,6 +1465,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 3bb540c0..219e74c0 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 c89898c5..57188a5e 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -55,6 +55,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"; @@ -292,6 +297,7 @@ const TerminalComponent: React.FC = ({ const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer()); const onTerminalDataCaptureRef = useRef(onTerminalDataCapture); const commandBufferRef = useRef(""); + const promptLineBreakStateRef = useRef(createPromptLineBreakState()); const [hasMouseTracking, setHasMouseTracking] = useState(false); const mouseTrackingRef = useRef(false); const serialLineBufferRef = useRef(""); @@ -511,6 +517,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 = ""; @@ -845,6 +852,7 @@ const TerminalComponent: React.FC = ({ fitAddonRef, serializeAddonRef, pendingAuthRef, + promptLineBreakStateRef, updateStatus, setStatus, setError, @@ -900,6 +908,7 @@ const TerminalComponent: React.FC = ({ setShowLogs(false); setIsCancelling(false); setIsDisconnectedDialogDismissed(false); + promptLineBreakStateRef.current = createPromptLineBreakState(); const boot = async () => { try { @@ -924,6 +933,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 feb85068..51067811 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)} /> + + undefined; @@ -25,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, @@ -91,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`]); }); @@ -187,6 +191,320 @@ 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("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> = []; + 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 6fd374c4..c39cd716 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.ts @@ -21,6 +21,12 @@ import { clearPasteResidualAfterTerminalWrite, prepareTerminalDataForUserPasteDisplay, } from "./terminalUserPaste"; +import { + markPromptLineBreakCommandPending, + prepareTerminalDataForPromptLineBreak, + syncPromptLineBreakState, + type PromptLineBreakState, +} from "./promptLineBreak"; /** * Per-connection token for stale-timer detection. The renderer reuses the @@ -138,6 +144,7 @@ export type TerminalSessionStartersContext = { fitAddonRef: RefObject; serializeAddonRef: RefObject; pendingAuthRef: RefObject; + promptLineBreakStateRef?: RefObject; updateStatus: (next: TerminalSession["status"]) => void; setStatus: Dispatch>; @@ -206,13 +213,54 @@ const handleTerminalOutputAutoScroll = ( term.scrollToBottom(); }; +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 writeTerminalLine = ( ctx: TerminalSessionStartersContext, term: XTerm, data: string, ) => { - ctx.onTerminalLogData?.(`${data}\r\n`); - term.writeln(data); + enqueueTerminalWrite(term, (done) => { + const lineData = `${data}\r\n`; + ctx.onTerminalLogData?.(lineData); + term.write(lineData, done); + }); }; const writeSessionData = ( @@ -220,25 +268,42 @@ const writeSessionData = ( term: XTerm, data: string, ) => { - const displayData = prepareTerminalDataForUserPasteDisplay(term, data); - ctx.onTerminalLogData?.(displayData); - const clearPasteResidualAndCapture = () => { - const cleanupData = clearPasteResidualAfterTerminalWrite(term); - if (cleanupData) { - ctx.onTerminalLogData?.(cleanupData); + 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; } - }; - const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings; - if (!shouldScrollOnTerminalOutput(settings)) { - term.write(displayData, () => { + const pasteDisplayData = prepareTerminalDataForUserPasteDisplay(term, data); + const displayData = prepareTerminalDataForPromptLineBreak( + term, + pasteDisplayData, + ctx.promptLineBreakStateRef?.current, + forcePromptNewLine, + ); + ctx.onTerminalLogData?.(pasteDisplayData); + const clearPasteResidualAndCapture = () => { + const cleanupData = clearPasteResidualAfterTerminalWrite(term); + if (cleanupData) { + ctx.onTerminalLogData?.(cleanupData); + } + }; + const syncPrompt = () => { + if (forcePromptNewLine) { + syncPromptLineBreakState(term, ctx.promptLineBreakStateRef?.current); + } + }; + const afterWrite = () => { clearPasteResidualAndCapture(); - }); - return; - } + syncPrompt(); + if (shouldScrollOnTerminalOutput(settings)) { + handleTerminalOutputAutoScroll(ctx, term); + } + done(); + }; - term.write(displayData, () => { - clearPasteResidualAndCapture(); - handleTerminalOutputAutoScroll(ctx, term); + term.write(displayData, afterWrite); }); }; @@ -329,6 +394,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.test.ts b/components/terminal/runtime/createXTermRuntime.test.ts new file mode 100644 index 00000000..f9f4cfcf --- /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 704d5fee..405b48a4 100644 --- a/components/terminal/runtime/createXTermRuntime.ts +++ b/components/terminal/runtime/createXTermRuntime.ts @@ -49,6 +49,10 @@ import { shouldBroadcastTerminalUserInput, shouldSuppressTerminalInputScrollForUserPaste, } from "./terminalUserPaste"; +import { + type PromptLineBreakState, +} from "./promptLineBreak"; +import { recordTerminalCommandExecution } from "./terminalCommandExecution"; import type { Host, KeyBinding, @@ -109,6 +113,7 @@ export type CreateXTermRuntimeContext = { sessionId: string, ) => void; commandBufferRef: RefObject; + promptLineBreakStateRef?: RefObject; setIsSearchOpen: Dispatch>; // Serial-specific options @@ -508,10 +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 = ""; + if (!snippet.noAutoRun) { + recordTerminalCommandExecution(snippet.command, ctx); } return false; } @@ -682,11 +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 = ""; + 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/promptLineBreak.test.ts b/components/terminal/runtime/promptLineBreak.test.ts new file mode 100644 index 00000000..9f6c8f5a --- /dev/null +++ b/components/terminal/runtime/promptLineBreak.test.ts @@ -0,0 +1,153 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +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("does not insert before prompt-like suffixes in a larger output chunk", () => { + assert.equal( + insertPromptLineBreakBeforePrompt("hello$ ", "$ ", 0), + "hello$ ", + ); +}); + +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("\x1b[32m$ \x1b[0m", "$ ", 5), + "\r\n\x1b[32m$ \x1b[0m", + ); +}); + +test("does not insert for non-prompt output", () => { + assert.equal( + insertPromptLineBreakBeforePrompt("hello> ", "$ ", 0), + "hello> ", + ); +}); + +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$ "; + 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("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$ "; + 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 new file mode 100644 index 00000000..b3c1f225 --- /dev/null +++ b/components/terminal/runtime/promptLineBreak.ts @@ -0,0 +1,170 @@ +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 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; + } 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) 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, + ); + const visibleText = mapVisibleText(data).text; + state.suppressNextPromptCache = + nextData === data && + (cursorXBeforeWrite > 0 || + hasAmbiguousPromptSuffix(data, state.lastPromptText)) && + !containsLineReset(visibleText); + 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; + } + + state.lastPromptText = prompt.promptText; + state.suppressNextPromptCache = false; + state.pendingCommand = false; +} diff --git a/components/terminal/runtime/terminalCommandExecution.ts b/components/terminal/runtime/terminalCommandExecution.ts new file mode 100644 index 00000000..606798bb --- /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); +}; diff --git a/domain/models.ts b/domain/models.ts index 2fc2ea60..c6bd53cd 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