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'); + }); +});