@arbidocs/tui
Version:
Interactive terminal UI for ARBI — chat-style RAG assistant with streaming responses
2,835 lines • 102 kB
JavaScript
#!/usr/bin/env node
'use strict';
var sdk = require('@arbidocs/sdk');
var commander = require('commander');
var piTui = require('@earendil-works/pi-tui');
var chalk = require('chalk');
require('fake-indexeddb/auto');
var client = require('@arbidocs/client');
var prompts = require('@inquirer/prompts');
var child_process = require('child_process');
var fs = require('fs');
var os = require('os');
var path = require('path');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var chalk__default = /*#__PURE__*/_interopDefault(chalk);
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// src/tui-helpers.ts
var tui_helpers_exports = {};
__export(tui_helpers_exports, {
applyWorkspaceSelection: () => applyWorkspaceSelection,
runInteractiveFlow: () => runInteractiveFlow,
showMessage: () => showMessage,
switchWorkspace: () => switchWorkspace
});
function showMessage(tui, message, level) {
tui.chatLog.addSystem(message, level);
tui.requestRender();
}
async function switchWorkspace(tui, workspaceId) {
if (!tui.authContext) return null;
try {
const ws = await sdk.selectWorkspaceById(
tui.authContext.arbi,
workspaceId,
tui.authContext.loginResult.serverSessionKey,
tui.store.requireCredentials().signingPrivateKeyBase64
);
tui.state.workspaceId = ws.external_id;
tui.state.workspaceName = ws.name;
tui.state.conversationMessageId = null;
tui.store.clearChatSession();
tui.store.updateConfig({ selectedWorkspaceId: ws.external_id });
await tui.refreshWorkspaceContext();
return ws;
} catch (err) {
showMessage(tui, `Failed to switch workspace: ${sdk.getErrorMessage(err)}`, "error");
return null;
}
}
async function applyWorkspaceSelection(tui, workspaceId, workspaceName) {
if (!workspaceId) return;
const wsCtx = await sdk.resolveWorkspace(tui.store, workspaceId);
tui.setWorkspaceContext(wsCtx);
tui.state.workspaceName = workspaceName ?? null;
}
async function runInteractiveFlow(tui, pauseMessage, action, errorPrefix) {
showMessage(tui, pauseMessage);
tui.stopTui();
try {
const result = await action();
tui.restartTui();
return result;
} catch (err) {
tui.restartTui();
showMessage(tui, `${errorPrefix}: ${sdk.getErrorMessage(err)}`, "error");
return null;
}
}
var init_tui_helpers = __esm({
"src/tui-helpers.ts"() {
}
});
var colors = {
/** Primary accent — teal/cyan used for headings, prompts, highlights */
accent: chalk__default.default.cyan,
accentBold: chalk__default.default.bold.cyan,
/** Secondary accent — blue for links and info */
secondary: chalk__default.default.blue,
/** Muted text — dim gray for borders, metadata */
muted: chalk__default.default.gray,
mutedDim: chalk__default.default.dim.gray,
/** Success — green for completed states */
success: chalk__default.default.green,
/** Warning — yellow for pending states, code */
warning: chalk__default.default.yellow,
/** Error — red for error messages */
error: chalk__default.default.red,
errorBold: chalk__default.default.bold.red,
/** Text — default and bold */
text: chalk__default.default.white,
textBold: chalk__default.default.bold.white,
/** User message styling */
userLabel: chalk__default.default.bold.green,
userText: chalk__default.default.white,
/** Assistant message styling */
assistantLabel: chalk__default.default.bold.cyan,
/** System message styling */
systemText: chalk__default.default.dim,
systemInfo: chalk__default.default.dim.cyan,
systemError: chalk__default.default.dim.red,
systemWarning: chalk__default.default.dim.yellow,
/** Agent step styling */
stepPending: chalk__default.default.dim.yellow,
stepComplete: chalk__default.default.dim.green
};
var selectListTheme = {
selectedPrefix: (s) => colors.accent(s),
selectedText: (s) => colors.text(s),
description: (s) => colors.muted(s),
scrollInfo: (s) => colors.muted(s),
noMatch: (s) => colors.muted(s)
};
var editorTheme = {
borderColor: (s) => colors.accent(s),
selectList: selectListTheme
};
var markdownTheme = {
heading: (s) => colors.accentBold(s),
link: (s) => chalk__default.default.underline.blue(s),
linkUrl: (s) => colors.muted(s),
code: (s) => colors.warning(s),
codeBlock: (s) => colors.text(s),
codeBlockBorder: (s) => colors.muted(s),
quote: (s) => chalk__default.default.italic.gray(s),
quoteBorder: (s) => colors.muted(s),
hr: (s) => colors.muted(s),
listBullet: (s) => colors.accent(s),
bold: (s) => chalk__default.default.bold(s),
italic: (s) => chalk__default.default.italic(s),
strikethrough: (s) => chalk__default.default.strikethrough(s),
underline: (s) => chalk__default.default.underline(s)
};
function formatHeader(workspaceName, status) {
const app = colors.accentBold("ARBI");
const ws = workspaceName ? colors.muted(` | ${workspaceName}`) : "";
const st = colors.muted(` | ${status}`);
return `${app}${ws}${st}`;
}
function highlightCode(code, lang) {
const lines = code.split("\n");
return lines.map((line) => {
let styled = line.replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, (m) => chalk__default.default.green(m)).replace(/\b(\d+\.?\d*)\b/g, (m) => chalk__default.default.yellow(m)).replace(/(\/\/.*|#.*)$/g, (m) => chalk__default.default.gray(m));
const keywords = /\b(const|let|var|function|return|if|else|for|while|import|export|from|class|extends|new|async|await|try|catch|throw|type|interface|enum|def|self|None|True|False|print)\b/g;
styled = styled.replace(keywords, (m) => chalk__default.default.cyan(m));
return styled;
});
}
// src/components/assistant-message.ts
var assistantMarkdownTheme = {
...markdownTheme,
highlightCode
};
var AssistantMessage = class extends piTui.Container {
markdown;
label;
constructor() {
super();
this.label = new piTui.Text(colors.assistantLabel("ARBI"), 1, 0);
this.markdown = new piTui.Markdown("", 1, 0, assistantMarkdownTheme);
this.addChild(this.label);
this.addChild(this.markdown);
}
/** Update the response text (called as tokens stream in). */
setText(text) {
this.markdown.setText(text);
}
};
var UserMessage = class extends piTui.Container {
constructor(text) {
super();
const label = new piTui.Text(colors.userLabel("You"), 1, 0);
const content = new piTui.Markdown(text, 1, 0, markdownTheme);
this.addChild(label);
this.addChild(content);
}
};
var SystemMessage = class extends piTui.Text {
constructor(message, level = "info") {
const styleFn = level === "error" ? colors.systemError : level === "warning" ? colors.systemWarning : colors.systemInfo;
super(styleFn(message), 1, 0);
}
};
var RightAlignedText = class {
text;
styleFn;
constructor(text, styleFn) {
this.text = text;
this.styleFn = styleFn;
}
invalidate() {
}
render(width) {
if (!this.text || this.text.trim() === "") return [];
const styled = this.styleFn(this.text);
const textWidth = piTui.visibleWidth(styled);
const padding = Math.max(0, width - textWidth);
return [" ".repeat(padding) + styled];
}
};
var GLYPH_LIFECYCLE = ">";
var GLYPH_TOOL = "\u25B8";
var GLYPH_RESULT = "\u2192";
var AgentStep = class extends piTui.Text {
completed = false;
constructor(label, details) {
super(buildText(label, details), 2, 0);
}
/** Mark this step as completed. Renderer is unchanged for now —
* see the file-header comment for the planned focusable-collapse
* upgrade path. */
complete() {
if (this.completed) return;
this.completed = true;
}
};
function buildText(label, details) {
const hasDetails = !!(details?.tool || details?.args || details?.result);
const glyph = hasDetails ? GLYPH_TOOL : GLYPH_LIFECYCLE;
const header = `${colors.stepPending(glyph)} ${hasDetails ? colors.accent(label) : colors.muted(label)}`;
if (!hasDetails) return header;
const lines = [header];
if (details?.tool && details.tool !== label) {
lines.push(` ${colors.muted("tool:")} ${details.tool}`);
}
if (details?.args) {
lines.push(` ${colors.muted("args:")} ${details.args}`);
}
if (details?.result) {
lines.push(` ${colors.muted(GLYPH_RESULT)} ${details.result}`);
}
return lines.join("\n");
}
// src/components/chat-log.ts
var ChatLog = class extends piTui.Container {
/** Currently streaming assistant message (null when idle). */
activeAssistant = null;
/** Agent steps associated with the current streaming run. */
activeSteps = [];
/** Add a user message to the log. */
addUser(text) {
this.addChild(new UserMessage(text));
this.addChild(new piTui.Spacer(1));
}
/** Add a complete assistant message (non-streaming, e.g. for history restoration). */
addAssistant(text) {
const msg = new AssistantMessage();
msg.setText(text);
this.addChild(msg);
this.addChild(new piTui.Spacer(1));
}
/** Add a system/info/error message. */
addSystem(message, level = "info") {
this.addChild(new SystemMessage(message, level));
this.addChild(new piTui.Spacer(1));
}
/** Add a right-aligned summary line (e.g. stream stats). */
addSummary(text) {
this.addChild(new RightAlignedText(text, colors.systemInfo));
this.addChild(new piTui.Spacer(1));
}
/** Add a received DM message (cyan label with sender email). */
addDm(senderEmail, text) {
const container = new piTui.Container();
const label = new piTui.Text(colors.accent(senderEmail), 1, 0);
const content = new piTui.Text(colors.text(text), 1, 0);
container.addChild(label);
container.addChild(content);
this.addChild(container);
this.addChild(new piTui.Spacer(1));
}
/** Remove all messages from the chat log. */
clearMessages() {
this.activeAssistant = null;
this.activeSteps = [];
while (this.children.length > 0) {
this.removeChild(this.children[this.children.length - 1]);
}
}
/** Start a new assistant streaming response. */
startAssistant() {
this.activeAssistant = new AssistantMessage();
this.activeSteps = [];
this.addChild(this.activeAssistant);
}
/** Update the active assistant message with accumulated text. */
updateAssistant(text) {
this.activeAssistant?.setText(text);
}
/** Add an agent step to the current streaming run. Optional
* ``details`` enriches the rendered block with ``tool`` / ``args``
* / ``result`` lines beneath the header — claude-code-style
* tool-use expansion. Callers that don't have structured detail
* can omit the second arg and get the original flat behaviour. */
addAgentStep(label, details) {
if (!this.activeAssistant) return;
const step = new AgentStep(label, details);
this.activeSteps.push(step);
this.addChild(step);
}
/** Remove all active agent steps from the chat log (e.g. when streaming starts). */
clearActiveSteps() {
for (const step of this.activeSteps) {
this.removeChild(step);
}
this.activeSteps = [];
}
/** Finalize the current assistant response. */
finalizeAssistant() {
this.clearActiveSteps();
this.activeAssistant = null;
this.addChild(new piTui.Spacer(1));
}
};
// src/event-log.ts
var WsEventLog = class {
entries = [];
capacity;
/** ``capacity`` defaults to 200, the same order-of-magnitude as
* a typical long chat session. Old entries roll off when the
* buffer fills — same shape as openclaw's session log. */
constructor(capacity = 200) {
this.capacity = capacity;
}
/** Append a new entry. Drops the oldest if at capacity. */
add(entry) {
this.entries.push({
at: entry.at ?? /* @__PURE__ */ new Date(),
text: entry.text,
level: entry.level,
msgType: entry.msgType
});
while (this.entries.length > this.capacity) this.entries.shift();
}
/** Read the last ``limit`` entries (most recent last). Optionally
* filter by level — useful for ``/events --errors``. */
getAll(options) {
let view = this.entries;
if (options?.level) view = view.filter((e) => e.level === options.level);
if (options?.limit != null) view = view.slice(-options.limit);
return view;
}
/** Number of entries currently retained (after eviction). */
get size() {
return this.entries.length;
}
};
function formatEventLine(entry) {
const h = String(entry.at.getHours()).padStart(2, "0");
const m = String(entry.at.getMinutes()).padStart(2, "0");
const s = String(entry.at.getSeconds()).padStart(2, "0");
const tag = entry.level.padEnd(7);
return `[${h}:${m}:${s}] ${tag} ${entry.text}`;
}
var SourcesIndex = class {
// Keyed by docId so successive turns add to the same entry rather
// than duplicating it.
entries = /* @__PURE__ */ new Map();
/** Fold every citation in ``metadata`` into the index. Idempotent
* on the same payload — adding the same metadata twice doesn't
* inflate counts. */
recordCitations(metadata) {
if (!metadata) return;
this.recordResolved(sdk.resolveCitations(metadata));
}
/** The underlying fold step, separated so tests can drive it with
* hand-rolled ``ResolvedCitation`` fixtures rather than mocking
* the full ``MessageMetadataPayload.tools.*`` graph that
* ``resolveCitations`` traverses. */
recordResolved(resolved) {
for (const c of resolved) {
for (const chunk of c.chunks) {
const docId = chunk.metadata?.doc_ext_id;
if (!docId) continue;
const title = chunk.metadata?.doc_title ?? docId;
const page = chunk.metadata?.page_number;
const existing = this.entries.get(docId);
if (existing) {
if (typeof page === "number" && !existing.pages.includes(page)) {
existing.pages.push(page);
existing.pages.sort((a, b) => a - b);
}
if (!existing.citationNums.includes(c.citationNum)) {
existing.citationNums.push(c.citationNum);
existing.citationNums.sort();
}
if (existing.title === docId && title !== docId) existing.title = title;
} else {
this.entries.set(docId, {
docId,
title,
pages: typeof page === "number" ? [page] : [],
citationNums: [c.citationNum]
});
}
}
}
}
/** All entries in registration order — first-cited first. The
* iterator order of ``Map`` is insertion order, which is what
* we want for ``/sources`` (oldest at top, newest at bottom). */
list() {
return Array.from(this.entries.values());
}
/** Clear the index. Wired to ``/new`` so a fresh conversation
* starts with no inherited sources. */
clear() {
this.entries.clear();
}
/** Number of unique documents tracked. */
get size() {
return this.entries.size;
}
};
var ArbiEditor = class extends piTui.Editor {
/** Callback when Escape is pressed (abort streaming). */
onEscape;
/** Callback when Ctrl+C is pressed (clear or exit). */
onCtrlC;
/** Callback when Ctrl+D is pressed (exit). */
onCtrlD;
/** Callback when Ctrl+W is pressed (workspace selector). */
onCtrlW;
/** Callback when Ctrl+N is pressed (new conversation). */
onCtrlN;
/**
* Callback fired after every keystroke that mutates the buffer.
*
* The TUI uses this to power the **pre-flight skill hint**: when
* the user types ``/<slug>`` we surface the skill's description in
* the chat log so they see what they're about to invoke before
* pressing Enter. The handler is responsible for its own
* deduplication (e.g. only show the hint when the parsed slug
* changes), since this callback fires on *every* mutating key.
*
* Receives the buffer text *after* the base ``handleInput`` has
* applied the change. Read-only — handlers must not mutate the
* editor synchronously or it'll loop.
*/
onBufferChange;
/** Track Ctrl+C presses for double-tap exit. */
lastCtrlCTime = 0;
/** Snapshot of the last text we emitted, so we don't fire on
* non-mutating keys (cursor moves, redraws). */
lastEmittedText = "";
constructor(tui) {
super(tui, editorTheme, { paddingX: 1 });
}
handleInput(data) {
if (piTui.matchesKey(data, piTui.Key.escape)) {
this.onEscape?.();
return;
}
if (piTui.matchesKey(data, piTui.Key.ctrl("c"))) {
const now = Date.now();
if (this.getText().trim()) {
this.setText("");
this.lastCtrlCTime = now;
} else if (now - this.lastCtrlCTime < 1e3) {
this.onCtrlC?.();
} else {
this.lastCtrlCTime = now;
this.onCtrlC?.();
}
return;
}
if (piTui.matchesKey(data, piTui.Key.ctrl("d"))) {
this.onCtrlD?.();
return;
}
if (piTui.matchesKey(data, piTui.Key.ctrl("w"))) {
this.onCtrlW?.();
return;
}
if (piTui.matchesKey(data, piTui.Key.ctrl("n"))) {
this.onCtrlN?.();
return;
}
super.handleInput(data);
if (this.onBufferChange) {
const current = this.getText();
if (current !== this.lastEmittedText) {
this.lastEmittedText = current;
try {
this.onBufferChange(current);
} catch {
}
}
}
}
};
var LEVEL_STYLE = {
info: chalk__default.default.dim.cyan,
success: chalk__default.default.dim.green,
warning: chalk__default.default.dim.yellow,
error: chalk__default.default.dim.red
};
function formatTimestamp() {
const now = /* @__PURE__ */ new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
var ToastContainer = class extends piTui.Container {
activeToasts = [];
renderCallback = null;
/** Set the callback used to trigger a TUI re-render after toast changes. */
setRenderCallback(cb) {
this.renderCallback = cb;
}
/** Show a toast notification that auto-dismisses after `durationMs`. */
show(message, level = "info", durationMs = 5e3) {
const styleFn = LEVEL_STYLE[level];
const formatted = styleFn(`[${formatTimestamp()}] ${message}`);
const text = new piTui.Text(formatted, 1, 0);
const timer = setTimeout(() => {
this.dismiss(text);
}, durationMs);
this.activeToasts.push({ text, timer });
this.addChild(text);
this.renderCallback?.();
}
/** Remove all active toasts immediately. */
clear() {
for (const toast of this.activeToasts) {
clearTimeout(toast.timer);
this.removeChild(toast.text);
}
this.activeToasts = [];
this.renderCallback?.();
}
dismiss(text) {
const idx = this.activeToasts.findIndex((t) => t.text === text);
if (idx !== -1) {
clearTimeout(this.activeToasts[idx].timer);
this.activeToasts.splice(idx, 1);
this.removeChild(text);
this.renderCallback?.();
}
}
};
// src/command-registry.ts
init_tui_helpers();
var registry = /* @__PURE__ */ new Map();
var KNOWN_SERVER_COMMANDS = /* @__PURE__ */ new Set([
"pa",
"setup",
"cold-start",
"create-skill"
]);
function registerCommand(def) {
registry.set(def.name, def);
}
function registerCommands(defs) {
for (const def of defs) {
registerCommand(def);
}
}
function toSlashCommands() {
const cmds = [];
for (const def of registry.values()) {
if (def.hidden) continue;
cmds.push({ name: def.name, description: def.description });
}
return cmds;
}
function toSlashCommandsWithSkills(skills, builtins = []) {
const out = toSlashCommands();
const taken = new Set(out.map((c) => c.name));
for (const b of builtins) {
if (taken.has(b.name)) continue;
taken.add(b.name);
out.push({ name: b.name, description: b.description });
}
for (const s of skills) {
if (taken.has(s.slug)) continue;
out.push({ name: s.slug, description: s.description });
}
return out;
}
function formatHelpText() {
const lines = [];
for (const def of registry.values()) {
if (def.hidden) continue;
const hint = def.argHint ? ` <${def.argHint}>` : "";
lines.push(` /${def.name}${hint} \u2014 ${def.description}`);
}
return [
"Available commands:",
"",
...lines,
"",
"Direct messages:",
" @name \u2014 Switch to DM channel with a contact",
" @arbi \u2014 Switch back to AI chat",
"",
"Keyboard shortcuts:",
" Ctrl+N \u2014 New conversation",
" Ctrl+W \u2014 Switch workspace",
" Ctrl+D \u2014 Exit",
" Escape \u2014 Abort streaming",
"",
"Skills: any workspace SKILL.md with `user-invocable: true` becomes a",
" ``/<slug>`` command \u2014 type it like a regular slash command and the",
" agent handles it. ``/skills`` lists what is available."
].join("\n");
}
async function dispatchCommand(tui, input2) {
const parsed = sdk.parseSlashCommand(input2);
if (!parsed) return false;
const cmdName = parsed.slug;
const args = parsed.args.length > 0 ? parsed.args.split(/\s+/) : [];
const trimmed = input2.trim();
const def = registry.get(cmdName);
if (!def) {
const skill = tui.skillsCache?.find(
(s) => s.slug === cmdName || s.name.toLowerCase() === cmdName
);
if (skill) {
const meta = skill.arg_hint ? `Skill: ${skill.name} ${skill.arg_hint} \u2014 ${skill.description}` : `Skill: ${skill.name} \u2014 ${skill.description}`;
showMessage(tui, meta, "info");
return false;
}
const isBuiltinServerCommand = tui.commandsCache?.some((c) => c.name === cmdName) || KNOWN_SERVER_COMMANDS.has(cmdName);
if (isBuiltinServerCommand) {
return false;
}
showMessage(tui, `Unknown command: /${cmdName}. Type /help for available commands.`, "warning");
return true;
}
if (def.minArgs && args.length < def.minArgs) {
const hint = def.argHint ? ` <${def.argHint}>` : "";
showMessage(tui, `Usage: /${def.name}${hint}`, "warning");
return true;
}
let ctx;
if (def.requires === "none") {
ctx = { requires: "none", args, rawInput: trimmed, tui };
} else if (def.requires === "auth") {
if (!tui.authContext) {
showMessage(tui, "Not authenticated. Use /login first.", "error");
return true;
}
ctx = {
requires: "auth",
args,
rawInput: trimmed,
tui,
arbi: tui.authContext.arbi,
authContext: tui.authContext
};
} else {
if (!tui.authContext) {
showMessage(tui, "Not authenticated. Use /login first.", "error");
return true;
}
if (!tui.wsContext) {
showMessage(tui, "No workspace selected. Use /workspace <id> first.", "warning");
return true;
}
ctx = {
requires: "workspace",
args,
rawInput: trimmed,
tui,
arbi: tui.wsContext.arbi,
authContext: tui.authContext,
wsContext: tui.wsContext,
authHeaders: {
baseUrl: tui.wsContext.config.baseUrl,
accessToken: tui.wsContext.accessToken
}
};
}
try {
const result = await def.run(ctx);
if (typeof result === "string") {
showMessage(tui, result);
} else if (Array.isArray(result)) {
showMessage(tui, result.join("\n"));
}
} catch (err) {
showMessage(tui, `Command /${def.name} failed: ${sdk.getErrorMessage(err)}`, "error");
}
return true;
}
// src/commands/general.ts
var generalCommands = [
{
name: "help",
description: "Show available commands",
requires: "none",
run: () => formatHelpText()
},
{
name: "status",
description: "Show auth/workspace/connection status",
requires: "none",
run: (ctx) => {
const { tui } = ctx;
const { state } = tui;
return [
`Authenticated: ${state.isAuthenticated ? colors.success("yes") : colors.error("no")}`,
`Workspace: ${state.workspaceName ? colors.accent(state.workspaceName) : colors.muted("none")}`,
`Workspace ID: ${state.workspaceId ?? colors.muted("none")}`,
`Conversation: ${state.conversationMessageId ? colors.muted(state.conversationMessageId) : colors.muted("new")}`,
`Status: ${state.activityStatus}`,
`WebSocket: ${tui.wsConnected ? colors.success("connected") : colors.muted("disconnected")}`
];
}
},
{
name: "new",
description: "Start fresh conversation (clear threading)",
requires: "none",
run: (ctx) => {
const { tui } = ctx;
tui.state.conversationMessageId = null;
tui.lastMetadata = null;
tui.sourcesIndex.clear();
tui.store.clearChatSession();
return "Started new conversation.";
}
},
{
name: "exit",
description: "Exit TUI",
requires: "none",
run: (ctx) => {
const { tui } = ctx;
tui.shutdown();
}
},
{
name: "quit",
description: "Exit TUI",
requires: "none",
hidden: true,
run: (ctx) => {
const { tui } = ctx;
tui.shutdown();
}
}
];
async function promptSelect(message, choices) {
return prompts.select({ message, choices });
}
async function promptInput(message, required = true) {
return prompts.input({
message,
validate: required ? (v) => v.trim() ? true : "Required" : void 0
});
}
async function promptPassword(message) {
return prompts.password({
message,
mask: "*",
validate: (v) => v ? true : "Required"
});
}
async function promptConfirm(message, defaultValue = true) {
return prompts.confirm({ message, default: defaultValue });
}
// src/auth.ts
async function interactiveLogin(store) {
const config = store.requireConfig();
const email = await promptInput("Email");
const pw = await promptPassword("Password");
const authContext = await sdk.performPasswordLogin(config, email, pw, store);
console.info(`Logged in as ${email}`);
const result = await selectOrCreateWorkspace(authContext, store);
return { authContext, ...result };
}
async function interactiveRegister(store) {
const config = store.requireConfig();
const email = await promptInput("Email");
const arbi = client.createArbiClient({
baseUrl: config.baseUrl,
deploymentDomain: config.deploymentDomain,
credentials: "omit"
});
await arbi.crypto.initSodium();
const codeMethod = await promptSelect("Verification method", [
{ name: "I have an invitation code", value: "code" },
{ name: "Send me a verification email", value: "email" }
]);
let verificationCode;
if (codeMethod === "code") {
verificationCode = await promptInput("Invitation code");
} else {
console.info("Sending verification email...");
const verifyResponse = await arbi.fetch.POST("/v1/user/verify-email", {
body: { email }
});
if (verifyResponse.error) {
throw new Error(`Failed to send verification email: ${JSON.stringify(verifyResponse.error)}`);
}
console.info("Verification email sent. Check your inbox.");
verificationCode = await promptInput("Verification code");
}
const pw = await promptPassword("Password");
const confirmPw = await promptPassword("Confirm password");
if (pw !== confirmPw) {
throw new Error("Passwords do not match.");
}
const firstName = await promptInput("First name (optional)", false) || "User";
const lastName = await promptInput("Last name (optional)", false) || "";
await arbi.auth.register({
email,
password: pw,
verificationCode,
firstName,
lastName
});
console.info(`
Registered successfully as ${email}`);
console.info("Logging in...");
const authContext = await sdk.performPasswordLogin(config, email, pw, store);
console.info(`Logged in as ${email}`);
const result = await selectOrCreateWorkspace(authContext, store);
return { authContext, ...result };
}
async function selectOrCreateWorkspace(authContext, store) {
const wsList = await sdk.workspaces.listWorkspaces(authContext.arbi);
if (wsList.length === 1) {
store.updateConfig({ selectedWorkspaceId: wsList[0].external_id });
console.info(`Workspace: ${wsList[0].name}`);
return { selectedWorkspaceId: wsList[0].external_id, selectedWorkspaceName: wsList[0].name };
}
if (wsList.length > 1) {
const choices = sdk.formatWorkspaceChoices(wsList);
const selectedWorkspaceId = await promptSelect("Select workspace", choices);
const ws = wsList.find((w) => w.external_id === selectedWorkspaceId);
store.updateConfig({ selectedWorkspaceId });
console.info(`Workspace: ${ws?.name}`);
return { selectedWorkspaceId, selectedWorkspaceName: ws?.name };
}
console.info("No workspaces found.");
const shouldCreate = await promptConfirm("Create a new workspace?");
if (shouldCreate) {
const name = await promptInput("Workspace name");
const userProjects = await sdk.projects.listProjects(authContext.arbi);
const defaultProjectExtId = userProjects[0]?.external_id;
if (!defaultProjectExtId) throw new Error("No projects found for user");
const encryptedKey = await sdk.generateNewWorkspaceKey(
authContext.arbi,
authContext.loginResult.serverSessionKey
);
const ws = await sdk.workspaces.createWorkspace(
authContext.arbi,
name.trim(),
encryptedKey,
defaultProjectExtId
);
store.updateConfig({ selectedWorkspaceId: ws.external_id });
console.info(`Created workspace: ${ws.name}`);
return { selectedWorkspaceId: ws.external_id, selectedWorkspaceName: ws.name };
}
return {};
}
async function ensureAuthenticated(store) {
try {
const authContext = await sdk.resolveAuth(store);
const config = store.requireConfig();
return {
authContext,
selectedWorkspaceId: config.selectedWorkspaceId
};
} catch {
console.info("Not authenticated.\n");
}
while (true) {
const action = await promptSelect("What would you like to do?", [
{ name: "Log in", value: "login" },
{ name: "Register a new account", value: "register" },
{ name: "Exit", value: "exit" }
]);
if (action === "exit") {
process.exit(0);
}
try {
if (action === "register") {
return await interactiveRegister(store);
}
return await interactiveLogin(store);
} catch (err) {
console.error(`
${sdk.getErrorMessage(err)}
`);
}
}
}
// src/commands/auth.ts
init_tui_helpers();
var authCommands = [
{
name: "login",
description: "Log in (re-authenticate)",
requires: "none",
run: async (ctx) => {
const { tui } = ctx;
const result = await runInteractiveFlow(
tui,
"Pausing TUI for login...",
() => interactiveLogin(tui.store),
"Login failed"
);
if (result) {
tui.setAuthContext(result.authContext);
await applyWorkspaceSelection(tui, result.selectedWorkspaceId, result.selectedWorkspaceName);
showMessage(tui, "Logged in successfully.");
}
}
},
{
name: "register",
description: "Register a new account",
requires: "none",
run: async (ctx) => {
const { tui } = ctx;
const result = await runInteractiveFlow(
tui,
"Pausing TUI for registration...",
() => interactiveRegister(tui.store),
"Registration failed"
);
if (result) {
tui.setAuthContext(result.authContext);
await applyWorkspaceSelection(tui, result.selectedWorkspaceId, result.selectedWorkspaceName);
showMessage(tui, "Registered and logged in.");
}
}
},
{
name: "logout",
description: "Log out and clear credentials",
requires: "none",
run: (ctx) => {
const { tui } = ctx;
tui.store.deleteCredentials();
tui.store.updateConfig({ selectedWorkspaceId: void 0 });
tui.store.clearChatSession();
tui.authContext = null;
tui.state.isAuthenticated = false;
tui.state.workspaceId = null;
tui.state.workspaceName = null;
tui.state.conversationMessageId = null;
return "Logged out. Use /login to authenticate again.";
}
}
];
init_tui_helpers();
var workspaceCommands = [
{
name: "workspaces",
description: "List all workspaces",
requires: "auth",
run: async (ctx) => {
const { arbi, tui } = ctx;
const wsList = await sdk.workspaces.listWorkspaces(arbi);
const lines = wsList.map((ws) => {
const current = ws.external_id === tui.state.workspaceId ? colors.accent(" (current)") : "";
return ` ${ws.external_id} ${ws.name}${current}`;
});
return ["Workspaces:", "", ...lines];
}
},
{
name: "workspace",
description: "Switch workspace",
argHint: "id",
requires: "auth",
run: async (ctx) => {
const { args, arbi, tui } = ctx;
if (!args[0]) {
const wsList = await sdk.workspaces.listWorkspaces(arbi);
const lines = wsList.map((ws2) => {
const current = ws2.external_id === tui.state.workspaceId ? colors.accent(" (current)") : "";
return ` ${ws2.external_id} ${ws2.name}${current}`;
});
showMessage(tui, ["Workspaces:", "", ...lines].join("\n"));
return "Use /workspace <id> to switch.";
}
showMessage(tui, `Switching to workspace ${args[0]}...`);
const ws = await switchWorkspace(tui, args[0]);
if (ws) {
return `Switched to workspace: ${colors.accentBold(ws.name)}`;
}
}
},
{
name: "create",
description: "Create a new workspace",
argHint: "name",
minArgs: 1,
requires: "auth",
run: async (ctx) => {
const { args, arbi, authContext, tui } = ctx;
const name = args.join(" ").trim();
showMessage(tui, `Creating workspace "${name}"...`);
const userProjects = await sdk.projects.listProjects(arbi);
const defaultProjectExtId = userProjects[0]?.external_id;
if (!defaultProjectExtId) throw new Error("No projects found");
const encryptedKey = await sdk.generateNewWorkspaceKey(
arbi,
authContext.loginResult.serverSessionKey
);
const ws = await sdk.workspaces.createWorkspace(arbi, name, encryptedKey, defaultProjectExtId);
const selected = await switchWorkspace(tui, ws.external_id);
if (selected) {
return `Created and switched to workspace: ${colors.accentBold(selected.name)}`;
}
}
},
{
name: "ws-delete",
description: "Delete a workspace",
argHint: "id",
minArgs: 1,
requires: "auth",
run: async (ctx) => {
const { args, arbi } = ctx;
await sdk.workspaces.deleteWorkspaces(arbi, [args[0]]);
return `${colors.success("Deleted")} workspace ${args[0]}`;
}
},
{
name: "ws-users",
description: "List users in current workspace",
requires: "workspace",
run: async (ctx) => {
const { arbi } = ctx;
const users = await sdk.workspaces.listWorkspaceUsers(arbi);
if (users.length === 0) return "No users in this workspace.";
const lines = users.map((u) => {
const name = sdk.formatUserName(u.user);
const nameStr = name ? colors.muted(` (${name})`) : "";
const role = u.role ? colors.muted(` [${u.role}]`) : "";
return ` ${u.user.email}${nameStr}${role}`;
});
return [`Workspace users (${users.length}):`, "", ...lines];
}
},
{
name: "ws-add-user",
description: "Add a user to current workspace",
argHint: "email",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
const email = args[0].trim();
await sdk.workspaces.addWorkspaceUsers(arbi, [email]);
return `${colors.success("Added")} ${email} to workspace.`;
}
},
{
name: "ws-remove-user",
description: "Remove a user from current workspace",
argHint: "email",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
const email = args[0].trim();
const users = await sdk.workspaces.listWorkspaceUsers(arbi);
const user = users.find((u) => u.user.email === email);
if (!user) return `User ${email} not found in this workspace.`;
const userId = user.user.external_id;
if (!userId) return `Could not resolve user ID for ${email}.`;
await sdk.workspaces.removeWorkspaceUsers(arbi, [userId]);
return `${colors.success("Removed")} ${email} from workspace.`;
}
}
];
init_tui_helpers();
var documentCommands = [
{
name: "docs",
description: "List documents in current workspace",
requires: "workspace",
run: async (ctx) => {
const { arbi } = ctx;
const docs = await sdk.documents.listDocuments(arbi);
if (docs.length === 0) return "No documents in this workspace.";
const lines = docs.map((d) => ` ${d.external_id} ${d.file_name ?? "(unnamed)"}`);
return [`Documents (${docs.length}):`, "", ...lines];
}
},
{
name: "doc",
description: "Show document details",
argHint: "id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
const docs = await sdk.documents.getDocuments(arbi, [args[0]]);
if (docs.length === 0) return `Document ${args[0]} not found.`;
const doc = docs[0];
const lines = [
`Document: ${colors.accent(doc.file_name ?? "(unnamed)")}`,
"",
` ID: ${doc.external_id}`,
` Status: ${doc.status ?? colors.muted("unknown")}`,
` Size: ${doc.file_size != null ? `${doc.file_size} bytes` : colors.muted("unknown")}`,
` Created: ${doc.created_at ?? colors.muted("unknown")}`
];
return lines;
}
},
{
name: "upload",
description: "Upload a file",
argHint: "path",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, tui, wsContext } = ctx;
const filePath = args.join(" ").trim();
showMessage(tui, `Uploading ${filePath}...`);
const result = await sdk.documentsNode.uploadLocalFile(
{
baseUrl: wsContext.config.baseUrl,
accessToken: wsContext.accessToken
},
filePath
);
const ids = (result.doc_ext_ids ?? []).join(", ");
showMessage(tui, `${colors.success("Uploaded")} ${result.fileName} \u2014 doc ID(s): ${ids}`);
if (result.skipped && result.skipped.length > 0) {
showMessage(
tui,
`Skipped: ${result.skipped.map((s) => `${s.file_name} (${s.reason})`).join(", ")}`,
"warning"
);
}
}
},
{
name: "upload-url",
description: "Upload a document from URL",
argHint: "url",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi, wsContext, tui } = ctx;
const url = args[0].trim();
showMessage(tui, `Uploading from ${url}...`);
const result = await sdk.documents.uploadUrl(arbi, [url], {
workspaceExtId: wsContext.workspaceId
});
const ids = (result.doc_ext_ids ?? []).join(", ");
const lines = [`${colors.success("Uploaded")} from URL \u2014 doc ID(s): ${ids}`];
if (result.skipped && result.skipped.length > 0) {
lines.push(
`Skipped: ${result.skipped.map((s) => `${s.file_name} (${s.reason})`).join(", ")}`
);
}
return lines;
}
},
{
name: "delete",
description: "Delete a document",
argHint: "doc-id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi, tui } = ctx;
const docId = args[0].trim();
showMessage(tui, `Deleting document ${docId}...`);
await sdk.documents.deleteDocuments(arbi, [docId]);
return `${colors.success("Deleted")} document ${docId}`;
}
},
{
name: "parsed",
description: "Show parsed content of a document",
argHint: "doc-id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, authHeaders } = ctx;
const docId = args[0].trim();
const data = await sdk.documents.getParsedContent(authHeaders, docId, "content");
const content = data.content;
if (!content) return `No parsed content available for document ${docId}.`;
const maxLen = 3e3;
const truncated = content.length > maxLen ? content.slice(0, maxLen) + "\n... (truncated)" : content;
return [`Parsed content for ${docId}:`, "", truncated];
}
}
];
var conversationCommands = [
{
name: "conversations",
description: "List conversations",
requires: "workspace",
run: async (ctx) => {
const { arbi, wsContext } = ctx;
const convs = await sdk.conversations.listConversations(arbi, wsContext.workspaceId);
if (convs.length === 0) return "No conversations in this workspace.";
const lines = convs.map((c) => ` ${c.external_id} ${c.title ?? "(untitled)"}`);
return [`Conversations (${convs.length}):`, "", ...lines];
}
},
{
name: "conv-delete",
description: "Delete a conversation",
argHint: "id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
await sdk.conversations.deleteConversation(arbi, args[0]);
return `${colors.success("Deleted")} conversation ${args[0]}`;
}
},
{
name: "conv-title",
description: "Rename a conversation",
argHint: "id title",
minArgs: 2,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
const [id, ...titleParts] = args;
const title = titleParts.join(" ");
await sdk.work.updateConversation(arbi, id, { title });
return `Renamed conversation ${id} to: ${colors.accent(title)}`;
}
},
{
name: "conv-share",
description: "Share a conversation",
argHint: "id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
await sdk.work.updateConversation(arbi, args[0], { shared: true });
return `${colors.success("Shared")} conversation ${args[0]} with the workspace`;
}
},
{
name: "conv-unshare",
description: "Make a shared conversation private again",
argHint: "id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
await sdk.work.updateConversation(arbi, args[0], { shared: false });
return `${colors.success("Private")} again: conversation ${args[0]}`;
}
}
];
var tagCommands = [
{
name: "tags",
description: "List tags in workspace",
requires: "workspace",
run: async (ctx) => {
const { arbi } = ctx;
const data = await sdk.tags.listTags(arbi);
if (data.length === 0) return "No tags in this workspace.";
const lines = data.map((t) => ` ${t.external_id} ${t.name}`);
return [`Tags (${data.length}):`, "", ...lines];
}
},
{
name: "tag-create",
description: "Create a tag",
argHint: "name",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
const name = args.join(" ").trim();
const tag = await sdk.tags.createTag(arbi, { name });
return `${colors.success("Created")} tag "${tag.name}" \u2014 ID: ${tag.external_id}`;
}
},
{
name: "tag-delete",
description: "Delete a tag",
argHint: "id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi } = ctx;
await sdk.tags.deleteTag(arbi, args[0]);
return `${colors.success("Deleted")} tag ${args[0]}`;
}
}
];
var miscCommands = [
{
name: "contacts",
description: "List contacts (type @name to DM)",
requires: "auth",
run: async (ctx) => {
const { arbi } = ctx;
const contactList = await sdk.contacts.listContacts(arbi);
const { registered, pending, cards } = sdk.contacts.groupContactsByStatus(contactList);
if (registered.length === 0 && pending.length === 0 && cards.length === 0) {
return "No contacts. Use the web app to add contacts.";
}
const lines = [];
if (registered.length > 0) {
lines.push(`Contacts (${registered.length}):`, "");
for (const c of registered) {
const name = sdk.formatUserName(c.user);
const nameStr = name ? colors.muted(` (${name})`) : "";
lines.push(` ${c.email}${nameStr}`);
}
}
if (pending.length > 0) {
if (lines.length > 0) lines.push("");
lines.push(`Pending invitations (${pending.length}):`);
for (const c of pending) {
lines.push(` ${c.email} \u2014 ${colors.muted(c.status)}`);
}
}
if (cards.length > 0) {
if (lines.length > 0) lines.push("");
lines.push(`Address book cards (${cards.length}):`);
for (const c of cards) {
const label = c.card?.name || c.card?.emails?.[0]?.email || colors.muted("(locked card)");
lines.push(` ${label}`);
}
}
lines.push("", colors.muted("Type @name to start a DM with a registered contact."));
return lines;
}
},
{
name: "invite",
description: "Invite a contact",
argHint: "email",
minArgs: 1,
requires: "auth",
run: async (ctx) => {
const { args, arbi, tui } = ctx;
const email = args[0].trim();
const { showMessage: showMessage2 } = await Promise.resolve().then(() => (init_tui_helpers(), tui_helpers_exports));
showMessage2(tui, `Inviting ${email}...`);
const result = await sdk.contacts.inviteContacts(arbi, [email]);
const contact = result[0];
if (!contact || contact.status === "failed") {
return `${colors.error("Not invited")} ${email} \u2014 ${contact?.outcome_detail ?? "the invitation could not be sent"}`;
}
if (contact.status === "registered") {
return `${colors.success("Added")} ${email} \u2014 already registered. You can DM them with @${email.split("@")[0]}`;
}
return `${colors.success("Invited")} ${email} \u2014 invitation sent. They'll appear in /contacts once they register.`;
}
},
{
name: "models",
description: "List available AI models",
requires: "auth",
run: async (ctx) => {
const { arbi } = ctx;
const data = await sdk.health.getHealthModels(arbi);
const models = Array.isArray(data) ? data : data.data ?? [];
if (models.length === 0) return "No models available.";
const lines = [`Models (${models.length}):`, ""];
for (const m of models) {
const model = m;
const name = model.model_name ?? model.name ?? "unknown";
const provider = model.provider ?? "";
const apiType = model.api_type ?? "";
const parts = [colors.accent(name)];
if (provider) parts.push(`provider: ${provider}`);
if (apiType) parts.push(`api: ${apiType}`);
lines.push(` ${parts.join(" ")}`);
}
return lines;
}
},
{
name: "health",
description: "Show system health status",
requires: "auth",
run: async (ctx) => {
const { arbi } = ctx;
const data = await sdk.health.getHealth(arbi);
const lines = ["System Health:", ""];
const status = data.status;
if (status) {
const statusColor = status === "healthy" ? colors.success(status) : colors.warning(status);
lines.push(` Status: ${statusColor}`);
}
const services = data.services;
if (services) {
lines.push("", " Services:");
for (const [name, info] of Object.entries(services)) {
const svc = info;
const svcStatus = svc.status;
const statusStr = svcStatus ? svcStatus === "healthy" ? colors.success(svcStatus) : colors.error(svcStatus) : colors.muted("unknown");
lines.push(` ${name}: ${statusStr}`);
}
}
return lines;
}
},
{
name: "settings",
description: "Show or update user settings",
argHint: "get | set <key> <value>",
requires: "auth",
run: async (ctx) => {
const { args, arbi } = ctx;
const sub = args[0]?.toLowerCase();
if (sub === "set") {
const key = args[1];
const rawValue = args.slice(2).join(" ").trim();
if (!key || rawValue.length === 0) {
return `${colors.error("Usage:")} /settings set <key> <value> (e.g. /settings set show_pa_mode true)`;
}
const value = coerceSettingValue(rawValue);
await sdk.settings.updateSettings(arbi, {
[key]: value
});
return `${colors.success("Updated")} ${colors.accent(key)} \u2192 ${String(value)}`;
}
if (sub && sub !== "get") {
return `${colors.error("Unknown subcommand:")} /settings ${sub} \u2014 use /settings, /settings get, or /settings set <key> <value>`;
}
const data = await sdk.settings.getSettings(arbi);
const entries = Object.entries(data);
if (entries.length === 0) return "No settings configured.";
const lines = ["User Settings:", ""];
for (const [key, value] of entries) {
const display = typeof value === "object" ? JSON.stringify(value) : String(value);
lines.push(` ${colors.accent(key)}: ${display}`);
}
return lines;
}
}
];
function coerceSettingValue(raw) {
const lower = raw.toLowerCase();
if (lower === "true" || lower === "yes" || lower === "on") return true;
if (lower === "false" || lower === "no" || lower === "off") return false;
if (lower === "null") return null;
if (/^-?\d+$/.test(raw)) return Number(raw);
if (/^-?\d+\.\d+$/.test(raw)) return Number(raw);
return raw;
}
var MAX_PASSAGE_CHARS = 2e3;
var CitationPanel = class extends piTui.Box {
constructor(citation, passageIndex = 0) {
super(1, 1);
const chunk = citation.chunks[passageIndex];
const docTitle = chunk?.metadata?.doc_title ?? "Unknown document";
const page = chunk?.metadata?.page_number;
const headerParts = [
`${colors.accentBold(`[Citation ${citation.citationNum}]`)} ${colors.textBold(docTitle)}`
];
if (page != null) {
headerParts.push(colors.muted(` Page ${page}`));
}
if (citation.chunks.length > 1) {
headerParts.push(colors.muted(` Passage ${passageIndex + 1}/${citation.chunks.length}`));
}
this.addChild(new piTui.Text(headerParts.join(""), 0, 0));
this.addChild(new piTui.Text("", 0, 0));
this.addChild(new piTui.Text(colors.textBold("Statement:"), 0, 0));
this.addChild(new piTui.Text(` ${citation.citationData.statement}`, 0, 0));
this.addChild(new piTui.Text("", 0, 0));
this.addChild(new piTui.Text(colors.textBold("Passage:"), 0, 0));
if (chunk) {
let content = chunk.content;
if (content.length > MAX_PASSAGE_CHARS) {
content = content.slice(0, MAX_PASSAGE_CHARS) + "\n...(truncated)";
}
this.addChild(new piTui.Markdown(content, 0, 0, markdownTheme));
} else {
this.addChild(new piTui.Text(colors.muted(" (no passage data available)"), 0, 0));
}
this.addChild(new piTui.Text("", 0, 0));
const hints = [];
if (citation.chunks.length > 1) {
hints.push(
`/cite ${citation.citationNum} <1-${citation.chunks.length}> to view other passages`
);
}
hints.push("Press Escape to close");
this.addChild(new piTui.Text(colors.muted(hints.join(" \xB7 ")), 0, 0));
}
};
// src/commands/citation.ts
function handleCite(ctx) {
const { tui, args } = ctx;
if (!tui.lastMetadata) {
return "No citation data available. Ask a question first.";
}
const count = sdk.countCitations(tui.lastMetadata);
if (count === 0) {
return "The last response contained no citations.";
}
const resolved = sdk.resolveCitations(tui.lastMetadata);
if (args.length >= 1) {
const citationNum = args[0];
const citation = resolved.find((r) => r.citationNum === citationNum);
if (!citation) {
return `Citation [${citationNum}] not found. Use /cite to list all citations.`;
}
let passageIndex = 0;
if (args.length >= 2) {
const p = parseInt(args[1], 10);
if (isNaN(p) || p < 1 || p > citation.chunks.length) {
return `Passage must be between 1 and ${citation.chunks.length}.`;
}
passageIndex = p - 1;
}
const panel = new CitationPanel(citation, passageIndex);
tui.showCitationOverlay(panel);
return;
}
const summaries = sdk.summarizeCitations(resolved);
const lines = [`Citations (${summaries.length}):
`];
for (const s of summaries) {
const page = s.pageNumber != null ? `, p${s.pageNumber}` : "";
const chunks = s.chunkCount > 1 ? ` (${s.chunkCount} passages)` : "";
const truncated = s.statement.length > 100 ? s.statement.slice(0, 100).replace(/\n/g, " ").trim() + "..." : s.statement.replace(/\n/g, " ").trim();
lines.push(
` ${colors.accent(`[${s.citationNum}]`)} ${colors.textBold(s.docTitle)}${page}${chunks}`
);
lines.push(` ${colors.muted(truncated)}`);
}
lines.push(colors.muted("\nUse /cite <N> to view full passage."));
return lines;
}
var citationCommands = [
{
name: "cite",
description: "Browse citations from last response",
requires: "none",
argHint: "[N] [passage]",
run: (ctx) => handleCite(ctx)
},
{
name: "refs",
description: "Browse citations from last response",
requires: "none",
hidden: true,
run: (ctx) => handleCite(ctx)
}
];
// src/commands/events.ts
var DEFAULT_LIMIT = 20;
function parseEventsArg(arg) {
if (!arg) return { limit: DEFAULT_LIMIT };
const lower = arg.trim().toLowerCase();
if (lower === "all") return {};
if (lower === "errors" || lower === "error") {
return { limit: DEFAULT_LIMIT, level: "error" };
}
if (lower === "warnings" || lower === "warning") {
return { limit: DEFAULT_LIMIT, level: "warning" };
}
const n = Number.parseInt(lower, 10);
if (Number.isFinite(n) && n > 0) return { limit: n };
return { limit: DEFAULT_LIMIT };
}
var eventCommands = [
{
name: "events",
description: "Show recent WebSocket events (replaces toasts that scroll off)",
argHint: "count|all|errors",
requires: "none",
run: (ctx) => {
const { tui, args } = ctx;
const query = parseEventsArg(args[0]);
const entries = tui.eventLog.getAll(query);
if (entries.length === 0) {
if (query.level) {
return `No ${query.level} events recorded yet.`;
}
return "No WebSocket events recorded yet.";
}
const lines = entries.map((e) => {
const line = formatEventLine(e);
switch (e.level) {
case "error":
return colors.error(line);
case "warning":
return colors.warning(line);
case "success":
return colors.success(line);
default:
return colors.muted(line);
}
});
const header = query.level != null ? `Events (${entries.length}, level=${query.level}):` : `Events (${entries.length} of ${tui.eventLog.size} retained):`;
return [header, "", ...lines];
}
}
];
var MAX_CHARS_PER_PAGE = 2e3;
function splitIntoPages(content) {
if (!content) return [];
const trimmed = content.trim();
if (!trimmed) return [];
if (trimmed.includes("\n---\n")) {
return trimmed.split(/\n---\n/).map((p) => p.trim()).filter((p) => p.length > 0);
}
if (trimmed.includes("\f")) {
return trimmed.split("\f").map((p) => p.trim()).filter((p) => p.length > 0);
}
return [trimmed];
}
function indexCitationsByPage(docId, citations) {
const byPage = /* @__PURE__ */ new Map();
for (const c of citations) {
for (const chunk of c.chunks) {
if (chunk.metadata?.doc_ext_id !== docId) continue;
const page = chunk.metadata?.page_number ?? 1;
const list = byPage.get(page) ?? [];
if (!list.includes(c.citationNum)) list.push(c.citationNum);
byPage.set(page, list);
}
}
return byPage;
}
function renderDocView(opts) {
const { docId, title, fileName, pages, citationsByPage } = opts;
const totalCitations = Array.from(citationsByPage.values()).reduce((n, ids) => n + ids.length, 0);
const lines = [];
lines.push(colors.textBold(title));
if (fileName && fileName !== title) lines.push(colors.muted(fileName));
lines.push(colors.muted(`${docId} \u2014 ${pages.length} page${pages.length === 1 ? "" : "s"}`));
if (totalCitations > 0) {
lines.push(
colors.muted(
`${totalCitations} citation${totalCitations === 1 ? "" : "s"} from the current conversation`
)
);
}
lines.push("");
for (let i = 0; i < pages.length; i++) {
const pageNum = i + 1;
const cites = citationsByPage.get(pageNum);
const citeBadge = cites && cites.length > 0 ? colors.accent(` [${cites.map((n) => `[${n}]`).join(" ")}]`) : "";
lines.push(colors.accentBold(`\u2500\u2500 page ${pageNum} \u2500\u2500`) + citeBadge);
const body = pages[i];
if (body.length > MAX_CHARS_PER_PAGE) {
lines.push(body.slice(0, MAX_CHARS_PER_PAGE));
lines.push(
colors.muted(
`\u2026 (${body.length - MAX_CHARS_PER_PAGE} more chars \u2014 use /parsed ${docId} for the raw stream)`
)
);
} else {
lines.push(body);
}
lines.push("");
}
return lines;
}
var viewCommands = [
{
name: "view",
description: "Read a document in the chat log, with citation hints per page",
argHint: "doc-id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi, authHeaders, tui } = ctx;
const docId = args[0].trim();
const [docs, parsed] = await Promise.all([
sdk.documents.getDocuments(arbi, [docId]),
sdk.documents.getParsedContent(authHeaders, docId, "content")
]);
const doc = docs[0];
if (!doc) return `Document ${docId} not found.`;
const meta = doc.doc_metadata;
const title = meta?.title ?? doc.file_name ?? docId;
const content = parsed.content ?? "";
if (!content) return `No parsed content available for ${docId}.`;
const pages = splitIntoPages(content);
const citationsByPage = tui.lastMetadata ? indexCitationsByPage(docId, sdk.resolveCitations(tui.lastMetadata)) : /* @__PURE__ */ new Map();
return renderDocView({
docId,
title,
fileName: doc.file_name ?? null,
pages,
citationsByPage
});
}
}
];
// src/commands/sources.ts
function renderSourcesList(entries) {
if (entries.length === 0) {
return "No sources cited yet in this conversation. Ask a question first.";
}
const lines = [`Sources in this conversation (${entries.length}):`, ""];
for (const e of entries) {
const cites = e.citationNums.map((n) => `[${n}]`).join(" ");
const pages = e.pages.length > 0 ? ` p.${e.pages.join(", p.")}` : "";
lines.push(` ${colors.accent(cites)} ${colors.textBold(e.title)}${pages}`);
lines.push(` ${colors.muted(e.docId)}`);
}
lines.push("");
lines.push(
colors.muted("Use /view <doc-id> to read a source, or /cite N for a specific passage.")
);
return lines;
}
var sourcesCommands = [
{
name: "sources",
description: "List documents cited in this conversation",
requires: "none",
run: (ctx) => {
const { tui } = ctx;
return renderSourcesList(tui.sourcesIndex.list());
}
}
];
var DEFAULT_LIMIT2 = 10;
function formatHistoryLine(row) {
const title = row.title?.trim() || colors.muted("(untitled)");
const count = typeof row.message_count === "number" ? ` \xB7 ${row.message_count} msg` : "";
const when = row.updated_at ? ` \xB7 ${formatRelativeTime(row.updated_at)}` : "";
return ` ${colors.accent(row.external_id)} ${title}${colors.muted(count + when)}`;
}
function formatRelativeTime(iso, now = /* @__PURE__ */ new Date()) {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return iso;
const seconds = Math.floor((now.getTime() - t.getTime()) / 1e3);
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds < 86400 * 2) return "yesterday";
if (seconds < 86400 * 7) return `${Math.floor(seconds / 86400)}d ago`;
return t.toLocaleDateString(void 0, { month: "short", day: "numeric" });
}
var historyCommands = [
{
name: "history",
description: "Browse past conversations",
argHint: "count|all",
requires: "workspace",
run: async (ctx) => {
const { arbi, args, wsContext } = ctx;
const arg = args[0]?.trim().toLowerCase();
const convs = await sdk.conversations.listConversations(
arbi,
wsContext.workspaceId
);
if (convs.length === 0) {
return "No conversations yet in this workspace. Ask a question to start one.";
}
const slice = arg === "all" ? convs : convs.slice(0, parseLimit(arg) ?? DEFAULT_LIMIT2);
const lines = [
`Conversations (${slice.length}${slice.length < convs.length ? ` of ${convs.length}` : ""}):`,
""
];
for (const row of slice) lines.push(formatHistoryLine(row));
lines.push("");
lines.push(colors.muted("Use /resume <conv-id> to switch to one of these."));
return lines;
}
},
{
name: "resume",
description: "Switch to a past conversation by ID",
argHint: "conv-id",
minArgs: 1,
requires: "workspace",
run: async (ctx) => {
const { args, arbi, tui } = ctx;
const convId = args[0].trim();
const data = await sdk.conversations.getConversationThreads(arbi, convId);
const threadList = data.threads ?? [];
const primary = threadList[0];
if (!primary?.leaf_message_ext_id) {
return `Conversation ${convId} not found or has no messages.`;
}
tui.chatLog.clearMessages();
for (const msg of primary.history ?? []) {
if (msg.role === "user") tui.chatLog.addUser(msg.content);
else if (msg.role === "assistant") tui.chatLog.addAssistant(msg.content);
}
tui.chatLog.addSystem(`--- resumed conversation ${convId} ---`, "info");
tui.state.conversationMessageId = primary.leaf_message_ext_id;
tui.store.updateChatSession({
lastMessageExtId: primary.leaf_message_ext_id,
conversationExtId: convId
});
tui.sourcesIndex.clear();
tui.lastMetadata = null;
tui.requestRender();
return `Resumed conversation ${convId}.`;
}
}
];
function parseLimit(arg) {
if (!arg) return void 0;
const n = Number.parseInt(arg, 10);
return Number.isFinite(n) && n > 0 ? n : void 0;
}
var workflowCommands = [
{
name: "automations",
description: "List workspace automations",
requires: "workspace",
run: async (ctx) => {
const { arbi } = ctx;
const rows = await sdk.workflows.listWorkflows(arbi);
return rows.length ? rows.map(
(row) => `${row.external_id} ${row.name} ${row.active ? "Active" : "Paused"} ${row.action?.type ?? "Chat"} ${row.next_fire_at ?? "Run now / notification"}`
) : "No automations in this workspace.";
}
},
{
name: "automation",
description: "Manage an automation: view | run | runs | pause | resume | delete <id>; create <JSON>; update <id> <JSON>",
argHint: "subcommand id",
minArgs: 2,
requires: "workspace",
run: async (ctx) => {
const { arbi, args, wsContext, rawInput } = ctx;
const [sub, id, ...rest] = args;
switch (sub) {
case "view":
return JSON.stringify(await sdk.workflows.getWorkflow(arbi, id), null, 2);
case "run": {
const result = await sdk.workflows.runWorkflow(arbi, id);
if (result.error) throw new Error(result.error);
return `${result.started ? "Started" : "Submitted"} ${id}${result.execution_id ? ` \u2014 execution ${result.execution_id}` : ""}${result.conversation_ext_id ? ` \u2014 thread ${result.conversation_ext_id}` : ""}`;
}
case "runs": {
const rows = await sdk.workflows.listWorkflowRuns(arbi, id);
return rows.length ? rows.map((row) => JSON.stringify(row)) : "This automation has not run yet.";
}
case "pause":
case "resume":
await sdk.workflows.updateWorkflow(arbi, id, { active: sub === "resume" });
return `${sub === "pause" ? "Paused" : "Resumed"} ${id}`;
case "delete":
await sdk.workflows.deleteWorkflow(arbi, id);
return `Deleted ${id}; existing threads and results are preserved.`;
case "create": {
const body = JSON.parse(
rawInput?.slice(rawInput.indexOf("{")) || [id, ...rest].join(" ")
);
body.workspace_ext_id ??= wsContext.workspaceId;
const result = await sdk.workflows.createWorkflow(arbi, body);
return `Created ${result.name} (${result.external_id})`;
}
case "update":
await sdk.workflows.updateWorkflow(
arbi,
id,
JSON.parse(
rawInput?.slice(rawInput.indexOf("{")) || rest.join(" ")
)
);
return `Updated ${id}`;
default:
return "Use /automation view|run|runs|pause|resume|delete <wfl-id>, create <JSON>, or update <wfl-id> <JSON>.";
}
}
}
];
init_tui_helpers();
var skillCommands = [
{
name: "skills",
description: "List user-invocable skills in the active workspace",
requires: "workspace",
run: async (ctx) => {
const { arbi, tui, wsContext } = ctx;
const list = await sdk.assistant.listSkills(arbi, { workspaceExtId: wsContext.workspaceId });
tui.skillsCache = list;
if (list.length === 0) {
return [
"No skills in this workspace yet.",
colors.muted(
"Skills are documents with wp_type=skill. Upload a SKILL.md with frontmatter (name, description, user-invocable: true) to add one."
)
];
}
const lines = [`Skills (${list.length}):`, ""];
for (const s of list) {
const argHint = s.arg_hint ? colors.muted(` ${s.arg_hint}`) : "";
const typeBadge = s.skill_type && s.skill_type !== "markdown" ? colors.muted(` [${s.skill_type}]`) : "";
lines.push(` ${colors.accent("/" + s.slug)}${argHint}${typeBadge}`);
if (s.description) {
lines.push(` ${colors.muted(s.description)}`);
}
}
lines.push("");
lines.push(
colors.muted("Use /skill view <slug> to read a skill, /skill source <slug> for its doc-id.")
);
return lines;
}
},
{
name: "skill",
description: "Inspect or edit a skill (view | source | edit) <slug>",
argHint: "subcommand slug",
minArgs: 2,
requires: "workspace",
run: async (ctx) => {
const { args, arbi, tui } = ctx;
const [sub, slug, ...rest] = args;
if (sub !== "view" && sub !== "source" && sub !== "edit") {
return `Unknown /skill subcommand "${sub}". Use /skill view <slug>, /skill source <slug>, or /skill edit <slug>.`;
}
const list = tui.skillsCache?.length ? tui.skillsCache : await sdk.assistant.listSkills(arbi, {
includeHidden: true,
workspaceExtId: ctx.wsContext.workspaceId
});
const skill = list.find((s) => s.slug === slug || s.name === slug);
if (!skill) {
return `No skill matching "${slug}" in this workspace. Use /skills to list available skills.`;
}
if (!skill.doc_ext_id) {
if (sub !== "view") {
return `/${skill.slug} is a built-in skill (it ships with ARBI) and cannot be edited. Save a skill of your own with the same name to replace it.`;
}
const lines2 = [colors.textBold(`${skill.name} (${skill.slug})`)];
if (skill.description) lines2.push(colors.muted(skill.description));
if (skill.arg_hint) lines2.push(colors.muted(`args: ${skill.arg_hint}`));
lines2.push(colors.muted(`type: ${skill.skill_type} built-in (ships with ARBI)`));
lines2.push("");
lines2.push(skill.content ?? "");
return lines2;
}
const docExtId = skill.doc_ext_id;
if (sub === "source") {
return [
colors.textBold(`${skill.name} (${skill.slug})`),
colors.muted(`doc-id: ${skill.doc_ext_id}`),
colors.muted(`workspace: ${skill.workspace_ext_id}`),
colors.muted(
"Use `arbi upload --folder skills/<slug>` to replace the SKILL.md, or edit it via the web app."
)
];
}
const { authHeaders } = ctx;
if (sub === "edit") {
return await editSkill(tui, authHeaders, { ...skill, doc_ext_id: docExtId });
}
const dlRes = await sdk.documents.downloadDocument(authHeaders, docExtId);
const body = dlRes.ok ? await dlRes.text() : "";
const lines = [colors.textBold(`${skill.name} (${skill.slug})`)];
if (skill.description) lines.push(colors.muted(skill.description));
if (skill.arg_hint) lines.push(colors.muted(`args: ${skill.arg_hint}`));
lines.push(colors.muted(`type: ${skill.skill_type} doc: ${skill.doc_ext_id}`));
lines.push("");
lines.push(body);
return lines;
}
}
];
async function editSkill(tui, authHeaders, skill) {
const dlRes = await sdk.documents.downloadDocument(authHeaders, skill.doc_ext_id);
if (!dlRes.ok) {
return `Failed to fetch skill body (HTTP ${dlRes.status}). Edit aborted.`;
}
const original = await dlRes.text();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `arbi-skill-${skill.slug}-`));
const path$1 = path.join(dir, "SKILL.md");
fs.writeFileSync(path$1, original, "utf8");
const mtimeBefore = fs.statSync(path$1).mtimeMs;
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
const result = await runInteractiveFlow(
tui,
colors.muted(`Opening ${skill.slug}/SKILL.md in ${editor}\u2026`),
async () => {
const child = child_process.spawnSync(editor, [path$1], { stdio: "inherit" });
if (child.error) throw child.error;
if (child.status !== 0 && child.status !== null) {
throw new Error(`${editor} exited with status ${child.status}`);
}
return fs.readFileSync(path$1, "utf8");
},
`Skill edit (${skill.slug}) failed`
);
const mtimeAfter = (() => {
try {
return fs.statSync(path$1).mtimeMs;
} catch {
return mtimeBefore;
}
})();
const updated = result ?? original;
fs.rmSync(dir, { recursive: true, force: true });
if (result === null) {
return [];
}
if (mtimeAfter === mtimeBefore || updated === original) {
return colors.muted(`No changes to ${skill.slug}/SKILL.md \u2014 skill unchanged.`);
}
await sdk.assistant.uploadSkill(authHeaders, skill.slug, updated, { shared: sdk.isSharedSkill(skill) });
await tui.refreshSkillsCache();
return [
colors.textBold(`Updated ${skill.name} (${skill.slug})`),
colors.muted("Skill cache refreshed \u2014 new description and args take effect immediately.")
];
}
// src/commands/index.ts
function registerAllCommands() {
registerCommands(generalCommands);
registerCommands(authCommands);
registerCommands(workspaceCommands);
registerCommands(documentCommands);
registerCommands(conversationCommands);
registerCommands(tagCommands);
registerCommands(miscCommands);
registerCommands(citationCommands);
registerCommands(eventCommands);
registerCommands(viewCommands);
registerCommands(sourcesCommands);
registerCommands(historyCommands);
registerCommands(skillCommands);
registerCommands(workflowCommands);
}
async function streamResponse(tui, response, docNames) {
tui.chatLog.startAssistant();
tui.state.activityStatus = "streaming";
tui.requestRender();
let accumulated = "";
let elapsedTime = null;
let firstToken = true;
const updateAnswer = (content) => {
if (!content) return;
if (firstToken) {
firstToken = false;
tui.chatLog.clearActiveSteps();
}
accumulated = content;
tui.chatLog.updateAssistant(accumulated);
tui.requestRender();
};
let lastItemId = null;
let lastStepLabel = "";
const callbacks = {
onStreamStart: (data) => {
if (data.assistant_message_ext_id) {
tui.state.conversationMessageId = data.assistant_message_ext_id;
}
},
onToken: (content) => {
updateAnswer(accumulated + content);
},
onTextDone: updateAnswer,
onOutputItemDone: (ev) => {
const item = ev.item;
if (item.type !== "function_call" && item.type !== "reasoning") return;
const label = sdk.formatItemLabel(item, docNames);
if (label) {
if (item.id === lastItemId && label === lastStepLabel) return;
lastItemId = item.id ?? null;
lastStepLabel = label;
tui.chatLog.addAgentStep(label);
tui.requestRender();
}
},
onElapsedTime: (t) => {
elapsedTime = t;
},
onError: (message) => {
tui.chatLog.addSystem(`Stream error: ${message}`, "error");
tui.requestRender();
}
};
try {
const result = await sdk.streamSSE(response, callbacks);
if (result.text && result.text !== accumulated) updateAnswer(result.text);
tui.chatLog.finalizeAssistant();
tui.lastMetadata = result.metadata ?? null;
tui.sourcesIndex.recordCitations(result.metadata);
const summary = sdk.formatStreamSummary(result, elapsedTime);
if (summary) {
const refs = sdk.countCitations(result.metadata ?? null);
const refSuffix = refs > 0 ? ` \xB7 ${refs} ref${refs === 1 ? "" : "s"}` : "";
tui.chatLog.addSummary(`[${summary}${refSuffix}]`);
}
tui.state.activityStatus = "idle";
tui.requestRender();
return result;
} catch (err) {
tui.chatLog.finalizeAssistant();
tui.state.activityStatus = "error";
tui.chatLog.addSystem(`Streaming failed: ${sdk.getErrorMessage(err)}`, "error");
tui.requestRender();
throw err;
}
}
init_tui_helpers();
function chatCrypto(tui) {
const userExtId = tui.authContext?.loginResult.userExtId;
if (!tui.encryptionKeys || !userExtId) return null;
return { encryptionKeypair: tui.encryptionKeys, userExtId };
}
async function ensureChannelChat(tui, channel) {
if (channel.ring && channel.chatExtId) return channel.ring;
const crypto = chatCrypto(tui);
if (!tui.authContext || !crypto) throw new Error("Not authenticated");
const chat = await sdk.chats.openDirectChat(tui.authContext.arbi, channel.user.external_id);
channel.chatExtId = chat.external_id;
channel.ring = await sdk.chats.openChatKeyRing(chat, crypto);
return channel.ring;
}
function deriveEncryptionKeypair(signingPrivateKeyBase64) {
const signingPrivateKey = client.base64ToBytes(signingPrivateKeyBase64);
return client.encryptionKeypairFromSigningKey(signingPrivateKey);
}
async function resolveRecipient(tui, query) {
if (!tui.authContext) {
showMessage(tui, "Not authenticated. Use /login first.", "error");
return null;
}
const queryLower = query.toLowerCase();
try {
const contactList = await sdk.contacts.listContacts(tui.authContext.arbi);
const registered = contactList.filter(
(c) => c.status === "registered" && c.user?.encryption_public_key
);
const matches = registered.filter((c) => {
const emailPrefix = (c.email ?? "").split("@")[0]?.toLowerCase();
const givenName = c.user?.given_name?.toLowerCase();
return emailPrefix === queryLower || givenName === queryLower;
});
if (matches.length === 0) {
showMessage(
tui,
`No registered contact matching "${query}". Use /contacts to see available contacts.`,
"warning"
);
return null;
}
if (matches.length > 1) {
const names = matches.map((c) => ` ${c.email} (${c.user?.given_name ?? ""} ${c.user?.family_name ?? ""})`).join("\n");
showMessage(tui, `Ambiguous match for "${query}". Be more specific:
${names}`, "warning");
return null;
}
const contact = matches[0];
const user = contact.user;
return {
external_id: user.external_id,
email: user.email,
encryption_public_key: user.encryption_public_key,
given_name: user.given_name,
family_name: user.family_name
};
} catch (err) {
showMessage(tui, `Failed to resolve contact: ${sdk.getErrorMessage(err)}`, "error");
return null;
}
}
async function handleChannelSwitch(tui, input2) {
const query = input2.slice(1).trim();
if (!query) {
showMessage(tui, "Usage: @username to switch to DM, @arbi to switch back.", "warning");
return;
}
if (query.toLowerCase() === "arbi") {
switchToArbi(tui);
return;
}
const user = await resolveRecipient(tui, query);
if (!user) return;
await switchToDmChannel(tui, user);
}
async function switchToDmChannel(tui, user) {
if (!tui.authContext || !tui.encryptionKeys) {
showMessage(tui, "Not authenticated or encryption not initialized.", "error");
return;
}
tui.currentDmChannel = { user };
tui.updateHeader();
tui.chatLog.clearMessages();
showMessage(tui, `Switched to DM with ${colors.accent(user.email)}. Type @arbi to switch back.`);
await loadDmHistory(tui, user);
}
function switchToArbi(tui) {
if (!tui.currentDmChannel) {
showMessage(tui, "Already in AI chat mode.", "info");
return;
}
tui.currentDmChannel = null;
tui.updateHeader();
tui.chatLog.clearMessages();
showMessage(tui, "Switched back to AI chat.");
}
async function loadDmHistory(tui, user) {
const channel = tui.currentDmChannel;
if (!tui.authContext || !channel) return;
const myExtId = tui.authContext.loginResult.userExtId;
try {
const ring = await ensureChannelChat(tui, channel);
const raw = await sdk.chats.listChatMessages(tui.authContext.arbi, ring.chatExtId, { limit: 100 });
const conversation = (await sdk.chats.decryptChatMessages(ring, raw)).reverse();
if (conversation.length > 0) {
sdk.chats.markChatRead(tui.authContext.arbi, ring.chatExtId).catch(() => {
});
}
for (const msg of conversation) {
if (!msg.content) continue;
displayDmMessage(tui, msg.content, msg.sender_ext_id === myExtId, user.email);
}
tui.requestRender();
} catch (err) {
showMessage(tui, `Failed to load DM history: ${sdk.getErrorMessage(err)}`, "error");
}
}
async function sendEncryptedDm(tui, plaintext) {
const channel = tui.currentDmChannel;
if (!tui.authContext || !tui.encryptionKeys || !channel) {
showMessage(tui, "Cannot send DM \u2014 not in a DM channel.", "error");
return;
}
try {
const ring = await ensureChannelChat(tui, channel);
await sdk.chats.sendChatMessage(tui.authContext.arbi, ring, plaintext);
displayDmMessage(tui, plaintext, true, channel.user.email);
tui.requestRender();
} catch (err) {
showMessage(tui, `Failed to send DM: ${sdk.getErrorMessage(err)}`, "error");
}
}
async function handleIncomingDm(tui, event) {
const channel = tui.currentDmChannel;
if (!channel || !tui.encryptionKeys || !tui.authContext) return false;
const msg = event.message;
if (msg.sender_ext_id !== channel.user.external_id) return false;
let ring;
try {
ring = await ensureChannelChat(tui, channel);
} catch {
return false;
}
if (event.chat_ext_id !== ring.chatExtId) return false;
const decrypted = await sdk.chats.decryptChatMessage(ring, msg);
const text = decrypted.decryptFailed ? "[decryption failed]" : decrypted.content;
if (text) {
displayDmMessage(tui, text, false, channel.user.email);
tui.requestRender();
sdk.chats.markChatRead(tui.authContext.arbi, ring.chatExtId, msg.external_id).catch(() => {
});
}
return true;
}
function displayDmMessage(tui, text, isSent, senderEmail) {
if (isSent) {
tui.chatLog.addUser(text);
} else {
tui.chatLog.addDm(senderEmail, text);
}
}
// src/ws-handler.ts
var DURATION_SHORT = 3e3;
var DURATION_DEFAULT = 5e3;
var DURATION_LONG = 8e3;
var DURATION_BY_LEVEL = {
info: DURATION_SHORT,
success: DURATION_DEFAULT,
error: DURATION_LONG,
warning: DURATION_DEFAULT
};
function isChatMessage(msg) {
return msg.type === "chat.message";
}
function shouldToast(level, msgType) {
if (level === "error" || level === "warning" || level === "success") return true;
if (msgType === "response_complete" || msgType === "batch_complete") return true;
return false;
}
async function handleMessage(msg, toasts, eventLog, tui) {
if (isChatMessage(msg) && tui) {
const handled = await handleIncomingDm(tui, msg);
if (handled) return;
}
const { text, level } = sdk.formatWsMessage(msg);
const msgType = msg.type;
eventLog.add({ text, level, msgType });
if (shouldToast(level, msgType)) {
const duration = DURATION_BY_LEVEL[level] ?? DURATION_DEFAULT;
toasts.show(text, level, duration);
}
}
async function connectTuiWebSocket(options) {
const { baseUrl, accessToken, toasts, eventLog, tui } = options;
const recordTransport = (text, level) => {
eventLog.add({ text, level });
const duration = DURATION_BY_LEVEL[level] ?? DURATION_DEFAULT;
toasts.show(text, level, duration);
};
try {
const connection = await sdk.connectWithReconnect({
baseUrl,
accessToken,
onMessage: (msg) => handleMessage(msg, toasts, eventLog, tui ?? null),
onClose: () => recordTransport("WebSocket disconnected", "warning"),
onReconnecting: (attempt, maxRetries) => recordTransport(`Reconnecting... (${attempt}/${maxRetries})`, "warning"),
onReconnected: () => recordTransport("WebSocket reconnected", "success"),
onReconnectFailed: () => recordTransport("WebSocket reconnection failed", "error")
});
return connection;
} catch {
recordTransport("WebSocket connection failed", "warning");
return null;
}
}
// src/tui.ts
init_tui_helpers();
var ArbiTui = class {
tui;
header;
/** The chat message log — public so command handlers can add messages. */
chatLog;
/** Toast notifications from WebSocket events. */
toastContainer;
/** The input editor. */
editor;
/** Application state — public so handlers can read/write it. */
state;
/** Auth context from @arbidocs/sdk — set during init. */
authContext = null;
/** Workspace context with tokens/headers — set when workspace is selected. */
workspaceContext = null;
/** Config store for persistence. */
store;
/** Active WebSocket connection (null when not connected). */
wsConnection = null;
/** X25519 encryption keypair derived from signing key (null until login). */
encryptionKeyPair = null;
/** Current DM channel (null = AI chat mode). */
dmChannel = null;
/** Last response metadata — used by /cite for citation browsing. */
lastMetadata = null;
/** Persistent log of WebSocket events for this session. Toasts are
* ephemeral; this is the "what happened?" scrollback that
* ``/events`` reads from. Public so ws-handler can append and the
* events command can read. */
eventLog = new WsEventLog();
/** Running tally of documents cited in the current conversation.
* Cleared on ``/new``; appended to after every assistant response
* that carries citation metadata. Drives ``/sources`` and (later)
* the persistent right-side sources panel. */
sourcesIndex = new SourcesIndex();
/** Cached list of user-invocable skills in the active workspace.
* Refreshed on workspace switch (``setWorkspaceContext``) and
* invalidated to ``[]`` when leaving a workspace. Drives the
* autocomplete provider and the ``/skills`` / ``/skill view``
* commands. ``null`` means "haven't fetched yet"; ``[]`` means
* "fetched, no skills exist". */
skillsCache = [];
/** Cached built-in slash commands (``GET /v1/assistant/commands``) available
* to the caller — the shipped ``/image`` / ``/compact`` / ``/goal`` … set,
* gated per-user. Refreshed alongside ``skillsCache`` on workspace switch.
* Drives autocomplete and the "known server command" passthrough so the TUI
* never hardcodes (and drifts from) the backend's command vocabulary. */
commandsCache = [];
/** Active citation overlay handle (null when not showing). */
citationOverlay = null;
/** Input listener ID for overlay dismiss (null when no overlay). */
overlayInputListener = null;
constructor(store) {
this.store = store;
this.state = {
workspaceId: null,
workspaceName: null,
conversationMessageId: null,
activityStatus: "idle",
isAuthenticated: false
};
registerAllCommands();
this.tui = new piTui.TUI(new piTui.ProcessTerminal());
this.header = new piTui.Text(formatHeader(null, "starting..."), 1, 0);
this.chatLog = new ChatLog();
this.toastContainer = new ToastContainer();
this.toastContainer.setRenderCallback(() => this.tui.requestRender());
this.editor = this.createEditor();
this.tui.addChild(this.header);
this.tui.addChild(new piTui.Spacer(1));
this.tui.addChild(this.toastContainer);
this.tui.addChild(this.chatLog);
this.tui.addChild(this.editor);
this.tui.setFocus(this.editor);
}
// ── Lifecycle ──────────────────────────────────────────────────────────
/** Start the TUI event loop (clears screen for fullscreen layout). */
start() {
this.tui.start();
this.updateHeader();
this.tui.requestRender(true);
}
/** Gracefully shut down the TUI and exit. */
shutdown() {
this.wsConnection?.close();
this.wsConnection = null;
this.toastContainer.clear();
this.tui.stop();
process.exit(0);
}
/**
* Temporarily stop the TUI (releases terminal raw mode).
* Used before interactive prompts that need stdin (login, register).
*/
stopTui() {
this.tui.stop();
}
/**
* Restart the TUI after a stopTui() call.
* Rebuilds the component tree with a fresh TUI instance since
* pi-tui doesn't support stop/start cycling on the same instance.
*/
restartTui() {
this.tui = new piTui.TUI(new piTui.ProcessTerminal());
this.tui.addChild(this.header);
this.tui.addChild(new piTui.Spacer(1));
this.tui.addChild(this.toastContainer);
this.tui.addChild(this.chatLog);
this.editor = this.createEditor();
this.tui.addChild(this.editor);
this.tui.setFocus(this.editor);
this.tui.start();
this.updateHeader();
this.tui.requestRender(true);
}
/** Create and wire a new editor instance. */
createEditor() {
const editor = new ArbiEditor(this.tui);
editor.setAutocompleteProvider(
new piTui.CombinedAutocompleteProvider(toSlashCommands(), process.cwd())
);
editor.onSubmit = (text) => this.handleSubmit(text);
editor.onEscape = () => this.handleAbort();
editor.onCtrlC = () => this.shutdown();
editor.onCtrlD = () => this.shutdown();
editor.onCtrlW = () => this.handleSubmit("/workspaces");
editor.onCtrlN = () => this.handleSubmit("/new");
editor.onBufferChange = (text) => this.handleBufferChange(text);
return editor;
}
/** The slug we last surfaced as a pre-flight hint. ``null`` once
* the user has navigated away from the skill prefix (e.g. typed a
* non-slash char first, or deleted past the slash). Tracking this
* here — not in the editor — keeps the editor pure: it just emits
* text, the TUI decides what's interesting. */
lastHintedSlug = null;
/**
* Pre-flight skill hint dispatcher.
*
* Watches the editor buffer; when it starts with ``/<token>`` where
* ``<token>`` is a slug in ``skillsCache`` *and* not also a static
* command (those have their own ``--help`` style help), we drop a
* one-line description into the chat log so the user sees what
* they're about to invoke before pressing Enter. Equivalent of
* claude-code's slash-command palette preview, minus the modal.
*
* Dedup rule: only emit when the matched slug *changes*. Typing
* "/foo" → "/foob" → "/foo" emits once for ``foo``, never for the
* intermediate ``foob`` (no match), and not again when the user
* lands back on ``foo``. The state resets whenever the buffer no
* longer matches anything, so a fresh ``/foo`` after a clear emits
* the hint again.
*/
handleBufferChange(text) {
const parsed = sdk.parseSlashCommand(text);
if (!parsed) {
this.lastHintedSlug = null;
return;
}
const slug = parsed.slug;
if (this.skillsCache.length === 0) return;
const skill = this.skillsCache.find((s) => s.slug === slug);
if (!skill) {
this.lastHintedSlug = null;
return;
}
if (this.lastHintedSlug === slug) return;
this.lastHintedSlug = slug;
const argHint = skill.arg_hint ? ` ${skill.arg_hint}` : "";
showMessage(this, `Skill: /${skill.slug}${argHint} \u2014 ${skill.description}`, "info");
}
/** Request a render update. */
requestRender() {
this.tui.requestRender();
}
// ── Auth / Workspace ───────────────────────────────────────────────────
/** Set authentication context after login. */
setAuthContext(ctx) {
this.authContext = ctx;
this.state.isAuthenticated = true;
this.encryptionKeyPair = deriveEncryptionKeypair(
this.store.requireCredentials().signingPrivateKeyBase64
);
this.updateHeader();
}
/** Set workspace context after workspace selection. */
setWorkspaceContext(ctx) {
this.workspaceContext = ctx;
this.state.workspaceId = ctx.workspaceId;
this.updateHeader();
this.connectWebSocket();
void this.refreshSkillsCache();
void this.refreshCommandsCache();
}
/** Refresh the cached skills list for the current workspace. Called
* on workspace switch and from ``/skills`` itself so a freshly
* uploaded skill becomes invocable without a TUI restart. */
async refreshSkillsCache() {
if (!this.workspaceContext) {
this.skillsCache = [];
this.rebuildAutocomplete();
return;
}
try {
this.skillsCache = await sdk.assistant.listSkills(this.workspaceContext.arbi, {
workspaceExtId: this.workspaceContext.workspaceId
});
} catch {
this.skillsCache = [];
}
this.rebuildAutocomplete();
}
/** Refresh the cached built-in commands for the current workspace. The
* endpoint resolves via WorkspaceContext (like ``/skills``) and applies
* per-user gates, so it's refreshed on workspace switch. Failures are
* swallowed — older backends without ``GET /v1/assistant/commands`` fall
* back to the static ``KNOWN_SERVER_COMMANDS`` passthrough set. */
async refreshCommandsCache() {
if (!this.workspaceContext) {
this.commandsCache = [];
this.rebuildAutocomplete();
return;
}
try {
this.commandsCache = await sdk.assistant.listCommands(this.workspaceContext.arbi);
} catch {
this.commandsCache = [];
}
this.rebuildAutocomplete();
}
/** Re-feed the editor's autocomplete provider with the merged
* static-commands + skills list. Called on workspace switch and
* after every ``refreshSkillsCache`` so a newly uploaded skill
* appears in tab-complete without a TUI restart. */
rebuildAutocomplete() {
if (!this.editor) return;
const merged = toSlashCommandsWithSkills(
this.skillsCache.map((s) => ({ slug: s.slug, description: s.description })),
this.commandsCache.map((c) => ({ name: c.name, description: c.description }))
);
this.editor.setAutocompleteProvider(new piTui.CombinedAutocompleteProvider(merged, process.cwd()));
}
/** Refresh workspace context (after switching workspaces). */
async refreshWorkspaceContext() {
if (!this.state.workspaceId || !this.authContext) return;
const { resolveWorkspace: resolveWorkspace3 } = await import('@arbidocs/sdk');
const ctx = await resolveWorkspace3(this.store, this.state.workspaceId);
this.workspaceContext = ctx;
this.updateHeader();
}
/** Public accessor for workspace context (used by command handlers). */
get wsContext() {
return this.workspaceContext;
}
/** Whether the WebSocket is currently connected. */
get wsConnected() {
return this.wsConnection !== null;
}
/** Connect (or reconnect) WebSocket for real-time notifications. */
connectWebSocket() {
if (!this.workspaceContext) return;
this.wsConnection?.close();
this.wsConnection = null;
const { config, accessToken } = this.workspaceContext;
connectTuiWebSocket({
baseUrl: config.baseUrl,
accessToken,
toasts: this.toastContainer,
eventLog: this.eventLog,
tui: this
}).then((conn) => {
this.wsConnection = conn;
});
}
// ── Input handling ─────────────────────────────────────────────────────
async handleSubmit(text) {
const trimmed = text.trim();
if (!trimmed) return;
if (trimmed.startsWith("@")) {
await handleChannelSwitch(this, trimmed);
return;
}
if (trimmed.startsWith("/")) {
const handled = await dispatchCommand(this, trimmed);
if (handled) return;
}
if (this.dmChannel) {
await sendEncryptedDm(this, trimmed);
} else {
await this.sendMessage(trimmed);
}
}
handleAbort() {
if (this.state.activityStatus === "streaming") {
this.chatLog.addSystem("Abort requested (stream will complete current chunk).", "warning");
this.requestRender();
}
}
// ── Messaging ──────────────────────────────────────────────────────────
async sendMessage(question) {
if (!this.workspaceContext) {
this.chatLog.addSystem(
"No workspace selected. Use /workspace <id> or /workspaces to choose one.",
"warning"
);
this.requestRender();
return;
}
this.chatLog.addUser(question);
this.state.activityStatus = "sending";
this.updateHeader();
this.requestRender();
try {
const docs = await sdk.documents.listDocuments(this.workspaceContext.arbi);
const docIds = docs.map((d) => d.external_id);
const docNames = sdk.buildDocNameMap(docs);
const session = this.store.getChatSession();
const previousResponseId = this.state.conversationMessageId ?? session.lastMessageExtId;
const response = await sdk.assistant.queryAssistant({
baseUrl: this.workspaceContext.config.baseUrl,
accessToken: this.workspaceContext.accessToken,
workspaceId: this.workspaceContext.workspaceId,
question,
docIds,
previousResponseId
});
const result = await streamResponse(this, response, docNames);
if (this.lastMetadata) {
const n = sdk.countCitations(this.lastMetadata);
if (n > 0) {
const plural = n === 1 ? "" : "s";
this.chatLog.addSystem(
`${n} citation${plural} \u2014 type /cite to browse, /cite N to view one`,
"info"
);
}
}
if (result.assistantMessageExtId) {
this.state.conversationMessageId = result.assistantMessageExtId;
const sessionUpdate = {
lastMessageExtId: result.assistantMessageExtId
};
const conversationExtId = result.userMessage?.conversation_ext_id ?? result.metadata?.conversation_ext_id;
if (conversationExtId) {
sessionUpdate.conversationExtId = conversationExtId;
}
this.store.updateChatSession(sessionUpdate);
}
} catch (err) {
this.state.activityStatus = "error";
this.chatLog.addSystem(`Error: ${sdk.getErrorMessage(err)}`, "error");
}
this.state.activityStatus = "idle";
this.updateHeader();
this.requestRender();
}
// ── UI Updates ─────────────────────────────────────────────────────────
/** Update the header bar — public so dm-handler can call it. */
updateHeader() {
const statusText = this.state.activityStatus === "idle" ? colors.success("ready") : this.state.activityStatus === "streaming" ? colors.warning("streaming...") : this.state.activityStatus === "sending" ? colors.warning("sending...") : colors.error("error");
if (this.dmChannel) {
this.header.setText(formatHeader(`DM: ${this.dmChannel.user.email}`, statusText));
} else {
this.header.setText(formatHeader(this.state.workspaceName, statusText));
}
}
// ── Citation overlay ───────────────────────────────────────────────────
/** Show a component as a centered overlay (used by /cite). */
showCitationOverlay(component) {
this.dismissCitationOverlay();
this.citationOverlay = this.tui.showOverlay(component, {
anchor: "center",
width: "80%",
maxHeight: "80%",
margin: 2
});
this.overlayInputListener = this.tui.addInputListener((data) => {
if (piTui.matchesKey(data, piTui.Key.escape)) {
this.dismissCitationOverlay();
return { consume: true };
}
return void 0;
});
this.tui.requestRender();
}
/** Dismiss the active citation overlay. */
dismissCitationOverlay() {
if (this.citationOverlay) {
this.citationOverlay.hide();
this.citationOverlay = null;
}
if (this.overlayInputListener) {
this.overlayInputListener();
this.overlayInputListener = null;
}
this.tui.requestRender();
}
// ── DM channel accessors (used by dm-handler) ────────────────────────────
/** Current DM channel (null = AI chat mode). */
get currentDmChannel() {
return this.dmChannel;
}
set currentDmChannel(ch) {
this.dmChannel = ch;
}
/** Encryption keypair for E2E DM encryption. */
get encryptionKeys() {
return this.encryptionKeyPair;
}
};
// src/index.ts
console.debug = () => {
};
var _origInfo = console.info;
console.info = (...args) => {
if (typeof args[0] === "string" && args[0].startsWith("[API]")) return;
_origInfo(...args);
};
var program = new commander.Command();
program.name("arbi-tui").description("Interactive terminal UI for ARBI \u2014 chat with the RAG assistant").version("0.1.0").option("-w, --workspace <id>", "Workspace ID to use").action(async (opts) => {
const store = new sdk.FileConfigStore();
try {
store.requireConfig();
} catch {
console.error("Not configured. Run `arbi config set-url <url>` first.");
process.exit(1);
}
const { authContext, selectedWorkspaceId, selectedWorkspaceName } = await ensureAuthenticated(store);
const workspaceId = opts.workspace || selectedWorkspaceId;
const tui = new ArbiTui(store);
tui.setAuthContext(authContext);
if (workspaceId) {
try {
const wsCtx = await sdk.resolveWorkspace(store, workspaceId);
tui.setWorkspaceContext(wsCtx);
if (selectedWorkspaceName && workspaceId === selectedWorkspaceId) {
tui.state.workspaceName = selectedWorkspaceName;
} else {
const wsList = await sdk.workspaces.listWorkspaces(authContext.arbi);
const ws = wsList.find((w) => w.external_id === workspaceId);
if (ws) {
tui.state.workspaceName = ws.name;
}
}
} catch (err) {
tui.chatLog.addSystem(`Failed to load workspace: ${sdk.getErrorMessage(err)}`, "warning");
tui.chatLog.addSystem("Use /workspaces to list and /workspace <id> to select.");
}
} else {
tui.chatLog.addSystem(
"No workspace selected. Use /workspaces to list and /workspace <id> to select."
);
}
const session = store.getChatSession();
if (session.lastMessageExtId) {
tui.state.conversationMessageId = session.lastMessageExtId;
}
if (session.conversationExtId && session.lastMessageExtId && tui.authContext) {
try {
const threads = await sdk.conversations.getConversationThreads(
tui.authContext.arbi,
session.conversationExtId
);
const threadList = threads.threads ?? [];
const thread = threadList.find(
(t) => t.leaf_message_ext_id === session.lastMessageExtId
);
if (thread?.history && thread.history.length > 0) {
for (const msg of thread.history) {
if (msg.role === "user") {
tui.chatLog.addUser(msg.content);
} else if (msg.role === "assistant") {
tui.chatLog.addAssistant(msg.content);
}
}
tui.chatLog.addSystem("--- restored from previous session ---");
}
} catch {
}
}
tui.chatLog.addSystem(
"Type a question to chat with the ARBI assistant. Use /help for commands."
);
tui.start();
});
program.parse();
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map