diff --git a/manifest.json b/manifest.json index db5f739..aadf546 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "browser.cpp", "short_name": "browser.cpp", "description": "In-browser C++20 IDE powered by Monaco Editor and WASM Clang", - "version": "0.4.6", + "version": "0.4.7", "minimum_chrome_version": "105", "icons": { "16": "icons/icon16.png", diff --git a/package-lock.json b/package-lock.json index edd59e1..255c6a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "browser.cpp", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browser.cpp", - "version": "0.4.6", + "version": "0.4.7", "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/package.json b/package.json index 7033771..045c8c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browser.cpp", - "version": "0.4.6", + "version": "0.4.7", "description": "In-browser C++20 IDE with WASM Clang toolchain", "private": true, "scripts": { diff --git a/scripts/e2e-terminal-stop.test.mjs b/scripts/e2e-terminal-stop.test.mjs index 47f93fd..8cfacc1 100644 --- a/scripts/e2e-terminal-stop.test.mjs +++ b/scripts/e2e-terminal-stop.test.mjs @@ -13,6 +13,7 @@ import { showInitialPrompt, startRun, stopRun, + writeStdout, } from '../src/ui/terminal.js'; function setupTerminalHarness({ @@ -123,6 +124,31 @@ test('e2e: clearing during startup does not reveal the initial prompt early', () assert.ok(output.indexOf('Clang WASM compiler loaded') < output.indexOf('browser.cpp')); }); +test('e2e: clearing discards typed input and homes the fresh prompt', () => { + const ctx = setupTerminalHarness(); + + showInitialPrompt(); + __handleTerminalKeyForTesting('c', { key: 'c', ctrlKey: false, altKey: false }); + __handleTerminalKeyForTesting('a', { key: 'a', ctrlKey: false, altKey: false }); + clearTerminal(); + + assert.equal(__getTerminalStateForTesting().inputBuffer, ''); + assert.equal(ctx.clearCalls.length, 1); + assert.equal(ctx.writes.at(-2), '\x1b[2J\x1b[H'); + assert.match(ctx.writes.at(-1), /browser\.cpp.*:~\$ /); +}); + +test('e2e: prompt restoration follows newline-less program output on a new line', () => { + const ctx = setupTerminalHarness(); + + showInitialPrompt(); + onRunStart({ stdinMode: 'interactive' }); + writeStdout('program output'); + onRunResult({ exitCode: 0 }); + + assert.match(ctx.writes.join(''), /program output\r\n.*browser\.cpp.*:~\$ /); +}); + test('e2e: Ctrl+C while running stops the program once and restores the prompt', async () => { const ctx = setupTerminalHarness(); diff --git a/src/ui/terminal.js b/src/ui/terminal.js index 707e620..fc13c3e 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -60,6 +60,8 @@ const C = { }; const CRLF = '\r\n'; +const CLEAR_SCREEN = '\x1b[2J\x1b[H'; +const CSI_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'); /** Maximum number of commands retained in shell history. */ const MAX_HISTORY_SIZE = 200; @@ -69,6 +71,8 @@ const TAB_COMMANDS = ['g++ ', 'g++ main.cpp', './a.out', 'clear', 'echo ', 'ls', let term = null; let fitAddon = null; +let terminalLineHasContent = false; +const trackedTerminals = new WeakSet(); /** Current line being typed */ let inputBuffer = ''; @@ -301,7 +305,7 @@ export function createTerminal(container, { initialPromptShown = false; busy = true; - term = new Terminal({ + setTerminal(new Terminal({ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", fontSize: 13, lineHeight: 1.4, @@ -330,7 +334,7 @@ export function createTerminal(container, { cursorBlink: true, scrollback: 5000, convertEol: false, - }); + })); fitAddon = new FitAddon(); term.loadAddon(fitAddon); @@ -357,7 +361,9 @@ export function fitTerminal() { /** Clear the terminal screen. */ export function clearTerminal() { - term?.clear(); + inputBuffer = ''; + historyIdx = -1; + clearScreen(); if (!initialPromptShown) return; writePrompt(); } @@ -371,7 +377,7 @@ export function resetTerminalSession(workspace = null) { inputBuffer = ''; historyIdx = -1; setWorkspace(workspace); - term?.clear(); + clearScreen(); if (initialPromptShown) writePrompt(); } @@ -685,7 +691,7 @@ function handleKey({ key, domEvent }) { // Ctrl+L – clear screen if (domEvent.ctrlKey && code === 'l') { - term.clear(); + clearScreen(); writePrompt(); term.write(inputBuffer); return; @@ -766,7 +772,7 @@ async function executeCommand(cmdLine) { cmdGxx(args); break; case 'clear': - term.clear(); + clearScreen(); writePrompt(); break; case 'echo': @@ -1072,9 +1078,47 @@ function cmdHelp() { // ── Utilities ───────────────────────────────────────────────────────────────── function writePrompt() { + if (terminalLineHasContent) term?.write(CRLF); term?.write(`${C.green}${C.bold}browser.cpp${C.reset}${C.dim}:${promptPath()}$ ${C.reset}`); } +/** Clear the terminal and place the next prompt at the top-left of the screen. */ +function clearScreen() { + term?.clear(); + terminalLineHasContent = false; + if (initialPromptShown) term?.write(CLEAR_SCREEN); +} + +/** + * Keep prompt placement independent of the many existing terminal write sites. + * xterm writes are asynchronous, so record the logical line state as output is queued. + */ +function setTerminal(nextTerm) { + term = nextTerm; + terminalLineHasContent = false; + if (!term || trackedTerminals.has(term)) return; + + const terminalInstance = term; + const write = terminalInstance.write.bind(terminalInstance); + terminalInstance.write = (text, ...args) => { + if (term === terminalInstance) trackTerminalLine(text); + return write(text, ...args); + }; + trackedTerminals.add(terminalInstance); +} + +function trackTerminalLine(text) { + if (typeof text !== 'string') return; + + const visibleText = text.replace(CSI_SEQUENCE, ''); + const lastLineBreak = Math.max(visibleText.lastIndexOf('\n'), visibleText.lastIndexOf('\r')); + if (lastLineBreak >= 0) { + terminalLineHasContent = visibleText.slice(lastLineBreak + 1).length > 0; + } else if (visibleText.length > 0) { + terminalLineHasContent = true; + } +} + function setRunState(nextRunning) { if (running === nextRunning) return; running = nextRunning; @@ -1105,7 +1149,7 @@ export function __setTerminalTestHarness({ supportsMessageInteractiveStdin: supportsMessageInteractiveStdinForTest = () => false, createStdinSessionId: createStdinSessionIdForTest = () => 'stdin-session-test', } = {}) { - term = terminalInstance || null; + setTerminal(terminalInstance || null); fitAddon = null; _onCompile = onCompile; _onRun = onRun;