import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useInput, useStdout } from "ink"; import Spinner from "ink-spinner"; import SelectInput from "ink-select-input"; import TextInput from "ink-text-input"; import z from "zod"; import { RowboatApi } from "./api.js"; import { ModelConfig } from "../models/models.js"; import { Agent } from "../agents/agents.js"; import { ListRunsResponse } from "../runs/repo.js"; import { Run } from "../runs/runs.js"; import { RunEvent } from "../entities/run-events.js"; type AgentType = z.infer; type ModelConfigType = z.infer; type RunSummary = z.infer["runs"][number]; type RunType = z.infer; type RunEventType = z.infer; type Toast = { type: "info" | "error" | "success"; text: string; }; type ChatLine = { text: string; color?: string; variant?: "user" | "assistant" | "streaming" | "thinking" | "system" | "tool" | "other"; }; type ModalState = | { type: "agent-picker" } | { type: "human-response"; runId: string; requestId: string; subflow: string[]; prompt: string; value: string; submitting: boolean; }; type ConnectionState = "connecting" | "ready" | "error"; type FocusTarget = "chat" | "sidebar"; type PendingPermission = { toolCallId: string; toolName: string; args: unknown; subflow: string[]; }; type PendingHuman = { toolCallId: string; query: string; subflow: string[]; }; type SidebarItem = | { kind: "action"; action: "new-copilot" | "new-agent"; label: string; hint?: string } | { kind: "run"; run: RunSummary; status: { label: string; color: string } }; export function RowboatTui({ serverUrl }: { serverUrl: string }) { const api = useMemo(() => new RowboatApi({ baseUrl: serverUrl }), [serverUrl]); const { exit } = useApp(); const { stdout } = useStdout(); const [connectionState, setConnectionState] = useState("connecting"); const [connectionError, setConnectionError] = useState(null); const [modelConfig, setModelConfig] = useState(null); const [agents, setAgents] = useState([]); const [runs, setRuns] = useState([]); const [runsCursor, setRunsCursor] = useState(); const [runsLoading, setRunsLoading] = useState(false); const [runDetails, setRunDetails] = useState>({}); const [activeRunId, setActiveRunId] = useState(null); const [draftAgent, setDraftAgent] = useState("copilot"); const [composerValue, setComposerValue] = useState(""); const [composerBusy, setComposerBusy] = useState(false); const [focusTarget, setFocusTarget] = useState("chat"); const [sidebarIndex, setSidebarIndex] = useState(0); const [toast, setToast] = useState(null); const [modal, setModal] = useState(null); const [streamError, setStreamError] = useState(null); const [eventStreamActive, setEventStreamActive] = useState(false); const [chatScrollOffset, setChatScrollOffset] = useState(0); const selectedRun = activeRunId ? runDetails[activeRunId] : undefined; const pendingPermissions = useMemo(() => derivePendingPermissions(selectedRun), [selectedRun]); const pendingHuman = useMemo(() => derivePendingHuman(selectedRun), [selectedRun]); const defaultCopilot = useMemo(() => { return "copilot"; }, [agents]); useEffect(() => { if (!agents.length) { return; } setDraftAgent((prev) => prev || defaultCopilot); }, [agents, defaultCopilot]); const runStatusMap = useMemo(() => { const map: Record = {}; for (const summary of runs) { map[summary.id] = getRunStatus(runDetails[summary.id]); } return map; }, [runs, runDetails]); const sidebarItems: SidebarItem[] = useMemo(() => { const items: SidebarItem[] = [ { kind: "action", action: "new-copilot", label: `+ New chat (${defaultCopilot})`, hint: "Ctrl+N", }, { kind: "action", action: "new-agent", label: "+ New chat (choose agent)", hint: "Ctrl+G", }, ]; for (const run of runs) { items.push({ kind: "run", run, status: runStatusMap[run.id] ?? { label: "loading…", color: "gray" }, }); } return items; }, [defaultCopilot, runStatusMap, runs]); useEffect(() => { setSidebarIndex((idx) => { if (sidebarItems.length === 0) { return 0; } return Math.min(idx, sidebarItems.length - 1); }); }, [sidebarItems.length]); const showToast = useCallback((next: Toast) => { setToast(next); }, []); useEffect(() => { if (!toast) { return; } const timer = setTimeout(() => { setToast(null); }, 4000); return () => clearTimeout(timer); }, [toast]); const loadInitial = useCallback(async () => { setConnectionState("connecting"); setConnectionError(null); try { const [health, config, agentList, runsResponse] = await Promise.all([ api.getHealth(), api.getModelConfig(), api.listAgents(), api.listRuns(), ]); if (health.status !== "ok") { throw new Error("Server is not healthy"); } setModelConfig(config); setAgents(agentList); setRuns(runsResponse.runs); setRunsCursor(runsResponse.nextCursor); setConnectionState("ready"); } catch (error) { setConnectionState("error"); setConnectionError(error instanceof Error ? error.message : String(error)); } }, [api]); useEffect(() => { loadInitial(); }, [loadInitial]); useEffect(() => { if (!activeRunId) { return; } if (runDetails[activeRunId]) { return; } let cancelled = false; (async () => { try { const run = await api.getRun(activeRunId); if (!cancelled) { setRunDetails((prev) => ({ ...prev, [run.id]: run, })); } } catch (error) { if (!cancelled) { showToast({ type: "error", text: `Failed to load run: ${error instanceof Error ? error.message : String(error)}`, }); } } })(); return () => { cancelled = true; }; }, [activeRunId, api, runDetails, showToast]); const refreshRuns = useCallback(async () => { setRunsLoading(true); try { const response = await api.listRuns(); setRuns(response.runs); setRunsCursor(response.nextCursor); } catch (error) { showToast({ type: "error", text: `Failed to refresh runs: ${error instanceof Error ? error.message : String(error)}`, }); } finally { setRunsLoading(false); } }, [api, showToast]); useEffect(() => { if (connectionState !== "ready") { return; } let unsub: (() => void) | null = null; let cancelled = false; setStreamError(null); setEventStreamActive(false); (async () => { try { unsub = await api.subscribeToEvents((event) => { if (cancelled) { return; } setEventStreamActive(true); if (event.type === "start") { setRuns((prev) => { const next = [...prev]; const idx = next.findIndex((r) => r.id === event.runId); const summary: RunSummary = { id: event.runId, agentId: event.agentName, createdAt: event.ts ?? new Date().toISOString(), }; if (idx >= 0) { next[idx] = summary; return next; } return [summary, ...next]; }); } setRunDetails((prev) => { const existing = prev[event.runId]; if (!existing) { return prev; } return { ...prev, [event.runId]: { ...existing, log: [...existing.log, event], }, }; }); }, (error) => { setStreamError(error.message); }); } catch (error) { if (!cancelled) { setStreamError(error instanceof Error ? error.message : String(error)); } } })(); return () => { cancelled = true; unsub?.(); }; }, [api, connectionState]); const startDraftChat = useCallback((agentName: string) => { setActiveRunId(null); setDraftAgent(agentName); setComposerValue(""); setFocusTarget("chat"); setSidebarIndex(0); }, []); const composeMessage = useCallback(async (value: string) => { const trimmed = value.trim(); if (!trimmed) { return; } setComposerBusy(true); try { let runId = activeRunId; if (!runId) { const agentName = draftAgent || defaultCopilot; const run = await api.createRun(agentName); runId = run.id; setRuns((prev) => { const without = prev.filter((r) => r.id !== run.id); return [ { id: run.id, createdAt: run.createdAt, agentId: run.agentId, }, ...without, ]; }); setRunDetails((prev) => ({ ...prev, [run.id]: run, })); setActiveRunId(run.id); } await api.sendMessage(runId, trimmed); setComposerValue(""); showToast({ type: "success", text: "Message queued", }); } catch (error) { showToast({ type: "error", text: `Failed to send message: ${error instanceof Error ? error.message : String(error)}`, }); } finally { setComposerBusy(false); } }, [activeRunId, api, defaultCopilot, draftAgent, showToast]); const handleApprovePermission = useCallback(async () => { const run = selectedRun; const pending = pendingPermissions[0]; if (!run || !pending) { showToast({ type: "info", text: "No pending tool permissions" }); return; } try { await api.authorizeTool(run.id, { toolCallId: pending.toolCallId, response: "approve", subflow: pending.subflow, }); showToast({ type: "success", text: `Approved ${pending.toolName}` }); } catch (error) { showToast({ type: "error", text: `Failed to approve: ${error instanceof Error ? error.message : String(error)}`, }); } }, [api, pendingPermissions, selectedRun, showToast]); const handleDenyPermission = useCallback(async () => { const run = selectedRun; const pending = pendingPermissions[0]; if (!run || !pending) { showToast({ type: "info", text: "No pending tool permissions" }); return; } try { await api.authorizeTool(run.id, { toolCallId: pending.toolCallId, response: "deny", subflow: pending.subflow, }); showToast({ type: "success", text: `Denied ${pending.toolName}` }); } catch (error) { showToast({ type: "error", text: `Failed to deny: ${error instanceof Error ? error.message : String(error)}`, }); } }, [api, pendingPermissions, selectedRun, showToast]); const handleStopRun = useCallback(async () => { if (!selectedRun) { showToast({ type: "info", text: "No run selected" }); return; } try { await api.stopRun(selectedRun.id); showToast({ type: "success", text: `Stop requested for ${selectedRun.id}` }); } catch (error) { showToast({ type: "error", text: `Failed to stop: ${error instanceof Error ? error.message : String(error)}`, }); } }, [api, selectedRun, showToast]); const handleReplyHuman = useCallback(async (value: string, context: PendingHuman | undefined) => { if (!selectedRun || !context) { showToast({ type: "info", text: "No pending human requests" }); return; } try { await api.replyToHuman(selectedRun.id, context.toolCallId, { toolCallId: context.toolCallId, response: value, subflow: context.subflow, }); showToast({ type: "success", text: "Reply sent" }); } catch (error) { showToast({ type: "error", text: `Failed to send reply: ${error instanceof Error ? error.message : String(error)}`, }); throw error; } }, [api, selectedRun, showToast]); const currentHumanRequest = pendingHuman[0]; const maxVisibleEvents = Math.max(8, (stdout?.rows ?? 40) - 14); const chatTimeline = useMemo(() => { if (!selectedRun) { return { visibleEvents: [] as ChatLine[], maxOffset: 0, total: 0, }; } const lines: ChatLine[] = []; let streamingText = ""; let streamingActive = false; let reasoningText = ""; let reasoningActive = false; for (const event of selectedRun.log) { if (event.type === "llm-stream-event") { const step = event.event; switch (step.type) { case "text-start": streamingActive = true; streamingText = ""; break; case "text-delta": streamingActive = true; streamingText += step.delta; break; case "text-end": case "finish-step": streamingActive = false; break; case "reasoning-start": reasoningActive = true; reasoningText = ""; break; case "reasoning-delta": reasoningActive = true; reasoningText += step.delta; break; case "reasoning-end": reasoningActive = false; break; default: break; } continue; } const formatted = formatEvent(event); if (formatted) { lines.push(formatted); } } if (reasoningActive && reasoningText) { lines.push({ text: `assistant (thinking): ${reasoningText}`, color: "black", variant: "thinking", }); } if (streamingActive && streamingText) { lines.push({ text: `assistant (streaming): ${streamingText}`, color: "black", variant: "streaming", }); } const total = lines.length; const maxOffset = Math.max(0, total - maxVisibleEvents); const clampedOffset = Math.min(chatScrollOffset, maxOffset); const end = total - clampedOffset; const start = Math.max(0, end - maxVisibleEvents); return { visibleEvents: lines.slice(start, end), maxOffset, total, }; }, [chatScrollOffset, maxVisibleEvents, selectedRun]); useEffect(() => { setChatScrollOffset(0); }, [selectedRun?.id]); useEffect(() => { setChatScrollOffset((offset) => Math.min(offset, chatTimeline.maxOffset)); }, [chatTimeline.maxOffset]); useInput((input, key) => { if (modal) { if (key.escape) { setModal(null); } return; } if (key.tab) { setFocusTarget((prev) => (prev === "chat" ? "sidebar" : "chat")); return; } if (key.ctrl && input === "q") { exit(); return; } if (key.ctrl && input === "n") { startDraftChat(defaultCopilot); return; } if (key.ctrl && input === "g") { if (agents.length === 0) { showToast({ type: "error", text: "No agents available" }); return; } setModal({ type: "agent-picker" }); return; } if (key.ctrl && input === "l") { refreshRuns(); return; } if (key.ctrl && input === "a") { handleApprovePermission(); return; } if (key.ctrl && input === "d") { handleDenyPermission(); return; } if (key.ctrl && input === "s") { handleStopRun(); return; } if (key.ctrl && input === "h") { if (!currentHumanRequest) { showToast({ type: "info", text: "No pending human input requests" }); return; } if (!selectedRun) { showToast({ type: "info", text: "Select a run to respond" }); return; } setModal({ type: "human-response", runId: selectedRun.id, requestId: currentHumanRequest.toolCallId, subflow: currentHumanRequest.subflow, prompt: currentHumanRequest.query, value: "", submitting: false, }); return; } if (focusTarget === "sidebar") { if (key.upArrow) { setSidebarIndex((idx) => Math.max(0, idx - 1)); return; } if (key.downArrow) { setSidebarIndex((idx) => Math.min(sidebarItems.length - 1, idx + 1)); return; } if (key.return) { const item = sidebarItems[sidebarIndex]; if (!item) { return; } if (item.kind === "action") { if (item.action === "new-copilot") { startDraftChat(defaultCopilot); } else { if (agents.length === 0) { showToast({ type: "error", text: "No agents available" }); } else { setModal({ type: "agent-picker" }); } } } else { setActiveRunId(item.run.id); setFocusTarget("chat"); } } } if (focusTarget === "chat") { const scrollStep = Math.max(3, Math.floor(maxVisibleEvents / 2)); if (key.pageUp) { setChatScrollOffset((offset) => Math.min(chatTimeline.maxOffset, offset + scrollStep)); return; } if (key.pageDown) { setChatScrollOffset((offset) => Math.max(0, offset - scrollStep)); return; } } }); return (
0} scrollHint={chatTimeline.maxOffset > 0} /> Tab toggles focus · Ctrl+N new Copilot chat · Ctrl+G choose agent · Ctrl+L refresh chats · Ctrl+Q quit {toast && ( {toast.text} )} {modal && ( {modal.type === "agent-picker" && ( { setModal(null); startDraftChat(agent); }} onCancel={() => setModal(null)} /> )} {modal.type === "human-response" && ( setModal({ ...modal, value })} onSubmit={async (value) => { const ctx: PendingHuman = { toolCallId: modal.requestId, query: modal.prompt, subflow: modal.subflow, }; setModal({ ...modal, submitting: true }); try { await handleReplyHuman(value.trim(), ctx); setModal(null); } catch { setModal({ ...modal, submitting: false }); } }} onCancel={() => setModal(null)} /> )} )} ); } function Header({ serverUrl, state, error, modelConfig, agentsCount, runsCount, runsCursor, streamError, listening, }: { serverUrl: string; state: ConnectionState; error: string | null; modelConfig: ModelConfigType | null; agentsCount: number; runsCount: number; runsCursor: string | undefined; streamError: string | null; listening: boolean; }) { return ( RowboatX chat · Server {serverUrl} {state === "connecting" && ( <> {" "} Connecting… )} {state === "ready" && ( Connected · default {modelConfig?.defaults?.provider ?? "n/a"}/{modelConfig?.defaults?.model ?? "n/a"} )} {state === "error" && ( Offline: {error ?? "Unknown error"} · Ctrl+L to retry )} Agents: {agentsCount} · Chats loaded: {runsCount} {runsCursor ? " (+ more)" : ""} {streamError && ( Event stream issue: {streamError} )} {state === "ready" && listening === false && ( Listening for run events… )} ); } function Sidebar({ items, focus, index, activeRunId, runsLoading, }: { items: SidebarItem[]; focus: boolean; index: number; activeRunId: string | null; runsLoading: boolean; }) { return ( Chats {focus ? "↑/↓ move · Enter select · Esc to leave" : "Tab to focus sidebar"} {runsLoading && ( refreshing… )} {items.length === 0 && No chats yet.} {items.map((item, idx) => { let divider: React.ReactNode = null; const isCursor = focus && idx === index; if (item.kind === "action") { return ( {isCursor ? "❯" : " "} {item.label} {item.hint ? `(${item.hint})` : ""} ); } const previousRuns = items.slice(0, idx).some((entry) => entry.kind === "run"); if (!previousRuns) { divider = ( ── recent chats ── ); } const isActiveRun = item.run.id === activeRunId; return ( {divider} {isCursor ? "❯" : isActiveRun ? "●" : " "} {" "} {item.run.agentId}{" "} {item.run.id}{" "} {item.status.label}{" "} {timeAgo(item.run.createdAt)} ); })} ); } function ChatPanel({ focus, draftAgent, run, events, composerValue, composerBusy, onChangeComposer, onSubmitComposer, pendingPermissions, pendingHuman, showHumanHint, showPermissionHint, scrollHint, }: { focus: boolean; draftAgent: string; run: RunType | undefined; events: ChatLine[]; composerValue: string; composerBusy: boolean; onChangeComposer: (value: string) => void; onSubmitComposer: (value: string) => void; pendingPermissions: PendingPermission[]; pendingHuman: PendingHuman[]; showHumanHint: boolean; showPermissionHint: boolean; scrollHint: boolean; }) { return ( {run ? run.agentId : draftAgent} {" "} {run ? ( <> · Run {run.id} · started {formatTimestamp(run.createdAt)} ({timeAgo(run.createdAt)}) ) : ( · new chat )} {!run && ( Type a prompt and press enter to spin up a new {draftAgent} chat. )} {showPermissionHint && ( Tool approval pending · Ctrl+A approve · Ctrl+D deny )} {showHumanHint && ( Agent asked for help · Ctrl+H to reply )} {run && events.length === 0 && ( Loading chat log… )} {!run && ( No messages yet. )} {events.map((event, idx) => ( ))} {focus ? `Enter to send · Ctrl+N new chat${scrollHint ? " · PgUp/PgDn scroll" : ""}` : "Tab to focus composer"} onSubmitComposer(value)} focus={focus && !composerBusy} placeholder="Send a message…" /> {composerBusy && ( Sending… )} ); } function ModalSurface({ children }: { children: React.ReactNode }) { return ( {children} ); } function AgentPickerModal({ agents, onSelect, onCancel, }: { agents: AgentType[]; onSelect: (agentName: string) => void; onCancel: () => void; }) { const items = agents.map((agent) => ({ label: `${agent.name} – ${truncate(agent.description, 40)}`, value: agent.name, })); return ( Select an agent (esc to cancel) {items.length === 0 ? ( No agents configured. ) : ( items={items} onSelect={(item) => onSelect(item.value)} /> )} {items.length} agents available. ); } function MessageModal({ typeLabel, prompt, value, submitting, onChange, onSubmit, onCancel, }: { typeLabel: string; prompt?: string; value: string; submitting: boolean; onChange: (value: string) => void; onSubmit: (value: string) => Promise; onCancel: () => void; }) { return ( {typeLabel} (esc to cancel) {prompt && ( {truncate(prompt, 120)} )} { if (!text.trim()) { return; } onSubmit(text); }} focus={!submitting} placeholder="Type your response…" /> {submitting ? ( Sending… ) : ( Enter to submit · esc to cancel )} ); } function derivePendingPermissions(run: RunType | undefined): PendingPermission[] { if (!run) { return []; } const responded = new Set( run.log .filter((event) => event.type === "tool-permission-response") .map((event) => event.toolCallId), ); const pending: PendingPermission[] = []; for (const event of run.log) { if (event.type === "tool-permission-request") { const id = event.toolCall.toolCallId; if (!responded.has(id)) { pending.push({ toolCallId: id, toolName: event.toolCall.toolName, args: event.toolCall.arguments, subflow: event.subflow, }); } } } return pending; } function derivePendingHuman(run: RunType | undefined): PendingHuman[] { if (!run) { return []; } const responded = new Set( run.log .filter((event) => event.type === "ask-human-response") .map((event) => event.toolCallId), ); const pending: PendingHuman[] = []; for (const event of run.log) { if (event.type === "ask-human-request" && !responded.has(event.toolCallId)) { pending.push({ toolCallId: event.toolCallId, query: event.query, subflow: event.subflow, }); } } return pending; } function getRunStatus(run: RunType | undefined): { label: string; color: string } { if (!run) { return { label: "loading…", color: "gray" }; } const last = run.log[run.log.length - 1]; if (last?.type === "error") { return { label: "error", color: "red" }; } if (derivePendingHuman(run).length > 0) { return { label: "awaiting human", color: "magenta" }; } if (derivePendingPermissions(run).length > 0) { return { label: "needs approval", color: "yellow" }; } return { label: "running", color: "green" }; } function MessageBubble({ event }: { event: ChatLine }) { const isUser = event.variant === "user"; const isAssistant = event.variant === "assistant" || event.variant === "streaming"; const align = isUser ? "flex-end" : "flex-start"; const bubbleColor = isUser ? "blue" : undefined; const textColor = isUser ? "white" : event.color; return ( {event.text} ); } function formatEvent(event: RunEventType): ChatLine | null { switch (event.type) { case "start": return { text: `▶ Start · ${event.agentName}`, color: "green", variant: "system" }; case "message": { const content = typeof event.message.content === "string" ? event.message.content : event.message.content .map((part) => { if (part.type === "text" || part.type === "reasoning") { return part.text; } if (part.type === "tool-call") { return `[tool:${part.toolName}] ${JSON.stringify(part.arguments)}`; } return ""; }) .join("\n"); return { text: `${event.message.role}: ${content}`, color: event.message.role === "user" ? "black" : event.message.role === "assistant" ? "black" : "white", variant: event.message.role === "user" ? "user" : event.message.role === "assistant" ? "assistant" : "system", }; } case "tool-invocation": return { text: `🔧 Invoking ${event.toolName} ${JSON.stringify(event.input)}`, color: "yellow", variant: "tool" }; case "tool-result": return { text: `✅ ${event.toolName} → ${truncate(JSON.stringify(event.result), 120)}`, color: "green", variant: "tool" }; case "tool-permission-request": return { text: `⚠️ Permission needed for ${event.toolCall.toolName}`, color: "yellow", variant: "system" }; case "tool-permission-response": return { text: `Permission ${event.response} for ${event.toolCallId}`, color: event.response === "approve" ? "green" : "red", variant: "system" }; case "ask-human-request": return { text: `🧑 Agent asks: ${truncate(event.query, 120)}`, color: "magenta", variant: "system" }; case "ask-human-response": return { text: `🙋 Human replied`, color: "magenta", variant: "system" }; case "llm-stream-event": return { text: `… ${event.event.type}`, color: "gray" }; case "error": return { text: `✖ ${event.error}`, color: "red", variant: "system" }; case "spawn-subflow": return { text: `↳ Spawned ${event.agentName}`, color: "cyan", variant: "system" }; default: return { text: "unknown event", color: "white", variant: "other" }; } } function truncate(input: string, len = 60): string { if (input.length <= len) { return input; } return `${input.slice(0, len - 1)}…`; } function formatTimestamp(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) { return iso; } return date.toLocaleString(); } function timeAgo(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) { return iso; } const diff = Date.now() - date.getTime(); const seconds = Math.floor(diff / 1000); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; }