diff --git a/.changeset/web-extract-composer-input-history.md b/.changeset/web-extract-composer-input-history.md new file mode 100644 index 000000000..24947eea8 --- /dev/null +++ b/.changeset/web-extract-composer-input-history.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Extract the composer's shell-style input-history recall into a reusable composable. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index 075c206cf..3f5b8cbb2 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -11,6 +11,7 @@ import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPrompt import type { AppModel, AppSkill, ThinkingLevel } from '../api/types'; import { modelThinkingAvailability } from '../lib/modelThinking'; import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage'; +import { useInputHistory } from '../composables/useInputHistory'; // --------------------------------------------------------------------------- // Attachment state @@ -142,66 +143,11 @@ watch( ); // --------------------------------------------------------------------------- -// Sent-message history recall (shell-style ↑/↓). ArrowUp on the first line -// recalls older messages; ArrowDown on the last line walks back toward the live -// draft. Editing the text drops out of history browsing. +// Sent-message history recall (shell-style ↑/↓). See useInputHistory for the +// implementation; the composer keeps the keydown orchestration (which also +// juggles the slash and mention menus). // --------------------------------------------------------------------------- -const inputHistory = ref([]); -// -1 = browsing nothing (live draft). Otherwise an index into inputHistory. -let historyIndex = -1; -let draftBeforeHistory = ''; - -function pushInputHistory(entry: string): void { - const trimmed = entry.trim(); - historyIndex = -1; - if (!trimmed) return; - // Skip consecutive duplicates so repeated sends don't pad the history. - if (inputHistory.value[inputHistory.value.length - 1] === trimmed) return; - inputHistory.value = [...inputHistory.value, trimmed]; -} - -function caretAtFirstLine(): boolean { - const el = textareaRef.value; - if (!el) return false; - const pos = el.selectionStart ?? 0; - // No newline before the caret → it sits on the first visual line. - return el.value.lastIndexOf('\n', pos - 1) === -1; -} - -function applyHistoryText(value: string): void { - text.value = value; - void nextTick(() => { - const el = textareaRef.value; - if (!el) return; - autosize(); - const pos = value.length; - el.setSelectionRange(pos, pos); - }); -} - -function recallOlder(): void { - if (inputHistory.value.length === 0) return; - if (historyIndex === -1) { - draftBeforeHistory = text.value; - historyIndex = inputHistory.value.length - 1; - } else if (historyIndex > 0) { - historyIndex -= 1; - } else { - return; // already at the oldest entry - } - applyHistoryText(inputHistory.value[historyIndex]!); -} - -function recallNewer(): void { - if (historyIndex === -1) return; - if (historyIndex < inputHistory.value.length - 1) { - historyIndex += 1; - applyHistoryText(inputHistory.value[historyIndex]!); - } else { - historyIndex = -1; - applyHistoryText(draftBeforeHistory); - } -} +const history = useInputHistory({ text, textareaRef, autosize }); // --------------------------------------------------------------------------- // Slash-command menu @@ -316,7 +262,7 @@ function selectMentionItem(item: FileItem): void { function handleInput(): void { // Manual typing leaves history-browsing mode — the text is now a fresh draft. - historyIndex = -1; + history.resetBrowsing(); updateSlashMenu(); updateMentionMenu(); } @@ -542,7 +488,7 @@ function handleSubmit(): void { } attachments.value = []; - pushInputHistory(trimmed); + history.push(trimmed); text.value = ''; slashOpen.value = false; mentionOpen.value = false; @@ -570,7 +516,7 @@ function handleSteer(): void { revokeAttachment(att); } attachments.value = []; - pushInputHistory(trimmed); + history.push(trimmed); text.value = ''; slashOpen.value = false; mentionOpen.value = false; @@ -680,26 +626,26 @@ function handleKeydown(e: KeyboardEvent): void { return; } - // History recall (shell-style ↑/↓). + // History recall (shell-style ↑/↓) — see useInputHistory for the machinery. // // ENTERING history: a plain ArrowUp only recalls when the caret is on the // first line, so editing a multi-line draft with the arrows still works. - // ONCE BROWSING (historyIndex !== -1), the arrows walk history directly, - // regardless of where the caret landed — a recalled multi-line entry leaves - // the caret at its end, and the old "must be on the first line" gate then - // trapped it there, so further ArrowUp did nothing ("only one step back"). - // Walking freely while browsing fixes that; typing exits history (handleInput - // resets historyIndex), after which the arrows move the caret normally again. + // ONCE BROWSING, the arrows walk history directly, regardless of where the + // caret landed — a recalled multi-line entry leaves the caret at its end, and + // the old "must be on the first line" gate then trapped it there, so further + // ArrowUp did nothing ("only one step back"). Walking freely while browsing + // fixes that; typing exits history (handleInput resets browsing), after which + // the arrows move the caret normally again. if (!slashOpen.value && !mentionOpen.value && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) { - const browsing = historyIndex !== -1; - if (e.key === 'ArrowUp' && inputHistory.value.length > 0 && (browsing || caretAtFirstLine())) { + const browsing = history.isBrowsing(); + if (e.key === 'ArrowUp' && history.hasHistory() && (browsing || history.caretAtFirstLine())) { e.preventDefault(); - recallOlder(); + history.recallOlder(); return; } if (e.key === 'ArrowDown' && browsing) { e.preventDefault(); - recallNewer(); + history.recallNewer(); return; } } diff --git a/apps/kimi-web/src/composables/useInputHistory.ts b/apps/kimi-web/src/composables/useInputHistory.ts new file mode 100644 index 000000000..134e1e30a --- /dev/null +++ b/apps/kimi-web/src/composables/useInputHistory.ts @@ -0,0 +1,107 @@ +// apps/kimi-web/src/composables/useInputHistory.ts +import { nextTick, ref, type Ref } from 'vue'; + +export interface InputHistoryDeps { + /** The live composer text — recalled entries overwrite it. */ + text: Ref; + /** The textarea element, used to read the caret and move the selection. */ + textareaRef: Ref; + /** Re-fit the textarea after its text changes. */ + autosize: () => void; +} + +/** + * Shell-style ↑/↓ recall of previously sent messages. + * + * `ArrowUp` on the first line steps back through older entries; `ArrowDown` + * walks forward again and ultimately restores the draft the user had before + * they started browsing. Any manual edit drops out of browsing mode (see + * `resetBrowsing`, called from the composer's input handler). + * + * The composer keeps the keydown orchestration (which also juggles the slash + * and mention menus); this composable owns only the history list, the browsing + * cursor, and the textarea caret/selection work needed to apply a recalled + * entry. + */ +export function useInputHistory(deps: InputHistoryDeps) { + const { text, textareaRef, autosize } = deps; + + const inputHistory = ref([]); + // -1 = browsing nothing (live draft). Otherwise an index into inputHistory. + let historyIndex = -1; + let draftBeforeHistory = ''; + + function push(entry: string): void { + const trimmed = entry.trim(); + historyIndex = -1; + if (!trimmed) return; + // Skip consecutive duplicates so repeated sends don't pad the history. + if (inputHistory.value.at(-1) === trimmed) return; + inputHistory.value = [...inputHistory.value, trimmed]; + } + + function caretAtFirstLine(): boolean { + const el = textareaRef.value; + if (!el) return false; + const pos = el.selectionStart ?? 0; + // No newline before the caret → it sits on the first visual line. + return el.value.lastIndexOf('\n', pos - 1) === -1; + } + + function applyHistoryText(value: string): void { + text.value = value; + void nextTick(() => { + const el = textareaRef.value; + if (!el) return; + autosize(); + const pos = value.length; + el.setSelectionRange(pos, pos); + }); + } + + function recallOlder(): void { + if (inputHistory.value.length === 0) return; + if (historyIndex === -1) { + draftBeforeHistory = text.value; + historyIndex = inputHistory.value.length - 1; + } else if (historyIndex > 0) { + historyIndex -= 1; + } else { + return; // already at the oldest entry + } + applyHistoryText(inputHistory.value[historyIndex]!); + } + + function recallNewer(): void { + if (historyIndex === -1) return; + if (historyIndex < inputHistory.value.length - 1) { + historyIndex += 1; + applyHistoryText(inputHistory.value[historyIndex]!); + } else { + historyIndex = -1; + applyHistoryText(draftBeforeHistory); + } + } + + function resetBrowsing(): void { + historyIndex = -1; + } + + function isBrowsing(): boolean { + return historyIndex !== -1; + } + + function hasHistory(): boolean { + return inputHistory.value.length > 0; + } + + return { + push, + caretAtFirstLine, + recallOlder, + recallNewer, + resetBrowsing, + isBrowsing, + hasHistory, + }; +} diff --git a/apps/kimi-web/test/input-history.test.ts b/apps/kimi-web/test/input-history.test.ts new file mode 100644 index 000000000..1fe294945 --- /dev/null +++ b/apps/kimi-web/test/input-history.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { ref, type Ref } from 'vue'; +import { useInputHistory } from '../src/composables/useInputHistory'; + +interface MockTextarea { + value: string; + selectionStart: number; + selectionEnd: number; + setSelectionRange: (start: number, end: number) => void; +} + +function setup(initialText = '', caret = 0) { + const textarea: MockTextarea = { + value: initialText, + selectionStart: caret, + selectionEnd: caret, + setSelectionRange(start: number, end: number) { + this.selectionStart = start; + this.selectionEnd = end; + }, + }; + const text = ref(initialText); + const textareaRef = ref(textarea as unknown as HTMLTextAreaElement) as Ref; + const history = useInputHistory({ text, textareaRef, autosize: () => {} }); + return { text, textarea, history }; +} + +describe('useInputHistory — push', () => { + it('ignores empty or whitespace-only entries', () => { + const { history } = setup(); + history.push(''); + history.push(' '); + expect(history.hasHistory()).toBe(false); + }); + + it('appends distinct entries newest-last', () => { + const { history } = setup(); + history.push('a'); + history.push('b'); + history.push('c'); + expect(history.hasHistory()).toBe(true); + }); + + it('skips a consecutive duplicate', () => { + const { text, history } = setup(); + history.push('a'); + history.push('a'); // duplicate of the newest entry — must be dropped + history.push('b'); + history.recallOlder(); // -> b + expect(text.value).toBe('b'); + history.recallOlder(); // -> a (only one 'a' was kept) + expect(text.value).toBe('a'); + history.recallOlder(); // already oldest — must stay, not land on a second 'a' + expect(text.value).toBe('a'); + }); +}); + +describe('useInputHistory — recall', () => { + it('walks backward from the most recent entry, then restores the live draft', () => { + const { text, history } = setup('draft'); + history.push('a'); + history.push('b'); + history.push('c'); + + expect(history.isBrowsing()).toBe(false); + history.recallOlder(); // -> c + expect(text.value).toBe('c'); + expect(history.isBrowsing()).toBe(true); + history.recallOlder(); // -> b + expect(text.value).toBe('b'); + history.recallOlder(); // -> a + expect(text.value).toBe('a'); + history.recallOlder(); // already oldest, stay + expect(text.value).toBe('a'); + + history.recallNewer(); // -> b + expect(text.value).toBe('b'); + history.recallNewer(); // -> c + expect(text.value).toBe('c'); + history.recallNewer(); // -> back to the live draft + expect(text.value).toBe('draft'); + expect(history.isBrowsing()).toBe(false); + }); + + it('restores an empty live draft after recalling the single newest entry', () => { + const { text, history } = setup(''); + history.push('only'); + history.recallOlder(); + expect(text.value).toBe('only'); + history.recallNewer(); + expect(text.value).toBe(''); + }); + + it('does nothing when recalling with an empty history', () => { + const { text, history } = setup('draft'); + history.recallOlder(); + history.recallNewer(); + expect(text.value).toBe('draft'); + expect(history.isBrowsing()).toBe(false); + }); + + it('resetBrowsing drops out of history mode without changing text', () => { + const { text, history } = setup('draft'); + history.push('a'); + history.recallOlder(); + expect(history.isBrowsing()).toBe(true); + history.resetBrowsing(); + expect(history.isBrowsing()).toBe(false); + expect(text.value).toBe('a'); // the recalled entry stays as the editable text + }); +}); + +describe('useInputHistory — caretAtFirstLine', () => { + it('is true at the very start of the text', () => { + const { textarea, history } = setup('hello\nworld', 0); + textarea.value = 'hello\nworld'; + expect(history.caretAtFirstLine()).toBe(true); + }); + + it('is true when the caret sits before any newline', () => { + const { textarea, history } = setup('hello\nworld', 3); + textarea.value = 'hello\nworld'; + expect(history.caretAtFirstLine()).toBe(true); + }); + + it('is false once the caret is past the first newline', () => { + const { textarea, history } = setup('hello\nworld', 8); + textarea.value = 'hello\nworld'; + expect(history.caretAtFirstLine()).toBe(false); + }); + + it('is true for an empty composer', () => { + const { history } = setup('', 0); + expect(history.caretAtFirstLine()).toBe(true); + }); +});