mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
1175 lines
41 KiB
TypeScript
1175 lines
41 KiB
TypeScript
|
|
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<typeof Agent>;
|
|||
|
|
type ModelConfigType = z.infer<typeof ModelConfig>;
|
|||
|
|
type RunSummary = z.infer<typeof ListRunsResponse>["runs"][number];
|
|||
|
|
type RunType = z.infer<typeof Run>;
|
|||
|
|
type RunEventType = z.infer<typeof RunEvent>;
|
|||
|
|
|
|||
|
|
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<ConnectionState>("connecting");
|
|||
|
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|||
|
|
const [modelConfig, setModelConfig] = useState<ModelConfigType | null>(null);
|
|||
|
|
const [agents, setAgents] = useState<AgentType[]>([]);
|
|||
|
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|||
|
|
const [runsCursor, setRunsCursor] = useState<string | undefined>();
|
|||
|
|
const [runsLoading, setRunsLoading] = useState<boolean>(false);
|
|||
|
|
const [runDetails, setRunDetails] = useState<Record<string, RunType>>({});
|
|||
|
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
|||
|
|
const [draftAgent, setDraftAgent] = useState<string>("copilot");
|
|||
|
|
const [composerValue, setComposerValue] = useState<string>("");
|
|||
|
|
const [composerBusy, setComposerBusy] = useState<boolean>(false);
|
|||
|
|
const [focusTarget, setFocusTarget] = useState<FocusTarget>("chat");
|
|||
|
|
const [sidebarIndex, setSidebarIndex] = useState<number>(0);
|
|||
|
|
const [toast, setToast] = useState<Toast | null>(null);
|
|||
|
|
const [modal, setModal] = useState<ModalState | null>(null);
|
|||
|
|
const [streamError, setStreamError] = useState<string | null>(null);
|
|||
|
|
const [eventStreamActive, setEventStreamActive] = useState<boolean>(false);
|
|||
|
|
const [chatScrollOffset, setChatScrollOffset] = useState<number>(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<string, { label: string; color: string }> = {};
|
|||
|
|
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 (
|
|||
|
|
<Box flexDirection="column" padding={1} height="100%" flexGrow={1} gap={1}>
|
|||
|
|
<Header
|
|||
|
|
serverUrl={serverUrl}
|
|||
|
|
state={connectionState}
|
|||
|
|
error={connectionError}
|
|||
|
|
modelConfig={modelConfig}
|
|||
|
|
agentsCount={agents.length}
|
|||
|
|
runsCount={runs.length}
|
|||
|
|
runsCursor={runsCursor}
|
|||
|
|
streamError={streamError}
|
|||
|
|
listening={eventStreamActive}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<Box flexDirection="row" gap={1} flexGrow={1} minHeight={0}>
|
|||
|
|
<Sidebar
|
|||
|
|
items={sidebarItems}
|
|||
|
|
focus={focusTarget === "sidebar"}
|
|||
|
|
index={sidebarIndex}
|
|||
|
|
activeRunId={activeRunId}
|
|||
|
|
runsLoading={runsLoading}
|
|||
|
|
/>
|
|||
|
|
<ChatPanel
|
|||
|
|
focus={focusTarget === "chat"}
|
|||
|
|
draftAgent={draftAgent || defaultCopilot}
|
|||
|
|
run={selectedRun}
|
|||
|
|
events={chatTimeline.visibleEvents}
|
|||
|
|
composerValue={composerValue}
|
|||
|
|
composerBusy={composerBusy}
|
|||
|
|
onChangeComposer={setComposerValue}
|
|||
|
|
onSubmitComposer={composeMessage}
|
|||
|
|
pendingPermissions={pendingPermissions}
|
|||
|
|
pendingHuman={pendingHuman}
|
|||
|
|
showHumanHint={Boolean(currentHumanRequest)}
|
|||
|
|
showPermissionHint={pendingPermissions.length > 0}
|
|||
|
|
scrollHint={chatTimeline.maxOffset > 0}
|
|||
|
|
/>
|
|||
|
|
</Box>
|
|||
|
|
|
|||
|
|
<Box>
|
|||
|
|
<Text dimColor>
|
|||
|
|
Tab toggles focus · Ctrl+N new Copilot chat · Ctrl+G choose agent · Ctrl+L refresh chats · Ctrl+Q quit
|
|||
|
|
</Text>
|
|||
|
|
</Box>
|
|||
|
|
|
|||
|
|
{toast && (
|
|||
|
|
<Box>
|
|||
|
|
<Text color={toast.type === "error" ? "red" : toast.type === "success" ? "green" : "yellow"}>
|
|||
|
|
{toast.text}
|
|||
|
|
</Text>
|
|||
|
|
</Box>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{modal && (
|
|||
|
|
<ModalSurface>
|
|||
|
|
{modal.type === "agent-picker" && (
|
|||
|
|
<AgentPickerModal
|
|||
|
|
agents={agents}
|
|||
|
|
onSelect={(agent) => {
|
|||
|
|
setModal(null);
|
|||
|
|
startDraftChat(agent);
|
|||
|
|
}}
|
|||
|
|
onCancel={() => setModal(null)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
{modal.type === "human-response" && (
|
|||
|
|
<MessageModal
|
|||
|
|
typeLabel="Reply to agent"
|
|||
|
|
prompt={modal.prompt}
|
|||
|
|
value={modal.value}
|
|||
|
|
submitting={modal.submitting}
|
|||
|
|
onChange={(value) => 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)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</ModalSurface>
|
|||
|
|
)}
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 (
|
|||
|
|
<Box flexDirection="column">
|
|||
|
|
<Text>
|
|||
|
|
<Text color="cyanBright">RowboatX</Text> chat · Server {serverUrl}
|
|||
|
|
</Text>
|
|||
|
|
<Text>
|
|||
|
|
{state === "connecting" && (
|
|||
|
|
<>
|
|||
|
|
<Text color="yellow">
|
|||
|
|
<Spinner type="dots" />
|
|||
|
|
</Text>{" "}
|
|||
|
|
Connecting…
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
{state === "ready" && (
|
|||
|
|
<Text color="green">
|
|||
|
|
Connected · default {modelConfig?.defaults?.provider ?? "n/a"}/{modelConfig?.defaults?.model ?? "n/a"}
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
{state === "error" && (
|
|||
|
|
<Text color="red">
|
|||
|
|
Offline: {error ?? "Unknown error"} · Ctrl+L to retry
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
</Text>
|
|||
|
|
<Text dimColor>
|
|||
|
|
Agents: {agentsCount} · Chats loaded: {runsCount}
|
|||
|
|
{runsCursor ? " (+ more)" : ""}
|
|||
|
|
</Text>
|
|||
|
|
{streamError && (
|
|||
|
|
<Text color="yellow">Event stream issue: {streamError}</Text>
|
|||
|
|
)}
|
|||
|
|
{state === "ready" && listening === false && (
|
|||
|
|
<Text dimColor>Listening for run events…</Text>
|
|||
|
|
)}
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Sidebar({
|
|||
|
|
items,
|
|||
|
|
focus,
|
|||
|
|
index,
|
|||
|
|
activeRunId,
|
|||
|
|
runsLoading,
|
|||
|
|
}: {
|
|||
|
|
items: SidebarItem[];
|
|||
|
|
focus: boolean;
|
|||
|
|
index: number;
|
|||
|
|
activeRunId: string | null;
|
|||
|
|
runsLoading: boolean;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column" borderStyle="round" borderColor={focus ? "cyan" : "gray"} padding={1} width={38} minHeight={0}>
|
|||
|
|
<Text color="cyan">Chats</Text>
|
|||
|
|
<Text dimColor>{focus ? "↑/↓ move · Enter select · Esc to leave" : "Tab to focus sidebar"}</Text>
|
|||
|
|
<Box marginTop={1} flexDirection="column" flexGrow={1} minHeight={0}>
|
|||
|
|
{runsLoading && (
|
|||
|
|
<Text color="yellow">
|
|||
|
|
<Spinner type="dots" /> refreshing…
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
{items.length === 0 && <Text dimColor>No chats yet.</Text>}
|
|||
|
|
{items.map((item, idx) => {
|
|||
|
|
let divider: React.ReactNode = null;
|
|||
|
|
const isCursor = focus && idx === index;
|
|||
|
|
if (item.kind === "action") {
|
|||
|
|
return (
|
|||
|
|
<Text key={item.action} color={isCursor ? "greenBright" : "green"}>
|
|||
|
|
{isCursor ? "❯" : " "} {item.label} {item.hint ? `(${item.hint})` : ""}
|
|||
|
|
</Text>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const previousRuns = items.slice(0, idx).some((entry) => entry.kind === "run");
|
|||
|
|
if (!previousRuns) {
|
|||
|
|
divider = (
|
|||
|
|
<Box key={`divider-${idx}`} marginY={1}>
|
|||
|
|
<Text dimColor>── recent chats ──</Text>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const isActiveRun = item.run.id === activeRunId;
|
|||
|
|
return (
|
|||
|
|
<Box key={item.run.id} flexDirection="column">
|
|||
|
|
{divider}
|
|||
|
|
<Text>
|
|||
|
|
<Text color={isCursor ? "greenBright" : isActiveRun ? "cyan" : undefined}>
|
|||
|
|
{isCursor ? "❯" : isActiveRun ? "●" : " "}
|
|||
|
|
</Text>{" "}
|
|||
|
|
<Text bold={isActiveRun}>{item.run.agentId}</Text>{" "}
|
|||
|
|
<Text dimColor>{item.run.id}</Text>{" "}
|
|||
|
|
<Text color={item.status.color}>{item.status.label}</Text>{" "}
|
|||
|
|
<Text dimColor>{timeAgo(item.run.createdAt)}</Text>
|
|||
|
|
</Text>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 (
|
|||
|
|
<Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor={focus ? "cyan" : "gray"} padding={1} minHeight={0}>
|
|||
|
|
<Text>
|
|||
|
|
<Text color="cyan" bold>
|
|||
|
|
{run ? run.agentId : draftAgent}
|
|||
|
|
</Text>{" "}
|
|||
|
|
{run ? (
|
|||
|
|
<>
|
|||
|
|
· Run {run.id} · started {formatTimestamp(run.createdAt)} ({timeAgo(run.createdAt)})
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<Text dimColor>· new chat</Text>
|
|||
|
|
)}
|
|||
|
|
</Text>
|
|||
|
|
{!run && (
|
|||
|
|
<Text dimColor>Type a prompt and press enter to spin up a new {draftAgent} chat.</Text>
|
|||
|
|
)}
|
|||
|
|
{showPermissionHint && (
|
|||
|
|
<Text color="yellow">Tool approval pending · Ctrl+A approve · Ctrl+D deny</Text>
|
|||
|
|
)}
|
|||
|
|
{showHumanHint && (
|
|||
|
|
<Text color="magenta">Agent asked for help · Ctrl+H to reply</Text>
|
|||
|
|
)}
|
|||
|
|
<Box flexDirection="column" flexGrow={1} marginTop={1} overflow="hidden">
|
|||
|
|
{run && events.length === 0 && (
|
|||
|
|
<Text dimColor>Loading chat log…</Text>
|
|||
|
|
)}
|
|||
|
|
{!run && (
|
|||
|
|
<Text dimColor>No messages yet.</Text>
|
|||
|
|
)}
|
|||
|
|
{events.map((event, idx) => (
|
|||
|
|
<MessageBubble key={`${event.text}-${idx}-${event.variant}`} event={event} />
|
|||
|
|
))}
|
|||
|
|
</Box>
|
|||
|
|
<Box flexDirection="column" marginTop={1}>
|
|||
|
|
<Text dimColor>
|
|||
|
|
{focus
|
|||
|
|
? `Enter to send · Ctrl+N new chat${scrollHint ? " · PgUp/PgDn scroll" : ""}`
|
|||
|
|
: "Tab to focus composer"}
|
|||
|
|
</Text>
|
|||
|
|
<TextInput
|
|||
|
|
value={composerValue}
|
|||
|
|
onChange={onChangeComposer}
|
|||
|
|
onSubmit={(value) => onSubmitComposer(value)}
|
|||
|
|
focus={focus && !composerBusy}
|
|||
|
|
placeholder="Send a message…"
|
|||
|
|
/>
|
|||
|
|
{composerBusy && (
|
|||
|
|
<Text color="yellow">
|
|||
|
|
<Spinner type="dots" /> Sending…
|
|||
|
|
</Text>
|
|||
|
|
)}
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ModalSurface({ children }: { children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<Box marginTop={1} justifyContent="center">
|
|||
|
|
<Box borderStyle="round" borderColor="cyan" padding={1} width="80%" flexDirection="column">
|
|||
|
|
{children}
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 (
|
|||
|
|
<Box flexDirection="column">
|
|||
|
|
<Text>Select an agent (esc to cancel)</Text>
|
|||
|
|
{items.length === 0 ? (
|
|||
|
|
<Text color="yellow">No agents configured.</Text>
|
|||
|
|
) : (
|
|||
|
|
<SelectInput<string>
|
|||
|
|
items={items}
|
|||
|
|
onSelect={(item) => onSelect(item.value)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
<Text dimColor>{items.length} agents available.</Text>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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<void>;
|
|||
|
|
onCancel: () => void;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column">
|
|||
|
|
<Text>{typeLabel} (esc to cancel)</Text>
|
|||
|
|
{prompt && (
|
|||
|
|
<Text dimColor>{truncate(prompt, 120)}</Text>
|
|||
|
|
)}
|
|||
|
|
<TextInput
|
|||
|
|
value={value}
|
|||
|
|
onChange={onChange}
|
|||
|
|
onSubmit={(text) => {
|
|||
|
|
if (!text.trim()) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
onSubmit(text);
|
|||
|
|
}}
|
|||
|
|
focus={!submitting}
|
|||
|
|
placeholder="Type your response…"
|
|||
|
|
/>
|
|||
|
|
{submitting ? (
|
|||
|
|
<Text color="yellow">
|
|||
|
|
<Spinner type="dots" /> Sending…
|
|||
|
|
</Text>
|
|||
|
|
) : (
|
|||
|
|
<Text dimColor>Enter to submit · esc to cancel</Text>
|
|||
|
|
)}
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 (
|
|||
|
|
<Box justifyContent={align} marginBottom={1}>
|
|||
|
|
<Box width="80%">
|
|||
|
|
<Text
|
|||
|
|
backgroundColor={bubbleColor}
|
|||
|
|
color={textColor}
|
|||
|
|
>
|
|||
|
|
{event.text}
|
|||
|
|
</Text>
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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`;
|
|||
|
|
}
|