feat(ui): ralpi-style chat progress and pipeline-overview footer

phases.ts becomes a live chat widget (spinner + tool-call tree) and the
check-runner posts per-agent tool-call summaries and an expandable
completion tree through a custom message renderer; footer.ts adds the
static pipeline-overview status strip for single checks and /pygienium-all.
This commit is contained in:
2026-08-09 16:45:29 -04:00
parent d0e8ad5571
commit 5f8a5cbe5f
10 changed files with 1165 additions and 55 deletions

View File

@@ -18,7 +18,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { loadAgents, type AgentDef } from "./agents.js";
import type { ToolCallEntry } from "./phases.js";
export interface AgentTaskOptions {
/** Absolute working directory for the sub-agent. */
@@ -31,6 +31,8 @@ export interface AgentTaskOptions {
allowedTools?: string[];
/** Optional explicit agent definition (skips `loadAgents`). */
agent?: AgentDef;
/** Live callback fired as each tool call starts (ralpi-style stream). */
onToolCall?: (entry: ToolCallEntry) => void;
}
export interface AgentRunResult {
@@ -40,6 +42,8 @@ export interface AgentRunResult {
text: string;
/** Error message when `ok` is false. */
error?: string;
/** Every tool invocation captured during the run (name + label). */
toolCalls: ToolCallEntry[];
}
export type AgentRunner = (opts: AgentTaskOptions) => Promise<AgentRunResult>;
@@ -112,6 +116,7 @@ export async function defaultAgentRunner(
try {
let text = "";
const toolCalls: ToolCallEntry[] = [];
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (
event.type === "message_update" &&
@@ -119,15 +124,24 @@ export async function defaultAgentRunner(
) {
text += event.assistantMessageEvent.delta;
}
if (event.type === "tool_execution_start") {
const entry: ToolCallEntry = {
name: event.toolName,
label: formatToolArg(event.toolName, event.args),
};
toolCalls.push(entry);
opts.onToolCall?.(entry);
}
});
await session.prompt(opts.task, { expandPromptTemplates: false });
unsubscribe();
return { ok: true, text };
return { ok: true, text, toolCalls };
} catch (err) {
return {
ok: false,
text: "",
error: err instanceof Error ? err.message : String(err),
toolCalls: [],
};
} finally {
try {
@@ -170,12 +184,57 @@ export const fakeAgentRunner: AgentRunner = async (opts) => {
findings.push((echo[1] ?? "").replace(/^["']|["']$/g, ""));
}
}
return { ok: true, text: findings.join("\n") };
return { ok: true, text: findings.join("\n"), toolCalls: [] };
} catch (err) {
return {
ok: false,
text: findings.join("\n"),
error: err instanceof Error ? err.message : String(err),
toolCalls: [],
};
}
};
// ─── Tool-call label formatting (ported from ralpi) ─────────────────────────────
/** Collapse newlines and strip control chars so a tool arg fits one line. */
function sanitizeLabel(s: string): string {
return s
.replace(/\r?\n/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim();
}
/** Keep the start and end of a long value, inserting an ellipsis in the middle. */
function truncateMiddle(s: string, maxLen: number): string {
if (s.length <= maxLen) return s;
const half = Math.floor((maxLen - 1) / 2);
return s.slice(0, half) + "…" + s.slice(s.length - half);
}
/**
* Format a tool call's arguments into a short one-line label, by tool name.
* Mirrors ralpi's `formatToolArg` so the chat tree shows the useful argument
* (command for bash, path for read/write/edit, pattern for grep, …).
*/
export function formatToolArg(name: string, args: unknown): string {
const a = (args ?? {}) as Record<string, unknown>;
switch (name) {
case "bash":
return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70));
case "write":
case "read":
case "edit":
return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
case "grep":
return sanitizeLabel(
`${a.pattern ?? "?"}${truncateMiddle(String(a.path ?? ""), 40)}`,
);
case "find":
return sanitizeLabel(`${a.path ?? "."}${a.glob ?? "*"}`);
case "ls":
return sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));
default:
return name;
}
}