diff --git a/components/ChatInput.test.mjs b/components/ChatInput.test.mjs index 5e5529354..bbecc312d 100644 --- a/components/ChatInput.test.mjs +++ b/components/ChatInput.test.mjs @@ -8,7 +8,7 @@ const jiti = createJiti(import.meta.url, { }); const React = await jiti.import("react"); const { renderToStaticMarkup } = await jiti.import("react-dom/server"); -const { ChatInput, ModelErrorBanner, ModelScopeWarningBanner, canClearBuiltinCommandInput, canRestoreUserMessage, canRunBuiltinSlashCommandWhileStreaming, compressImageFile, filterModelOptions, getUpwardMenuMaxHeight, getUserMessageText, getUserMessageDraftImages, isExactSlashCommand, modelSupportsImageInput, shouldCompressImageFile } = await jiti.import("./ChatInput.tsx"); +const { ChatInput, ModelErrorBanner, ModelScopeWarningBanner, canClearBuiltinCommandInput, canRestoreUserMessage, canRunBuiltinSlashCommandWhileStreaming, compressImageFile, filterModelOptions, getTextBoundaryPosition, getUpwardMenuMaxHeight, getUserMessageText, getUserMessageDraftImages, isExactSlashCommand, modelSupportsImageInput, shouldCompressImageFile } = await jiti.import("./ChatInput.tsx"); const { ModelSelector } = await jiti.import("./ModelSelector.tsx"); const { clearDraft, getDraft, mergeRestoredSubmissionDraft, mergeRestoredSubmissionText, rekeyDraft, setDraft } = await jiti.import("@/lib/draft-store.ts"); const { I18nProvider } = await jiti.import("@/hooks/useI18n"); @@ -168,6 +168,24 @@ test("caps an upward menu to the visible space above its anchor", () => { assert.equal(getUpwardMenuMaxHeight(40, 36), 0); }); +test("finds Home and End destinations in single-line and multiline input", () => { + const text = "first line\nsecond line\nthird"; + + assert.equal(getTextBoundaryPosition(text, 17, "Home"), 11); + assert.equal(getTextBoundaryPosition(text, 17, "End"), 22); + assert.equal(getTextBoundaryPosition(text, 17, "Home", true), 0); + assert.equal(getTextBoundaryPosition(text, 17, "End", true), text.length); + assert.equal(getTextBoundaryPosition("single line", 4, "Home"), 0); + assert.equal(getTextBoundaryPosition("single line", 4, "End"), 11); +}); + +test("keeps a caret on its current line at newline boundaries", () => { + const text = "first\nsecond"; + + assert.equal(getTextBoundaryPosition(text, 5, "End"), 5); + assert.equal(getTextBoundaryPosition(text, 6, "Home"), 6); +}); + test("compresses large images while preserving small images and GIFs", async () => { assert.equal(shouldCompressImageFile({ size: 1024 * 1024, type: "image/png" }), false); assert.equal(shouldCompressImageFile({ size: 1024 * 1024 + 1, type: "image/png" }), true); diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 510540f0a..50f057699 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -101,6 +101,20 @@ const COMPOSITION_END_ENTER_GRACE_MS = 100; const TEXT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); const ANCHORED_MENU_GAP = 8; +type TextBoundaryKey = "Home" | "End"; + +export function getTextBoundaryPosition( + text: string, + caret: number, + key: TextBoundaryKey, + wholeText = false, +): number { + if (wholeText) return key === "Home" ? 0 : text.length; + if (key === "Home") return text.lastIndexOf("\n", Math.max(0, caret - 1)) + 1; + const lineEnd = text.indexOf("\n", caret); + return lineEnd === -1 ? text.length : lineEnd; +} + export function getUpwardMenuMaxHeight(menuBottom: number, visibleTop: number, gap = ANCHORED_MENU_GAP): number { return Math.max(0, Math.floor(menuBottom - visibleTop - gap)); } @@ -1145,6 +1159,33 @@ export const ChatInput = forwardRef(function ChatInput({ return; } + if ((e.key === "Home" || e.key === "End") && !e.altKey && !isComposing) { + e.preventDefault(); + const textarea = e.currentTarget; + const selectionStart = textarea.selectionStart; + const selectionEnd = textarea.selectionEnd; + const focus = textarea.selectionDirection === "backward" ? selectionStart : selectionEnd; + const anchor = textarea.selectionDirection === "backward" ? selectionEnd : selectionStart; + const target = getTextBoundaryPosition( + textarea.value, + focus, + e.key, + e.ctrlKey || e.metaKey, + ); + + if (e.shiftKey) { + textarea.setSelectionRange( + Math.min(anchor, target), + Math.max(anchor, target), + target < anchor ? "backward" : target > anchor ? "forward" : "none", + ); + } else { + textarea.setSelectionRange(target, target); + } + updateAtQuery(textarea.value, target); + return; + } + if (historyMenuOpen && !isComposing) { if (e.key === "ArrowDown") { e.preventDefault(); @@ -1265,7 +1306,7 @@ export const ChatInput = forwardRef(function ChatInput({ } } }, - [isMobile, isStreaming, onSteer, onFollowUp, onAbort, slashMenuOpen, slashQuery, displayedSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, value] + [isMobile, isStreaming, onSteer, onFollowUp, onAbort, slashMenuOpen, slashQuery, displayedSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, updateAtQuery, value] ); const handleInput = useCallback(() => {