- 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.
253 lines
8.8 KiB
TypeScript
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;
|
|
}
|