feat(webui): bash-style chat input history navigation
Import the chat input history navigation from neurocis PR #1573 while keeping the change scoped to the chat input files. Adjust Down navigation so it only triggers when the caret is collapsed at end-of-text, preserving normal textarea movement from the start of multiline input.
neurocis committed
May 5, 2026 at 17:17 UTC
46fef7fa59324fa525a94b31f93d582acd7ea776
2 files changed
+163
webui/components/chat/input/chat-bar-input.html
+2
@@ -40,6 +40,8 @@
40
<div id="chat-input-container" style="position: relative;">
41
<textarea id="chat-input" :placeholder="$store.chatInput.inputPlaceholder" rows="1"
42
@keydown.enter="if (!$event.shiftKey && !$event.isComposing && $event.keyCode !== 229) { $event.preventDefault(); $store.chatInput.sendMessage(); }"
43
+ @keydown.up="$store.chatInput.historyPrev($event)"
44
+ @keydown.down="$store.chatInput.historyNext($event)"
45
@input="$store.chatInput.adjustTextareaHeight()"
46
x-model="$store.chatInput.message"></textarea>
47
<button id="expand-button" @click="$store.fullScreenInputModal.openModal()" aria-label="Expand input">
webui/components/chat/input/input-store.js
+161
@@ -8,6 +8,10 @@ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
8
const model = {
9
paused: false,
10
message: "",
11
+ _history: [],
12
+ _historyIndex: null,
13
+ _draft: "",
14
+ _historyCtxid: null,
15
/** Composer + menu (bottom actions moved into dropdown) */
16
chatMoreMenuOpen: false,
17
progressText: "",
@@ -71,6 +75,8 @@ const model = {
75
},
76
77
async sendMessage() {
78
+ // Capture sent prompt to per-chat history (bash-style)
79
+ try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
80
// Delegate to the global function
81
if (globalThis.sendMessage) {
82
await globalThis.sendMessage();
@@ -232,10 +238,165 @@ const model = {
238
}
239
},
240
241
+ _loadHistory() {
242
+ let ctxid = null;
243
+ try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }
244
+ this._historyCtxid = ctxid;
245
+ this._history = [];
246
+ this._historyIndex = null;
247
+ this._draft = "";
248
+ if (!ctxid) return;
249
+ let raw = null;
250
+ try { raw = localStorage.getItem("a0:chat-history:" + ctxid); } catch (_e) { raw = null; }
251
+ if (raw !== null) {
252
+ try {
253
+ const arr = JSON.parse(raw);
254
+ if (Array.isArray(arr)) {
255
+ this._history = arr.filter((s) => typeof s === "string");
256
+ }
257
+ } catch (_e) { /* ignore */ }
258
+ return;
259
+ }
260
+ // No entry yet for this chat: seed from rendered chat DOM (one-time bootstrap)
261
+ try {
262
+ const seeded = this._seedFromChatDom();
263
+ if (seeded.length > 0) {
264
+ this._history = seeded;
265
+ this._saveHistory();
266
+ } else {
267
+ // Persist an empty array so we don't re-seed on every nav; respects user clearing
268
+ this._saveHistory();
269
+ }
270
+ } catch (_e) { /* ignore */ }
271
+ },
272
+
273
+ _seedFromChatDom() {
274
+ const out = [];
275
+ let nodes;
276
+ try {
277
+ nodes = document.querySelectorAll(".user-container .message-user .message-text pre");
278
+ } catch (_e) {
279
+ return out;
280
+ }
281
+ for (const pre of nodes) {
282
+ const text = (pre.textContent || "").trim();
283
+ if (!text) continue;
284
+ if (out.length > 0 && out[out.length - 1] === text) continue; // ignoredups
285
+ out.push(text);
286
+ }
287
+ if (out.length > 50) return out.slice(-50);
288
+ return out;
289
+ },
290
+
291
+ _saveHistory() {
292
+ if (!this._historyCtxid) return;
293
+ try {
294
+ localStorage.setItem(
295
+ "a0:chat-history:" + this._historyCtxid,
296
+ JSON.stringify(this._history)
297
+ );
298
+ } catch (_e) { /* ignore quota / disabled */ }
299
+ },
300
+
301
+ _ensureHistoryLoaded() {
302
+ let ctxid = null;
303
+ try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }
304
+ if (ctxid !== this._historyCtxid) {
305
+ this._loadHistory();
306
+ }
307
+ },
308
+
309
+ _pushHistory(text) {
310
+ if (typeof text !== "string") return;
311
+ const trimmed = text.trim();
312
+ if (!trimmed) return;
313
+ this._ensureHistoryLoaded();
314
+ if (this._history.length > 0 && this._history[this._history.length - 1] === trimmed) {
315
+ this._historyIndex = null;
316
+ this._draft = "";
317
+ return;
318
+ }
319
+ this._history.push(trimmed);
320
+ if (this._history.length > 50) {
321
+ this._history = this._history.slice(-50);
322
+ }
323
+ this._saveHistory();
324
+ this._historyIndex = null;
325
+ this._draft = "";
326
+ },
327
+
328
+ _setCaretStart() {
329
+ queueMicrotask(() => {
330
+ const ta = document.getElementById("chat-input");
331
+ if (ta) {
332
+ try { ta.setSelectionRange(0, 0); } catch (_e) { /* ignore */ }
333
+ try { ta.scrollTop = 0; } catch (_e) { /* ignore */ }
334
+ }
335
+ this.adjustTextareaHeight();
336
+ });
337
+ },
338
+
339
+ _setCaretEnd() {
340
+ queueMicrotask(() => {
341
+ const ta = document.getElementById("chat-input");
342
+ if (ta) {
343
+ const end = ta.value.length;
344
+ try { ta.setSelectionRange(end, end); } catch (_e) { /* ignore */ }
345
+ try { ta.scrollTop = ta.scrollHeight; } catch (_e) { /* ignore */ }
346
+ }
347
+ this.adjustTextareaHeight();
348
+ });
349
+ },
350
+
351
+ historyPrev($event) {
352
+ if ($event && ($event.isComposing || $event.keyCode === 229)) return;
353
+ const ta = ($event && $event.target) ? $event.target : document.getElementById("chat-input");
354
+ if (!ta) return;
355
+ const start = ta.selectionStart;
356
+ const end = ta.selectionEnd;
357
+ if (start !== 0 || end !== 0) return;
358
+ $event.preventDefault();
359
+ this._ensureHistoryLoaded();
360
+ if (this._history.length === 0) return;
361
+ if (this._historyIndex === null) {
362
+ this._draft = this.message || "";
363
+ this._historyIndex = this._history.length - 1;
364
+ } else if (this._historyIndex > 0) {
365
+ this._historyIndex -= 1;
366
+ } else {
367
+ return;
368
+ }
369
+ this.message = this._history[this._historyIndex];
370
+ this._setCaretStart();
371
+ },
372
+
373
+ historyNext($event) {
374
+ if ($event && ($event.isComposing || $event.keyCode === 229)) return;
375
+ const ta = ($event && $event.target) ? $event.target : document.getElementById("chat-input");
376
+ if (!ta) return;
377
+ const value = ta.value;
378
+ const start = ta.selectionStart;
379
+ const end = ta.selectionEnd;
380
+ if (start !== value.length || end !== value.length) return;
381
+ $event.preventDefault();
382
+ if (this._historyIndex === null) return;
383
+ if (this._historyIndex < this._history.length - 1) {
384
+ this._historyIndex += 1;
385
+ this.message = this._history[this._historyIndex];
386
+ } else {
387
+ this._historyIndex = null;
388
+ this.message = this._draft || "";
389
+ this._draft = "";
390
+ }
391
+ this._setCaretEnd();
392
+ },
393
+
394
reset() {
395
this.message = "";
396
attachmentsStore.clearAttachments();
397
this.chatMoreMenuOpen = false;
398
+ this._historyIndex = null;
399
+ this._draft = "";
400
this.adjustTextareaHeight();
401
}
402
};