1404 lines
43 KiB
TypeScript
1404 lines
43 KiB
TypeScript
|
|
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;
|
||
|
|
};
|
||
|
|
|
||
|
|
const PROVIDERS: Provider[] = ["openai", "anthropic", "xai"];
|
||
|
|
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||
|
|
openai: ["gpt-4.1-mini"],
|
||
|
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||
|
|
xai: ["grok-3-mini"],
|
||
|
|
};
|
||
|
|
|
||
|
|
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";
|
||
|
|
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];
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
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;
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadSelection(selection: SidebarSelection | null) {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
forceScrollToBottom = true;
|
||
|
|
updateUI();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function refreshCollections(options?: { preferredSelection?: SidebarSelection; loadSelection?: boolean }) {
|
||
|
|
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) {
|
||
|
|
await loadSelection(selectedItem);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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();
|
||
|
|
await loadSelection(selectedItem);
|
||
|
|
}
|
||
|
|
|
||
|
|
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),
|
||
|
|
],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
forceScrollToBottom = true;
|
||
|
|
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 };
|
||
|
|
forceScrollToBottom = true;
|
||
|
|
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 };
|
||
|
|
forceScrollToBottom = true;
|
||
|
|
updateUI();
|
||
|
|
},
|
||
|
|
onError: (payload) => {
|
||
|
|
streamErrorMessage = payload.message;
|
||
|
|
},
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
if (streamErrorMessage) {
|
||
|
|
throw new Error(streamErrorMessage);
|
||
|
|
}
|
||
|
|
|
||
|
|
await refreshCollections({ preferredSelection: { kind: "chat", id: chatId }, loadSelection: false });
|
||
|
|
|
||
|
|
const currentSelection = selectedItem;
|
||
|
|
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
|
||
|
|
await loadSelection(currentSelection);
|
||
|
|
}
|
||
|
|
|
||
|
|
pendingChatState = null;
|
||
|
|
forceScrollToBottom = true;
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
forceScrollToBottom = true;
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
forceScrollToBottom = true;
|
||
|
|
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;
|
||
|
|
forceScrollToBottom = true;
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await refreshCollections({ preferredSelection: { kind: "search", id: searchId }, loadSelection: false });
|
||
|
|
|
||
|
|
const currentSelection = selectedItem;
|
||
|
|
if (currentSelection?.kind === "search" && currentSelection.id === searchId) {
|
||
|
|
await loadSelection(currentSelection);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
} 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 });
|
||
|
|
}
|
||
|
|
|
||
|
|
function cycleProvider() {
|
||
|
|
const currentIndex = PROVIDERS.indexOf(provider);
|
||
|
|
const nextProvider: Provider = PROVIDERS[(currentIndex + 1) % PROVIDERS.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> | 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();
|
||
|
|
});
|
||
|
|
|
||
|
|
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 () => {
|
||
|
|
await Promise.all([refreshCollections({ loadSelection: true }), refreshModels()]);
|
||
|
|
});
|
||
|
|
|
||
|
|
focusComposer();
|
||
|
|
updateUI();
|
||
|
|
}
|
||
|
|
|
||
|
|
void main().catch((error) => {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
console.error(message);
|
||
|
|
process.exit(1);
|
||
|
|
});
|