Skip to content

Commit ac320f8

Browse files
authored
Merge pull request #31 from yaacov/history
history
2 parents 4c3d379 + 5686e63 commit ac320f8

3 files changed

Lines changed: 88 additions & 1 deletion

File tree

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/src/components/chat-composer.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { LitElement, html, css } from "lit";
22
import { customElement, property, state, query } from "lit/decorators.js";
33
import { appState } from "../state/app-state.js";
4+
import { InputHistory } from "../utils/input-history.js";
45

56
@customElement("chat-composer")
67
export class ChatComposer extends LitElement {
@@ -179,6 +180,7 @@ export class ChatComposer extends LitElement {
179180

180181
@query("textarea") private textarea!: HTMLTextAreaElement;
181182

183+
private history = new InputHistory(this);
182184
private unsubscribe?: () => void;
183185

184186
connectedCallback() {
@@ -208,10 +210,23 @@ export class ChatComposer extends LitElement {
208210

209211
private handleInput(e: InputEvent) {
210212
this.text = (e.target as HTMLTextAreaElement).value;
213+
this.history.reset();
211214
this.autoResize();
212215
}
213216

214217
private handleKeyDown(e: KeyboardEvent) {
218+
const result = this.history.handleKeyDown(e, this.textarea, this.text);
219+
if (result.handled) {
220+
e.preventDefault();
221+
this.text = result.text;
222+
this.updateComplete.then(() => {
223+
this.autoResize();
224+
if (this.textarea) {
225+
this.textarea.selectionStart = this.textarea.selectionEnd = this.textarea.value.length;
226+
}
227+
});
228+
return;
229+
}
215230
if (e.key === "Enter" && !e.shiftKey) {
216231
e.preventDefault();
217232
this.send();
@@ -226,6 +241,7 @@ export class ChatComposer extends LitElement {
226241
const msg = this.text.trim();
227242
if (!msg || this.isStreaming) return;
228243
this.text = "";
244+
this.history.reset();
229245
if (this.textarea) {
230246
this.textarea.style.height = "auto";
231247
}

web/src/utils/input-history.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { ReactiveController, ReactiveControllerHost } from "lit";
2+
import { appState } from "../state/app-state.js";
3+
4+
export interface HistoryResult {
5+
handled: boolean;
6+
text: string;
7+
}
8+
9+
function isCursorOnFirstLine(ta: HTMLTextAreaElement): boolean {
10+
return !ta.value.substring(0, ta.selectionStart).includes("\n");
11+
}
12+
13+
function isCursorOnLastLine(ta: HTMLTextAreaElement): boolean {
14+
return !ta.value.substring(ta.selectionEnd).includes("\n");
15+
}
16+
17+
/**
18+
* Lit reactive controller that provides shell-like arrow-up / arrow-down
19+
* input history for a textarea. History entries come from the current
20+
* chat's user messages (via appState).
21+
*/
22+
export class InputHistory implements ReactiveController {
23+
private index = -1;
24+
private draft = "";
25+
26+
constructor(private host: ReactiveControllerHost) {
27+
host.addController(this);
28+
}
29+
30+
hostConnected() {}
31+
hostDisconnected() {
32+
this.reset();
33+
}
34+
35+
private entries(): string[] {
36+
return appState.state.messages
37+
.filter((m) => m.role === "user")
38+
.map((m) => m.content)
39+
.reverse();
40+
}
41+
42+
handleKeyDown(
43+
e: KeyboardEvent,
44+
textarea: HTMLTextAreaElement,
45+
currentText: string,
46+
): HistoryResult {
47+
const unchanged: HistoryResult = { handled: false, text: currentText };
48+
49+
if (e.key === "ArrowUp" && isCursorOnFirstLine(textarea)) {
50+
const hist = this.entries();
51+
if (!hist.length) return unchanged;
52+
if (this.index === -1) this.draft = currentText;
53+
this.index = Math.min(this.index + 1, hist.length - 1);
54+
return { handled: true, text: hist[this.index] };
55+
}
56+
57+
if (e.key === "ArrowDown" && isCursorOnLastLine(textarea)) {
58+
if (this.index === -1) return unchanged;
59+
this.index -= 1;
60+
const text = this.index === -1 ? this.draft : this.entries()[this.index];
61+
return { handled: true, text };
62+
}
63+
64+
return unchanged;
65+
}
66+
67+
reset(): void {
68+
this.index = -1;
69+
this.draft = "";
70+
}
71+
}

0 commit comments

Comments
 (0)