import { useEffect, useMemo, useRef } from "react"; import type { Model, RuntimeView, TodoTask } from "../workspace/types"; import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc"; import { CommandPalette } from "./CommandPalette"; import { ModelSelector } from "./ModelSelector"; type SessionStatus = { label: string; detail: string; phase: RuntimeView["progress"]["phase"]; bridge: string; extensions: string[]; }; export function messageText(message: RuntimeView["messages"][number]) { if (typeof message.content === "string") return message.content; return (message.content ?? []) .map((part) => { if (part.type === "text") return part.text ?? ""; if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`; if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`; return ""; }) .filter(Boolean) .join("\n"); } function visibleLines(text: string) { const lines = text.replace(/\r\n?/g, "\n").split("\n"); while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); return lines; } function compactCount(value: number) { const units = [ { threshold: 1_000_000_000, divisor: 1_000_000_000, suffix: "b" }, { threshold: 1_000_000, divisor: 1_000_000, suffix: "m" }, { threshold: 1_000, divisor: 1_000, suffix: "k" }, ]; const unit = units.find((candidate) => Math.abs(value) >= candidate.threshold); if (!unit) return String(value); const scaled = Math.round((value / unit.divisor) * 10) / 10; return `${scaled}${unit.suffix}`; } function runtimeStatusLabel(isWorking: boolean, state: string) { if (isWorking) return "Working"; return state === "idle" ? "Pi ready" : state; } function plainStatusText(value: string) { return value .normalize("NFC") .replace(/\u001B\[[0-?]*[ -/]*[@-~]/gu, "") .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/gu, "") .replace( /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uE000-\uF8FF\uFEFF\uFFFD]/gu, "", ) .replace(/\s+/gu, " ") .trim(); } function TranscriptMessage({ message, }: { message: RuntimeView["messages"][number]; }) { const role = message.role ?? "system"; if (role === "toolResult") { const text = messageText(message); const lines = visibleLines(text); const label = `Tool result · ${message.toolName ?? "tool"}`; if (lines.length <= 2) return (
{label}
{text}
); return (
{label} {lines.slice(0, 2).join("\n")} {lines.length - 2} more {lines.length - 2 === 1 ? "line" : "lines"}{" "} hidden · Expand to view
{text}
); } return (
{message.role ?? "message"}
{messageText(message)}
); } function pendingStatus(pending: RuntimeView["pendingSubmissions"][number]) { if (pending.phase === "sending") return pending.delivery === "follow_up" ? "Queueing follow-up…" : "Sending prompt…"; return pending.delivery === "follow_up" ? "Follow-up queued · waiting for Pi" : "Prompt accepted · waiting for Pi"; } function todos(messages: RuntimeView["messages"]) { const order: Record = { in_progress: 0, pending: 1, completed: 2, deleted: 3, }; for (let index = messages.length - 1; index >= 0; index--) { if ( messages[index].role === "toolResult" && messages[index].toolName === "todo" && Array.isArray(messages[index].details?.tasks) ) return messages[index] .details!.tasks!.filter((task) => task.status !== "deleted") .sort((a, b) => order[a.status] - order[b.status]); } return [] as TodoTask[]; } export function ConversationWorkspace({ runtime, onDraft, onSubmit, onAbort, onScroll, onModel, onThinking, isSubmitting = false, isAborting = false, isModelUpdating = false, locked = false, lockReason = "renewing", status, onCommand, }: { runtime: RuntimeView; onDraft: (value: string) => void; onSubmit: () => void; onAbort: () => void; onScroll: (atBottom: boolean, scrollTop: number) => void; onModel: (model: Model) => Promise; onThinking: (level: string) => Promise; isSubmitting?: boolean; isAborting?: boolean; isModelUpdating?: boolean; locked?: boolean; lockReason?: "renewing" | "restarting"; status?: SessionStatus; onCommand: (command: RpcCommand) => void; }) { const ref = useRef(null); const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]); const runtimeIsWorking = runtime.summary.state === "streaming" || runtime.progress.phase === "working"; const isWorking = isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering"; const presentedStatus = status ?? { label: runtimeStatusLabel(isWorking, runtime.summary.state), detail: isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail, phase: isWorking ? ("working" as const) : runtime.progress.phase, bridge: "unknown", extensions: Object.values(runtime.extensionStatuses), }; const visibleStatus = { label: plainStatusText(presentedStatus.label) || "Pi status", detail: plainStatusText(presentedStatus.detail) || "No status details", bridge: plainStatusText(presentedStatus.bridge) || "unknown", extensions: presentedStatus.extensions.map(plainStatusText).filter(Boolean), }; const activeTool = runtime.progress.tool ? plainStatusText(runtime.progress.tool) : undefined; const inputTokens = runtime.stats.tokens?.input; const outputTokens = runtime.stats.tokens?.output; const cacheReadTokens = runtime.stats.tokens?.cacheRead; const undividedTokens = inputTokens === undefined && outputTokens === undefined ? runtime.stats.tokens?.total : undefined; const context = runtime.stats.contextUsage; const contextPercent = context?.percent ?? (context?.tokens != null && context.contextWindow ? (context.tokens / context.contextWindow) * 100 : undefined); let contextDetail: string | undefined; if (context?.tokens != null) { contextDetail = compactCount(context.tokens); if (context.contextWindow !== undefined) contextDetail += ` / ${compactCount(context.contextWindow)}`; if (contextPercent !== undefined) contextDetail += ` · ${Math.round(contextPercent)}%`; } useEffect(() => { if (runtime.scroll.atBottom && ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]); const scrollToLatest = () => { const transcript = ref.current; if (!transcript) return; transcript.scrollTop = transcript.scrollHeight; onScroll(true, transcript.scrollTop); transcript.focus(); }; return (
{ const node = event.currentTarget; onScroll( node.scrollHeight - node.scrollTop - node.clientHeight <= 4, node.scrollTop, ); }} > {isWorking && (
Pi is working {isSubmitting ? "Sending your prompt…" : runtime.progress.detail} {runtime.progress.tool && Using {runtime.progress.tool}}
)} {runtime.loading && !runtime.messages.length && (

Loading session…

)} {!runtime.loading && !runtime.messages.length && (

No messages yet.

)} {runtime.messages.map((message, index) => ( ))} {runtime.pendingSubmissions.map((pending) => (
You {pendingStatus(pending)}
{pending.text}
))}
{!runtime.scroll.atBottom && ( )}