feat: chat rendering toolcalls, footer overview

This commit is contained in:
2026-08-09 20:24:11 -04:00
parent cb8a88d0bd
commit 259b1d3b2d
14 changed files with 664 additions and 383 deletions

View File

@@ -24,6 +24,24 @@ bun test
Pi auto-discovers the extension from this location via the `pi.extensions` entry in `package.json` — no settings.json config is needed. On load it emits a TUI notification `Pygienium loaded. Run /pygienium-help for available checks and flags.` (only when a dialog-capable UI is available). Pi auto-discovers the extension from this location via the `pi.extensions` entry in `package.json` — no settings.json config is needed. On load it emits a TUI notification `Pygienium loaded. Run /pygienium-help for available checks and flags.` (only when a dialog-capable UI is available).
## Configuration
Pygienium reads its chat-rendering style from pi's `settings.json` (`~/.pi/agent/settings.json`) under a `pygienium` key:
```json
{
"pygienium": {
"chatStyle": "verbose"
}
}
```
| Setting | Default | Values | Description |
| --- | --- | --- | --- |
| `pygienium.chatStyle` | `"verbose"` | `"verbose"` \| `"compact"` | Chat rendering for sub-agent tool calls. **verbose** (piolium-style) streams each tool event live as its own chat line (`[Comments: Scanning] → bash ...` / `← (ok)`). **compact** (ralpi-style) suppresses the per-event stream and shows only the final completion message with its expandable phase tree. |
No entry means `"verbose"` (the default). An unreadable or missing `settings.json` also falls back to `"verbose"`.
## Commands ## Commands
Every command accepts a `[path]` target (default: the current directory) and is Every command accepts a `[path]` target (default: the current directory) and is

View File

@@ -6,6 +6,7 @@ allowedTools:
- find - find
- ls - ls
- bash - bash
- write
--- ---
You are the **Pygienium scanner** sub-agent — a focused code-hygiene analyst. You are the **Pygienium scanner** sub-agent — a focused code-hygiene analyst.

34
bun.lock Normal file
View File

@@ -0,0 +1,34 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "pygienium",
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
},
"peerDependencies": {
"@earendil-works/pi-agent-core": "*",
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*",
},
"optionalPeers": [
"@earendil-works/pi-agent-core",
"@earendil-works/pi-ai",
"@earendil-works/pi-coding-agent",
"@earendil-works/pi-tui",
"typebox",
],
},
},
"packages": {
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}

View File

