Merge remote-tracking branch 'can1357/main' into fix/empty-stop-guard-tooluse

This commit is contained in:
DarkPhilosophy
2026-06-07 15:04:44 +03:00
545 changed files with 26429 additions and 8342 deletions
@@ -15,6 +15,7 @@
*/
import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { resolveRoleSelection } from "../config/model-resolver";
import type { Settings } from "../config/settings";
@@ -82,7 +83,10 @@ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Prom
messages: [{ role: "user", content: input, timestamp: Date.now() }],
},
{
apiKey,
apiKey: deps.registry.resolver(model.provider, {
sessionId: deps.sessionId,
baseUrl: model.baseUrl,
}),
maxTokens,
disableReasoning: true,
metadata,
@@ -1,4 +1,4 @@
import { matchesKey, replaceTabs, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { matchesKey, replaceTabs, ScrollView, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import type { Theme } from "../modes/theme/theme";
import { formatElapsed, formatNum, isBetter } from "./helpers";
import { currentResults, findBaselineMetric, findBaselineRunNumber, findBaselineSecondary } from "./state";
@@ -76,14 +76,14 @@ export function createDashboardController(): DashboardController {
const viewportRows = Math.max(4, terminalRows - 4);
const maxScroll = Math.max(0, body.length - viewportRows);
if (scrollOffset > maxScroll) scrollOffset = maxScroll;
const visible = body.slice(scrollOffset, scrollOffset + viewportRows);
const footer = renderOverlayFooter(width, scrollOffset, viewportRows, body.length, theme);
return [
header,
...visible,
...Array.from({ length: Math.max(0, viewportRows - visible.length) }, () => ""),
footer,
];
const sv = new ScrollView(body.slice(scrollOffset, scrollOffset + viewportRows), {
height: viewportRows,
scrollbar: "auto",
totalRows: body.length,
theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(scrollOffset);
return [header, ...sv.render(width), renderOverlayFooter(width, theme)];
},
handleInput(data: string): void {
const totalRows =
@@ -406,18 +406,8 @@ function renderOverlayRunningLine(
);
}
function renderOverlayFooter(
width: number,
scrollOffset: number,
viewportRows: number,
totalRows: number,
theme: Theme,
): string {
const position =
totalRows > viewportRows
? ` ${scrollOffset + 1}-${Math.min(totalRows, scrollOffset + viewportRows)}/${totalRows}`
: "";
const hint = theme.fg("dim", ` up/down j/k pageup pagedown g G esc${position} `);
function renderOverlayFooter(width: number, theme: Theme): string {
const hint = theme.fg("dim", " up/down j/k pageup pagedown g G esc ");
const fill = Math.max(0, width - visibleWidth(hint));
return theme.fg("borderMuted", "-".repeat(fill)) + hint;
}
@@ -22,6 +22,7 @@ export const commands: CommandEntry[] = [
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
{ name: "dry-balance", load: () => import("./commands/dry-balance").then(m => m.default) },
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
{ name: "gallery", load: () => import("./commands/gallery").then(m => m.default) },
{ name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
{ name: "install", load: () => import("./commands/install").then(m => m.default) },
{ name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* CLI argument parsing and help display
*/
import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai/effort";
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { parseEffort } from "../thinking";
@@ -284,7 +284,7 @@ export function getExtraHelpText(): string {
${chalk.dim("# Search & Tools")}
EXA_API_KEY - Exa web search
BRAVE_API_KEY - Brave web search
PERPLEXITY_API_KEY - Perplexity web search (API)
PERPLEXITY_API_KEY - Perplexity web search API key (optional; anonymous fallback)
PERPLEXITY_COOKIES - Perplexity web search (session cookie)
TAVILY_API_KEY - Tavily web search
ANTHROPIC_SEARCH_API_KEY - Anthropic web search (override; isolates search from main ANTHROPIC_API_KEY)
@@ -380,6 +380,18 @@ function isMessagesRequest(message: ParsedHttpMessage): boolean {
return pathNameFromRequestTarget(message.path ?? "") === "/v1/messages";
}
// Claude Code fires a background warmup/classification call on its small fast
// model (a haiku variant, ANTHROPIC_SMALL_FAST_MODEL) before sending the user's
// real message. Skip it so the capture lands on the actual prompt.
function isBackgroundModelRequest(message: ParsedHttpMessage): boolean {
try {
const parsed = JSON.parse(decodeBody(message.headers, message.body)) as { model?: unknown };
return typeof parsed.model === "string" && parsed.model.toLowerCase().includes("haiku");
} catch {
return false;
}
}
function decodeBody(headers: readonly HeaderEntry[], body: Buffer): string {
const encoding = headerValue(headers, "content-encoding")?.toLowerCase().trim();
try {
@@ -636,7 +648,7 @@ export class ClaudeMessagesProxy {
upstreamTls.write(data);
const messages = requestParser.push(data);
for (const message of messages) {
if (!isMessagesRequest(message)) {
if (!isMessagesRequest(message) || isBackgroundModelRequest(message)) {
responseQueue.push(null);
continue;
}
@@ -1,8 +1,10 @@
import type {
Api,
ApiKeyResolver,
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
AuthCredentialSnapshotEntry,
Context,
Model,
OAuthAccess,
@@ -59,6 +61,12 @@ export interface DryBalanceAuthStorage {
options?: DryBalanceAuthOptions,
): Promise<OAuthAccess | undefined>;
getOAuthAccesses?(provider: string, options?: DryBalanceAuthOptions): Promise<OAuthAccessResolution[]>;
/**
* Force-refresh a single credential by id (step (b) of the auth-retry
* policy). The bench re-mints the failing account's token in place on a
* 401 rather than rotating accounts — it is measuring each account.
*/
forceRefreshCredentialById?(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry>;
}
export interface DryBalanceModelRegistry {
@@ -152,6 +160,8 @@ export interface DryBalanceDependencies {
now?: () => number;
stdoutIsTTY?: boolean;
stderrIsTTY?: boolean;
stdoutColumns?: number;
stderrColumns?: number;
}
type DryBalanceAttemptResult =
@@ -181,6 +191,7 @@ type DryBalanceBenchTarget =
ok: true;
account: string;
accessToken: string;
credentialId?: number;
}
| {
ok: false;
@@ -310,10 +321,11 @@ function renderBenchStatusLine(
}
}
function createBenchProgressSink(
export function createBenchProgressSink(
total: number,
write: (text: string) => void,
interactive: boolean,
columns: number,
): DryBalanceBenchProgressSink {
const statuses: DryBalanceBenchProgressStatus[] = Array.from({ length: total }, () => ({ state: "waiting" }));
if (!interactive) {
@@ -333,13 +345,21 @@ function createBenchProgressSink(
let frame = 0;
let lineCount = 0;
let timer: NodeJS.Timeout | undefined;
const width = Number.isFinite(columns) && columns > 0 ? Math.trunc(columns) : 80;
const render = (): void => {
const lines = [
chalk.bold("bench requests"),
...statuses.map((status, index) => renderBenchStatusLine(status, index, total, frame)),
];
if (lineCount > 0) write(`\x1b[${lineCount}A`);
write(`${lines.map(line => `\x1b[2K${line}`).join("\n")}\n`);
// Anchor every redraw at column 0 and terminate each row with CRLF: a
// bare `\n` only returns to column 0 when the tty performs ONLCR
// translation, which is off whenever the terminal is in raw mode — there
// the old column-preserving cursor-up staircased each frame into
// scrollback. Cap each line to the terminal width so a wrapped row never
// desyncs the `\x1b[<n>A` cursor-up from the logical line count.
const move = lineCount > 0 ? `\x1b[${lineCount}A` : "";
const body = lines.map(line => `\x1b[2K${truncateToWidth(line, width)}`).join("\r\n");
write(`${move}\r${body}\r\n`);
lineCount = lines.length;
};
render();
@@ -370,13 +390,23 @@ function createBenchProgressSink(
async function runBenchRequest(
model: Model<Api>,
sessionId: string,
account: string,
accessToken: string,
target: Extract<DryBalanceBenchTarget, { ok: true }>,
authStorage: DryBalanceAuthStorage,
streamFn: DryBalanceStreamSimple,
now: () => number,
): Promise<DryBalanceBenchResult> {
const { account, accessToken, credentialId } = target;
const startedAt = now();
let firstTokenAt: number | undefined;
// Re-mint the cached token on a 401: a peer/broker may have rotated it out
// from under our snapshot (Anthropic rotates refresh tokens on every use).
// The bench measures one account, so the switch step intentionally declines.
const apiKey: ApiKeyResolver = async ({ lastChance, error }) => {
if (error === undefined) return accessToken;
if (lastChance || credentialId === undefined || !authStorage.forceRefreshCredentialById) return undefined;
const refreshed = await authStorage.forceRefreshCredentialById(credentialId);
return refreshed.credential.type === "oauth" ? refreshed.credential.access : undefined;
};
try {
const context: Context = {
messages: [
@@ -389,7 +419,7 @@ async function runBenchRequest(
],
};
const stream = streamFn(model, context, {
apiKey: accessToken,
apiKey,
sessionId,
maxTokens: resolveBenchMaxTokens(model),
temperature: 0.2,
@@ -454,7 +484,7 @@ async function resolveBenchTargets(
seen.add(key);
const account = extractAccount(entry);
if (entry.ok) {
targets.push({ ok: true, account, accessToken: entry.accessToken });
targets.push({ ok: true, account, accessToken: entry.accessToken, credentialId: entry.credentialId });
} else {
targets.push({ ok: false, account, error: entry.error });
}
@@ -465,6 +495,7 @@ async function resolveBenchTargets(
async function runBenchTargets(
model: Model<Api>,
targets: DryBalanceBenchTarget[],
authStorage: DryBalanceAuthStorage,
randomSessionId: () => string,
progress: DryBalanceBenchProgressSink | undefined,
streamFn: DryBalanceStreamSimple,
@@ -482,14 +513,7 @@ async function runBenchTargets(
return result;
}
progress?.markRunning(index, target.account);
const result = await runBenchRequest(
model,
randomSessionId(),
target.account,
target.accessToken,
streamFn,
now,
);
const result = await runBenchRequest(model, randomSessionId(), target, authStorage, streamFn, now);
progress?.complete(index, result);
return result;
}),
@@ -792,8 +816,19 @@ export async function runDryBalanceCommand(
const progressInteractive = command.flags.json
? (deps.stderrIsTTY ?? process.stderr.isTTY === true)
: (deps.stdoutIsTTY ?? process.stdout.isTTY === true);
progress = createBenchProgressSink(targets.length, progressWrite, progressInteractive);
benchResults = await runBenchTargets(model, targets, randomSessionId, progress, streamFn, now);
const progressColumns = command.flags.json
? (deps.stderrColumns ?? process.stderr.columns ?? 80)
: (deps.stdoutColumns ?? process.stdout.columns ?? 80);
progress = createBenchProgressSink(targets.length, progressWrite, progressInteractive, progressColumns);
benchResults = await runBenchTargets(
model,
targets,
runtime.modelRegistry.authStorage,
randomSessionId,
progress,
streamFn,
now,
);
results = targets.map(target =>
target.ok ? { ok: true, account: target.account } : { ok: false, reason: target.error },
);
@@ -0,0 +1,226 @@
/**
* `omp gallery` — render every built-in tool's renderer across its lifecycle.
*
* For each tool with a registered renderer, the gallery drives a real
* {@link ToolExecutionComponent} through four states — streaming arguments,
* arguments complete (in progress), success, and failure — and prints the
* rendered output to stdout. It exists for visual QA of tool renderers without
* having to provoke each state through a live agent session.
*/
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { TUI } from "@oh-my-pi/pi-tui";
import { getProjectDir } from "@oh-my-pi/pi-utils";
import { Settings } from "../config/settings";
import { ToolExecutionComponent } from "../modes/components/tool-execution";
import { initTheme, theme } from "../modes/theme/theme";
import { toolRenderers } from "../tools/renderers";
import { type GalleryFixture, type GalleryResult, galleryFixtures } from "./gallery-fixtures";
import { captureGalleryScreenshots } from "./gallery-screenshot";
/** Lifecycle states the gallery renders, in display order. */
export const GALLERY_STATES = ["streaming", "progress", "success", "error"] as const;
export type GalleryState = (typeof GALLERY_STATES)[number];
const STATE_LABELS: Record<GalleryState, string> = {
streaming: "streaming args",
progress: "in progress",
success: "done",
error: "failed",
};
export interface GalleryCommandArgs {
/** Render width in columns (defaults to terminal width, clamped). */
width?: number;
/** Restrict to a single tool name. */
tool?: string;
/** Restrict to specific lifecycle states. */
states?: GalleryState[];
/** Render the expanded variant of each renderer. */
expanded?: boolean;
/** Strip ANSI styling from the output (useful when redirecting to a file). */
plain?: boolean;
/** Capture the rendered gallery as PNG screenshot(s) via VHS instead of printing ANSI. */
screenshot?: boolean;
/** Screenshot output path (single image) or base path (suffixed when split across images). */
out?: string;
/** Font family for screenshots (must be installed; Nerd Font recommended for icon glyphs). */
font?: string;
/** Font size in points for screenshots. */
fontSize?: number;
}
/** One tool's rendered lifecycle, as ANSI lines: a leading blank, the section rule, then each state. */
export interface GallerySection {
heading: string;
lines: string[];
}
const GENERIC_ERROR: GalleryResult = {
content: [{ type: "text", text: "Error: operation failed" }],
isError: true,
};
/**
* Build the fake `AgentTool` the component needs for its label, edit mode, and —
* for `customRendered` fixtures — the renderer functions that route it through
* the same custom-tool branch production uses (see {@link GalleryFixture}).
*/
function fakeToolFor(name: string, fixture: GalleryFixture | undefined): AgentTool | undefined {
if (!fixture?.label && !fixture?.editMode && !fixture?.customRendered) return undefined;
const tool: Record<string, unknown> = { name, label: fixture.label ?? name, mode: fixture.editMode };
if (fixture.customRendered) {
const renderer = toolRenderers[name] as
| { renderCall?: unknown; renderResult?: unknown; mergeCallAndResult?: unknown; inline?: unknown }
| undefined;
if (renderer) {
tool.renderCall = renderer.renderCall;
tool.renderResult = renderer.renderResult;
tool.mergeCallAndResult = renderer.mergeCallAndResult;
tool.inline = renderer.inline;
}
}
return tool as unknown as AgentTool;
}
/** The curated fixture for a tool, or a generic one for registry tools lacking sample data. */
export function resolveFixture(name: string): GalleryFixture {
return (
galleryFixtures[name] ??
({
args: { note: `sample ${name} call` },
result: { content: [{ type: "text", text: `${name} completed` }] },
} satisfies GalleryFixture)
);
}
/**
* Render a single tool/state pair to lines. Builds a fresh component, drives it
* to the requested state, settles any async edit preview, then snapshots the
* render and stops all animation timers.
*/
export async function renderGalleryState(
name: string,
fixture: GalleryFixture,
state: GalleryState,
width: number,
expanded = false,
): Promise<string[]> {
const tool = fakeToolFor(name, fixture);
const streamingArgs = state === "streaming" ? (fixture.streamingArgs ?? fixture.args) : fixture.args;
// The component only calls `requestRender` during a static render;
// `imageBudget` is consulted solely when images render, which the gallery
// disables. A cast avoids constructing a real terminal.
const ui = { requestRender() {} } as unknown as TUI;
const component = new ToolExecutionComponent(name, streamingArgs, { showImages: false }, tool, ui, getProjectDir());
component.setExpanded(expanded);
if (state !== "streaming") {
component.setArgsComplete();
}
if (state === "success") {
component.updateResult(fixture.result, false);
} else if (state === "error") {
component.updateResult(fixture.errorResult ?? GENERIC_ERROR, false);
}
// Edit-like renderers compute their diff preview off the render path; wait
// for it to settle so the snapshot is deterministic instead of racing a tick.
await component.whenPreviewSettled();
const lines = component.render(width);
component.stopAnimation();
return lines;
}
function resolveWidth(requested: number | undefined): number {
const fallback = process.stdout.columns ?? 100;
const width = requested ?? fallback;
return Math.max(40, Math.min(200, width));
}
function sectionRule(label: string, width: number): string {
const prefix = `── ${label} `;
const fill = Math.max(0, width - prefix.length);
return theme.fg("accent", theme.bold(`${prefix}${"─".repeat(fill)}`));
}
/**
* Render each requested tool's lifecycle into ANSI section blocks. The block
* layout (leading blank, section rule, then a blank + dim label + body per
* state) is shared by the stdout and screenshot paths so both stay identical.
*/
async function renderGallerySections(
names: string[],
states: GalleryState[],
width: number,
expanded: boolean,
): Promise<GallerySection[]> {
const sections: GallerySection[] = [];
for (const name of names) {
const fixture = resolveFixture(name);
const heading = fixture.label && fixture.label !== name ? `${name} — ${fixture.label}` : name;
const lines: string[] = ["", sectionRule(heading, width)];
for (const state of states) {
lines.push("", theme.fg("dim", ` · ${STATE_LABELS[state]}`));
try {
for (const line of await renderGalleryState(name, fixture, state, width, expanded)) lines.push(line);
} catch (err) {
lines.push(theme.fg("error", ` render failed: ${String(err)}`));
}
}
sections.push({ heading, lines });
}
return sections;
}
/**
* Render the gallery. Iterates the renderer registry (or a single tool),
* printing each requested lifecycle state under a labeled section — or, with
* `screenshot`, capturing the rendered output as PNG(s) via VHS.
*/
export async function runGalleryCommand(args: GalleryCommandArgs): Promise<void> {
const settingsInstance = await Settings.init();
// Screenshots must carry exact theme RGB regardless of how the invoking
// terminal advertises its color support, so force truecolor before the theme
// (and therefore every SGR escape it emits) is built.
if (args.screenshot) process.env.COLORTERM = "truecolor";
await initTheme(
false,
settingsInstance.get("symbolPreset"),
settingsInstance.get("colorBlindMode"),
settingsInstance.get("theme.dark"),
settingsInstance.get("theme.light"),
);
const width = resolveWidth(args.width);
const expanded = args.expanded ?? false;
const states = args.states && args.states.length > 0 ? args.states : [...GALLERY_STATES];
// Renderer-registry tools plus fixture-only tools (no dedicated renderer,
// e.g. `report_tool_issue` / custom extension tools) so the gallery covers
// the generic fallback + custom-tool branches too.
const allNames = Array.from(new Set([...Object.keys(toolRenderers), ...Object.keys(galleryFixtures)])).sort();
const names = args.tool ? allNames.filter(name => name === args.tool) : allNames;
if (args.tool && names.length === 0) {
process.stdout.write(`Unknown tool '${args.tool}'. Known tools: ${allNames.join(", ")}\n`);
return;
}
const sections = await renderGallerySections(names, states, width, expanded);
if (args.screenshot) {
const paths = await captureGalleryScreenshots(sections, {
width,
font: args.font,
fontSize: args.fontSize,
out: args.out,
});
process.stdout.write(`${paths.join("\n")}\n`);
return;
}
const lines = sections.flatMap(section => section.lines);
lines.push("");
const text = lines.map(line => (args.plain ? Bun.stripANSI(line) : line)).join("\n");
process.stdout.write(`${text}\n`);
}
@@ -0,0 +1,292 @@
// Gallery fixtures for the agentic orchestration tools (task, goal, job).
import type { GalleryFixture } from "./types";
export const agenticFixtures: Record<string, GalleryFixture> = {
task: {
label: "Task",
customRendered: true,
// Streaming: agent chosen, first task fully arrived, second still landing.
streamingArgs: {
agent: "task",
tasks: [
{
id: "AuthLoader",
description: "Load auth middleware",
assignment: "Read packages/server/src/auth/*.ts and summarize the session-cookie flow.",
},
{ id: "RateLimiter", description: "Audit rate limiter" },
],
},
args: {
agent: "task",
context: [
"# Goal",
"Harden the HTTP auth stack before the release cut.",
"# Constraints",
"Touch only files under packages/server/src/auth/. Do not run gates.",
].join("\n"),
tasks: [
{
id: "AuthLoader",
description: "Load auth middleware",
assignment:
"Read packages/server/src/auth/session.ts and middleware.ts, then document the session-cookie validation flow and any TODOs.",
},
{
id: "RateLimiter",
description: "Audit rate limiter",
assignment:
"Inspect packages/server/src/auth/rate-limit.ts. Confirm the 429 path sets Retry-After and report gaps.",
},
{
id: "TokenRotation",
description: "Check token rotation",
assignment:
"Trace refresh-token rotation in packages/server/src/auth/tokens.ts and flag any reuse window.",
},
],
},
result: {
content: [
{
type: "text",
text: "3 agents completed: AuthLoader, RateLimiter, TokenRotation.",
},
],
details: {
projectAgentsDir: null,
totalDurationMs: 48_200,
usage: { cost: { total: 0.34 } },
results: [
{
index: 0,
id: "AuthLoader",
agent: "task",
agentSource: "bundled",
description: "Load auth middleware",
task: "Read packages/server/src/auth/session.ts and middleware.ts",
assignment:
"Read packages/server/src/auth/session.ts and middleware.ts, then document the session-cookie validation flow and any TODOs.",
exitCode: 0,
output: [
"Session validation runs in middleware.ts:42 via verifySessionCookie().",
"Cookies are HMAC-signed (SHA-256) and checked against the session store.",
"TODO at session.ts:88 — sliding-expiration refresh is stubbed.",
].join("\n"),
stderr: "",
truncated: false,
durationMs: 41_900,
tokens: 61_400,
contextTokens: 23_100,
contextWindow: 200_000,
resolvedModel: "anthropic/claude-sonnet",
usage: { cost: { total: 0.12 } },
outputMeta: { lineCount: 3, charCount: 214 },
},
{
index: 1,
id: "RateLimiter",
agent: "task",
agentSource: "bundled",
description: "Audit rate limiter",
task: "Inspect packages/server/src/auth/rate-limit.ts",
assignment:
"Inspect packages/server/src/auth/rate-limit.ts. Confirm the 429 path sets Retry-After and report gaps.",
exitCode: 0,
output: [
"rate-limit.ts uses a fixed-window counter keyed by client IP.",
"429 responses set Retry-After (rate-limit.ts:57).",
"Gap: no per-account limit, so a botnet across IPs bypasses the cap.",
].join("\n"),
stderr: "",
truncated: false,
durationMs: 38_500,
tokens: 54_800,
contextTokens: 19_700,
contextWindow: 200_000,
resolvedModel: "anthropic/claude-sonnet",
usage: { cost: { total: 0.1 } },
outputMeta: { lineCount: 3, charCount: 198 },
},
{
index: 2,
id: "TokenRotation",
agent: "task",
agentSource: "bundled",
description: "Check token rotation",
task: "Trace refresh-token rotation in packages/server/src/auth/tokens.ts",
assignment:
"Trace refresh-token rotation in packages/server/src/auth/tokens.ts and flag any reuse window.",
exitCode: 0,
output: [
"Refresh tokens rotate on every use (tokens.ts:120) and the old jti is revoked.",
"Reuse of a rotated token triggers full-family revocation — no reuse window found.",
].join("\n"),
stderr: "",
truncated: false,
durationMs: 48_200,
tokens: 49_200,
contextTokens: 17_500,
contextWindow: 200_000,
resolvedModel: "anthropic/claude-sonnet",
usage: { cost: { total: 0.12 } },
outputMeta: { lineCount: 2, charCount: 160 },
},
],
},
},
errorResult: {
isError: true,
content: [
{
type: "text",
text: "1 of 3 agents failed: RateLimiter.",
},
],
details: {
projectAgentsDir: null,
totalDurationMs: 39_400,
usage: { cost: { total: 0.21 } },
results: [
{
index: 0,
id: "AuthLoader",
agent: "task",
agentSource: "bundled",
description: "Load auth middleware",
task: "Read packages/server/src/auth/session.ts and middleware.ts",
assignment:
"Read packages/server/src/auth/session.ts and middleware.ts, then document the session-cookie validation flow and any TODOs.",
exitCode: 0,
output: "Session validation runs in middleware.ts:42 via verifySessionCookie().",
stderr: "",
truncated: false,
durationMs: 31_200,
tokens: 58_100,
contextTokens: 21_900,
contextWindow: 200_000,
resolvedModel: "anthropic/claude-sonnet",
usage: { cost: { total: 0.11 } },
outputMeta: { lineCount: 1, charCount: 70 },
},
{
index: 1,
id: "RateLimiter",
agent: "task",
agentSource: "bundled",
description: "Audit rate limiter",
task: "Inspect packages/server/src/auth/rate-limit.ts",
assignment:
"Inspect packages/server/src/auth/rate-limit.ts. Confirm the 429 path sets Retry-After and report gaps.",
exitCode: 1,
output: "",
stderr: "ENOENT: packages/server/src/auth/rate-limit.ts",
truncated: false,
durationMs: 9_800,
tokens: 12_300,
contextTokens: 6_400,
contextWindow: 200_000,
resolvedModel: "anthropic/claude-sonnet",
usage: { cost: { total: 0.1 } },
error: "Subagent exited 1: target file packages/server/src/auth/rate-limit.ts does not exist.",
outputMeta: { lineCount: 0, charCount: 0 },
},
],
},
},
},
goal: {
label: "Goal",
// Streaming: op is "create"; objective text still being typed.
streamingArgs: { op: "create", objective: "Ship the auth hardening" },
args: {
op: "create",
objective: "Ship the auth hardening pass: per-account rate limits and sliding session expiry.",
token_budget: 500_000,
},
result: {
content: [
{
type: "text",
text: "Goal set. Working toward: Ship the auth hardening pass.",
},
],
details: {
op: "create",
remainingTokens: 451_800,
completionBudgetReport: null,
goal: {
id: "goal_8f2a",
objective: "Ship the auth hardening pass: per-account rate limits and sliding session expiry.",
status: "active",
tokenBudget: 500_000,
tokensUsed: 48_200,
timeUsedSeconds: 312,
createdAt: 1_749_200_000_000,
updatedAt: 1_749_200_312_000,
},
},
},
errorResult: {
isError: true,
content: [{ type: "text", text: "Goal tool failed: objective is required when op=create." }],
details: { op: "create" },
},
},
job: {
label: "Job",
// Streaming: polling a single job id; the second id is still arriving.
streamingArgs: { poll: ["job_a1"] },
args: { poll: ["job_a1", "job_b2", "job_c3"] },
result: {
content: [{ type: "text", text: "3 jobs settled." }],
details: {
jobs: [
{
id: "job_a1",
type: "bash",
status: "completed",
label: "bun test packages/server/test/auth.test.ts",
durationMs: 18_400,
resultText: "42 pass, 0 fail (18.4s)",
},
{
id: "job_b2",
type: "task",
status: "completed",
label: "Migrate rate limiter to a sliding window",
durationMs: 96_700,
resultText: "Rewrote rate-limit.ts to a token-bucket; added per-account keys.",
},
{
id: "job_c3",
type: "bash",
status: "failed",
label: "bunx biome check packages/server/src/auth",
durationMs: 4_100,
errorText: "biome: 2 errors in tokens.ts — noUnusedVariables, useConst",
},
],
},
},
errorResult: {
isError: true,
content: [{ type: "text", text: "Job cancelled by user." }],
details: {
jobs: [
{
id: "job_d4",
type: "task",
status: "cancelled",
label: "Refactor the session store to Redis",
durationMs: 52_300,
errorText: "Aborted: superseded by goal re-scope.",
},
],
cancelled: [{ id: "job_d4", status: "cancelled" }],
},
},
},
};
@@ -0,0 +1,188 @@
/** Gallery fixtures for the code-intelligence tools (lsp, debug). */
import type { GalleryFixture } from "./types";
export const codeintelFixtures: Record<string, GalleryFixture> = {
lsp: {
label: "LSP",
customRendered: true,
streamingArgs: {
action: "references",
file: "src/server/auth.ts",
},
args: {
action: "references",
file: "src/server/auth.ts",
line: 42,
symbol: "validateToken",
},
result: {
content: [
{
type: "text",
text: [
"Found 6 reference(s):",
" src/server/auth.ts:42:14",
" 41: ",
" 42: export function validateToken(token: string): Claims {",
" 43: const claims = verifyJwt(token);",
" src/server/auth.ts:118:21",
' 117: if (!header) throw new HttpError(401, "missing token");',
" 118: const claims = validateToken(stripBearer(header));",
" 119: return claims.sub;",
" src/server/middleware/session.ts:57:18",
" 56: const token = req.cookies.session;",
" 57: const claims = validateToken(token);",
" 58: req.userId = claims.sub;",
" src/server/router.ts:153:20",
" 152: router.use(async (req, res, next) => {",
" 153: req.claims = await validateToken(req.token);",
" 154: next();",
" test/auth.test.ts:24:9",
' 23: it("rejects expired tokens", () => {',
" 24: expect(() => validateToken(expired)).toThrow(/expired/);",
" 25: });",
" test/auth.test.ts:41:9",
' 40: it("accepts valid tokens", () => {',
" 41: const claims = validateToken(signed);",
' 42: expect(claims.sub).toBe("u_123");',
].join("\n"),
},
],
details: {
serverName: "typescript-language-server",
action: "references",
success: true,
request: {
action: "references",
file: "src/server/auth.ts",
line: 42,
symbol: "validateToken",
},
},
},
errorResult: {
content: [
{
type: "text",
text: "No language server found for this file",
},
],
isError: true,
details: {
serverName: "typescript-language-server",
action: "references",
success: false,
request: {
action: "references",
file: "src/server/auth.ts",
line: 42,
symbol: "validateToken",
},
},
},
},
debug: {
label: "Debug",
streamingArgs: {
action: "stack_trace",
},
args: {
action: "stack_trace",
levels: 20,
},
result: {
content: [
{
type: "text",
text: [
"Stack trace:",
"- #1000 validate_token @ app/server.py:42:14",
"- #1001 authenticate @ app/server.py:88:9",
"- #1002 handle_request @ app/router.py:153:20",
"- #1003 dispatch @ app/router.py:97:5",
"- #1004 <module> @ app/server.py:212:1",
].join("\n"),
},
],
details: {
action: "stack_trace",
success: true,
snapshot: {
id: "dbg-1",
adapter: "debugpy",
cwd: "/Users/dev/project",
program: "./app/server.py",
status: "stopped",
launchedAt: "2026-06-06T14:21:08.412Z",
lastUsedAt: "2026-06-06T14:22:55.901Z",
threadId: 1,
frameId: 1000,
stopReason: "breakpoint",
stopDescription: "breakpoint 2",
frameName: "validate_token",
instructionPointerReference: "0x00000001000034a8",
source: { name: "server.py", path: "app/server.py" },
line: 42,
column: 14,
breakpointFiles: 1,
breakpointCount: 2,
functionBreakpointCount: 0,
outputBytes: 248,
outputTruncated: false,
needsConfigurationDone: false,
},
stackFrames: [
{
id: 1000,
name: "validate_token",
source: { name: "server.py", path: "app/server.py" },
line: 42,
column: 14,
},
{
id: 1001,
name: "authenticate",
source: { name: "server.py", path: "app/server.py" },
line: 88,
column: 9,
},
{
id: 1002,
name: "handle_request",
source: { name: "router.py", path: "app/router.py" },
line: 153,
column: 20,
},
{
id: 1003,
name: "dispatch",
source: { name: "router.py", path: "app/router.py" },
line: 97,
column: 5,
},
{
id: 1004,
name: "<module>",
source: { name: "server.py", path: "app/server.py" },
line: 212,
column: 1,
},
],
},
},
errorResult: {
content: [
{
type: "text",
text: "No active debug session. Launch or attach first.",
},
],
isError: true,
details: {
action: "stack_trace",
success: false,
},
},
},
};
@@ -0,0 +1,194 @@
/** Gallery fixtures for the edit tools (edit, apply_patch, ast_edit). */
import type { GalleryFixture } from "./types";
export const editFixtures: Record<string, GalleryFixture> = {
edit: {
label: "Edit",
editMode: "replace",
// `previewDiff` is surfaced verbatim by the renderer's call preview, and the
// harness diff strategy skips `{ file_path, previewDiff }` (no `path`/`edits`),
// so the canned diff survives the streaming and progress states.
streamingArgs: {
file_path: "packages/coding-agent/src/tools/read.ts",
previewDiff: [
"@@ -88,3 +88,4 @@",
" const offset = args.offset ?? 1;",
"- const limit = args.limit ?? 2000;",
"+ const limit = args.limit ?? 4000;",
].join("\n"),
},
args: {
file_path: "packages/coding-agent/src/tools/read.ts",
previewDiff: [
"@@ -88,5 +88,6 @@",
" const offset = args.offset ?? 1;",
"- const limit = args.limit ?? 2000;",
"+ const limit = args.limit ?? 4000;",
" const raw = await Bun.file(path).text();",
"- return raw.slice(offset, offset + limit);",
'+ return raw.split("\\n").slice(offset - 1, offset - 1 + limit).join("\\n");',
].join("\n"),
},
result: {
content: [{ type: "text", text: "Edited packages/coding-agent/src/tools/read.ts (1 hunk, +3 -2)" }],
details: {
path: "packages/coding-agent/src/tools/read.ts",
firstChangedLine: 89,
diff: [
"@@ -88,5 +88,6 @@",
" const offset = args.offset ?? 1;",
"- const limit = args.limit ?? 2000;",
"+ const limit = args.limit ?? 4000;",
" const raw = await Bun.file(path).text();",
"- return raw.slice(offset, offset + limit);",
'+ return raw.split("\\n").slice(offset - 1, offset - 1 + limit).join("\\n");',
].join("\n"),
},
},
errorResult: {
content: [
{
type: "text",
text: "Edit failed: the search text was not found in packages/coding-agent/src/tools/read.ts",
},
],
isError: true,
details: {
path: "packages/coding-agent/src/tools/read.ts",
diff: "",
errorText:
"No match for the search text. Expected `const limit = args.limit ?? 2000;` near line 89, but the file has `const limit = args.limit ?? 1000;`. Re-read the file and retry with the current contents.",
},
},
},
apply_patch: {
label: "Apply Patch",
editMode: "apply_patch",
streamingArgs: {
file_path: "packages/coding-agent/src/edit/renderer.ts",
previewDiff: [
"@@ -464,2 +464,2 @@",
"- fileCount = countEditFiles(editArgs.edits);",
"+ fileCount = countDistinctFiles(editArgs.edits);",
].join("\n"),
},
args: {
file_path: "packages/coding-agent/src/edit/renderer.ts",
previewDiff: [
"@@ -177,4 +177,4 @@",
" /** Count distinct file paths in an edits array. */",
"-function countEditFiles(edits: EditRenderEntry[]): number {",
"+function countDistinctFiles(edits: EditRenderEntry[]): number {",
" return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;",
" }",
"@@ -467,2 +467,2 @@",
"- fileCount = countEditFiles(editArgs.edits);",
"+ fileCount = countDistinctFiles(editArgs.edits);",
].join("\n"),
},
result: {
content: [
{ type: "text", text: "Applied patch to packages/coding-agent/src/edit/renderer.ts (2 hunks, +2 -2)" },
],
details: {
op: "update",
path: "packages/coding-agent/src/edit/renderer.ts",
firstChangedLine: 178,
diff: [
"@@ -177,4 +177,4 @@",
" /** Count distinct file paths in an edits array. */",
"-function countEditFiles(edits: EditRenderEntry[]): number {",
"+function countDistinctFiles(edits: EditRenderEntry[]): number {",
" return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;",
" }",
"@@ -467,2 +467,2 @@",
"- fileCount = countEditFiles(editArgs.edits);",
"+ fileCount = countDistinctFiles(editArgs.edits);",
].join("\n"),
},
},
errorResult: {
content: [
{
type: "text",
text: "Apply patch failed: context does not match at line 177 of packages/coding-agent/src/edit/renderer.ts",
},
],
isError: true,
details: {
op: "update",
path: "packages/coding-agent/src/edit/renderer.ts",
diff: "",
errorText:
"Hunk @@ -177,4 +177,4 @@ failed to apply: the context line `function countEditFiles(edits: EditRenderEntry[]): number {` does not match the file. The file may have changed since it was read.",
},
},
},
ast_edit: {
label: "AST Edit",
streamingArgs: {
ops: [{ pat: "countEditFiles($$$ARGS)" }],
paths: ["packages/coding-agent/src/**/*.ts"],
},
args: {
ops: [{ pat: "countEditFiles($$$ARGS)", out: "countDistinctFiles($$$ARGS)" }],
paths: ["packages/coding-agent/src/**/*.ts"],
},
result: {
content: [
{
type: "text",
text: [
"# edit/renderer.ts (2 replacements)",
"-468: fileCount = countEditFiles(editArgs.edits);",
"+468: fileCount = countDistinctFiles(editArgs.edits);",
"-488: const totalFiles = args?.edits ? countEditFiles(args.edits) : 0;",
"+488: const totalFiles = args?.edits ? countDistinctFiles(args.edits) : 0;",
"",
"# tools/tool-result.ts (1 replacement)",
"-42: return countEditFiles(files);",
"+42: return countDistinctFiles(files);",
].join("\n"),
},
],
details: {
totalReplacements: 3,
filesTouched: 2,
filesSearched: 214,
applied: false,
limitReached: false,
scopePath: "packages/coding-agent/src",
searchPath: "/Users/dev/Projects/pi/packages/coding-agent/src",
files: ["edit/renderer.ts", "tools/tool-result.ts"],
fileReplacements: [
{ path: "edit/renderer.ts", count: 2 },
{ path: "tools/tool-result.ts", count: 1 },
],
displayContent: [
"# edit/",
"## renderer.ts (2 replacements)",
"-468│ fileCount = countEditFiles(editArgs.edits);",
"+468│ fileCount = countDistinctFiles(editArgs.edits);",
"-488│ const totalFiles = args?.edits ? countEditFiles(args.edits) : 0;",
"+488│ const totalFiles = args?.edits ? countDistinctFiles(args.edits) : 0;",
"",
"# tools/",
"## tool-result.ts (1 replacement)",
"-42│ return countEditFiles(files);",
"+42│ return countDistinctFiles(files);",
].join("\n"),
},
},
errorResult: {
content: [
{
type: "text",
text: "Pattern parse error in ops[0].pat: unbalanced parenthesis in `countEditFiles($$$ARGS`",
},
],
isError: true,
},
},
};
@@ -0,0 +1,153 @@
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: sample source-code strings (read fixtures) intentionally contain literal ${...}.
// Gallery fixtures for the filesystem tools (read, write, find).
import type { GalleryFixture } from "./types";
const readSnippet = [
"export const findToolRenderer = {",
"\tinline: true,",
"\trenderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {",
"\t\tconst meta: string[] = [];",
"\t\tif (args.limit !== undefined) meta.push(`limit:${args.limit}`);",
"",
"\t\tconst text = renderStatusLine(",
'\t\t\t{ icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },',
"\t\t\tuiTheme,",
"\t\t);",
"\t\treturn new Text(text, 0, 0);",
"\t},",
].join("\n");
const writtenContent = [
'import { describe, expect, it } from "bun:test";',
'import { parseSel } from "../src/tools/read";',
"",
'describe("parseSel", () => {',
'\tit("parses a single line range", () => {',
'\t\texpect(parseSel("42-58")).toEqual({',
'\t\t\tkind: "lines",',
"\t\t\tranges: [{ startLine: 42, endLine: 58 }],",
"\t\t});",
"\t});",
"",
'\tit("treats raw as a verbatim selector", () => {',
'\t\texpect(parseSel("raw")).toEqual({ kind: "raw" });',
"\t});",
"});",
"",
].join("\n");
export const fsFixtures: Record<string, GalleryFixture> = {
read: {
label: "Read",
// Streaming: path still being typed, selector not yet appended.
streamingArgs: { path: "packages/coding-agent/src/tools/find" },
args: { path: "packages/coding-agent/src/tools/find.ts:437-448" },
result: {
content: [
{
type: "text",
text: [
"[packages/coding-agent/src/tools/find.ts#E48E]",
"437:export const findToolRenderer = {",
"438:\tinline: true,",
"439:\trenderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {",
"440:\t\tconst meta: string[] = [];",
"441:\t\tif (args.limit !== undefined) meta.push(`limit:${args.limit}`);",
"442:",
"443:\t\tconst text = renderStatusLine(",
'444:\t\t\t{ icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },',
"445:\t\t\tuiTheme,",
"446:\t\t);",
"447:\t\treturn new Text(text, 0, 0);",
"448:\t},",
].join("\n"),
},
],
details: {
kind: "file",
resolvedPath: "/Users/dev/Projects/pi/packages/coding-agent/src/tools/find.ts",
contentType: "text/typescript",
displayContent: { text: readSnippet, startLine: 437 },
},
},
errorResult: {
isError: true,
content: [
{
type: "text",
text: "Error: ENOENT: no such file or directory, open 'packages/coding-agent/src/tools/find.ts'",
},
],
},
},
write: {
label: "Write",
// Streaming: path known, content still arriving (only the imports so far).
streamingArgs: {
path: "packages/coding-agent/test/parse-sel.test.ts",
content: 'import { describe, expect, it } from "bun:test";\nimport { parseSel } from "../src/tools/read";\n',
},
args: {
path: "packages/coding-agent/test/parse-sel.test.ts",
content: writtenContent,
},
result: {
content: [
{
type: "text",
text: "Created packages/coding-agent/test/parse-sel.test.ts (17 lines, 412 bytes).",
},
],
details: {},
},
errorResult: {
isError: true,
content: [
{
type: "text",
text: "Error: EACCES: permission denied, open 'packages/coding-agent/test/parse-sel.test.ts'",
},
],
},
},
find: {
label: "Find",
// Streaming: glob half-typed, no limit yet.
streamingArgs: { paths: ["packages/coding-agent/src/tools/*-render"] },
args: { paths: ["packages/coding-agent/src/**/*.test.ts"], limit: 50 },
result: {
content: [
{
type: "text",
text: [
"packages/coding-agent/src/tools/read.test.ts",
"packages/coding-agent/src/tools/write.test.ts",
"packages/coding-agent/src/tools/find.test.ts",
"packages/coding-agent/src/cli/gallery-cli.test.ts",
"packages/coding-agent/src/edit/edit.test.ts",
].join("\n"),
},
],
details: {
scopePath: "packages/coding-agent/src",
cwd: "/Users/dev/Projects/pi",
fileCount: 5,
truncated: false,
files: [
"packages/coding-agent/src/cli/gallery-cli.test.ts",
"packages/coding-agent/src/edit/edit.test.ts",
"packages/coding-agent/src/tools/find.test.ts",
"packages/coding-agent/src/tools/read.test.ts",
"packages/coding-agent/src/tools/write.test.ts",
],
},
},
errorResult: {
isError: true,
content: [{ type: "text", text: "Find failed: invalid glob pattern '[unclosed'." }],
details: { error: "invalid glob pattern '[unclosed'" },
},
},
};
@@ -0,0 +1,40 @@
/**
* Aggregated sample data for the `omp gallery` command.
*
* Each fixture drives one tool's renderer through the four lifecycle states the
* gallery showcases: arguments streaming in, arguments complete but awaiting a
* result, a successful result, and a failed result. The data is intentionally
* hand-written (rather than schema-derived) so the gallery reflects what a real
* tool call looks like — the whole point is visual QA of the renderers.
*
* Fixtures are grouped by subsystem into sibling modules and merged here.
* Adding a tool to one of those groups is enough for the gallery to render it.
* Tools present in the renderer registry but missing here fall back to a
* generic fixture (see `gallery-cli.ts`), so the gallery never crashes on a
* newly added tool — it just looks plain until a fixture is supplied.
*/
import { agenticFixtures } from "./agentic";
import { codeintelFixtures } from "./codeintel";
import { editFixtures } from "./edit";
import { fsFixtures } from "./fs";
import { interactionFixtures } from "./interaction";
import { memoryFixtures } from "./memory";
import { miscFixtures } from "./misc";
import { searchFixtures } from "./search";
import { shellFixtures } from "./shell";
import { webFixtures } from "./web";
export * from "./types";
export const galleryFixtures = {
...interactionFixtures,
...shellFixtures,
...fsFixtures,
...searchFixtures,
...editFixtures,
...agenticFixtures,
...memoryFixtures,
...webFixtures,
...codeintelFixtures,
...miscFixtures,
};
@@ -0,0 +1,49 @@
/** Gallery fixtures for the todo / ask / resolve interaction tools. */
import type { GalleryFixture } from "./types";
export const interactionFixtures: Record<string, GalleryFixture> = {
todo: {
label: "Todo",
streamingArgs: {
ops: [{ op: "init", list: [{ phase: "Foundation", items: ["Scaffold crate"] }] }],
},
args: {
ops: [
{
op: "init",
list: [
{ phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
{ phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
],
},
],
},
result: {
content: [{ type: "text", text: "Initialized 4 tasks across 2 phases" }],
details: {
storage: "session",
phases: [
{
name: "Foundation",
tasks: [
{ content: "Scaffold crate", status: "done" },
{ content: "Wire workspace", status: "in_progress" },
],
},
{
name: "Auth",
tasks: [
{ content: "Port credential store", status: "pending" },
{ content: "Wire OAuth providers", status: "pending" },
],
},
],
completedTasks: [{ phase: "Foundation", content: "Scaffold crate" }],
},
},
errorResult: {
content: [{ type: "text", text: "Unknown phase 'Auth' — initialize the list first" }],
isError: true,
},
},
};
@@ -0,0 +1,81 @@
// Gallery fixtures for the long-term memory tools (retain, recall, reflect).
import type { GalleryFixture } from "./types";
export const memoryFixtures: Record<string, GalleryFixture> = {
retain: {
label: "Retain",
// Streaming: first item complete, second still arriving without a context.
streamingArgs: {
items: [{ content: "User prefers Bun over Node for all new scripts in this repo." }],
},
args: {
items: [
{
content: "User prefers Bun over Node for all new scripts in this repo.",
context: "Established while wiring up the gallery command tooling.",
},
{
content: "The TUI renderers live in packages/coding-agent/src/tools/*-render.ts.",
context: "Discovered during the gallery-fixtures task.",
},
],
},
result: {
content: [{ type: "text", text: "2 memories stored." }],
details: { count: 2 },
},
errorResult: {
isError: true,
content: [{ type: "text", text: "Retain failed: memory store is not initialized." }],
},
},
recall: {
label: "Recall",
// Streaming: query partially typed.
streamingArgs: { query: "bun vs node" },
args: { query: "Which runtime does the user prefer for scripts?" },
result: {
content: [
{
type: "text",
text: [
"Found 2 relevant memories:",
"",
"1. [0.92] User prefers Bun over Node for all new scripts in this repo.",
" (Established while wiring up the gallery command tooling.)",
"2. [0.78] The TUI renderers live in packages/coding-agent/src/tools/*-render.ts.",
" (Discovered during the gallery-fixtures task.)",
].join("\n"),
},
],
},
errorResult: {
isError: true,
content: [{ type: "text", text: "Recall failed: vector index unavailable." }],
},
},
reflect: {
label: "Reflect",
streamingArgs: { query: "what have we learned about the user's" },
args: { query: "What have we learned about the user's tooling preferences?" },
result: {
content: [
{
type: "text",
text: [
"The user consistently favors Bun as the runtime for scripts in this",
"repository, avoiding Node where possible. They also track the location",
"of TUI renderers under packages/coding-agent/src/tools, suggesting an",
"interest in keeping rendering logic discoverable and well-organized.",
].join("\n"),
},
],
},
errorResult: {
isError: true,
content: [{ type: "text", text: "Reflect failed: no memories matched the query." }],
},
},
};
@@ -0,0 +1,250 @@
/** Gallery fixtures for the ask / resolve / ssh / github / inspect_image tools. */
import type { GalleryFixture } from "./types";
export const miscFixtures: Record<string, GalleryFixture> = {
ask: {
label: "Ask",
streamingArgs: {
questions: [
{
id: "db",
question: "Which database should the new service use?",
options: [{ label: "Postgres" }],
},
],
},
args: {
questions: [
{
id: "db",
question: "Which database should the new service use?",
options: [
{ label: "Postgres", description: "Relational, strong consistency, JSONB support" },
{ label: "SQLite", description: "Embedded, zero-ops, great for single-node" },
{ label: "MongoDB", description: "Document store, flexible schema" },
],
recommended: 0,
},
{
id: "features",
question: "Which auth flows should ship in v1?",
options: [
{ label: "Email + password" },
{ label: "OAuth (Google, GitHub)" },
{ label: "Magic links" },
{ label: "SAML SSO", description: "Enterprise; can be deferred" },
],
multi: true,
},
],
},
result: {
content: [
{
type: "text",
text: "db: Postgres\nfeatures: Email + password, OAuth (Google, GitHub)",
},
],
details: {
results: [
{
id: "db",
question: "Which database should the new service use?",
options: ["Postgres", "SQLite", "MongoDB"],
multi: false,
selectedOptions: ["Postgres"],
},
{
id: "features",
question: "Which auth flows should ship in v1?",
options: ["Email + password", "OAuth (Google, GitHub)", "Magic links", "SAML SSO"],
multi: true,
selectedOptions: ["Email + password", "OAuth (Google, GitHub)"],
},
],
},
},
errorResult: {
content: [{ type: "text", text: "Prompt cancelled by user before any answer was given" }],
isError: true,
},
},
resolve: {
label: "Resolve",
streamingArgs: {
action: "apply",
},
args: {
action: "apply",
reason: "Rename is mechanical and the staged diff matches the intended refactor.",
extra: { title: "rename-usecredentials-hook" },
},
result: {
content: [{ type: "text", text: "Applied pending ast_edit: 7 replacements across 3 files" }],
details: {
action: "apply",
reason: "Rename is mechanical and the staged diff matches the intended refactor.",
extra: { title: "rename-usecredentials-hook" },
sourceToolName: "ast_edit",
label: "ast_edit: 7 replacements across 3 files",
},
},
errorResult: {
content: [{ type: "text", text: "No pending action to resolve" }],
isError: true,
details: {
action: "apply",
reason: "Rename is mechanical and the staged diff matches the intended refactor.",
sourceToolName: "ast_edit",
label: "ast_edit: 7 replacements across 3 files",
},
},
},
ssh: {
label: "SSH",
streamingArgs: {
host: "deploy@web-01",
command: "systemctl status",
},
args: {
host: "deploy@web-01",
command: "systemctl status omp-api --no-pager | head -n 12",
cwd: "/srv/omp",
timeout: 60,
},
result: {
content: [
{
type: "text",
text: [
"● omp-api.service - Oh My Pi API",
" Loaded: loaded (/etc/systemd/system/omp-api.service; enabled)",
" Active: active (running) since Sat 2026-06-06 09:14:02 UTC; 3h 21min ago",
" Main PID: 4812 (bun)",
" Tasks: 17 (limit: 4915)",
" Memory: 142.6M",
" CPU: 38.214s",
" CGroup: /system.slice/omp-api.service",
" └─4812 /usr/local/bin/bun run dist/server.js",
].join("\n"),
},
],
},
errorResult: {
content: [
{
type: "text",
text: "ssh: connect to host web-01 port 22: Connection timed out",
},
],
isError: true,
},
},
github: {
label: "GitHub",
streamingArgs: {
op: "search_prs",
query: "is:open author:@me",
},
args: {
op: "search_prs",
query: "is:open review-requested:@me sort:updated",
repo: "oh-my-pi/pi",
},
result: {
content: [
{
type: "text",
text: [
"#1842 feat(tui): virtualized scrollback for tool output openyou · 2h ago +312 -47",
"#1839 fix(agent): retry stream on transient 529 dvir · 5h ago +18 -4",
"#1830 refactor(edit): unify hashline + ast_edit previews mira · 1d ago +540 -210",
"#1817 docs: document gallery fixtures contract leo · 2d ago +96 -0",
"",
"4 open pull requests requesting your review",
].join("\n"),
},
],
},
errorResult: {
content: [
{
type: "text",
text: "gh: Could not resolve to a Repository with the name 'oh-my-pi/pi'. (HTTP 404)",
},
],
isError: true,
},
},
inspect_image: {
label: "Inspect Image",
streamingArgs: {
path: "docs/assets/dashboard-mock.png",
},
args: {
path: "docs/assets/dashboard-mock.png",
question: "What chart types are shown and roughly what layout does the dashboard use?",
},
result: {
content: [
{
type: "text",
text: [
"The dashboard uses a two-column layout on a dark background.",
"Top row: four KPI cards (Revenue, Active Users, Churn, MRR) with sparklines.",
"Left column: a stacked area chart of weekly sessions over ~3 months.",
"Right column: a horizontal bar chart ranking the top 6 referrers.",
"Bottom: a paginated table of recent transactions with status pills.",
].join("\n"),
},
],
details: {
model: "claude-opus-4",
imagePath: "docs/assets/dashboard-mock.png",
mimeType: "image/png",
},
},
errorResult: {
content: [{ type: "text", text: "Image not found: docs/assets/dashboard-mock.png" }],
isError: true,
details: {
model: "claude-opus-4",
imagePath: "docs/assets/dashboard-mock.png",
mimeType: "image/png",
},
},
},
// Built-in tool with no dedicated renderer — exercises the generic fallback
// (`#formatToolExecution`) path so its padded, state-tinted block is QA'd.
report_tool_issue: {
label: "Report Tool Issue",
streamingArgs: { tool: "lsp" },
args: {
tool: "lsp",
report: "Rename returned no edit for an exported symbol that has 12 references",
},
result: { content: [{ type: "text", text: "Noted, thanks!" }] },
errorResult: {
content: [{ type: "text", text: "Could not record the report: issue tracker unreachable" }],
isError: true,
},
},
// Stand-in for a custom/extension tool that ships no renderer — same generic
// fallback path most MCP/extension tools take.
custom: {
label: "Custom Tool",
streamingArgs: { query: "weather" },
args: { query: "weather in Tokyo", units: "metric" },
result: { content: [{ type: "text", text: "Tokyo: 22°C, partly cloudy, humidity 64%." }] },
errorResult: {
content: [{ type: "text", text: "Upstream provider returned 503 Service Unavailable" }],
isError: true,
},
},
};
@@ -0,0 +1,213 @@
/** Gallery fixtures for the search tools (search, search_tool_bm25, ast_grep). */
import type { GalleryFixture } from "./types";
export const searchFixtures: Record<string, GalleryFixture> = {
search: {
label: "Search",
streamingArgs: {
pattern: "useState",
},
args: {
pattern: "useState",
paths: ["packages/tui/src"],
},
result: {
content: [
{
type: "text",
text: [
"# packages/tui/src/components/",
"## SearchBox.tsx",
'18: const [query, setQuery] = useState("");',
"19: const [results, setResults] = useState<Match[]>([]);",
"## StatusBar.tsx",
"27: const [expanded, setExpanded] = useState(false);",
"",
"# packages/tui/src/hooks/",
"## useDebounced.ts",
"9: const [value, setValue] = useState(initial);",
"10: const [pending, setPending] = useState(false);",
].join("\n"),
},
],
details: {
scopePath: "packages/tui/src",
searchPath: "/Users/dev/Projects/pi/packages/tui/src",
matchCount: 5,
fileCount: 3,
files: [
"packages/tui/src/components/SearchBox.tsx",
"packages/tui/src/components/StatusBar.tsx",
"packages/tui/src/hooks/useDebounced.ts",
],
fileMatches: [
{ path: "packages/tui/src/components/SearchBox.tsx", count: 2 },
{ path: "packages/tui/src/components/StatusBar.tsx", count: 1 },
{ path: "packages/tui/src/hooks/useDebounced.ts", count: 2 },
],
truncated: false,
displayContent: [
"# packages/tui/src/components/",
"## SearchBox.tsx",
'*18│ const [query, setQuery] = useState("");',
"*19│ const [results, setResults] = useState<Match[]>([]);",
"## StatusBar.tsx",
"*27│ const [expanded, setExpanded] = useState(false);",
"",
"# packages/tui/src/hooks/",
"## useDebounced.ts",
" *9│ const [value, setValue] = useState(initial);",
"*10│ const [pending, setPending] = useState(false);",
].join("\n"),
},
},
errorResult: {
content: [
{
type: "text",
text: "Invalid regex pattern: unclosed group near index 8",
},
],
isError: true,
details: {
error: "Invalid regex pattern: unclosed group near index 8",
},
},
},
search_tool_bm25: {
label: "SearchTools",
streamingArgs: {
query: "read pdf and ext",
},
args: {
query: "read pdf and extract tables",
limit: 5,
},
result: {
content: [
{
type: "text",
text: JSON.stringify({
query: "read pdf and extract tables",
activated_tools: ["docling_extract_tables", "docling_convert", "pdf_read_text"],
match_count: 4,
total_tools: 142,
}),
},
],
details: {
query: "read pdf and extract tables",
limit: 5,
total_tools: 142,
activated_tools: ["docling_extract_tables", "docling_convert", "pdf_read_text"],
active_selected_tools: ["read", "search", "edit", "bash"],
tools: [
{
name: "docling_extract_tables",
label: "Extract Tables",
description: "Extract tabular data from PDF documents into CSV or JSON rows.",
server_name: "docling",
mcp_tool_name: "extract_tables",
schema_keys: ["path", "pages", "format"],
score: 9.412037,
},
{
name: "docling_convert",
label: "Convert Document",
description: "Convert PDF, DOCX, or PPTX into structured Markdown with layout preserved.",
server_name: "docling",
mcp_tool_name: "convert",
schema_keys: ["path", "target", "ocr"],
score: 6.83102,
},
{
name: "pdf_read_text",
label: "Read PDF Text",
description: "Read raw text from a PDF, optionally scoped to a page range.",
server_name: "pdf-tools",
mcp_tool_name: "read_text",
schema_keys: ["path", "page_start", "page_end"],
score: 5.207884,
},
{
name: "tabula_scan",
label: "Scan Tables",
description: "Detect table bounding boxes on scanned PDF pages before extraction.",
server_name: "pdf-tools",
mcp_tool_name: "scan",
schema_keys: ["path", "dpi"],
score: 3.119556,
},
],
},
},
errorResult: {
content: [
{
type: "text",
text: "Tool discovery is disabled. Enable tools.discoveryMode or mcp.discoveryMode to use search_tool_bm25.",
},
],
isError: true,
},
},
ast_grep: {
label: "AST Grep",
streamingArgs: {
pat: "useState(",
},
args: {
pat: "useState($A)",
paths: ["packages/tui/src/components"],
},
result: {
content: [
{
type: "text",
text: [
"# packages/tui/src/components/",
"## SearchBox.tsx",
'18: const [query, setQuery] = useState("");',
' meta: $A=""',
"## StatusBar.tsx",
"27: const [expanded, setExpanded] = useState(false);",
" meta: $A=false",
].join("\n"),
},
],
details: {
matchCount: 2,
fileCount: 2,
filesSearched: 14,
limitReached: false,
scopePath: "packages/tui/src/components",
searchPath: "/Users/dev/Projects/pi/packages/tui/src/components",
files: ["packages/tui/src/components/SearchBox.tsx", "packages/tui/src/components/StatusBar.tsx"],
fileMatches: [
{ path: "packages/tui/src/components/SearchBox.tsx", count: 1 },
{ path: "packages/tui/src/components/StatusBar.tsx", count: 1 },
],
displayContent: [
"# packages/tui/src/components/",
"## SearchBox.tsx",
'*18│ const [query, setQuery] = useState("");',
' meta: $A=""',
"## StatusBar.tsx",
"*27│ const [expanded, setExpanded] = useState(false);",
" meta: $A=false",
].join("\n"),
},
},
errorResult: {
content: [
{
type: "text",
text: "Pattern parse error: incomplete node `useState(` — expected a closing `)`",
},
],
isError: true,
},
},
};
@@ -0,0 +1,167 @@
/** Gallery fixtures for the shell tools (bash, eval). */
import type { GalleryFixture } from "./types";
export const shellFixtures: Record<string, GalleryFixture> = {
bash: {
label: "Bash",
streamingArgs: {
command: "git status --short && git log --on",
},
args: {
command: "git status --short && git log --oneline -5",
cwd: "packages/coding-agent",
timeout: 30,
},
result: {
content: [
{
type: "text",
text: [
" M src/cli/gallery-cli.ts",
" M src/tools/bash.ts",
"?? src/cli/gallery-fixtures/shell.ts",
"a1b2c3d Wire gallery command into CLI dispatch",
"9f8e7d6 Add ToolExecutionComponent lifecycle states",
"4c5b6a7 Extract createShellRenderer from bashToolRenderer",
"2d3e4f5 Strip LLM-facing notices before TUI render",
"7a8b9c0 Cap preview lines in pending command block",
].join("\n"),
},
],
details: {
exitCode: 0,
wallTimeMs: 184,
timeoutSeconds: 30,
},
},
errorResult: {
content: [
{
type: "text",
text: [
"src/tools/bash.ts:1142:34 - error TS2339: Property 'requestedTimeoutSeconds' does not exist on type 'BashToolDetails'.",
"",
"1142 const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;",
" ~~~~~~~~~~~~~~~~~~~~~~~~",
"Found 1 error in src/tools/bash.ts:1142",
].join("\n"),
},
],
isError: true,
details: {
exitCode: 2,
wallTimeMs: 5120,
timeoutSeconds: 30,
},
},
},
eval: {
label: "Eval",
streamingArgs: {
cells: [
{
language: "py",
code: 'import json\nfrom pathlib import Path\n\ndata = json.loads(Path("package.js',
title: "load config",
},
],
},
args: {
cells: [
{
language: "py",
title: "load config",
code: [
"import json",
"from pathlib import Path",
"",
'data = json.loads(Path("package.json").read_text())',
'deps = data.get("dependencies", {})',
'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
'print(f"{len(deps)} dependencies")',
"display(sorted(deps)[:3])",
].join("\n"),
},
],
},
result: {
content: [
{
type: "text",
text: ["@oh-my-pi/coding-agent v0.42.0", "37 dependencies"].join("\n"),
},
],
details: {
language: "python",
languages: ["python"],
jsonOutputs: [["@ai-sdk/anthropic", "@oh-my-pi/pi-ai", "@oh-my-pi/pi-tui"]],
cells: [
{
index: 0,
title: "load config",
language: "python",
code: [
"import json",
"from pathlib import Path",
"",
'data = json.loads(Path("package.json").read_text())',
'deps = data.get("dependencies", {})',
'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
'print(f"{len(deps)} dependencies")',
"display(sorted(deps)[:3])",
].join("\n"),
output: ["@oh-my-pi/coding-agent v0.42.0", "37 dependencies"].join("\n"),
status: "complete",
durationMs: 64,
exitCode: 0,
},
],
},
},
errorResult: {
content: [
{
type: "text",
text: [
"Traceback (most recent call last):",
' File "<cell 0>", line 4, in <module>',
' data = json.loads(Path("package.json").read_text())',
" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
"json.decoder.JSONDecodeError: Expecting ',' delimiter: line 12 column 3 (char 318)",
].join("\n"),
},
],
isError: true,
details: {
language: "python",
languages: ["python"],
isError: true,
cells: [
{
index: 0,
title: "load config",
language: "python",
code: [
"import json",
"from pathlib import Path",
"",
'data = json.loads(Path("package.json").read_text())',
'deps = data.get("dependencies", {})',
'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
].join("\n"),
output: [
"Traceback (most recent call last):",
' File "<cell 0>", line 4, in <module>',
' data = json.loads(Path("package.json").read_text())',
"json.decoder.JSONDecodeError: Expecting ',' delimiter: line 12 column 3 (char 318)",
].join("\n"),
status: "error",
durationMs: 41,
exitCode: 1,
},
],
},
},
},
};
@@ -0,0 +1,41 @@
/**
* Types for `omp gallery` sample data. See {@link ./index} for the aggregated
* fixture registry and the contract each fixture must satisfy.
*/
import type { EditMode } from "../../edit";
/** A tool result snapshot, matching the shape `ToolExecutionComponent` consumes. */
export interface GalleryResult {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: unknown;
isError?: boolean;
}
export interface GalleryFixture {
/** Display label for the tool header (defaults to the tool name). */
label?: string;
/** Edit mode for edit-like tools so the streaming preview dispatches correctly. */
editMode?: EditMode;
/**
* Set for tools whose real `AgentTool` attaches `renderCall`/`renderResult`
* directly on the instance (e.g. `lsp`, `task`). The harness then attaches
* the registry renderer onto the fake tool so the component routes through
* the custom-tool branch — the same path production takes — instead of the
* built-in registry branch. The two branches can diverge, so exercising the
* real one keeps the gallery honest for these tools.
*/
customRendered?: boolean;
/**
* Arguments shown during the streaming state — a partial view of {@link args}
* as if the tool-call JSON were still arriving. May include `__partialJson`
* for renderers (bash, edit) that surface fields before the object closes.
* Defaults to {@link args} when omitted.
*/
streamingArgs?: unknown;
/** Complete arguments shown for the in-progress, success, and error states. */
args: unknown;
/** Successful result. */
result: GalleryResult;
/** Failed result. Falls back to a generic error when omitted. */
errorResult?: GalleryResult;
}
@@ -0,0 +1,158 @@
// Gallery fixtures for the web tools (web_search, browser).
import type { GalleryFixture } from "./types";
export const webFixtures: Record<string, GalleryFixture> = {
web_search: {
label: "Web Search",
// Streaming: query still being typed, no recency/limit yet.
streamingArgs: { query: "bun vs node performance" },
args: {
query: "Bun vs Node.js performance benchmarks 2026",
recency: "month",
limit: 4,
},
result: {
content: [
{
type: "text",
text: [
"Bun continues to outperform Node.js on raw HTTP throughput and cold-start",
"time thanks to its JavaScriptCore engine and native-Zig runtime, while",
"Node.js retains an edge in ecosystem maturity and long-term stability.",
"For script-heavy workflows Bun's faster startup is the decisive factor.",
].join("\n"),
},
],
details: {
response: {
provider: "perplexity",
model: "sonar-pro",
authMode: "api_key",
requestId: "req_a1b2c3d4e5f6",
answer: [
"Bun continues to outperform Node.js on raw HTTP throughput and cold-start",
"time thanks to its JavaScriptCore engine and native-Zig runtime, while",
"Node.js retains an edge in ecosystem maturity and long-term stability.",
"For script-heavy workflows Bun's faster startup is the decisive factor.",
].join("\n"),
searchQueries: ["bun vs node.js performance benchmarks 2026", "bun http throughput vs node"],
sources: [
{
title: "Bun 1.2 Benchmarks: HTTP, SQLite, and Startup Time",
url: "https://bun.sh/blog/bun-v1.2-benchmarks",
snippet:
"Bun serves roughly 2.5x the requests per second of Node.js on a simple HTTP server and starts in under 10ms.",
ageSeconds: 86400 * 12,
author: "The Bun Team",
},
{
title: "Node.js vs Bun: A 2026 Performance Deep Dive",
url: "https://blog.platformatic.dev/nodejs-vs-bun-2026",
snippet:
"Across CPU-bound workloads the gap narrows, but Bun's faster module resolution keeps cold starts ahead.",
ageSeconds: 86400 * 3,
author: "Matteo Collina",
},
{
title: "Real-world API latency: Bun, Deno, and Node compared",
url: "https://www.theregister.com/2026/05/18/js_runtime_latency/",
snippet:
"Under sustained load p99 latencies converge, suggesting runtime choice matters less for steady-state services.",
ageSeconds: 86400 * 19,
},
{
title: "Why we migrated our CLI tooling from Node to Bun",
url: "https://engineering.example.com/posts/bun-cli-migration",
snippet:
"Startup dropped from 180ms to 22ms, shaving seconds off every developer command invocation.",
ageSeconds: 86400 * 27,
author: "Dana Whitfield",
},
],
citations: [
{
url: "https://bun.sh/blog/bun-v1.2-benchmarks",
title: "Bun 1.2 Benchmarks",
citedText: "Bun serves roughly 2.5x the requests per second of Node.js",
},
],
usage: {
inputTokens: 312,
outputTokens: 248,
totalTokens: 560,
searchRequests: 2,
},
},
},
},
errorResult: {
isError: true,
content: [{ type: "text", text: "Web search failed: provider returned HTTP 429 (rate limited)." }],
details: {
response: {
provider: "perplexity",
sources: [],
},
error: "Provider returned HTTP 429 (rate limited). Retry after 30s.",
},
},
},
browser: {
label: "Browser",
// Streaming: code body still arriving for a `run` action.
streamingArgs: {
action: "run",
name: "docs",
code: "const obs = await tab.observe();\n",
},
args: {
action: "run",
name: "docs",
code: [
"const obs = await tab.observe();",
"const heading = obs.elements.find(e => e.role === 'heading');",
"display({ url: obs.url, title: obs.title, headings: obs.elements.filter(e => e.role === 'heading').length });",
"return heading?.name ?? 'no heading found';",
].join("\n"),
},
result: {
content: [
{
type: "text",
text: [
'{ url: "https://bun.sh/docs", title: "Bun Documentation", headings: 14 }',
'"Get started with Bun"',
].join("\n"),
},
],
details: {
action: "run",
name: "docs",
url: "https://bun.sh/docs",
browser: "headless",
viewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
result: '"Get started with Bun"',
},
},
errorResult: {
isError: true,
content: [
{
type: "text",
text: [
"TimeoutError: waiting for selector `aria/Sign in` failed: timeout 30000ms exceeded",
" at Tab.waitFor (browser/tab.ts:212:13)",
" at run (eval:3:7)",
].join("\n"),
},
],
details: {
action: "run",
name: "docs",
url: "https://bun.sh/docs",
browser: "headless",
},
},
},
};
@@ -0,0 +1,279 @@
/**
* Render `omp gallery` output to PNG screenshots via VHS.
*
* ANSI escapes are invisible to anything that can only read raw bytes (e.g.
* agents), so `--screenshot` drives the rendered gallery through a real virtual
* terminal (VHS + ttyd + ffmpeg) and writes the captured frame to disk. The
* gallery is pre-rendered to truecolor ANSI in this process — where the user's
* theme and symbol preset are correct — then `cat`'d inside VHS so the captured
* pixels match exactly what the live TUI would draw.
*
* VHS is a hard dependency of this path: if it is not installed we fail loudly
* rather than degrade to a lossy fallback.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { $which } from "@oh-my-pi/pi-utils";
import { theme } from "../modes/theme/theme";
import type { GallerySection } from "./gallery-cli";
/** Nerd Font family so the gallery's icon glyphs (PUA) render instead of tofu. */
export const DEFAULT_SCREENSHOT_FONT = "JetBrainsMono Nerd Font";
export const DEFAULT_SCREENSHOT_FONT_SIZE = 18;
/** Inner padding (px) VHS leaves around the terminal grid. */
const PADDING = 14;
const LINE_HEIGHT = 1.0;
/**
* Upper-bound cell metrics relative to font size. Real monospace cells are
* smaller, so over-provisioning the canvas guarantees the gallery never
* soft-wraps (too few columns) or scrolls off the top (too few rows). The slack
* shows up only as a modest background margin, which is harmless for review.
*/
const CELL_WIDTH_RATIO = 0.65;
const CELL_HEIGHT_RATIO = 1.5;
/** Keep each image well under headless-Chromium's tall-canvas limits. */
const MAX_IMAGE_HEIGHT_PX = 8000;
export interface GalleryScreenshotOptions {
/** Gallery render width in columns (matches the ANSI line width). */
width: number;
/** VHS `FontFamily`. */
font?: string;
/** VHS `FontSize`. */
fontSize?: number;
/**
* Output destination. When omitted, PNGs land in a fresh temp directory.
* With multiple images the path is suffixed (`name-01.png`, `name-02.png`).
*/
out?: string;
}
/**
* Capture the gallery sections as one or more PNGs and return their absolute
* paths. Tall galleries are split across images so no single capture exceeds
* the terminal-canvas height limit.
*/
export async function captureGalleryScreenshots(
sections: GallerySection[],
options: GalleryScreenshotOptions,
): Promise<string[]> {
const vhs = $which("vhs");
if (!vhs) {
throw new Error(
"`omp gallery --screenshot` requires VHS, which is not installed. " +
"Install it (e.g. `brew install vhs`, or see https://github.com/charmbracelet/vhs) and retry.",
);
}
const font = options.font ?? DEFAULT_SCREENSHOT_FONT;
const fontSize = options.fontSize ?? DEFAULT_SCREENSHOT_FONT_SIZE;
const cellHeight = fontSize * Math.max(LINE_HEIGHT, 1) * CELL_HEIGHT_RATIO;
const cellWidth = fontSize * CELL_WIDTH_RATIO;
const rowBudget = Math.max(40, Math.floor((MAX_IMAGE_HEIGHT_PX - 2 * PADDING) / cellHeight) - 2);
const chunks = chunkGallerySections(sections, rowBudget);
const themeJson = buildVhsTheme();
const baseDir = options.out
? path.dirname(path.resolve(options.out))
: fs.mkdtempSync(path.join(os.tmpdir(), "omp-gallery-"));
await fs.promises.mkdir(baseDir, { recursive: true });
const outPaths: string[] = [];
for (let i = 0; i < chunks.length; i++) {
if (chunks.length > 1) {
process.stderr.write(`Rendering gallery screenshot ${i + 1}/${chunks.length}…\n`);
}
const outPng = resolveScreenshotOutputPath(options.out, baseDir, i, chunks.length);
const lines = chunks[i].flatMap(section => section.lines);
await renderChunk({ vhs, lines, outPng, font, fontSize, cellWidth, cellHeight, width: options.width, themeJson });
outPaths.push(outPng);
}
return outPaths;
}
interface RenderChunkArgs {
vhs: string;
lines: string[];
outPng: string;
font: string;
fontSize: number;
cellWidth: number;
cellHeight: number;
width: number;
themeJson: string;
}
async function renderChunk(args: RenderChunkArgs): Promise<void> {
const rows = args.lines.length;
const widthPx = Math.ceil(args.width * args.cellWidth) + 2 * PADDING;
const heightPx = Math.ceil((rows + 2) * args.cellHeight) + 2 * PADDING;
const dir = path.dirname(args.outPng);
const stem = path.basename(args.outPng, path.extname(args.outPng));
const ansiPath = path.join(dir, `.${stem}.ansi`);
const tapePath = path.join(dir, `.${stem}.tape`);
const gifPath = path.join(dir, `.${stem}.gif`);
// CRLF so each gallery line is its own terminal row regardless of how the
// captured shell handles bare LF.
await Bun.write(ansiPath, `${args.lines.join("\r\n")}\r\n`);
await Bun.write(
tapePath,
buildTape({
gifPath,
outPng: args.outPng,
ansiPath,
widthPx,
heightPx,
font: args.font,
fontSize: args.fontSize,
themeJson: args.themeJson,
}),
);
try {
const result = await Bun.$`${args.vhs} ${tapePath}`.quiet().nothrow();
if (result.exitCode !== 0 || !(await Bun.file(args.outPng).exists())) {
const detail = result.stderr.toString().trim() || result.stdout.toString().trim();
throw new Error(`VHS failed to render the gallery screenshot${detail ? `: ${detail.slice(-600)}` : ""}`);
}
} finally {
await Promise.all([
fs.promises.rm(ansiPath, { force: true }),
fs.promises.rm(tapePath, { force: true }),
fs.promises.rm(gifPath, { force: true }),
]);
}
}
interface TapeArgs {
gifPath: string;
outPng: string;
ansiPath: string;
widthPx: number;
heightPx: number;
font: string;
fontSize: number;
themeJson: string;
}
function buildTape(args: TapeArgs): string {
// `Output` (a throwaway GIF) is mandatory for VHS to record; the screenshot
// is captured from the final visible frame. Setup is hidden so the typed
// `cat` command and shell prompt never appear in the capture, and a trailing
// `sleep` keeps the shell from drawing a fresh prompt under the output.
const shellCommand = `clear; cat ${shellSingleQuote(args.ansiPath)}; sleep 120`;
return `${[
`Output ${JSON.stringify(args.gifPath)}`,
`Set Width ${args.widthPx}`,
`Set Height ${args.heightPx}`,
`Set FontFamily ${JSON.stringify(args.font)}`,
`Set FontSize ${args.fontSize}`,
`Set Padding ${PADDING}`,
`Set LineHeight ${LINE_HEIGHT}`,
`Set Theme ${args.themeJson}`,
"Hide",
`Type ${JSON.stringify(shellCommand)}`,
"Enter",
"Sleep 1.2s",
"Show",
"Sleep 400ms",
`Screenshot ${JSON.stringify(args.outPng)}`,
].join("\n")}\n`;
}
/**
* Build the VHS terminal theme. Only background/foreground/cursor matter: the
* gallery emits truecolor (`38;2`/`48;2`) escapes, so the 16-color palette is
* never consulted — it is filler to satisfy VHS's theme schema.
*/
function buildVhsTheme(): string {
const background = parseAnsiRgb(theme.getBgAnsi("statusLineBg")) ?? (theme.isLight ? "#ffffff" : "#1a1a1a");
const foreground = theme.isLight ? "#1a1a1a" : "#d4d4d4";
const selection = theme.isLight ? "#c8d6ff" : "#404862";
return JSON.stringify({
name: "omp-gallery",
background,
foreground,
cursor: foreground,
selection,
black: "#000000",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#6272ff",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#bfbfbf",
brightBlack: "#4d4d4d",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#8aa0ff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
});
}
/** Extract `#rrggbb` from a truecolor SGR escape (`…38;2;r;g;b…` / `…48;2;…`). */
function parseAnsiRgb(ansi: string): string | undefined {
const match = /[34]8;2;(\d+);(\d+);(\d+)/.exec(ansi);
if (!match) return undefined;
const hex = (value: string) => Number(value).toString(16).padStart(2, "0");
return `#${hex(match[1])}${hex(match[2])}${hex(match[3])}`;
}
/** POSIX single-quote a path for embedding in the VHS shell command. */
function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* Resolve a chunk's PNG path. A single image keeps the bare name (or the exact
* `out`); multiple images gain a zero-padded `-NN` suffix so they sort and never
* collide.
*/
export function resolveScreenshotOutputPath(
out: string | undefined,
baseDir: string,
index: number,
total: number,
): string {
if (total === 1) {
return out ? path.resolve(out) : path.join(baseDir, "gallery.png");
}
const suffix = String(index + 1).padStart(2, "0");
if (out) {
const resolved = path.resolve(out);
const ext = path.extname(resolved) || ".png";
const stem = path.basename(resolved, ext);
return path.join(path.dirname(resolved), `${stem}-${suffix}${ext}`);
}
return path.join(baseDir, `gallery-${suffix}.png`);
}
/**
* Group whole tool sections into chunks that stay under `rowBudget` rows. A
* single section larger than the budget gets its own (taller) image rather than
* being split mid-renderer.
*/
export function chunkGallerySections(sections: GallerySection[], rowBudget: number): GallerySection[][] {
const chunks: GallerySection[][] = [];
let current: GallerySection[] = [];
let currentRows = 0;
for (const section of sections) {
const rows = section.lines.length;
if (current.length > 0 && currentRows + rows > rowBudget) {
chunks.push(current);
current = [];
currentRows = 0;
}
current.push(section);
currentRows += rows;
}
if (current.length > 0) chunks.push(current);
return chunks.length > 0 ? chunks : [[]];
}
@@ -0,0 +1,52 @@
/**
* Render every built-in tool's renderer across its lifecycle states.
*/
import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
import { GALLERY_STATES, type GalleryState, runGalleryCommand } from "../cli/gallery-cli";
export default class Gallery extends Command {
static description = "Preview tool renderers across streaming, in-progress, success, and failure states";
static flags = {
tool: Flags.string({ char: "t", description: "Render a single tool by name" }),
state: Flags.string({
char: "s",
description: "Render only the given lifecycle state(s)",
options: [...GALLERY_STATES],
multiple: true,
}),
width: Flags.integer({ char: "w", description: "Render width in columns" }),
expanded: Flags.boolean({
char: "e",
description: "Render the expanded variant of each renderer",
default: false,
}),
plain: Flags.boolean({ description: "Strip ANSI styling from the output", default: false }),
screenshot: Flags.boolean({
description:
"Capture the rendered output as PNG screenshot(s) via VHS instead of printing ANSI (requires vhs)",
default: false,
}),
out: Flags.string({
char: "o",
description: "Screenshot output path (with --screenshot); suffixed per image when split across multiple",
}),
font: Flags.string({ description: "Screenshot font family (default: JetBrainsMono Nerd Font)" }),
"font-size": Flags.integer({ description: "Screenshot font size in points (default: 18)" }),
};
async run(): Promise<void> {
const { flags } = await this.parse(Gallery);
await runGalleryCommand({
tool: flags.tool,
states: flags.state as GalleryState[] | undefined,
width: flags.width,
expanded: flags.expanded,
plain: flags.plain,
screenshot: flags.screenshot,
out: flags.out,
font: flags.font,
fontSize: flags["font-size"],
});
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Root command for the coding agent CLI.
*/
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai/effort";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
import { parseArgs } from "../cli/args";
@@ -1,5 +1,5 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" };
@@ -14,7 +14,7 @@ const ConventionalAnalysisTool = createConventionalAnalysisTool(
export interface ConventionalAnalysisInput {
model: Model<Api>;
apiKey: string;
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
contextFiles?: Array<{ path: string; content: string }>;
userContext?: string;
@@ -1,5 +1,5 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
@@ -19,7 +19,7 @@ const SummaryTool = {
export interface SummaryInput {
model: Model<Api>;
apiKey: string;
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
commitType: string;
scope: string | null;
@@ -1,5 +1,5 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
@@ -25,7 +25,7 @@ export const changelogTool = {
export interface ChangelogPromptInput {
model: Model<Api>;
apiKey: string;
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
changelogPath: string;
isPackageChangelog: boolean;
@@ -1,6 +1,6 @@
import * as path from "node:path";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { CHANGELOG_CATEGORIES } from "../../commit/types";
import * as git from "../../utils/git";
@@ -15,7 +15,7 @@ const DEFAULT_MAX_DIFF_CHARS = 120_000;
export interface ChangelogFlowInput {
cwd: string;
model: Model<Api>;
apiKey: string;
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
stagedFiles: string[];
dryRun: boolean;
@@ -1,5 +1,5 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
import { $env } from "@oh-my-pi/pi-utils";
import { parseFileDiffs } from "../../commit/git/diff";
import type { ConventionalAnalysis } from "../../commit/types";
@@ -21,10 +21,10 @@ export interface MapReduceSettings {
export interface MapReduceInput {
model: Model<Api>;
apiKey: string;
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
smolModel: Model<Api>;
smolApiKey: string;
smolApiKey: ApiKey;
smolThinkingLevel?: ThinkingLevel;
diff: string;
stat: string;
@@ -1,5 +1,5 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import fileObserverSystemPrompt from "../../commit/prompts/file-observer-system.md" with { type: "text" };
@@ -18,7 +18,7 @@ const RETRY_BACKOFF_MS = 1000;
export interface MapPhaseInput {
model: Model<Api>;
apiKey: string;
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
files: FileDiff[];
config?: {
@@ -1,5 +1,5 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" };
@@ -12,7 +12,7 @@ const ReduceTool = createConventionalAnalysisTool("Synthesize file observations
export interface ReducePhaseInput {
model: Model<Api>;
apiKey: string;
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
observations: FileObservation[];
stat: string;
@@ -1,5 +1,6 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
import type { ApiKeyResolverRegistry } from "../config/api-key-resolver";
import { MODEL_ROLE_IDS } from "../config/model-registry";
import {
type ModelLookupRegistry,
@@ -12,13 +13,19 @@ import MODEL_PRIO from "../priority.json" with { type: "json" };
export interface ResolvedCommitModel {
model: Model<Api>;
apiKey: string;
/**
* Resolver for the model's bearer: re-resolves on 401 / usage-limit so the
* whole commit pipeline (analysis, map/reduce, changelog) inherits the
* central force-refresh + account-rotation policy.
*/
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
}
type CommitModelRegistry = ModelLookupRegistry & {
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
};
type CommitModelRegistry = ModelLookupRegistry &
ApiKeyResolverRegistry & {
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
};
export async function resolvePrimaryModel(
override: string | undefined,
@@ -38,20 +45,32 @@ export async function resolvePrimaryModel(
if (!apiKey) {
throw new Error(`No API key available for model ${model.provider}/${model.id}`);
}
return { model, apiKey, thinkingLevel: resolved?.thinkingLevel };
return {
model,
apiKey: modelRegistry.resolver(model.provider, { baseUrl: model.baseUrl }),
thinkingLevel: resolved?.thinkingLevel,
};
}
export async function resolveSmolModel(
settings: Settings,
modelRegistry: CommitModelRegistry,
fallbackModel: Model<Api>,
fallbackApiKey: string,
fallbackApiKey: ApiKey,
): Promise<ResolvedCommitModel> {
const available = modelRegistry.getAvailable();
const resolvedSmol = resolveRoleSelection(["smol"], settings, available, modelRegistry);
if (resolvedSmol?.model) {
const apiKey = await modelRegistry.getApiKey(resolvedSmol.model);
if (apiKey) return { model: resolvedSmol.model, apiKey, thinkingLevel: resolvedSmol.thinkingLevel };
if (apiKey) {
return {
model: resolvedSmol.model,
apiKey: modelRegistry.resolver(resolvedSmol.model.provider, {
baseUrl: resolvedSmol.model.baseUrl,
}),
thinkingLevel: resolvedSmol.thinkingLevel,
};
}
}
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
@@ -59,7 +78,12 @@ export async function resolveSmolModel(
const candidate = parseModelPattern(pattern, available, matchPreferences, { modelRegistry }).model;
if (!candidate) continue;
const apiKey = await modelRegistry.getApiKey(candidate);
if (apiKey) return { model: candidate, apiKey };
if (apiKey) {
return {
model: candidate,
apiKey: modelRegistry.resolver(candidate.provider, { baseUrl: candidate.baseUrl }),
};
}
}
return { model: fallbackModel, apiKey: fallbackApiKey };
+4 -4
View File
@@ -1,6 +1,6 @@
import * as path from "node:path";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
import { getProjectDir, logger, prompt } from "@oh-my-pi/pi-utils";
import { ModelRegistry } from "../config/model-registry";
import { Settings } from "../config/settings";
@@ -145,10 +145,10 @@ async function generateAnalysis(input: {
contextFiles: Array<{ path: string; content: string }>;
userContext?: string;
primaryModel: Model<Api>;
primaryApiKey: string;
primaryApiKey: ApiKey;
primaryThinkingLevel?: ThinkingLevel;
smolModel: Model<Api>;
smolApiKey: string;
smolApiKey: ApiKey;
smolThinkingLevel?: ThinkingLevel;
commitSettings: {
mapReduceEnabled: boolean;
@@ -206,7 +206,7 @@ async function generateSummaryWithRetry(input: {
analysis: ConventionalAnalysis;
stat: string;
model: Model<Api>;
apiKey: string;
apiKey: ApiKey;
thinkingLevel?: ThinkingLevel;
userContext?: string;
}): Promise<{ summary: string }> {
@@ -0,0 +1,58 @@
import type { ApiKeyResolver, AuthStorage } from "@oh-my-pi/pi-ai";
export interface ApiKeyResolverOptions {
/** Session id for credential stickiness; read at resolve time by the caller. */
sessionId?: string;
/** Provider base URL hint forwarded to the auth-storage cascade. */
baseUrl?: string;
}
/**
* Minimal slice of `ModelRegistry` the resolver needs. Typed structurally so
* narrower registry shells (e.g. the commit pipeline's `CommitModelRegistry`)
* can build resolvers without depending on the full class.
*/
export interface ApiKeyResolverRegistry {
getApiKeyForProvider(
provider: string,
sessionId?: string,
options?: { baseUrl?: string; forceRefresh?: boolean; signal?: AbortSignal },
): Promise<string | undefined>;
authStorage: Pick<AuthStorage, "rotateSessionCredential">;
/**
* Build an {@link ApiKeyResolver} implementing the central a/b/c auth-retry
* policy: initial → resolve; step (b) → force-refresh same account; step (c)
* → rotate to a sibling credential, then re-resolve.
*
* The resolver is stateless (safe to reuse across requests). Callers that
* need the initial key for a guard can call `resolveApiKeyOnce(resolver)`.
*/
resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver;
}
/**
* Default implementation of {@link ApiKeyResolverRegistry.resolver}.
* Also usable standalone for structural registries that don't carry the method.
*/
export function createApiKeyResolver(
registry: Pick<ApiKeyResolverRegistry, "getApiKeyForProvider" | "authStorage">,
provider: string,
options: ApiKeyResolverOptions = {},
): ApiKeyResolver {
const { sessionId, baseUrl } = options;
return async ({ lastChance, error, signal }) => {
if (error === undefined) {
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl });
}
if (lastChance) {
// Account constraint (401 / usage / account-rate-limit): rotate to a
// sibling credential. We do NOT honor any retry-after here — if a
// sibling exists we switch immediately; the precise no-sibling backoff
// is owned by `markUsageLimitReached` (default + server usage-report
// reset) and the outer whole-turn retry layer.
await registry.authStorage.rotateSessionCredential(provider, sessionId, { error, signal });
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl });
}
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, forceRefresh: true, signal });
};
}
@@ -21,6 +21,7 @@ interface AppKeybindings {
"app.clear": true;
"app.exit": true;
"app.suspend": true;
"app.display.reset": true;
"app.thinking.cycle": true;
"app.thinking.toggle": true;
"app.model.cycleForward": true;
@@ -86,6 +87,10 @@ export const KEYBINDINGS = {
defaultKeys: "ctrl+z",
description: "Suspend application",
},
"app.display.reset": {
defaultKeys: "ctrl+l",
description: "Reset terminal display",
},
"app.thinking.cycle": {
defaultKeys: "shift+tab",
description: "Cycle thinking level",
@@ -103,7 +108,7 @@ export const KEYBINDINGS = {
description: "Cycle to previous model",
},
"app.model.select": {
defaultKeys: "ctrl+l",
defaultKeys: "alt+m",
description: "Select model",
},
"app.model.selectTemporary": {
@@ -119,7 +124,10 @@ export const KEYBINDINGS = {
description: "Open external editor",
},
"app.message.followUp": {
defaultKeys: "ctrl+enter",
// Ctrl+Enter is preserved for terminals that deliver it (Kitty/iTerm2/WezTerm/Ghostty),
// but Windows Terminal does not emit a distinct event for Ctrl+Enter — Ctrl+Q is listed
// first so the default binding works there without remapping (#1903).
defaultKeys: ["ctrl+q", "ctrl+enter"],
description: "Send follow-up message",
},
"app.message.dequeue": {
@@ -213,6 +221,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
clear: "app.clear",
exit: "app.exit",
suspend: "app.suspend",
displayReset: "app.display.reset",
cycleThinkingLevel: "app.thinking.cycle",
cycleModelForward: "app.model.cycleForward",
cycleModelBackward: "app.model.cycleBackward",
@@ -439,16 +448,50 @@ function migrateKeybindingsConfigFile(agentDir: string): void {
loadKeybindingsConfig(readPath, writeBackPath);
}
const FOLLOW_UP_KEYBINDING: AppKeybinding = "app.message.followUp";
const WINDOWS_FOLLOW_UP_FALLBACK_KEY: KeyId = "ctrl+q";
function keyListIncludes(keys: KeyId | KeyId[] | undefined, target: KeyId): boolean {
if (keys === undefined) return false;
const keyList = Array.isArray(keys) ? keys : [keys];
for (const key of keyList) {
if (key.toLowerCase() === target) return true;
}
return false;
}
function userBindingClaimsKey(config: KeybindingsConfig, target: KeyId, except: Keybinding): boolean {
for (const [keybinding, keys] of Object.entries(config)) {
if (!(keybinding in KEYBINDINGS)) continue;
if (keybinding === except) continue;
if (keyListIncludes(keys, target)) return true;
}
return false;
}
function removeKey(keys: KeyId[], target: KeyId): KeyId[] {
return keys.filter(key => key !== target);
}
function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
if (keys.length === 1) {
const key = keys[0];
if (key !== undefined) return key;
}
return [...keys];
}
/**
* Manages all keybindings (app + TUI).
* Extends the TUI KeybindingsManager with app-specific functionality.
*/
export class KeybindingsManager extends TuiKeybindingsManager {
#configPath: string | undefined;
#userBindings: KeybindingsConfig;
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
super(KEYBINDINGS, userBindings);
this.#configPath = configPath;
this.#userBindings = userBindings;
}
/**
@@ -480,6 +523,29 @@ export class KeybindingsManager extends TuiKeybindingsManager {
this.setUserBindings(config);
}
setUserBindings(userBindings: KeybindingsConfig): void {
this.#userBindings = userBindings;
super.setUserBindings(userBindings);
}
getKeys(keybinding: Keybinding): KeyId[] {
const keys = super.getKeys(keybinding);
if (keybinding === FOLLOW_UP_KEYBINDING) {
if (this.#userBindings[FOLLOW_UP_KEYBINDING] !== undefined) return keys;
if (!userBindingClaimsKey(this.#userBindings, WINDOWS_FOLLOW_UP_FALLBACK_KEY, FOLLOW_UP_KEYBINDING)) {
return keys;
}
return removeKey(keys, WINDOWS_FOLLOW_UP_FALLBACK_KEY);
}
return keys;
}
getResolvedBindings(): KeybindingsConfig {
const resolved = super.getResolvedBindings();
resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING));
return resolved;
}
/**
* Get the effective resolved bindings (defaults + user overrides).
*/
@@ -58,7 +58,7 @@ const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
};
const kModelResolutionCache = Symbol("model-equivalence.resolutionCache");
interface CompiledEquivalenceConfigWithCache extends CompiledEquivalenceConfig {
[kModelResolutionCache]?: WeakMap<Model<Api>, ResolvedCanonicalModel>;
[kModelResolutionCache]?: Map<string, ResolvedCanonicalModel>;
}
const FAMILY_EXTRACTION_PATTERNS = [
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
@@ -128,10 +128,18 @@ function normalizeCanonicalIdKey(canonicalId: string): string {
return canonicalId.trim().toLowerCase();
}
function getCanonicalSuffixAliasKey(candidate: string): string {
return PENALTY_HAS_UPPERCASE.test(candidate) ? normalizeCanonicalIdKey(candidate) : candidate;
}
export function formatCanonicalVariantSelector(model: Model<Api>): string {
return `${model.provider}/${model.id}`;
}
function getModelResolutionCacheKey(model: Model<Api>): string {
return `${model.provider}\0${model.id}`;
}
function buildOverrideMap(overrides: Record<string, string> | undefined): Map<string, string> {
const result = new Map<string, string>();
if (!overrides) {
@@ -159,13 +167,24 @@ function buildExclusionSet(exclusions: readonly string[] | undefined): Set<strin
return result;
}
const compiledEquivalenceCache = new WeakMap<ModelEquivalenceConfig, CompiledEquivalenceConfig>();
function compileEquivalenceConfig(config: ModelEquivalenceConfig | undefined): CompiledEquivalenceConfig {
if (config) {
const cached = compiledEquivalenceCache.get(config);
if (cached) {
return cached;
}
}
const overrides = buildOverrideMap(config?.overrides);
const exclude = buildExclusionSet(config?.exclude);
if (overrides.size === 0 && exclude.size === 0) {
return EMPTY_COMPILED_EQUIVALENCE;
}
return { overrides, exclude };
const compiled: CompiledEquivalenceConfig = { overrides, exclude };
if (config) {
compiledEquivalenceCache.set(config, compiled);
}
return compiled;
}
function addCanonicalCandidate(candidates: Set<string>, candidate: string): void {
@@ -277,7 +296,7 @@ function expandCompactSeriesMinorVersions(candidate: string): string[] {
// safely return the same instance. Cap keeps memory bounded under adversarial
// model-id churn.
const QUALIFIED_NAMESPACE_SUFFIX_CACHE = new Map<string, string[]>();
const QUALIFIED_NAMESPACE_SUFFIX_CACHE_CAP = 256;
const QUALIFIED_NAMESPACE_SUFFIX_CACHE_CAP = 4096;
function getQualifiedNamespaceSuffixes(candidate: string): string[] {
const cached = QUALIFIED_NAMESPACE_SUFFIX_CACHE.get(candidate);
if (cached !== undefined) {
@@ -670,7 +689,7 @@ function expandHeavyCanonicalCandidates(normalized: string, queue: string[]): vo
// is unused — kept for signature stability). The returned array is consumed via
// `.filter` at every callsite, so sharing the cached instance is safe.
const HEURISTIC_CANDIDATES_CACHE = new Map<string, string[]>();
const HEURISTIC_CANDIDATES_CACHE_CAP = 256;
const HEURISTIC_CANDIDATES_CACHE_CAP = 4096;
function getHeuristicCanonicalCandidates(modelId: string, _officialIds?: ReadonlySet<string>): string[] {
const cached = HEURISTIC_CANDIDATES_CACHE.get(modelId);
if (cached !== undefined) {
@@ -728,10 +747,10 @@ function getPreferredFallbackCanonicalCandidate(modelId: string, candidates: rea
function resolveCanonicalIdForModel(
model: Model<Api>,
selector: string,
equivalence: CompiledEquivalenceConfig,
referenceData: CanonicalReferenceData,
): ResolvedCanonicalModel {
const selector = formatCanonicalVariantSelector(model);
const normalizedSelector = normalizeSelectorKey(selector);
if (equivalence.overrides.has(normalizedSelector)) {
@@ -753,9 +772,12 @@ function resolveCanonicalIdForModel(
}
const heuristicCandidates = getHeuristicCanonicalCandidates(model.id, referenceData.officialIds);
const officialMatches = new Set(heuristicCandidates.filter(candidate => referenceData.officialIds.has(candidate)));
const officialMatches = new Set<string>();
for (const candidate of heuristicCandidates) {
const aliased = referenceData.suffixAliases.get(normalizeCanonicalIdKey(candidate));
if (referenceData.officialIds.has(candidate)) {
officialMatches.add(candidate);
}
const aliased = referenceData.suffixAliases.get(getCanonicalSuffixAliasKey(candidate));
if (aliased) {
officialMatches.add(aliased);
}
@@ -814,17 +836,18 @@ export function buildCanonicalModelIndex(
const compiledWithCache = compiledEquivalence as CompiledEquivalenceConfigWithCache;
let modelCache = compiledWithCache[kModelResolutionCache];
if (!modelCache) {
modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
modelCache = new Map<string, ResolvedCanonicalModel>();
compiledWithCache[kModelResolutionCache] = modelCache;
}
for (const model of models) {
let canonical = modelCache.get(model);
if (!canonical) {
canonical = resolveCanonicalIdForModel(model, compiledEquivalence, referenceData);
modelCache.set(model, canonical);
}
const selector = formatCanonicalVariantSelector(model);
const cacheKey = getModelResolutionCacheKey(model);
let canonical = modelCache.get(cacheKey);
if (!canonical) {
canonical = resolveCanonicalIdForModel(model, selector, compiledEquivalence, referenceData);
modelCache.set(cacheKey, canonical);
}
const variant: CanonicalModelVariant = {
canonicalId: canonical.id,
selector,
@@ -4,34 +4,49 @@ const MODEL_ID_SEGMENT_PATTERN = /[a-z0-9.:-]+/g;
const MODEL_FAMILY_PREFIX_PATTERN =
/^(claude|gemini|gpt|grok|glm|qwen|deepseek|kimi|mimo|doubao|ernie|gpt-oss|gemma|minimax|step|command|jamba|llama|o[1345])/i;
function hasDigit(value: string): boolean {
return /\d/.test(value);
function normalizeModelIdWhitespace(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
/** Ordering for model-like segments: longest first, ties broken lexicographically. */
function compareSegmentPreference(left: string, right: string): number {
if (left.length !== right.length) {
return right.length - left.length;
}
return left.localeCompare(right);
return left.length !== right.length ? right.length - left.length : left.localeCompare(right);
}
export function getModelLikeIdSegments(modelId: string): string[] {
const normalized = normalizeModelIdWhitespace(modelId).toLowerCase();
if (!normalized) return [];
const segments = (normalized.match(MODEL_ID_SEGMENT_PATTERN) ?? []).filter(
segment => MODEL_FAMILY_PREFIX_PATTERN.test(segment) && hasDigit(segment),
);
const unique = [...new Set(segments)];
unique.sort(compareSegmentPreference);
return unique;
const matches = normalizeModelIdWhitespace(modelId).toLowerCase().match(MODEL_ID_SEGMENT_PATTERN);
if (!matches) return [];
const segments = new Set<string>();
for (const segment of matches) {
if (MODEL_FAMILY_PREFIX_PATTERN.test(segment) && /\d/.test(segment)) segments.add(segment);
}
return [...segments].sort(compareSegmentPreference);
}
export function getLongestModelLikeIdSegment(modelId: string): string | undefined {
return getModelLikeIdSegments(modelId)[0];
const matches = normalizeModelIdWhitespace(modelId).toLowerCase().match(MODEL_ID_SEGMENT_PATTERN);
if (!matches) return undefined;
let best: string | undefined;
for (const segment of matches) {
if (
MODEL_FAMILY_PREFIX_PATTERN.test(segment) &&
/\d/.test(segment) &&
(best === undefined || compareSegmentPreference(segment, best) < 0)
) {
best = segment;
}
}
return best;
}
function normalizeModelIdWhitespace(value: string): string {
return value.trim().replace(/\s+/g, " ");
function hasBracketAffixMarker(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code === 91 || code === 93 || code === 0x3010 || code === 0x3011) {
return true;
}
}
return false;
}
/**
@@ -39,18 +54,20 @@ function normalizeModelIdWhitespace(value: string): string {
* upstream model id, e.g.
* "[Kiro] claude-opus-4-8" -> "claude-opus-4-8"
* "[gcli转] gemini-3.1-pro-preview [假流]" -> "gemini-3.1-pro-preview"
*
* Candidates are returned most-stripped first: both ends, then leading-only, then trailing-only.
*/
export function getBracketStrippedModelIdCandidates(modelId: string): string[] {
if (!hasBracketAffixMarker(modelId)) return [];
const normalized = normalizeModelIdWhitespace(modelId);
if (!normalized) return [];
const candidates = new Set<string>();
const withoutLeading = normalizeModelIdWhitespace(normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, ""));
const strippedLeading = normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, "");
const withoutLeading = normalizeModelIdWhitespace(strippedLeading);
const withoutTrailing = normalizeModelIdWhitespace(normalized.replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""));
const withoutBoth = normalizeModelIdWhitespace(
normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, "").replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""),
);
const withoutBoth = normalizeModelIdWhitespace(strippedLeading.replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""));
const candidates = new Set<string>();
for (const candidate of [withoutBoth, withoutLeading, withoutTrailing]) {
if (candidate && candidate !== normalized) {
candidates.add(candidate);
@@ -1,27 +1,19 @@
import * as path from "node:path";
import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry";
import { readModelCache } from "@oh-my-pi/pi-ai/model-cache";
import { createModelManager, type ModelManagerOptions, type ModelRefreshStrategy } from "@oh-my-pi/pi-ai/model-manager";
import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-ai/models";
import {
type Api,
type AssistantMessageEventStream,
type Context,
createModelManager,
enrichModelThinking,
getBundledModels,
getBundledProviders,
googleAntigravityModelManagerOptions,
googleGeminiCliModelManagerOptions,
type Model,
type ModelManagerOptions,
type ModelRefreshStrategy,
openaiCodexModelManagerOptions,
PROVIDER_DESCRIPTORS,
readModelCache,
registerCustomApi,
type SimpleStreamOptions,
type ThinkingConfig,
UNK_CONTEXT_WINDOW,
UNK_MAX_TOKENS,
unregisterCustomApis,
} from "@oh-my-pi/pi-ai";
} from "@oh-my-pi/pi-ai/provider-models";
import type { Api, Context, Model, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types";
import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
// Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
// any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
@@ -103,12 +95,14 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
...SPECIAL_MODEL_MANAGER_PROVIDER_IDS,
];
import type { ApiKeyResolver } from "@oh-my-pi/pi-ai";
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
import { isRecord, logger } from "@oh-my-pi/pi-utils";
import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
import { type ConfigError, ConfigFile } from "./config-file";
import {
buildCanonicalModelIndex,
@@ -2373,12 +2367,33 @@ export class ModelRegistry {
/**
* Get API key for a provider (e.g., "openai").
*
* `options.forceRefresh` powers step (b) of the auth-retry policy — it
* re-mints the session-sticky OAuth token even when the cached copy still
* looks valid. `options.signal` is threaded into any broker-bound refresh.
*/
async getApiKeyForProvider(provider: string, sessionId?: string, baseUrl?: string): Promise<string | undefined> {
async getApiKeyForProvider(
provider: string,
sessionId?: string,
options?: { baseUrl?: string; forceRefresh?: boolean; signal?: AbortSignal },
): Promise<string | undefined> {
if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
return kNoAuth;
}
return this.authStorage.getApiKey(provider, sessionId, { baseUrl });
return this.authStorage.getApiKey(provider, sessionId, {
baseUrl: options?.baseUrl,
forceRefresh: options?.forceRefresh,
signal: options?.signal,
});
}
/**
* Build an {@link ApiKeyResolver} for this provider, implementing the
* central a/b/c auth-retry policy. Callers that need the initial key for
* a guard can call `resolveApiKeyOnce(resolver)`.
*/
resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver {
return createApiKeyResolver(this, provider, options);
}
async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
@@ -2609,6 +2624,14 @@ export class ModelRegistry {
}
return true;
}
/**
* Clear all cooldown suppressions recorded via {@link suppressSelector}.
* Used to reset retry-fallback cooldown state without a full {@link refresh}.
*/
clearSuppressedSelectors(): void {
this.#suppressedSelectors.clear();
}
}
/**
@@ -660,6 +660,16 @@ export const SETTINGS_SCHEMA = {
},
},
"display.smoothStreaming": {
type: "boolean",
default: true,
ui: {
tab: "appearance",
label: "Smooth Streaming",
description: "Reveal assistant text smoothly while streamed chunks arrive",
},
},
"display.showTokenUsage": {
type: "boolean",
default: false,
@@ -902,6 +912,15 @@ export const SETTINGS_SCHEMA = {
"Maximum wait between retries, in ms. When the provider asks us to wait longer than this and no credential or model fallback succeeds, the request fails fast instead of sleeping (e.g. 3-hour Anthropic rate-limit windows).",
},
},
"retry.modelFallback": {
type: "boolean",
default: true,
ui: {
tab: "model",
label: "Retry Model Fallback",
description: "Allow retry recovery to switch to configured fallback models",
},
},
"retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
"retry.fallbackRevertPolicy": {
type: "enum",
@@ -1855,7 +1874,7 @@ export const SETTINGS_SCHEMA = {
tab: "editing",
label: "Hash Lines",
description:
"Include snapshot-tag headers and line numbers in read output for hashline edit mode (PATH#tag plus LINE:content)",
"Include snapshot-tag headers and line numbers in read output for hashline edit mode ([PATH#TAG] plus LINE:content)",
},
},
@@ -2493,13 +2512,13 @@ export const SETTINGS_SCHEMA = {
// Tool Discovery
"tools.discoveryMode": {
type: "enum",
values: ["off", "mcp-only", "all"] as const,
default: "off",
values: ["auto", "off", "mcp-only", "all"] as const,
default: "auto",
ui: {
tab: "tools",
label: "Tool Discovery",
description:
"Hide tools behind a search tool to save tokens. 'mcp-only' hides MCP tools; 'all' hides all non-essential built-ins too.",
"Hide tools behind a search tool to save tokens. 'auto' hides MCP tools once the tool set has more than 40 tools; 'mcp-only' always hides MCP tools; 'all' hides all non-essential built-ins too.",
},
},
@@ -3050,13 +3069,26 @@ export const SETTINGS_SCHEMA = {
],
},
},
"providers.parallelFetch": {
type: "boolean",
default: true,
"providers.fetch": {
type: "enum",
values: ["auto", "native", "trafilatura", "lynx", "parallel", "jina"] as const,
default: "auto",
ui: {
tab: "providers",
label: "Parallel Fetch",
description: "Use Parallel extract API for URL fetching when credentials are available",
label: "Fetch Provider",
description: "Reader backend priority for the fetch/read URL tool",
options: [
{
value: "auto",
label: "Auto",
description: "Priority: native > trafilatura > lynx > parallel > jina",
},
{ value: "native", label: "Native", description: "In-process HTML→Markdown converter (always available)" },
{ value: "trafilatura", label: "Trafilatura", description: "Auto-installs via uv/pip" },
{ value: "lynx", label: "Lynx", description: "Requires lynx system package" },
{ value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
{ value: "jina", label: "Jina", description: "Uses r.jina.ai reader (JINA_API_KEY optional)" },
],
},
},
"provider.appendOnlyContext": {
@@ -3307,6 +3339,7 @@ export interface RetrySettings {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
modelFallback: boolean;
}
export interface MemoriesSettings {
+31 -2
View File
@@ -240,11 +240,13 @@ export class Settings {
return promise.then(
instance => {
globalInstance = instance;
clearBoundSettingsMethods();
globalInstancePromise = Promise.resolve(instance);
return instance;
},
error => {
globalInstance = null;
clearBoundSettingsMethods();
throw error;
},
);
@@ -712,6 +714,17 @@ export class Settings {
}
}
// providers.parallelFetch (boolean) replaced by the providers.fetch reader
// priority enum. The new default ("auto") supersedes both old values —
// Parallel is now a deep fallback in the auto chain rather than the first
// choice — so drop the legacy key (flat and nested) and let the enum
// default apply.
const providersObj = raw.providers as Record<string, unknown> | undefined;
if (providersObj && "parallelFetch" in providersObj) {
delete providersObj.parallelFetch;
}
delete raw["providers.parallelFetch"];
// Map legacy `memories.enabled` boolean to the explicit `memory.backend`
// enum if the latter hasn't been set yet. Idempotent: subsequent
// migrations are no-ops once memory.backend is materialised.
@@ -967,6 +980,13 @@ export function onHindsightScopeChanged(cb: () => void): () => void {
let globalInstance: Settings | null = null;
let globalInstancePromise: Promise<Settings> | null = null;
let boundSettingsInstance: Settings | null = null;
let boundSettingsMethods = new Map<PropertyKey, unknown>();
function clearBoundSettingsMethods(): void {
boundSettingsInstance = null;
boundSettingsMethods = new Map<PropertyKey, unknown>();
}
export function isSettingsInitialized(): boolean {
return globalInstance !== null;
@@ -979,6 +999,7 @@ export function isSettingsInitialized(): boolean {
export function resetSettingsForTest(): void {
globalInstance = null;
globalInstancePromise = null;
clearBoundSettingsMethods();
}
/**
@@ -990,9 +1011,17 @@ export const settings = new Proxy({} as Settings, {
if (!globalInstance) {
throw new Error("Settings not initialized. Call Settings.init() first.");
}
const value = (globalInstance as unknown as Record<string | symbol, unknown>)[prop];
if (boundSettingsInstance !== globalInstance) {
clearBoundSettingsMethods();
boundSettingsInstance = globalInstance;
}
const value = (globalInstance as unknown as Record<PropertyKey, unknown>)[prop];
if (typeof value === "function") {
return value.bind(globalInstance);
const cached = boundSettingsMethods.get(prop);
if (cached) return cached;
const bound = value.bind(globalInstance);
boundSettingsMethods.set(prop, bound);
return bound;
}
return value;
},
+14 -16
View File
@@ -1,4 +1,5 @@
import { logger, ptree } from "@oh-my-pi/pi-utils";
import * as fs from "node:fs/promises";
import { isEnoent, logger, ptree } from "@oh-my-pi/pi-utils";
import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
import { ToolAbortError } from "../tools/tool-errors";
import type {
@@ -165,19 +166,7 @@ export class DapClient {
detached: true,
});
// Wait for the socket file to appear (dlv needs to start listening)
await waitForCondition(
() => {
try {
Bun.file(socketPath).size;
return true;
} catch {
return false;
}
},
10_000,
proc,
);
await waitForCondition(() => isUnixSocketReady(socketPath), 10_000, proc);
const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
@@ -553,15 +542,24 @@ export class DapClient {
}
}
async function isUnixSocketReady(socketPath: string): Promise<boolean> {
try {
return (await fs.stat(socketPath)).isSocket();
} catch (error) {
if (isEnoent(error)) return false;
throw error;
}
}
/** Poll a condition until it returns true, or timeout/process exit. */
async function waitForCondition(
check: () => boolean,
check: () => boolean | Promise<boolean>,
timeoutMs: number,
proc: { exitCode: number | null },
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (check()) return;
if (await check()) return;
if (proc.exitCode !== null) {
throw new Error("Adapter process exited before socket was ready");
}
+41 -2
View File
@@ -27,6 +27,7 @@ function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null {
rootMarkers: normalizeStringArray(config.rootMarkers),
launchDefaults: normalizeObject(config.launchDefaults),
attachDefaults: normalizeObject(config.attachDefaults),
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
...(connectMode ? { connectMode } : {}),
};
}
@@ -64,6 +65,7 @@ export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAda
launchDefaults: config.launchDefaults ?? {},
attachDefaults: config.attachDefaults ?? {},
connectMode: config.connectMode ?? "stdio",
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
};
}
@@ -124,12 +126,19 @@ function sortAdaptersForLaunch(program: string, cwd: string, adapters: DapResolv
return rootAware.map(entry => entry.adapter);
}
export function selectLaunchAdapter(program: string, cwd: string, adapterName?: string): DapResolvedAdapter | null {
export function selectLaunchAdapter(
program: string,
cwd: string,
adapterName?: string,
programKind: LaunchProgramKind = "file",
): DapResolvedAdapter | null {
if (adapterName) {
return resolveAdapter(adapterName, cwd);
}
const matches = getMatchingAdapters(program, cwd);
const sorted = sortAdaptersForLaunch(program, cwd, matches);
const candidates =
programKind === "directory" ? matches.filter(adapter => adapter.acceptsDirectoryProgram) : matches;
const sorted = sortAdaptersForLaunch(program, cwd, candidates.length > 0 ? candidates : matches);
return sorted[0] ?? null;
}
@@ -148,3 +157,33 @@ export function selectAttachAdapter(cwd: string, adapterName?: string, port?: nu
}
return available[0] ?? null;
}
/** How the launch `program` resolves on disk. `"missing"` is reserved for
* programs the adapter creates on demand (rare); we treat them like files. */
export type LaunchProgramKind = "file" | "directory" | "missing";
/** Compute adapter-specific launch arguments that depend on the resolved
* program. Returned values are spread over `adapter.launchDefaults` so they
* take precedence over the static defaults but can still be overridden by
* the fields `DapSessionManager.launch` sets explicitly (program, cwd, args).
*
* Currently scoped to dlv, where `mode` selects how the program path is
* interpreted: directories and `.go` source files debug as a Go package
* (`mode=debug`), anything else is treated as a compiled binary (`mode=exec`).
*/
export function resolveLaunchOverrides(
adapter: DapResolvedAdapter,
program: string,
programKind: LaunchProgramKind,
): Record<string, unknown> {
if (adapter.name === "dlv") {
const extension = path.extname(program).toLowerCase();
if (programKind === "directory" || extension === ".go") {
return { mode: "debug" };
}
if (programKind === "file") {
return { mode: "exec" };
}
}
return {};
}
@@ -65,6 +65,7 @@
"languages": ["go"],
"fileTypes": [".go"],
"rootMarkers": ["go.mod", "go.sum"],
"acceptsDirectoryProgram": true,
"launchDefaults": {
"request": "launch",
"mode": "debug",
+1
View File
@@ -259,6 +259,7 @@ export class DapSessionManager {
session.needsConfigurationDone = session.capabilities.supportsConfigurationDoneRequest === true;
const launchArguments: DapLaunchArguments = {
...options.adapter.launchDefaults,
...(options.extraLaunchArguments ?? {}),
program: options.program,
cwd: options.cwd,
args: options.args,
+10
View File
@@ -488,6 +488,10 @@ export interface DapAdapterConfig {
* On Linux, connects via a unix domain socket.
* On macOS, the adapter dials into a local TCP listener (--client-addr). */
connectMode?: "stdio" | "socket";
/** When true, the adapter accepts a directory as the launch `program`
* (e.g. dlv treats it as a Go package path). When false/undefined, the
* debug tool rejects directory programs upfront. */
acceptsDirectoryProgram?: boolean;
}
export interface DapResolvedAdapter {
@@ -501,6 +505,7 @@ export interface DapResolvedAdapter {
launchDefaults: Record<string, unknown>;
attachDefaults: Record<string, unknown>;
connectMode: "stdio" | "socket";
acceptsDirectoryProgram: boolean;
}
export interface DapBreakpointRecord {
@@ -589,6 +594,11 @@ export interface DapLaunchSessionOptions {
program: string;
args?: string[];
cwd: string;
/** Per-launch overrides merged over `adapter.launchDefaults`. Used to
* inject adapter-specific values that depend on the resolved program
* (e.g. dlv's `mode` switches between `debug` and `exec` based on
* whether `program` is a Go package path or a compiled binary). */
extraLaunchArguments?: Record<string, unknown>;
}
export interface DapAttachSessionOptions {
+40 -54
View File
@@ -19,6 +19,7 @@ import {
} from "@oh-my-pi/pi-tui";
import { getSessionsDir } from "@oh-my-pi/pi-utils";
import { DynamicBorder } from "../modes/components/dynamic-border";
import { TranscriptBlock } from "../modes/components/transcript-container";
import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme";
import type { InteractiveModeContext } from "../modes/types";
import { formatBytes } from "../tools/render-utils";
@@ -150,13 +151,13 @@ export class DebugSelectorComponent extends Container {
}
// Show message and wait for keypress
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new Text(theme.fg("accent", `${theme.status.info} CPU profiling started`), 1, 0));
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
const block = new TranscriptBlock();
block.addChild(new Text(theme.fg("accent", `${theme.status.info} CPU profiling started`), 1, 0));
block.addChild(new Spacer(1));
block.addChild(
new Text(theme.fg("muted", "Reproduce the performance issue, then press Enter to stop profiling."), 1, 0),
);
this.ctx.ui.requestRender();
this.ctx.present(block);
// Wait for Enter keypress
const { promise, resolve } = Promise.withResolvers<void>();
@@ -201,19 +202,16 @@ export class DebugSelectorComponent extends Container {
loader.stop();
this.ctx.statusContainer.clear();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0),
);
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
const block = new TranscriptBlock();
block.addChild(new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0));
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
this.ctx.present(block);
} catch (err) {
loader.stop();
this.ctx.statusContainer.clear();
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
}
this.ctx.ui.requestRender();
}
async #handleWorkReport(): Promise<void> {
@@ -231,15 +229,13 @@ export class DebugSelectorComponent extends Container {
openPath(tmpPath);
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
this.ctx.present([
new Spacer(1),
new Text(theme.fg("dim", `Opened flamegraph (${workProfile.sampleCount} samples)`), 1, 0),
);
]);
} catch (err) {
this.ctx.showError(`Failed to open profile: ${err instanceof Error ? err.message : String(err)}`);
}
this.ctx.ui.requestRender();
}
async #handleDumpReport(): Promise<void> {
@@ -262,19 +258,16 @@ export class DebugSelectorComponent extends Container {
loader.stop();
this.ctx.statusContainer.clear();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0),
);
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
const block = new TranscriptBlock();
block.addChild(new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0));
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
this.ctx.present(block);
} catch (err) {
loader.stop();
this.ctx.statusContainer.clear();
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
}
this.ctx.ui.requestRender();
}
async #handleMemoryReport(): Promise<void> {
@@ -301,19 +294,16 @@ export class DebugSelectorComponent extends Container {
loader.stop();
this.ctx.statusContainer.clear();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0),
);
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
const block = new TranscriptBlock();
block.addChild(new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0));
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
this.ctx.present(block);
} catch (err) {
loader.stop();
this.ctx.statusContainer.clear();
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
}
this.ctx.ui.requestRender();
}
async #handleViewLogs(): Promise<void> {
@@ -365,15 +355,14 @@ export class DebugSelectorComponent extends Container {
const info = await collectSystemInfo();
const formatted = formatSystemInfo(info);
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.chatContainer.addChild(new Text(formatted, 1, 0));
this.ctx.chatContainer.addChild(new DynamicBorder());
const block = new TranscriptBlock();
block.addChild(new DynamicBorder());
block.addChild(new Text(formatted, 1, 0));
block.addChild(new DynamicBorder());
this.ctx.present(block);
} catch (err) {
this.ctx.showError(`Failed to collect system info: ${err instanceof Error ? err.message : String(err)}`);
}
this.ctx.ui.requestRender();
}
async #handleViewTerminalState(): Promise<void> {
@@ -384,11 +373,11 @@ export class DebugSelectorComponent extends Container {
});
const formatted = formatTerminalState(info);
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.chatContainer.addChild(new Text(formatted, 1, 0));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.ui.requestRender();
const block = new TranscriptBlock();
block.addChild(new DynamicBorder());
block.addChild(new Text(formatted, 1, 0));
block.addChild(new DynamicBorder());
this.ctx.present(block);
}
async #handleViewProtocols(): Promise<void> {
@@ -407,15 +396,14 @@ export class DebugSelectorComponent extends Container {
TERMINAL.sendNotification(notification);
}
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
this.ctx.present([
new Spacer(1),
new ProtocolProbeComponent({
image: buildSampleImage(),
imageBudget: this.ctx.ui.imageBudget,
notificationSuppressed: suppressed,
}),
);
this.ctx.ui.requestRender();
]);
}
async #handleTranscriptExport(): Promise<void> {
@@ -487,21 +475,19 @@ export class DebugSelectorComponent extends Container {
loader.stop();
this.ctx.statusContainer.clear();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
this.ctx.present([
new Spacer(1),
new Text(
theme.fg("success", `${theme.status.success} Cleared ${result.removed} artifact directories`),
1,
0,
),
);
]);
} catch (err) {
loader.stop();
this.ctx.statusContainer.clear();
this.ctx.showError(`Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`);
}
this.ctx.ui.requestRender();
}
#getResolvedSettings(): Record<string, unknown> {
+18 -4
View File
@@ -1,4 +1,12 @@
import { type Component, matchesKey, padding, replaceTabs, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import {
type Component,
matchesKey,
padding,
replaceTabs,
ScrollView,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import { theme } from "../modes/theme/theme";
import { copyToClipboard } from "../utils/clipboard";
@@ -146,14 +154,20 @@ export class RawSseViewerComponent implements Component {
const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
const bodyHeight = this.#bodyHeight();
const rawLines = this.#renderRawLines(innerWidth);
const body = rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight);
while (body.length < bodyHeight) body.push("");
const sv = new ScrollView(rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight), {
height: bodyHeight,
scrollbar: "auto",
totalRows: rawLines.length,
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(this.#scrollOffset);
const bodyRows = sv.render(innerWidth);
return [
this.#frameTop(innerWidth),
this.#frameLine(this.#summaryText(), innerWidth),
this.#frameSeparator(innerWidth),
...body.map(line => this.#frameLine(line, innerWidth)),
...bodyRows.map(line => this.#frameLine(line, innerWidth)),
this.#frameLine(this.#statusText(), innerWidth),
this.#frameBottom(innerWidth),
];
@@ -14,7 +14,7 @@ import { normalizeToLF } from "./normalize";
/**
* Upper bound on the file size we snapshot. A section tag is a content hash of
* the *whole* file, so minting one means holding the full normalized text in
* the store. Files above this cap emit no `path#tag` header — line-anchored
* the store. Files above this cap emit no `[path#tag]` header — line-anchored
* editing of multi-megabyte files is out of scope under the full-content model.
*/
export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
+1 -1
View File
@@ -275,7 +275,7 @@ function extractApprovalPath(args: unknown): string {
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const input = typeof record.input === "string" ? record.input : undefined;
if (input) {
const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
const hashlineMatch = /^\[([^#\r\n]+)(?:#[0-9a-fA-F]{4})?\]/m.exec(input);
if (hashlineMatch?.[1]) return hashlineMatch[1];
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
+114 -122
View File
@@ -2,9 +2,9 @@
* Edit tool renderer and LSP batching helpers.
*/
import { HL_FILE_PREFIX } from "@oh-my-pi/hashline";
import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
import { visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { FileDiagnosticsResult } from "../lsp";
@@ -16,7 +16,6 @@ import {
formatDiffStats,
formatExpandHint,
formatStatusIcon,
formatTitle,
getDiffStats,
getLspBatchRequest,
type LspBatchRequest,
@@ -25,7 +24,7 @@ import {
shortenPath,
truncateDiffByHunk,
} from "../tools/render-utils";
import { fileHyperlink, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import { fileHyperlink, framedBlock, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import type { EditMode } from "../utils/edit-mode";
import type { DiffError, DiffResult } from "./diff";
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
@@ -179,11 +178,6 @@ function countEditFiles(edits: EditRenderEntry[]): number {
return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
}
function countLines(text: string): number {
if (!text) return 0;
return text.split("\n").length;
}
function getOperationTitle(op: Operation | undefined): string {
return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
}
@@ -191,10 +185,14 @@ function getOperationTitle(op: Operation | undefined): string {
function formatEditPathDisplay(
rawPath: string,
uiTheme: Theme,
options?: { rename?: string; firstChangedLine?: number },
options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
): string {
// `rawPath`/`rename` are shown (cwd-relative) but the OSC 8 link targets the
// absolute path when known — a relative `rawPath` would yield a `file:///rel`
// URI that resolves against filesystem root instead of cwd.
const linkTarget = options?.linkPath || rawPath;
let pathDisplay = rawPath
? fileHyperlink(rawPath, uiTheme.fg("accent", shortenPath(rawPath)))
? fileHyperlink(linkTarget, uiTheme.fg("accent", shortenPath(rawPath)))
: uiTheme.fg("toolOutput", "…");
if (options?.firstChangedLine) {
@@ -202,7 +200,8 @@ function formatEditPathDisplay(
}
if (options?.rename) {
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(options.rename, uiTheme.fg("accent", shortenPath(options.rename)))}`;
const renameTarget = options.renameLinkPath || options.rename;
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", shortenPath(options.rename)))}`;
}
return pathDisplay;
@@ -211,7 +210,7 @@ function formatEditPathDisplay(
function formatEditDescription(
rawPath: string,
uiTheme: Theme,
options?: { rename?: string; firstChangedLine?: number },
options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
): { language: string; description: string } {
const language = getLanguageFromPath(rawPath) ?? "text";
const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language));
@@ -233,19 +232,22 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
return preview.trimEnd();
}
function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
function formatStreamingDiff(
diff: string,
rawPath: string,
uiTheme: Theme,
expanded: boolean,
label = "streaming",
): string {
if (!diff) return "";
// "Cursor" tail window: pin the last EDIT_STREAMING_PREVIEW_LINES rows to the
// bottom of the diff so freshly streamed changes stay on screen, and accept
// the trailing rows "from the back" once the diff outgrows the window. The
// whole-file diff is recomputed on every streamed chunk and its Myers
// alignment is not monotonic in payload length, so a hunk-aware window that
// kept whole change segments gained and lost rows tick to tick — the box
// stuttered, and the earlier high-water fix traded that for a half-empty
// rectangle. A strict fixed-height window keeps the box steady and always
// full of real diff context instead of blank padding.
// Collapsed uses a "Cursor" tail window: pin the last
// EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes
// stay on screen. The whole-file diff is recomputed on every streamed chunk
// and its Myers alignment is not monotonic in payload length, so a hunk-aware
// window stutters as rows move between hunks. Expanded deliberately lifts that
// cap for the approval-time full view.
const allLines = diff.replace(/\n+$/u, "").split("\n");
const hiddenLines = Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
let text = "\n\n";
if (hiddenLines > 0) {
@@ -256,19 +258,11 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
}
text += renderDiffColored(visible.join("\n"), { filePath: rawPath });
text += uiTheme.fg("dim", `\n(${label})`);
if (!expanded || label !== "preview") text += uiTheme.fg("dim", `\n(${label})`);
return text;
}
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
const icon = uiTheme.getLangIcon(language);
if (lineCount !== null) {
return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
}
return uiTheme.fg("dim", `${icon}`);
}
function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string {
function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme, expanded: boolean): string {
const parts: string[] = [];
for (const preview of previews) {
if (!preview.diff && !preview.error) continue;
@@ -278,7 +272,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
continue;
}
if (preview.diff) {
parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`);
parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview")}`);
}
}
return parts.join("");
@@ -289,16 +283,17 @@ function getCallPreview(
rawPath: string,
uiTheme: Theme,
renderContext: EditRenderContext | undefined,
expanded: boolean,
): string {
const multi = renderContext?.perFileDiffPreview;
if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
return formatMultiFileStreamingDiff(multi, uiTheme);
return formatMultiFileStreamingDiff(multi, uiTheme, expanded);
}
if (args.previewDiff) {
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview");
}
if (args.diff && args.op) {
return formatStreamingDiff(args.diff, rawPath, uiTheme);
return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded);
}
if (args.diff) {
return renderPlainTextPreview(args.diff, uiTheme, rawPath);
@@ -328,12 +323,12 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
}
function parseHashlineInputPreviewHeader(line: string): string | null {
if (!line.startsWith(HL_FILE_PREFIX)) return null;
// Mirror hashline/input.ts: strip every leading file marker so canonical
// `¶ PATH` headers and stray `¶¶ PATH` / `¶¶¶PATH` runs render clean paths.
let prefixEnd = 0;
while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
const body = line.slice(prefixEnd).trim();
const trimmed = line.trimEnd();
if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
// Keep streaming previews tolerant while the closing bracket is still
// being generated; the parser enforces the final `[path#TAG]` shape.
const bodyEnd = trimmed.endsWith(HL_FILE_SUFFIX) ? trimmed.length - HL_FILE_SUFFIX.length : trimmed.length;
const body = trimmed.slice(HL_FILE_PREFIX.length, bodyEnd).trim();
const previewPath = normalizeHashlineInputPreviewPath(body);
return previewPath.length > 0 ? previewPath : null;
}
@@ -383,6 +378,13 @@ function getApplyPatchRenderSummary(
}
}
function formatDiffStatsSuffix(diff: string, uiTheme: Theme): string {
const { added, removed, hunks } = getDiffStats(diff);
const stats = formatDiffStats(added, removed, hunks, uiTheme);
if (!stats) return "";
return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
}
function renderDiffSection(
diff: string,
rawPath: string,
@@ -390,15 +392,6 @@ function renderDiffSection(
uiTheme: Theme,
renderDiffFn: (t: string, o?: { filePath?: string }) => string,
): string {
let text = "";
const diffStats = getDiffStats(diff);
text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${formatDiffStats(
diffStats.added,
diffStats.removed,
diffStats.hunks,
uiTheme,
)}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
const {
text: truncatedDiff,
hiddenHunks,
@@ -407,7 +400,7 @@ function renderDiffSection(
? { text: diff, hiddenHunks: 0, hiddenLines: 0 }
: truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
text += `\n\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
let text = `\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
const remainder: string[] = [];
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
@@ -470,23 +463,31 @@ export const editToolRenderer = {
const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
const spinner =
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
// Show file count hint for multi-file edits
let fileCount = hashlineInputSummary?.entries.length ?? applyPatchSummary?.entries.length ?? 0;
if (Array.isArray(editArgs.edits)) {
fileCount = countEditFiles(editArgs.edits);
}
if (fileCount > 1) {
text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
}
text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
if (applyPatchSummary?.error) {
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), CALL_TEXT_PREVIEW_WIDTH))}`;
}
return new Text(text, 0, 0);
return framedBlock(uiTheme, width => {
let header = renderStatusLine(
{ icon: "pending", spinnerFrame: options?.spinnerFrame, title: getOperationTitle(op), description },
uiTheme,
);
if (fileCount > 1) header += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
let body = getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded);
if (applyPatchSummary?.error) {
body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`;
}
const bodyLines = body ? body.split("\n") : [];
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
return {
header,
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
state: applyPatchSummary?.error ? "error" : "pending",
borderColor: applyPatchSummary?.error ? "error" : "borderMuted",
width,
contentPaddingLeft: 0,
};
});
},
renderResult(
@@ -528,11 +529,6 @@ function renderSingleFileResult(
"";
const op = args?.op || firstEdit?.op || details?.op;
const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
const { language } = formatEditDescription(rawPath, uiTheme, { rename });
const editTextSource = args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch;
const metadataLineCount = editTextSource ? countLines(editTextSource) : null;
const metadataLine = op !== "delete" ? `\n${formatMetadataLine(metadataLineCount, language, uiTheme)}` : "";
const displayErrorText = isError && details && "displayErrorText" in details ? details.displayErrorText : undefined;
const errorText = isError
@@ -541,61 +537,57 @@ function renderSingleFileResult(
(result.content?.find(c => c.type === "text")?.text ?? "")
: "";
let cached: RenderCache | undefined;
return framedBlock(uiTheme, width => {
const { expanded, renderContext } = options;
const editDiffPreview = renderContext?.editDiffPreview;
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
return {
render(width) {
const { expanded, renderContext } = options;
const editDiffPreview = renderContext?.editDiffPreview;
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const firstChangedLine =
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
(details && !isError ? details.firstChangedLine : undefined);
const linkPath = details && "path" in details ? details.path : undefined;
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine, linkPath });
const firstChangedLine =
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
(details && !isError ? details.firstChangedLine : undefined);
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
// Change stats ride inline on the header bar next to the path.
const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
const headerDiff = isError ? undefined : details?.diff || previewDiff;
const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
const header =
renderStatusLine({ icon: isError ? "error" : "success", title: getOperationTitle(op), description }, uiTheme) +
statsSuffix;
const header = renderStatusLine(
{
icon: isError ? "error" : "success",
title: getOperationTitle(op),
description,
},
uiTheme,
let body = "";
if (isError) {
if (errorText) body = uiTheme.fg("error", replaceTabs(errorText, rawPath));
} else if (details?.diff) {
body = renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
} else if (editDiffPreview) {
if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath));
else if (editDiffPreview.diff)
body = renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
}
if (details?.diagnostics) {
body += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
uiTheme.getLangIcon(getLanguageFromPath(fp)),
);
let text = header;
text += metadataLine;
}
if (isError) {
if (errorText) {
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText, rawPath))}`;
}
} else if (details?.diff) {
text += renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
} else if (editDiffPreview) {
if ("error" in editDiffPreview) {
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath))}`;
} else if (editDiffPreview.diff) {
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
}
}
// Diff lines self-wrap with a continuation gutter; pre-wrap to the frame's
// inner width so renderOutputBlock's generic wrap is a no-op. Edit frames
// use a flush left border because code-frame gutters already provide padding.
const innerWidth = Math.max(1, width - 2);
const bodyLines = body.length > 0 ? body.split("\n").flatMap(line => wrapEditRendererLine(line, innerWidth)) : [];
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
if (details?.diagnostics) {
text += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
uiTheme.getLangIcon(getLanguageFromPath(fp)),
);
}
const lines =
width > 0 ? text.split("\n").flatMap(line => wrapEditRendererLine(line, width)) : text.split("\n");
cached = { key, lines };
return lines;
},
invalidate() {
cached = undefined;
},
};
return {
header,
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
state: isError ? "error" : options.isPartial ? "pending" : "success",
borderColor: isError ? "error" : "borderMuted",
width,
contentPaddingLeft: 0,
};
});
}
function renderMultiFileResult(
@@ -650,7 +642,7 @@ function renderMultiFileResult(
},
invalidate() {
cached = undefined;
for (const c of fileComponents) c.invalidate();
for (const c of fileComponents) c.invalidate?.();
},
};
}
+1 -1
View File
@@ -424,7 +424,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
return previews.length > 0 ? previews : null;
},
renderStreamingFallback() {
// Never leak raw hashline syntax (`64:`, `|payload`, `path#hash`)
// Never leak raw hashline syntax (`64:`, `|payload`, `[path#hash]`)
// to the user — the streaming preview already projects every
// parseable op onto the real file via applyPartialTo, and an
// unparseable trailing chunk renders as "no preview yet" rather
@@ -10,7 +10,7 @@ import { AgentOutputManager } from "../../task/output-manager";
import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
import type { ToolSession } from "../../tools";
import { EVAL_AGENT_MAX_DEPTH, runEvalAgent } from "../agent-bridge";
import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs } from "../heartbeat";
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
import { IdleTimeout } from "../idle-timeout";
import { disposeAllVmContexts } from "../js/context-manager";
import { executeJs } from "../js/executor";
@@ -231,12 +231,62 @@ describe("runEvalAgent", () => {
});
await expect(runEvalAgent({ prompt: "fail" }, { session: makeSession() })).rejects.toThrow("boom");
});
// Regression: a runtime-limit abort returns exitCode=1, stderr="", error=undefined,
// aborted=true, abortReason="Subagent runtime limit exceeded (...)". The previous
// failure-message coalesce stopped at the empty `stderr` (since `??` only skips
// nullish values) and shipped an empty error through the bridge — Python then
// surfaced the generic `bridge call '__agent__' failed`. See #2006.
it("surfaces abortReason for aborts that leave stderr empty", async () => {
mockAgents();
const runSpy = vi.spyOn(taskExecutor, "runSubprocess");
runSpy.mockImplementationOnce(async options =>
singleResult(options, {
exitCode: 1,
output: "",
stderr: "",
error: undefined,
aborted: true,
abortReason: "Subagent runtime limit exceeded (task.maxRuntimeMs=900000)",
}),
);
runSpy.mockImplementationOnce(async options =>
singleResult(options, {
exitCode: 1,
output: "",
stderr: " ",
error: " ",
aborted: true,
abortReason: "Cancelled by caller",
}),
);
runSpy.mockImplementationOnce(async options =>
singleResult(options, {
exitCode: 1,
output: "",
stderr: "",
error: undefined,
}),
);
await expect(runEvalAgent({ prompt: "slow" }, { session: makeSession() })).rejects.toThrow(
"Subagent runtime limit exceeded (task.maxRuntimeMs=900000)",
);
// Whitespace-only stderr/error must not mask abortReason either.
await expect(runEvalAgent({ prompt: "cancelled" }, { session: makeSession() })).rejects.toThrow(
"Cancelled by caller",
);
// Last resort: still produce a non-empty message even when nothing useful is set,
// so Python never falls back to `bridge call '__agent__' failed`.
await expect(runEvalAgent({ prompt: "blank" }, { session: makeSession() })).rejects.toThrow(
"agent() subagent 'task' failed.",
);
});
});
describe("agent() through eval runtimes", () => {
afterEach(() => {
vi.restoreAllMocks();
setBridgeHeartbeatIntervalMs();
});
afterAll(async () => {
@@ -327,18 +377,6 @@ describe("agent() through eval runtimes", () => {
singleResult(options, { output: "hello from python" }),
);
const probe = await executePython('print("probe")', {
cwd: tempDir.path(),
sessionId: `${sessionId}:probe`,
sessionFile,
kernelMode: "per-call",
});
if (probe.exitCode === undefined && probe.cancelled) {
expect(probe.output).toBe("");
return;
}
expect(probe.exitCode).toBe(0);
const result = await executePython('print(agent("hi"))', {
cwd: tempDir.path(),
sessionId,
@@ -346,6 +384,10 @@ describe("agent() through eval runtimes", () => {
kernelMode: "per-call",
toolSession: session,
});
if (result.exitCode === undefined && result.cancelled) {
expect(result.output).toBe("");
return; // kernel unavailable in this environment
}
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("hello from python");
@@ -374,22 +416,14 @@ describe("agent() through eval runtimes", () => {
}
});
const probe = await executePython('print("probe")', {
cwd: tempDir.path(),
sessionId: `${sessionId}:probe`,
sessionFile,
kernelMode: "per-call",
});
if (probe.exitCode === undefined && probe.cancelled) {
expect(probe.output).toBe("");
return;
}
expect(probe.exitCode).toBe(0);
const result = await executePython(
'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))',
{ cwd: tempDir.path(), sessionId, sessionFile, kernelMode: "per-call", toolSession: session },
);
if (result.exitCode === undefined && result.cancelled) {
expect(result.output).toBe("");
return; // kernel unavailable in this environment
}
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
@@ -413,7 +447,14 @@ describe("agent() through eval runtimes", () => {
// The host must respond the instant the cell aborts so the kernel can
// unwind via KeyboardInterrupt instead of being hard-killed (which used to
// surface "[kernel] Python kernel shutdown" and lose all session state).
let inFlight = 0;
let markSaturated: (() => void) | undefined;
const saturated = new Promise<void>(resolve => {
markSaturated = resolve;
});
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
// task.maxConcurrency=6 → six bridge calls block at once; signal then.
if (++inFlight >= 6) markSaturated?.();
await Bun.sleep(9000); // deliberately ignores options.signal
return singleResult(options, { output: options.assignment ?? "" });
});
@@ -433,8 +474,9 @@ describe("agent() through eval runtimes", () => {
expect(seed.exitCode).toBe(0);
const ac = new AbortController();
// Abort ~1s in, after the worker threads are blocked in their bridge calls.
setTimeout(() => ac.abort(new Error("external interrupt")), 1000);
// Abort the instant all six worker threads are confirmed blocked in their
// bridge calls (condition-driven) instead of waiting a fixed wall second.
void saturated.then(() => ac.abort(new Error("external interrupt")));
const start = Date.now();
const result = await executePython(
@@ -560,52 +602,52 @@ describe("agent() through eval runtimes", () => {
expect(displayAgentEvents.length).toBe(2);
});
it("keeps the idle watchdog armed while a quiet agent() runs past the budget", async () => {
using tempDir = TempDir.createSync("@omp-eval-agent-heartbeat-");
const { session } = makeEvalSession(tempDir, "js-agent-heartbeat");
it("pauses the idle watchdog while a quiet agent() runs past the budget", async () => {
using tempDir = TempDir.createSync("@omp-eval-agent-timeout-pause-");
const { session } = makeEvalSession(tempDir, "js-agent-timeout-pause");
mockAgents();
// Heartbeat cadence well under the idle budget so a working-but-silent
// subagent re-arms the watchdog several times before it could expire.
setBridgeHeartbeatIntervalMs(15);
// runSubprocess runs far past the budget and emits NO progress of its own
// — the only thing standing between the subagent and a spurious idle abort
// is the heartbeat keepalive the bridge pumps while it awaits.
// runSubprocess runs far past the eval timeout budget and emits NO progress
// of its own. The bridge pause must make that delegated time invisible to
// the watchdog.
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
await Bun.sleep(200);
await Bun.sleep(40);
return singleResult(options, { output: "done" });
});
// Mirror the eval tool's wiring: an IdleTimeout drives cancellation and
// ONLY a bridge heartbeat re-arms it.
using idle = new IdleTimeout(60);
const ops: string[] = [];
using idle = new IdleTimeout(20);
const result = await runEvalAgent(
{ prompt: "investigate" },
{
session,
signal: idle.signal,
emitStatus: event => {
if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
ops.push(event.op);
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
},
},
);
expect(idle.signal.aborted).toBe(false);
expect(result.text).toBe("done");
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
expect(idle.signal.aborted).toBe(false);
await Bun.sleep(60);
expect(idle.signal.aborted).toBe(true);
});
it("does not let agent() progress snapshots re-arm the watchdog without a heartbeat", async () => {
using tempDir = TempDir.createSync("@omp-eval-agent-progress-no-rearm-");
const { session } = makeEvalSession(tempDir, "js-agent-progress-no-rearm");
it("keeps timeout paused despite agent() progress snapshots", async () => {
using tempDir = TempDir.createSync("@omp-eval-agent-progress-timeout-pause-");
const { session } = makeEvalSession(tempDir, "js-agent-progress-timeout-pause");
mockAgents();
// Heartbeat slower than the budget: only the immediate beat at call start
// fires, so after the budget elapses nothing re-arms the watchdog.
setBridgeHeartbeatIntervalMs(10_000);
// Stream frequent progress snapshots (op:"agent") for well past the budget.
// Progress is rendered but MUST NOT count as activity — only heartbeats do.
// They render as status, but timeout accounting is controlled only by the
// bridge pause/resume events.
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
for (let i = 0; i < 40; i++) {
for (let i = 0; i < 20; i++) {
options.onProgress?.({
index: options.index,
id: options.id,
@@ -622,28 +664,30 @@ describe("agent() through eval runtimes", () => {
cost: 0,
durationMs: i * 10,
});
await Bun.sleep(10);
await Bun.sleep(5);
}
return singleResult(options, { output: "done" });
});
const ops: string[] = [];
using idle = new IdleTimeout(80);
await runEvalAgent(
using idle = new IdleTimeout(40);
const result = await runEvalAgent(
{ prompt: "investigate" },
{
session,
signal: idle.signal,
emitStatus: event => {
ops.push(event.op);
if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
},
},
);
// Progress streamed, but the watchdog still fired: agent snapshots never
// re-armed it, and the lone start heartbeat lapsed before the call ended.
expect(result.text).toBe("done");
expect(ops[0]).toBe(EVAL_TIMEOUT_PAUSE_OP);
expect(ops).toContain("agent");
expect(idle.signal.aborted).toBe(true);
expect(ops.at(-1)).toBe(EVAL_TIMEOUT_RESUME_OP);
expect(idle.signal.aborted).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from "bun:test";
import {
EVAL_TIMEOUT_PAUSE_OP,
EVAL_TIMEOUT_RESUME_OP,
isEvalTimeoutControlEvent,
withBridgeTimeoutPause,
} from "../bridge-timeout";
import type { JsStatusEvent } from "../js/shared/types";
describe("withBridgeTimeoutPause", () => {
it("emits one pause before the operation and one resume after it settles", async () => {
const events: JsStatusEvent[] = [];
const value = await withBridgeTimeoutPause(
event => events.push(event),
async () => {
await Bun.sleep(80);
return "done";
},
);
expect(value).toBe("done");
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
const settledCount = events.length;
await Bun.sleep(40);
expect(events.length).toBe(settledCount);
});
it("resumes timeout accounting even when the operation throws", async () => {
const events: JsStatusEvent[] = [];
await expect(
withBridgeTimeoutPause(
event => events.push(event),
async () => {
await Bun.sleep(20);
throw new Error("boom");
},
),
).rejects.toThrow("boom");
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
});
it("runs the operation without emitting when no status sink is wired", async () => {
let ran = 0;
const value = await withBridgeTimeoutPause(undefined, async () => {
ran++;
await Bun.sleep(20);
return 42;
});
expect(value).toBe(42);
expect(ran).toBe(1);
});
it("identifies timeout-control events as non-renderable status", () => {
expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_PAUSE_OP })).toBe(true);
expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_RESUME_OP })).toBe(true);
expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
});
});
@@ -1,84 +0,0 @@
import { afterEach, describe, expect, it } from "bun:test";
import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs, withBridgeHeartbeat } from "../heartbeat";
import type { JsStatusEvent } from "../js/shared/types";
describe("withBridgeHeartbeat", () => {
afterEach(() => {
setBridgeHeartbeatIntervalMs();
});
it("pumps heartbeat events on cadence while the operation is pending, then stops", async () => {
setBridgeHeartbeatIntervalMs(20);
const events: JsStatusEvent[] = [];
const value = await withBridgeHeartbeat(
event => events.push(event),
async () => {
await Bun.sleep(130);
return "done";
},
);
expect(value).toBe("done");
// ~6 ticks fit in 130ms at a 20ms cadence; assert it ticked repeatedly
// without pinning the exact count (scheduler jitter).
expect(events.length).toBeGreaterThanOrEqual(3);
expect(events.every(event => event.op === EVAL_HEARTBEAT_OP)).toBe(true);
// The interval is cleared once the operation settles: no further ticks.
const settledCount = events.length;
await Bun.sleep(80);
expect(events.length).toBe(settledCount);
});
it("emits a heartbeat immediately so a bridge call extends the budget at once", async () => {
// Interval far longer than the operation: the only beat that can fire is
// the immediate one at call start. It must still reach the sink.
setBridgeHeartbeatIntervalMs(10_000);
const events: JsStatusEvent[] = [];
await withBridgeHeartbeat(
event => events.push(event),
async () => {
await Bun.sleep(30);
return "done";
},
);
expect(events.length).toBe(1);
expect(events[0]?.op).toBe(EVAL_HEARTBEAT_OP);
});
it("runs the operation without emitting when no status sink is wired", async () => {
setBridgeHeartbeatIntervalMs(5);
let ran = 0;
const value = await withBridgeHeartbeat(undefined, async () => {
ran++;
await Bun.sleep(40);
return 42;
});
expect(value).toBe(42);
expect(ran).toBe(1);
});
it("clears the heartbeat even when the operation throws", async () => {
setBridgeHeartbeatIntervalMs(15);
const events: JsStatusEvent[] = [];
await expect(
withBridgeHeartbeat(
event => events.push(event),
async () => {
await Bun.sleep(60);
throw new Error("boom");
},
),
).rejects.toThrow("boom");
const afterThrow = events.length;
await Bun.sleep(60);
expect(events.length).toBe(afterThrow);
});
});
@@ -32,21 +32,34 @@ describe("IdleTimeout", () => {
expect((idle.signal.reason as DOMException).name).toBe("TimeoutError");
});
it("re-arms on every bump and only fires after activity stops", async () => {
using idle = new IdleTimeout(150);
// Bump well past a single window; each bump must push the deadline forward
// so the watchdog never trips while activity continues.
for (let i = 0; i < 6; i++) {
await Bun.sleep(40);
idle.bump();
}
it("ignores elapsed time while paused and resumes with a fresh window", async () => {
using idle = new IdleTimeout(80);
idle.pause();
await Bun.sleep(160);
expect(idle.signal.aborted).toBe(false);
// Activity stopped — the watchdog should now fire within roughly one window.
const fired = await abortedWithin(idle.signal, 800);
idle.resume();
const firedEarly = await abortedWithin(idle.signal, 30);
expect(firedEarly).toBe(false);
const fired = await abortedWithin(idle.signal, 500);
expect(fired).toBe(true);
});
it("reference-counts overlapping pauses", async () => {
using idle = new IdleTimeout(60);
idle.pause();
idle.pause();
await Bun.sleep(120);
expect(idle.signal.aborted).toBe(false);
idle.resume();
await Bun.sleep(90);
expect(idle.signal.aborted).toBe(false);
idle.resume();
const fired = await abortedWithin(idle.signal, 500);
expect(fired).toBe(true);
});
it("never fires after dispose()", async () => {
const idle = new IdleTimeout(30);
idle.dispose();
@@ -55,12 +68,13 @@ describe("IdleTimeout", () => {
expect(idle.signal.aborted).toBe(false);
});
it("ignores bump() after the watchdog has already fired", async () => {
it("ignores pause/resume after the watchdog has already fired", async () => {
using idle = new IdleTimeout(30);
await abortedWithin(idle.signal, 500);
expect(idle.signal.aborted).toBe(true);
// Late activity must not un-abort or rearm a settled watchdog.
idle.bump();
idle.pause();
idle.resume();
expect(idle.signal.aborted).toBe(true);
});
});
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it } from "bun:test";
import {
__resetWindowsConsoleProbeCache,
consoleAttachedViaTTY,
hostHasInheritableConsole,
shouldHideKernelWindow,
} from "../py/spawn-options";
/**
* `shouldHideKernelWindow` decides whether the long-lived Python kernel
* subprocess is spawned with `windowsHide: true`. On Windows, Bun maps that
* option to `CREATE_NO_WINDOW`, which detaches the child from any inherited
* console — breaking both (a) `LoadLibraryExW` for NumPy/pandas native
* extensions and (b) SIGINT delivery via `GenerateConsoleCtrlEvent`. See
* issue #1960. The tests below pin the three layered concerns the PR review
* surfaced:
*
* 1. `shouldHideKernelWindow` — pure predicate over a single boolean.
* 2. `consoleAttachedViaTTY` — the TTY-OR fallback used when the Win32 FFI
* probe is unavailable; covers the partial-redirection cases.
* 3. `hostHasInheritableConsole` — the integration boundary. Off-Windows it
* short-circuits to the TTY fallback; on Windows it is expected to
* consult `kernel32!GetConsoleWindow()` first, which is the authoritative
* signal even for the all-stdio-redirected case.
*/
describe("shouldHideKernelWindow", () => {
it("inherits the host console on Windows when one is attached", () => {
// Reporter's repro: omp launched in Windows Terminal, host has a
// console, kernel must inherit so `import pandas` doesn't deadlock in
// `_multiarray_umath` and SIGINT can recover the cell.
expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: true })).toBe(false);
});
it("hides on Windows only when the host has no console at all (true service / daemon)", () => {
// CREATE_NO_WINDOW here suppresses the console window Windows would
// otherwise auto-allocate for the console-app Python kernel.
expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: false })).toBe(true);
});
it("never sets windowsHide off-Windows (the option is a Win32-only flag)", () => {
// On POSIX `windowsHide` is a no-op; the predicate must return false
// everywhere off-Windows so the spawn site matches pre-fix behavior.
expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: true })).toBe(false);
expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: false })).toBe(false);
expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: true })).toBe(false);
expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: false })).toBe(false);
});
});
describe("consoleAttachedViaTTY (FFI fallback heuristic)", () => {
// The OR of three TTY signals correctly classifies the realistic shell
// redirection scenarios that motivated widening the check beyond stdout
// in the first review pass (PR #1961). The all-three-redirected case
// (false here) is the gap that the Win32 FFI probe in
// `hostHasInheritableConsole` is meant to close — this fallback is best-
// effort.
it("treats a fully interactive launch as console-attached", () => {
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
});
it("treats `omp -p '...' > out.txt` (stdout-only redirect) as console-attached", () => {
// The reviewer's first-pass repro: stdout off the terminal, stdin
// and stderr still attached. OR keeps the console.
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: false, stderrIsTTY: true })).toBe(true);
});
it("treats stdin-only redirects (`< in.txt`) as console-attached", () => {
expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
});
it("treats stderr-only redirects (`2> err.log`) as console-attached", () => {
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: false })).toBe(true);
});
it("returns false only when none of stdin/stdout/stderr is a TTY", () => {
// This is the gap: a real Windows Terminal session with all three
// streams redirected (`omp ... < in > out 2> err`) lands here.
// `hostHasInheritableConsole` uses the Win32 FFI probe to recover
// the right answer in that scenario; this helper is the fallback.
expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: false, stderrIsTTY: false })).toBe(false);
});
});
describe("hostHasInheritableConsole", () => {
afterEach(() => {
__resetWindowsConsoleProbeCache();
});
if (process.platform !== "win32") {
it("matches the TTY-OR fallback off-Windows", () => {
// Off-Windows, `windowsHide` is a no-op anyway, but we still
// expose `hostHasInheritableConsole` symmetrically. Confirm it
// degrades to the same OR the call site would compute by hand.
const tty = consoleAttachedViaTTY({
stdinIsTTY: !!process.stdin.isTTY,
stdoutIsTTY: !!process.stdout.isTTY,
stderrIsTTY: !!process.stderr.isTTY,
});
expect(hostHasInheritableConsole()).toBe(tty);
});
}
});
@@ -4,16 +4,17 @@ import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import * as ai from "@oh-my-pi/pi-ai";
import { Effort } from "@oh-my-pi/pi-ai";
import { TempDir } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import type { ModelRegistry } from "../../config/model-registry";
import { Settings } from "../../config/settings";
import type { ToolSession } from "../../tools";
import { ToolError } from "../../tools/tool-errors";
import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs } from "../heartbeat";
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
import { IdleTimeout } from "../idle-timeout";
import { disposeAllVmContexts } from "../js/context-manager";
import { executeJs } from "../js/executor";
import { runEvalLlm } from "../llm-bridge";
import { disposeAllKernelSessions, executePython } from "../py/executor";
import { disposeAllKernelSessions, type PythonResult } from "../py/executor";
function makeModel(provider: string, id: string, extra: Partial<Model<Api>> = {}): Model<Api> {
return {
@@ -57,6 +58,7 @@ function makeSession(opts: SessionOptions = {}): ToolSession {
const modelRegistry = {
getAvailable: () => opts.available ?? [SMOL, DEFAULT, SLOW],
getApiKey: async () => (opts.apiKey === undefined ? "test-key" : opts.apiKey),
resolver: () => async () => (opts.apiKey === undefined ? "test-key" : opts.apiKey),
} as unknown as ModelRegistry;
return {
settings,
@@ -96,10 +98,80 @@ function assistant(opts: {
};
}
async function runPythonLlmInSubprocess(options: { structured: boolean; tempDir: TempDir }): Promise<PythonResult> {
const repoRoot = path.resolve(import.meta.dir, "../../../..");
const scriptPath = path.join(options.tempDir.path(), "run-python-llm.ts");
const resultPath = path.join(options.tempDir.path(), "python-llm-result.json");
const aiPath = path.resolve(import.meta.dir, "../../../../ai/src/index.ts");
const executorPath = path.resolve(import.meta.dir, "../py/executor.ts");
const settingsPath = path.resolve(import.meta.dir, "../../config/settings.ts");
const code = options.structured
? 'import json\nprint(json.dumps(llm("hi", schema={"type": "object"})))'
: 'print(llm("hi", model="smol"))';
const responseContent = options.structured
? '[{ type: "toolCall", id: "tc-1", name: "respond", arguments: { ok: true } }]'
: '[{ type: "text", text: "hello from python" }]';
await Bun.write(
scriptPath,
`
import { vi } from "bun:test";
import * as ai from ${JSON.stringify(aiPath)};
import { executePython } from ${JSON.stringify(executorPath)};
import { Settings } from ${JSON.stringify(settingsPath)};
const SMOL = {
id: "smol",
name: "smol",
api: "openai-responses",
provider: "p",
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 4096,
};
const settings = Settings.isolated({ "async.enabled": false, "task.isolation.mode": "none" });
settings.setModelRole("smol", "p/smol");
settings.setModelRole("slow", "p/slow");
const session = {
settings,
modelRegistry: {
getAvailable: () => [SMOL],
getApiKey: async () => "test-key",
resolver: () => async () => "test-key",
},
getActiveModelString: () => "p/smol",
};
vi.spyOn(ai, "completeSimple").mockResolvedValue({
role: "assistant",
api: "openai-responses",
provider: "p",
model: "smol",
stopReason: "stop",
content: ${responseContent},
});
const result = await executePython(${JSON.stringify(code)}, {
cwd: ${JSON.stringify(options.tempDir.path())},
sessionId: ${JSON.stringify(`py-llm:${options.structured ? "struct" : "plain"}`)},
sessionFile: ${JSON.stringify(path.join(options.tempDir.path(), "session.jsonl"))},
toolSession: session,
kernelMode: "per-call",
});
await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify(result));
process.exit(0);
`,
);
const child = await $`bun ${scriptPath}`.cwd(repoRoot).quiet().nothrow();
const stdout = child.stdout.toString();
const stderr = child.stderr.toString();
if (child.exitCode !== 0) throw new Error(stderr || stdout || `Python llm subprocess exited with ${child.exitCode}`);
return (await Bun.file(resultPath).json()) as PythonResult;
}
describe("runEvalLlm", () => {
afterEach(() => {
vi.restoreAllMocks();
setBridgeHeartbeatIntervalMs();
});
it("resolves each tier to its expected model", async () => {
@@ -217,31 +289,32 @@ describe("runEvalLlm", () => {
);
});
it("keeps the idle watchdog armed while a slow llm() request is in flight", async () => {
// A oneshot completion emits no status until it returns; a slow request
// must not look like a stalled cell. The bridge pumps a heartbeat while it
// awaits, re-arming the watchdog through emitStatus.
setBridgeHeartbeatIntervalMs(15);
it("pauses the idle watchdog while a slow llm() request is in flight", async () => {
// A oneshot completion emits no status until it returns; delegated model
// time must be invisible to the eval timeout budget.
vi.spyOn(ai, "completeSimple").mockImplementation(async () => {
await Bun.sleep(200);
return assistant({ text: "the answer" });
});
const ops: string[] = [];
using idle = new IdleTimeout(60);
const result = await runEvalLlm(
{ prompt: "q", model: "smol" },
{
session: makeSession(),
signal: idle.signal,
// Mirror the eval tool: only a bridge heartbeat re-arms the watchdog.
emitStatus: event => {
if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
ops.push(event.op);
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
},
},
);
expect(idle.signal.aborted).toBe(false);
expect(result.text).toBe("the answer");
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "llm"]);
expect(idle.signal.aborted).toBe(false);
});
});
@@ -290,38 +363,24 @@ describe("llm() through eval runtimes", () => {
});
it("exposes llm() in the Python runtime", async () => {
using tempDir = TempDir.createSync("@omp-eval-llm-py-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-llm:${crypto.randomUUID()}`;
vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "hello from python" }));
const result = await executePython('print(llm("hi", model="smol"))', {
cwd: tempDir.path(),
sessionId,
sessionFile,
toolSession: makeSession(),
});
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("hello from python");
const tempDir = TempDir.createSync("@omp-eval-llm-py-");
try {
const result = await runPythonLlmInSubprocess({ structured: false, tempDir });
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("hello from python");
} finally {
tempDir.removeSync();
}
});
it("parses structured llm() output in the Python runtime", async () => {
using tempDir = TempDir.createSync("@omp-eval-llm-py-struct-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-llm-struct:${crypto.randomUUID()}`;
vi.spyOn(ai, "completeSimple").mockResolvedValue(
assistant({ toolCall: { name: "respond", arguments: { ok: true } } }),
);
const result = await executePython('import json\nprint(json.dumps(llm("hi", schema={"type": "object"})))', {
cwd: tempDir.path(),
sessionId,
sessionFile,
toolSession: makeSession(),
});
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.output.trim())).toEqual({ ok: true });
const tempDir = TempDir.createSync("@omp-eval-llm-py-struct-");
try {
const result = await runPythonLlmInSubprocess({ structured: true, tempDir });
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.output.trim())).toEqual({ ok: true });
} finally {
tempDir.removeSync();
}
});
});
@@ -1,609 +0,0 @@
import { afterAll, afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { TempDir } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../../config/model-registry";
import { Settings } from "../../config/settings";
import type { LoadExtensionsResult } from "../../extensibility/extensions/types";
import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "../../sdk";
import * as sdkModule from "../../sdk";
import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../session/agent-session";
import { TaskTool } from "../../task";
import * as discoveryModule from "../../task/discovery";
import type { AgentDefinition, TaskParams } from "../../task/types";
import type { ToolSession } from "../../tools";
import { EventBus } from "../../utils/event-bus";
import { disposeAllVmContexts } from "../js/context-manager";
import { executeJs } from "../js/executor";
import { disposeAllKernelSessions, executePython } from "../py/executor";
function createToolSession(cwd: string, sessionFile: string | null, evalSessionId?: string): ToolSession {
const modelRegistry = {
authStorage: undefined,
refresh: async () => {},
getAvailable: () => [],
getApiKey: async () => null,
} as unknown as ModelRegistry;
return {
cwd,
hasUI: false,
settings: Settings.isolated({
"async.enabled": false,
"task.isolation.mode": "none",
}),
getSessionFile: () => sessionFile,
getSessionSpawns: () => "*",
getEvalSessionId: evalSessionId ? () => evalSessionId : undefined,
modelRegistry,
} as unknown as ToolSession;
}
function createBridgeToolSession(resultText: string, calls: unknown[]): ToolSession {
const readTool = {
name: "read",
label: "read",
description: "read",
parameters: { type: "object" },
async execute(_id: string, args: unknown) {
calls.push(args);
return { content: [{ type: "text" as const, text: resultText }] };
},
};
const tools = new Map<string, unknown>([["read", readTool]]);
return { getToolByName: (name: string) => tools.get(name) } as unknown as ToolSession;
}
function assistantStopMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: text ? [{ type: "text", text }] : [],
api: "openai-responses",
provider: "openai",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
function createYieldingSubagentSession(onPrompt: () => Promise<void>): AgentSession {
const listeners: Array<(event: AgentSessionEvent) => void> = [];
const state = { messages: [] as AssistantMessage[] };
const emit = (event: AgentSessionEvent) => {
for (const listener of listeners) listener(event);
};
return {
state,
agent: { state: { systemPrompt: ["test"] } },
model: undefined,
extensionRunner: undefined,
sessionManager: {
appendSessionInit: () => {},
},
getActiveToolNames: () => ["eval", "yield"],
setActiveToolsByName: async () => {},
subscribe: (listener: (event: AgentSessionEvent) => void) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index >= 0) listeners.splice(index, 1);
};
},
prompt: async (_text: string, _options?: PromptOptions) => {
await onPrompt();
state.messages.push(assistantStopMessage("done"));
emit({
type: "tool_execution_end",
toolCallId: "yield-call",
toolName: "yield",
result: {
content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { ok: true } },
},
isError: false,
});
},
waitForIdle: async () => {},
getLastAssistantMessage: () => state.messages[state.messages.length - 1],
abort: async () => {},
dispose: async () => {},
} as unknown as AgentSession;
}
const taskAgent: AgentDefinition = {
name: "task",
description: "Task agent",
systemPrompt: "Read eval state and yield.",
source: "bundled",
tools: ["eval", "yield"],
};
const taskParams: TaskParams = {
agent: "task",
tasks: [{ id: "ReadEval", description: "Read eval state", assignment: "Read parent eval state." }],
};
describe("shared eval executors", () => {
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await disposeAllVmContexts();
await disposeAllKernelSessions();
});
it("shares JavaScript state across executeJs calls with one session id", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-shared-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-shared:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
await executeJs("globalThis.x = 41;", { sessionId, session, sessionFile });
const result = await executeJs("return globalThis.x + 1;", { sessionId, session, sessionFile });
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("42");
});
it("treats idleTimeoutMs as an inactivity budget, not a fixed timer", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-idle-budget-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-idle-budget:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
// With no wall-clock deadlineMs/timeoutMs and no aborting signal, a cell that
// runs well past idleTimeoutMs must still complete: the backend must never
// derive a competing fixed timer from the inactivity budget.
const result = await executeJs("await Bun.sleep(120); return 'done';", {
sessionId,
session,
sessionFile,
idleTimeoutMs: 30,
});
expect(result.cancelled).toBe(false);
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("done");
});
it("shares Python state across executePython calls with one session id", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-shared-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-shared:${crypto.randomUUID()}`;
await executePython("x = 41", { cwd: tempDir.path(), sessionId, sessionFile });
const result = await executePython("print(x + 1)", { cwd: tempDir.path(), sessionId, sessionFile });
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("42");
});
it("deduplicates concurrent first JavaScript session acquisition", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-cold-start-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-cold-start:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const [first, second] = await Promise.all([
executeJs(
"globalThis.sharedMarker ??= crypto.randomUUID(); await Bun.sleep(50); return globalThis.sharedMarker;",
{
sessionId,
session,
sessionFile,
},
),
executeJs("globalThis.sharedMarker ??= crypto.randomUUID(); return globalThis.sharedMarker;", {
sessionId,
session,
sessionFile,
}),
]);
const third = await executeJs("return globalThis.sharedMarker;", { sessionId, session, sessionFile });
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(third.exitCode).toBe(0);
expect(first.output.trim()).toBe(second.output.trim());
expect(third.output.trim()).toBe(first.output.trim());
});
it("deduplicates concurrent first Python session acquisition", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-cold-start-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-cold-start:${crypto.randomUUID()}`;
const [first, second] = await Promise.all([
executePython(
`import asyncio, uuid
shared_marker = globals().get("shared_marker") or str(uuid.uuid4())
globals()["shared_marker"] = shared_marker
await asyncio.sleep(0.05)
print(shared_marker)`,
{ cwd: tempDir.path(), sessionId, sessionFile },
),
executePython(
`import uuid
shared_marker = globals().get("shared_marker") or str(uuid.uuid4())
globals()["shared_marker"] = shared_marker
print(shared_marker)`,
{ cwd: tempDir.path(), sessionId, sessionFile },
),
]);
const third = await executePython("print(shared_marker)", { cwd: tempDir.path(), sessionId, sessionFile });
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(third.exitCode).toBe(0);
expect(first.output.trim()).toBe(second.output.trim());
expect(third.output.trim()).toBe(first.output.trim());
});
it("splits retained Python kernels by cwd for one shared session id", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-cwd-");
const dirA = path.join(tempDir.path(), "a");
const dirB = path.join(tempDir.path(), "b");
await fs.mkdir(dirA);
await fs.mkdir(dirB);
const realDirA = await fs.realpath(dirA);
const realDirB = await fs.realpath(dirB);
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-cwd:${crypto.randomUUID()}`;
const first = await executePython(
`import os
token = "from-a"
print(os.getcwd())`,
{
cwd: dirA,
sessionId,
sessionFile,
},
);
const second = await executePython(
`import os
print(os.getcwd())
print("token" in globals())`,
{
cwd: dirB,
sessionId,
sessionFile,
},
);
const third = await executePython("print(token)", { cwd: dirA, sessionId, sessionFile });
expect(first.exitCode).toBe(0);
expect(first.output.trim()).toBe(realDirA);
expect(second.exitCode).toBe(0);
expect(second.output.trim().split("\n")).toEqual([realDirB, "False"]);
expect(third.exitCode).toBe(0);
expect(third.output.trim()).toBe("from-a");
});
it("interrupts timed out synchronous Python cells before they mutate shared state", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-sync-timeout-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-sync-timeout:${crypto.randomUUID()}`;
const timedOut = await executePython("import time\ntime.sleep(0.2)\nleaked_after_timeout = True", {
cwd: tempDir.path(),
sessionId,
sessionFile,
timeoutMs: 20,
});
await Bun.sleep(250);
const probe = await executePython('print("leaked_after_timeout" in globals())', {
cwd: tempDir.path(),
sessionId,
sessionFile,
});
expect(timedOut.cancelled).toBe(true);
expect(probe.exitCode).toBe(0);
expect(probe.output.trim()).toBe("False");
});
it("settles Python cells that raise SystemExit", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-system-exit-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-system-exit:${crypto.randomUUID()}`;
const result = await executePython('raise SystemExit("bye")', {
cwd: tempDir.path(),
sessionId,
sessionFile,
timeoutMs: 500,
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain("SystemExit");
expect(result.output).toContain("bye");
});
it("lets a subagent inherit parent JavaScript and Python eval state", async () => {
using tempDir = TempDir.createSync("@omp-eval-subagent-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const evalSessionId = `session:${sessionFile}:cwd:${tempDir.path()}`;
const parentSession = createToolSession(tempDir.path(), sessionFile, evalSessionId);
let seenJs = "";
let seenPy = "";
let capturedOptions: CreateAgentSessionOptions | undefined;
await executeJs('globalThis.parentSecret = "hello-js";', {
sessionId: `js:${evalSessionId}`,
session: parentSession,
sessionFile,
});
await executePython('parent_secret = "hello-py"', {
cwd: tempDir.path(),
sessionId: `python:${evalSessionId}`,
sessionFile,
});
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [taskAgent], projectAgentsDir: null });
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async (options = {}) => {
capturedOptions = options;
const inherited = options.parentEvalSessionId;
if (!inherited) throw new Error("Missing parent eval session id");
return {
session: createYieldingSubagentSession(async () => {
const jsResult = await executeJs("return globalThis.parentSecret;", {
sessionId: `js:${inherited}`,
session: parentSession,
sessionFile,
});
const pyResult = await executePython("print(parent_secret)", {
cwd: tempDir.path(),
sessionId: `python:${inherited}`,
sessionFile,
});
seenJs = jsResult.output.trim();
seenPy = pyResult.output.trim();
}),
extensionsResult: {} as unknown as LoadExtensionsResult,
setToolUIContext: () => {},
eventBus: new EventBus(),
} satisfies CreateAgentSessionResult;
});
const tool = await TaskTool.create(parentSession);
await tool.execute("tool-call", taskParams);
expect(capturedOptions?.parentEvalSessionId).toBe(evalSessionId);
expect(seenJs).toBe("hello-js");
expect(seenPy).toBe("hello-py");
});
it("routes interleaved JavaScript display output to the matching run", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-interleave-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-interleave:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const first = executeJs('await Bun.sleep(80); display({ label: "A" });', {
sessionId,
session,
sessionFile,
});
await Bun.sleep(10);
const second = executeJs('display({ label: "B" });', {
sessionId,
session,
sessionFile,
});
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.exitCode).toBe(0);
expect(secondResult.exitCode).toBe(0);
expect(firstResult.displayOutputs).toEqual([{ type: "json", data: { label: "A" } }]);
expect(secondResult.displayOutputs).toEqual([{ type: "json", data: { label: "B" } }]);
});
it("routes interleaved Python display output to the matching run", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-interleave-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-interleave:${crypto.randomUUID()}`;
const first = executePython(
`import asyncio
await asyncio.sleep(0.08)
display({"label": "A"})`,
{
cwd: tempDir.path(),
sessionId,
sessionFile,
},
);
await Bun.sleep(10);
const second = executePython('display({"label": "B"})', {
cwd: tempDir.path(),
sessionId,
sessionFile,
});
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.exitCode).toBe(0);
expect(secondResult.exitCode).toBe(0);
expect(firstResult.displayOutputs).toEqual([{ type: "json", data: { label: "A" } }]);
expect(secondResult.displayOutputs).toEqual([{ type: "json", data: { label: "B" } }]);
});
it("preserves module-level singleton state across re-imports of an unchanged file", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-mtime-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-mtime:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const modulePath = path.join(tempDir.path(), "singleton.ts");
const moduleSpec = JSON.stringify(modulePath);
await Bun.write(
modulePath,
"let value = 0;\nexport function set(v) { value = v; }\nexport function get() { return value; }\n",
);
const initResult = await executeJs(`const mod = await import(${moduleSpec}); mod.set(42); return mod.get();`, {
sessionId,
session,
sessionFile,
});
expect(initResult.exitCode).toBe(0);
expect(initResult.output.trim()).toBe("42");
// Unchanged file: re-import must reuse the existing module namespace so the
// counter is still 42. This is the regression — the previous unconditional
// `delete require.cache[target]` reset singletons on every dynamic import.
const reuseResult = await executeJs(`const mod = await import(${moduleSpec}); return mod.get();`, {
sessionId,
session,
sessionFile,
});
expect(reuseResult.exitCode).toBe(0);
expect(reuseResult.output.trim()).toBe("42");
// Bump mtime by 5s to simulate an edit; the next import must evict the cache
// and re-evaluate the file, dropping the counter back to its initializer.
const future = new Date(Date.now() + 5_000);
await fs.utimes(modulePath, future, future);
const reloadResult = await executeJs(`const mod = await import(${moduleSpec}); return mod.get();`, {
sessionId,
session,
sessionFile,
});
expect(reloadResult.exitCode).toBe(0);
expect(reloadResult.output.trim()).toBe("0");
});
it("reloads a local re-export when a transitive dependency changes", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-transitive-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-transitive:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const leafPath = path.join(tempDir.path(), "leaf.ts");
const entryPath = path.join(tempDir.path(), "entry.ts");
const entrySpec = JSON.stringify(entryPath);
await Bun.write(leafPath, "export const value = 1;\n");
await Bun.write(entryPath, 'export { value } from "./leaf.ts";\n');
const initial = await executeJs(`const mod = await import(${entrySpec}); return mod.value;`, {
sessionId,
session,
sessionFile,
});
expect(initial.exitCode).toBe(0);
expect(initial.output.trim()).toBe("1");
await Bun.write(leafPath, "export const value = 2;\n");
const future = new Date(Date.now() + 5_000);
await fs.utimes(leafPath, future, future);
const reloaded = await executeJs(`const mod = await import(${entrySpec}); return mod.value;`, {
sessionId,
session,
sessionFile,
});
expect(reloaded.exitCode).toBe(0);
expect(reloaded.output.trim()).toBe("2");
});
it("links a cyclic local module graph without crashing", async () => {
// Regression: the loader used to link()+evaluate() each local module individually
// inside the recursive linker callback. On any import cycle that re-entered Bun's
// node:vm linker mid-instantiation and segfaulted the process (SIGTRAP,
// getImportedModule on a null record) — e.g. `await import("…/edit/streaming.ts")`,
// whose relative-import subtree is cyclic. The graph must now link in a single pass.
using tempDir = TempDir.createSync("@omp-eval-js-cycle-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-cycle:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const alphaPath = path.join(tempDir.path(), "alpha.ts");
const betaPath = path.join(tempDir.path(), "beta.ts");
const alphaSpec = JSON.stringify(alphaPath);
const betaSpec = JSON.stringify(betaPath);
await Bun.write(
alphaPath,
'import { betaName } from "./beta.ts";\nexport const alphaName = "alpha";\nexport function combined() { return alphaName + ":" + betaName; }\n',
);
await Bun.write(
betaPath,
'import { alphaName } from "./alpha.ts";\nexport const betaName = "beta";\nexport function viaAlpha() { return alphaName; }\n',
);
const result = await executeJs(
`const a = await import(${alphaSpec});\nconst b = await import(${betaSpec});\nreturn [a.combined(), b.viaAlpha()].join("|");`,
{ sessionId, session, sessionFile },
);
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("alpha:beta|alpha");
});
it("loads TypeScript type-only imports in cells and local modules", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-type-imports-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-type-imports:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const typesPath = path.join(tempDir.path(), "types.ts");
const valuesPath = path.join(tempDir.path(), "values.ts");
const entryPath = path.join(tempDir.path(), "entry.ts");
const typesSpec = JSON.stringify(typesPath);
const entrySpec = JSON.stringify(entryPath);
await Bun.write(typesPath, "export interface TypeOnly { value: number }\n");
await Bun.write(valuesPath, "export interface InlineOnly { value: number }\nexport const imported = 41;\n");
await Bun.write(
entryPath,
[
'import type { TypeOnly } from "./types.ts";',
'import { type InlineOnly, imported } from "./values.ts";',
"export const typeOnly = 1;",
"export const inlineType = imported;",
"",
].join("\n"),
);
const result = await executeJs(
`import type { TypeOnly } from ${typesSpec};\nconst mod = await import(${entrySpec});\nreturn mod.typeOnly + mod.inlineType;`,
{
sessionId,
session,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("42");
});
it("refreshes the Python tool proxy when bridge env appears after kernel warm-up", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-tool-proxy-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-tool-proxy:${crypto.randomUUID()}`;
const bridgeCalls: unknown[] = [];
const bridgeSession = createBridgeToolSession("bridge-ok", bridgeCalls);
const withoutBridge = await executePython(
'try:\n print(tool.read({"path": "foo.txt"}))\nexcept Exception as exc:\n print(type(exc).__name__)\n print(str(exc))',
{ cwd: tempDir.path(), sessionId, sessionFile },
);
const withBridge = await executePython('print(tool.read({"path": "foo.txt"}))', {
cwd: tempDir.path(),
sessionId,
sessionFile,
toolSession: bridgeSession,
});
expect(withoutBridge.exitCode).toBe(0);
expect(withoutBridge.output).toContain("RuntimeError");
expect(withoutBridge.output).toContain("tool bridge is unavailable");
expect(withBridge.exitCode).toBe(0);
expect(withBridge.output.trim()).toBe("bridge-ok");
expect(bridgeCalls).toEqual([{ path: "foo.txt", _i: "py prelude" }]);
});
});
+38 -12
View File
@@ -13,10 +13,10 @@ import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.m
import * as taskDiscovery from "../task/discovery";
import * as taskExecutor from "../task/executor";
import { AgentOutputManager } from "../task/output-manager";
import type { AgentDefinition, AgentProgress } from "../task/types";
import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types";
import type { ToolSession } from "../tools";
import { ToolError } from "../tools/tool-errors";
import { withBridgeHeartbeat } from "./heartbeat";
import { withBridgeTimeoutPause } from "./bridge-timeout";
import type { JsStatusEvent } from "./js/shared/types";
// Import review tools for side effects (registers subagent tool handlers).
import "../tools/review";
@@ -173,6 +173,26 @@ function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undef
});
}
/**
* Coalesce a subagent failure into a non-empty, human-meaningful error message.
*
* When the executor aborts a subagent (runtime limit, parent cancellation, …)
* the actionable explanation lives on `abortReason`, while `error`/`stderr`
* are routinely empty strings. Plain `??` coalescing stops at the empty string
* and ships an empty error through the bridge — Python then surfaces only the
* generic `bridge call '__agent__' failed`. See #2006.
*/
function buildSubagentFailureMessage(agentName: string, result: SingleResult): string {
const abortReason = trimToUndefined(result.abortReason);
if (result.aborted && abortReason) return abortReason;
return (
trimToUndefined(result.error) ??
trimToUndefined(result.stderr) ??
abortReason ??
`agent() subagent '${agentName}' failed.`
);
}
/**
* Run a single subagent on behalf of an eval cell's `agent()` call.
*/
@@ -225,17 +245,15 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
getSessionId: options.session.getSessionId ?? (() => null),
};
const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined;
const parentEvalSessionId = options.session.getEvalSessionId?.() ?? undefined;
const mcpManager = options.session.mcpManager ?? MCPManager.instance();
const { sessionFile, artifactsDir, contextFile } = await getArtifacts(options.session);
const outputManager = getOutputManager(options.session);
const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
const assignment = parsed.prompt.trim();
const context = trimToUndefined(parsed.context);
// Pump a heartbeat while the subagent runs so the eval idle watchdog stays
// armed across quiet stretches (time-to-first-token, long nested tools)
// where `onProgress` would otherwise emit no status to re-arm it.
const result = await withBridgeHeartbeat(options.emitStatus, () =>
// Suspend eval timeout accounting while the subagent owns control. The
// timeout clock restarts once the bridge returns to the cell runtime.
const result = await withBridgeTimeoutPause(options.emitStatus, () =>
taskExecutor.runSubprocess({
cwd: options.session.cwd,
agent: effectiveAgent,
@@ -261,6 +279,12 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
authStorage: options.session.authStorage,
modelRegistry: options.session.modelRegistry,
settings: options.session.settings,
// Eval `agent()` subagents are never wall-clock capped: the parent
// cell's idle watchdog is suspended for the whole bridge call
// (withBridgeTimeoutPause), so a long-running phase/recovery workflow
// must not be killed by `task.maxRuntimeMs`. Force the limit off
// regardless of the inherited session setting.
maxRuntimeMs: 0,
mcpManager,
contextFiles,
skills: availableSkills,
@@ -272,14 +296,16 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
parentHindsightSessionState: options.session.getHindsightSessionState?.(),
parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
parentTelemetry: options.session.getTelemetry?.(),
parentEvalSessionId,
// Deliberately omit parentEvalSessionId: the parent's Python kernel is
// blocked on this bridge call, so sharing the eval session would deadlock
// (subagent queues behind the parent's in-flight execution, parent waits
// for subagent → circular). Each bridge-spawned subagent gets its own
// eval session with an independent kernel.
}),
);
if (result.exitCode !== 0 || result.error) {
const failureMessage =
result.error ?? result.stderr ?? result.abortReason ?? `agent() subagent '${agentName}' failed.`;
throw new ToolError(failureMessage);
if (result.exitCode !== 0 || result.error || result.aborted) {
throw new ToolError(buildSubagentFailureMessage(agentName, result));
}
options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0);
+6 -6
View File
@@ -10,12 +10,12 @@ export interface ExecutorBackendExecOptions {
signal?: AbortSignal;
session: ToolSession;
/**
* Inactivity budget in milliseconds (the cell's `timeout`). Cancellation is
* driven entirely by `signal`, which the eval tool arms as an idle watchdog
* that fires a `TimeoutError` reason after this much time with no progress
* (status) events. Backends use this value only for timeout-annotation text
* and as cold-start headroom; they MUST NOT derive a competing wall-clock
* timer from it.
* Runtime-work budget in milliseconds (the cell's `timeout`). Cancellation is
* driven entirely by `signal`, which the eval tool arms as a watchdog that
* pauses on bridge timeout-control status events and fires a `TimeoutError`
* reason only while the Python/JS runtime owns control. Backends use this
* value only for timeout-annotation text and as cold-start headroom; they MUST
* NOT derive a competing wall-clock timer from it.
*/
idleTimeoutMs: number;
reset: boolean;
@@ -0,0 +1,44 @@
/**
* Timeout suspension for in-flight host-side eval bridge calls.
*
* The eval watchdog caps a cell's `timeout` as a budget on the cell runtime's
* own work. Host-side `agent()` / `parallel()` / `llm()` bridge calls hand
* control to the outer TypeScript process, where the Python kernel or JS VM is
* only waiting for a result. While that delegated work is in flight, the cell
* timeout must be ignored completely; once the bridge returns and the runtime is
* back in control, the watchdog starts a fresh timeout window.
*
* Bridge helpers express that handoff with synthetic pause/resume status events
* on the existing `emitStatus → onStatus` path. Consumers MUST treat these as
* timeout-control events only: update the watchdog and drop them from rendered
* or persisted cell output.
*/
import type { JsStatusEvent } from "./js/shared/types";
/** Synthetic status op emitted when a bridge call leaves the cell runtime. */
export const EVAL_TIMEOUT_PAUSE_OP = "timeout-pause";
/** Synthetic status op emitted when a bridge call returns control to the runtime. */
export const EVAL_TIMEOUT_RESUME_OP = "timeout-resume";
/** Whether a status event is pure eval-timeout control and should not render. */
export function isEvalTimeoutControlEvent(event: JsStatusEvent): boolean {
return event.op === EVAL_TIMEOUT_PAUSE_OP || event.op === EVAL_TIMEOUT_RESUME_OP;
}
/**
* Run {@link operation} while suspending the eval watchdog through
* {@link emitStatus}. A no-op wrapper when no status sink is wired.
*/
export async function withBridgeTimeoutPause<T>(
emitStatus: ((event: JsStatusEvent) => void) | undefined,
operation: () => Promise<T>,
): Promise<T> {
if (!emitStatus) return operation();
emitStatus({ op: EVAL_TIMEOUT_PAUSE_OP });
try {
return await operation();
} finally {
emitStatus({ op: EVAL_TIMEOUT_RESUME_OP });
}
}
@@ -1,74 +0,0 @@
/**
* Keepalive for in-flight host-side eval bridge calls.
*
* The eval watchdog ({@link ../tools/eval IdleTimeout}) caps a cell's `timeout`
* as a wall-clock budget on the cell's *own* work, but pauses that budget while
* a host-side `agent()`/`parallel()` (via `runSubprocess`) or `llm()` (a single
* completion) call is in flight. Those calls are the only thing that re-arms the
* watchdog — and they can run for long stretches with **no** status of their own
* (a subagent's time-to-first-token on a reasoning model, a long quiet nested
* tool, or the entire body of a oneshot `llm()` call). Without a keepalive the
* watchdog would mistake that delegated work for the cell stalling and abort it
* mid-flight, killing the subagent.
*
* {@link withBridgeHeartbeat} bridges that gap by emitting a synthetic
* {@link EVAL_HEARTBEAT_OP} status event immediately when the call begins and
* then on a fixed cadence until it settles. The event rides the same
* `emitStatus → onStatus` channel both runtimes already forward, so it re-arms
* the watchdog without any new plumbing. The heartbeat is the *sole* signal that
* extends the budget: consumers MUST treat it as a pure keepalive — bump the
* watchdog and drop it (never persist or render it) — see the executor display
* sinks and the eval tool's `onStatus` handler. Every other status event
* (compute helpers, `log()`/`phase()`, tool results) counts against the budget.
*/
import type { JsStatusEvent } from "./js/shared/types";
/**
* Synthetic status op emitted purely to keep the eval idle watchdog alive while
* a host-side bridge call is in flight. Carries no payload.
*/
export const EVAL_HEARTBEAT_OP = "heartbeat";
/**
* Heartbeat cadence. Comfortably below the default 30s idle budget (and the
* larger budgets long fanouts run under), so a working bridge call always bumps
* the watchdog before it expires, while a genuine stall is still bounded once
* the call settles and the heartbeat stops.
*/
const HEARTBEAT_INTERVAL_MS = 5_000;
let heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS;
/**
* Test seam: override the heartbeat cadence so integration tests can exercise
* the keepalive within a sub-second idle budget. Pass no value to restore the
* production default.
*/
export function setBridgeHeartbeatIntervalMs(ms?: number): void {
heartbeatIntervalMs = ms === undefined ? HEARTBEAT_INTERVAL_MS : Math.max(1, Math.floor(ms));
}
/**
* Run {@link operation}, pumping {@link EVAL_HEARTBEAT_OP} status events through
* {@link emitStatus} — one immediately, then on a fixed cadence — until it
* settles. The immediate beat pauses the watchdog the instant the call begins,
* so a bridge call that starts close to the budget edge (after the cell already
* spent most of it computing) is not aborted before the first interval tick. A
* no-op wrapper when no `emitStatus` sink is wired (the heartbeat would reach
* nobody).
*/
export async function withBridgeHeartbeat<T>(
emitStatus: ((event: JsStatusEvent) => void) | undefined,
operation: () => Promise<T>,
): Promise<T> {
if (!emitStatus) return operation();
emitStatus({ op: EVAL_HEARTBEAT_OP });
const timer = setInterval(() => emitStatus({ op: EVAL_HEARTBEAT_OP }), heartbeatIntervalMs);
// Never keep the event loop alive for the heartbeat alone.
timer.unref?.();
try {
return await operation();
} finally {
clearInterval(timer);
}
}
+34 -16
View File
@@ -1,17 +1,15 @@
/**
* Inactivity watchdog for eval cells.
* Watchdog for eval cell work.
*
* A cell's `timeout` is treated as an *idle* budget rather than a hard
* wall-clock deadline: the watchdog aborts {@link signal} (with a
* `TimeoutError` reason, matching `AbortSignal.timeout`) only once `idleMs`
* elapses with no {@link bump}. Every progress signal re-arms it, so a
* long-running fanout that keeps reporting progress (e.g. `agent()` status
* updates, `log()`/`phase()`) never trips the timeout, while a genuinely
* stalled cell still gets interrupted.
* A cell's `timeout` bounds time while the Python kernel or JS VM is in control.
* Host-side bridge calls can {@link pause} the watchdog so delegated
* `agent()`/`parallel()`/`llm()` work is ignored completely, then {@link resume}
* starts a fresh timeout window once the runtime gets control back.
*
* The timer self-reschedules instead of being torn down and recreated on every
* bump, so a high-frequency stream of bumps (sub-second agent progress) costs
* one timestamp write per event rather than churning a timer each time.
* The active timer self-reschedules instead of being torn down on every
* activity event, so frequent activity costs one timestamp write per event.
* Pause is reference-counted because `parallel()` can have multiple bridge calls
* in flight at once.
*/
export class IdleTimeout {
readonly #controller = new AbortController();
@@ -20,6 +18,7 @@ export class IdleTimeout {
#deadlineMs: number;
#timer: NodeJS.Timeout | undefined;
#settled = false;
#pauseDepth = 0;
constructor(idleMs: number) {
this.#idleMs = Math.max(1, Math.floor(idleMs));
@@ -27,21 +26,40 @@ export class IdleTimeout {
this.#arm(this.#idleMs);
}
/** Aborts with a `TimeoutError` reason once the inactivity budget is exhausted. */
/** Aborts with a `TimeoutError` reason once the active timeout window is exhausted. */
get signal(): AbortSignal {
return this.#controller.signal;
}
/** Configured inactivity budget in milliseconds. */
/** Configured active timeout window in milliseconds. */
get idleMs(): number {
return this.#idleMs;
}
/** Record activity, pushing the inactivity deadline forward by `idleMs`. */
/** Record runtime activity, pushing the active deadline forward by `idleMs`. */
bump(): void {
if (this.#settled) return;
if (this.#settled || this.#pauseDepth > 0) return;
this.#deadlineMs = Date.now() + this.#idleMs;
}
/** Suspend timeout accounting while control is delegated to host-side work. */
pause(): void {
if (this.#settled) return;
this.#pauseDepth++;
if (this.#pauseDepth !== 1) return;
if (this.#timer) {
clearTimeout(this.#timer);
this.#timer = undefined;
}
}
/** Resume timeout accounting with a fresh timeout window. */
resume(): void {
if (this.#settled || this.#pauseDepth === 0) return;
this.#pauseDepth--;
if (this.#pauseDepth > 0) return;
this.#deadlineMs = Date.now() + this.#idleMs;
this.#arm(this.#idleMs);
}
/** Stop the watchdog. Safe to call multiple times. */
dispose(): void {
@@ -65,7 +83,7 @@ export class IdleTimeout {
}
#onExpire(): void {
if (this.#settled) return;
if (this.#settled || this.#pauseDepth > 0) return;
const remainingMs = this.#deadlineMs - Date.now();
if (remainingMs > 0) {
// A bump moved the deadline forward after this timer was armed; wait
+10 -10
View File
@@ -1,7 +1,7 @@
import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
import type { ToolSession } from "../../tools";
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
import { EVAL_HEARTBEAT_OP } from "../heartbeat";
import { isEvalTimeoutControlEvent } from "../bridge-timeout";
import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
import type { JsStatusEvent } from "./shared/types";
@@ -10,9 +10,9 @@ export interface JsExecutorOptions {
timeoutMs?: number;
deadlineMs?: number;
/**
* Inactivity budget (ms). Used for worker cold-start headroom and
* timeout-annotation text when the caller drives cancellation via an
* idle-aware `signal` instead of `deadlineMs`/`timeoutMs`. Never arms a timer.
* Runtime-work budget (ms). Used for worker cold-start headroom and
* timeout-annotation text when the caller drives cancellation via the eval
* watchdog `signal` instead of `deadlineMs`/`timeoutMs`. Never arms a timer.
*/
idleTimeoutMs?: number;
onChunk?: (chunk: string) => Promise<void> | void;
@@ -85,9 +85,9 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
options.signal && timeoutSignal
? AbortSignal.any([options.signal, timeoutSignal])
: (options.signal ?? timeoutSignal);
// The eval tool drives cancellation via an idle-aware `signal` and passes only
// an inactivity budget; use it solely as worker cold-start headroom and never
// derive a competing fixed timer from it.
// The eval tool drives cancellation via its own watchdog `signal` and passes
// only the runtime-work budget; use it solely as worker cold-start headroom
// and never derive a competing fixed timer from it.
const acquireBudgetMs = legacyTimeoutMs ?? options.idleTimeoutMs;
try {
@@ -105,10 +105,10 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
onText: chunk => outputSink.push(chunk),
onDisplay: output => {
if (output.type === "status") {
// Heartbeats are pure idle-watchdog keepalives: forward them so
// the eval tool re-arms its timer, but never store or render them.
// Timeout-control events drive the eval watchdog only; never
// store or render them as cell output.
options.onStatus?.(output.event);
if (output.event.op === EVAL_HEARTBEAT_OP) return;
if (isEvalTimeoutControlEvent(output.event)) return;
}
displayOutputs.push(output);
},
+12 -8
View File
@@ -15,10 +15,11 @@ import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent
import { type Api, Effort, getSupportedEfforts, type Model, type Tool } from "@oh-my-pi/pi-ai";
import * as z from "zod/v4";
import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils";
import { expandRoleAlias, formatModelString, resolveModelFromString } from "../config/model-resolver";
import type { ToolSession } from "../tools";
import { ToolError } from "../tools/tool-errors";
import { withBridgeHeartbeat } from "./heartbeat";
import { withBridgeTimeoutPause } from "./bridge-timeout";
import type { JsStatusEvent } from "./js/shared/types";
/** Synthetic bridge name reserved for the `llm()` helper across both runtimes. */
@@ -112,8 +113,9 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
);
}
const apiKey = await options.session.modelRegistry?.getApiKey(model);
if (!apiKey) {
const registry = options.session.modelRegistry;
const apiKey = await registry?.getApiKey(model);
if (!registry || !apiKey) {
throw new ToolError(
`llm() has no API key for ${formatModelString(model)}. Configure credentials for this provider or choose another tier.`,
);
@@ -132,10 +134,9 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
const telemetry = resolveTelemetry(options.session.getTelemetry?.(), options.session.getSessionId?.() ?? undefined);
// A oneshot completion emits no status until it returns, so pump a heartbeat
// while it runs to keep the eval idle watchdog armed across a slow (e.g.
// reasoning-tier) request that would otherwise look like a stalled cell.
const response = await withBridgeHeartbeat(options.emitStatus, () =>
// Suspend eval timeout accounting while the model request owns control. The
// timeout clock restarts once the bridge returns to the cell runtime.
const response = await withBridgeTimeoutPause(options.emitStatus, () =>
instrumentedCompleteSimple(
model,
{
@@ -144,7 +145,10 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
tools,
},
{
apiKey,
apiKey: registry.resolver(model.provider, {
sessionId: options.session.getSessionId?.() ?? undefined,
baseUrl: model.baseUrl,
}),
signal: options.signal,
reasoning: reasoningForTier(tier, model),
toolChoice: schema ? { type: "tool", name: STRUCTURED_TOOL_NAME } : undefined,
@@ -5,7 +5,7 @@ import { Settings } from "../../config/settings";
import { OutputSink } from "../../session/streaming-output";
import type { ToolSession } from "../../tools";
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
import { EVAL_HEARTBEAT_OP } from "../heartbeat";
import { isEvalTimeoutControlEvent } from "../bridge-timeout";
import type { JsStatusEvent } from "../js/shared/types";
import {
checkPythonKernelAvailability,
@@ -27,8 +27,8 @@ export interface PythonExecutorOptions {
/** Absolute wall-clock deadline in milliseconds since epoch */
deadlineMs?: number;
/**
* Inactivity budget (ms). Used only for timeout-annotation text when the
* caller drives cancellation via an idle-aware `signal` instead of a
* Runtime-work budget (ms). Used only for timeout-annotation text when the
* caller drives cancellation via the eval watchdog `signal` instead of a
* wall-clock `deadlineMs`/`timeoutMs`. Does not arm a timer.
*/
idleTimeoutMs?: number;
@@ -492,10 +492,10 @@ async function executeWithKernel(
// long-running bridge helpers (e.g. `agent()`) surface progress mid-cell.
const collectDisplay = (output: KernelDisplayOutput) => {
if (output.type === "status") {
// Heartbeats are pure idle-watchdog keepalives: forward them so the
// eval tool re-arms its timer, but never store or render them.
// Timeout-control events drive the eval watchdog only; never store or
// render them as cell output.
options?.onStatus?.(output.event);
if (output.event.op === EVAL_HEARTBEAT_OP) return;
if (isEvalTimeoutControlEvent(output.event)) return;
}
displayOutputs.push(output);
};
+11 -1
View File
@@ -18,6 +18,7 @@ import { type KernelDisplayOutput, renderKernelDisplay } from "./display";
import { PYTHON_PRELUDE } from "./prelude";
import RUNNER_SCRIPT from "./runner.py" with { type: "text" };
import { enumeratePythonRuntimes, filterEnv, type PythonRuntime, resolvePythonRuntime } from "./runtime";
import { hostHasInheritableConsole, shouldHideKernelWindow } from "./spawn-options";
export type { KernelDisplayOutput, PythonStatusEvent } from "./display";
export { renderKernelDisplay } from "./display";
@@ -253,7 +254,16 @@ export class PythonKernel {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
// Detached from any inherited console only when the host itself
// has no console — kernel32!GetConsoleWindow() is authoritative
// (works even when every stdio stream is redirected), with a
// TTY-OR fallback when the FFI probe is unavailable. See #1960
// for the numpy/pandas LoadLibraryExW hang + SIGINT-recovery
// failure that motivates the predicate.
windowsHide: shouldHideKernelWindow({
platform: process.platform,
hostHasInheritableConsole: hostHasInheritableConsole(),
}),
});
kernel.#proc = proc;
kernel.#stdin = proc.stdin;
@@ -0,0 +1,126 @@
/**
* Subprocess spawn-option helpers for the Python kernel.
*
* Pure helpers (`shouldHideKernelWindow`, `consoleAttachedViaTTY`) live here
* so they can be unit-tested without dragging in the kernel's runtime
* dependencies. The effectful `hostHasInheritableConsole` wraps a Win32 FFI
* probe with a TTY fallback and is the function `kernel.ts` actually calls.
*/
import { dlopen, FFIType } from "bun:ffi";
/**
* Decide whether the long-lived Python kernel subprocess should be spawned
* with `windowsHide: true`.
*
* On Windows, Bun maps `windowsHide: true` to the `CREATE_NO_WINDOW` flag,
* which detaches the child from any inherited console. The Python kernel
* runs user code that imports NumPy/pandas; those native extensions
* (`numpy/_core/_multiarray_umath.pyd` + bundled OpenBLAS/SLEEF thread-pool
* init) can deadlock inside `LoadLibraryExW` when no console is attached,
* and a console-less child cannot receive SIGINT via
* `GenerateConsoleCtrlEvent` (the recovery path the host relies on). See
* issue #1960.
*
* So on Windows we hide only when the host itself has no console to share.
* In any launch where a console is attached — even one with every stdio
* stream redirected — the kernel inherits the parent's console, matching
* `python.exe` invoked from `cmd.exe`, which keeps native imports and
* SIGINT recovery working.
*
* Short-lived helper subprocesses elsewhere in the codebase (LSP probes,
* git, plugin installs) keep `windowsHide: true` because they don't load
* complex native modules and the brief console flash would be user-visible
* noise.
*/
export function shouldHideKernelWindow(opts: {
platform: NodeJS.Platform;
hostHasInheritableConsole: boolean;
}): boolean {
if (opts.platform !== "win32") return false;
return !opts.hostHasInheritableConsole;
}
/**
* TTY-based fallback used when the Win32 console probe is unavailable.
*
* Returns `true` if any of stdin/stdout/stderr is currently a TTY. This
* correctly detects the common interactive launches and the partial-
* redirection cases (`omp -p > out.txt`, `< in.txt`, `2> err.log`) where at
* least one stream stays bound to the terminal. The all-stdio-redirected
* case (`< in > out 2> err` from a console) is the reason we prefer the
* Win32 probe over this fallback whenever possible.
*/
export function consoleAttachedViaTTY(opts: {
stdinIsTTY: boolean;
stdoutIsTTY: boolean;
stderrIsTTY: boolean;
}): boolean {
return opts.stdinIsTTY || opts.stdoutIsTTY || opts.stderrIsTTY;
}
/**
* Probe `kernel32.dll!GetConsoleWindow()` to detect whether the current
* Windows process owns a console window.
*
* Returns `true` for a non-NULL HWND, `false` when NULL (no console — true
* service / `DETACHED_PROCESS` / GUI parent), and `null` when the probe
* itself fails (off-Windows, FFI disabled, or unexpected kernel32 layout).
* A `null` return means "don't trust me, use the TTY fallback".
*
* Cached on first call because in practice the console attachment of a
* long-lived OMP host never changes for the lifetime of the process, and
* we don't want to re-dlopen kernel32 on every kernel spawn.
*/
type ConsoleProbeResult = boolean | null;
let cachedWindowsConsoleProbe: { value: ConsoleProbeResult } | undefined;
function probeWindowsConsoleWindow(): ConsoleProbeResult {
if (cachedWindowsConsoleProbe) return cachedWindowsConsoleProbe.value;
let value: ConsoleProbeResult = null;
try {
const lib = dlopen("kernel32.dll", {
GetConsoleWindow: { args: [], returns: FFIType.ptr },
});
try {
const hwnd = lib.symbols.GetConsoleWindow();
// FFIType.ptr returns `Pointer | null`; a 0 pointer should also be
// treated as NULL defensively in case Bun ever returns 0n / 0.
value = hwnd !== null && hwnd !== 0;
} finally {
lib.close();
}
} catch {
value = null;
}
cachedWindowsConsoleProbe = { value };
return value;
}
/** Reset the cached Win32 probe result. Test-only; not part of the public surface. */
export function __resetWindowsConsoleProbeCache(): void {
cachedWindowsConsoleProbe = undefined;
}
/**
* Whether the host process owns a console its children can inherit.
*
* - On Windows, the authoritative signal is `GetConsoleWindow()`. It returns
* a non-NULL HWND whenever the process has a console attached, regardless
* of how the standard streams are redirected — so an `omp -p ... < in.txt
* > out.txt 2> err.log` launched from a real Windows Terminal session is
* correctly classified as console-attached and the kernel keeps its
* inheritable console.
* - On any other platform, or if the FFI probe fails, fall back to the
* TTY-OR heuristic. That still catches the common interactive cases.
*/
export function hostHasInheritableConsole(): boolean {
if (process.platform === "win32") {
const native = probeWindowsConsoleWindow();
if (native !== null) return native;
}
return consoleAttachedViaTTY({
stdinIsTTY: !!process.stdin.isTTY,
stdoutIsTTY: !!process.stdout.isTTY,
stderrIsTTY: !!process.stderr.isTTY,
});
}
+9
View File
@@ -294,6 +294,9 @@ export class TtsrManager {
/** Add a TTSR rule to be monitored. */
addRule(rule: Rule): boolean {
if (!this.#settings.enabled) {
return false;
}
if (this.#rules.has(rule.name)) {
return false;
}
@@ -357,6 +360,9 @@ export class TtsrManager {
}
#matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] {
if (!this.#settings.enabled) {
return [];
}
const matches: Rule[] = [];
for (const [name, entry] of this.#rules) {
if (!this.#canTrigger(name)) {
@@ -433,6 +439,9 @@ export class TtsrManager {
/** Check if any TTSR rules are registered. */
hasRules(): boolean {
if (!this.#settings.enabled) {
return false;
}
return this.#rules.size > 0;
}
@@ -12,6 +12,35 @@ async function getHeadTag(api: CustomCommandAPI): Promise<string | undefined> {
}
}
async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
try {
return (await git.branch.current(api.cwd)) ?? "HEAD";
} catch {
return "HEAD";
}
}
async function getPushRemote(api: CustomCommandAPI, branch: string): Promise<string | undefined> {
try {
return (
(await git.config.getBranch(api.cwd, branch, "pushRemote")) ??
(await git.config.getBranch(api.cwd, branch, "remote"))
);
} catch {
return undefined;
}
}
async function getHeadTagContext(api: CustomCommandAPI): Promise<{ branch: string; headTag?: string; remote: string }> {
const branch = await getCurrentBranch(api);
const [headTag, pushRemote] = await Promise.all([getHeadTag(api), getPushRemote(api, branch)]);
return {
headTag,
branch,
remote: pushRemote ?? "origin",
};
}
export class GreenCommand implements CustomCommand {
name = "green";
description = "Generate a prompt to iterate on CI failures until the branch is green";
@@ -19,7 +48,7 @@ export class GreenCommand implements CustomCommand {
constructor(private api: CustomCommandAPI) {}
async execute(_args: string[], _ctx: HookCommandContext): Promise<string> {
const headTag = await getHeadTag(this.api);
return prompt.render(ciGreenRequestTemplate, { headTag });
const { headTag, branch, remote } = await getHeadTagContext(this.api);
return prompt.render(ciGreenRequestTemplate, { headTag, branch, remote });
}
}
@@ -354,6 +354,9 @@ export class ExtensionRunner {
"ctrl+o": true,
"ctrl+t": true,
"ctrl+g": true,
"alt+m": true,
// Default chord for `app.message.followUp` (Windows Terminal can't deliver Ctrl+Enter; #1903).
"ctrl+q": true,
"shift+tab": true,
"shift+ctrl+p": true,
"alt+enter": true,
@@ -25,7 +25,6 @@ export async function runDoctorChecks(): Promise<DoctorCheck[]> {
const apiKeys = [
{ name: "ANTHROPIC_API_KEY", description: "Anthropic API" },
{ name: "OPENAI_API_KEY", description: "OpenAI API" },
{ name: "PERPLEXITY_API_KEY", description: "Perplexity search" },
{ name: "EXA_API_KEY", description: "Exa search" },
];
@@ -0,0 +1,49 @@
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
type MarketplaceAutoUpdateMode = "off" | "notify" | "auto";
interface MarketplaceAutoUpdateOptions {
autoUpdate: MarketplaceAutoUpdateMode;
resolveActiveProjectRegistryPath: (cwd: string) => Promise<string | null>;
clearPluginRootsCache: () => void;
}
export function scheduleMarketplaceAutoUpdate(options: MarketplaceAutoUpdateOptions): void {
if (options.autoUpdate === "off") {
return;
}
void runMarketplaceAutoUpdate(options);
}
async function runMarketplaceAutoUpdate(options: MarketplaceAutoUpdateOptions): Promise<void> {
try {
// Startup perf: marketplace manager pulls scraper/fetch/cache code; keep it out of the initial TUI graph.
const {
MarketplaceManager,
getInstalledPluginsRegistryPath,
getMarketplacesCacheDir,
getMarketplacesRegistryPath,
getPluginsCacheDir,
} = await import("./marketplace");
const mgr = new MarketplaceManager({
marketplacesRegistryPath: getMarketplacesRegistryPath(),
installedRegistryPath: getInstalledPluginsRegistryPath(),
projectInstalledRegistryPath: (await options.resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
marketplacesCacheDir: getMarketplacesCacheDir(),
pluginsCacheDir: getPluginsCacheDir(),
clearPluginRootsCache: options.clearPluginRootsCache,
});
await mgr.refreshStaleMarketplaces();
const updates = await mgr.checkForUpdates();
if (updates.length === 0) return;
if (options.autoUpdate === "auto") {
await mgr.upgradeAllPlugins();
logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s)`);
} else {
logger.debug(`${updates.length} marketplace plugin update(s) available — /marketplace upgrade`);
}
} catch {
// Silently ignore — network failure, corrupt data, offline.
}
}
@@ -8,9 +8,9 @@ import type { Theme, ThemeColor } from "../../modes/theme/theme";
import goalDescription from "../../prompts/tools/goal.md" with { type: "text" };
import { formatDuration } from "../../slash-commands/helpers/format";
import type { ToolSession } from "../../tools";
import { formatErrorMessage, TRUNCATE_LENGTHS } from "../../tools/render-utils";
import { formatErrorDetail, TRUNCATE_LENGTHS } from "../../tools/render-utils";
import { ToolError } from "../../tools/tool-errors";
import { renderStatusLine, truncateToWidth } from "../../tui";
import { framedBlock, renderStatusLine, truncateToWidth } from "../../tui";
import { completionBudgetReport, remainingTokens } from "../runtime";
import type { Goal, GoalStatus, GoalToolDetails } from "../state";
@@ -173,8 +173,7 @@ export const goalToolRenderer = {
if (args.op === "create" && args.token_budget !== undefined) {
meta.push(`budget ${formatNumber(args.token_budget)}`);
}
const text = renderStatusLine({ icon: "pending", title: "Goal", description, meta }, uiTheme);
return new Text(text, 0, 0);
return new Text(renderStatusLine({ icon: "pending", title: "Goal", description, meta }, uiTheme), 0, 0);
},
renderResult(
@@ -190,51 +189,62 @@ export const goalToolRenderer = {
if (result.isError) {
const header = renderStatusLine({ icon: "error", title: "Goal", description }, uiTheme);
const body = formatErrorMessage(fallbackText || "Goal tool failed", uiTheme);
return new Text([header, body].join("\n"), 0, 0);
return framedBlock(uiTheme, width => ({
header,
sections: [{ lines: formatErrorDetail(fallbackText || "Goal tool failed", uiTheme).split("\n") }],
state: "error",
borderColor: "error",
width,
}));
}
const goal = details?.goal ?? null;
if (!goal) {
const header = renderStatusLine({ icon: "warning", title: "Goal", description }, uiTheme);
const body = uiTheme.fg("muted", "No active goal.");
return new Text([header, body].join("\n"), 0, 0);
return new Text(
renderStatusLine({ icon: "warning", title: "Goal", description, meta: ["no active goal"] }, uiTheme),
0,
0,
);
}
const lines: string[] = [];
lines.push(
renderStatusLine(
{
icon: "success",
title: "Goal",
description,
badge: { label: goal.status, color: goalBadgeColor(goal.status) },
},
uiTheme,
),
const header = renderStatusLine(
{
icon: "success",
title: "Goal",
description,
badge: { label: goal.status, color: goalBadgeColor(goal.status) },
},
uiTheme,
);
const lines: string[] = [];
const objectiveText = truncateToWidth(goal.objective.trim(), TRUNCATE_LENGTHS.LONG);
lines.push(` ${uiTheme.italic(uiTheme.fg("muted", `"${objectiveText}"`))}`);
lines.push(uiTheme.italic(uiTheme.fg("muted", `"${objectiveText}"`)));
const used = formatNumber(goal.tokensUsed);
const tokensLine =
goal.tokenBudget !== undefined
? `${used} / ${formatNumber(goal.tokenBudget)} tokens (${formatNumber(Math.max(0, goal.tokenBudget - goal.tokensUsed))} left)`
: `${used} tokens`;
lines.push(` ${uiTheme.fg("dim", tokensLine)}`);
const metaParts = [tokensLine];
if (goal.timeUsedSeconds > 0) {
lines.push(` ${uiTheme.fg("dim", `${formatDuration(goal.timeUsedSeconds * 1000)} elapsed`)}`);
metaParts.push(`${formatDuration(goal.timeUsedSeconds * 1000)} elapsed`);
}
lines.push(uiTheme.fg("dim", metaParts.join(" · ")));
const report = details?.completionBudgetReport;
const sections: Array<{ label?: string; lines: string[] }> = [{ lines }];
if (report) {
lines.push("");
lines.push(uiTheme.italic(uiTheme.fg("muted", report)));
sections.push({ label: "Report", lines: report.split("\n").map(line => uiTheme.fg("muted", line)) });
}
return new Text(lines.join("\n"), 0, 0);
return framedBlock(uiTheme, width => ({
header,
sections,
state: "success",
borderColor: "borderMuted",
width,
}));
},
mergeCallAndResult: true,
+179 -52
View File
@@ -1,3 +1,4 @@
import * as path from "node:path";
import { isEnoent, logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
import { applyWorkspaceEdit } from "./edits";
@@ -147,6 +148,7 @@ const CLIENT_CAPABILITIES = {
failureHandling: "textOnlyTransactional",
},
configuration: true,
workspaceFolders: true,
symbol: {
dynamicRegistration: false,
symbolKind: {
@@ -172,49 +174,69 @@ const CLIENT_CAPABILITIES = {
// LSP Message Protocol
// =============================================================================
/**
* Parse a single LSP message from a buffer.
* Returns the parsed message and remaining buffer, or null if incomplete.
*/
function parseMessage(
buffer: Buffer,
): { message: LspJsonRpcResponse | LspJsonRpcNotification; remaining: Buffer } | null {
// Only decode enough to find the header
const headerEndIndex = findHeaderEnd(buffer);
if (headerEndIndex === -1) return null;
const headerText = new TextDecoder().decode(buffer.slice(0, headerEndIndex));
const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
if (!contentLengthMatch) return null;
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageStart = headerEndIndex + 4; // Skip \r\n\r\n
const messageEnd = messageStart + contentLength;
if (buffer.length < messageEnd) return null;
const messageBytes = buffer.subarray(messageStart, messageEnd);
const messageText = new TextDecoder().decode(messageBytes);
const remaining = buffer.subarray(messageEnd);
return {
message: JSON.parse(messageText),
remaining,
};
}
// Reused for all full (non-streaming) decodes; each decode() resets state, so a
// single instance is safe and avoids per-message TextDecoder allocation.
const MESSAGE_DECODER = new TextDecoder("utf-8");
/**
* Find the end of the header section (before \r\n\r\n)
* Locate the `\r\n\r\n` header terminator across the pending chunk list.
* Returns the absolute byte index of the first `\r`, or -1 when not present.
* Equivalent to scanning the contiguous concatenation of the chunks.
*/
function findHeaderEnd(buffer: Uint8Array): number {
for (let i = 0; i < buffer.length - 3; i++) {
if (buffer[i] === 13 && buffer[i + 1] === 10 && buffer[i + 2] === 13 && buffer[i + 3] === 10) {
return i;
function findHeaderEndInChunks(chunks: Buffer[]): number {
let global = 0;
let b0 = -1;
let b1 = -1;
let b2 = -1;
for (const chunk of chunks) {
for (let i = 0; i < chunk.length; i++) {
const b3 = chunk[i];
if (b0 === 13 && b1 === 10 && b2 === 13 && b3 === 10) {
return global - 3;
}
b0 = b1;
b1 = b2;
b2 = b3;
global++;
}
}
return -1;
}
/** Copy the byte range [from, to) out of the pending chunk list into one Buffer. */
function copyChunkRange(chunks: Buffer[], from: number, to: number): Buffer {
const out = Buffer.allocUnsafe(to - from);
let global = 0;
let written = 0;
for (const chunk of chunks) {
const chunkEnd = global + chunk.length;
if (chunkEnd > from && global < to) {
const start = Math.max(from, global) - global;
const end = Math.min(to, chunkEnd) - global;
chunk.copy(out, written, start, end);
written += end - start;
}
global = chunkEnd;
if (global >= to) break;
}
return out;
}
/** Drop the first `count` bytes from the pending chunk list in place. */
function dropChunkFront(chunks: Buffer[], count: number): void {
let removed = 0;
while (chunks.length > 0) {
const head = chunks[0];
if (removed + head.length <= count) {
removed += head.length;
chunks.shift();
} else {
chunks[0] = head.subarray(count - removed);
break;
}
}
}
async function writeMessage(
sink: Bun.FileSink,
message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
@@ -247,22 +269,43 @@ async function startMessageReader(client: LspClient): Promise<void> {
const reader = (client.proc.stdout as ReadableStream<Uint8Array>).getReader();
// Incoming bytes are buffered as a list of chunks and only joined when a full
// message is framed. Concatenating the accumulator on every read was O(n^2)
// for messages that span many reads (e.g. a large initial diagnostics burst).
const pendingChunks: Buffer[] = [];
let pendingLen = 0;
if (client.messageBuffer.length > 0) {
const seed = Buffer.from(client.messageBuffer);
pendingChunks.push(seed);
pendingLen = seed.length;
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Atomically update buffer before processing
const currentBuffer: Buffer = Buffer.concat([client.messageBuffer, value]);
client.messageBuffer = currentBuffer;
pendingChunks.push(Buffer.from(value));
pendingLen += value.length;
// Process all complete messages in buffer
// Use local variable to avoid race with concurrent buffer updates
let workingBuffer = currentBuffer;
let parsed = parseMessage(workingBuffer);
while (parsed) {
const { message, remaining } = parsed;
workingBuffer = remaining;
// Drain every complete message currently buffered.
while (true) {
const headerEnd = findHeaderEndInChunks(pendingChunks);
if (headerEnd === -1) break;
const headerText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, 0, headerEnd));
const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
if (!contentLengthMatch) break;
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageStart = headerEnd + 4; // Skip \r\n\r\n
const messageEnd = messageStart + contentLength;
if (pendingLen < messageEnd) break;
const messageText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, messageStart, messageEnd));
const message: LspJsonRpcResponse | LspJsonRpcNotification = JSON.parse(messageText);
dropChunkFront(pendingChunks, messageEnd);
pendingLen -= messageEnd;
// Route message
if ("id" in message && message.id !== undefined) {
@@ -299,12 +342,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
}
}
}
parsed = parseMessage(workingBuffer);
}
// Atomically commit processed buffer
client.messageBuffer = workingBuffer;
}
} catch (err) {
// Connection closed or error - reject all pending requests
@@ -313,11 +351,34 @@ async function startMessageReader(client: LspClient): Promise<void> {
}
client.pendingRequests.clear();
} finally {
// Persist any unparsed remainder so a restarted reader resumes mid-message.
client.messageBuffer =
pendingChunks.length === 0
? new Uint8Array(0)
: pendingChunks.length === 1
? pendingChunks[0]
: Buffer.concat(pendingChunks, pendingLen);
reader.releaseLock();
client.isReading = false;
}
}
/**
* Build the workspace folder list advertised to the server. Identical shape
* for `initialize` params and `workspace/workspaceFolders` server requests.
*/
function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name: string }> {
return [{ uri: fileToUri(client.cwd), name: path.basename(client.cwd) || "workspace" }];
}
/**
* Handle workspace/workspaceFolders requests from the server.
*/
async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
if (typeof message.id !== "number") return;
await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
}
/**
* Handle workspace/configuration requests from the server.
*/
@@ -364,6 +425,10 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
await handleConfigurationRequest(client, message);
return;
}
if (message.method === "workspace/workspaceFolders") {
await handleWorkspaceFoldersRequest(client, message);
return;
}
if (message.method === "workspace/applyEdit") {
await handleApplyEditRequest(client, message);
return;
@@ -412,7 +477,66 @@ async function sendResponse(
/** Timeout for warmup initialize requests (5 seconds) */
export const WARMUP_TIMEOUT_MS = 5000;
/** Max time to wait for the server to report project loading completion via $/progress */
/** Max time to poll rust-analyzer after progress ends but before Cargo workspaces are ready. */
const RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS = 5_000;
const RUST_ANALYZER_WORKSPACE_READY_POLL_MS = 100;
const RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS = 2_000;
const RUST_ANALYZER_STATUS_REQUEST_TIMEOUT_MS = 1_000;
const rustAnalyzerReadyClients = new WeakSet<LspClient>();
function commandBasename(command: string): string {
const slash = command.lastIndexOf("/");
const backslash = command.lastIndexOf("\\");
const separator = Math.max(slash, backslash);
return separator === -1 ? command : command.slice(separator + 1);
}
function isRustAnalyzerClient(client: LspClient): boolean {
return (
commandBasename(client.config.command) === "rust-analyzer" ||
(client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false)
);
}
function isRustAnalyzerStatusTimeout(err: unknown): boolean {
return err instanceof Error && err.message.startsWith("LSP request rust-analyzer/analyzerStatus timed out after ");
}
async function waitForRustAnalyzerWorkspace(client: LspClient, signal?: AbortSignal): Promise<void> {
if (rustAnalyzerReadyClients.has(client)) {
return;
}
const timings = client.config.workspaceReadyTimings;
const timeoutMs = timings?.timeoutMs ?? RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS;
const pollMs = timings?.pollMs ?? RUST_ANALYZER_WORKSPACE_READY_POLL_MS;
const settleMs = timings?.settleMs ?? RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS;
const statusRequestTimeoutMs = timings?.statusRequestTimeoutMs ?? RUST_ANALYZER_STATUS_REQUEST_TIMEOUT_MS;
const started = Date.now();
const deadline = started + timeoutMs;
while (true) {
throwIfAborted(signal);
let status: unknown;
try {
status = await sendRequest(client, "rust-analyzer/analyzerStatus", {}, signal, statusRequestTimeoutMs);
} catch (err) {
if (!isRustAnalyzerStatusTimeout(err) || Date.now() >= deadline) {
return;
}
await Bun.sleep(pollMs);
continue;
}
const ready = typeof status === "string" && !status.startsWith("No workspaces");
if (ready && Date.now() - started >= settleMs) {
rustAnalyzerReadyClients.add(client);
return;
}
if (Date.now() >= deadline) {
return;
}
await Bun.sleep(pollMs);
}
}
const PROJECT_LOAD_TIMEOUT_MS = 15_000;
/** Max time to wait for graceful LSP shutdown and process exit. */
@@ -530,7 +654,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
rootPath: cwd,
capabilities: CLIENT_CAPABILITIES,
initializationOptions: config.initOptions ?? {},
workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
workspaceFolders: currentWorkspaceFolders(client),
},
undefined, // signal
initTimeoutMs,
@@ -635,6 +759,9 @@ export async function waitForProjectLoaded(client: LspClient, signal?: AbortSign
? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))]
: []),
]);
if (isRustAnalyzerClient(client)) {
await waitForRustAnalyzerWorkspace(client, signal);
}
}
/**
+38 -4
View File
@@ -304,6 +304,32 @@ const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
const WORKSPACE_SYMBOL_LIMIT = 200;
const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
"definition",
"type_definition",
"implementation",
"references",
"rename",
"hover",
]);
const RUST_WORKSPACE_MARKERS = ["Cargo.toml", "rust-analyzer.toml"] as const;
function hasRustWorkspaceAncestor(filePath: string): boolean {
let dir = path.dirname(filePath);
while (true) {
for (const marker of RUST_WORKSPACE_MARKERS) {
if (fs.existsSync(path.join(dir, marker))) {
return true;
}
}
const parent = path.dirname(dir);
if (parent === dir) {
return false;
}
dir = parent;
}
}
function limitDiagnosticMessages(messages: string[]): string[] {
if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
@@ -1940,10 +1966,21 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
try {
const client = await getOrCreateClient(serverConfig, this.session.cwd);
const targetFile = resolvedFile;
const isRustAnalyzerServer =
serverName === "rust-analyzer" ||
path.basename(serverConfig.command) === "rust-analyzer" ||
(serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
const needsProjectIndex =
targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
const rustWorkspaceWait =
needsProjectIndex && isRustAnalyzerServer && targetFile !== null && hasRustWorkspaceAncestor(targetFile);
if (targetFile) {
await ensureFileOpen(client, targetFile, signal);
}
if (rustWorkspaceWait) {
await waitForProjectLoaded(client, signal);
}
// For project-aware servers, references/rename/definition without a `symbol`
// silently falls back to the first non-whitespace column on the line, which
@@ -1968,10 +2005,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
let output: string;
// Wait for project loading to complete before cross-file operations
// to ensure the server has indexed all project files.
const crossFileActions = new Set(["definition", "type_definition", "implementation", "references", "rename"]);
if (crossFileActions.has(action)) {
if (needsProjectIndex && !isRustAnalyzerServer) {
await waitForProjectLoaded(client, signal);
}
+3 -3
View File
@@ -21,7 +21,7 @@ import {
truncateToWidth,
} from "../tools/render-utils";
import { renderStatusLine } from "../tui";
import { CachedOutputBlock } from "../tui/output-block";
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
import type { LspParams, LspToolDetails } from "./types";
// =============================================================================
@@ -138,7 +138,7 @@ export function renderResult(
const outputBlock = new CachedOutputBlock();
return {
return markFramedBlockComponent({
render(width: number): string[] {
// Read mutable state at render time
const { expanded, isPartial, spinnerFrame } = options;
@@ -194,7 +194,7 @@ export function renderResult(
invalidate() {
outputBlock.invalidate();
},
};
});
}
// =============================================================================
+10
View File
@@ -356,6 +356,16 @@ export interface ServerConfig {
disabled?: boolean;
/** Per-server warmup timeout in milliseconds. Overrides the global WARMUP_TIMEOUT_MS for this server during startup. */
warmupTimeoutMs?: number;
/**
* Per-server overrides for rust-analyzer workspace-ready polling. When omitted, the module
* defaults are used. Primarily a tuning/test seam to bound the multi-second settle window.
*/
workspaceReadyTimings?: {
timeoutMs?: number;
pollMs?: number;
settleMs?: number;
statusRequestTimeoutMs?: number;
};
capabilities?: ServerCapabilities;
/** If true, this is a linter/formatter server (e.g., Biome) - used only for diagnostics/actions, not type intelligence */
isLinter?: boolean;
+3 -2
View File
@@ -153,9 +153,10 @@ export function formatDiagnostic(diagnostic: Diagnostic, filePath: string): stri
const DIAG_PATH_RE = /^(.+?):(\d+:\d+\s+.*)$/;
/**
* Reformat pre-formatted diagnostic messages into grep-style directory/file groups.
* Reformat pre-formatted diagnostic messages into a multi-level, prefix-folded
* directory/file grouping (see `formatGroupedFiles`).
* Input: ["path:line:col [sev] msg", ...]
* Output: "# dir/\n## file.ts\n line:col [sev] msg"
* Output: "# pkg/src/\n## file.ts\n line:col [sev] msg"
*
* Messages that don't match the expected format are appended ungrouped at the end.
*/
+53 -56
View File
@@ -40,19 +40,13 @@ import {
resolveActiveProjectRegistryPath,
} from "./discovery/helpers";
import { injectOmpExtensionCliRoots } from "./discovery/omp-extension-roots";
import { exportFromFile } from "./export/html";
import { ExtensionRunner } from "./extensibility/extensions/runner";
import type { ExtensionUIContext } from "./extensibility/extensions/types";
import {
getInstalledPluginsRegistryPath,
getMarketplacesCacheDir,
getMarketplacesRegistryPath,
getPluginsCacheDir,
MarketplaceManager,
} from "./extensibility/plugins/marketplace";
import { scheduleMarketplaceAutoUpdate } from "./extensibility/plugins/marketplace-auto-update";
import type { MCPManager } from "./mcp";
import { InteractiveMode, runAcpMode, runPrintMode, runRpcMode } from "./modes";
import { ALL_SCENES, runSetupWizard, selectSetupScenes } from "./modes/setup-wizard";
import { InteractiveMode } from "./modes/interactive-mode";
import type { PrintModeOptions } from "./modes/print-mode";
import { CURRENT_SETUP_VERSION } from "./modes/setup-version";
import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
import type { SubmittedUserInput } from "./modes/types";
import {
@@ -72,6 +66,13 @@ import type { LspStartupServerInfo } from "./tools";
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog";
import { EventBus } from "./utils/event-bus";
type RunAcpMode = (createSession: AcpSessionFactory) => Promise<never>;
type RunPrintMode = (session: AgentSession, options: PrintModeOptions) => Promise<void>;
type RunRpcMode = (
session: AgentSession,
setToolUIContext?: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
) => Promise<never>;
async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
if (!settings.get("startup.checkUpdate")) {
return;
@@ -261,17 +262,29 @@ async function runInteractiveMode(
eventBus,
);
const setupScenes = await selectSetupScenes(settings.get("setupVersion"), ALL_SCENES, mode, {
resuming,
isTTY: process.stdin.isTTY && process.stdout.isTTY,
setupWizardEnabled: settings.get("startup.setupWizard"),
force: forceSetupWizard,
// Cold-launch gate: the full setup wizard (every scene + the overlay and
// their TUI/OAuth/search/theme deps) is heavy, yet the common case only needs
// to know whether the stored setup version is current. Lazy-load the wizard
// barrel only when setup is stale or forced; otherwise skip it entirely.
const storedSetupVersion = settings.get("setupVersion");
const setupWizard =
forceSetupWizard || storedSetupVersion < CURRENT_SETUP_VERSION ? await import("./modes/setup-wizard") : undefined;
const setupScenes = setupWizard
? await setupWizard.selectSetupScenes(storedSetupVersion, setupWizard.ALL_SCENES, mode, {
resuming,
isTTY: process.stdin.isTTY && process.stdout.isTTY,
setupWizardEnabled: settings.get("startup.setupWizard"),
force: forceSetupWizard,
})
: [];
await mode.init({
suppressWelcomeIntro: resuming || setupScenes.length > 0,
clearInitialTerminalHistory: true,
});
await mode.init({ suppressWelcomeIntro: setupScenes.length > 0 });
if (setupScenes.length > 0) {
await runSetupWizard(mode, setupScenes);
if (setupWizard && setupScenes.length > 0) {
await setupWizard.runSetupWizard(mode, setupScenes);
}
versionCheckPromise
@@ -285,12 +298,11 @@ async function runInteractiveMode(
})
.catch(() => {});
// Cold-launch cleanup: wipe the terminal scrollback before painting the
// resumed/new transcript. The TUI's initial paint deliberately preserves
// native scrollback (prior shell content), but on `omp`/`omp -c` that leaves
// the previous run's welcome + transcript stacked above the fresh one. Every
// in-process session load already clears via `clearTerminalHistory`; the cold
// launch is the lone path that did not.
// Cold-launch cleanup: the first paint already clears native history, and this
// replay replaces the welcome/startup frame with the resumed/new transcript.
// Every in-process session load also uses `clearTerminalHistory`; cold launch
// follows the same clean-cutover path instead of preserving a previous run's
// transcript above the fresh one.
mode.renderInitialMessages(undefined, { preserveExistingChat: true, clearTerminalHistory: true });
for (const notify of notifs) {
@@ -716,7 +728,7 @@ async function buildSessionOptions(
interface RunRootCommandDependencies {
createAgentSession?: typeof createAgentSession;
discoverAuthStorage?: typeof discoverAuthStorage;
runAcpMode?: typeof runAcpMode;
runAcpMode?: RunAcpMode;
settings?: Settings;
forceSetupWizard?: boolean;
}
@@ -773,6 +785,7 @@ export async function runRootCommand(
let result: string;
try {
const outputPath = parsedArgs.messages.length > 0 ? parsedArgs.messages[0] : undefined;
const { exportFromFile } = await import("./export/html");
result = await exportFromFile(parsedArgs.export, outputPath);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to export session";
@@ -940,33 +953,11 @@ export async function runRootCommand(
await pluginPreloadPromise;
// Background marketplace auto-update — never blocks startup.
const autoUpdate = settingsInstance.get("marketplace.autoUpdate");
if (autoUpdate !== "off") {
void (async () => {
try {
const mgr = new MarketplaceManager({
marketplacesRegistryPath: getMarketplacesRegistryPath(),
installedRegistryPath: getInstalledPluginsRegistryPath(),
projectInstalledRegistryPath: (await resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
marketplacesCacheDir: getMarketplacesCacheDir(),
pluginsCacheDir: getPluginsCacheDir(),
clearPluginRootsCache: clearPluginRootsAndCaches,
});
await mgr.refreshStaleMarketplaces();
const updates = await mgr.checkForUpdates();
if (updates.length === 0) return;
if (autoUpdate === "auto") {
await mgr.upgradeAllPlugins();
logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s)`);
} else {
logger.debug(`${updates.length} marketplace plugin update(s) available — /marketplace upgrade`);
}
} catch {
// Silently ignore — network failure, corrupt data, offline.
}
})();
}
scheduleMarketplaceAutoUpdate({
autoUpdate: settingsInstance.get("marketplace.autoUpdate"),
resolveActiveProjectRegistryPath,
clearPluginRootsCache: clearPluginRootsAndCaches,
});
const { options: sessionOptions } = await logger.time(
"buildSessionOptions",
@@ -988,7 +979,7 @@ export async function runRootCommand(
// Both are no-ops when OTEL_EXPORTER_OTLP_ENDPOINT is unset. An empty config
// is enough to enable telemetry — content capture is governed by the
// standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var.
initTelemetryExport();
await initTelemetryExport();
if (isTelemetryExportEnabled()) {
sessionOptions.telemetry = {};
}
@@ -1027,7 +1018,9 @@ export async function runRootCommand(
rawArgs,
createSession,
});
await (deps.runAcpMode ?? runAcpMode)(createAcpSession);
// Branch-only protocol runner: keep ACP server code out of normal interactive startup.
const runAcpMode = deps.runAcpMode ?? (await import("./modes/acp/acp-mode")).runAcpMode;
await runAcpMode(createAcpSession);
} else {
// Resolve extension-registered CLI flags before creating the session so a
// bad `@file` fails fast WITHOUT leaving a junk session/breadcrumb
@@ -1091,6 +1084,8 @@ export async function runRootCommand(
}
if (mode === "rpc" || mode === "rpc-ui") {
// Branch-only protocol runner: keep RPC host code out of normal interactive startup.
const runRpcMode: RunRpcMode = (await import("./modes/rpc/rpc-mode")).runRpcMode;
await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined);
} else if (isInteractive) {
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
@@ -1109,7 +1104,7 @@ export async function runRootCommand(
if ($env.PI_TIMING) {
logger.printTimings();
if ($env.PI_TIMING === "x") {
if (logger.shouldExitAfterTimings()) {
process.exit(0);
}
}
@@ -1132,6 +1127,8 @@ export async function runRootCommand(
initialImages,
);
} else {
// Branch-only single-shot runner: keep print-mode code out of normal interactive startup.
const runPrintMode: RunPrintMode = (await import("./modes/print-mode")).runPrintMode;
await runPrintMode(session, {
mode,
messages: initialArgs.messages,
+12 -5
View File
@@ -3,8 +3,9 @@ import type * as fsNode from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { clampThinkingLevelForModel, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
import { type ApiKey, clampThinkingLevelForModel, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { resolveModelRoleValue } from "../config/model-resolver";
import type { Settings } from "../config/settings";
@@ -271,7 +272,10 @@ async function runPhase1(options: {
const result = await runStage1Job({
claim,
model: phase1Model,
apiKey: phase1ApiKey,
apiKey: modelRegistry.resolver(phase1Model.provider, {
sessionId: session.sessionId,
baseUrl: phase1Model.baseUrl,
}),
modelMaxTokens: computeModelTokenBudget(phase1Model, config),
config,
metadata: session.agent?.metadataForProvider(phase1Model.provider),
@@ -428,7 +432,10 @@ async function runPhase2(options: {
const consolidated = await runConsolidationModel({
memoryRoot,
model: phase2Model,
apiKey: phase2ApiKey,
apiKey: modelRegistry.resolver(phase2Model.provider, {
sessionId: session.sessionId,
baseUrl: phase2Model.baseUrl,
}),
metadata: session.agent?.metadataForProvider(phase2Model.provider),
});
await applyConsolidation(memoryRoot, consolidated);
@@ -574,7 +581,7 @@ function extractPersistableMessages(payload: string): AgentMessage[] {
async function runStage1Job(options: {
claim: Stage1Claim;
model: Model;
apiKey: string;
apiKey: ApiKey;
modelMaxTokens: number;
config: MemoryRuntimeConfig;
metadata?: Record<string, unknown>;
@@ -718,7 +725,7 @@ async function readRolloutSummaries(memoryRoot: string): Promise<string> {
async function runConsolidationModel(options: {
memoryRoot: string;
model: Model;
apiKey: string;
apiKey: ApiKey;
metadata?: Record<string, unknown>;
}): Promise<{
memoryMd: string;
@@ -1,4 +1,16 @@
export * from "../mnemopi";
export type {
MnemopiBackendConfig,
MnemopiLlmMode,
MnemopiProviderOptions,
MnemopiScoping,
} from "../mnemopi/config";
export type {
MnemopiMemoryEditOperation,
MnemopiMemoryEditOptions,
MnemopiMemoryEditResult,
MnemopiSessionState,
MnemopiSessionStateOptions,
} from "../mnemopi/state";
export * from "./local-backend";
export * from "./off-backend";
export * from "./resolve";
@@ -1,6 +1,4 @@
import type { Settings } from "../config/settings";
import { hindsightBackend } from "../hindsight";
import { mnemopiBackend } from "../mnemopi";
import { localBackend } from "./local-backend";
import { offBackend } from "./off-backend";
import type { MemoryBackend } from "./types";
@@ -18,10 +16,10 @@ import type { MemoryBackend } from "./types";
* `memories.enabled` remains accepted only as a legacy migration input. Once
* a config is loaded, `memory.backend` is the sole runtime selector.
*/
export function resolveMemoryBackend(settings: Settings): MemoryBackend {
export async function resolveMemoryBackend(settings: Settings): Promise<MemoryBackend> {
const id = settings.get("memory.backend");
if (id === "hindsight") return hindsightBackend;
if (id === "mnemopi") return mnemopiBackend;
if (id === "hindsight") return (await import("../hindsight/backend")).hindsightBackend;
if (id === "mnemopi") return (await import("../mnemopi/backend")).mnemopiBackend;
if (id === "local") return localBackend;
return offBackend;
}
@@ -1,7 +1,7 @@
/**
* Memory backend abstraction.
*
* Backends are mutually exclusive — `resolveMemoryBackend(settings)` returns
* Backends are mutually exclusive — `await resolveMemoryBackend(settings)` resolves
* exactly one. Implementations MUST be self-contained: they own the per-session
* state they create in `start()` and tear it down on `clear()`.
*/
+5 -1
View File
@@ -5,6 +5,7 @@ import { Mnemopi } from "@oh-my-pi/pi-mnemopi";
import { BankManager } from "@oh-my-pi/pi-mnemopi/core";
import { type DiagnosticSummary, inspectDatabase } from "@oh-my-pi/pi-mnemopi/diagnose";
import { logger } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { resolveRoleSelection } from "../config/model-resolver";
import type { MemoryBackend, MemoryBackendStartOptions } from "../memory-backend/types";
@@ -334,7 +335,10 @@ async function resolveMnemopiProviderOptions(
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
},
{
apiKey,
apiKey: modelRegistry.resolver(model.provider, {
sessionId,
baseUrl: model.baseUrl,
}),
maxTokens: opts?.maxTokens,
temperature: opts?.temperature,
},
@@ -1,3 +1,4 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
type Agent,
@@ -62,7 +63,7 @@ import { MCPManager } from "../../mcp/manager";
import type { MCPServerConfig } from "../../mcp/types";
import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
import { theme } from "../../modes/theme/theme";
import { type PlanApprovalDetails, renameApprovedPlanFile, resolvePlanTitle } from "../../plan-mode/approved-plan";
import { type PlanApprovalDetails, resolveApprovedPlan } from "../../plan-mode/approved-plan";
import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
import {
@@ -1425,24 +1426,16 @@ export class AcpAgent implements Agent {
if (!state?.enabled) {
throw new ToolError("Plan mode is not active.");
}
const planFilePath = state.planFilePath;
const planContent = await this.#readAcpPlanFile(session, planFilePath);
if (planContent === null) {
throw new ToolError(
`Plan file not found at ${planFilePath}. Write the finalized plan to ${planFilePath} before requesting approval.`,
);
}
const normalized = resolvePlanTitle({
const { planFilePath, planContent, title } = await resolveApprovedPlan({
suppliedTitle: extra?.title,
planContent,
planFilePath,
statePlanFilePath: state.planFilePath,
readPlan: url => this.#readAcpPlanFile(session, url),
listPlanFiles: () => this.#listAcpLocalPlanFiles(session),
});
const finalPlanFilePath = `local://${normalized.fileName}`;
const approved = await this.#requestAcpPlanApprovalChoice(session.sessionId, normalized.title, planContent);
const approved = await this.#requestAcpPlanApprovalChoice(session.sessionId, title, planContent);
const details: PlanApprovalDetails = {
planFilePath,
finalPlanFilePath,
title: normalized.title,
title,
planExists: true,
};
if (!approved) {
@@ -1458,16 +1451,10 @@ export class AcpAgent implements Agent {
details,
};
}
// Approved. Rename plan to its titled filename, set the plan
// reference so the next turn injects the plan content as
// context, then exit plan mode so the agent regains full tools.
await renameApprovedPlanFile({
planFilePath,
finalPlanFilePath,
getArtifactsDir: () => session.sessionManager.getArtifactsDir(),
getSessionId: () => session.sessionManager.getSessionId(),
});
session.setPlanReferencePath(finalPlanFilePath);
// Approved. Set the plan reference so the next turn injects the plan
// content as context (the file keeps its agent-chosen name — no
// rename), then exit plan mode so the agent regains full tools.
session.setPlanReferencePath(planFilePath);
session.setStandingResolveHandler?.(null);
session.setPlanModeState(undefined);
try {
@@ -1486,7 +1473,7 @@ export class AcpAgent implements Agent {
content: [
{
type: "text" as const,
text: `Plan approved at ${finalPlanFilePath}. Plan mode exited; proceed with the implementation.`,
text: `Plan approved at ${planFilePath}. Plan mode exited; proceed with the implementation.`,
},
],
details,
@@ -1518,6 +1505,26 @@ export class AcpAgent implements Agent {
}
}
/** `local://` URLs of plan files in the session-local root, newest first —
* the `resolveApprovedPlan` fallback for a dropped `extra.title`. */
async #listAcpLocalPlanFiles(session: AgentSession): Promise<string[]> {
const localRoot = this.#resolveAcpPlanFilePath(session, "local://");
try {
const entries = await fs.readdir(localRoot, { withFileTypes: true });
const plans = await Promise.all(
entries
.filter(entry => entry.isFile() && /plan\.md$/i.test(entry.name))
.map(async entry => {
const stat = await fs.stat(path.join(localRoot, entry.name)).catch(() => null);
return { url: `local://${entry.name}`, mtime: stat?.mtimeMs ?? 0 };
}),
);
return plans.sort((a, b) => b.mtime - a.mtime).map(plan => plan.url);
} catch {
return [];
}
}
/**
* Ask the ACP client to confirm plan approval. Returns `true` only on an
* explicit `APPROVE_OPTION` selection. Refine, dismissal (`undefined`), or
@@ -27,6 +27,7 @@ import {
matchesKey,
padding,
replaceTabs,
ScrollView,
Spacer,
Text,
truncateToWidth,
@@ -205,9 +206,12 @@ class AgentListPane implements Component {
return lines;
}
const overflow = this.agents.length > this.maxVisible;
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
const start = this.scrollOffset;
const end = Math.min(start + this.maxVisible, this.agents.length);
const rows: string[] = [];
for (let i = start; i < end; i++) {
const agent = this.agents[i];
const selected = i === this.selectedIndex;
@@ -224,12 +228,17 @@ class AgentListPane implements Component {
line = theme.fg("dim", line);
}
lines.push(truncateToWidth(line, width));
rows.push(truncateToWidth(line, rowWidth));
}
if (this.agents.length > this.maxVisible) {
lines.push(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.agents.length})`));
}
const sv = new ScrollView(rows, {
height: rows.length,
scrollbar: "auto",
totalRows: this.agents.length,
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(this.scrollOffset);
lines.push(...sv.render(width));
return lines;
}
@@ -4,7 +4,7 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
import { settings } from "../../config/settings";
import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { isSilentAbort } from "../../session/messages";
import { isSilentAbort, resolveAbortLabel } from "../../session/messages";
import { resolveImageOptions } from "../../tools/render-utils";
/**
@@ -18,6 +18,15 @@ export class AssistantMessageComponent extends Container {
#convertedKittyImages = new Map<string, ImageContent>();
#kittyConversionsInFlight = new Set<string>();
#transcriptBlockFinalized: boolean;
/**
* When true, the turn-ending `Error: …` line for `stopReason === "error"` is
* suppressed because the same error is currently shown in the pinned banner
* above the editor (see `EventController` + `ErrorBannerComponent`). Avoids
* rendering the identical error twice (inline + banner) at the error moment.
* Restored to `false` when the banner is cleared at the next turn so the
* transcript keeps the error in history.
*/
#errorPinned = false;
constructor(
message?: AssistantMessage,
@@ -49,6 +58,18 @@ export class AssistantMessageComponent extends Container {
this.hideThinkingBlock = hide;
}
/**
* Toggle suppression of the inline `Error: …` line while the same error is
* pinned in the banner above the editor. Re-renders so the change is visible.
*/
setErrorPinned(pinned: boolean): void {
if (this.#errorPinned === pinned) return;
this.#errorPinned = pinned;
if (this.#lastMessage) {
this.updateContent(this.#lastMessage);
}
}
isTranscriptBlockFinalized(): boolean {
return this.#transcriptBlockFinalized;
}
@@ -187,10 +208,6 @@ export class AssistantMessageComponent extends Container {
c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
);
if (hasVisibleContent) {
this.#contentContainer.addChild(new Spacer(1));
}
// Render content in order
let thinkingIndex = 0;
for (let i = 0; i < message.content.length; i++) {
@@ -236,17 +253,14 @@ export class AssistantMessageComponent extends Container {
const hasToolCalls = message.content.some(c => c.type === "toolCall");
if (!hasToolCalls) {
if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
const abortMessage =
message.errorMessage && message.errorMessage !== "Request was aborted"
? message.errorMessage
: "Operation aborted";
const abortMessage = resolveAbortLabel(message.errorMessage);
if (hasVisibleContent) {
this.#contentContainer.addChild(new Spacer(1));
} else {
this.#contentContainer.addChild(new Spacer(1));
}
this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
} else if (message.stopReason === "error") {
} else if (message.stopReason === "error" && !this.#errorPinned) {
const errorMsg = message.errorMessage || "Unknown error";
this.#contentContainer.addChild(new Spacer(1));
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
@@ -0,0 +1,111 @@
import { Container } from "@oh-my-pi/pi-tui";
/**
* Capabilities a mounted {@link ChatBlock} may use against its host transcript.
* Kept minimal so blocks never reach into the full TUI/InteractiveMode surface.
*/
export interface ChatBlockHost {
/** Schedule a repaint of the transcript. */
requestRender(): void;
}
/**
* Lifecycle-aware transcript block — the "return a block, let the host mount it"
* primitive, modelled on React/Svelte component lifecycles.
*
* Producers build and return a `ChatBlock` instead of poking `chatContainer` and
* `ui.requestRender()` directly. The host (`ctx.present`) appends it and calls
* {@link mount}, which runs {@link onMount}; effects started there register
* teardown via {@link onCleanup}. The block repaints through {@link requestRender}
* — never touching the TUI — and tears down exactly once on {@link finish}
* (self-complete: stop the animation, keep the final frame in the transcript) or
* {@link dispose} (host discards it, e.g. a transcript reset).
*
* While mounted and unfinished a block reports `isTranscriptBlockFinalized() ===
* false` so {@link "../components/transcript-container".TranscriptContainer}
* keeps it in the live, repaintable region on ED3-risk terminals; after
* `finish()`/`dispose()` it reports `true` and freezes at its final content.
*/
export abstract class ChatBlock extends Container {
#host: ChatBlockHost | undefined;
#cleanups: Array<() => void> = [];
#active = false;
#disposed = false;
/**
* Run setup after the block is in the transcript: start timers/subscriptions
* and register their teardown with {@link onCleanup}. Default: no-op (a block
* whose content is fixed at construction needs no mount work).
*/
protected onMount(): void {}
/**
* Register a teardown to run on {@link finish}/{@link dispose}, à la a
* `useEffect` cleanup. If the block is already disposed the cleanup runs
* immediately so callers never leak.
*/
protected onCleanup(cleanup: () => void): void {
if (this.#disposed) {
cleanup();
return;
}
this.#cleanups.push(cleanup);
}
/** Ask the host to repaint. No-op before mount or after dispose. */
protected requestRender(): void {
this.#host?.requestRender();
}
/** True between {@link mount} and {@link finish}/{@link dispose}. */
protected get active(): boolean {
return this.#active;
}
/**
* Host-only: attach the host and run {@link onMount}. Idempotent — a second
* call (e.g. a transcript rebuild that re-presents the same instance) is a
* no-op.
*/
mount(host: ChatBlockHost): void {
if (this.#host || this.#disposed) return;
this.#host = host;
this.#active = true;
this.onMount();
}
/**
* Self-complete: stop ongoing effects and freeze the block at its current
* content, leaving it rendered in the transcript. Use when the operation the
* block represents finishes (connection resolved, download done).
*/
finish(): void {
if (!this.#active) return;
this.#active = false;
this.#runCleanups();
this.requestRender();
}
/**
* Host-only teardown: release everything and propagate to children. Called
* when the host permanently discards the block (transcript reset). Idempotent.
*/
override dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#active = false;
this.#runCleanups();
super.dispose();
this.#host = undefined;
}
/** Live blocks stay repaintable; finished/disposed ones may freeze. */
isTranscriptBlockFinalized(): boolean {
return !this.#active;
}
#runCleanups(): void {
const cleanups = this.#cleanups.splice(0);
for (const cleanup of cleanups) cleanup();
}
}
@@ -0,0 +1,206 @@
import { type Component, matchesKey, padding, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { replaceTabs } from "../../tools/render-utils";
import { highlightCode, theme } from "../theme/theme";
import type { CopyTarget } from "../utils/copy-targets";
import {
matchesSelectCancel,
matchesSelectDown,
matchesSelectPageDown,
matchesSelectPageUp,
matchesSelectUp,
} from "../utils/keybinding-matchers";
import { keyHint, rawKeyHint } from "./keybinding-hints";
import { bottomBorder, divider, row, topBorder } from "./overlay-box";
/** Minimum rows reserved for the tree even on short terminals. */
const MIN_TREE_ROWS = 3;
/** Fixed chrome rows: top border, two dividers, footer, bottom border. */
const CHROME_ROWS = 5;
export interface CopySelectorCallbacks {
/** A copy target was chosen — copy its `content`. */
onPick: (target: CopyTarget) => void;
/** The picker was dismissed. */
onCancel: () => void;
}
interface FlatNode {
target: CopyTarget;
depth: number;
/** Last among its siblings (drives └─ vs ├─). */
isLast: boolean;
/** Per-ancestor flag: does ancestor at that level have a following sibling? */
ancestorHasNext: boolean[];
}
/** Render one tree connector as exactly three cells (e.g. "├─ ", "└─ ", "|--"). */
function connectorCells(symbol: string): string {
const chars = Array.from(symbol);
return (chars[0] ?? " ") + (chars[1] ?? theme.tree.horizontal) + (chars[2] ?? " ");
}
/** The 3-cell ancestor gutter: a vertical guide when the ancestor continues. */
function gutterCells(hasNext: boolean): string {
return `${hasNext ? theme.tree.vertical : " "} `;
}
/**
* Fullscreen `/copy` picker rendered as a `/tree`-style tree inside one
* outlined box: a title, the tree of copy targets (recent assistant messages
* with their code blocks nested beneath), a live preview of the highlighted
* node, and a keybinding footer. Every node copies its `content` on Enter.
*/
export class CopySelectorComponent implements Component {
#roots: CopyTarget[];
#cursorId: string;
#treeRows = MIN_TREE_ROWS;
// Reused across renders to wrap preview content to the pane width.
#previewText = new Text("", 0, 0);
constructor(
roots: CopyTarget[],
private readonly callbacks: CopySelectorCallbacks,
) {
this.#roots = roots;
this.#cursorId = roots[0]?.id ?? "";
}
invalidate(): void {}
#flatten(): FlatNode[] {
const out: FlatNode[] = [];
const walk = (nodes: CopyTarget[], depth: number, ancestorHasNext: boolean[]) => {
nodes.forEach((target, i) => {
const isLast = i === nodes.length - 1;
out.push({ target, depth, isLast, ancestorHasNext });
if (target.children?.length) walk(target.children, depth + 1, [...ancestorHasNext, !isLast]);
});
};
walk(this.#roots, 0, []);
return out;
}
handleInput(keyData: string): void {
if (matchesSelectCancel(keyData)) {
this.callbacks.onCancel();
return;
}
const flat = this.#flatten();
if (flat.length === 0) return;
const idx = Math.max(
0,
flat.findIndex(n => n.target.id === this.#cursorId),
);
if (matchesSelectUp(keyData)) {
this.#cursorId = flat[idx === 0 ? flat.length - 1 : idx - 1]!.target.id;
} else if (matchesSelectDown(keyData)) {
this.#cursorId = flat[idx === flat.length - 1 ? 0 : idx + 1]!.target.id;
} else if (matchesSelectPageUp(keyData)) {
this.#cursorId = flat[Math.max(0, idx - this.#treeRows)]!.target.id;
} else if (matchesSelectPageDown(keyData)) {
this.#cursorId = flat[Math.min(flat.length - 1, idx + this.#treeRows)]!.target.id;
} else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
const target = flat[idx]!.target;
if (target.content !== undefined) this.callbacks.onPick(target);
}
}
#renderTree(width: number, flat: FlatNode[], cursorIdx: number, rows: number): string[] {
const inner = Math.max(0, width - 4);
const start = Math.max(0, Math.min(cursorIdx - Math.floor(rows / 2), Math.max(0, flat.length - rows)));
const out: string[] = [];
for (let r = 0; r < rows; r++) {
const i = start + r;
const node = flat[i];
if (!node) {
out.push(row("", width));
continue;
}
const target = node.target;
const isSelected = i === cursorIdx;
let prefix = "";
for (let l = 0; l < node.depth - 1; l++) prefix += gutterCells(node.ancestorHasNext[l]!);
if (node.depth > 0) prefix += connectorCells(node.isLast ? theme.tree.last : theme.tree.branch);
const cursor = isSelected ? "❯ " : " ";
const hint = target.hint ?? "";
const hintWidth = hint ? visibleWidth(hint) + 2 : 0;
const used = visibleWidth(cursor) + visibleWidth(prefix);
const labelPlain = truncateToWidth(target.label, Math.max(1, inner - used - hintWidth));
const left = isSelected
? theme.fg("accent", cursor) + theme.fg("dim", prefix) + theme.bold(theme.fg("accent", labelPlain))
: cursor + theme.fg("dim", prefix) + labelPlain;
const gap = Math.max(1, inner - used - visibleWidth(labelPlain) - visibleWidth(hint));
out.push(row(left + padding(gap) + (hint ? theme.fg("dim", hint) : ""), width));
}
return out;
}
#renderPreview(width: number, target: CopyTarget | undefined, rows: number): string[] {
const out: string[] = [];
const hint = target?.hint;
out.push(row(theme.fg("dim", `Preview${hint ? ` · ${hint}` : ""}`), width));
const contentRows = rows - 1;
if (!target || contentRows <= 0) {
while (out.length < rows) out.push(row("", width));
return out;
}
// Code/command previews are syntax-highlighted; everything else is shown
// as plain text. Both are wrapped (not hard-truncated) to the pane width.
const isCode = target.language !== undefined;
const source = isCode
? highlightCode(replaceTabs(target.preview), target.language).join("\n")
: replaceTabs(target.preview);
this.#previewText.setText(source);
const wrapped = this.#previewText.render(Math.max(1, width - 4));
const hasMore = wrapped.length > contentRows;
const visibleCount = hasMore ? contentRows - 1 : Math.min(wrapped.length, contentRows);
for (let k = 0; k < contentRows; k++) {
if (k < visibleCount) {
out.push(row(isCode ? wrapped[k]! : theme.fg("muted", wrapped[k]!), width));
} else if (k === visibleCount && hasMore) {
out.push(row(theme.fg("dim", `… ${wrapped.length - visibleCount} more lines`), width));
} else {
out.push(row("", width));
}
}
return out;
}
render(width: number): string[] {
const height = process.stdout.rows || 40;
const flat = this.#flatten();
const cursorIdx = Math.max(
0,
flat.findIndex(n => n.target.id === this.#cursorId),
);
const selected = flat[cursorIdx]?.target;
const available = Math.max(MIN_TREE_ROWS + 1, height - CHROME_ROWS);
const treeRows = Math.max(1, Math.min(flat.length, Math.floor(available / 2)));
this.#treeRows = treeRows;
const previewRows = Math.max(1, available - treeRows);
const footer = [
rawKeyHint("↑↓", "move"),
keyHint("tui.select.confirm", "copy"),
keyHint("tui.select.cancel", "quit"),
].join(theme.fg("dim", " · "));
return [
topBorder(width, "Copy to clipboard"),
...this.#renderTree(width, flat, cursorIdx, treeRows),
divider(width),
...this.#renderPreview(width, selected, previewRows),
divider(width),
row(footer, width),
bottomBorder(width),
];
}
}
@@ -10,6 +10,7 @@ type ConfigurableEditorAction = Extract<
| "app.clear"
| "app.exit"
| "app.suspend"
| "app.display.reset"
| "app.thinking.cycle"
| "app.model.cycleForward"
| "app.model.cycleBackward"
@@ -30,10 +31,11 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
"app.clear": ["ctrl+c"],
"app.exit": ["ctrl+d"],
"app.suspend": ["ctrl+z"],
"app.display.reset": ["ctrl+l"],
"app.thinking.cycle": ["shift+tab"],
"app.model.cycleForward": ["ctrl+p"],
"app.model.cycleBackward": ["shift+ctrl+p"],
"app.model.select": ["ctrl+l"],
"app.model.select": ["alt+m"],
"app.model.selectTemporary": ["alt+p"],
"app.tools.expand": ["ctrl+o"],
"app.thinking.toggle": ["ctrl+t"],
@@ -45,6 +47,21 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
"app.clipboard.copyPrompt": ["alt+shift+c"],
};
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
export function extractBracketedImagePastePath(data: string): string | undefined {
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
if (!pasted || /[\r\n]/.test(pasted)) return undefined;
if (!BRACKETED_IMAGE_PATH_REGEX.test(pasted)) return undefined;
return pasted;
}
/**
* Custom editor that handles configurable app-level shortcuts for coding-agent.
*/
@@ -65,6 +82,7 @@ export class CustomEditor extends Editor {
onEscape?: () => void;
onClear?: () => void;
onExit?: () => void;
onDisplayReset?: () => void;
onCycleThinkingLevel?: () => void;
onCycleModelForward?: () => void;
onCycleModelBackward?: () => void;
@@ -79,6 +97,8 @@ export class CustomEditor extends Editor {
onCopyPrompt?: () => void;
/** Called when the configured image-paste shortcut is pressed. */
onPasteImage?: () => Promise<boolean>;
/** Called when a bracketed paste contains exactly one image-file path. */
onPasteImagePath?: (path: string) => void;
/** Called when the configured raw text-paste shortcut is pressed. */
onPasteTextRaw?: () => void;
/** Called when the configured dequeue shortcut is pressed. */
@@ -134,6 +154,12 @@ export class CustomEditor extends Editor {
return;
}
const pastedImagePath = extractBracketedImagePastePath(data);
if (pastedImagePath && this.onPasteImagePath) {
this.onPasteImagePath(pastedImagePath);
return;
}
// Intercept configured image paste (async - fires and handles result)
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
void this.onPasteImage();
@@ -158,6 +184,12 @@ export class CustomEditor extends Editor {
return;
}
// Intercept configured display reset shortcut
if (this.#matchesAction(data, "app.display.reset") && this.onDisplayReset) {
this.onDisplayReset();
return;
}
// Intercept configured suspend shortcut
if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
this.onSuspend();
@@ -1,5 +1,5 @@
import type { Component } from "@oh-my-pi/pi-tui";
import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
import { Box, Container } from "@oh-my-pi/pi-tui";
import type { MessageRenderer } from "../../extensibility/extensions/types";
import { theme } from "../../modes/theme/theme";
import type { CustomMessage } from "../../session/messages";
@@ -20,8 +20,6 @@ export class CustomMessageComponent extends Container {
) {
super();
this.addChild(new Spacer(1));
// Create box with custom background (used for default rendering)
this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
@@ -7,7 +7,7 @@
* stay in their respective files.
*/
import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { type Component, Container, Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
import { DynamicBorder } from "./dynamic-border";
@@ -31,7 +31,6 @@ export function buildExecutionFrame(
): { contentContainer: Container; loader: Loader } {
const borderColor = (str: string) => theme.fg(colorKey, str);
parent.addChild(new Spacer(1));
parent.addChild(new DynamicBorder(borderColor));
const contentContainer = new Container();
@@ -10,6 +10,7 @@ import {
extractPrintableText,
matchesKey,
padding,
ScrollView,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
@@ -134,25 +135,33 @@ export class ExtensionList implements Component {
const startIdx = this.#scrollOffset;
const endIdx = Math.min(startIdx + this.#maxVisible, this.#listItems.length);
// Reserve the rightmost column for the scrollbar when overflowing
const overflow = this.#listItems.length > this.#maxVisible;
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
// Render visible items
const rows: string[] = [];
for (let i = startIdx; i < endIdx; i++) {
const listItem = this.#listItems[i];
const isSelected = this.#focused && i === this.#selectedIndex;
if (listItem.type === "master") {
lines.push(this.#renderMasterSwitch(listItem, isSelected, width));
rows.push(this.#renderMasterSwitch(listItem, isSelected, rowWidth));
} else if (listItem.type === "kind-header") {
lines.push(this.#renderKindHeader(listItem, isSelected, width));
rows.push(this.#renderKindHeader(listItem, isSelected, rowWidth));
} else {
lines.push(this.#renderExtensionRow(listItem.item, isSelected, width, masterDisabled));
rows.push(this.#renderExtensionRow(listItem.item, isSelected, rowWidth, masterDisabled));
}
}
// Scroll indicator
if (this.#listItems.length > this.#maxVisible) {
const indicator = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#listItems.length})`);
lines.push(indicator);
}
const sv = new ScrollView(rows, {
height: rows.length,
scrollbar: "auto",
totalRows: this.#listItems.length,
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(this.#scrollOffset);
lines.push(...sv.render(width));
return lines;
}
@@ -5,6 +5,7 @@ import {
Input,
matchesKey,
padding,
ScrollView,
Spacer,
Text,
truncateToWidth,
@@ -115,15 +116,19 @@ class HistoryResultsList implements Component {
);
const endIndex = Math.min(startIndex + this.#maxVisible, this.#results.length);
const overflow = this.#results.length > this.#maxVisible;
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
const rows: string[] = [];
for (let i = startIndex; i < endIndex; i++) {
const entry = this.#results[i];
const isSelected = i === this.#selectedIndex;
const timeStr = relativeTime(entry.created_at);
const timeWidth = visibleWidth(timeStr);
const showTime = width >= gutterWidth + 12 + timeWidth;
const showTime = rowWidth >= gutterWidth + 12 + timeWidth;
const promptBudget = Math.max(4, width - gutterWidth - (showTime ? timeWidth + 1 : 0));
const promptBudget = Math.max(4, rowWidth - gutterWidth - (showTime ? timeWidth + 1 : 0));
const normalized = entry.prompt.replace(/\s+/g, " ").trim();
const plain = truncateToWidth(normalized, promptBudget);
const highlighted = highlightTokens(plain, this.#tokens);
@@ -133,21 +138,24 @@ class HistoryResultsList implements Component {
if (showTime) {
// Pad the prompt region so the timestamp sits flush right with a one-cell gap.
line = `${truncateToWidth(line, width - timeWidth - 1, Ellipsis.Unicode, true)} ${theme.fg("dim", timeStr)}`;
line = `${truncateToWidth(line, rowWidth - timeWidth - 1, Ellipsis.Unicode, true)} ${theme.fg("dim", timeStr)}`;
}
lines.push(
rows.push(
isSelected
? theme.bg("selectedBg", truncateToWidth(line, width, Ellipsis.Omit, true))
: truncateToWidth(line, width),
? theme.bg("selectedBg", truncateToWidth(line, rowWidth, Ellipsis.Omit, true))
: truncateToWidth(line, rowWidth),
);
}
if (startIndex > 0 || endIndex < this.#results.length) {
const scrollText = ` ${this.#selectedIndex + 1}/${this.#results.length}`;
lines.push(theme.fg("muted", truncateToWidth(scrollText, width, Ellipsis.Omit)));
}
const sv = new ScrollView(rows, {
height: rows.length,
scrollbar: "auto",
totalRows: this.#results.length,
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(startIndex);
lines.push(...sv.render(width));
return lines;
}
}
@@ -1,5 +1,5 @@
import type { Component } from "@oh-my-pi/pi-tui";
import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
import { Box, Container } from "@oh-my-pi/pi-tui";
import type { HookMessageRenderer } from "../../extensibility/hooks/types";
import { theme } from "../../modes/theme/theme";
import type { HookMessage } from "../../session/messages";
@@ -23,8 +23,6 @@ export class HookMessageComponent extends Container {
) {
super();
this.addChild(new Spacer(1));
// Create box with purple background (used for default rendering)
this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
@@ -6,6 +6,7 @@ import {
getKeybindings,
Input,
matchesKey,
ScrollView,
Spacer,
type Tab,
TabBar,
@@ -13,6 +14,7 @@ import {
type TUI,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { formatNumber } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../../config/model-registry";
import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry";
import { resolveModelRoleValue } from "../../config/model-resolver";
@@ -147,6 +149,7 @@ export class ModelSelectorComponent extends Container {
#tui: TUI;
#scopedModels: ReadonlyArray<ScopedModelItem>;
#temporaryOnly: boolean;
#currentContextTokens: number;
#menuRoleActions: MenuRoleAction[] = [];
@@ -172,7 +175,7 @@ export class ModelSelectorComponent extends Container {
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: RoleSelectCallback,
onCancel: () => void,
options?: { temporaryOnly?: boolean; initialSearchInput?: string },
options?: { temporaryOnly?: boolean; initialSearchInput?: string; currentContextTokens?: number },
) {
super();
@@ -183,6 +186,9 @@ export class ModelSelectorComponent extends Container {
this.#onSelectCallback = onSelect;
this.#onCancelCallback = onCancel;
this.#temporaryOnly = options?.temporaryOnly ?? false;
const currentContextTokens = options?.currentContextTokens ?? 0;
this.#currentContextTokens =
Number.isFinite(currentContextTokens) && currentContextTokens > 0 ? Math.floor(currentContextTokens) : 0;
const initialSearchInput = options?.initialSearchInput;
// Initialize menu role actions (built-in + custom from settings)
@@ -215,8 +221,8 @@ export class ModelSelectorComponent extends Container {
this.#searchInput.setValue(initialSearchInput);
}
this.#searchInput.onSubmit = () => {
// Enter on search input opens menu if we have a selection
if (this.#filteredModels[this.#selectedIndex]) {
// Enter on search input opens menu if we have an enabled selection
if (this.#getSelectedItem()) {
this.#openMenu();
}
};
@@ -460,7 +466,11 @@ export class ModelSelectorComponent extends Container {
this.#filteredModels = models;
this.#canonicalModels = canonicalModels;
this.#filteredCanonicalModels = canonicalModels;
this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1));
const visibleModels = this.#isCanonicalTab() ? canonicalModels : models;
this.#selectedIndex = this.#coerceSelectedIndex(
Math.min(this.#selectedIndex, Math.max(0, visibleModels.length - 1)),
visibleModels,
);
}
async #loadModels(): Promise<void> {
@@ -626,6 +636,74 @@ export class ModelSelectorComponent extends Container {
return this.#getActiveTabId() === CANONICAL_TAB;
}
#isModelOverContextLimit(model: Model): boolean {
const contextWindow = model.contextWindow ?? 0;
return this.#currentContextTokens > 0 && contextWindow > 0 && this.#currentContextTokens > contextWindow;
}
#isItemDisabled(item: ModelItem | CanonicalModelItem): boolean {
return this.#isModelOverContextLimit(item.model);
}
#formatContextLimitSuffix(model: Model): string {
if (!this.#isModelOverContextLimit(model)) {
return "";
}
return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow).toLowerCase()}`;
}
#getVisibleItems(): ReadonlyArray<ModelItem | CanonicalModelItem> {
return this.#isCanonicalTab() ? this.#filteredCanonicalModels : this.#filteredModels;
}
#coerceSelectedIndex(
index: number,
visibleItems: ReadonlyArray<ModelItem | CanonicalModelItem> = this.#getVisibleItems(),
): number {
const maxIndex = visibleItems.length - 1;
if (maxIndex < 0) {
return 0;
}
const clamped = Math.max(0, Math.min(index, maxIndex));
const clampedItem = visibleItems[clamped];
if (clampedItem && !this.#isItemDisabled(clampedItem)) {
return clamped;
}
for (let i = clamped + 1; i <= maxIndex; i++) {
const item = visibleItems[i];
if (item && !this.#isItemDisabled(item)) {
return i;
}
}
for (let i = clamped - 1; i >= 0; i--) {
const item = visibleItems[i];
if (item && !this.#isItemDisabled(item)) {
return i;
}
}
return clamped;
}
#moveSelection(delta: number): void {
const visibleItems = this.#getVisibleItems();
const count = visibleItems.length;
if (count === 0) {
return;
}
let index = this.#selectedIndex;
for (let step = 0; step < count; step++) {
index = (index + delta + count) % count;
const item = visibleItems[index];
if (item && !this.#isItemDisabled(item)) {
this.#selectedIndex = index;
this.#updateList();
return;
}
}
this.#selectedIndex = this.#coerceSelectedIndex(this.#selectedIndex, visibleItems);
this.#updateList();
}
#filterModels(query: string): void {
const activeTabId = this.#getActiveTabId();
const activeProviderId = this.#getActiveProviderId();
@@ -696,8 +774,11 @@ export class ModelSelectorComponent extends Container {
this.#filteredCanonicalModels = baseCanonicalModels;
}
const visibleCount = isCanonicalTab ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, visibleCount - 1));
const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels;
this.#selectedIndex = this.#coerceSelectedIndex(
Math.min(this.#selectedIndex, Math.max(0, visibleItems.length - 1)),
visibleItems,
);
this.#updateList();
}
@@ -778,6 +859,7 @@ export class ModelSelectorComponent extends Container {
const showProvider = this.#getActiveTabId() === ALL_TAB;
const rows: string[] = [];
// Show visible slice of filtered models
for (let i = startIndex; i < endIndex; i++) {
const item = visibleItems[i];
@@ -786,6 +868,8 @@ export class ModelSelectorComponent extends Container {
const providerItem = isCanonicalTab ? undefined : (item as ModelItem);
const isSelected = i === this.#selectedIndex;
const isDisabled = this.#isItemDisabled(item);
const disabledSuffix = this.#formatContextLimitSuffix(item.model);
// Build role badges (inverted: color as background, black text)
const roleBadgeTokens: string[] = [];
@@ -815,34 +899,42 @@ export class ModelSelectorComponent extends Container {
if (isCanonicalTab) {
const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}`;
line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}${disabledSuffix}`;
} else if (showProvider) {
const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}`;
line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}${disabledSuffix}`;
} else {
line = `${prefix}${theme.fg("accent", item.id)}${badgeText}`;
line = `${prefix}${theme.fg("accent", item.id)}${badgeText}${disabledSuffix}`;
}
} else {
const prefix = " ";
if (isCanonicalTab) {
const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
line = `${prefix}${item.id}${variants}${backing}${badgeText}`;
line = `${prefix}${item.id}${variants}${backing}${badgeText}${disabledSuffix}`;
} else if (showProvider) {
const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}`;
line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}${disabledSuffix}`;
} else {
line = `${prefix}${item.id}${badgeText}`;
line = `${prefix}${item.id}${badgeText}${disabledSuffix}`;
}
}
this.#listContainer.addChild(new Text(line, 0, 0));
if (isDisabled) {
line = theme.fg("dim", Bun.stripANSI(line));
}
rows.push(line);
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < visibleItems.length) {
const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${visibleItems.length})`);
this.#listContainer.addChild(new Text(scrollInfo, 0, 0));
if (rows.length > 0) {
const sv = new ScrollView(rows, {
height: rows.length,
scrollbar: "auto",
totalRows: visibleItems.length,
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(startIndex);
this.#listContainer.addChild(sv);
}
// Show error message or "no results" if empty
@@ -863,8 +955,14 @@ export class ModelSelectorComponent extends Container {
const suffix = isCanonicalTab
? ` (${selected.model.provider}/${selected.model.id}, ${(selected as CanonicalModelItem).variantCount} variants)`
: "";
const limitWarning = this.#isItemDisabled(selected)
? theme.fg(
"dim",
` — current context ${formatNumber(this.#currentContextTokens).toLowerCase()} > ${formatNumber(selected.model.contextWindow).toLowerCase()} limit`,
)
: "";
this.#listContainer.addChild(
new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`), 0, 0),
new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`) + limitWarning, 0, 0),
);
}
}
@@ -890,7 +988,8 @@ export class ModelSelectorComponent extends Container {
}
#openMenu(): void {
if (!this.#getSelectedItem()) return;
const selectedItem = this.#getSelectedItem();
if (!selectedItem || this.#isItemDisabled(selectedItem)) return;
this.#isMenuOpen = true;
this.#menuStep = "role";
@@ -978,26 +1077,20 @@ export class ModelSelectorComponent extends Container {
// Up arrow - navigate list (wrap to bottom when at top)
if (matchesSelectUp(keyData)) {
const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
if (itemCount === 0) return;
this.#selectedIndex = this.#selectedIndex === 0 ? itemCount - 1 : this.#selectedIndex - 1;
this.#updateList();
this.#moveSelection(-1);
return;
}
// Down arrow - navigate list (wrap to top when at bottom)
if (matchesSelectDown(keyData)) {
const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
if (itemCount === 0) return;
this.#selectedIndex = this.#selectedIndex === itemCount - 1 ? 0 : this.#selectedIndex + 1;
this.#updateList();
this.#moveSelection(1);
return;
}
// Enter - open context menu or select directly in temporary mode
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
const selectedItem = this.#getSelectedItem();
if (selectedItem) {
if (selectedItem && !this.#isItemDisabled(selectedItem)) {
if (this.#temporaryOnly) {
// In temporary mode, skip menu and select directly
this.#handleSelect(selectedItem, null);
@@ -1020,7 +1113,7 @@ export class ModelSelectorComponent extends Container {
}
#handleMenuInput(keyData: string): void {
const selectedItem = this.#getSelectedItem();
if (!selectedItem) return;
if (!selectedItem || this.#isItemDisabled(selectedItem)) return;
const optionCount =
this.#menuStep === "thinking" && this.#menuSelectedRole !== null
@@ -1079,6 +1172,9 @@ export class ModelSelectorComponent extends Container {
role: string | null,
thinkingLevel?: ConfiguredThinkingLevel,
): void {
if (this.#isItemDisabled(item)) {
return;
}
// For temporary role, don't save to settings - just notify caller
if (role === null) {
this.#onSelectCallback(item.model, null, undefined, item.selector);
@@ -1,6 +1,14 @@
import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/utils/oauth/types";
import { Container, extractPrintableText, fuzzyFilter, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
import {
Container,
extractPrintableText,
fuzzyFilter,
matchesKey,
ScrollView,
Spacer,
TruncatedText,
} from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
import type { AuthStorage } from "../../session/auth-storage";
@@ -162,14 +170,10 @@ export class OAuthSelectorComponent extends Container {
return this.#isSearchEnabled() || this.#searchQuery.length > 0;
}
#renderStatusLine(total: number): string {
const selectedCount = total === 0 ? 0 : this.#selectedIndex + 1;
const count =
this.#searchQuery.trim() && total !== this.#allProviders.length
? `${selectedCount}/${total} of ${this.#allProviders.length}`
: `${selectedCount}/${total}`;
const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search";
return theme.fg("muted", ` (${count})${suffix}`);
#renderStatusLine(_total: number): string {
const query = this.#searchQuery.trim();
const suffix = query ? `Search: ${this.#searchQuery}` : "Type to search";
return theme.fg("muted", ` ${suffix}`);
}
#getProviderSearchText(provider: OAuthProviderInfo): string {
@@ -223,6 +227,7 @@ export class OAuthSelectorComponent extends Container {
: Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
const endIndex = Math.min(startIndex + maxVisible, total);
const rows: string[] = [];
for (let i = startIndex; i < endIndex; i++) {
const provider = this.#filteredProviders[i];
if (!provider) continue;
@@ -239,11 +244,22 @@ export class OAuthSelectorComponent extends Container {
const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
line = text + statusIndicator;
}
this.#listContainer.addChild(new TruncatedText(line, 0, 0));
rows.push(line);
}
// Scroll/search indicator when list is windowed or searchable
if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) {
if (rows.length > 0) {
const sv = new ScrollView(rows, {
height: rows.length,
scrollbar: "auto",
totalRows: total,
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(startIndex);
this.#listContainer.addChild(sv);
}
// Search status line (scrollbar covers overflow indication)
if (this.#shouldRenderSearchStatus()) {
this.#listContainer.addChild(new TruncatedText(this.#renderStatusLine(total), 0, 0));
}
@@ -0,0 +1,108 @@
/**
* Shared box-drawing chrome for fullscreen overlays (the `/copy` picker, the
* plan-review overlay, …). Every helper paints with `theme.boxSharp` glyphs and
* the `border`/`accent` theme colors so all outlined overlays read identically.
*/
import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { theme } from "../theme/theme";
/** Pad or truncate a (possibly ANSI-styled) string to exactly `width` columns. */
export function fit(text: string, width: number): string {
if (width <= 0) return "";
const w = visibleWidth(text);
if (w === width) return text;
if (w < width) return text + padding(width - w);
const cut = truncateToWidth(text, width);
const cw = visibleWidth(cut);
return cw < width ? cut + padding(width - cw) : cut;
}
function paint(s: string): string {
return theme.fg("border", s);
}
/** Top border with an optional accent-colored title inset into the rule. */
export function topBorder(width: number, title: string): string {
const box = theme.boxSharp;
const inner = Math.max(0, width - 2);
if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight);
const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
const fillWidth = Math.max(0, inner - 1 - visibleWidth(shown));
return (
paint(box.topLeft + box.horizontal) +
theme.bold(theme.fg("accent", shown)) +
paint(box.horizontal.repeat(fillWidth) + box.topRight)
);
}
/** A horizontal rule with left/right tees, splitting overlay sections. */
export function divider(width: number): string {
const box = theme.boxSharp;
return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft);
}
export function bottomBorder(width: number): string {
const box = theme.boxSharp;
return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight);
}
/** Wrap pre-styled content in vertical borders with single-column insets. */
export function row(content: string, width: number): string {
const box = theme.boxSharp;
return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`;
}
/**
* Column index (0-based) of the inner divider for a two-column layout whose
* sidebar content area is `sidebarWidth` columns wide. The layout is
* `│ sidebar │ body │` with a single-column inset on every side, so the divider
* vertical sits at `sidebarWidth + 3` and the body content area is
* {@link splitBodyWidth} columns.
*/
function splitDividerCol(sidebarWidth: number): number {
return sidebarWidth + 3;
}
/** Body content width for a two-column overlay of total `width`. */
export function splitBodyWidth(width: number, sidebarWidth: number): number {
return Math.max(0, width - sidebarWidth - 7);
}
/** Top border carrying the title, split by a `┬` over the column divider. */
export function topBorderSplit(width: number, title: string, sidebarWidth: number): string {
const box = theme.boxSharp;
const dividerCol = splitDividerCol(sidebarWidth);
const leftLen = Math.max(0, dividerCol - 1);
const rightLen = Math.max(0, width - 2 - dividerCol);
let left: string;
if (!title) {
left = paint(box.topLeft + box.horizontal.repeat(leftLen));
} else {
const shown = truncateToWidth(` ${title} `, Math.max(0, leftLen - 1));
const fillWidth = Math.max(0, leftLen - 1 - visibleWidth(shown));
left =
paint(box.topLeft + box.horizontal) +
theme.bold(theme.fg("accent", shown)) +
paint(box.horizontal.repeat(fillWidth));
}
return left + paint(box.teeDown + box.horizontal.repeat(rightLen) + box.topRight);
}
/** Section rule that closes the sidebar column with a `┴` over the divider. */
export function dividerSplit(width: number, sidebarWidth: number): string {
const box = theme.boxSharp;
const dividerCol = splitDividerCol(sidebarWidth);
const leftLen = Math.max(0, dividerCol - 1);
const rightLen = Math.max(0, width - 2 - dividerCol);
return paint(
box.teeRight + box.horizontal.repeat(leftLen) + box.teeUp + box.horizontal.repeat(rightLen) + box.teeLeft,
);
}
/** A two-column content row: `│ sidebar │ body │`, each inset by one column. */
export function splitRow(sidebar: string, body: string, width: number, sidebarWidth: number): string {
const box = theme.boxSharp;
const bodyWidth = splitBodyWidth(width, sidebarWidth);
const bar = paint(box.vertical);
return `${bar} ${fit(sidebar, sidebarWidth)} ${bar} ${fit(body, bodyWidth)} ${bar}`;
}
@@ -0,0 +1,799 @@
/**
* Fullscreen plan-review overlay. The overlay owns its entire content: the plan
* is split into sections (preamble + one per heading), each rendered through its
* own {@link Markdown} and windowed by a {@link ScrollView}, while the approval
* options (plus the optional model-tier slider) sit beneath inside the same
* outlined box — one self-contained surface in the spirit of the `/copy` picker.
*
* When the terminal is wide enough and the plan has ≥2 headings, a Contents
* sidebar appears: it tracks the scrolled section with an accent "glow", and —
* when focused — lets the operator jump between sections, delete a section
* (with undo), and annotate sections with feedback that feeds the Refine loop.
*
* Focus regions (`toc`/`body`/`actions`) cycle with Tab/Shift+Tab; arrows move
* within the focused region and step left into the sidebar. The default focus is
* `actions`, so the muscle memory of the old single-target overlay carries over:
* ↑/↓ select options, Enter confirms, ←/→ drives the slider when there is no
* sidebar, g/G + PgUp/PgDn scroll, and the external-editor key opens the plan.
*/
import {
type Component,
Ellipsis,
Input,
Markdown,
type MarkdownTheme,
matchesKey,
ScrollView,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme";
import {
matchesAppExternalEditor,
matchesSelectCancel,
matchesSelectDown,
matchesSelectUp,
} from "../utils/keybinding-matchers";
import type { HookSelectorSlider } from "./hook-selector";
import {
bottomBorder,
divider,
dividerSplit,
fit,
row,
splitBodyWidth,
splitRow,
topBorder,
topBorderSplit,
} from "./overlay-box";
import { joinPlanSections, parsePlanSections, sectionDeletionSpan } from "./plan-toc";
import { renderSegmentTrack } from "./segment-track";
/** Title shown in the overlay's top border. */
const OVERLAY_TITLE = "Plan Review";
/** Minimum plan-body rows kept visible even on short terminals. */
const MIN_BODY_ROWS = 3;
/** Sidebar gates: enough headings, a wide terminal, and a usable body column. */
const SIDEBAR_MIN_HEADINGS = 2;
const SIDEBAR_MIN_TOTAL_WIDTH = 64;
const SIDEBAR_MIN_BODY_WIDTH = 40;
type Focus = "toc" | "body" | "actions";
interface OverlaySection {
level: number;
title: string;
raw: string;
md: Markdown;
annotations: string[];
}
/** Undo snapshot: joined plan text, annotations aligned by section, and the
* accumulated deleted-section feedback at the time of the snapshot. */
interface UndoEntry {
text: string;
annotations: string[][];
deleted: string[];
}
export interface PlanReviewOverlayCallbacks {
/** Invoked with the chosen option label (never a disabled one). */
onPick: (label: string) => void;
/** Invoked on Esc / cancel. */
onCancel: () => void;
/** Invoked when the external-editor key is pressed (overlay stays open). */
onExternalEditor?: () => void;
/** Invoked with the new full plan text after an in-overlay delete/undo. */
onPlanEdited?: (content: string) => void;
/** Invoked with the Refine feedback markdown whenever annotations change. */
onFeedbackChange?: (feedback: string) => void;
}
export interface PlanReviewOverlayOptions {
/** Prompt rendered above the options (e.g. "Plan mode - next step"). */
promptTitle?: string;
options: string[];
/** Indices into `options` that render dimmed and cannot be selected. */
disabledIndices?: number[];
/** Trailing footer hint (cancel hint); the overlay prepends dynamic help. */
helpText?: string;
/** Initially highlighted option index. */
initialIndex?: number;
/** Optional model-tier slider rendered between the plan body and options. */
slider?: HookSelectorSlider;
/** Display label for the external-editor key, surfaced in the footer help. */
externalEditorLabel?: string;
}
/** Default trailing footer hint when the caller supplies none. */
const DEFAULT_HELP_SUFFIX = "esc cancel";
export class PlanReviewOverlay implements Component {
#mdTheme: MarkdownTheme;
#scrollView: ScrollView;
#sections: OverlaySection[] = [];
#toc: number[] = [];
/** Shallowest level among ToC entries, used to flatten indentation. */
#tocBaseLevel = 1;
#sectionOffsets: number[] = [];
#undo: UndoEntry[] = [];
/** Titles of sections deleted in the overlay, surfaced as Refine feedback. */
#deleted: string[] = [];
#options: string[];
#disabled: Set<number>;
#helpSuffix: string;
#externalEditorLabel: string | undefined;
#promptTitle: string | undefined;
#selectedIndex: number;
#slider: HookSelectorSlider | undefined;
#sliderIndex: number;
#focus: Focus = "actions";
#tocCursor = 0;
#sidebarShown = false;
#pendingScrollToToc = false;
// Click hit-testing, rebuilt every render. Keys are 0-based rendered-line
// indices (== screen rows, since the fullscreen overlay paints from row 0).
#optionClickRows = new Map<number, number>();
#tocClickRows = new Map<number, number>();
#bodyClickRows = new Set<number>();
/** 1-based column at/under which a region-row click targets the sidebar. */
#sidebarClickMaxCol = 0;
#annotating = false;
#input: Input;
constructor(
planContent: string,
options: PlanReviewOverlayOptions,
private readonly callbacks: PlanReviewOverlayCallbacks,
) {
this.#mdTheme = getMarkdownTheme();
this.#scrollView = new ScrollView([], {
height: MIN_BODY_ROWS,
scrollbar: "auto",
ellipsis: Ellipsis.Omit,
theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
});
this.#options = options.options;
this.#disabled = new Set(
(options.disabledIndices ?? []).filter(i => Number.isInteger(i) && i >= 0 && i < this.#options.length),
);
this.#helpSuffix = options.helpText ?? DEFAULT_HELP_SUFFIX;
this.#externalEditorLabel = options.externalEditorLabel;
this.#promptTitle = options.promptTitle;
this.#selectedIndex = this.#coerceIndex(options.initialIndex ?? 0);
if (options.slider && options.slider.segments.length > 0) {
this.#slider = options.slider;
this.#sliderIndex = Math.max(0, Math.min(options.slider.index, options.slider.segments.length - 1));
} else {
this.#sliderIndex = 0;
}
this.#input = new Input();
this.#input.setUseTerminalCursor(false);
this.#input.onSubmit = value => this.#submitAnnotation(value);
this.#input.onEscape = () => this.#exitAnnotate();
this.#setSections(planContent);
}
invalidate(): void {
for (const section of this.#sections) section.md.invalidate();
}
/** Swap the displayed plan (e.g. after an external-editor round-trip) and
* reset scroll/focus so the operator starts at the top. Does not emit
* `onPlanEdited` (the editor round-trip already persisted the file). */
setPlanContent(planContent: string): void {
this.#setSections(planContent);
this.#scrollView.scrollToTop();
this.#tocCursor = 0;
// A wholesale external-editor swap supersedes prior in-overlay deletions.
this.#deleted = [];
this.#undo = [];
this.#recomputeFeedback();
}
#setSections(planContent: string): void {
this.#sections = parsePlanSections(planContent).map(section => ({
level: section.level,
title: section.title,
raw: section.raw,
md: new Markdown(section.raw, 1, 0, this.#mdTheme),
annotations: [] as string[],
}));
this.#rebuildToc();
this.#tocCursor = Math.min(this.#tocCursor, Math.max(0, this.#toc.length - 1));
}
#rebuildToc(): void {
const headings: number[] = [];
for (let i = 0; i < this.#sections.length; i++) {
if (this.#sections[i]!.level >= 1) headings.push(i);
}
// Drop the plan's title from the ToC: a single shallowest heading at the
// top of the document is the plan name itself ("we know it's the plan"),
// so listing it adds noise. Plans with several top-level sections keep
// them all.
let minLevel = Number.POSITIVE_INFINITY;
for (const i of headings) minLevel = Math.min(minLevel, this.#sections[i]!.level);
const topLevel = headings.filter(i => this.#sections[i]!.level === minLevel);
const titleIndex = topLevel.length === 1 && headings[0] === topLevel[0] ? topLevel[0] : -1;
this.#toc = headings.filter(i => i !== titleIndex);
this.#tocBaseLevel = this.#toc.length > 0 ? Math.min(...this.#toc.map(i => this.#sections[i]!.level)) : 1;
}
/** Clamp `index` to range, then walk to the nearest enabled option so the
* cursor never rests on a disabled row. */
#coerceIndex(index: number): number {
const max = this.#options.length - 1;
if (max < 0) return -1;
const clamped = Math.max(0, Math.min(index, max));
if (!this.#disabled.has(clamped)) return clamped;
for (let i = clamped + 1; i <= max; i++) if (!this.#disabled.has(i)) return i;
for (let i = clamped - 1; i >= 0; i--) if (!this.#disabled.has(i)) return i;
return clamped;
}
/** First enabled option index (or -1 when none), used to detect the "top". */
#firstEnabledIndex(): number {
for (let i = 0; i < this.#options.length; i++) if (!this.#disabled.has(i)) return i;
return -1;
}
/** Move the option cursor by `delta`, skipping disabled rows, stopping at the
* list edge. */
#moveSelection(delta: number): void {
const max = this.#options.length - 1;
if (max < 0) return;
let index = this.#selectedIndex;
while (true) {
const next = Math.max(0, Math.min(index + delta, max));
if (next === index) return;
index = next;
if (!this.#disabled.has(index)) {
this.#selectedIndex = index;
return;
}
}
}
/** Step the slider by `delta`, clamped to its edges (narrow-terminal mode). */
#moveSlider(delta: number): void {
const slider = this.#slider;
if (!slider) return;
const next = Math.max(0, Math.min(slider.segments.length - 1, this.#sliderIndex + delta));
if (next === this.#sliderIndex) return;
this.#sliderIndex = next;
slider.onChange?.(next);
}
#confirmSelection(): void {
const index = this.#selectedIndex;
if (index >= 0 && index < this.#options.length && !this.#disabled.has(index)) {
this.callbacks.onPick(this.#options[index]!);
}
}
handleInput(keyData: string): void {
if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) return;
if (this.#annotating) {
this.#input.handleInput(keyData);
return;
}
if (matchesSelectCancel(keyData)) {
this.callbacks.onCancel();
return;
}
if (this.callbacks.onExternalEditor && matchesAppExternalEditor(keyData)) {
this.callbacks.onExternalEditor();
return;
}
if (matchesKey(keyData, "tab") || keyData === "\t") {
this.#cycleRegion(1);
return;
}
if (matchesKey(keyData, "shift+tab") || keyData === "\x1b[Z") {
this.#cycleRegion(-1);
return;
}
switch (this.#focus) {
case "actions":
this.#handleActions(keyData);
return;
case "body":
this.#handleBody(keyData);
return;
case "toc":
this.#handleToc(keyData);
return;
}
}
/**
* Hit-test an SGR mouse report (`\x1b[<b;x;yM/m`) against the click maps the
* last render recorded. Returns true when consumed. The fullscreen overlay
* paints from screen row 0, so a 1-based mouse row maps directly to the
* rendered-line index. Wheel scrolls the body; a left click on an option
* activates it (select + confirm), on a ToC row jumps to that section, and on
* the body column focuses the body.
*/
#handleMouse(data: string): boolean {
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
if (!match) return false;
const button = Number(match[1]);
const x = Number(match[2]);
const row = Number(match[3]) - 1;
if (button & 64) {
// Scroll wheel: low bit selects direction (64 up, 65 down).
this.#scrollView.scroll(button & 1 ? 3 : -3);
return true;
}
if (match[4] !== "M") return true; // release
if (button & 32) return true; // motion/drag
if ((button & 3) !== 0) return true; // not the left button
const optionIndex = this.#optionClickRows.get(row);
if (optionIndex !== undefined) {
if (!this.#disabled.has(optionIndex)) {
this.#focus = "actions";
this.#selectedIndex = optionIndex;
this.#confirmSelection();
}
return true;
}
const tocPos = this.#tocClickRows.get(row);
if (tocPos !== undefined && x <= this.#sidebarClickMaxCol) {
this.#focus = "toc";
this.#tocCursor = tocPos;
this.#scrubBodyToToc();
return true;
}
if (this.#bodyClickRows.has(row)) {
this.#setFocus("body");
}
return true;
}
#cycleRegion(direction: number): void {
// Sidebar is skipped from the cycle when it is not shown.
const regions: Focus[] = this.#sidebarShown ? ["toc", "body", "actions"] : ["body", "actions"];
const current = regions.indexOf(this.#focus);
const base = current < 0 ? regions.length - 1 : current;
this.#setFocus(regions[(base + direction + regions.length) % regions.length]!);
}
#setFocus(focus: Focus): void {
this.#focus = focus;
if (focus === "toc") this.#tocCursor = this.#deriveTocCursorFromScroll();
}
#handleActions(data: string): void {
// Left/right always drive the slider. The sidebar sits beside the body
// (above this row), not the slider, so stealing left for it would strand
// the operator unable to step the model tier back — reach the ToC via Tab.
const isLeft = matchesKey(data, "left") || (this.#slider !== undefined && data === "h");
const isRight = matchesKey(data, "right") || (this.#slider !== undefined && data === "l");
if (isLeft) {
this.#moveSlider(-1);
return;
}
if (isRight) {
this.#moveSlider(1);
return;
}
if (matchesSelectUp(data) || data === "k") {
if (this.#selectedIndex === this.#firstEnabledIndex()) this.#setFocus("body");
else this.#moveSelection(-1);
return;
}
if (matchesSelectDown(data) || data === "j") {
this.#moveSelection(1);
return;
}
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
this.#confirmSelection();
return;
}
this.#handleBodyScroll(data);
}
#handleBody(data: string): void {
if (matchesKey(data, "left") || data === "h") {
if (this.#sidebarShown) this.#setFocus("toc");
return;
}
if (
matchesKey(data, "right") ||
data === "l" ||
matchesKey(data, "enter") ||
matchesKey(data, "return") ||
data === "\n"
) {
this.#setFocus("actions");
return;
}
// Vertical nav flows between regions at the edges: scrolling off the bottom
// drops into the actions ("next step"); scrolling off the top steps back up
// to the ToC.
if (matchesSelectUp(data) || data === "k") {
if (this.#scrollView.getScrollOffset() <= 0 && this.#sidebarShown) this.#setFocus("toc");
else this.#scrollView.scroll(-1);
return;
}
if (matchesSelectDown(data) || data === "j") {
if (this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset()) this.#setFocus("actions");
else this.#scrollView.scroll(1);
return;
}
this.#handleBodyScroll(data);
}
/**
* Shared scroll dispatch for body + actions focus. Delegates standard keys
* (Arrows, Shift+Arrow fast-scroll, PgUp/PgDn, Home/End) to the ScrollView,
* then adds the vim g/G jumps. Plain Arrow/k/j are consumed by the callers
* before this runs, so here it only ever sees the paging/fast keys.
*/
#handleBodyScroll(data: string): void {
if (this.#scrollView.handleScrollKey(data)) return;
if (data === "g") this.#scrollView.scrollToTop();
else if (data === "G") this.#scrollView.scrollToBottom();
}
#handleToc(data: string): void {
if (matchesSelectUp(data) || data === "k") {
this.#moveTocCursor(-1);
return;
}
if (matchesSelectDown(data) || data === "j") {
// Past the last section, fall through to the actions ("next step").
if (this.#tocCursor >= this.#toc.length - 1) this.#setFocus("actions");
else this.#moveTocCursor(1);
return;
}
if (
matchesKey(data, "right") ||
data === "l" ||
matchesKey(data, "enter") ||
matchesKey(data, "return") ||
data === "\n"
) {
this.#setFocus("body");
return;
}
if (data === "d" || matchesKey(data, "delete")) {
this.#deleteSelectedSection();
return;
}
if (data === "a") {
this.#startAnnotate();
return;
}
if (data === "u") {
this.#undoLast();
return;
}
}
#moveTocCursor(delta: number): void {
if (this.#toc.length === 0) return;
const next = Math.max(0, Math.min(this.#toc.length - 1, this.#tocCursor + delta));
if (next === this.#tocCursor) return;
this.#tocCursor = next;
this.#scrubBodyToToc();
}
/** Scroll the body so the selected ToC section's heading sits at the top. */
#scrubBodyToToc(): void {
const sectionIndex = this.#toc[this.#tocCursor];
if (sectionIndex === undefined) return;
const offset = this.#sectionOffsets[sectionIndex];
if (offset !== undefined) this.#scrollView.setScrollOffset(offset);
}
/** Greatest ToC position whose section starts at or above the scroll offset. */
#deriveTocCursorFromScroll(): number {
if (this.#toc.length === 0) return 0;
const scrollOffset = this.#scrollView.getScrollOffset();
let current = 0;
for (let i = 0; i < this.#sections.length; i++) {
if ((this.#sectionOffsets[i] ?? 0) <= scrollOffset) current = i;
else break;
}
let pos = 0;
for (let p = 0; p < this.#toc.length; p++) {
if (this.#toc[p]! <= current) pos = p;
else break;
}
return pos;
}
#pushUndo(): void {
this.#undo.push({
text: joinPlanSections(this.#sections),
annotations: this.#sections.map(section => [...section.annotations]),
deleted: [...this.#deleted],
});
}
#deleteSelectedSection(): void {
const sectionIndex = this.#toc[this.#tocCursor];
if (sectionIndex === undefined) return;
const span = sectionDeletionSpan(this.#sections, sectionIndex);
if (span.length === 0) return;
this.#pushUndo();
// Record the removed headings so the Refine feedback can ask the model to
// drop them, then splice from the bottom up so earlier indices stay valid.
for (const i of span) {
const section = this.#sections[i]!;
if (section.level >= 1 && section.title) this.#deleted.push(section.title);
}
for (let i = span.length - 1; i >= 0; i--) this.#sections.splice(span[i]!, 1);
this.#rebuildToc();
this.#tocCursor = Math.min(this.#tocCursor, Math.max(0, this.#toc.length - 1));
this.#pendingScrollToToc = true;
this.callbacks.onPlanEdited?.(joinPlanSections(this.#sections));
this.#recomputeFeedback();
}
#undoLast(): void {
const entry = this.#undo.pop();
if (!entry) return;
this.#setSections(entry.text);
for (let i = 0; i < this.#sections.length; i++) {
this.#sections[i]!.annotations = entry.annotations[i] ? [...entry.annotations[i]!] : [];
}
this.#deleted = [...entry.deleted];
this.#tocCursor = Math.min(this.#tocCursor, Math.max(0, this.#toc.length - 1));
this.#pendingScrollToToc = true;
this.callbacks.onPlanEdited?.(joinPlanSections(this.#sections));
this.#recomputeFeedback();
}
#startAnnotate(): void {
if (this.#toc[this.#tocCursor] === undefined) return;
this.#annotating = true;
this.#input.setValue("");
}
#submitAnnotation(value: string): void {
this.#annotating = false;
const note = value.trim();
const sectionIndex = this.#toc[this.#tocCursor];
if (note && sectionIndex !== undefined) {
this.#pushUndo();
this.#sections[sectionIndex]!.annotations.push(note);
this.#recomputeFeedback();
}
this.#input.setValue("");
}
#exitAnnotate(): void {
this.#annotating = false;
this.#input.setValue("");
}
#recomputeFeedback(): void {
const annotated = this.#sections.filter(section => section.level >= 1 && section.annotations.length > 0);
if (annotated.length === 0 && this.#deleted.length === 0) {
this.callbacks.onFeedbackChange?.("");
return;
}
let feedback = "Refinement feedback on the plan:\n";
if (this.#deleted.length > 0) {
feedback += "\nRemove these sections:\n";
for (const title of this.#deleted) feedback += `- ${title}\n`;
}
for (const section of annotated) {
feedback += `\n## ${section.title}\n`;
for (const note of section.annotations) feedback += `- ${note}\n`;
}
this.callbacks.onFeedbackChange?.(feedback);
}
#renderSliderLines(): string[] {
const slider = this.#slider;
if (!slider) return [];
const active = this.#sliderIndex;
const track = renderSegmentTrack(slider.segments, active);
const leftArrow = theme.fg(active > 0 ? "accent" : "dim", "◂");
const rightArrow = theme.fg(active < slider.segments.length - 1 ? "accent" : "dim", "▸");
const caption = slider.caption ? `${theme.fg("dim", slider.caption)} ` : "";
const trackLine = `${caption}${leftArrow} ${track} ${rightArrow}`;
const detail = slider.segments[active]?.detail;
if (!detail) return [trackLine];
return [trackLine, ` ${theme.fg("dim", "↳")} ${theme.fg("muted", detail)}`];
}
#renderOptionLines(): string[] {
const active = this.#focus === "actions";
return this.#options.map((label, i) => {
const selected = i === this.#selectedIndex;
const isDisabled = this.#disabled.has(i);
// The cursor marks the selected option; it dims when actions are not the
// focused region so the active region's highlight stays unambiguous.
const cursor = selected ? theme.fg(active ? "accent" : "dim", `${theme.nav.cursor} `) : " ";
const text = isDisabled
? theme.fg("dim", label)
: selected && active
? theme.bold(theme.fg("accent", label))
: theme.fg("text", label);
return cursor + text;
});
}
#buildHelp(): string {
const sep = " · ";
const parts: string[] = [];
switch (this.#focus) {
case "actions":
parts.push("↑↓ select", "⏎ confirm");
if (this.#slider) parts.push("◂▸ model");
break;
case "toc":
parts.push("↑↓ section", "⏎ open", "a annotate", "d delete", "u undo");
break;
case "body":
parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends");
break;
}
parts.push("tab regions");
if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`);
parts.push(this.#helpSuffix);
return parts.join(sep);
}
/** Build the concatenated body lines and record each section's start row. */
#buildBody(bodyContentWidth: number): string[] {
const lines: string[] = [];
const offsets: number[] = new Array(this.#sections.length);
for (let i = 0; i < this.#sections.length; i++) {
const section = this.#sections[i]!;
offsets[i] = lines.length;
const rendered = section.md.render(bodyContentWidth);
if (section.level >= 1 && section.annotations.length > 0 && rendered.length > 0) {
lines.push(rendered[0]!);
for (const note of section.annotations) {
lines.push(`${theme.fg("warning", "▎ ")}${theme.fg("dim", "note: ")}${theme.fg("accent", note)}`);
}
for (let k = 1; k < rendered.length; k++) lines.push(rendered[k]!);
} else {
for (const line of rendered) lines.push(line);
}
}
this.#sectionOffsets = offsets;
return lines;
}
#sidebarWidthFor(width: number): number {
return Math.max(18, Math.min(30, Math.round(width * 0.24)));
}
#sidebarVisible(width: number): boolean {
if (this.#toc.length < SIDEBAR_MIN_HEADINGS) return false;
if (width < SIDEBAR_MIN_TOTAL_WIDTH) return false;
return splitBodyWidth(width, this.#sidebarWidthFor(width)) >= SIDEBAR_MIN_BODY_WIDTH;
}
/** Sidebar lines plus, per row, the ToC position shown there (for clicks). */
#renderSidebarLines(
regionRows: number,
sidebarWidth: number,
): { lines: string[]; posForRow: (number | undefined)[] } {
// No "Contents" label and no plan-title entry: the box title already says
// "Plan Review", so the sidebar is just the bare list of sections, VS
// Code-style. Window the entries around the cursor.
const lines: string[] = [];
const posForRow: (number | undefined)[] = [];
const slots = Math.max(0, regionRows);
const total = this.#toc.length;
let start = 0;
if (total > slots) {
start = Math.max(0, Math.min(this.#tocCursor - Math.floor(slots / 2), total - slots));
}
for (let r = 0; r < slots; r++) {
const p = start + r;
lines.push(p < total ? this.#renderTocEntry(p, sidebarWidth) : "");
posForRow.push(p < total ? p : undefined);
}
return { lines, posForRow };
}
#renderTocEntry(p: number, width: number): string {
const section = this.#sections[this.#toc[p]!]!;
const highlighted = p === this.#tocCursor;
const selected = highlighted && this.#focus === "toc";
const glow = highlighted && this.#focus !== "toc";
// Compact, VS Code-like rows: a single-column gutter, one space of indent
// per nesting level, then the title and an annotation marker.
const indent = " ".repeat(Math.max(0, section.level - this.#tocBaseLevel));
const ann = section.annotations.length > 0 ? " ✎" : "";
const avail = Math.max(0, width - 1 - indent.length - visibleWidth(ann));
const title = truncateToWidth(section.title || "(untitled)", avail, Ellipsis.Unicode);
const body = indent + title + ann;
// Single-column gutter glyph: a cursor `›` on the focused selection, an
// accent bar `▎` on the current scrolled section, otherwise blank. The
// glyph keeps the cursor legible even where the selection background is
// subtle; the focused row also gets the full-row highlight.
const gutter = selected ? "›" : glow ? "▎" : " ";
const line = gutter + body;
if (selected) return theme.bg("selectedBg", theme.bold(fit(line, width)));
if (glow) return theme.fg("accent", line);
return theme.fg("muted", line);
}
#renderFooterLines(innerWidth: number): string[] {
if (this.#annotating) {
const section = this.#sections[this.#toc[this.#tocCursor]!];
const title = section?.title ?? "";
const caption = `${theme.fg("dim", "Annotate")} ${theme.fg("accent", `‹${title}›`)}`;
return [caption, this.#input.render(innerWidth)[0] ?? ""];
}
return [theme.fg("dim", this.#buildHelp())];
}
render(width: number): string[] {
const termHeight = process.stdout.rows || 40;
const sidebarShown = this.#sidebarVisible(width);
this.#sidebarShown = sidebarShown;
const sidebarWidth = sidebarShown ? this.#sidebarWidthFor(width) : 0;
const innerWidth = Math.max(1, width - 4);
const bodyContentWidth = sidebarShown ? splitBodyWidth(width, sidebarWidth) : innerWidth;
const sliderLines = this.#renderSliderLines();
const optionLines = this.#renderOptionLines();
const promptLines = this.#promptTitle ? [theme.bold(theme.fg("accent", this.#promptTitle))] : [];
const footerLines = this.#renderFooterLines(innerWidth);
// Chrome rows: top border, two dividers, bottom border, plus the
// prompt/slider/option/footer rows between them.
const chrome = 4 + promptLines.length + sliderLines.length + optionLines.length + footerLines.length;
const regionRows = Math.max(MIN_BODY_ROWS, termHeight - chrome);
const bodyLines = this.#buildBody(bodyContentWidth);
this.#scrollView.setLines(bodyLines);
this.#scrollView.setHeight(regionRows);
if (this.#pendingScrollToToc) {
this.#pendingScrollToToc = false;
this.#scrubBodyToToc();
}
if (this.#focus !== "toc") this.#tocCursor = this.#deriveTocCursorFromScroll();
const body = this.#scrollView.render(bodyContentWidth);
this.#optionClickRows.clear();
this.#tocClickRows.clear();
this.#bodyClickRows.clear();
this.#sidebarClickMaxCol = sidebarShown ? sidebarWidth + 3 : 0;
const out: string[] = [];
if (sidebarShown) {
const { lines: sidebar, posForRow } = this.#renderSidebarLines(regionRows, sidebarWidth);
out.push(topBorderSplit(width, OVERLAY_TITLE, sidebarWidth));
for (let i = 0; i < regionRows; i++) {
const pos = posForRow[i];
if (pos !== undefined) this.#tocClickRows.set(out.length, pos);
this.#bodyClickRows.add(out.length);
out.push(splitRow(sidebar[i] ?? "", body[i] ?? "", width, sidebarWidth));
}
out.push(dividerSplit(width, sidebarWidth));
} else {
out.push(topBorder(width, OVERLAY_TITLE));
for (const line of body) {
this.#bodyClickRows.add(out.length);
out.push(row(line, width));
}
out.push(divider(width));
}
for (const line of promptLines) out.push(row(line, width));
for (const line of sliderLines) out.push(row(line, width));
for (let i = 0; i < optionLines.length; i++) {
this.#optionClickRows.set(out.length, i);
out.push(row(optionLines[i]!, width));
}
out.push(divider(width));
for (const line of footerLines) out.push(row(line, width));
out.push(bottomBorder(width));
return out;
}
}
@@ -0,0 +1,138 @@
/**
* Pure heading/section parser for the plan-review overlay. It splits a plan's
* markdown into a flat list of sections — a leading preamble (text before the
* first heading) followed by one entry per ATX heading — preserving the exact
* source bytes of each section so the overlay can render, reorder-free delete,
* and round-trip the document without a full markdown re-render.
*
* No TUI dependencies: this module is unit-tested in isolation.
*/
/** ATX heading: 1-6 `#`, required whitespace, a title, optional closing `#`s. */
const HEADING_RE = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
/** Opening/closing code fence run (``` or ~~~), allowing up to 3 lead spaces. */
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
export interface PlanSection {
/** `0` = preamble (no heading, no ToC entry); `1..6` = heading depth. */
level: number;
/** Plain-text heading label with inline markdown lightly stripped. */
title: string;
/** Exact source slice for this section, including its trailing newline(s). */
raw: string;
}
/**
* Collapse inline markdown emphasis/link/code syntax to readable text. This is
* a deliberately light strip (not a full markdown render) just so ToC entries
* read cleanly — `**Goal** & [docs](x)` becomes `Goal & docs`.
*/
export function stripInlineMarkdown(text: string): string {
let out = text;
// Images first (so the link pass below does not eat the `(url)`), then links.
out = out.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
out = out.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
out = out.replace(/\[([^\]]*)\]\[[^\]]*\]/g, "$1");
// Autolinks `<https://…>` keep their URL as the readable text.
out = out.replace(/<([^>\s]+)>/g, "$1");
// Inline code, then bold/italic/strikethrough emphasis runs.
out = out.replace(/`([^`]+)`/g, "$1");
out = out.replace(/(\*\*|__)(.+?)\1/g, "$2");
out = out.replace(/(\*|_)(.+?)\1/g, "$2");
out = out.replace(/~~(.+?)~~/g, "$1");
return out.replace(/\s+/g, " ").trim();
}
/**
* Split `text` into preamble + heading sections. `#` characters inside fenced
* code blocks are never treated as headings. Concatenating every section's
* `raw` reproduces the original text exactly.
*/
export function parsePlanSections(text: string): PlanSection[] {
const lines = text.split("\n");
// Character offset of each line start so section `raw` can slice the source.
const offsets: number[] = new Array(lines.length);
let cursor = 0;
for (let i = 0; i < lines.length; i++) {
offsets[i] = cursor;
cursor += lines[i]!.length + 1; // +1 for the "\n" join separator
}
// Heading line indices (start of each heading section), with metadata.
const heads: { line: number; level: number; title: string }[] = [];
let fenceChar: string | null = null;
let fenceLen = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const fence = FENCE_RE.exec(line);
if (fenceChar === null) {
if (fence) {
fenceChar = fence[1]![0]!;
fenceLen = fence[1]!.length;
}
// Opening-fence lines are body, not headings.
if (fence) continue;
} else {
// Inside a fence: only a matching-or-longer run of the same char closes.
if (fence && fence[1]![0] === fenceChar && fence[1]!.length >= fenceLen && fence[2]!.trim() === "") {
fenceChar = null;
fenceLen = 0;
}
continue;
}
const heading = HEADING_RE.exec(line);
if (heading) {
heads.push({ line: i, level: heading[1]!.length, title: stripInlineMarkdown(heading[2]!) });
}
}
const sections: PlanSection[] = [];
const sliceRaw = (startLine: number, endLine: number): string => {
const startOffset = offsets[startLine]!;
const endOffset = endLine < lines.length ? offsets[endLine]! : text.length;
return text.slice(startOffset, endOffset);
};
// Preamble: everything before the first heading (only when non-empty).
const firstHeadLine = heads.length > 0 ? heads[0]!.line : lines.length;
if (firstHeadLine > 0) {
const raw = sliceRaw(0, firstHeadLine);
if (raw.length > 0) sections.push({ level: 0, title: "", raw });
}
for (let h = 0; h < heads.length; h++) {
const head = heads[h]!;
const endLine = h + 1 < heads.length ? heads[h + 1]!.line : lines.length;
sections.push({ level: head.level, title: head.title, raw: sliceRaw(head.line, endLine) });
}
return sections;
}
/**
* Concatenate every section's `raw` back into a single document and guarantee a
* single trailing newline. Inverse of {@link parsePlanSections} for any input
* that already ends with a newline.
*/
export function joinPlanSections(sections: readonly PlanSection[]): string {
let joined = "";
for (const section of sections) joined += section.raw;
if (joined.length === 0) return "";
return joined.endsWith("\n") ? joined : `${joined}\n`;
}
/**
* Indices to remove when deleting `sections[index]`: the heading itself plus
* every following section nested deeper than it (its sub-headings). The
* preamble (level 0) is never a deletion target and yields an empty span.
*/
export function sectionDeletionSpan(sections: readonly PlanSection[], index: number): number[] {
const target = sections[index];
if (!target || target.level === 0) return [];
const span = [index];
for (let j = index + 1; j < sections.length; j++) {
if (sections[j]!.level > target.level) span.push(j);
else break;
}
return span;
}
@@ -81,6 +81,14 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
#text: Text;
#expanded = false;
#showContentPreview: boolean;
// A read group accretes entries across multiple assistant completions for as
// long as the run of reads is uninterrupted. While it is the active group it
// must stay in the transcript's repaintable live region — its header line
// re-layouts from `Read <path>` to `Read (N)` + tree as entries arrive, so a
// frozen snapshot taken on a risk terminal would strand the single-entry form
// (see TranscriptContainer / NativeScrollbackLiveRegion). The controller calls
// `finalize()` once the run breaks so the block can commit to native scrollback.
#finalized = false;
constructor(options: ReadToolGroupOptions = {}) {
super();
@@ -90,6 +98,14 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
this.#updateDisplay();
}
isTranscriptBlockFinalized(): boolean {
return this.#finalized;
}
finalize(): void {
this.#finalized = true;
}
updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
if (!toolCallId) return;
const basePath = args.file_path || args.path || "";
@@ -181,9 +197,9 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
const total = entriesWithoutPreview.length;
for (const [index, entry] of entriesWithoutPreview.entries()) {
const connector = index === total - 1 ? theme.tree.last : theme.tree.branch;
const statusSymbol = this.#formatStatus(entry.status);
const statusPrefix = entry.status === "success" ? "" : `${this.#formatStatus(entry.status)} `;
const pathDisplay = this.#formatPath(entry);
lines.push(` ${theme.fg("dim", connector)} ${statusSymbol} ${pathDisplay}`.trimEnd());
lines.push(` ${theme.fg("dim", connector)} ${statusPrefix}${pathDisplay}`.trimEnd());
}
this.#text.setText(lines.join("\n"));
@@ -198,7 +214,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
/**
* Add a code-cell content preview below the entry summary.
* When collapsed: shows first COLLAPSED_PREVIEW_LINES lines with "… N more lines (Ctrl+O for more)" hint.
* When collapsed: shows first COLLAPSED_PREVIEW_LINES lines with a "… N more lines ⟨<key>: Expand⟩" hint.
* When expanded: shows full content.
*/
#addContentPreview(entry: ReadEntry): void {
@@ -254,7 +270,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
#formatStatus(status: ReadEntry["status"]): string {
if (status === "success") {
return theme.fg("success", theme.status.success);
return theme.fg("text", theme.status.enabled);
}
if (status === "warning") {
return theme.fg("warning", theme.status.warning);

Some files were not shown because too many files have changed in this diff Show More