From 63c296be22938008409dd5f792ce3bdd800108a5 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Sun, 20 Sep 2026 00:25:10 +0800 Subject: [PATCH] feat(composer): recall sent messages with the arrow keys With the message input empty, ArrowUp now walks back through the messages you sent in the current conversation and ArrowDown walks forward again, so an earlier prompt can be recalled, edited, or resent without scrolling the transcript. History is read from the messages already in the pane, so it survives restarts without extra storage, and editing a recalled message, switching conversations, or sending drops out of history mode. --- CHANGELOG.md | 3 + .../chat/controllers/input-controller.ts | 13 + .../controllers/prompt-history-controller.ts | 107 ++++++++ src/features/chat/tabs/tab-input-events.ts | 1 + .../prompt-history-controller.test.ts | 253 ++++++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 src/features/chat/controllers/prompt-history-controller.ts create mode 100644 tests/unit/features/chat/controllers/prompt-history-controller.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ebfa259..cd5d1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ version with its date and start a fresh empty `[Unreleased]` above it. - An update icon appears in the view header when GitHub has a newer stable release; click it to open Qoderian's plugin page, where Obsidian's update button lives. +- With the message input empty, ArrowUp walks back through the messages you + sent in this conversation and ArrowDown walks forward again, so you can + recall, edit, or resend an earlier prompt without scrolling the transcript. ### Changed diff --git a/src/features/chat/controllers/input-controller.ts b/src/features/chat/controllers/input-controller.ts index 6fdfd80..b5c2659 100644 --- a/src/features/chat/controllers/input-controller.ts +++ b/src/features/chat/controllers/input-controller.ts @@ -31,6 +31,7 @@ import type { BrowserSelectionController } from './browser-selection-controller' import type { CanvasSelectionController } from './canvas-selection-controller'; import type { ConversationController } from './conversation-controller'; import { InputCommandController } from './input-command-controller'; +import { PromptHistoryController } from './prompt-history-controller'; import { QueuedMessageController } from './queued-message-controller'; import { cloneChatTurnRequest, type QueuedChatTurn } from './queued-turn'; import type { SelectionController } from './selection-controller'; @@ -76,6 +77,7 @@ export class InputController { private deps: InputControllerDeps; private readonly approvalFlow: ApprovalFlowController; private readonly inputCommands: InputCommandController; + private readonly promptHistory: PromptHistoryController; private readonly queuedMessages: QueuedMessageController; private activeStreamingAssistantMessage: ChatMessage | null = null; // While a steer splice is swapping the render target (finalizing the old @@ -134,6 +136,11 @@ export class InputController { }); }, }); + this.promptHistory = new PromptHistoryController({ + getInputEl: deps.getInputEl, + getMessages: () => deps.state.messages, + getConversationId: () => deps.state.currentConversationId, + }); } private getAgentService(): ChatRuntime | null { @@ -1106,6 +1113,12 @@ export class InputController { return this.inputCommands.handleResumeKeydown(e); } + handlePromptHistoryKeydown(e: KeyboardEvent): boolean { + // An armed instruction mode (#) owns the empty composer; leave the arrows alone. + if (this.deps.getInstructionModeManager()?.isActive()) return false; + return this.promptHistory.handleKeydown(e); + } + isResumeDropdownVisible(): boolean { return this.inputCommands.isResumeDropdownVisible(); } diff --git a/src/features/chat/controllers/prompt-history-controller.ts b/src/features/chat/controllers/prompt-history-controller.ts new file mode 100644 index 0000000..a26c811 --- /dev/null +++ b/src/features/chat/controllers/prompt-history-controller.ts @@ -0,0 +1,107 @@ +import type { ChatMessage } from '../../../core/types'; +import { extractUserDisplayContent } from '../../../qoder/prompt/context/prompt-context'; +import { autoResizeTextarea } from '../ui/textarea-resize'; + +export interface PromptHistoryControllerDeps { + getInputEl: () => HTMLTextAreaElement; + getMessages: () => ChatMessage[]; + getConversationId: () => string | null; +} + +/** + * ArrowUp in an empty composer walks back through the messages sent in the + * current session; ArrowDown walks forward again and finally back to the empty + * draft. Browsing is considered intact only while the composer still shows the + * entry that was last recalled, so typing, sending, or switching conversations + * drops out of history mode. + */ +export class PromptHistoryController { + private entries: string[] = []; + private position = 0; + private sourceConversationId: string | null = null; + + constructor(private readonly deps: PromptHistoryControllerDeps) {} + + /** Returns true when the key was consumed as history navigation. */ + handleKeydown(event: KeyboardEvent): boolean { + if (event.isComposing) return false; + if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return false; + if (event.key === 'ArrowUp') return this.showPreviousEntry(event); + if (event.key === 'ArrowDown') return this.showNextEntry(event); + return false; + } + + private showPreviousEntry(event: KeyboardEvent): boolean { + if (!this.isBrowsingIntact()) { + if (this.deps.getInputEl().value.length > 0) return false; + this.entries = this.collectSentMessages(); + if (this.entries.length === 0) return false; + this.position = this.entries.length; + this.sourceConversationId = this.deps.getConversationId(); + } + + if (this.position === 0) return false; + + this.position -= 1; + this.restoreEntry(this.entries[this.position]); + event.preventDefault(); + return true; + } + + private showNextEntry(event: KeyboardEvent): boolean { + if (!this.isBrowsingIntact()) return false; + if (this.position >= this.entries.length) return false; + + this.position += 1; + event.preventDefault(); + + if (this.position === this.entries.length) { + this.clearInput(); + return true; + } + + this.restoreEntry(this.entries[this.position]); + return true; + } + + private isBrowsingIntact(): boolean { + if (this.entries.length === 0) return false; + if (this.sourceConversationId !== this.deps.getConversationId()) return false; + + const shown = this.position < this.entries.length ? this.entries[this.position] : ''; + return this.deps.getInputEl().value === shown; + } + + private collectSentMessages(): string[] { + const entries: string[] = []; + + for (const message of this.deps.getMessages()) { + if (message.role !== 'user') continue; + if (message.isInterrupt || message.isRebuiltContext) continue; + + const text = this.getDisplayText(message).trim(); + if (text.length > 0) entries.push(text); + } + + return entries; + } + + private getDisplayText(message: ChatMessage): string { + return message.displayContent + ?? extractUserDisplayContent(message.content) + ?? message.content; + } + + private restoreEntry(text: string): void { + const inputEl = this.deps.getInputEl(); + inputEl.value = text; + autoResizeTextarea(inputEl); + inputEl.setSelectionRange(text.length, text.length); + } + + private clearInput(): void { + const inputEl = this.deps.getInputEl(); + inputEl.value = ''; + autoResizeTextarea(inputEl); + } +} diff --git a/src/features/chat/tabs/tab-input-events.ts b/src/features/chat/tabs/tab-input-events.ts index 6b40651..b4e1bcd 100644 --- a/src/features/chat/tabs/tab-input-events.ts +++ b/src/features/chat/tabs/tab-input-events.ts @@ -49,6 +49,7 @@ export function wireTabInputEvents(tab: TabData, plugin: QoderianPlugin): void { if (ui.instructionModeManager?.handleKeydown(event)) return; if (sendTabInputMessageFromExplicitEnterShortcut(tab, event)) return; if (handleDropdownKeydown(event)) return; + if (controllers.inputController?.handlePromptHistoryKeydown(event)) return; if (event.key === 'Escape' && !event.isComposing && state.isStreaming) { event.preventDefault(); diff --git a/tests/unit/features/chat/controllers/prompt-history-controller.test.ts b/tests/unit/features/chat/controllers/prompt-history-controller.test.ts new file mode 100644 index 0000000..9943cfa --- /dev/null +++ b/tests/unit/features/chat/controllers/prompt-history-controller.test.ts @@ -0,0 +1,253 @@ +import type { ChatMessage } from '@/core/types'; +import { + PromptHistoryController, + type PromptHistoryControllerDeps, +} from '@/features/chat/controllers/prompt-history-controller'; +import { autoResizeTextarea } from '@/features/chat/ui/textarea-resize'; + +jest.mock('@/features/chat/ui/textarea-resize', () => ({ + autoResizeTextarea: jest.fn(), +})); + +function createInput(): HTMLTextAreaElement { + return { + value: '', + focus: jest.fn(), + setSelectionRange: jest.fn(), + } as unknown as HTMLTextAreaElement; +} + +function createUserMessage(text: string, overrides: Partial = {}): ChatMessage { + return { + id: `msg-${text}`, + role: 'user', + content: text, + displayContent: text, + timestamp: Date.now(), + ...overrides, + }; +} + +function createKeyEvent(key: string, overrides: Partial = {}): KeyboardEvent { + return { + key, + isComposing: false, + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + preventDefault: jest.fn(), + ...overrides, + } as unknown as KeyboardEvent; +} + +const autoResizeMock = autoResizeTextarea as unknown as jest.Mock; + +describe('PromptHistoryController', () => { + let inputEl: HTMLTextAreaElement; + let messages: ChatMessage[]; + let conversationId: string | null; + + function createController(overrides: Partial = {}) { + return new PromptHistoryController({ + getInputEl: () => inputEl, + getMessages: () => messages, + getConversationId: () => conversationId, + ...overrides, + }); + } + + beforeEach(() => { + inputEl = createInput(); + conversationId = 'conv-1'; + messages = [ + createUserMessage('first message'), + { id: 'assistant-1', role: 'assistant', content: 'reply', timestamp: Date.now() }, + createUserMessage('second message'), + createUserMessage('third message'), + ]; + autoResizeMock.mockClear(); + }); + + it('recalls the most recent sent message on ArrowUp in an empty input', () => { + const controller = createController(); + const event = createKeyEvent('ArrowUp'); + + expect(controller.handleKeydown(event)).toBe(true); + expect(inputEl.value).toBe('third message'); + expect(event.preventDefault).toHaveBeenCalled(); + expect(autoResizeMock).toHaveBeenCalledWith(inputEl); + expect(inputEl.setSelectionRange).toHaveBeenCalledWith('third message'.length, 'third message'.length); + }); + + it('walks further back on repeated ArrowUp and stops at the oldest message', () => { + const controller = createController(); + + controller.handleKeydown(createKeyEvent('ArrowUp')); + expect(inputEl.value).toBe('third message'); + + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(true); + expect(inputEl.value).toBe('second message'); + + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(true); + expect(inputEl.value).toBe('first message'); + + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(false); + expect(inputEl.value).toBe('first message'); + }); + + it('walks forward on ArrowDown and returns to the empty draft past the newest message', () => { + const controller = createController(); + + controller.handleKeydown(createKeyEvent('ArrowUp')); + controller.handleKeydown(createKeyEvent('ArrowUp')); + expect(inputEl.value).toBe('second message'); + + const forward = createKeyEvent('ArrowDown'); + expect(controller.handleKeydown(forward)).toBe(true); + expect(inputEl.value).toBe('third message'); + + const backToDraft = createKeyEvent('ArrowDown'); + expect(controller.handleKeydown(backToDraft)).toBe(true); + expect(inputEl.value).toBe(''); + expect(backToDraft.preventDefault).toHaveBeenCalled(); + + expect(controller.handleKeydown(createKeyEvent('ArrowDown'))).toBe(false); + expect(inputEl.value).toBe(''); + }); + + it('does not start browsing while the composer holds a draft', () => { + const controller = createController(); + inputEl.value = 'typing something'; + + const event = createKeyEvent('ArrowUp'); + expect(controller.handleKeydown(event)).toBe(false); + expect(inputEl.value).toBe('typing something'); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('stops browsing once the recalled message is edited', () => { + const controller = createController(); + controller.handleKeydown(createKeyEvent('ArrowUp')); + + inputEl.value = 'third message edited'; + expect(controller.handleKeydown(createKeyEvent('ArrowDown'))).toBe(false); + + // Typing leaves a draft behind, so ArrowUp no longer recalls either. + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(false); + expect(inputEl.value).toBe('third message edited'); + }); + + it('restarts browsing from the newest message when the draft is cleared', () => { + const controller = createController(); + controller.handleKeydown(createKeyEvent('ArrowUp')); + controller.handleKeydown(createKeyEvent('ArrowUp')); + expect(inputEl.value).toBe('second message'); + + inputEl.value = ''; + + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(true); + expect(inputEl.value).toBe('third message'); + }); + + it('restarts browsing against the messages of the current conversation', () => { + const controller = createController(); + controller.handleKeydown(createKeyEvent('ArrowUp')); + expect(inputEl.value).toBe('third message'); + + conversationId = 'conv-2'; + inputEl.value = ''; + messages = [createUserMessage('other conversation message')]; + + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(true); + expect(inputEl.value).toBe('other conversation message'); + }); + + it('ignores arrow keys while an IME composition is active', () => { + const controller = createController(); + const event = createKeyEvent('ArrowUp', { isComposing: true } as Partial); + + expect(controller.handleKeydown(event)).toBe(false); + expect(inputEl.value).toBe(''); + }); + + it('ignores arrow keys when a modifier is held', () => { + const controller = createController(); + + for (const modifier of ['shiftKey', 'ctrlKey', 'metaKey', 'altKey'] as const) { + const event = createKeyEvent('ArrowUp', { [modifier]: true } as Partial); + expect(controller.handleKeydown(event)).toBe(false); + } + expect(inputEl.value).toBe(''); + }); + + it('ignores other keys', () => { + const controller = createController(); + + expect(controller.handleKeydown(createKeyEvent('ArrowLeft'))).toBe(false); + expect(controller.handleKeydown(createKeyEvent('Enter'))).toBe(false); + expect(controller.handleKeydown(createKeyEvent('a'))).toBe(false); + expect(inputEl.value).toBe(''); + }); + + it('does nothing when the conversation has no sent messages', () => { + messages = []; + const controller = createController(); + + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(false); + expect(inputEl.value).toBe(''); + }); + + it('skips interrupt and rebuilt-context messages', () => { + messages = [ + createUserMessage('real message'), + createUserMessage('interrupt', { isInterrupt: true }), + createUserMessage('rebuilt', { isRebuiltContext: true }), + ]; + const controller = createController(); + + controller.handleKeydown(createKeyEvent('ArrowUp')); + expect(inputEl.value).toBe('real message'); + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(false); + }); + + it('skips messages without text', () => { + messages = [ + createUserMessage('with text'), + { id: 'image-only', role: 'user', content: '', displayContent: '', timestamp: Date.now() }, + ]; + const controller = createController(); + + controller.handleKeydown(createKeyEvent('ArrowUp')); + expect(inputEl.value).toBe('with text'); + expect(controller.handleKeydown(createKeyEvent('ArrowUp'))).toBe(false); + }); + + it('prefers the display content of a message over the expanded prompt', () => { + messages = [ + createUserMessage('', { + content: '/tests\n\n\npath.md\n', + displayContent: '/tests', + }), + ]; + const controller = createController(); + + controller.handleKeydown(createKeyEvent('ArrowUp')); + expect(inputEl.value).toBe('/tests'); + }); + + it('extracts the display content when a message has no display content', () => { + messages = [ + { + id: 'legacy', + role: 'user', + content: 'check this\n\n\npath.md\n', + timestamp: Date.now(), + }, + ]; + const controller = createController(); + + controller.handleKeydown(createKeyEvent('ArrowUp')); + expect(inputEl.value).toBe('check this'); + }); +});