Files
oh-my-pi/packages/coding-agent/src/modes/utils/transcript-render-helpers.ts
T
can1357 5ff277349c refactor(coding-agent): consolidated tool surface onto xd:// devices and hub
- Added the `xd://` virtual device protocol (`internal-urls/xd-protocol.ts`, `tools/xdev.ts`): tools declaring `loadMode: "discoverable"` are unmounted from the request tools array and driven via `read xd://` (list/docs+schema) and `write xd://<tool>` (execute), gated by the `tools.xdev` setting (default on) and inlined into the system prompt.
- Merged the `irc`, `job`, and `launch` tools into a single `hub` tool (`tools/hub/`, `async/job-manager.ts`): messaging keeps `send`/`inbox`/`list`, job control maps to `wait`/`cancel`/`jobs`, process supervision keeps `start`/`logs`/`stop`/`restart`/`describe` with `ps`, and the unified `wait` races background jobs against peer messages; SDK `IrcTool`/`JobTool`/`LaunchTool` are replaced by `HubTool`.
- Removed the hidden `resolve` tool in favor of the `xd://resolve`/`xd://reject`/`xd://propose` resolution devices, auto-including `write` whenever a deferrable tool or plan mode is present.
- Removed the BM25 tool-discovery system: the `search_tool_bm25` tool, the `tool-discovery` module, the `tools.discoveryMode`/`mcp.discoveryMode`/`mcp.discoveryDefaultServers`/`tools.essentialOverride` settings, per-tool MCP selection, and the `mcp_tool_selection` message type.
- Unified tool presentation on `ToolLoadMode` (`essential`|`discoverable`), replacing the custom-tool `xdev?: boolean` opt-out; custom, extension, MCP, RPC host, image-generation, and TTS tools now default to `discoverable`, and added a `satisfies` predicate to `SoftToolRequirement`.
- Removed the standalone `ssh` command tool and `ssh/ssh-executor` (the `ssh://` read/write/search protocol stays), and made `--tools` address hidden built-ins.
- Updated collab-web to render `xd://` dispatches and `hub` op families, dropped the `search_tool_bm25`/`ssh`/`report-finding` renderers, refreshed tool docs and prompts, and migrated the affected tests and changelogs.
2026-07-15 15:16:29 +02:00

253 lines
8.8 KiB
TypeScript

