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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions src/features/chat/controllers/input-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
Expand Down
107 changes: 107 additions & 0 deletions src/features/chat/controllers/prompt-history-controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions src/features/chat/tabs/tab-input-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading