Files
Sybil-2/tui/src/index.ts

1461 lines
45 KiB
TypeScript
Raw Normal View History

2026-03-02 20:33:41 -08:00
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,
} from "./types.js";
type SidebarSelection = { kind: "chat" | "search"; id: string };
type DraftSelectionKind = "chat" | "search";
type SidebarItem = SidebarSelection & {
title: string;
updatedAt: string;
createdAt: string;
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<string, unknown>;
startedAt?: string;
completedAt?: string;
durationMs?: number;
error?: string | null;
resultPreview?: string | null;
};
2026-05-04 21:52:39 -07:00
const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
2026-03-02 20:33:41 -08:00
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"],
2026-05-04 21:52:39 -07:00
"hermes-agent": ["hermes-agent"],
2026-03-02 20:33:41 -08:00
};
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";
2026-05-04 21:52:39 -07:00
if (provider === "hermes-agent") return "Hermes Agent";
2026-03-02 20:33:41 -08:00
return "";
}
function getChatTitle(chat: Pick<ChatSummary, "title">, 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<SearchSummary, "title" | "query">) {
if (search.title?.trim()) return search.title.trim();
if (search.query?.trim()) return search.query.trim().slice(0, 64);
return "New search";
}
function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): SidebarItem[] {
const items: SidebarItem[] = [
...chats.map((chat) => ({
kind: "chat" as const,
id: chat.id,
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
})),
...searches.map((search) => ({
kind: "search" as const,
id: search.id,
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
initiatedProvider: null,
initiatedModel: null,
lastUsedProvider: null,
lastUsedModel: null,
})),
];
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
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];
}
2026-05-04 21:52:39 -07:00
function getVisibleProviders(catalog: ModelCatalogResponse["providers"]) {
return PROVIDERS.filter((provider) => provider !== "hermes-agent" || catalog[provider] !== undefined);
}
2026-03-02 20:33:41 -08:00
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 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<Provider, string | null> = {
openai: null,
anthropic: null,
xai: null,
2026-05-04 21:52:39 -07:00
"hermes-agent": null,
2026-03-02 20:33:41 -08:00
};
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<string>();
let renderedSidebarSelectedIndex = -1;
let highlightedSidebarKey: string | null = null;
let renderedSidebarItems: SidebarItem[] = [];
let renderedSidebarLines: string[] = [];
let suppressedSidebarSelectEvents = 0;
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 focusables = [sidebar, transcript, composer] as const;
2026-03-11 01:23:13 -07:00
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();
}
2026-03-02 20:33:41 -08:00
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(chats, searches);
}
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 title = truncate(item.title, 36);
const initiatedLabel =
item.kind === "chat" && item.initiatedModel
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
: "";
return `${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 [d] delete [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 = [];
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);
}
2026-03-11 01:23:13 -07:00
async function loadSelection(selection: SidebarSelection | null, options?: { scrollToBottom?: boolean | undefined }) {
2026-03-02 20:33:41 -08:00
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;
}
2026-03-11 01:23:13 -07:00
if (options?.scrollToBottom) {
forceScrollToBottom = true;
}
2026-03-02 20:33:41 -08:00
updateUI();
}
}
2026-03-11 01:23:13 -07:00
async function refreshCollections(options?: {
preferredSelection?: SidebarSelection;
loadSelection?: boolean;
scrollToBottomOnLoad?: boolean | undefined;
}) {
2026-03-02 20:33:41 -08:00
isLoadingCollections = true;
updateUI();
try {
const [nextChats, nextSearches] = await Promise.all([api.listChats(), api.listSearches()]);
chats = nextChats;
searches = nextSearches;
const nextItems = buildSidebarItems(nextChats, nextSearches);
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) {
2026-03-11 01:23:13 -07:00
await loadSelection(selectedItem, { scrollToBottom: options?.scrollToBottomOnLoad });
2026-03-02 20:33:41 -08:00
}
}
async function refreshModels() {
const data = await api.listModels();
modelCatalog = data.providers;
syncModelForProvider();
updateUI();
}
function focusComposer() {
composer.focus();
composer.readInput();
}
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();
2026-03-11 01:23:13 -07:00
await loadSelection(selectedItem, { scrollToBottom: true });
2026-03-02 20:33:41 -08:00
}
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 ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
if (selectedChat?.id === updated.id) {
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
}
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)];
selectedItem = { kind: "chat", id: chat.id };
pendingChatState = pendingChatState ? { ...pendingChatState, chatId } : pendingChatState;
selectedChat = {
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
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),
],
};
}
2026-03-11 01:23:13 -07:00
queueTranscriptScrollToBottomIfFollowing();
2026-03-02 20:33:41 -08:00
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 };
2026-03-11 01:23:13 -07:00
queueTranscriptScrollToBottomIfFollowing();
2026-03-02 20:33:41 -08:00
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 };
2026-03-11 01:23:13 -07:00
queueTranscriptScrollToBottomIfFollowing();
2026-03-02 20:33:41 -08:00
updateUI();
},
onError: (payload) => {
streamErrorMessage = payload.message;
},
}
);
if (streamErrorMessage) {
throw new Error(streamErrorMessage);
}
2026-03-11 01:23:13 -07:00
const shouldFollowTranscript = isTranscriptNearBottom();
2026-03-02 20:33:41 -08:00
await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false });
const currentSelection = selectedItem;
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
2026-03-11 01:23:13 -07:00
await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript });
2026-03-02 20:33:41 -08:00
}
pendingChatState = null;
2026-03-11 01:23:13 -07:00
forceScrollToBottom = shouldFollowTranscript;
2026-03-02 20:33:41 -08:00
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)];
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,
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,
};
2026-03-11 01:23:13 -07:00
queueTranscriptScrollToBottomIfFollowing();
2026-03-02 20:33:41 -08:00
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,
};
2026-03-11 01:23:13 -07:00
queueTranscriptScrollToBottomIfFollowing();
2026-03-02 20:33:41 -08:00
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;
2026-03-11 01:23:13 -07:00
queueTranscriptScrollToBottomIfFollowing();
2026-03-02 20:33:41 -08:00
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;
}
}
2026-03-11 01:23:13 -07:00
const shouldFollowTranscript = isTranscriptNearBottom();
2026-03-02 20:33:41 -08:00
await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false });
const currentSelection = selectedItem;
if (currentSelection?.kind === "search" && currentSelection.id === searchId) {
2026-03-11 01:23:13 -07:00
await loadSelection(currentSelection, { scrollToBottom: shouldFollowTranscript });
2026-03-02 20:33:41 -08:00
}
}
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) {
2026-03-11 01:23:13 -07:00
await loadSelection(selectedItem, { scrollToBottom: true });
2026-03-02 20:33:41 -08:00
}
} 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;
}
2026-03-11 01:23:13 -07:00
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
2026-03-02 20:33:41 -08:00
}
function cycleProvider() {
2026-05-04 21:52:39 -07:00
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";
2026-03-02 20:33:41 -08:00
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> | 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();
});
2026-03-11 01:23:13 -07:00
transcript.key(["pageup"], () => {
scrollTranscriptByPage(-1);
});
transcript.key(["pagedown"], () => {
scrollTranscriptByPage(1);
});
2026-03-02 20:33:41 -08:00
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 (isTextInputFocused(screen, composer)) return;
screen.destroy();
process.exit(0);
});
screen.key(["tab"], () => {
if (isTextInputFocused(screen, composer)) return;
cycleFocus(1);
});
screen.key(["S-tab", "backtab"], () => {
if (isTextInputFocused(screen, composer)) return;
cycleFocus(-1);
});
composer.key(["tab"], () => {
cycleFocus(1);
});
composer.key(["S-tab", "backtab"], () => {
cycleFocus(-1);
});
composer.key(["escape"], () => {
enterCommandMode();
});
screen.key(["n"], () => {
if (isTextInputFocused(screen, composer)) return;
handleCreateChat();
});
screen.key(["/"], () => {
if (isTextInputFocused(screen, composer)) return;
handleCreateSearch();
});
screen.key(["d"], () => {
if (isTextInputFocused(screen, composer)) return;
void runAction(async () => {
await handleDeleteSelection();
});
});
screen.key(["p"], () => {
if (isTextInputFocused(screen, composer)) return;
if (getIsSearchMode() || isSending) return;
cycleProvider();
});
screen.key(["m"], () => {
if (isTextInputFocused(screen, composer)) return;
if (getIsSearchMode() || isSending) return;
cycleModel();
});
screen.key(["r"], () => {
if (isTextInputFocused(screen, composer)) 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 () => {
2026-03-11 01:23:13 -07:00
await Promise.all([refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true }), refreshModels()]);
2026-03-02 20:33:41 -08:00
});
focusComposer();
updateUI();
}
void main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exit(1);
});