/**
* Render helpers shared between the live transcript ({@link UiHelpers}) and the
* file/remote-backed {@link ChatTranscriptBuilder}. Both surfaces build the same
* transcript rows from persisted message entries; holding the row construction
* here keeps the two byte-for-byte identical.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { type Component, Text } from "@oh-my-pi/pi-tui";
import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils";
import {
type CustomMessage,
type FileMentionMessage,
resolveAbortLabel,
shouldRenderAbortReason,
} from "../../session/messages";
import { createIrcMessageCard } from "../../tools/hub";
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
import { canonicalizeMessage } from "../../utils/thinking-display";
import { TranscriptBlock } from "../components/transcript-container";
import { theme } from "../theme/theme";
type CustomOrHookMessage = Extract<AgentMessage, { role: "custom" | "hookMessage" }>;
type AssistantAgentMessage = Extract<AgentMessage, { role: "assistant" }>;
/**
* Render an `async-result` custom message (a completed background bash/task job,
* or a batch of them) as a transcript block of one "Background job completed"
* row per job.
*/
export function buildAsyncResultBlock(message: CustomOrHookMessage): TranscriptBlock {
const details = (
message as CustomMessage<{
jobId?: string;
type?: "bash" | "task";
label?: string;
durationMs?: number;
jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
}>
).details;
const jobs =
details?.jobs && details.jobs.length > 0
? details.jobs
: [
{
jobId: details?.jobId,
type: details?.type,
label: details?.label,
durationMs: details?.durationMs,
},
];
const block = new TranscriptBlock();
for (const job of jobs) {
const jobId = job.jobId ?? "unknown";
const typeLabel = job.type ? `[${job.type}]` : "[job]";
const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
const line = [
theme.fg("success", `${theme.status.done} Background job completed`),
theme.fg("dim", typeLabel),
theme.fg("accent", jobId),
duration ? theme.fg("dim", `(${duration})`) : undefined,
]
.filter(Boolean)
.join(" ");
block.addChild(new Text(line, 1, 0));
}
return block;
}
/**
* Render a live IRC traffic custom message (`irc:incoming` / `irc:autoreply` /
* `irc:relay`) as a transcript card. `getExpanded` supplies the live
* expanded-state getter for the cached card.
*/
export function buildIrcMessageCard(message: CustomOrHookMessage, getExpanded: () => boolean): Component {
const details = (
message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
).details;
const kind =
message.customType === "irc:incoming"
? ("incoming" as const)
: message.customType === "irc:autoreply"
? ("autoreply" as const)
: ("relay" as const);
return createIrcMessageCard(
{
kind,
from: details?.from,
to: details?.to,
body: kind === "incoming" ? details?.message : details?.body,
replyTo: details?.replyTo,
timestamp: message.timestamp,
},
getExpanded,
theme,
);
}
/**
* Render a `fileMention` message's files as a transcript block of "Read <path>"
* rows. `indent` sets the left pad: the live chat renders within an outer gutter
* (0), the transcript viewer renders body rows without one so rows own their pad
* (1).
*/
export function buildFileMentionBlock(files: FileMentionMessage["files"], indent: number): TranscriptBlock {
const block = new TranscriptBlock();
for (const file of files) {
let suffix: string;
if (file.skippedReason === "tooLarge" || file.skippedReason === "binary") {
const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
suffix = file.skippedReason === "binary" ? `(skipped: binary, ${size})` : `(skipped: ${size})`;
} else {
suffix = file.image
? "(image)"
: file.lineCount === undefined
? "(unknown lines)"
: `(${file.lineCount} lines)`;
}
const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
"accent",
file.path,
)} ${theme.fg("dim", suffix)}`;
block.addChild(new Text(text, indent, 0));
}
return block;
}
/**
* Whether an assistant turn has visible text or thinking content (after
* canonicalization) — i.e. content that closes the current read-tool run.
*/
export function assistantHasVisibleContent(message: AssistantAgentMessage): boolean {
return message.content.some(
content =>
(content.type === "text" && canonicalizeMessage(content.text)) ||
(content.type === "thinking" && canonicalizeMessage(content.thinking)),
);
}
/**
* Split mixed assistant turns into visible text before tool execution and
* visible text segments that must render immediately after the preceding tool.
* Cursor can return intro text, tool calls, progress text, and the final answer
* in one assistant message; keeping every text block in the leading assistant
* block buries post-tool text above tool results in the transcript.
*/
export function splitAssistantMessageToolTimeline(message: AssistantAgentMessage): {
beforeTools: AssistantAgentMessage;
afterToolCalls: ReadonlyMap<string, AssistantAgentMessage>;
hasToolCalls: boolean;
} {
const beforeTools: AssistantAgentMessage["content"] = [];
const afterToolCalls = new Map<string, AssistantAgentMessage>();
let pendingAfterTool: AssistantAgentMessage["content"] = [];
let lastToolCallId: string | undefined;
let sawToolCall = false;
const displaySegment = (content: AssistantAgentMessage["content"]): AssistantAgentMessage => ({
...message,
content,
stopReason: "stop",
errorMessage: undefined,
retryRecovery: undefined,
});
const flushPendingAfterTool = () => {
if (!lastToolCallId || pendingAfterTool.length === 0) return;
afterToolCalls.set(lastToolCallId, displaySegment(pendingAfterTool));
pendingAfterTool = [];
};
for (const content of message.content) {
if (content.type === "toolCall") {
flushPendingAfterTool();
sawToolCall = true;
lastToolCallId = content.id;
continue;
}
if (sawToolCall) {
pendingAfterTool.push(content);
} else {
beforeTools.push(content);
}
}
flushPendingAfterTool();
if (!sawToolCall) {
return { beforeTools: message, afterToolCalls, hasToolCalls: false };
}
return { beforeTools: displaySegment(beforeTools), afterToolCalls, hasToolCalls: true };
}
/**
* Normalize raw tool-call arguments to a plain record, collapsing non-object or
* array values to an empty object.
*/
export function normalizeToolArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
}
export type AssistantErrorPresentation =
| { kind: "none" }
| { kind: "full"; text: string; isError: true }
| { kind: "compact-recovered"; text: string; isError: false };
function sanitizeRecoveredRetryNote(note: string): string {
const normalized = replaceTabs(note).replace(/\s+/g, " ").trim();
return truncateToWidth(normalized || "retried", TRUNCATE_LENGTHS.CONTENT);
}
/**
* Resolve the turn-ending assistant error presentation, if any.
* Silent and user-interrupt aborts yield no label. Recovered auto-retry errors
* collapse to a single non-error note; terminal errors keep the full red presentation.
*/
export function resolveAssistantErrorPresentation(
message: AssistantAgentMessage,
retryAttempt = 0,
): AssistantErrorPresentation {
if (message.retryRecovery?.status === "recovered") {
return {
kind: "compact-recovered",
text: sanitizeRecoveredRetryNote(message.retryRecovery.note),
isError: false,
};
}
if (message.stopReason === "aborted") {
if (!shouldRenderAbortReason(message)) return { kind: "none" };
return { kind: "full", text: resolveAbortLabel(message, retryAttempt), isError: true };
}
if (message.stopReason === "error") {
return { kind: "full", text: message.errorMessage || "Error", isError: true };
}
if (message.errorMessage && shouldRenderAbortReason(message)) {
return { kind: "full", text: message.errorMessage, isError: true };
}
return { kind: "none" };
}
/**
* Whether an assistant turn's `usage` reflects work the operator was billed
* for. Empty automated turns from providers that emit `usage: 0` collapse to
* `false`, but any input, output, cache, or premium request keeps the row so
* cost transparency survives — the live path and the resume/rebuild path
* agree turn-by-turn.
*/
export function assistantUsageIsBilled(usage: AssistantAgentMessage["usage"]): boolean {
if (usage.input > 0 || usage.output > 0) return true;
if (usage.cacheRead > 0 || usage.cacheWrite > 0) return true;
if ((usage.premiumRequests ?? 0) > 0) return true;
return false;
}