From d8dbe6271fbb748b78f54f434fb3038fd885baec Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 5 Sep 2025 15:29:54 -0700 Subject: [PATCH] Fix syntax highlighting and rendering issues. (#7759) Co-authored-by: Miguel Solorio --- .../src/ui/components/InputPrompt.test.tsx | 27 ++++++ .../cli/src/ui/components/InputPrompt.tsx | 29 +++--- packages/cli/src/ui/utils/highlight.test.ts | 90 +++++++++++++------ packages/cli/src/ui/utils/highlight.ts | 19 ++-- 4 files changed, 120 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 9d072709e4..d2a08d8fcc 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1311,6 +1311,33 @@ describe('InputPrompt', () => { }); }); + describe('multiline rendering', () => { + it('should correctly render multiline input including blank lines', async () => { + const text = 'hello\n\nworld'; + mockBuffer.text = text; + mockBuffer.lines = text.split('\n'); + mockBuffer.viewportVisualLines = text.split('\n'); + mockBuffer.allVisualLines = text.split('\n'); + mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world" + + const { stdout, unmount } = renderWithProviders( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + // Check that all lines, including the empty one, are rendered. + // This implicitly tests that the Box wrapper provides height for the empty line. + expect(frame).toContain('hello'); + expect(frame).toContain(`world${chalk.inverse(' ')}`); + + const outputLines = frame!.split('\n'); + // The number of lines should be 2 for the border plus 3 for the content. + expect(outputLines.length).toBe(5); + unmount(); + }); + }); + describe('multiline paste', () => { it.each([ { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index ca1f9f5498..58b19e4f8d 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -723,7 +723,10 @@ export const InputPrompt: React.FC = ({ ) : ( linesToRender .map((lineText, visualIdxInRenderedSet) => { - const tokens = parseInputForHighlighting(lineText); + const tokens = parseInputForHighlighting( + lineText, + visualIdxInRenderedSet, + ); const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; const isOnCursorLine = @@ -784,7 +787,9 @@ export const InputPrompt: React.FC = ({ ) { if (!currentLineGhost) { renderedLine.push( - {chalk.inverse(' ')}, + + {chalk.inverse(' ')} + , ); } } @@ -796,15 +801,17 @@ export const InputPrompt: React.FC = ({ currentLineGhost; return ( - - {renderedLine} - {showCursorBeforeGhost && chalk.inverse(' ')} - {currentLineGhost && ( - - {currentLineGhost} - - )} - + + + {renderedLine} + {showCursorBeforeGhost && chalk.inverse(' ')} + {currentLineGhost && ( + + {currentLineGhost} + + )} + + ); }) .concat( diff --git a/packages/cli/src/ui/utils/highlight.test.ts b/packages/cli/src/ui/utils/highlight.test.ts index 9d03f21528..8d4c5ce620 100644 --- a/packages/cli/src/ui/utils/highlight.test.ts +++ b/packages/cli/src/ui/utils/highlight.test.ts @@ -9,96 +9,128 @@ import { parseInputForHighlighting } from './highlight.js'; describe('parseInputForHighlighting', () => { it('should handle an empty string', () => { - expect(parseInputForHighlighting('')).toEqual([ + expect(parseInputForHighlighting('', 0)).toEqual([ { text: '', type: 'default' }, ]); }); it('should handle text with no commands or files', () => { const text = 'this is a normal sentence'; - expect(parseInputForHighlighting(text)).toEqual([ + expect(parseInputForHighlighting(text, 0)).toEqual([ { text, type: 'default' }, ]); }); - it('should highlight a single command at the beginning', () => { + it('should highlight a single command at the beginning when index is 0', () => { const text = '/help me'; - expect(parseInputForHighlighting(text)).toEqual([ + expect(parseInputForHighlighting(text, 0)).toEqual([ { text: '/help', type: 'command' }, { text: ' me', type: 'default' }, ]); }); + it('should NOT highlight a command at the beginning when index is not 0', () => { + const text = '/help me'; + expect(parseInputForHighlighting(text, 1)).toEqual([ + { text: '/help', type: 'default' }, + { text: ' me', type: 'default' }, + ]); + }); + it('should highlight a single file path at the beginning', () => { const text = '@path/to/file.txt please'; - expect(parseInputForHighlighting(text)).toEqual([ + expect(parseInputForHighlighting(text, 0)).toEqual([ { text: '@path/to/file.txt', type: 'file' }, { text: ' please', type: 'default' }, ]); }); - it('should highlight a command in the middle', () => { + it('should not highlight a command in the middle', () => { const text = 'I need /help with this'; - expect(parseInputForHighlighting(text)).toEqual([ - { text: 'I need ', type: 'default' }, - { text: '/help', type: 'command' }, - { text: ' with this', type: 'default' }, + expect(parseInputForHighlighting(text, 0)).toEqual([ + { text: 'I need /help with this', type: 'default' }, ]); }); it('should highlight a file path in the middle', () => { const text = 'Please check @path/to/file.txt for details'; - expect(parseInputForHighlighting(text)).toEqual([ + expect(parseInputForHighlighting(text, 0)).toEqual([ { text: 'Please check ', type: 'default' }, { text: '@path/to/file.txt', type: 'file' }, { text: ' for details', type: 'default' }, ]); }); - it('should highlight multiple commands and files', () => { + it('should highlight files but not commands not at the start', () => { const text = 'Use /run with @file.js and also /format @another/file.ts'; - expect(parseInputForHighlighting(text)).toEqual([ - { text: 'Use ', type: 'default' }, - { text: '/run', type: 'command' }, - { text: ' with ', type: 'default' }, + expect(parseInputForHighlighting(text, 0)).toEqual([ + { text: 'Use /run with ', type: 'default' }, { text: '@file.js', type: 'file' }, - { text: ' and also ', type: 'default' }, - { text: '/format', type: 'command' }, - { text: ' ', type: 'default' }, + { text: ' and also /format ', type: 'default' }, { text: '@another/file.ts', type: 'file' }, ]); }); - it('should handle adjacent highlights', () => { + it('should handle adjacent highlights at start', () => { const text = '/run@file.js'; - expect(parseInputForHighlighting(text)).toEqual([ + expect(parseInputForHighlighting(text, 0)).toEqual([ { text: '/run', type: 'command' }, { text: '@file.js', type: 'file' }, ]); }); - it('should handle highlights at the end of the string', () => { + it('should not highlight command at the end of the string', () => { const text = 'Get help with /help'; - expect(parseInputForHighlighting(text)).toEqual([ - { text: 'Get help with ', type: 'default' }, - { text: '/help', type: 'command' }, + expect(parseInputForHighlighting(text, 0)).toEqual([ + { text: 'Get help with /help', type: 'default' }, ]); }); it('should handle file paths with dots and dashes', () => { const text = 'Check @./path-to/file-name.v2.txt'; - expect(parseInputForHighlighting(text)).toEqual([ + expect(parseInputForHighlighting(text, 0)).toEqual([ { text: 'Check ', type: 'default' }, { text: '@./path-to/file-name.v2.txt', type: 'file' }, ]); }); - it('should handle commands with dashes and numbers', () => { + it('should not highlight command with dashes and numbers not at start', () => { const text = 'Run /command-123 now'; - expect(parseInputForHighlighting(text)).toEqual([ - { text: 'Run ', type: 'default' }, + expect(parseInputForHighlighting(text, 0)).toEqual([ + { text: 'Run /command-123 now', type: 'default' }, + ]); + }); + + it('should highlight command with dashes and numbers at start', () => { + const text = '/command-123 now'; + expect(parseInputForHighlighting(text, 0)).toEqual([ { text: '/command-123', type: 'command' }, { text: ' now', type: 'default' }, ]); }); + + it('should still highlight a file path on a non-zero line', () => { + const text = 'some text @path/to/file.txt'; + expect(parseInputForHighlighting(text, 1)).toEqual([ + { text: 'some text ', type: 'default' }, + { text: '@path/to/file.txt', type: 'file' }, + ]); + }); + + it('should not highlight command but highlight file on a non-zero line', () => { + const text = '/cmd @file.txt'; + expect(parseInputForHighlighting(text, 2)).toEqual([ + { text: '/cmd', type: 'default' }, + { text: ' ', type: 'default' }, + { text: '@file.txt', type: 'file' }, + ]); + }); + + it('should highlight a file path with escaped spaces', () => { + const text = 'cat @/my\\ path/file.txt'; + expect(parseInputForHighlighting(text, 0)).toEqual([ + { text: 'cat ', type: 'default' }, + { text: '@/my\\ path/file.txt', type: 'file' }, + ]); + }); }); diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts index 2bba3e8d5f..f1a6e4656d 100644 --- a/packages/cli/src/ui/utils/highlight.ts +++ b/packages/cli/src/ui/utils/highlight.ts @@ -9,10 +9,11 @@ export type HighlightToken = { type: 'default' | 'command' | 'file'; }; -const HIGHLIGHT_REGEX = /(\/[a-zA-Z0-9_-]+|@[a-zA-Z0-9_./-]+)/g; +const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_./-])+)/g; export function parseInputForHighlighting( text: string, + index: number, ): readonly HighlightToken[] { if (!text) { return [{ text: '', type: 'default' }]; @@ -36,10 +37,18 @@ export function parseInputForHighlighting( // Add the matched token const type = fullMatch.startsWith('/') ? 'command' : 'file'; - tokens.push({ - text: fullMatch, - type, - }); + // Only highlight slash commands if the index is 0. + if (type === 'command' && index !== 0) { + tokens.push({ + text: fullMatch, + type: 'default', + }); + } else { + tokens.push({ + text: fullMatch, + type, + }); + } lastIndex = matchIndex + fullMatch.length; }