From e8108c2c38e90f0c6afa5eb5cd834fea9d7569a6 Mon Sep 17 00:00:00 2001 From: robertoffmoura Date: Thu, 4 Dec 2025 13:51:07 +0000 Subject: [PATCH 1/3] Prevent extra lines from sticking to terminal after cycling through multi line commands in history --- src/commandwindow/CommandWindow.ts | 4 +++- src/test/tools/tester/TerminalTester.ts | 15 +++++++++++++++ src/test/ui/terminal.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/commandwindow/CommandWindow.ts b/src/commandwindow/CommandWindow.ts index aaeda9d..aea19cb 100644 --- a/src/commandwindow/CommandWindow.ts +++ b/src/commandwindow/CommandWindow.ts @@ -566,7 +566,9 @@ export default class CommandWindow implements vscode.Pseudoterminal { } private _eraseExistingPromptLine (): void { - const numberOfLinesBehind = Math.floor(this._getAbsoluteIndexOnLine(this._cursorIndex) / this._terminalDimensions.columns); + const textUpToCursor = this._currentPromptLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex)); + const numberOfExplicitNewlines = (textUpToCursor.match(/\r?\n/g) ?? []).length; + const numberOfLinesBehind = Math.floor(this._getAbsoluteIndexOnLine(this._cursorIndex) / this._terminalDimensions.columns) + numberOfExplicitNewlines; if (numberOfLinesBehind !== 0) { this._writeEmitter.fire(ACTION_KEYS.UP.repeat(numberOfLinesBehind)) } diff --git a/src/test/tools/tester/TerminalTester.ts b/src/test/tools/tester/TerminalTester.ts index 6304485..8d74fa7 100644 --- a/src/test/tools/tester/TerminalTester.ts +++ b/src/test/tools/tester/TerminalTester.ts @@ -48,6 +48,13 @@ export class TerminalTester { return await this.vs.poll(this.doesTerminalContain.bind(this, expected), true, `Assertion on terminal content: ${message}`) } + /** + * Assert the MATLAB terminal does not contain some content + */ + public async assertNotContains (expected: string, message: string): Promise { + return await this.vs.poll(this.doesTerminalNotContain.bind(this, expected), true, `Assertion on terminal content: ${message}`) + } + /** * Checks if the MATLAB terminal contains some content (no polling) */ @@ -56,6 +63,14 @@ export class TerminalTester { return content.includes(expected) } + /** + * Checks if the MATLAB terminal does not contain some content (no polling) + */ + private async doesTerminalNotContain (expected: string): Promise { + const content = await this.getTerminalContent() + return !content.includes(expected) + } + public async type (text: string): Promise { const container = await this.terminal.findElement(vet.By.className('xterm-helper-textarea')); return await container.sendKeys(text) diff --git a/src/test/ui/terminal.test.ts b/src/test/ui/terminal.test.ts index b87fb60..f688b65 100644 --- a/src/test/ui/terminal.test.ts +++ b/src/test/ui/terminal.test.ts @@ -90,4 +90,26 @@ suite('Terminal UI Tests', () => { await vs.terminal.assertContains('a = 123;', 'Up arrow after typing "a" should recall matching command') await vs.terminal.type(Key.ESCAPE) }); + + test('Test multi-line command history cycling', async () => { + // Execute a multi-line command by pasting (simulates copy-paste of multi-line text) + await vs.terminal.type('x = [1 2\n 3 4]') + await vs.terminal.type(Key.RETURN) + + // Execute another command to move forward in history + await vs.terminal.executeCommand('y = 5;') + await vs.terminal.executeCommand('clc') + + // Recall the multi-line command with up arrow + await vs.terminal.type(Key.ARROW_UP) + await vs.terminal.type(Key.ARROW_UP) + await vs.terminal.assertContains('x = [1 2', 'Up arrow should recall first line of multi-line command') + await vs.terminal.assertContains('3 4]', 'Up arrow should recall second line of multi-line command') + + // Cycle away from the multi-line command + await vs.terminal.type(Key.ARROW_DOWN) + await vs.terminal.assertNotContains('x = [1 2', 'First line should not stick after cycling away with down arrow') + await vs.terminal.assertNotContains('3 4]', 'Second line should not stick after cycling away with down arrow') + await vs.terminal.type(Key.ESCAPE) + }); }); From ac10064e479ac19c1ee610229f1d1c9fdf63735a Mon Sep 17 00:00:00 2001 From: robertoffmoura Date: Thu, 4 Dec 2025 15:36:02 +0000 Subject: [PATCH 2/3] Fix cursor position when cycling through multi line command history. Allow cursor to move between lines of a single multi line command --- src/commandwindow/CommandWindow.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/commandwindow/CommandWindow.ts b/src/commandwindow/CommandWindow.ts index aea19cb..4adb075 100644 --- a/src/commandwindow/CommandWindow.ts +++ b/src/commandwindow/CommandWindow.ts @@ -462,7 +462,17 @@ export default class CommandWindow implements vscode.Pseudoterminal { // Don't actually move the cursor, but do move the index we think the cursor is at. this._justTypedLastInColumn = false; } else { - if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0) { + // Check if the character before cursor is a newline (explicit line break) + const charBeforeCursor = this._currentPromptLine.charAt(this._getAbsoluteIndexOnLine(this._cursorIndex) - 1); + if (charBeforeCursor === '\n') { + // Moving left across an explicit newline - need to go up and find position on previous line + const textBeforeNewline = this._currentPromptLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex) - 1); + const previousNewlineIndex = textBeforeNewline.lastIndexOf('\n'); + const positionOnPreviousLine = previousNewlineIndex === -1 + ? textBeforeNewline.length + : textBeforeNewline.length - previousNewlineIndex - 1; + this._writeEmitter.fire(ACTION_KEYS.UP + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE((positionOnPreviousLine % this._terminalDimensions.columns) + 1)); + } else if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0) { this._writeEmitter.fire(ACTION_KEYS.UP + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE(this._terminalDimensions.columns)); } else { this._writeEmitter.fire(ACTION_KEYS.LEFT); @@ -477,7 +487,12 @@ export default class CommandWindow implements vscode.Pseudoterminal { if (this._justTypedLastInColumn) { // Not possible } else { - if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === (this._terminalDimensions.columns - 1)) { + // Check if the character at cursor is a newline (explicit line break) + const charAtCursor = this._currentPromptLine.charAt(this._getAbsoluteIndexOnLine(this._cursorIndex)); + if (charAtCursor === '\n') { + // Moving right across an explicit newline - go down to start of next line + this._writeEmitter.fire(ACTION_KEYS.DOWN + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE(1)); + } else if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === (this._terminalDimensions.columns - 1)) { this._writeEmitter.fire(ACTION_KEYS.DOWN + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE(0)); } else { this._writeEmitter.fire(ACTION_KEYS.RIGHT); @@ -669,7 +684,13 @@ export default class CommandWindow implements vscode.Pseudoterminal { } else if (lineNumberCursorShouldBeOn < lineOfInputCursorIsCurrentlyOn) { this._writeEmitter.fire(ACTION_KEYS.UP.repeat(lineOfInputCursorIsCurrentlyOn - lineNumberCursorShouldBeOn)); } - this._writeEmitter.fire(ACTION_KEYS.MOVE_TO_POSITION_IN_LINE((this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns) + 1)); + // Calculate column position accounting for explicit newlines + const textUpToCursor = this._currentPromptLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex)); + const lastNewlineIndex = textUpToCursor.lastIndexOf('\n'); + const positionOnCurrentLine = lastNewlineIndex === -1 + ? this._getAbsoluteIndexOnLine(this._cursorIndex) + : textUpToCursor.length - lastNewlineIndex - 1; + this._writeEmitter.fire(ACTION_KEYS.MOVE_TO_POSITION_IN_LINE((positionOnCurrentLine % this._terminalDimensions.columns) + 1)); } setDimensions (dimensions: vscode.TerminalDimensions): void { From 55edd9657a56a83801bd47654e797704b0f784a7 Mon Sep 17 00:00:00 2001 From: robertoffmoura Date: Thu, 4 Dec 2025 14:52:58 +0000 Subject: [PATCH 3/3] Expose cursor position and add tests to check cursor position when cycling through multi line commands in command history --- src/commandwindow/CommandWindow.ts | 20 ++++++ src/commandwindow/TerminalService.ts | 8 +++ src/extension.ts | 1 + src/test/tools/tester/TerminalTester.ts | 28 ++++++++ src/test/ui/terminal.test.ts | 94 +++++++++++++++++++++++++ 5 files changed, 151 insertions(+) diff --git a/src/commandwindow/CommandWindow.ts b/src/commandwindow/CommandWindow.ts index 4adb075..d6a1df5 100644 --- a/src/commandwindow/CommandWindow.ts +++ b/src/commandwindow/CommandWindow.ts @@ -900,6 +900,26 @@ export default class CommandWindow implements vscode.Pseudoterminal { this._justTypedLastInColumn = this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0; } + /** + * Get cursor position information for testing purposes. + * Returns the logical line number (0-based) and column position (0-based) within that line. + * For multi-line commands with explicit newlines, the line is determined by counting newlines. + */ + getCursorPosition (): { line: number, column: number } { + const textUpToCursor = this._currentPromptLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex)); + const lastNewlineIndex = textUpToCursor.lastIndexOf('\n'); + + // Count newlines to determine line number + const line = (textUpToCursor.match(/\n/g) ?? []).length; + + // Calculate column position within the current line + const column = lastNewlineIndex === -1 + ? this._cursorIndex // No newlines, so cursor is on first line + : textUpToCursor.length - lastNewlineIndex - 1 - this._currentPrompt.length; + + return { line, column }; + } + onDidWrite: vscode.Event; onDidOverrideDimensions?: vscode.Event | undefined; onDidClose?: vscode.Event | undefined; diff --git a/src/commandwindow/TerminalService.ts b/src/commandwindow/TerminalService.ts index 8b1550e..4160e6f 100644 --- a/src/commandwindow/TerminalService.ts +++ b/src/commandwindow/TerminalService.ts @@ -99,6 +99,14 @@ export default class TerminalService { getCommandWindow (): CommandWindow { return this._commandWindow; } + + /** + * Get cursor position information for testing purposes. + * Returns the logical line number (0-based) and column position (0-based) within that line. + */ + getCursorPosition (): { line: number, column: number } { + return this._commandWindow.getCursorPosition(); + } } /** diff --git a/src/extension.ts b/src/extension.ts index e145c3b..982c758 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -162,6 +162,7 @@ export async function activate (context: vscode.ExtensionContext): Promise context.subscriptions.push(vscode.commands.registerCommand('matlab.runSelection', async () => await executionCommandProvider.handleRunSelection())) context.subscriptions.push(vscode.commands.registerCommand('matlab.interrupt', () => executionCommandProvider.handleInterrupt())) context.subscriptions.push(vscode.commands.registerCommand('matlab.openCommandWindow', async () => await terminalService.openTerminalOrBringToFront())) + context.subscriptions.push(vscode.commands.registerCommand('matlab.getCursorPosition', () => terminalService.getCursorPosition())) context.subscriptions.push(vscode.commands.registerCommand('matlab.addFolderToPath', async (uri: vscode.Uri) => await executionCommandProvider.handleAddFolderToPath(uri))) context.subscriptions.push(vscode.commands.registerCommand('matlab.addFolderAndSubfoldersToPath', async (uri: vscode.Uri) => await executionCommandProvider.handleAddFolderAndSubfoldersToPath(uri))) context.subscriptions.push(vscode.commands.registerCommand('matlab.changeDirectory', async (uri: vscode.Uri) => await executionCommandProvider.handleChangeDirectory(uri))) diff --git a/src/test/tools/tester/TerminalTester.ts b/src/test/tools/tester/TerminalTester.ts index 8d74fa7..5256c8e 100644 --- a/src/test/tools/tester/TerminalTester.ts +++ b/src/test/tools/tester/TerminalTester.ts @@ -75,4 +75,32 @@ export class TerminalTester { const container = await this.terminal.findElement(vet.By.className('xterm-helper-textarea')); return await container.sendKeys(text) } + + /** + * Get the current cursor position in the MATLAB terminal + * @returns The cursor position as { line: number, column: number } (both 0-based) + */ + public async getCursorPosition (): Promise<{ line: number, column: number }> { + const workbench = new vet.Workbench() + const position = await workbench.executeCommand('matlab.getCursorPosition') + return position as { line: number, column: number } + } + + /** + * Assert that the cursor is at the expected position + * @param expectedLine Expected line number (0-based) + * @param expectedColumn Expected column number (0-based) + * @param message Message to display if assertion fails + */ + public async assertCursorPosition (expectedLine: number, expectedColumn: number, message: string): Promise { + return await this.vs.poll( + async () => await this.getCursorPosition(), + { line: expectedLine, column: expectedColumn }, + `Assertion on cursor position: ${message}`, + 5000, + async (result) => { + console.log(`Expected cursor at line ${expectedLine}, column ${expectedColumn}, but got line ${result.line}, column ${result.column}`) + } + ) + } } diff --git a/src/test/ui/terminal.test.ts b/src/test/ui/terminal.test.ts index f688b65..5ffd789 100644 --- a/src/test/ui/terminal.test.ts +++ b/src/test/ui/terminal.test.ts @@ -112,4 +112,98 @@ suite('Terminal UI Tests', () => { await vs.terminal.assertNotContains('3 4]', 'Second line should not stick after cycling away with down arrow') await vs.terminal.type(Key.ESCAPE) }); + + test('Test multi-line command cursor position', async () => { + // Execute a multi-line command + await vs.terminal.type('a = 1\nb = 2') + await vs.terminal.type(Key.RETURN) + await vs.terminal.executeCommand('clc') + + // Recall the multi-line command + await vs.terminal.type(Key.ARROW_UP) + + // Cursor should be at the end of the command - verify position of cursor + // Should be on line 1 (second line), at column 5 (after "b = 2") + await vs.terminal.assertCursorPosition(1, 5, 'Cursor should be at end of multi-line command') + await vs.terminal.type(Key.ESCAPE) + }); + + test('Test multi-line command left arrow navigation to upper lines', async () => { + // Execute a multi-line command + await vs.terminal.type('x = 10\ny = 20\nz = 30') + await vs.terminal.type(Key.RETURN) + await vs.terminal.executeCommand('clc') + + // Recall the multi-line command + await vs.terminal.type(Key.ARROW_UP) + + // Move left to navigate from last line to first line + // Start at end: "z = 30|" + for (let i = 0; i < 6; i++) { + await vs.terminal.type(Key.ARROW_LEFT) + } + // Now at: "z = 30" -> should cross newline to second line + await vs.terminal.type(Key.ARROW_LEFT) + + // Verify we're on second line at the end + // Should be on line 1 (second line), at column 6 (after "y = 20") + await vs.terminal.assertCursorPosition(1, 6, 'Cursor should be at end of second line after navigating left from third line') + await vs.terminal.type(Key.ESCAPE) + }); + + test('Test multi-line command right arrow navigation to lower lines', async () => { + // Execute a multi-line command + await vs.terminal.type('p = 1\nq = 2') + await vs.terminal.type(Key.RETURN) + await vs.terminal.executeCommand('clc') + + // Recall the multi-line command and navigate to start + await vs.terminal.type(Key.ARROW_UP) + await vs.terminal.type(Key.HOME) + + // Now at start of first line: "|p = 1" + // Move right to end of first line + for (let i = 0; i < 5; i++) { + await vs.terminal.type(Key.ARROW_RIGHT) + } + + // Now at: "p = 1|" -> next right should cross newline to second line + await vs.terminal.type(Key.ARROW_RIGHT) + + // Verify we're on second line at the beginning + // Should be on line 1 (second line), at column 0 (start of "q = 2") + await vs.terminal.assertCursorPosition(1, 0, 'Cursor should be at start of second line after navigating right from first line') + await vs.terminal.type(Key.ESCAPE) + }); + + test('Test multi-line command bidirectional navigation', async () => { + // Execute a three-line command + await vs.terminal.type('line1\nline2\nline3') + await vs.terminal.type(Key.RETURN) + await vs.terminal.executeCommand('clc') + + // Recall and navigate: end -> line2 -> line1 -> line2 -> line3 + await vs.terminal.type(Key.ARROW_UP) + + // Navigate to middle of second line using left arrows + for (let i = 0; i < 8; i++) { // "line3" (5 chars) + newline + "li" (2 chars) = 8 left arrows + await vs.terminal.type(Key.ARROW_LEFT) + } + + // Verify position on line2 + // Should be on line 1 (second line), at column 2 (after "li") + await vs.terminal.assertCursorPosition(1, 2, 'Cursor should be at position 2 on line 1 after navigating left') + + // Navigate back right to line3 + await vs.terminal.type(Key.ARROW_RIGHT) // move past 'n' + await vs.terminal.type(Key.ARROW_RIGHT) // 'e' + await vs.terminal.type(Key.ARROW_RIGHT) // '2' + await vs.terminal.type(Key.ARROW_RIGHT) // cross newline to line3 + + // Verify we're back on line3 + // Should be on line 2 (third line), at column 0 (start of "line3") + await vs.terminal.assertCursorPosition(2, 0, 'Cursor should be at start of line 2 after navigating right back') + + await vs.terminal.type(Key.ESCAPE) + }); });