agent-zero/webui/components/chat/input/input-store.js
neurocis 46fef7fa59 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.
2026-05-05 17:17:22 +02:00

406 lines
12 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import * as shortcuts from "/js/shortcuts.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
const model = {
paused: false,
message: "",
_history: [],
_historyIndex: null,
_draft: "",
_historyCtxid: null,
/** Composer + menu (bottom actions moved into dropdown) */
chatMoreMenuOpen: false,
progressText: "",
progressActive: false,
toggleChatMoreMenu() {
this.chatMoreMenuOpen = !this.chatMoreMenuOpen;
},
closeChatMoreMenu() {
this.chatMoreMenuOpen = false;
},
_getSendState() {
const hasInput = this.message.trim() || attachmentsStore?.attachments?.length > 0;
const hasQueue = !!messageQueueStore?.hasQueue;
const running = !!chatsStore.selectedContext?.running;
if (hasQueue && !hasInput) return "all";
if ((running || hasQueue) && hasInput) return "queue";
return "normal";
},
get inputPlaceholder() {
const state = this._getSendState();
if (state === "all") return "Press Enter to send queued messages";
// Show progress as ghost text when agent is working and input is empty
if (this.progressText && !this.message) {
return "|> " + this.progressText;
}
return "Type your message here...";
},
// Computed: send button icon type
get sendButtonIcon() {
const state = this._getSendState();
if (state === "all") return "send_and_archive";
if (state === "queue") return "schedule_send";
return "send";
},
// Computed: send button CSS class
get sendButtonClass() {
const state = this._getSendState();
if (state === "all") return "send-queue send-all";
if (state === "queue") return "send-queue queue";
return "";
},
// Computed: send button title
get sendButtonTitle() {
const state = this._getSendState();
if (state === "all") return "Send all queued messages";
if (state === "queue") return "Add to queue";
return "Send message";
},
init() {
console.log("Input store initialized");
// Event listeners are now handled via Alpine directives in the component
},
async sendMessage() {
// Capture sent prompt to per-chat history (bash-style)
try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
// Delegate to the global function
if (globalThis.sendMessage) {
await globalThis.sendMessage();
}
},
adjustTextareaHeight() {
const chatInput = document.getElementById("chat-input");
if (chatInput) {
if (!this.message) chatInput.value = "";
chatInput.style.height = "auto";
chatInput.style.height = chatInput.scrollHeight + "px";
// pick up any layout shift triggered by the height assignment
chatInput.style.height = Math.max(chatInput.scrollHeight, parseInt(chatInput.style.height)) + "px";
}
},
async pauseAgent(paused) {
const prev = this.paused;
this.paused = paused;
try {
const context = globalThis.getContext?.();
if (!globalThis.sendJsonData)
throw new Error("sendJsonData not available");
await globalThis.sendJsonData("/pause", { paused, context });
} catch (e) {
this.paused = prev;
if (globalThis.toastFetchError) {
globalThis.toastFetchError("Error pausing agent", e);
}
}
},
async nudge() {
try {
const context = globalThis.getContext();
await globalThis.sendJsonData("/nudge", { ctxid: context });
} catch (e) {
if (globalThis.toastFetchError) {
globalThis.toastFetchError("Error nudging agent", e);
}
}
},
async loadKnowledge() {
try {
const resp = await shortcuts.callJsonApi(
"/plugins/_memory/knowledge_path_get",
{ ctxid: shortcuts.getCurrentContextId() }
);
if (!resp.ok) throw new Error("Error getting knowledge path");
const path = resp.path;
// open file browser and wait for it to close
await fileBrowserStore.open(path);
// progress notification
shortcuts.frontendNotification({
type: shortcuts.NotificationType.PROGRESS,
message: "Loading knowledge...",
priority: shortcuts.NotificationPriority.NORMAL,
displayTime: 999,
group: "knowledge_load",
frontendOnly: true,
});
// then reindex knowledge
await globalThis.sendJsonData("/plugins/_memory/knowledge_reindex", {
ctxid: shortcuts.getCurrentContextId(),
});
// finished notification
shortcuts.frontendNotification({
type: shortcuts.NotificationType.SUCCESS,
message: "Knowledge loaded successfully",
priority: shortcuts.NotificationPriority.NORMAL,
displayTime: 2,
group: "knowledge_load",
frontendOnly: true,
});
} catch (e) {
// error notification
shortcuts.frontendNotification({
type: shortcuts.NotificationType.ERROR,
message: "Error loading knowledge",
priority: shortcuts.NotificationPriority.NORMAL,
displayTime: 5,
group: "knowledge_load",
frontendOnly: true,
});
}
},
// previous implementation without projects
async _loadKnowledge() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt,.pdf,.csv,.html,.json,.md";
input.multiple = true;
input.onchange = async () => {
try {
const formData = new FormData();
for (let file of input.files) {
formData.append("files[]", file);
}
formData.append("ctxid", globalThis.getContext());
const response = await globalThis.fetchApi("/import_knowledge", {
method: "POST",
body: formData,
});
if (!response.ok) {
if (globalThis.toast)
globalThis.toast(await response.text(), "error");
} else {
const data = await response.json();
if (globalThis.toast) {
globalThis.toast(
"Knowledge files imported: " + data.filenames.join(", "),
"success"
);
}
}
} catch (e) {
if (globalThis.toastFetchError) {
globalThis.toastFetchError("Error loading knowledge", e);
}
}
};
input.click();
},
async browseFiles(path) {
if (!path) {
const ctxid = shortcuts.getCurrentContextId();
if (ctxid) {
try {
const resp = await shortcuts.callJsonApi("/chat_files_path_get", {
ctxid,
});
if (resp.ok) path = resp.path;
} catch (_e) {
console.error("Error getting chat files path", _e);
}
}
}
await fileBrowserStore.open(path);
},
focus() {
const chatInput = document.getElementById("chat-input");
if (chatInput) {
chatInput.focus();
}
},
_loadHistory() {
let ctxid = null;
try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }
this._historyCtxid = ctxid;
this._history = [];
this._historyIndex = null;
this._draft = "";
if (!ctxid) return;
let raw = null;
try { raw = localStorage.getItem("a0:chat-history:" + ctxid); } catch (_e) { raw = null; }
if (raw !== null) {
try {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
this._history = arr.filter((s) => typeof s === "string");
}
} catch (_e) { /* ignore */ }
return;
}
// No entry yet for this chat: seed from rendered chat DOM (one-time bootstrap)
try {
const seeded = this._seedFromChatDom();
if (seeded.length > 0) {
this._history = seeded;
this._saveHistory();
} else {
// Persist an empty array so we don't re-seed on every nav; respects user clearing
this._saveHistory();
}
} catch (_e) { /* ignore */ }
},
_seedFromChatDom() {
const out = [];
let nodes;
try {
nodes = document.querySelectorAll(".user-container .message-user .message-text pre");
} catch (_e) {
return out;
}
for (const pre of nodes) {
const text = (pre.textContent || "").trim();
if (!text) continue;
if (out.length > 0 && out[out.length - 1] === text) continue; // ignoredups
out.push(text);
}
if (out.length > 50) return out.slice(-50);
return out;
},
_saveHistory() {
if (!this._historyCtxid) return;
try {
localStorage.setItem(
"a0:chat-history:" + this._historyCtxid,
JSON.stringify(this._history)
);
} catch (_e) { /* ignore quota / disabled */ }
},
_ensureHistoryLoaded() {
let ctxid = null;
try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }
if (ctxid !== this._historyCtxid) {
this._loadHistory();
}
},
_pushHistory(text) {
if (typeof text !== "string") return;
const trimmed = text.trim();
if (!trimmed) return;
this._ensureHistoryLoaded();
if (this._history.length > 0 && this._history[this._history.length - 1] === trimmed) {
this._historyIndex = null;
this._draft = "";
return;
}
this._history.push(trimmed);
if (this._history.length > 50) {
this._history = this._history.slice(-50);
}
this._saveHistory();
this._historyIndex = null;
this._draft = "";
},
_setCaretStart() {
queueMicrotask(() => {
const ta = document.getElementById("chat-input");
if (ta) {
try { ta.setSelectionRange(0, 0); } catch (_e) { /* ignore */ }
try { ta.scrollTop = 0; } catch (_e) { /* ignore */ }
}
this.adjustTextareaHeight();
});
},
_setCaretEnd() {
queueMicrotask(() => {
const ta = document.getElementById("chat-input");
if (ta) {
const end = ta.value.length;
try { ta.setSelectionRange(end, end); } catch (_e) { /* ignore */ }
try { ta.scrollTop = ta.scrollHeight; } catch (_e) { /* ignore */ }
}
this.adjustTextareaHeight();
});
},
historyPrev($event) {
if ($event && ($event.isComposing || $event.keyCode === 229)) return;
const ta = ($event && $event.target) ? $event.target : document.getElementById("chat-input");
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start !== 0 || end !== 0) return;
$event.preventDefault();
this._ensureHistoryLoaded();
if (this._history.length === 0) return;
if (this._historyIndex === null) {
this._draft = this.message || "";
this._historyIndex = this._history.length - 1;
} else if (this._historyIndex > 0) {
this._historyIndex -= 1;
} else {
return;
}
this.message = this._history[this._historyIndex];
this._setCaretStart();
},
historyNext($event) {
if ($event && ($event.isComposing || $event.keyCode === 229)) return;
const ta = ($event && $event.target) ? $event.target : document.getElementById("chat-input");
if (!ta) return;
const value = ta.value;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start !== value.length || end !== value.length) return;
$event.preventDefault();
if (this._historyIndex === null) return;
if (this._historyIndex < this._history.length - 1) {
this._historyIndex += 1;
this.message = this._history[this._historyIndex];
} else {
this._historyIndex = null;
this.message = this._draft || "";
this._draft = "";
}
this._setCaretEnd();
},
reset() {
this.message = "";
attachmentsStore.clearAttachments();
this.chatMoreMenuOpen = false;
this._historyIndex = null;
this._draft = "";
this.adjustTextareaHeight();
}
};
const store = createStore("chatInput", model);
export { store };