diff --git a/README.md b/README.md index 6df84a0..c359fe4 100644 --- a/README.md +++ b/README.md @@ -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). +## 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 Every command accepts a `[path]` target (default: the current directory) and is diff --git a/agents/scanner.md b/agents/scanner.md index ccadb43..5d443f3 100644 --- a/agents/scanner.md +++ b/agents/scanner.md @@ -6,6 +6,7 @@ allowedTools: - find - ls - bash + - write --- You are the **Pygienium scanner** sub-agent — a focused code-hygiene analyst. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f2adfd3 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/src/agent-runner.ts b/src/agent-runner.ts index da2e9f3..4c0ed32 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -19,7 +19,6 @@ 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, extensionRoot, type AgentDef } from "./agents.js"; -import type { ToolCallEntry } from "./phases.js"; export interface AgentTaskOptions { /** Absolute working directory for the sub-agent. */ @@ -32,8 +31,12 @@ 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; + /** + * 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 { @@ -43,8 +46,6 @@ 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; @@ -82,7 +83,6 @@ export async function defaultAgentRunner( return { ok: false, text: "", - toolCalls: [], error: `Unknown agent definition: "${opts.agentName}". ` + `Available agents: ${names.length > 0 ? names.join(", ") : "(none loaded — check agents/ directories exist)"}. ` + @@ -123,7 +123,8 @@ export async function defaultAgentRunner( try { let text = ""; - const toolCalls: ToolCallEntry[] = []; + let stopReason: string | undefined; + let errorMessage: string | undefined; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if ( event.type === "message_update" && @@ -131,24 +132,58 @@ 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), + if (event.type === "message_end") { + // Capture the full assistant text from the finalized message — + // models that don't stream text_delta (or truncate) still surface + // 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); - opts.onToolCall?.(entry); + if (message.stopReason) stopReason = message.stopReason; + 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 }); + // 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(); - 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) { return { ok: false, text: "", error: err instanceof Error ? err.message : String(err), - toolCalls: [], }; } finally { 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 * 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, "")); } } - return { ok: true, text: findings.join("\n"), toolCalls: [] }; + return { ok: true, text: findings.join("\n") }; } 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; - 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; - } -} diff --git a/src/commands.ts b/src/commands.ts index 978a12e..63f6ff5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,7 +9,10 @@ * @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 { getAllChecks, @@ -49,6 +52,11 @@ export type PygieniumCtx = Pick< > & { /** Optional callback to post messages to the chat window. */ 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 { @@ -169,6 +177,8 @@ export async function handleCheckCommand( ui: ctx.ui, hasUI: ctx.hasUI, sendChatMessage: ctx.sendChatMessage, + onAgentEvent: ctx.onAgentEvent, + sendPhaseLine: ctx.sendPhaseLine, gitignore: !noGitignore, }); @@ -206,6 +216,8 @@ export async function handleAllCommand( ui: ctx.ui, hasUI: ctx.hasUI, sendChatMessage: ctx.sendChatMessage, + onAgentEvent: ctx.onAgentEvent, + sendPhaseLine: ctx.sendPhaseLine, }); const giNote = outcome.gitignoreAppended ? " · .pygienium/ added to .gitignore" @@ -293,6 +305,8 @@ export async function handleResumeCommand( hasUI: ctx.hasUI, existingState: state, sendChatMessage: ctx.sendChatMessage, + onAgentEvent: ctx.onAgentEvent, + sendPhaseLine: ctx.sendPhaseLine, gitignore, }); state = outcome.state; diff --git a/src/footer.ts b/src/footer.ts index 897f6ce..6c43266 100644 --- a/src/footer.ts +++ b/src/footer.ts @@ -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 - * (detail, surfaced in the chat), the footer shows one STATIC overview line: - * the full ordered pipeline (the checks and/or phases) with the current - * position and what's to come — the piolium-style footer strip. The two - * never overlap: the footer stays a single status-bar slot owned by - * `ui.setStatus(key, text)`. + * Renders the full ordered pipeline (phases for a single check, or checks for + * `/pygienium-all`) as a **multi-line `belowEditor` widget** (via + * `ExtensionUIContext.setWidget`): one bulleted, color-themed line per step, + * with the live step marked `●` and completed/failed/skipped/pending steps + * carrying their terminal glyph. This is the pygienium analogue of piolium's + * `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 * 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"; -/** 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"; /** Status of a single pipeline item, carried into the footer line. */ @@ -33,17 +38,23 @@ export type ItemStatus = | "skipped"; /** - * Glyph per item status. `…`=pending (to come), `▶`=running (cursor), - * `✓`/`✗`/`-`=terminal. Kept short so a multi-phase pipeline fits one line. + * Marker per item status, mirroring piolium's phase-status-strip glyphs: + * `·`=pending (to come), `●`=running (cursor), `✓`/`✗`/`↷`=terminal. + * Kept short so a multi-phase pipeline fits one widget column. */ -export const FOOTER_GLYPH: Record = { - pending: "…", - running: "▶", +export const FOOTER_MARKER: Record = { + pending: "·", + running: "●", complete: "✓", 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. */ export interface FooterItem { /** Short label (a phase label like "Scanning" or a check label). */ @@ -53,16 +64,16 @@ export interface FooterItem { } export interface PipelineFooterOptions { - /** UI context; writes go to `ui.setStatus`. */ + /** UI context; writes go to `ui.setWidget` (belowEditor). */ ui?: ExtensionUIContext; /** Dialog-capable UI available (TUI / RPC). When false, footer is a no-op. */ hasUI?: boolean; - /** Status-bar key (defaults to {@link FOOTER_STATUS_KEY}). */ + /** Widget key (defaults to {@link FOOTER_STATUS_KEY}). */ statusKey?: string; /** * Whether to render the footer (default true). Set false when an outer * 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. */ enabled?: boolean; @@ -79,10 +90,46 @@ export interface PipelineFooter { getItems(): FooterItem[]; /** Snapshot of the last rendered title (for tests). */ getTitle(): string; - /** Clear the footer slot. Safe to call repeatedly. */ + /** Clear the footer widget. Safe to call repeatedly. */ 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 `• .