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 };