@@ -19,7 +19,6 @@ import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path"; import { dirname, isAbsolute, join } from "node:path";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js"; import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
import type { ToolCallEntry } from "./phases.js";
export interface AgentTaskOptions { export interface AgentTaskOptions {
/** Absolute working directory for the sub-agent. */ /** Absolute working directory for the sub-agent. */
@@ -32,8 +31,12 @@ export interface AgentTaskOptions {
allowedTools?: string[]; allowedTools?: string[];
/** Optional explicit agent definition (skips `loadAgents`). */ /** Optional explicit agent definition (skips `loadAgents`). */
agent?: AgentDef; agent?: AgentDef;
/** Live callback fired as each tool call starts (ralpi-style stream). */ /**
onToolCall?: (entry: ToolCallEntry) => void; * Live callback forwarding raw {@link AgentSessionEvent}s from the
* sub-agent session. The `pygienium-stream` forwarder in `index.ts`
* turns tool_execution_start/end + assistant turns into chat messages.
*/
onEvent?: (event: AgentSessionEvent) => void;
} }
export interface AgentRunResult { export interface AgentRunResult {
@@ -43,8 +46,6 @@ export interface AgentRunResult {
text: string; text: string;
/** Error message when `ok` is false. */ /** Error message when `ok` is false. */
error?: string; error?: string;
/** Every tool invocation captured during the run (name + label). */
toolCalls: ToolCallEntry[];
} }
export type AgentRunner = (opts: AgentTaskOptions) => Promise<AgentRunResult>; export type AgentRunner = (opts: AgentTaskOptions) => Promise<AgentRunResult>;
@@ -82,7 +83,6 @@ export async function defaultAgentRunner(
return { return {
ok: false, ok: false,
text: "", text: "",
toolCalls: [],
error: error:
`Unknown agent definition: "${opts.agentName}". ` + `Unknown agent definition: "${opts.agentName}". ` +
`Available agents: ${names.length > 0 ? names.join(", ") : "(none loaded — check agents/ directories exist)"}. ` + `Available agents: ${names.length > 0 ? names.join(", ") : "(none loaded — check agents/ directories exist)"}. ` +
@@ -123,7 +123,8 @@ export async function defaultAgentRunner(
try { try {
let text = ""; let text = "";
const toolCalls: ToolCallEntry[] = []; let stopReason: string | undefined;
let errorMessage: string | undefined;
const unsubscribe = session.subscribe((event: AgentSessionEvent) => { const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if ( if (
event.type === "message_update" && event.type === "message_update" &&
@@ -131,24 +132,58 @@ export async function defaultAgentRunner(
) { ) {
text += event.assistantMessageEvent.delta; text += event.assistantMessageEvent.delta;
} }
if (event.type === "tool_execution_start") { if (event.type === "message_end") {
const entry: ToolCallEntry = { // Capture the full assistant text from the finalized message —
name: event.toolName, // models that don't stream text_delta (or truncate) still surface
label: formatToolArg(event.toolName, event.args), // their output here. Prefer the streamed text when non-empty.
const message = event.message as {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
}; };
toolCalls.push(entry); if (message.stopReason) stopReason = message.stopReason;
opts.onToolCall?.(entry); if (message.errorMessage) errorMessage = message.errorMessage;
if (message.role === "assistant") {
const full = extractAssistantText(message.content).trim();
if (full && !text.trim()) text = full;
}
}
// Forward the stream-driving events to the chat forwarder; it turns
// each into its own `pygienium-stream` message (see index.ts).
if (
event.type === "tool_execution_start" ||
event.type === "tool_execution_end" ||
event.type === "message_end"
) {
opts.onEvent?.(event);
} }
}); });
await session.prompt(opts.task, { expandPromptTemplates: false }); await session.prompt(opts.task, { expandPromptTemplates: false });
// Ensure the agent has fully settled (tool calls may still be in-flight
// after prompt() resolves; piolium's runner calls this for the same
// reason).
await session.agent.waitForIdle();
unsubscribe(); unsubscribe();
return { ok: true, text, toolCalls }; // Surface session errors that didn't throw but left no useful output.
// A session ending with stopReason "error" and no text means the model
// call failed silently — treat that as a failed run, not ok:true.
if (errorMessage) {
return { ok: false, text, error: errorMessage };
}
if (!text.trim() && stopReason === "error") {
return {
ok: false,
text,
error: "sub-agent session ended in error with no output.",
};
}
return { ok: true, text };
} catch (err) { } catch (err) {
return { return {
ok: false, ok: false,
text: "", text: "",
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
toolCalls: [],
}; };
} finally { } finally {
try { try {
@@ -159,6 +194,22 @@ export async function defaultAgentRunner(
} }
} }
/**
* Extract joined text from an assistant message's content blocks.
* Mirrors piolium's `extractAssistantText`.
*/
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((c) =>
c && typeof c === "object" && (c as { type?: string }).type === "text"
? [(c as { text?: string }).text ?? ""]
: [],
)
.join("");
}
/** /**
* Fake agent runner for tests: it understands a tiny instruction protocol * Fake agent runner for tests: it understands a tiny instruction protocol
* embedded in the task so a no-op check can produce deterministic findings * embedded in the task so a no-op check can produce deterministic findings
@@ -191,57 +242,12 @@ export const fakeAgentRunner: AgentRunner = async (opts) => {
findings.push((echo[1] ?? "").replace(/^["']|["']$/g, "")); findings.push((echo[1] ?? "").replace(/^["']|["']$/g, ""));
} }
} }
return { ok: true, text: findings.join("\n"), toolCalls: [] }; return { ok: true, text: findings.join("\n") };
} catch (err) { } catch (err) {
return { return {
ok: false, ok: false,
text: findings.join("\n"), text: findings.join("\n"),
error: err instanceof Error ? err.message : String(err), 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;
}
}

View File

@@ -9,7 +9,10 @@
* @module pygienium/commands * @module pygienium/commands
*/ */
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import type {
AgentSessionEvent,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { import {
getAllChecks, getAllChecks,
@@ -49,6 +52,11 @@ export type PygieniumCtx = Pick<
> & { > & {
/** Optional callback to post messages to the chat window. */ /** Optional callback to post messages to the chat window. */
sendChatMessage?: SendChatMessage; sendChatMessage?: SendChatMessage;
/** Optional callback forwarding raw sub-agent events to the chat stream. */
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
/** Optional callback to emit synthetic progress lines (verify/cleanup/
* recon phases that don't run agents) into the chat stream. */
sendPhaseLine?: (phase: string, text: string) => void;
}; };
function print(ctx: PygieniumCtx, line: string): void { function print(ctx: PygieniumCtx, line: string): void {
@@ -169,6 +177,8 @@ export async function handleCheckCommand(
ui: ctx.ui, ui: ctx.ui,
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
sendChatMessage: ctx.sendChatMessage, sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine,
gitignore: !noGitignore, gitignore: !noGitignore,
}); });
@@ -206,6 +216,8 @@ export async function handleAllCommand(
ui: ctx.ui, ui: ctx.ui,
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
sendChatMessage: ctx.sendChatMessage, sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine,
}); });
const giNote = outcome.gitignoreAppended const giNote = outcome.gitignoreAppended
? " · .pygienium/ added to .gitignore" ? " · .pygienium/ added to .gitignore"
@@ -293,6 +305,8 @@ export async function handleResumeCommand(
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
existingState: state, existingState: state,
sendChatMessage: ctx.sendChatMessage, sendChatMessage: ctx.sendChatMessage,
onAgentEvent: ctx.onAgentEvent,
sendPhaseLine: ctx.sendPhaseLine,
gitignore, gitignore,
}); });
state = outcome.state; state = outcome.state;

View File

@@ -1,12 +1,17 @@
/** /**
* footer.ts — compact pipeline-overview status line in the TUI footer. * footer.ts — pipeline-overview status widget in the TUI footer area.
* *
* While the chat widget (phases.ts) shows the live spinner + tool-call tree * Renders the full ordered pipeline (phases for a single check, or checks for
* (detail, surfaced in the chat), the footer shows one STATIC overview line: * `/pygienium-all`) as a **multi-line `belowEditor` widget** (via
* the full ordered pipeline (the checks and/or phases) with the current * `ExtensionUIContext.setWidget`): one bulleted, color-themed line per step,
* position and what's to come — the piolium-style footer strip. The two * with the live step marked `●` and completed/failed/skipped/pending steps
* never overlap: the footer stays a single status-bar slot owned by * carrying their terminal glyph. This is the pygienium analogue of piolium's
* `ui.setStatus(key, text)`. * `phase-status-strip` widget — the footer-side *overview* view.
*
* The chat-side *detail* view is the live tool-event stream (see
* `pygienium-stream` in `index.ts`): each `tool_execution_start/end` and
* assistant turn is posted as its own chat message. The two never overlap:
* the footer owns the `belowEditor` slot, the stream owns the chat history.
* *
* Presentation-only and mode-aware: in print/JSON mode (no TUI) the footer is * Presentation-only and mode-aware: in print/JSON mode (no TUI) the footer is
* a no-op — stdout progress stays owned by the phase strip — so it can be * a no-op — stdout progress stays owned by the phase strip — so it can be
@@ -21,7 +26,7 @@
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
/** Footer/status-bar key pygienium writes its pipeline overview under. */ /** Widget key pygienium writes its pipeline-overview widget under. */
export const FOOTER_STATUS_KEY = "pygienium"; export const FOOTER_STATUS_KEY = "pygienium";
/** Status of a single pipeline item, carried into the footer line. */ /** Status of a single pipeline item, carried into the footer line. */
@@ -33,17 +38,23 @@ export type ItemStatus =
| "skipped"; | "skipped";
/** /**
* Glyph per item status. `…`=pending (to come), `▶`=running (cursor), * Marker per item status, mirroring piolium's phase-status-strip glyphs:
* `✓`/`✗`/`-`=terminal. Kept short so a multi-phase pipeline fits one line. * `·`=pending (to come), `●`=running (cursor), `✓`/`✗`/`↷`=terminal.
* Kept short so a multi-phase pipeline fits one widget column.
*/ */
export const FOOTER_GLYPH: Record<ItemStatus, string> = { export const FOOTER_MARKER: Record<ItemStatus, string> = {
pending: "", pending: "·",
running: "", running: "",
complete: "✓", complete: "✓",
failed: "✗", failed: "✗",
skipped: "-", skipped: "",
}; };
/** Theme subset the footer renders against (`ui.theme` satisfies this). */
export interface FooterTheme {
fg(color: string, text: string): string;
}
/** One labelled step in the pipeline overview. */ /** One labelled step in the pipeline overview. */
export interface FooterItem { export interface FooterItem {
/** Short label (a phase label like "Scanning" or a check label). */ /** Short label (a phase label like "Scanning" or a check label). */
@@ -53,16 +64,16 @@ export interface FooterItem {
} }
export interface PipelineFooterOptions { export interface PipelineFooterOptions {
/** UI context; writes go to `ui.setStatus`. */ /** UI context; writes go to `ui.setWidget` (belowEditor). */
ui?: ExtensionUIContext; ui?: ExtensionUIContext;
/** Dialog-capable UI available (TUI / RPC). When false, footer is a no-op. */ /** Dialog-capable UI available (TUI / RPC). When false, footer is a no-op. */
hasUI?: boolean; hasUI?: boolean;
/** Status-bar key (defaults to {@link FOOTER_STATUS_KEY}). */ /** Widget key (defaults to {@link FOOTER_STATUS_KEY}). */
statusKey?: string; statusKey?: string;
/** /**
* Whether to render the footer (default true). Set false when an outer * Whether to render the footer (default true). Set false when an outer
* run (e.g. `/pygienium-all`) already owns the footer, so two overviews * run (e.g. `/pygienium-all`) already owns the footer, so two overviews
* never compete over the same status slot — mirrors the phase strip's * never compete over the same widget slot — mirrors the phase strip's
* `widget` flag. * `widget` flag.
*/ */
enabled?: boolean; enabled?: boolean;
@@ -79,10 +90,46 @@ export interface PipelineFooter {
getItems(): FooterItem[]; getItems(): FooterItem[];
/** Snapshot of the last rendered title (for tests). */ /** Snapshot of the last rendered title (for tests). */
getTitle(): string; getTitle(): string;
/** Clear the footer slot. Safe to call repeatedly. */ /** Clear the footer widget. Safe to call repeatedly. */
done(): void; done(): void;
} }
/** Map an item status to a piolium-style theme color token. */
export function footerColor(status: ItemStatus, isCurrent: boolean): string {
if (status === "complete") return "success";
if (status === "failed") return "error";
if (status === "skipped") return "warning";
if (status === "running" || isCurrent) return "accent";
return "dim";
}
/** Width for the per-step index prefix (`1.` … `12.`). */
function indexWidth(total: number): number {
return total >= 10 ? 2 : 1;
}
/**
* Render the pipeline as a list of bulleted, color-themed lines — the pure
* core of the footer widget, exported so tests assert on layout without a
* TUI. Each line is `• <marker> <n>. <label>`, themed by status color.
* Mirrors piolium's `renderPhaseStatusList`.
*/
export function renderFooterList(
items: readonly FooterItem[],
cursor: number,
theme: FooterTheme,
): string[] {
const width = indexWidth(items.length);
return items.map((item, index) => {
const marker = FOOTER_MARKER[item.status] ?? "?";
const isCurrent = index === cursor;
const color = footerColor(item.status, isCurrent);
const order = String(index + 1).padStart(width, "0");
const text = `${marker} ${order}. ${item.label}`;
return theme.fg(color, text);
});
}
/** /**
* Create a pipeline-overview footer. The handle is cheap and stateful; callers * Create a pipeline-overview footer. The handle is cheap and stateful; callers
* keep one per run and call {@link PipelineFooter.done} when terminal. * keep one per run and call {@link PipelineFooter.done} when terminal.
@@ -99,17 +146,19 @@ export function createPipelineFooter(
let items: FooterItem[] = []; let items: FooterItem[] = [];
let cursor = -1; let cursor = -1;
/** Build and push the single status line, if a UI is available. */ /** Build and push the widget lines, if a UI is available. */
function render(): void { function render(): void {
if (!enabled || !hasUI) return; if (!enabled || !hasUI || !ui?.setWidget) return;
const parts = items.map((item, i) => { // `ui.theme` is present on a real ExtensionUIContext; fall back to a
const glyph = FOOTER_GLYPH[item.status] ?? "?"; // plain-text renderer only when a stub omits it (tests / headless RPC).
// The live cursor gets a space after the glyph for emphasis; the const theme: FooterTheme =
// rest stay glued (`…Fixing`) so a long pipeline stays compact. ui.theme && typeof ui.theme.fg === "function"
return i === cursor ? `${glyph} ${item.label}` : `${glyph}${item.label}`; ? ui.theme
}); : { fg: (_c: string, t: string) => t };
const text = parts.length > 0 ? `${title} · ${parts.join(" · ")}` : title; const lines: string[] = [];
ui?.setStatus?.(key, text); if (title) lines.push(theme.fg("dim", title));
lines.push(...renderFooterList(items, cursor, theme));
ui.setWidget(key, lines, { placement: "belowEditor" });
} }
return { return {
@@ -147,8 +196,8 @@ export function createPipelineFooter(
return title; return title;
}, },
done() { done() {
if (!enabled || !hasUI) return; if (!enabled || !hasUI || !ui?.setWidget) return;
ui?.setStatus?.(key, undefined); ui.setWidget(key, undefined, { placement: "belowEditor" });
items = []; items = [];
cursor = -1; cursor = -1;
title = ""; title = "";

View File

@@ -16,8 +16,9 @@
* @module pygienium/index * @module pygienium/index
*/ */
import { readdir } from "node:fs/promises"; import { readdir, readFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { import type {
ExtensionAPI, ExtensionAPI,
@@ -31,14 +32,41 @@ import { registerPygieniumCommands, type PygieniumCtx } from "./commands.js";
import { import {
type SendChatMessage, type SendChatMessage,
type CheckCompletionDetails, type CheckCompletionDetails,
type ToolCallEntry,
PHASE_GLYPH, PHASE_GLYPH,
} from "./phases.js"; } from "./phases.js";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
/** Startup hint mirrored after piolium's convention. */ /** Startup hint mirrored after piolium's convention. */
export const PYGIENIUM_STARTUP_HINT = export const PYGIENIUM_STARTUP_HINT =
"Pygienium loaded. Run /pygienium-help for available checks and flags."; "Pygienium loaded. Run /pygienium-help for available checks and flags.";
/** Custom message type for the live tool-event stream (mirrors piolium-stream). */
export const PYGIENIUM_STREAM = "pygienium-stream";
/** Chat rendering style ("verbose" = per-event stream, "compact" = completion-only). */
export type ChatStyle = "verbose" | "compact";
/**
* Read the pygienium chat style from pi's settings.json.
* Looks for `pygienium.chatStyle` under `~/.pi/agent/settings.json`.
* Defaults to "verbose" (piolium-style per-event stream) when absent or unreadable.
*/
async function readChatStyle(): Promise<ChatStyle> {
try {
const raw = await readFile(
join(homedir(), ".pi", "agent", "settings.json"),
"utf8",
);
const settings = JSON.parse(raw) as {
pygienium?: { chatStyle?: string };
};
const style = settings.pygienium?.chatStyle;
return style === "compact" ? "compact" : "verbose";
} catch {
return "verbose";
}
}
/** /**
* Local structural supertypes for the progress-message renderer params. * Local structural supertypes for the progress-message renderer params.
* These avoid relying on contextual typing from `MessageRenderer` (which * These avoid relying on contextual typing from `MessageRenderer` (which
@@ -62,6 +90,159 @@ interface ProgressTheme {
export { buildPygieniumHelpLines } from "./help.js"; export { buildPygieniumHelpLines } from "./help.js";
type StreamLineKind = "tool-start" | "tool-end" | "tool-error" | "assistant";
interface StreamLineDetails {
kind: StreamLineKind;
phase: string;
toolName?: string;
body?: string;
}
/** Pick the one useful argument from a tool-call's args (path/command/…). */
function summarizeArgs(args: unknown): string {
if (!args || typeof args !== "object") return "";
const obj = args as Record<string, unknown>;
const pickKey = [
"file_path",
"path",
"command",
"pattern",
"query",
"url",
].find((k) => typeof obj[k] === "string");
if (pickKey) {
const value = String(obj[pickKey]);
return value.length > 120 ? `${value.slice(0, 117)}` : value;
}
const json = JSON.stringify(obj);
return json.length > 120 ? `${json.slice(0, 117)}` : json;
}
/** Extract joined text from an assistant message's content blocks. */
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(c) =>
c && typeof c === "object" && (c as { type?: string }).type === "text",
)
.map((c) => (c as { text?: string }).text ?? "")
.join("");
}
/** Collapse a tool result down to a single short line. */
function summarizeToolResult(result: unknown): string {
if (result == null) return "";
if (typeof result === "string") return result;
if (typeof result === "number" || typeof result === "boolean")
return String(result);
if (Array.isArray(result)) {
return result
.map((item) => {
if (typeof item === "string") return item;
if (
item &&
typeof item === "object" &&
"text" in (item as Record<string, unknown>)
)
return String((item as { text?: unknown }).text ?? "");
return JSON.stringify(item);
})
.join("\n");
}
if (typeof result !== "object") return "";
const obj = result as Record<string, unknown>;
// MCP CallToolResult shape: { content: [{ type: "text", text: "..." }, ...] }
if (Array.isArray(obj.content)) {
const unwrapped = summarizeToolResult(obj.content);
if (unwrapped) return unwrapped;
}
const preferKey = ["stdout", "output", "text", "content", "result"].find(
(k) => typeof obj[k] === "string" && (obj[k] as string).length > 0,
);
if (preferKey) return obj[preferKey] as string;
try {
return JSON.stringify(obj);
} catch {
return "";
}
}
/** Collapse whitespace and cap a line at `max` chars with an ellipsis. */
function compactLine(text: string, max: number): string {
const collapsed = text.replace(/\s+/g, " ").trim();
if (collapsed.length <= max) return collapsed;
return `${collapsed.slice(0, max - 1)}`;
}
/**
* Chat stream forwarder: turns raw sub-agent events into `pygienium-stream`
* messages (one chat line per start/end/assistant turn), the pygienium
* analogue of piolium's `makeAgentEventForwarder`. Also exposes
* `sendPhaseLine` for synthetic progress lines during non-agent phases
* (verify, cleanup, recon) so the chat doesn't go silent.
*/
interface StreamForwarder {
/** Forward a raw sub-agent event tagged with a phase label. */
onAgentEvent(phase: string, event: AgentSessionEvent): void;
/** Emit a synthetic progress line (e.g. "checking artifacts…"). */
sendPhaseLine(phase: string, text: string): void;
}
function makeStreamForwarder(pi: ExtensionAPI): StreamForwarder {
const send = (details: StreamLineDetails, fallback: string) => {
pi.sendMessage<StreamLineDetails>({
customType: PYGIENIUM_STREAM,
content: fallback,
display: true,
details,
});
};
const onAgentEvent = (phase: string, event: AgentSessionEvent): void => {
switch (event.type) {
case "tool_execution_start": {
const body = summarizeArgs(event.args);
send(
{ kind: "tool-start", phase, toolName: event.toolName, body },
`[${phase}] → ${event.toolName}${body ? ` ${body}` : ""}`,
);
return;
}
case "tool_execution_end": {
const body = compactLine(summarizeToolResult(event.result), 200);
const kind: StreamLineKind = event.isError ? "tool-error" : "tool-end";
const marker = event.isError ? "✗" : "←";
send(
{ kind, phase, toolName: event.toolName, body },
`[${phase}] ${marker} ${event.toolName}${body ? ` ${body}` : ""}`,
);
return;
}
case "message_end": {
const message = event.message as {
role?: string;
content?: unknown;
};
if (message.role !== "assistant") return;
const text = extractAssistantText(message.content).trim();
if (!text) return;
const head = compactLine(text, 240);
send({ kind: "assistant", phase, body: head }, `[${phase}] ${head}`);
return;
}
}
};
const sendPhaseLine = (phase: string, text: string): void => {
send({ kind: "assistant", phase, body: text }, `[${phase}] ${text}`);
};
return { onAgentEvent, sendPhaseLine };
}
/** /**
* Import every `checks/*.ts` module (except the registry barrel) so each check * Import every `checks/*.ts` module (except the registry barrel) so each check
* file's top-level `registerCheck(def)` call runs before command binding. This * file's top-level `registerCheck(def)` call runs before command binding. This
@@ -83,11 +264,8 @@ async function loadCheckModules(): Promise<void> {
} }
} }
/** Max tool calls shown collapsed in a chat message (matches ralpi). */
const RENDERER_MAX_COLLAPSED = 3;
/** /**
* Create a callback to send messages to the main chat window. * Create a callback to send completion messages to the main chat window.
*/ */
function makeSendChatMessage(pi: ExtensionAPI): SendChatMessage { function makeSendChatMessage(pi: ExtensionAPI): SendChatMessage {
return (content: string, meta?: Record<string, unknown>) => { return (content: string, meta?: Record<string, unknown>) => {
@@ -102,7 +280,6 @@ function makeSendChatMessage(pi: ExtensionAPI): SendChatMessage {
}); });
}; };
} }
export default async function pygieniumExtension( export default async function pygieniumExtension(
pi: ExtensionAPI, pi: ExtensionAPI,
): Promise<void> { ): Promise<void> {
@@ -123,7 +300,6 @@ export default async function pygieniumExtension(
| { | {
phase?: string; phase?: string;
completion?: CheckCompletionDetails; completion?: CheckCompletionDetails;
toolCalls?: ToolCallEntry[];
error?: string; error?: string;
} }
| undefined; | undefined;
@@ -132,7 +308,6 @@ export default async function pygieniumExtension(
lines.push(String(message.content)); lines.push(String(message.content));
const completion = details?.completion; const completion = details?.completion;
const toolCalls = details?.toolCalls;
if (completion) { if (completion) {
if (expanded) { if (expanded) {
// Expanded: show every phase with status glyph + branch. // Expanded: show every phase with status glyph + branch.
@@ -164,33 +339,6 @@ export default async function pygieniumExtension(
); );
lines.push(hint); lines.push(hint);
} }
} else if (toolCalls && toolCalls.length > 0) {
const all = toolCalls;
if (expanded) {
for (let i = 0; i < all.length; i++) {
const entry = all[i]!;
const isLast = i === all.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = theme.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`);
}
} else {
const shown = all.slice(-RENDERER_MAX_COLLAPSED);
const remaining = all.length - shown.length;
if (remaining > 0) {
lines.push(theme.fg("dim", ` ├── ${remaining} more`));
}
for (let i = 0; i < shown.length; i++) {
const entry = shown[i]!;
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = theme.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`);
}
}
if (details?.error) {
lines.push(theme.fg("error", ` error: ${details.error}`));
}
} else if (!expanded) { } else if (!expanded) {
lines.push(theme.fg("dim", " ├── press Ctrl+O for detail")); lines.push(theme.fg("dim", " ├── press Ctrl+O for detail"));
} }
@@ -205,17 +353,84 @@ export default async function pygieniumExtension(
progressRenderer as MessageRenderer, progressRenderer as MessageRenderer,
); );
// Live tool-event stream renderer: one chat line per tool start/end and
// assistant turn, indented so ends nest under their start. Mirrors
// piolium's PIOLIUM_STREAM renderer.
pi.registerMessageRenderer<StreamLineDetails>(
PYGIENIUM_STREAM,
(message, _options, theme) => {
const details = message.details;
if (!details || typeof details !== "object") {
const fallback =
typeof message.content === "string" ? message.content : "";
return new Text(theme.fg("muted", fallback), 0, 0);
}
const { kind, phase, toolName, body } = details;
// Indent end/error lines so they visually nest under the matching
// start line. The pad width matches the "[phase] " prefix.
const phaseTag = theme.fg("accent", `[${phase}]`);
const indent = " ".repeat(phase.length + 3);
let line: string;
switch (kind) {
case "tool-start": {
const arrow = theme.fg("muted", "→");
const name = theme.fg("toolTitle", theme.bold(toolName ?? ""));
const args = body ? ` ${theme.fg("muted", body)}` : "";
line = `${phaseTag} ${arrow} ${name}${args}`;
break;
}
case "tool-end": {
const arrow = theme.fg("success", "←");
const result = body
? ` ${theme.fg("dim", body)}`
: ` ${theme.fg("dim", "(ok)")}`;
line = `${indent}${arrow}${result}`;
break;
}
case "tool-error": {
const marker = theme.fg("error", "✗");
const result = body
? ` ${theme.fg("error", body)}`
: ` ${theme.fg("error", "failed")}`;
line = `${indent}${marker}${result}`;
break;
}
case "assistant":
line = `${phaseTag} ${theme.fg("muted", body ?? "")}`;
break;
default:
line =
typeof message.content === "string"
? theme.fg("muted", message.content)
: "";
}
return new Text(line, 0, 0);
},
);
const forwarder = makeStreamForwarder(pi);
// Read chat style from pi's settings.json. When "compact", suppress the
// per-event stream + synthetic phase lines so only the completion message
// (with its expandable phase tree) shows — the ralpi-style rendering.
const chatStyle = await readChatStyle();
const verbose = chatStyle === "verbose";
const onAgentEvent = verbose ? forwarder.onAgentEvent : undefined;
const sendPhaseLine = verbose ? forwarder.sendPhaseLine : undefined;
registerPygieniumCommands((name, options) => { registerPygieniumCommands((name, options) => {
pi.registerCommand(name, { pi.registerCommand(name, {
description: options.description, description: options.description,
handler: (args: string, ctx: ExtensionCommandContext) => { handler: (args: string, ctx: ExtensionCommandContext) => {
// Create PygieniumCtx with sendChatMessage callback // Create PygieniumCtx with chat + stream callbacks.
const pygieniumCtx: PygieniumCtx = { const pygieniumCtx: PygieniumCtx = {
cwd: ctx.cwd, cwd: ctx.cwd,
mode: ctx.mode, mode: ctx.mode,
hasUI: ctx.hasUI, hasUI: ctx.hasUI,
ui: ctx.ui, ui: ctx.ui,
sendChatMessage, sendChatMessage,
onAgentEvent,
sendPhaseLine,
}; };
return options.handler(args, pygieniumCtx); return options.handler(args, pygieniumCtx);
}, },

View File

@@ -25,6 +25,7 @@ import { dirname, join, resolve } from "node:path";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import { getAllChecks, type CheckDefinition } from "../checks/registry.js"; import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
import { runCheck } from "./check-runner.js"; import { runCheck } from "./check-runner.js";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { createPhaseStrip, type SendChatMessage } from "../phases.js"; import { createPhaseStrip, type SendChatMessage } from "../phases.js";
import { createPipelineFooter, type ItemStatus } from "../footer.js"; import { createPipelineFooter, type ItemStatus } from "../footer.js";
import { runRecon } from "../recon.js"; import { runRecon } from "../recon.js";
@@ -75,6 +76,11 @@ export interface AllRunOptions {
hasUI?: boolean; hasUI?: boolean;
/** Optional callback to post completion messages into the chat. */ /** Optional callback to post completion messages into the chat. */
sendChatMessage?: SendChatMessage; sendChatMessage?: SendChatMessage;
/** Optional callback forwarding raw sub-agent events to the chat stream. */
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
/** Optional callback to emit synthetic progress lines during non-agent
* phases (verify, cleanup, recon) into the chat stream. */
sendPhaseLine?: (phase: string, text: string) => void;
} }
/** Outcome of {@link runAllChecks}. */ /** Outcome of {@link runAllChecks}. */
@@ -287,15 +293,14 @@ export async function runAllChecks(
} }
await saveRunState(state); await saveRunState(state);
// --- Unified phase strip listing all check names ----------------------- // --- Unified phase strip logging all check names -----------------------
const strip = createPhaseStrip({ const strip = createPhaseStrip({
ui: opts.ui, ui: opts.ui,
hasUI, hasUI,
statusKey: "pygienium-all",
}); });
setAllPhase(strip, selected, 0, "recon"); setAllPhase(strip, selected, 0, "recon");
// Pipeline-overview footer: one static line listing every check with the // Pipeline-overview footer: a multi-line widget listing every check with the
// cursor on the active one and what's to come. Detail lives in the chat // cursor on the active one and what's to come. Detail lives in the chat
// (per-check completion trees); the footer is the overview. Inner // (per-check completion trees); the footer is the overview. Inner
// `runCheck` calls pass `footer: false` so two overviews never compete. // `runCheck` calls pass `footer: false` so two overviews never compete.
@@ -366,13 +371,11 @@ export async function runAllChecks(
hasUI, hasUI,
existingState: state, existingState: state,
sendChatMessage: opts.sendChatMessage, sendChatMessage: opts.sendChatMessage,
// The unified `pygienium-all` strip already surfaces this check's onAgentEvent: opts.onAgentEvent,
// phase; suppress the per-check widget so two animated spinners sendPhaseLine: opts.sendPhaseLine,
// never compete over the same widget area. // The all-run footer already owns the pipeline-overview widget
widget: false,
// The all-run footer already owns the pipeline-overview status
// slot; suppress the per-check footer so two overviews never // slot; suppress the per-check footer so two overviews never
// compete over the same status line. // compete over the same `belowEditor` area.
footer: false, footer: false,
// Inner runs must not prime the .gitignore twice — the outer all-run // Inner runs must not prime the .gitignore twice — the outer all-run
// already ensured it. (ensureRunStateIgnored is memoized per cwd, // already ensured it. (ensureRunStateIgnored is memoized per cwd,

View File

@@ -25,11 +25,11 @@ import {
createPhaseStrip, createPhaseStrip,
type SendChatMessage, type SendChatMessage,
type CheckCompletionDetails, type CheckCompletionDetails,
type ToolCallEntry,
type PhaseLogEntry, type PhaseLogEntry,
type PhaseLogStatus, type PhaseLogStatus,
PHASE_LABELS, PHASE_LABELS,
} from "../phases.js"; } from "../phases.js";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import { createPipelineFooter, footerPhaseItems } from "../footer.js"; import { createPipelineFooter, footerPhaseItems } from "../footer.js";
import { import {
applyPhaseStatus, applyPhaseStatus,
@@ -100,6 +100,12 @@ export interface RunCheckOptions {
* overviews never compete over the same status slot. * overviews never compete over the same status slot.
*/ */
footer?: boolean; footer?: boolean;
/** Optional callback that forwards raw sub-agent events to the chat
* stream (see `pygienium-stream` in `index.ts`). */
onAgentEvent?: (phase: string, event: AgentSessionEvent) => void;
/** Optional callback to emit synthetic progress lines during non-agent
* phases (verify, cleanup, recon) into the chat stream. */
sendPhaseLine?: (phase: string, text: string) => void;
/** /**
* Ensure `<cwd>/.gitignore` excludes `.pygienium/` before this run writes * Ensure `<cwd>/.gitignore` excludes `.pygienium/` before this run writes
* state/artifacts (default true). Set false with `--no-gitignore`. * state/artifacts (default true). Set false with `--no-gitignore`.
@@ -173,9 +179,20 @@ async function runCheckImplInner(
ui: opts.ui, ui: opts.ui,
hasUI: opts.hasUI ?? false, hasUI: opts.hasUI ?? false,
checkLabel: check.label, checkLabel: check.label,
widget: opts.widget,
}); });
/** Phase tag combining check label + phase label for stream lines. */
const phaseTag = (phaseId: string): string =>
`${check.label}: ${PHASE_LABELS[phaseId] ?? phaseId}`;
/** Forward a raw agent event tagged with the current phase. */
const forward = (phaseId: string) => (event: AgentSessionEvent) =>
opts.onAgentEvent?.(phaseTag(phaseId), event);
/** Emit a synthetic stream line for non-agent phases (verify/cleanup/recon). */
const phaseLine = (phaseId: string, text: string): void =>
opts.sendPhaseLine?.(phaseTag(phaseId), text);
// Pipeline-overview footer: a static one-line view of the full phase list // Pipeline-overview footer: a static one-line view of the full phase list
// with the cursor on the current phase and what's to come. Detail lives in // with the cursor on the current phase and what's to come. Detail lives in
// the chat (phase strip + completion tree); the footer is the overview. // the chat (phase strip + completion tree); the footer is the overview.
@@ -229,12 +246,14 @@ async function runCheckImplInner(
footerEnter(PHASE_RECON); footerEnter(PHASE_RECON);
applyPhaseStatus(state, check.name, PHASE_RECON, "in_progress"); applyPhaseStatus(state, check.name, PHASE_RECON, "in_progress");
await saveRunState(state); await saveRunState(state);
phaseLine(PHASE_RECON, "scanning project structure…");
const snapshot = await runRecon(cwd); const snapshot = await runRecon(cwd);
state.recon = { state.recon = {
complete: true, complete: true,
path: join(stateDir(cwd), "recon.json"), path: join(stateDir(cwd), "recon.json"),
finishedAt: snapshot.createdAt, finishedAt: snapshot.createdAt,
}; };
phaseLine(PHASE_RECON, "✓ recon complete");
applyPhaseStatus(state, check.name, PHASE_RECON, "complete"); applyPhaseStatus(state, check.name, PHASE_RECON, "complete");
await saveRunState(state); await saveRunState(state);
footerComplete(PHASE_RECON); footerComplete(PHASE_RECON);
@@ -250,21 +269,12 @@ async function runCheckImplInner(
applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "in_progress"); applyPhaseStatus(state, check.name, PHASE_ANALYSIS, "in_progress");
await saveRunState(state); await saveRunState(state);
const scanTask = await check.buildScanTask(cwd, scope); const scanTask = await check.buildScanTask(cwd, scope);
const scanStartMs = Date.now();
const scanResult = await runAgentTask({ const scanResult = await runAgentTask({
cwd: scope.target, cwd: scope.target,
agentName: check.agentName, agentName: check.agentName,
task: scanTask, task: scanTask,
onToolCall: (entry) => strip.pushToolCall(entry), onEvent: forward(PHASE_ANALYSIS),
}); });
postAgentToolCalls(
opts,
PHASE_ANALYSIS,
scanResult.toolCalls,
scanResult.ok,
Date.now() - scanStartMs,
scanResult.error,
);
findings = scanResult.text; findings = scanResult.text;
recordCheckOutput(state, check.name, { findings }); recordCheckOutput(state, check.name, { findings });
if (!scanResult.ok) { if (!scanResult.ok) {
@@ -291,21 +301,12 @@ async function runCheckImplInner(
applyPhaseStatus(state, check.name, PHASE_FIX, "in_progress"); applyPhaseStatus(state, check.name, PHASE_FIX, "in_progress");
await saveRunState(state); await saveRunState(state);
const fixTask = await check.buildFixTask(cwd, scope, findings); const fixTask = await check.buildFixTask(cwd, scope, findings);
const fixStartMs = Date.now();
const fixResult = await runAgentTask({ const fixResult = await runAgentTask({
cwd: scope.target, cwd: scope.target,
agentName: check.fixAgentName ?? "fixer", agentName: check.fixAgentName ?? "fixer",
task: fixTask, task: fixTask,
onToolCall: (entry) => strip.pushToolCall(entry), onEvent: forward(PHASE_FIX),
}); });
postAgentToolCalls(
opts,
PHASE_FIX,
fixResult.toolCalls,
fixResult.ok,
Date.now() - fixStartMs,
fixResult.error,
);
changes = fixResult.text; changes = fixResult.text;
recordCheckOutput(state, check.name, { changes }); recordCheckOutput(state, check.name, { changes });
if (!fixResult.ok) { if (!fixResult.ok) {
@@ -337,6 +338,7 @@ async function runCheckImplInner(
footerEnter(PHASE_VERIFY); footerEnter(PHASE_VERIFY);
applyPhaseStatus(state, check.name, PHASE_VERIFY, "in_progress"); applyPhaseStatus(state, check.name, PHASE_VERIFY, "in_progress");
await saveRunState(state); await saveRunState(state);
phaseLine(PHASE_VERIFY, "checking artifacts…");
// Verify is a lightweight self-check. A check may supply a dedicated // Verify is a lightweight self-check. A check may supply a dedicated
// `verify` hook to confirm its artifacts were produced (e.g. // `verify` hook to confirm its artifacts were produced (e.g.
// findings.md / changes.md exist). When absent, fall back to re-running // findings.md / changes.md exist). When absent, fall back to re-running
@@ -357,6 +359,7 @@ async function runCheckImplInner(
state, state,
}; };
} }
phaseLine(PHASE_VERIFY, "✓ artifacts confirmed");
applyPhaseStatus(state, check.name, PHASE_VERIFY, "complete"); applyPhaseStatus(state, check.name, PHASE_VERIFY, "complete");
await saveRunState(state); await saveRunState(state);
footerComplete(PHASE_VERIFY); footerComplete(PHASE_VERIFY);
@@ -366,7 +369,9 @@ async function runCheckImplInner(
footerEnter(PHASE_CLEANUP); footerEnter(PHASE_CLEANUP);
applyPhaseStatus(state, check.name, PHASE_CLEANUP, "in_progress"); applyPhaseStatus(state, check.name, PHASE_CLEANUP, "in_progress");
await saveRunState(state); await saveRunState(state);
phaseLine(PHASE_CLEANUP, "removing transient artifacts…");
await cleanupTransientArtifacts(cwd, check.name); await cleanupTransientArtifacts(cwd, check.name);
phaseLine(PHASE_CLEANUP, "✓ done");
applyPhaseStatus(state, check.name, PHASE_CLEANUP, "complete"); applyPhaseStatus(state, check.name, PHASE_CLEANUP, "complete");
footerComplete(PHASE_CLEANUP); footerComplete(PHASE_CLEANUP);
markCheckStatus(state, check.name, "complete"); markCheckStatus(state, check.name, "complete");
@@ -491,35 +496,6 @@ function postCheckCompletion(
send(header, { phase: "complete", completion: details }); send(header, { phase: "complete", completion: details });
} }
/**
* Post a ralpi-style per-agent-execution message into the chat: header + the
* tool-call tree captured during that sub-process (analysis or fix). One
* message per agent run, so tool calls are broken down by which execution
* produced them. No-op when no callback is wired or the run made no calls.
*/
function postAgentToolCalls(
opts: RunCheckOptions,
phaseId: string,
toolCalls: ToolCallEntry[],
okc: boolean,
durationMs: number,
error?: string,
): void {
const send = opts.sendChatMessage;
if (!send) return;
if (toolCalls.length === 0) return;
const glyph = okc ? "✓" : "✗";
const label = (PHASE_LABELS[phaseId] ?? phaseId).toLowerCase();
const dur = formatDuration(durationMs);
const header = `${glyph} pygienium ${opts.check.label} · ${label} (${dur})`;
send(header, {
phase: "agent",
phaseId,
toolCalls,
error: okc ? undefined : error,
});
}
/** /**
* Remove transient per-check scratch artifacts (e.g. agent-extracted * Remove transient per-check scratch artifacts (e.g. agent-extracted
* manifests) written under `<cwd>/.pygienium/<check>-tmp-*`. Findings and * manifests) written under `<cwd>/.pygienium/<check>-tmp-*`. Findings and

View File

@@ -1,20 +1,19 @@
/** /**
* phases.ts — live progress widget + completion-message helpers. * phases.ts — phase-log accumulator + stdout progress + completion-message
* helpers.
* *
* Renders the active phase of a check run as a **live widget in the chat * The live *detail* view of a check run is the tool-event stream piped into
* area** (via `ExtensionUIContext.setWidget`): an animated spinner + header * the chat (see `pygienium-stream` in `index.ts`): each `tool_execution_start
* line naming the check and current phase. This is the pygienium analogue of * /end` and assistant turn becomes its own chat message. This module no longer
* ralpi's per-loop progress widget — the chat-side *detail* view. * owns a TUI widget — it only records phase transitions for the completion
* message and forwards phase headers to stdout in print/JSON mode.
* *
* The *overview* view is the footer status strip (see `footer.ts`): a single * The *overview* view is the footer widget (see `footer.ts`): a multi-line
* static line listing the full pipeline with the cursor and what's to come. * `belowEditor` strip listing the full pipeline with the cursor and what's to
* The two never overlap: the chat widget is the animated detail, the footer * come. The two never overlap: the chat is the per-event detail, the footer
* is the static overview. * is the static overview.
* *
* In print/JSON modes (no TUI) the same lines are forwarded to stdout behind * The strip accumulates a phase log (`getPhaseLog`) that the check-runner
* an async lock so parallel checks don't interleave.
*
* The strip also accumulates a phase log (`getPhaseLog`) that the check-runner
* turns into the expandable completion message rendered by * turns into the expandable completion message rendered by
* `registerMessageRenderer("pygienium-progress")` in `index.ts`. * `registerMessageRenderer("pygienium-progress")` in `index.ts`.
* *
@@ -23,14 +22,6 @@
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
/** A single tool invocation captured from a sub-agent session, for display. */
export interface ToolCallEntry {
/** Tool name (e.g. "bash", "read", "edit"). */
name: string;
/** Short human-readable label derived from the call's arguments. */
label: string;
}
/** Callback to post a message into the chat history (see `index.ts` renderer). */ /** Callback to post a message into the chat history (see `index.ts` renderer). */
export type SendChatMessage = ( export type SendChatMessage = (
content: string, content: string,
@@ -38,25 +29,11 @@ export type SendChatMessage = (
meta?: { meta?: {
phase?: string; phase?: string;
/** Tool calls captured during this agent execution (ralpi-style tree). */ /** Tool calls captured during this agent execution (ralpi-style tree). */
toolCalls?: ToolCallEntry[]; toolCalls?: never;
[meta: string]: unknown; [meta: string]: unknown;
}, },
) => void; ) => void;
/** Braille spinner frames (matches ralpi's loop widget). */
export const SPINNER_FRAMES = [
"⠋",
"⠙",
"⠹",
"⠸",
"⠼",
"⠴",
"⠦",
"⠧",
"⠇",
"⠏",
] as const;
/** Phase display metadata for a check run's phases. */ /** Phase display metadata for a check run's phases. */
export const PHASE_LABELS: Record<string, string> = { export const PHASE_LABELS: Record<string, string> = {
recon: "Recon", recon: "Recon",
@@ -82,40 +59,29 @@ export interface PhaseLogEntry {
} }
export interface PhaseStripOptions { export interface PhaseStripOptions {
/** Widget key (defaults to "pygienium"). */
statusKey?: string;
/** Check label shown alongside the phase, e.g. "comments". */ /** Check label shown alongside the phase, e.g. "comments". */
checkLabel?: string; checkLabel?: string;
/** Whether dialog-capable UI is available. */ /** Whether dialog-capable UI is available. */
hasUI?: boolean; hasUI?: boolean;
/** UI context to drive the live chat widget. */ /** UI context (unused for widget rendering since the stream owns the chat). */
ui?: ExtensionUIContext; ui?: ExtensionUIContext;
/**
* Render the live progress widget (default true). Set false when an outer
* strip (e.g. the `/pygienium-all` unified strip) already surfaces the same
* phase, so two spinners never fight over the widget area.
*/
widget?: boolean;
} }
/** /**
* A handle that renders a live progress widget and clears on completion. * A handle that records phase transitions for the completion message and
* Created by {@link createPhaseStrip}; the check-runner drives it. * forwards phase headers to stdout when no TUI is present. Created by
* {@link createPhaseStrip}; the check-runner drives it.
*/ */
export interface PhaseStrip { export interface PhaseStrip {
/** Set the current phase (id or a pre-rendered header string). */ /** Set the current phase (id or a pre-rendered header string). */
setPhase(phaseId: string): void; setPhase(phaseId: string): void;
/** Annotate the most recent phase (e.g. "findings: 12 lines"). */ /** Annotate the most recent phase (e.g. "findings: 12 lines"). */
setPhaseNote(note: string): void; setPhaseNote(note: string): void;
/** Record a tool call from the active sub-agent (live widget tree). */
pushToolCall(entry: ToolCallEntry): void;
/** Drop the tool-call log (e.g. when moving to a fresh sub-agent). */
clearToolCalls(): void;
/** Append a plain-text progress line (forwarded to stdout in print mode). */ /** Append a plain-text progress line (forwarded to stdout in print mode). */
log(line: string): void; log(line: string): void;
/** Snapshot of phase transitions for the completion message. */ /** Snapshot of phase transitions for the completion message. */
getPhaseLog(): PhaseLogEntry[]; getPhaseLog(): PhaseLogEntry[];
/** Clear the live widget. Call once the run is terminal. */ /** Mark the strip terminal. Safe to call repeatedly. */
done(): void; done(): void;
} }
@@ -135,80 +101,21 @@ async function withStdoutLock(fn: () => void): Promise<void> {
return stdoutLock; return stdoutLock;
} }
/** Widget container width budget (account for widget padding). */ /** Create a phase-strip UI adapter. */
const WIDGET_WIDTH = 78;
/** Max tool calls shown in a live widget before truncating (matches ralpi). */
const MAX_COLLAPSED_TOOLCALLS = 3;
/** Create a live phase-strip UI adapter. */
export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip { export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip {
const statusKey = opts.statusKey ?? "pygienium";
const widgetKey = `${statusKey}-progress`;
const ui = opts.ui;
const hasUI = opts.hasUI ?? false;
const widget = opts.widget ?? true;
const checkLabel = opts.checkLabel; const checkLabel = opts.checkLabel;
const hasUI = opts.hasUI ?? false;
const phaseLog: PhaseLogEntry[] = []; const phaseLog: PhaseLogEntry[] = [];
const toolCallLog: ToolCallEntry[] = [];
let disposed = false; let disposed = false;
// Live-widget state.
let currentHeader = checkLabel let currentHeader = checkLabel
? `pygienium ${checkLabel}: starting…` ? `pygienium ${checkLabel}: starting…`
: "pygienium: starting…"; : "pygienium: starting…";
let frameIndex = 0;
let spinnerTimer: NodeJS.Timeout | undefined;
function phaseLabel(id: string): string { function phaseLabel(id: string): string {
return PHASE_LABELS[id] ?? id; return PHASE_LABELS[id] ?? id;
} }
/** Build the current widget content: header + recent tool-call tree. */
function widgetLines(): string[] {
const frame = SPINNER_FRAMES[frameIndex] ?? SPINNER_FRAMES[0];
const lines = [`${frame} ${truncate(currentHeader, WIDGET_WIDTH - 2)}`];
const calls = toolCallLog;
if (calls.length > 0) {
const shown = calls.slice(-MAX_COLLAPSED_TOOLCALLS);
const remaining = calls.length - shown.length;
if (remaining > 0) {
lines.push(` ├── …${remaining} earlier`);
}
for (let i = 0; i < shown.length; i++) {
const entry = shown[i]!;
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
truncate(`${branch}[${entry.name}] ${entry.label}`, WIDGET_WIDTH - 2),
);
}
}
return lines;
}
function startWidget(): void {
if (!widget || !hasUI || !ui?.setWidget) return;
ui.setWidget(widgetKey, widgetLines());
if (!spinnerTimer) {
spinnerTimer = setInterval(() => {
if (disposed || !ui?.setWidget) return;
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
ui.setWidget(widgetKey, widgetLines());
}, 100);
}
}
function clearWidget(): void {
if (spinnerTimer) {
clearInterval(spinnerTimer);
spinnerTimer = undefined;
}
if (widget && hasUI && ui?.setWidget) {
ui.setWidget(widgetKey, undefined);
}
}
function writeStdout(text: string): void { function writeStdout(text: string): void {
if (!disposed && !hasUI) { if (!disposed && !hasUI) {
process.stdout.write(`${text}\n`); process.stdout.write(`${text}\n`);
@@ -218,38 +125,20 @@ export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip {
return { return {
setPhase(phaseId) { setPhase(phaseId) {
if (disposed) return; if (disposed) return;
toolCallLog.length = 0;
const label = phaseLabel(phaseId); const label = phaseLabel(phaseId);
currentHeader = checkLabel currentHeader = checkLabel
? `pygienium ${checkLabel}: ${label}` ? `pygienium ${checkLabel}: ${label}`
: `pygienium: ${phaseId}`; : `pygienium: ${phaseId}`;
phaseLog.push({ id: phaseId, label, status: "running" }); phaseLog.push({ id: phaseId, label, status: "running" });
if (widget) { if (!hasUI) {
if (hasUI && ui?.setWidget) { withStdoutLock(() => writeStdout(currentHeader)).catch(() => {});
if (!spinnerTimer) startWidget();
ui.setWidget(widgetKey, widgetLines());
} else if (!hasUI) {
withStdoutLock(() => writeStdout(currentHeader)).catch(() => {});
}
} }
// Widget suppressed (outer strip owns the UI): record the phase and
// render nothing, so no second spinner competes with the outer one.
}, },
setPhaseNote(note) { setPhaseNote(note) {
const last = phaseLog[phaseLog.length - 1]; const last = phaseLog[phaseLog.length - 1];
if (!last) return; if (!last) return;
last.note = note; last.note = note;
}, },
pushToolCall(entry) {
if (disposed) return;
toolCallLog.push(entry);
if (widget && hasUI && ui?.setWidget) {
ui.setWidget(widgetKey, widgetLines());
}
},
clearToolCalls() {
toolCallLog.length = 0;
},
log(line) { log(line) {
if (disposed || hasUI) return; if (disposed || hasUI) return;
withStdoutLock(() => writeStdout(line)).catch(() => {}); withStdoutLock(() => writeStdout(line)).catch(() => {});
@@ -260,7 +149,6 @@ export function createPhaseStrip(opts: PhaseStripOptions): PhaseStrip {
done() { done() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
clearWidget();
}, },
}; };
} }
@@ -282,11 +170,3 @@ export const PHASE_GLYPH: Record<PhaseLogStatus, string> = {
failed: "✗", failed: "✗",
skipped: "-", skipped: "-",
}; };
/** Truncate a string to a display width, appending an ellipsis if cut. */
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, Math.max(0, max - 1)) + "…";
}
export { truncate };

View File

@@ -374,7 +374,7 @@ describe("/pygienium-all orchestrator (task 12)", () => {
expect(joined).toContain("all ["); expect(joined).toContain("all [");
}); });
it("renders only the unified widget in UI mode (no per-check spinner)", async () => { it("renders only the unified footer widget in UI mode (no per-check footer)", async () => {
registerCheck(fakeCheck("alpha")); registerCheck(fakeCheck("alpha"));
registerCheck(fakeCheck("beta")); registerCheck(fakeCheck("beta"));
const calls: Array<[string, string[] | undefined]> = []; const calls: Array<[string, string[] | undefined]> = [];
@@ -387,14 +387,15 @@ describe("/pygienium-all orchestrator (task 12)", () => {
await runAllChecks({ cwd, ui, hasUI: true }); await runAllChecks({ cwd, ui, hasUI: true });
const keys = new Set(calls.map(([k]) => k)); const keys = new Set(calls.map(([k]) => k));
// The unified strip drives the widget area // The all-run footer drives the belowEditor widget area under its own key
expect(keys.has("pygienium-all-progress")).toBe(true); expect(keys.has("pygienium-all")).toBe(true);
// …and per-check strips never claim it, so no second spinner can // …and per-check footers never claim their slot (footer:false), so two
// flicker/swap against the unified one. // overviews never compete over the same widget area.
expect(keys.has("pygienium-progress")).toBe(false); expect(keys.has("pygienium")).toBe(false);
// The widget is cleared when the run completes. // The widget is cleared when the run completes.
const last = calls[calls.length - 1]!; const last = calls[calls.length - 1];
expect(last[0]).toBe("pygienium-all-progress"); expect(last).toBeDefined();
expect(last[1]).toBeUndefined(); expect(last?.[0]).toBe("pygienium-all");
expect(last?.[1]).toBeUndefined();
}); });
}); });

View File

@@ -1,33 +1,58 @@
/** /**
* footer.test.ts — unit tests for the pipeline-overview footer (task: footer). * footer.test.ts — unit tests for the pipeline-overview footer.
* *
* The footer is presentation-only state: with no UI it tracks items but writes * The footer is a presentation-only multi-line `belowEditor` widget: with no
* nothing; with a stub UI it renders one status line and clears on `done()`. * UI it tracks items but writes nothing; with a stub UI it pushes a string[]
* These tests exercise the state machine (cursor demotion, terminal marks) * of themed lines via `ui.setWidget` (key, lines, { placement: "belowEditor" })
* without spinning up a runner. * and clears on `done()`. The pure {@link renderFooterList} core is asserted
* directly (layout + theming); a light widget-glue test covers the wiring.
*/ */
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { import {
createPipelineFooter, createPipelineFooter,
footerPhaseItems, footerPhaseItems,
FOOTER_GLYPH, footerColor,
renderFooterList,
FOOTER_MARKER,
FOOTER_STATUS_KEY, FOOTER_STATUS_KEY,
type ItemStatus, type FooterItem,
type FooterTheme,
} from "../src/footer.js"; } from "../src/footer.js";
import { PHASE_LABELS } from "../src/phases.js"; import { PHASE_LABELS } from "../src/phases.js";
import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js"; import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js";
/** Minimal UI stub capturing `setStatus(key, text)` calls in order. */ /** A fake theme that wraps text as `<color>:<text>` so assertions can read it. */
function stubUi(): { function fakeTheme(): FooterTheme {
ui: { setStatus: (key: string, text: string | undefined) => void }; return { fg: (color, text) => `${color}:${text}` };
calls: { key: string; text: string | undefined }[]; }
/** Minimal UI stub capturing `setWidget` calls (key, lines, options). */
function stubUi(theme: FooterTheme = fakeTheme()): {
ui: {
theme: FooterTheme;
setWidget: (
key: string,
content: string[] | undefined,
options?: { placement?: string },
) => void;
};
calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[];
} { } {
const calls: { key: string; text: string | undefined }[] = []; const calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[] = [];
return { return {
calls, calls,
ui: { ui: {
setStatus(key, text) { theme,
calls.push({ key, text }); setWidget(key, content, options) {
calls.push({ key, content, placement: options?.placement });
}, },
}, },
}; };
@@ -38,9 +63,72 @@ function scanPhaseIds(): string[] {
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX]; return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
} }
/** Items for the canonical scan-only pipeline. */
function scanItems(status: FooterItem["status"] = "pending"): FooterItem[] {
return scanPhaseIds().map((id) => ({
label: PHASE_LABELS[id] ?? id,
status,
}));
}
describe("renderFooterList", () => {
it("renders one bulleted, numbered, themed line per phase", () => {
const lines = renderFooterList(scanItems(), -1, fakeTheme());
expect(lines).toHaveLength(3);
// Each line: `• <marker> <n>. <label>` wrapped `<color>:…`.
expect(lines[0]).toBe("dim:• · 1. Recon");
expect(lines[1]).toBe("dim:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes the cursor item as accent (running) and the rest as dim (pending)", () => {
const lines = renderFooterList(scanItems(), 1, fakeTheme());
expect(lines[0]).toBe("dim:• · 1. Recon");
// cursor (index 1) is pending-but-current → accent.
expect(lines[1]).toBe("accent:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes terminal statuses with success/error/warning colors", () => {
const items: FooterItem[] = [
{ label: "Recon", status: "complete" },
{ label: "Scan", status: "running" },
{ label: "Fix", status: "failed" },
{ label: "Verify", status: "skipped" },
];
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toBe("success:• ✓ 1. Recon");
expect(lines[1]).toBe("accent:• ● 2. Scan");
expect(lines[2]).toBe("error:• ✗ 3. Fix");
expect(lines[3]).toBe("warning:• ↷ 4. Verify");
});
it("pads the index to 2 digits when the pipeline has 10+ items", () => {
const items: FooterItem[] = Array.from({ length: 11 }, (_, i) => ({
label: `S${i}`,
status: "pending" as const,
}));
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toContain("01. S0");
expect(lines[10]).toContain("11. S10");
});
});
describe("footerColor", () => {
it("maps each status to its piolium-style color token", () => {
expect(footerColor("complete", false)).toBe("success");
expect(footerColor("failed", false)).toBe("error");
expect(footerColor("skipped", false)).toBe("warning");
expect(footerColor("running", false)).toBe("accent");
expect(footerColor("pending", false)).toBe("dim");
// A pending item under the cursor reads as accent (current).
expect(footerColor("pending", true)).toBe("accent");
});
});
describe("createPipelineFooter", () => { describe("createPipelineFooter", () => {
it("is a no-op without a UI but still tracks item state", () => { it("is a no-op without a UI but still tracks item state", () => {
// hasUI false: setStatus must never be called. // hasUI false: setWidget must never be called.
const footer = createPipelineFooter({ hasUI: false }); const footer = createPipelineFooter({ hasUI: false });
footer.setPipeline("pygienium smoke", [ footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" }, { label: "Recon", status: "pending" },
@@ -52,28 +140,31 @@ describe("createPipelineFooter", () => {
expect(footer.getTitle()).toBe("pygienium smoke"); expect(footer.getTitle()).toBe("pygienium smoke");
}); });
it("renders the full pipeline with the cursor running and clears on done", () => { it("renders the full pipeline as a belowEditor widget and clears on done", () => {
const { ui, calls } = stubUi(); const { ui, calls } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true }); const footer = createPipelineFooter({ ui, hasUI: true });
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS); const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
footer.setPipeline("pygienium smoke", items, 0); footer.setPipeline("pygienium smoke", items, 0);
// One setStatus call, under the canonical key, listing every phase: // One setWidget call, under the canonical key, placement belowEditor.
// cursor gets `▶ <label>`, the rest are glued `…<label>`.
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY); expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
expect(calls[0]?.text).toContain("pygienium smoke"); expect(calls[0]?.placement).toBe("belowEditor");
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} Recon`); const lines = calls[0]?.content ?? [];
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Scanning`); // Title line first (dim), then one bulleted line per phase.
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Fixing`); expect(lines[0]).toBe("dim:pygienium smoke");
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. Recon`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. Scanning`);
expect(lines[3]).toBe(`dim:• ${FOOTER_MARKER.pending} 3. Fixing`);
// The cursor item is marked running. // The cursor item is marked running.
expect(footer.getItems()[0]?.status).toBe("running"); expect(footer.getItems()[0]?.status).toBe("running");
footer.done(); footer.done();
// done() pushes an undefined to clear the slot, then resets state. // done() pushes an undefined to clear the slot, then resets state.
const last = calls[calls.length - 1]; const last = calls[calls.length - 1]!;
expect(last?.text).toBeUndefined(); expect(last.content).toBeUndefined();
expect(last.placement).toBe("belowEditor");
expect(footer.getItems()).toHaveLength(0); expect(footer.getItems()).toHaveLength(0);
}); });
@@ -103,8 +194,6 @@ describe("createPipelineFooter", () => {
0, 0,
); );
footer.setCursor(0); // recon running footer.setCursor(0); // recon running
// Simulate analysis completing (cursor was already moved there) then
// jumping to fix: a completed phase must stay complete, not revert.
footer.setItem(0, "complete"); footer.setItem(0, "complete");
footer.setCursor(1); // analysis running footer.setCursor(1); // analysis running
footer.setItem(1, "complete"); footer.setItem(1, "complete");
@@ -122,32 +211,26 @@ describe("createPipelineFooter", () => {
"pygienium comments", "pygienium comments",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS), footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
); );
// No cursor: all pending. Now a gate-skip marks every phase skipped,
// mirroring the runner's gate branch.
for (let i = 0; i < footer.getItems().length; i++) { for (let i = 0; i < footer.getItems().length; i++) {
footer.setItem(i, "skipped" as ItemStatus); footer.setItem(i, "skipped");
} }
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true); expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
}); });
it("can be disabled so it never touches the status slot", () => { it("can be disabled so it never touches the widget slot", () => {
const { ui, calls } = stubUi(); const { ui, calls } = stubUi();
const footer = createPipelineFooter({ const footer = createPipelineFooter({ ui, hasUI: true, enabled: false });
ui,
hasUI: true,
enabled: false,
});
footer.setPipeline("pygienium smoke", [ footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" }, { label: "Recon", status: "pending" },
]); ]);
footer.setCursor(0); footer.setCursor(0);
footer.done(); footer.done();
// enabled:false suppresses every setStatus call (used by /pygienium-all // enabled:false suppresses every setWidget call (used by /pygienium-all
// which owns its own footer). // which owns its own footer).
expect(calls).toHaveLength(0); expect(calls).toHaveLength(0);
}); });
it("writes under a custom status key (all-run owns its slot)", () => { it("writes under a custom widget key (all-run owns its slot)", () => {
const { ui, calls } = stubUi(); const { ui, calls } = stubUi();
const footer = createPipelineFooter({ const footer = createPipelineFooter({
ui, ui,
@@ -163,10 +246,12 @@ describe("createPipelineFooter", () => {
0, 0,
); );
expect(calls[0]?.key).toBe("pygienium-all"); expect(calls[0]?.key).toBe("pygienium-all");
expect(calls[0]?.text).toContain("pygienium: all"); expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
expect(lines[0]).toBe("dim:pygienium: all");
// Cursor on the first check; second still pending (to come). // Cursor on the first check; second still pending (to come).
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} comments`); expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. comments`);
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}dead-code`); expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. dead-code`);
}); });
}); });

View File

@@ -103,7 +103,7 @@ function flakyThenOkRunner(name: string): {
const runner: AgentRunner = async (opts) => { const runner: AgentRunner = async (opts) => {
calls++; calls++;
if (calls === 1) { if (calls === 1) {
return { ok: true, text: "", toolCalls: [] }; return { ok: true, text: "" };
} }
return fakeAgentRunner(opts); return fakeAgentRunner(opts);
}; };
@@ -171,7 +171,7 @@ describe("/pygienium-<check> resume semantics", () => {
const runner: AgentRunner = async (opts) => { const runner: AgentRunner = async (opts) => {
calls++; calls++;
if (calls === 1) { if (calls === 1) {
return { ok: true, text: "", toolCalls: [] }; return { ok: true, text: "" };
} }
return fakeAgentRunner(opts); return fakeAgentRunner(opts);
}; };

View File

@@ -38,7 +38,6 @@ import { defensiveGuardsCheck } from "../src/checks/defensive-guards.js";
const noopRunner: AgentRunner = async () => ({ const noopRunner: AgentRunner = async () => ({
ok: true, ok: true,
text: "", text: "",
toolCalls: [],
}); });
function stubCtx(cwd: string): PygieniumCtx { function stubCtx(cwd: string): PygieniumCtx {
@@ -135,7 +134,7 @@ describe("verify hooks fail loudly on empty agent output", () => {
registerCheck(defensiveGuardsCheck); registerCheck(defensiveGuardsCheck);
// Runner writes changes.md content into its text but never to disk. // Runner writes changes.md content into its text but never to disk.
setAgentRunner(async () => ({ ok: true, text: "", toolCalls: [] })); setAgentRunner(async () => ({ ok: true, text: "" }));
await handleCheckCommand(defensiveGuardsCheck, "--fix", stubCtx(cwd)); await handleCheckCommand(defensiveGuardsCheck, "--fix", stubCtx(cwd));
const state = await loadRunState(cwd); const state = await loadRunState(cwd);