mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
refactor(web): extract input-history recall into a composable (#1011)
* refactor(web): extract input-history recall into a composable Move the shell-style up/down recall of previously sent messages out of Composer into useInputHistory. The composable owns the history list, the browsing cursor, and the textarea caret/selection work needed to apply a recalled entry, taking the text ref, textarea ref, and autosize as deps. The composer keeps the keydown orchestration (which also juggles the slash and mention menus) and calls into the composable for push / recall / caret / browsing state. Composer.vue: 2104 -> 2050 lines. No behavior change. * test(web): cover useInputHistory recall behavior Add unit tests for the extracted input-history composable: push dedup and empty-skip, walking backward/forward through entries, restoring the live draft (empty and non-empty), empty-history no-op, resetBrowsing, and the caretAtFirstLine gate.
This commit is contained in:
parent
fd16ffb80a
commit
fb780fce96
4 changed files with 267 additions and 73 deletions
5
.changeset/web-extract-composer-input-history.md
Normal file
5
.changeset/web-extract-composer-input-history.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Extract the composer's shell-style input-history recall into a reusable composable.
|
||||
|
|
@ -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<string[]>([]);
|
||||
// -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
apps/kimi-web/src/composables/useInputHistory.ts
Normal file
107
apps/kimi-web/src/composables/useInputHistory.ts
Normal file
|
|
@ -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<string>;
|
||||
/** The textarea element, used to read the caret and move the selection. */
|
||||
textareaRef: Ref<HTMLTextAreaElement | null>;
|
||||
/** 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<string[]>([]);
|
||||
// -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,
|
||||
};
|
||||
}
|
||||
136
apps/kimi-web/test/input-history.test.ts
Normal file
136
apps/kimi-web/test/input-history.test.ts
Normal file
|
|
@ -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<HTMLTextAreaElement | null>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue