multica/apps/cli/src/output.ts
Naiyuan Qing 6ef58a0cab refactor: restructure to monorepo architecture
- Move core agent engine to packages/core/
- Add packages/types/ for shared TypeScript types
- Add packages/utils/ for utility functions
- Add apps/cli/ for command-line interface
- Add apps/gateway/ for NestJS WebSocket gateway
- Add apps/server/ for REST API server
- Restructure desktop app (electron/ → src/main/, src/preload/)
- Update pnpm workspace configuration
- Remove legacy src/ directory

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 18:00:23 +08:00

318 lines
10 KiB
TypeScript

import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import { colors, createSpinner, dim } from "./colors.js";
import { extractText, extractThinking } from "@multica/core";
import type { ReasoningMode } from "@multica/core";
export type AgentOutputState = {
lastAssistantText: string;
lastAssistantThinking: string;
printedLen: number;
printedThinkingLen: number;
streaming: boolean;
};
export type AgentOutput = {
state: AgentOutputState;
handleEvent: (event: AgentEvent) => void;
};
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}
// Exported for testing
export function toolDisplayName(name: string): string {
const map: Record<string, string> = {
read: "ReadFile",
write: "WriteFile",
edit: "EditFile",
exec: "Exec",
process: "Process",
grep: "Grep",
find: "FindFiles",
ls: "ListDir",
glob: "Glob",
web_search: "WebSearch",
web_fetch: "WebFetch",
};
return map[name] || name;
}
// Exported for testing
export function formatToolArgs(name: string, args: unknown): string {
if (!args || typeof args !== "object") return "";
const record = args as Record<string, unknown>;
const get = (key: string) => (record[key] !== undefined ? String(record[key]) : "");
switch (name) {
case "read":
return get("path") || get("file");
case "write":
return get("path") || get("file");
case "edit":
return get("path") || get("file");
case "grep":
return [get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
case "find":
return [get("glob") || get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
case "ls":
return get("path") || get("directory");
case "exec":
return get("command");
case "process":
return [get("action"), get("id")].filter(Boolean).join(" ");
case "glob":
return [get("pattern"), get("cwd")].filter(Boolean).join(" in ");
case "web_search":
return truncate(get("query"), 50);
case "web_fetch": {
const url = get("url");
try {
const parsed = new URL(url);
return parsed.hostname + (parsed.pathname !== "/" ? truncate(parsed.pathname, 30) : "");
} catch {
return truncate(url, 50);
}
}
default:
return "";
}
}
function formatToolLine(name: string, args: unknown, result?: unknown): string {
const title = colors.toolName(toolDisplayName(name));
const argText = formatToolArgs(name, args);
const resultSummary = formatResultSummary(name, result);
const bullet = colors.toolBullet("•");
let line = `${bullet} ${title}`;
if (argText) {
line += ` ${colors.toolArgs(`(${argText})`)}`;
}
if (resultSummary) {
line += ` ${colors.toolArrow("→")} ${colors.toolArgs(resultSummary)}`;
}
return line;
}
// Exported for testing
export function extractResultDetails(result: unknown): Record<string, unknown> | null {
if (!result || typeof result !== "object") return null;
// Try to extract from AgentMessage content array (JSON result)
const msg = result as { content?: Array<{ type: string; text?: string }> };
if (Array.isArray(msg.content)) {
for (const c of msg.content) {
if (c.type === "text" && c.text) {
try {
return JSON.parse(c.text) as Record<string, unknown>;
} catch {
continue;
}
}
}
}
const withDetails = result as { details?: unknown };
if (withDetails.details && typeof withDetails.details === "object") {
return withDetails.details as Record<string, unknown>;
}
// Try direct object access
return result as Record<string, unknown>;
}
// Exported for testing
export function formatResultSummary(name: string, result: unknown): string {
const details = extractResultDetails(result);
if (!details) return "";
switch (name) {
case "glob": {
const count = details.count ?? (Array.isArray(details.files) ? details.files.length : 0);
const truncated = details.truncated ? "+" : "";
return `${count}${truncated} files`;
}
case "web_search": {
if (details.error) return `error: ${details.message || details.error}`;
if (details.content) {
// Perplexity result
const citations = Array.isArray(details.citations) ? details.citations.length : 0;
return `${citations} citations`;
}
// Brave result
const count = details.count ?? (Array.isArray(details.results) ? details.results.length : 0);
return `${count} results`;
}
case "web_fetch": {
if (details.error) return `error: ${details.message || details.error}`;
const parts: string[] = [];
if (details.title) {
parts.push(`"${truncate(String(details.title), 30)}"`);
}
if (typeof details.length === "number") {
const kb = (details.length / 1024).toFixed(1);
parts.push(`${kb}KB`);
}
if (details.cached) {
parts.push("cached");
}
return parts.join(", ");
}
case "grep": {
// Try to count matches from result text
const text = extractText(result as AgentMessage | undefined);
if (text.includes("No matches found")) return "no matches";
const lines = text.split("\n").filter((l) => l.trim()).length;
if (lines > 0) return `${lines} matches`;
return "";
}
default:
return "";
}
}
export function createAgentOutput(params: {
stdout: NodeJS.WritableStream;
stderr: NodeJS.WritableStream;
reasoningMode?: ReasoningMode;
}): AgentOutput {
const reasoningMode = params.reasoningMode ?? "stream";
const state: AgentOutputState = {
lastAssistantText: "",
lastAssistantThinking: "",
printedLen: 0,
printedThinkingLen: 0,
streaming: false,
};
// Create spinner for thinking indicator
const spinner = createSpinner({ stream: params.stderr });
let pendingToolName = "";
let pendingToolArgs: unknown = null;
const handleEvent = (event: AgentEvent) => {
switch (event.type) {
case "message_start": {
const msg = event.message;
if (msg.role === "assistant") {
// Stop any running spinner when assistant starts responding
if (spinner.isSpinning()) {
spinner.stop();
}
state.streaming = true;
state.printedLen = 0;
state.printedThinkingLen = 0;
const text = extractText(msg);
if (text.length > 0) {
params.stdout.write(text);
state.printedLen = text.length;
}
// Stream thinking content in real-time
if (reasoningMode === "stream") {
const thinking = extractThinking(msg);
if (thinking.length > 0) {
params.stderr.write(dim(thinking));
state.printedThinkingLen = thinking.length;
}
}
}
break;
}
case "message_update": {
const msg = event.message;
if (msg.role === "assistant") {
const text = extractText(msg);
if (text.length > state.printedLen) {
params.stdout.write(text.slice(state.printedLen));
state.printedLen = text.length;
}
// Stream thinking content in real-time
if (reasoningMode === "stream") {
const thinking = extractThinking(msg);
if (thinking.length > state.printedThinkingLen) {
params.stderr.write(dim(thinking.slice(state.printedThinkingLen)));
state.printedThinkingLen = thinking.length;
}
}
}
break;
}
case "message_end": {
const msg = event.message;
if (msg.role === "assistant") {
const text = extractText(msg);
if (text.length > state.printedLen) {
params.stdout.write(text.slice(state.printedLen));
state.printedLen = text.length;
}
if (state.streaming) params.stdout.write("\n");
state.streaming = false;
state.lastAssistantText = text;
// Extract and store thinking content (skip when off)
const thinking = reasoningMode !== "off" ? extractThinking(msg) : "";
state.lastAssistantThinking = thinking;
// Show thinking at end for "on" mode
if (reasoningMode === "on" && thinking) {
params.stderr.write(`\n${dim("--- Thinking ---")}\n`);
params.stderr.write(dim(thinking));
params.stderr.write(`\n${dim("--- End Thinking ---")}\n`);
}
// Finish streaming thinking with a newline
if (reasoningMode === "stream" && state.printedThinkingLen > 0) {
params.stderr.write("\n");
}
}
break;
}
case "tool_execution_start": {
pendingToolName = event.toolName;
pendingToolArgs = event.args;
const title = colors.toolName(toolDisplayName(event.toolName));
const argText = formatToolArgs(event.toolName, event.args);
const displayText = argText ? `${title} ${colors.toolArgs(`(${argText})`)}` : title;
spinner.start(displayText);
break;
}
case "tool_execution_update": {
// Show real-time output updates (e.g., from exec tool)
const updateText = extractText(event.partialResult);
if (updateText && pendingToolName) {
const title = colors.toolName(toolDisplayName(pendingToolName));
const preview = colors.toolArgs(updateText.slice(-50).replace(/\n/g, " "));
spinner.update(`${title} ${colors.toolArrow("→")} ${preview}`);
}
break;
}
case "tool_execution_end": {
// Stop spinner and show final result with summary
const details = extractResultDetails(event.result);
const errorField = details?.error;
const hasError =
event.isError ||
Boolean(errorField) ||
details?.success === false;
if (hasError) {
const errorText =
(typeof details?.message === "string" && details.message) ||
(typeof errorField === "string" && errorField) ||
extractText(event.result) ||
"Tool failed";
const bullet = colors.toolError("✗");
const title = colors.toolName(toolDisplayName(event.toolName));
spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`);
} else {
spinner.stop(formatToolLine(event.toolName, pendingToolArgs, event.result));
}
pendingToolName = "";
pendingToolArgs = null;
break;
}
default:
break;
}
};
return { state, handleEvent };
}