From f1d95125f2a6b1dc0c2a379eb1bfbec407ac2994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20Fran=C3=A7ois?= Date: Thu, 13 Aug 2026 18:05:02 +0200 Subject: [PATCH] fix(composer): keep caret inside completed fence --- packages/ui/src/components/chat/ChatInput.tsx | 40 ++++--------- .../chat/composer/__tests__/text.test.ts | 37 ++++++++++++ .../chat/composer/editor/ComposerEditor.tsx | 9 +-- .../ui/src/components/chat/composer/text.ts | 58 +++++++++++++++++++ 4 files changed, 110 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 5ff1d8ab..c2541271 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -102,6 +102,7 @@ import { appendInlineText, appendWithLineBreaks, buildImagePasteInsertion, + getMarkdownAutoPairEdit, shouldWrapSelectionAsLink, withInlineInsertionBoundaries, } from './composer/text'; @@ -1513,39 +1514,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const selEnd = ta?.getSelection().end ?? -1; if (ta && selStart >= 0) { - const applyEdit = (next: string, caretStart: number, caretEnd: number) => { + const edit = getMarkdownAutoPairEdit(message, e.key, selStart, selEnd); + if (edit) { e.preventDefault(); - setMessage(next); - composerRef.current?.setSelection(caretStart, caretEnd); - updateAutocompleteState(next, caretEnd); - }; - - // Wrap the current selection: select text, press ` * _ ~ ( [ { " ' - const WRAP_PAIRS: Record = { - '`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'], - '(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'], - '"': ['"', '"'], "'": ["'", "'"], - }; - if (selEnd > selStart && WRAP_PAIRS[e.key]) { - const [open, close] = WRAP_PAIRS[e.key]; - const selected = message.slice(selStart, selEnd); - const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`; - applyEdit(next, selStart + open.length, selEnd + open.length); + ta.replaceRange( + edit.from, + edit.to, + edit.insert, + edit.selectionStart, + edit.selectionEnd, + ); return; } - - // Typing the third backtick at line start expands into a fenced - // code block with the caret on the empty middle line (Slack-like). - if (e.key === '`' && selStart === selEnd) { - const before = message.slice(0, selStart); - if (/(^|\n)``$/.test(before)) { - const after = message.slice(selEnd); - const next = `${before}\`\n\n\`\`\`${after}`; - const caret = before.length + 2; // after the completed ``` and first newline - applyEdit(next, caret, caret); - return; - } - } } } diff --git a/packages/ui/src/components/chat/composer/__tests__/text.test.ts b/packages/ui/src/components/chat/composer/__tests__/text.test.ts index dd301062..beb51de5 100644 --- a/packages/ui/src/components/chat/composer/__tests__/text.test.ts +++ b/packages/ui/src/components/chat/composer/__tests__/text.test.ts @@ -4,6 +4,7 @@ import { appendInlineText, appendWithLineBreaks, buildImagePasteInsertion, + getMarkdownAutoPairEdit, shouldWrapSelectionAsLink, withInlineInsertionBoundaries, } from '../text'; @@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => { expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false); }); }); + +describe('getMarkdownAutoPairEdit', () => { + test('completes a fenced block with the caret on the middle line', () => { + expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({ + from: 2, + to: 2, + insert: '`\n\n```', + selectionStart: 4, + selectionEnd: 4, + }); + }); + + test('completes a fence at the start of any line', () => { + expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({ + from: 8, + to: 8, + insert: '`\n\n```', + selectionStart: 10, + selectionEnd: 10, + }); + }); + + test('does not complete two backticks in the middle of a line', () => { + expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull(); + }); + + test('wraps selected text and keeps the text selected', () => { + expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({ + from: 1, + to: 4, + insert: '*ell*', + selectionStart: 2, + selectionEnd: 5, + }); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index 41a2825f..c26bdadf 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -63,8 +63,8 @@ export interface ComposerEditorHandle { selectAll(): void; /** Replace the current selection, leaving the caret after the insertion. */ insertText(text: string): void; - /** Replace an explicit range; the caret lands at `caret` or after the text. */ - replaceRange(from: number, to: number, text: string, caret?: number): void; + /** Replace a range; selection defaults to a caret after the inserted text. */ + replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void; /** Viewport coordinates of the caret, for positioning popups. */ caretCoords(position?: number): { top: number; bottom: number; left: number } | null; /** The scrollable element, for measuring and scroll compensation. */ @@ -513,12 +513,13 @@ export const ComposerEditor = React.forwardRef 0 && !selected.includes(']('); } + +const MARKDOWN_WRAP_PAIRS: Record = { + '`': ['`', '`'], + '*': ['*', '*'], + '_': ['_', '_'], + '~': ['~', '~'], + '(': ['(', ')'], + '[': ['[', ']'], + '{': ['{', '}'], + '"': ['"', '"'], + "'": ["'", "'"], +}; + +/** + * Markdown source-mode conveniences handled before CodeMirror inserts a key. + * The returned text change and selection belong to one editor transaction so + * the caret cannot be applied against the previous document. + */ +export function getMarkdownAutoPairEdit( + value: string, + key: string, + selectionStart: number, + selectionEnd: number, +): { + from: number; + to: number; + insert: string; + selectionStart: number; + selectionEnd: number; +} | null { + const pair = MARKDOWN_WRAP_PAIRS[key]; + if (selectionEnd > selectionStart && pair) { + const selected = value.slice(selectionStart, selectionEnd); + const [open, close] = pair; + return { + from: selectionStart, + to: selectionEnd, + insert: `${open}${selected}${close}`, + selectionStart: selectionStart + open.length, + selectionEnd: selectionEnd + open.length, + }; + } + + if (key === '`' && selectionStart === selectionEnd) { + const before = value.slice(0, selectionStart); + if (/(^|\n)``$/.test(before)) { + return { + from: selectionStart, + to: selectionEnd, + insert: '`\n\n```', + selectionStart: selectionStart + 2, + selectionEnd: selectionStart + 2, + }; + } + } + + return null; +}