import blessed from "blessed"; import { SybilApiClient } from "./api.js"; import { config } from "./config.js"; import type { ChatDetail, ChatSummary, CompletionRequestMessage, Message, ModelCatalogResponse, Provider, SearchDetail, SearchSummary, ToolCallEvent, WorkspaceItem, } from "./types.js"; type SidebarSelection = { kind: "chat" | "search"; id: string }; type DraftSelectionKind = "chat" | "search"; type SidebarItem = SidebarSelection & { title: string; updatedAt: string; createdAt: string; starred: boolean; starredAt: string | null; initiatedProvider: Provider | null; initiatedModel: string | null; lastUsedProvider: Provider | null; lastUsedModel: string | null; }; type ToolLogMetadata = { kind: "tool_call"; toolCallId?: string; toolName?: string; status?: "completed" | "failed"; summary?: string; args?: Record; startedAt?: string; completedAt?: string; durationMs?: number; error?: string | null; resultPreview?: string | null; }; const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"]; const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"]; const PROVIDER_FALLBACK_MODELS: Record = { openai: ["gpt-4.1-mini"], anthropic: ["claude-3-5-sonnet-latest"], xai: ["grok-3-mini"], "hermes-agent": ["hermes-agent"], }; const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { openai: { models: [], loadedAt: null, error: null }, anthropic: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null }, }; function escapeTags(value: string) { return value.replace(/\\/g, "\\\\").replace(/\{/g, "\\{").replace(/\}/g, "\\}"); } function truncate(value: string, maxLength: number) { if (value.length <= maxLength) return value; return `${value.slice(0, Math.max(0, maxLength - 1))}…`; } function formatDate(value: string) { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).format(new Date(value)); } function getProviderLabel(provider: Provider | null | undefined) { if (provider === "openai") return "OpenAI"; if (provider === "anthropic") return "Anthropic"; if (provider === "xai") return "xAI"; if (provider === "hermes-agent") return "Hermes Agent"; return ""; } function getChatTitle(chat: Pick, messages?: ChatDetail["messages"]) { if (chat.title?.trim()) return chat.title.trim(); const firstUserMessage = messages?.find((message) => message.role === "user")?.content.trim(); if (firstUserMessage) return firstUserMessage.slice(0, 48); return "New chat"; } function getSearchTitle(search: Pick) { if (search.title?.trim()) return search.title.trim(); if (search.query?.trim()) return search.query.trim().slice(0, 64); return "New search"; } function chatWorkspaceItem(chat: ChatSummary): WorkspaceItem { return { type: "chat", ...chat }; } function searchWorkspaceItem(search: SearchSummary): WorkspaceItem { return { type: "search", ...search }; } function splitWorkspaceItems(items: WorkspaceItem[]) { const chats: ChatSummary[] = []; const searches: SearchSummary[] = []; for (const item of items) { if (item.type === "chat") { const { type: _type, ...chat } = item; chats.push(chat); } else { const { type: _type, ...search } = item; searches.push(search); } } return { chats, searches }; } function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) { return [item, ...items.filter((existing) => existing.type !== item.type || existing.id !== item.id)]; } function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] { return items.map((item) => { if (item.type === "chat") { const chat = item; return { kind: "chat" as const, id: chat.id, title: getChatTitle(chat), updatedAt: chat.updatedAt, createdAt: chat.createdAt, starred: chat.starred, starredAt: chat.starredAt, initiatedProvider: chat.initiatedProvider, initiatedModel: chat.initiatedModel, lastUsedProvider: chat.lastUsedProvider, lastUsedModel: chat.lastUsedModel, }; } const search = item; return { kind: "search" as const, id: search.id, title: getSearchTitle(search), updatedAt: search.updatedAt, createdAt: search.createdAt, starred: search.starred, starredAt: search.starredAt, initiatedProvider: null, initiatedModel: null, lastUsedProvider: null, lastUsedModel: null, }; }); } function asToolLogMetadata(value: unknown): ToolLogMetadata | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; if (record.kind !== "tool_call") return null; return record as ToolLogMetadata; } function isToolCallLogMessage(message: Message) { return asToolLogMetadata(message.metadata) !== null; } function buildOptimisticToolMessage(event: ToolCallEvent): Message { return { id: `temp-tool-${event.toolCallId}`, createdAt: event.completedAt ?? new Date().toISOString(), role: "tool", content: event.summary, name: event.name, metadata: { kind: "tool_call", toolCallId: event.toolCallId, toolName: event.name, status: event.status, summary: event.summary, args: event.args, startedAt: event.startedAt, completedAt: event.completedAt, durationMs: event.durationMs, error: event.error ?? null, resultPreview: event.resultPreview ?? null, } satisfies ToolLogMetadata, }; } function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) { const providerModels = catalog[provider]?.models ?? []; if (providerModels.length) return providerModels; return PROVIDER_FALLBACK_MODELS[provider]; } function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) { return PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined); } function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) { if (fallback && options.includes(fallback)) return fallback; if (preferred && options.includes(preferred)) return preferred; return options[0] ?? ""; } function selectionKey(selection: SidebarSelection | null) { if (!selection) return null; return `${selection.kind}:${selection.id}`; } function formatHost(url: string) { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return url; } } function isTextInputFocused(screen: blessed.Widgets.Screen, composer: blessed.Widgets.TextboxElement) { return screen.focused === composer; } async function main() { const api = new SybilApiClient(config.apiBaseUrl, config.adminToken); let authMode: "open" | "token" | null = null; let chats: ChatSummary[] = []; let searches: SearchSummary[] = []; let workspaceItems: WorkspaceItem[] = []; let selectedItem: SidebarSelection | null = null; let selectedChat: ChatDetail | null = null; let selectedSearch: SearchDetail | null = null; let draftKind: DraftSelectionKind | null = null; let isLoadingCollections = false; let isLoadingSelection = false; let isSending = false; let pendingChatState: { chatId: string | null; messages: Message[] } | null = null; let provider: Provider = config.defaultProvider; let modelCatalog: ModelCatalogResponse["providers"] = EMPTY_MODEL_CATALOG; const providerModelPreferences: Record = { openai: null, anthropic: null, xai: null, "hermes-agent": null, }; let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null); let errorMessage: string | null = null; let forceScrollToBottom = true; let searchRunController: AbortController | null = null; let searchRunCounter = 0; const pendingTitleGeneration = new Set(); let renderedSidebarSelectedIndex = -1; let highlightedSidebarKey: string | null = null; let renderedSidebarItems: SidebarItem[] = []; let renderedSidebarLines: string[] = []; let suppressedSidebarSelectEvents = 0; let isRenamePromptOpen = false; const screen = blessed.screen({ smartCSR: true, fullUnicode: true, title: "Sybil TUI", }); const sidebar = blessed.list({ parent: screen, label: " Conversations ", border: "line", tags: true, top: 0, left: 0, bottom: 0, width: "32%", keys: true, vi: true, mouse: true, style: { border: { fg: "magenta" }, }, scrollbar: { ch: " ", }, scrollable: true, }); const sidebarStyle = sidebar.style as any; sidebarStyle.item = { fg: "white", bg: (item: unknown) => { const sidebarItem = getRenderedSidebarItemByElement(item); if (!sidebarItem) return undefined; return selectionKey(sidebarItem) === selectionKey(selectedItem) ? "brightblack" : undefined; }, }; sidebarStyle.selected = { fg: "white", bg: (item: unknown) => { const isSidebarFocused = screen.focused === sidebar; if (isSidebarFocused) return "blue"; const sidebarItem = getRenderedSidebarItemByElement(item); if (!sidebarItem) return undefined; return selectionKey(sidebarItem) === selectionKey(selectedItem) ? "brightblack" : undefined; }, bold: () => screen.focused === sidebar, }; const header = blessed.box({ parent: screen, label: " Workspace ", border: "line", tags: true, top: 0, left: "32%", width: "68%", height: 6, style: { border: { fg: "magenta" }, label: { fg: "magenta" }, }, }); const transcript = blessed.box({ parent: screen, label: " Transcript ", border: "line", tags: true, top: 6, left: "32%", width: "68%", bottom: 3, keys: true, vi: true, mouse: true, scrollable: true, alwaysScroll: true, scrollbar: { ch: " ", }, style: { border: { fg: "magenta" }, label: { fg: "magenta" }, }, }); const composer = blessed.textbox({ parent: screen, label: " Message Sybil ", border: "line", tags: true, inputOnFocus: true, keys: true, mouse: true, top: undefined, bottom: 0, left: "32%", width: "68%", height: 3, style: { border: { fg: "magenta" }, label: { fg: "magenta" }, fg: "white", }, }); const renamePrompt = (blessed as any).prompt({ parent: screen, label: " Rename chat ", border: "line", tags: true, keys: true, vi: true, mouse: true, top: "center", left: "center", width: "50%", height: "shrink", hidden: true, style: { border: { fg: "cyan" }, label: { fg: "cyan" }, fg: "white", }, }); const focusables = [sidebar, transcript, composer] as const; function getTranscriptViewportHeight() { const lpos = transcript.lpos; if (lpos) { return Math.max(1, lpos.yl - lpos.yi - Number(transcript.iheight ?? 0)); } return Math.max(1, Number(transcript.height ?? 1) - Number(transcript.iheight ?? 0)); } function isTranscriptNearBottom() { const viewportHeight = getTranscriptViewportHeight(); const maxScroll = Math.max(0, transcript.getScrollHeight() - viewportHeight); if (maxScroll === 0) return true; return maxScroll - transcript.getScroll() <= 1; } function queueTranscriptScrollToBottomIfFollowing() { if (isTranscriptNearBottom()) { forceScrollToBottom = true; } } function scrollTranscriptByPage(direction: 1 | -1) { forceScrollToBottom = false; const pageSize = Math.max(1, getTranscriptViewportHeight() - 1); transcript.scroll(direction * pageSize); screen.render(); } function withSuppressedSidebarSelectEvents(fn: () => void) { suppressedSidebarSelectEvents += 1; try { fn(); } finally { suppressedSidebarSelectEvents -= 1; } } function getRenderedSidebarItemByElement(item: unknown) { const listItems = ((sidebar as any).items as unknown[] | undefined) ?? []; const index = listItems.indexOf(item); if (index < 0) return null; return renderedSidebarItems[index] ?? null; } function getSidebarItems() { return buildSidebarItems(workspaceItems); } function getSelectedChatSummary() { if (!selectedItem || selectedItem.kind !== "chat") return null; const selectedId = selectedItem.id; return chats.find((chat) => chat.id === selectedId) ?? null; } function getSelectedSearchSummary() { if (!selectedItem || selectedItem.kind !== "search") return null; const selectedId = selectedItem.id; return searches.find((search) => search.id === selectedId) ?? null; } function getIsSearchMode() { if (draftKind) return draftKind === "search"; return selectedItem?.kind === "search"; } function getDisplayMessages() { const canonicalMessages = selectedChat?.messages ?? []; if (!pendingChatState) return canonicalMessages; if (pendingChatState.chatId) { if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) { return pendingChatState.messages; } return canonicalMessages; } if (getIsSearchMode()) return canonicalMessages; return pendingChatState.messages; } function syncModelForProvider() { const options = getModelOptions(modelCatalog, provider); model = pickProviderModel(options, providerModelPreferences[provider], model); } function updateProviderModelFromSelectedChat() { if (draftKind || selectedItem?.kind !== "chat") return; const detailSelection = selectedChat?.id === selectedItem.id && selectedChat.lastUsedProvider && selectedChat.lastUsedModel?.trim() ? { provider: selectedChat.lastUsedProvider, model: selectedChat.lastUsedModel.trim(), } : null; const summary = getSelectedChatSummary(); const summarySelection = summary?.lastUsedProvider && summary.lastUsedModel?.trim() ? { provider: summary.lastUsedProvider, model: summary.lastUsedModel.trim(), } : null; const next = detailSelection ?? summarySelection; if (!next) return; provider = next.provider; model = next.model; } function getSelectedTitle() { if (draftKind === "chat") return "New chat"; if (draftKind === "search") return "New search"; if (!selectedItem) return "Sybil"; if (selectedItem.kind === "chat") { if (selectedChat) return getChatTitle(selectedChat, selectedChat.messages); const summary = getSelectedChatSummary(); return summary ? getChatTitle(summary) : "New chat"; } if (selectedSearch) return getSearchTitle(selectedSearch); const summary = getSelectedSearchSummary(); return summary ? getSearchTitle(summary) : "New search"; } function renderSidebar() { const items = getSidebarItems(); const showingEmptyState = items.length === 0 && !isLoadingCollections; renderedSidebarItems = showingEmptyState ? [] : items; const lines = showingEmptyState ? ["No chats/searches yet. Press n or /. "] : items.map((item) => { const kind = item.kind === "chat" ? "C" : "S"; const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " "; const title = truncate(item.title, 36); const initiatedLabel = item.kind === "chat" && item.initiatedModel ? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}` : ""; return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`; }); const linesChanged = lines.length !== renderedSidebarLines.length || lines.some((line, index) => line !== renderedSidebarLines[index]); if (linesChanged) { withSuppressedSidebarSelectEvents(() => { sidebar.setItems(lines); }); renderedSidebarLines = lines.slice(); renderedSidebarSelectedIndex = -1; } if (showingEmptyState) { highlightedSidebarKey = null; if (renderedSidebarSelectedIndex !== 0) { withSuppressedSidebarSelectEvents(() => { sidebar.select(0); }); renderedSidebarSelectedIndex = 0; } sidebar.setLabel(isLoadingCollections ? " Conversations (loading...) " : " Conversations "); return; } const activeKey = selectionKey(selectedItem); const activeIndex = activeKey ? items.findIndex((item) => selectionKey(item) === activeKey) : -1; const highlightedIndex = highlightedSidebarKey ? items.findIndex((item) => selectionKey(item) === highlightedSidebarKey) : -1; const selectedIndex = highlightedIndex >= 0 ? highlightedIndex : activeIndex >= 0 ? activeIndex : items.length > 0 ? 0 : -1; if (selectedIndex >= 0 && renderedSidebarSelectedIndex !== selectedIndex) { withSuppressedSidebarSelectEvents(() => { sidebar.select(selectedIndex); }); renderedSidebarSelectedIndex = selectedIndex; } if (selectedIndex >= 0) { const highlightedItem = items[selectedIndex]; highlightedSidebarKey = highlightedItem ? selectionKey(highlightedItem) : null; } else { highlightedSidebarKey = null; } sidebar.setLabel(isLoadingCollections ? " Conversations (loading...) " : " Conversations "); } function buildChatTranscriptContent() { const isSendingActiveChat = isSending && !getIsSearchMode() && !!pendingChatState && !!pendingChatState.chatId && selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId; const messages = getDisplayMessages(); const parts: string[] = []; if (isLoadingSelection && messages.length === 0) { parts.push("{gray-fg}Loading messages...{/gray-fg}"); } if (!isLoadingSelection && messages.length === 0) { parts.push("{gray-fg}No messages yet. Send a prompt to start.{/gray-fg}"); } for (const message of messages) { const toolMeta = asToolLogMetadata(message.metadata); if (message.role === "tool" && toolMeta) { const prefix = toolMeta.status === "failed" ? "{red-fg}[tool failed]{/red-fg}" : "{cyan-fg}[tool]{/cyan-fg}"; const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed."; parts.push(`${prefix} ${escapeTags(summary)}`); continue; } if (message.role === "user") { parts.push(`{bold}{magenta-fg}You{/magenta-fg}{/bold}\n${escapeTags(message.content)}`); continue; } if (message.role === "assistant") { const body = message.content.trim().length ? escapeTags(message.content) : isSendingActiveChat && message.id.startsWith("temp-assistant-") ? "{gray-fg}Sybil is typing...{/gray-fg}" : ""; parts.push(`{bold}{cyan-fg}Sybil{/cyan-fg}{/bold}\n${body}`); continue; } parts.push(`{bold}${escapeTags(message.role)}{/bold}\n${escapeTags(message.content)}`); } if (isSendingActiveChat && !messages.some((message) => message.id.startsWith("temp-assistant-"))) { parts.push("{bold}{cyan-fg}Sybil{/cyan-fg}{/bold}\n{gray-fg}Sybil is typing...{/gray-fg}"); } return parts.join("\n\n"); } function buildSearchContent() { const parts: string[] = []; const search = selectedSearch; const isSearchRunning = isSending && getIsSearchMode(); if (search?.query?.trim()) { parts.push(`{bold}Results for{/bold} ${escapeTags(search.query.trim())}`); parts.push( `{gray-fg}${search.results.length} result${search.results.length === 1 ? "" : "s"}${search.latencyMs ? ` • ${search.latencyMs}ms` : ""}{/gray-fg}` ); } if (isSearchRunning || search?.answerText || search?.answerError) { parts.push("{bold}{magenta-fg}Answer{/magenta-fg}{/bold}"); if (isSearchRunning && !search?.answerText) { parts.push("{gray-fg}Generating answer...{/gray-fg}"); } else if (search?.answerText) { parts.push(escapeTags(search.answerText)); } if (search?.answerError) { parts.push(`{red-fg}${escapeTags(search.answerError)}{/red-fg}`); } const citations = (search?.answerCitations ?? []).filter((citation) => citation.url || citation.id); if (citations.length > 0) { const rendered = citations.slice(0, 8).map((citation, index) => { const href = citation.url || citation.id || ""; const label = citation.title?.trim() || formatHost(href); return `[${index + 1}] ${escapeTags(label)} - ${escapeTags(href)}`; }); parts.push("{gray-fg}Citations{/gray-fg}\n" + rendered.join("\n")); } } if ((isLoadingSelection || isSearchRunning) && !search?.results.length) { parts.push(`{gray-fg}${isSearchRunning ? "Searching Exa..." : "Loading search..."}{/gray-fg}`); } if (!isLoadingSelection && !isSearchRunning && search?.query && search.results.length === 0) { parts.push("{gray-fg}No results found.{/gray-fg}"); } if (search?.results.length) { parts.push("{bold}Results{/bold}"); for (let index = 0; index < search.results.length; index += 1) { const result = search.results[index]; if (!result) continue; parts.push( `${index + 1}. {cyan-fg}${escapeTags(result.title || result.url)}{/cyan-fg}\n` + `{gray-fg}${escapeTags(formatHost(result.url))}{/gray-fg}\n` + `${escapeTags(result.url)}` ); } } if (search?.error) { parts.push(`{red-fg}${escapeTags(search.error)}{/red-fg}`); } if (parts.length === 0) { parts.push("{gray-fg}Run a search to see results and the answer panel.{/gray-fg}"); } return parts.join("\n\n"); } function renderHeader() { const isSearchMode = getIsSearchMode(); const providerModelOptions = getModelOptions(modelCatalog, provider); const modeLabel = authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""; const sendState = isSending ? "{yellow-fg}Sending...{/yellow-fg}" : "{green-fg}Ready{/green-fg}"; const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`; let controls = "{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [r] rename [d] delete [C-r] refresh [q] quit"; if (!isSearchMode) { controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`; controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : ""; } const status = errorMessage ? `{red-fg}${escapeTags(errorMessage)}{/red-fg}` : sendState; header.setContent(`${top}\n${controls}\n${status}`); } function applyPaneFocusStyles() { const isSidebarFocused = screen.focused === sidebar; const isTranscriptFocused = screen.focused === transcript; const isComposerFocused = screen.focused === composer; const isWorkspaceFocused = isTranscriptFocused || isComposerFocused; (sidebar.style as any).border = { fg: isSidebarFocused ? "cyan" : "magenta" }; (sidebar.style as any).label = { fg: isSidebarFocused ? "cyan" : "magenta" }; (header.style as any).border = { fg: isWorkspaceFocused ? "cyan" : "magenta" }; (header.style as any).label = { fg: isWorkspaceFocused ? "cyan" : "magenta" }; (transcript.style as any).border = { fg: isTranscriptFocused ? "cyan" : "magenta" }; (transcript.style as any).label = { fg: isTranscriptFocused ? "cyan" : "magenta" }; (composer.style as any).border = { fg: isSending ? "yellow" : isComposerFocused ? "cyan" : "magenta" }; (composer.style as any).label = { fg: isComposerFocused ? "cyan" : "magenta" }; } function updateUI() { renderSidebar(); renderHeader(); const content = getIsSearchMode() ? buildSearchContent() : buildChatTranscriptContent(); transcript.setContent(content); if (forceScrollToBottom) { transcript.setScrollPerc(100); forceScrollToBottom = false; } const composerLabel = getIsSearchMode() ? " Search the web " : " Message Sybil "; composer.setLabel(composerLabel); applyPaneFocusStyles(); screen.render(); } function setError(message: string | null) { errorMessage = message; updateUI(); } function resetWorkspaceState() { chats = []; searches = []; workspaceItems = []; selectedItem = null; selectedChat = null; selectedSearch = null; draftKind = null; pendingChatState = null; isLoadingCollections = false; isLoadingSelection = false; isSending = false; forceScrollToBottom = true; renderedSidebarSelectedIndex = -1; highlightedSidebarKey = null; renderedSidebarItems = []; renderedSidebarLines = []; } function hasItem(items: SidebarItem[], selection: SidebarSelection | null) { if (!selection) return false; return items.some((item) => item.kind === selection.kind && item.id === selection.id); } async function loadSelection(selection: SidebarSelection | null, options?: { scrollToBottom?: boolean | undefined }) { if (!selection) { selectedChat = null; selectedSearch = null; updateUI(); return; } const requestedSelectionKey = selectionKey(selection); isLoadingSelection = true; updateUI(); try { if (selection.kind === "chat") { const chat = await api.getChat(selection.id); if (selectionKey(selectedItem) !== requestedSelectionKey) return; selectedChat = chat; selectedSearch = null; } else { const search = await api.getSearch(selection.id); if (selectionKey(selectedItem) !== requestedSelectionKey) return; selectedSearch = search; selectedChat = null; } updateProviderModelFromSelectedChat(); } finally { if (selectionKey(selectedItem) === requestedSelectionKey) { isLoadingSelection = false; } if (options?.scrollToBottom) { forceScrollToBottom = true; } updateUI(); } } async function refreshCollections(options?: { preferredSelection?: SidebarSelection; loadSelection?: boolean; scrollToBottomOnLoad?: boolean | undefined; }) { isLoadingCollections = true; updateUI(); try { const nextWorkspaceItems = await api.listWorkspaceItems(); const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems); workspaceItems = nextWorkspaceItems; chats = nextChats; searches = nextSearches; const nextItems = buildSidebarItems(nextWorkspaceItems); if (options?.preferredSelection && hasItem(nextItems, options.preferredSelection)) { selectedItem = options.preferredSelection; draftKind = null; } else if (selectedItem && !hasItem(nextItems, selectedItem)) { selectedItem = nextItems[0] ? { kind: nextItems[0].kind, id: nextItems[0].id } : null; } else if (!selectedItem && !draftKind) { selectedItem = nextItems[0] ? { kind: nextItems[0].kind, id: nextItems[0].id } : null; } if (!selectedItem && !draftKind) { selectedChat = null; selectedSearch = null; } } finally { isLoadingCollections = false; updateUI(); } if (options?.loadSelection) { await loadSelection(selectedItem, { scrollToBottom: options?.scrollToBottomOnLoad }); } } async function refreshModels() { const data = await api.listModels(); modelCatalog = data.providers; syncModelForProvider(); updateUI(); } function focusComposer() { composer.focus(); composer.readInput(); } function shouldIgnoreGlobalShortcut() { return isRenamePromptOpen || isTextInputFocused(screen, composer); } function promptForChatTitle(currentTitle: string) { isRenamePromptOpen = true; updateUI(); return new Promise((resolve) => { renamePrompt.input("Title:", currentTitle, (err: Error | null, value: string | null) => { isRenamePromptOpen = false; renamePrompt.hide(); screen.render(); if (err || value === null || value === undefined) { resolve(null); return; } resolve(value); }); }); } function cycleFocus(step: 1 | -1) { const focused = screen.focused; const currentIndex = focusables.findIndex((node) => node === focused); const next = focusables[(currentIndex + step + focusables.length) % focusables.length] ?? focusables[0]; next.focus(); if (next === composer) { composer.readInput(); } updateUI(); } function enterCommandMode() { if (!isTextInputFocused(screen, composer)) return; sidebar.focus(); updateUI(); } async function selectSidebarIndex(index: number) { const item = getSidebarItems()[index]; if (!item || item.id === "") return; highlightedSidebarKey = selectionKey(item) ?? null; renderedSidebarSelectedIndex = index; if (selectedItem?.kind === item.kind && selectedItem.id === item.id && !draftKind) { updateUI(); return; } draftKind = null; selectedItem = { kind: item.kind, id: item.id }; pendingChatState = null; errorMessage = null; forceScrollToBottom = true; updateUI(); await loadSelection(selectedItem, { scrollToBottom: true }); } function handleCreateChat() { draftKind = "chat"; selectedItem = null; selectedChat = null; selectedSearch = null; pendingChatState = null; errorMessage = null; forceScrollToBottom = true; updateUI(); focusComposer(); } function handleCreateSearch() { draftKind = "search"; selectedItem = null; selectedChat = null; selectedSearch = null; pendingChatState = null; errorMessage = null; forceScrollToBottom = true; updateUI(); focusComposer(); } async function maybeSuggestTitle(chatId: string, content: string) { const chatSummary = chats.find((chat) => chat.id === chatId); const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim()); if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return; pendingTitleGeneration.add(chatId); try { const updated = await api.suggestChatTitle({ chatId, content }); chats = chats.map((chat) => (chat.id === updated.id ? updated : chat)); workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item)); if (selectedChat?.id === updated.id) { selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt, starred: updated.starred, starredAt: updated.starredAt, initiatedProvider: updated.initiatedProvider, initiatedModel: updated.initiatedModel, lastUsedProvider: updated.lastUsedProvider, lastUsedModel: updated.lastUsedModel, }; } updateUI(); } catch { // ignored intentionally so chat flow is not interrupted } finally { pendingTitleGeneration.delete(chatId); } } async function handleSendChat(content: string) { const optimisticUserMessage: Message = { id: `temp-user-${Date.now()}`, createdAt: new Date().toISOString(), role: "user", content, name: null, metadata: null, }; const optimisticAssistantMessage: Message = { id: `temp-assistant-${Date.now()}`, createdAt: new Date().toISOString(), role: "assistant", content: "", name: null, metadata: null, }; pendingChatState = { chatId: selectedItem?.kind === "chat" ? selectedItem.id : null, messages: (selectedChat?.messages ?? []).concat(optimisticUserMessage, optimisticAssistantMessage), }; forceScrollToBottom = true; updateUI(); let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null; if (!chatId) { const chat = await api.createChat(); chatId = chat.id; draftKind = null; chats = [chat, ...chats.filter((existing) => existing.id !== chat.id)]; workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(chat)); selectedItem = { kind: "chat", id: chat.id }; pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState; selectedChat = { id: chat.id, title: chat.title, createdAt: chat.createdAt, updatedAt: chat.updatedAt, starred: chat.starred, starredAt: chat.starredAt, initiatedProvider: chat.initiatedProvider, initiatedModel: chat.initiatedModel, lastUsedProvider: chat.lastUsedProvider, lastUsedModel: chat.lastUsedModel, messages: [], }; selectedSearch = null; forceScrollToBottom = true; updateUI(); } if (!chatId) { throw new Error("Unable to initialize chat"); } void maybeSuggestTitle(chatId, content); let baseChat = selectedChat; if (!baseChat || baseChat.id !== chatId) { baseChat = await api.getChat(chatId); } const requestMessages: CompletionRequestMessage[] = [ ...baseChat.messages .filter((message) => !isToolCallLogMessage(message)) .map((message) => ({ role: message.role, content: message.content, ...(message.name ? { name: message.name } : {}), })), { role: "user", content, }, ]; const selectedModel = model.trim(); if (!selectedModel) { throw new Error("No model available for selected provider"); } let streamErrorMessage: string | null = null; await api.runCompletionStream( { chatId, provider, model: selectedModel, messages: requestMessages, }, { onMeta: (payload) => { if (payload.chatId !== chatId) return; pendingChatState = pendingChatState ? { ...pendingChatState, chatId: payload.chatId } : pendingChatState; updateUI(); }, onToolCall: (payload) => { if (!pendingChatState) return; const alreadyPresent = pendingChatState.messages.some( (message) => asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` ); if (alreadyPresent) return; const toolMessage = buildOptimisticToolMessage(payload); const assistantIndex = pendingChatState.messages.findIndex( (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-") ); if (assistantIndex < 0) { pendingChatState = { ...pendingChatState, messages: pendingChatState.messages.concat(toolMessage) }; } else { pendingChatState = { ...pendingChatState, messages: [ ...pendingChatState.messages.slice(0, assistantIndex), toolMessage, ...pendingChatState.messages.slice(assistantIndex), ], }; } queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onDelta: (payload) => { if (!payload.text || !pendingChatState) return; let updated = false; const nextMessages = pendingChatState.messages.map((message, index, all) => { const target = index === all.length - 1 && message.id.startsWith("temp-assistant-"); if (!target) return message; updated = true; return { ...message, content: message.content + payload.text }; }); if (!updated) return; pendingChatState = { ...pendingChatState, messages: nextMessages }; queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onDone: (payload) => { if (!pendingChatState) return; let updated = false; const nextMessages = pendingChatState.messages.map((message, index, all) => { const target = index === all.length - 1 && message.id.startsWith("temp-assistant-"); if (!target) return message; updated = true; return { ...message, content: payload.text }; }); if (!updated) return; pendingChatState = { ...pendingChatState, messages: nextMessages }; queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onError: (payload) => { streamErrorMessage = payload.message; }, } ); if (streamErrorMessage) { throw new Error(streamErrorMessage); } const shouldFollowTranscript = isTranscriptNearBottom(); await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false }); const currentSelection = selectedItem; if (currentSelection?.kind === "chat" && currentSelection.id === chatId) { await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript }); } pendingChatState = null; forceScrollToBottom = shouldFollowTranscript; updateUI(); } async function handleSendSearch(query: string) { const runId = ++searchRunCounter; searchRunController?.abort(); const abortController = new AbortController(); searchRunController = abortController; let searchId = draftKind === "search" ? null : selectedItem?.kind === "search" ? selectedItem.id : null; if (!searchId) { const search = await api.createSearch({ query, title: query.slice(0, 80), }); searchId = search.id; draftKind = null; selectedItem = { kind: "search", id: searchId }; searches = [search, ...searches.filter((existing) => existing.id !== search.id)]; workspaceItems = upsertWorkspaceItem(workspaceItems, searchWorkspaceItem(search)); selectedChat = null; forceScrollToBottom = true; updateUI(); } if (!searchId) { throw new Error("Unable to initialize search"); } const nowIso = new Date().toISOString(); if (!selectedSearch || selectedSearch.id !== searchId) { selectedSearch = { id: searchId, title: query.slice(0, 80), query, createdAt: nowIso, updatedAt: nowIso, starred: false, starredAt: null, requestId: null, latencyMs: null, error: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [], }; } else { selectedSearch = { ...selectedSearch, title: query.slice(0, 80), query, error: null, latencyMs: null, answerText: null, answerRequestId: null, answerCitations: null, answerError: null, results: [], }; } forceScrollToBottom = true; updateUI(); try { await api.runSearchStream( searchId, { query, title: query.slice(0, 80), type: "auto", numResults: config.searchNumResults, }, { onSearchResults: (payload) => { if (runId !== searchRunCounter) return; if (!selectedSearch || selectedSearch.id !== searchId) return; selectedSearch = { ...selectedSearch, requestId: payload.requestId ?? selectedSearch.requestId, error: null, results: payload.results, }; queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onSearchError: (payload) => { if (runId !== searchRunCounter) return; if (!selectedSearch || selectedSearch.id !== searchId) return; selectedSearch = { ...selectedSearch, error: payload.error }; updateUI(); }, onAnswer: (payload) => { if (runId !== searchRunCounter) return; if (!selectedSearch || selectedSearch.id !== searchId) return; selectedSearch = { ...selectedSearch, answerText: payload.answerText, answerRequestId: payload.answerRequestId, answerCitations: payload.answerCitations, answerError: null, }; queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onAnswerError: (payload) => { if (runId !== searchRunCounter) return; if (!selectedSearch || selectedSearch.id !== searchId) return; selectedSearch = { ...selectedSearch, answerError: payload.error }; updateUI(); }, onDone: (payload) => { if (runId !== searchRunCounter) return; selectedSearch = payload.search; selectedChat = null; queueTranscriptScrollToBottomIfFollowing(); updateUI(); }, onError: (payload) => { if (runId !== searchRunCounter) return; setError(payload.message); }, }, { signal: abortController.signal } ); } catch (error) { if (abortController.signal.aborted) return; throw error; } finally { if (runId === searchRunCounter) { searchRunController = null; } } const shouldFollowTranscript = isTranscriptNearBottom(); await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false }); const currentSelection = selectedItem; if (currentSelection?.kind === "search" && currentSelection.id === searchId) { await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript }); } } async function handleSend(content: string) { const trimmed = content.trim(); if (!trimmed || isSending) return; const isSearchMode = getIsSearchMode(); setError(null); isSending = true; updateUI(); try { if (isSearchMode) { await handleSendSearch(trimmed); } else { await handleSendChat(trimmed); } } catch (error) { const message = error instanceof Error ? error.message : String(error); setError(message); if (!isSearchMode) { pendingChatState = null; } if (selectedItem) { await loadSelection(selectedItem, { scrollToBottom: true }); } } finally { isSending = false; updateUI(); focusComposer(); } } async function handleDeleteSelection() { if (!selectedItem || isSending) return; const target = selectedItem; setError(null); if (target.kind === "chat") { await api.deleteChat(target.id); } else { await api.deleteSearch(target.id); } if (target.kind === "chat" && selectedChat?.id === target.id) { selectedChat = null; } if (target.kind === "search" && selectedSearch?.id === target.id) { selectedSearch = null; } await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }); } async function handleRenameSelection() { if (!selectedItem || selectedItem.kind !== "chat") return; const chatId = selectedItem.id; const summary = chats.find((chat) => chat.id === chatId); const currentTitle = selectedChat?.id === chatId ? getChatTitle(selectedChat, selectedChat.messages) : summary ? getChatTitle(summary) : "New chat"; const value = await promptForChatTitle(currentTitle); const title = value?.trim(); if (!title) { updateUI(); return; } setError(null); const updated = await api.updateChatTitle(chatId, title); chats = [updated, ...chats.filter((chat) => chat.id !== updated.id)]; workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(updated)); if (selectedChat?.id === updated.id) { selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt, initiatedProvider: updated.initiatedProvider, initiatedModel: updated.initiatedModel, lastUsedProvider: updated.lastUsedProvider, lastUsedModel: updated.lastUsedModel, }; } updateUI(); } async function handleToggleStarSelection() { if (!selectedItem) return; const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id); const nextStarred = !currentItem?.starred; setError(null); if (selectedItem.kind === "chat") { const updated = await api.updateChatStar(selectedItem.id, nextStarred); chats = chats.map((chat) => (chat.id === updated.id ? updated : chat)); if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats]; workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item)); if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) { workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems]; } if (selectedChat?.id === updated.id) { selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt, starred: updated.starred, starredAt: updated.starredAt, initiatedProvider: updated.initiatedProvider, initiatedModel: updated.initiatedModel, lastUsedProvider: updated.lastUsedProvider, lastUsedModel: updated.lastUsedModel, }; } } else { const updated = await api.updateSearchStar(selectedItem.id, nextStarred); searches = searches.map((search) => (search.id === updated.id ? updated : search)); if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches]; workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item)); if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) { workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems]; } if (selectedSearch?.id === updated.id) { selectedSearch = { ...selectedSearch, title: updated.title, query: updated.query, updatedAt: updated.updatedAt, starred: updated.starred, starredAt: updated.starredAt, }; } } updateUI(); } function cycleProvider() { const visibleProviders = getVisibleProviders(modelCatalog); const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS; const currentIndex = Math.max(0, cycleProviders.indexOf(provider)); const nextProvider: Provider = cycleProviders[(currentIndex + 1) % cycleProviders.length] ?? "openai"; provider = nextProvider; syncModelForProvider(); updateUI(); } function cycleModel() { const options = getModelOptions(modelCatalog, provider); if (!options.length) { setError("No models available for the selected provider"); return; } const currentIndex = Math.max(0, options.indexOf(model)); const nextModel = options[(currentIndex + 1) % options.length] ?? options[0]; if (!nextModel) { setError("No models available for the selected provider"); return; } model = nextModel; providerModelPreferences[provider] = nextModel; updateUI(); } async function runAction(action: () => Promise | void) { try { await action(); } catch (error) { const message = error instanceof Error ? error.message : String(error); setError(message); } } sidebar.on("focus", () => { updateUI(); }); transcript.on("focus", () => { updateUI(); }); composer.on("focus", () => { updateUI(); }); transcript.key(["pageup"], () => { scrollTranscriptByPage(-1); }); transcript.key(["pagedown"], () => { scrollTranscriptByPage(1); }); sidebar.on("select item", (_item, index) => { if (suppressedSidebarSelectEvents > 0) return; const highlightedItem = getSidebarItems()[index]; highlightedSidebarKey = highlightedItem ? selectionKey(highlightedItem) : null; renderedSidebarSelectedIndex = index; updateUI(); }); sidebar.on("action", (_item, index) => { if (typeof index !== "number") return; void runAction(async () => { await selectSidebarIndex(index); }); }); composer.on("submit", (value) => { const text = typeof value === "string" ? value : ""; composer.clearValue(); updateUI(); void runAction(async () => { await handleSend(text); }); }); screen.key(["C-c"], () => { screen.destroy(); process.exit(0); }); screen.key(["q"], () => { if (shouldIgnoreGlobalShortcut()) return; screen.destroy(); process.exit(0); }); screen.key(["tab"], () => { if (shouldIgnoreGlobalShortcut()) return; cycleFocus(1); }); screen.key(["S-tab", "backtab"], () => { if (shouldIgnoreGlobalShortcut()) return; cycleFocus(-1); }); composer.key(["tab"], () => { cycleFocus(1); }); composer.key(["S-tab", "backtab"], () => { cycleFocus(-1); }); composer.key(["escape"], () => { enterCommandMode(); }); screen.key(["n"], () => { if (shouldIgnoreGlobalShortcut()) return; handleCreateChat(); }); screen.key(["/"], () => { if (shouldIgnoreGlobalShortcut()) return; handleCreateSearch(); }); screen.key(["d"], () => { if (shouldIgnoreGlobalShortcut()) return; void runAction(async () => { await handleDeleteSelection(); }); }); screen.key(["s"], () => { if (shouldIgnoreGlobalShortcut()) return; void runAction(async () => { await handleToggleStarSelection(); }); }); screen.key(["p"], () => { if (shouldIgnoreGlobalShortcut()) return; if (getIsSearchMode() || isSending) return; cycleProvider(); }); screen.key(["m"], () => { if (shouldIgnoreGlobalShortcut()) return; if (getIsSearchMode() || isSending) return; cycleModel(); }); screen.key(["r"], () => { if (shouldIgnoreGlobalShortcut()) return; void runAction(async () => { await handleRenameSelection(); }); }); screen.key(["C-r"], () => { if (shouldIgnoreGlobalShortcut()) return; void runAction(async () => { await refreshCollections({ loadSelection: true }); await refreshModels(); }); }); process.on("SIGINT", () => { screen.destroy(); process.exit(0); }); process.on("SIGTERM", () => { screen.destroy(); process.exit(0); }); updateUI(); try { const session = await api.verifySession(); authMode = session.mode; } catch (error) { const message = error instanceof Error ? error.message : String(error); resetWorkspaceState(); updateUI(); screen.destroy(); if (message.includes("bearer token")) { const tokenHint = "Set SYBIL_TUI_ADMIN_TOKEN (or SYBIL_ADMIN_TOKEN) and rerun. Example: SYBIL_TUI_ADMIN_TOKEN=... npm run dev"; console.error(`Authentication failed: ${message}\n${tokenHint}`); } else { console.error(`Failed to connect to Sybil API at ${config.apiBaseUrl}: ${message}`); } process.exit(1); } await runAction(async () => { await Promise.all([refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }), refreshModels()]); }); focusComposer(); updateUI(); } void main().catch((error) => { const message = error instanceof Error ? error.message : String(error); console.error(message); process.exit(1); });