Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-extract-composer-input-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Extract the composer's shell-style input-history recall into a reusable composable.
92 changes: 19 additions & 73 deletions apps/kimi-web/src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -542,7 +488,7 @@ function handleSubmit(): void {
}
attachments.value = [];

pushInputHistory(trimmed);
history.push(trimmed);
text.value = '';
slashOpen.value = false;
mentionOpen.value = false;
Expand Down Expand Up @@ -570,7 +516,7 @@ function handleSteer(): void {
revokeAttachment(att);
}
attachments.value = [];
pushInputHistory(trimmed);
history.push(trimmed);
text.value = '';
slashOpen.value = false;
mentionOpen.value = false;
Expand Down Expand Up @@ -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;
}
}
Expand Down
107 changes: 107 additions & 0 deletions apps/kimi-web/src/composables/useInputHistory.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 136 additions & 0 deletions apps/kimi-web/test/input-history.test.ts
Original file line number Diff line number Diff line change
@@ -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