Skip to content

Commit d6b400b

Browse files
authored
feat(composer): recall sent messages with the arrow keys (#72)
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.
1 parent a4d3aa8 commit d6b400b

5 files changed

Lines changed: 377 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1919
- An update icon appears in the view header when GitHub has a newer stable
2020
release; click it to open Qoderian's plugin page, where Obsidian's update
2121
button lives.
22+
- With the message input empty, ArrowUp walks back through the messages you
23+
sent in this conversation and ArrowDown walks forward again, so you can
24+
recall, edit, or resend an earlier prompt without scrolling the transcript.
2225

2326
### Changed
2427

‎src/features/chat/controllers/input-controller.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type { BrowserSelectionController } from './browser-selection-controller'
3131
import type { CanvasSelectionController } from './canvas-selection-controller';
3232
import type { ConversationController } from './conversation-controller';
3333
import { InputCommandController } from './input-command-controller';
34+
import { PromptHistoryController } from './prompt-history-controller';
3435
import { QueuedMessageController } from './queued-message-controller';
3536
import { cloneChatTurnRequest, type QueuedChatTurn } from './queued-turn';
3637
import type { SelectionController } from './selection-controller';
@@ -76,6 +77,7 @@ export class InputController {
7677
private deps: InputControllerDeps;
7778
private readonly approvalFlow: ApprovalFlowController;
7879
private readonly inputCommands: InputCommandController;
80+
private readonly promptHistory: PromptHistoryController;
7981
private readonly queuedMessages: QueuedMessageController;
8082
private activeStreamingAssistantMessage: ChatMessage | null = null;
8183
// While a steer splice is swapping the render target (finalizing the old
@@ -134,6 +136,11 @@ export class InputController {
134136
});
135137
},
136138
});
139+
this.promptHistory = new PromptHistoryController({
140+
getInputEl: deps.getInputEl,
141+
getMessages: () => deps.state.messages,
142+
getConversationId: () => deps.state.currentConversationId,
143+
});
137144
}
138145

139146
private getAgentService(): ChatRuntime | null {
@@ -1106,6 +1113,12 @@ export class InputController {
11061113
return this.inputCommands.handleResumeKeydown(e);
11071114
}
11081115

1116+
handlePromptHistoryKeydown(e: KeyboardEvent): boolean {
1117+
// An armed instruction mode (#) owns the empty composer; leave the arrows alone.
1118+
if (this.deps.getInstructionModeManager()?.isActive()) return false;
1119+
return this.promptHistory.handleKeydown(e);
1120+
}
1121+
11091122
isResumeDropdownVisible(): boolean {
11101123
return this.inputCommands.isResumeDropdownVisible();
11111124
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { ChatMessage } from '../../../core/types';
2+
import { extractUserDisplayContent } from '../../../qoder/prompt/context/prompt-context';
3+
import { autoResizeTextarea } from '../ui/textarea-resize';
4+
5+
export interface PromptHistoryControllerDeps {
6+
getInputEl: () => HTMLTextAreaElement;
7+
getMessages: () => ChatMessage[];
8+
getConversationId: () => string | null;
9+
}
10+
11+
/**
12+
* ArrowUp in an empty composer walks back through the messages sent in the
13+
* current session; ArrowDown walks forward again and finally back to the empty
14+
* draft. Browsing is considered intact only while the composer still shows the
15+
* entry that was last recalled, so typing, sending, or switching conversations
16+
* drops out of history mode.
17+
*/
18+
export class PromptHistoryController {
19+
private entries: string[] = [];
20+
private position = 0;
21+
private sourceConversationId: string | null = null;
22+
23+
constructor(private readonly deps: PromptHistoryControllerDeps) {}
24+
25+
/** Returns true when the key was consumed as history navigation. */
26+
handleKeydown(event: KeyboardEvent): boolean {
27+
if (event.isComposing) return false;
28+
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return false;
29+
if (event.key === 'ArrowUp') return this.showPreviousEntry(event);
30+
if (event.key === 'ArrowDown') return this.showNextEntry(event);
31+
return false;
32+
}
33+
34+
private showPreviousEntry(event: KeyboardEvent): boolean {
35+
if (!this.isBrowsingIntact()) {
36+
if (this.deps.getInputEl().value.length > 0) return false;
37+
this.entries = this.collectSentMessages();
38+
if (this.entries.length === 0) return false;
39+
this.position = this.entries.length;
40+
this.sourceConversationId = this.deps.getConversationId();
41+
}
42+
43+
if (this.position === 0) return false;
44+
45+
this.position -= 1;
46+
this.restoreEntry(this.entries[this.position]);
47+
event.preventDefault();
48+
return true;
49+
}
50+
51+
private showNextEntry(event: KeyboardEvent): boolean {
52+
if (!this.isBrowsingIntact()) return false;
53+
if (this.position >= this.entries.length) return false;
54+
55+
this.position += 1;
56+
event.preventDefault();
57+
58+
if (this.position === this.entries.length) {
59+
this.clearInput();
60+
return true;
61+
}
62+
63+
this.restoreEntry(this.entries[this.position]);
64+
return true;
65+
}
66+
67+
private isBrowsingIntact(): boolean {
68+
if (this.entries.length === 0) return false;
69+
if (this.sourceConversationId !== this.deps.getConversationId()) return false;
70+
71+
const shown = this.position < this.entries.length ? this.entries[this.position] : '';
72+
return this.deps.getInputEl().value === shown;
73+
}
74+
75+
private collectSentMessages(): string[] {
76+
const entries: string[] = [];
77+
78+
for (const message of this.deps.getMessages()) {
79+
if (message.role !== 'user') continue;
80+
if (message.isInterrupt || message.isRebuiltContext) continue;
81+
82+
const text = this.getDisplayText(message).trim();
83+
if (text.length > 0) entries.push(text);
84+
}
85+
86+
return entries;
87+
}
88+
89+
private getDisplayText(message: ChatMessage): string {
90+
return message.displayContent
91+
?? extractUserDisplayContent(message.content)
92+
?? message.content;
93+
}
94+
95+
private restoreEntry(text: string): void {
96+
const inputEl = this.deps.getInputEl();
97+
inputEl.value = text;
98+
autoResizeTextarea(inputEl);
99+
inputEl.setSelectionRange(text.length, text.length);
100+
}
101+
102+
private clearInput(): void {
103+
const inputEl = this.deps.getInputEl();
104+
inputEl.value = '';
105+
autoResizeTextarea(inputEl);
106+
}
107+
}

‎src/features/chat/tabs/tab-input-events.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export function wireTabInputEvents(tab: TabData, plugin: QoderianPlugin): void {
4949
if (ui.instructionModeManager?.handleKeydown(event)) return;
5050
if (sendTabInputMessageFromExplicitEnterShortcut(tab, event)) return;
5151
if (handleDropdownKeydown(event)) return;
52+
if (controllers.inputController?.handlePromptHistoryKeydown(event)) return;
5253

5354
if (event.key === 'Escape' && !event.isComposing && state.isStreaming) {
5455
event.preventDefault();

0 commit comments

Comments
 (0)