feat: chat rendering toolcalls, footer overview
This commit is contained in:
18
README.md
18
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).
|
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
|
||||||
|
|||||||
@@ -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
34
bun.lock
Normal 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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
109
src/footer.ts
109
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
|
* 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 = "";
|
||||||
|
|||||||
289
src/index.ts
289
src/index.ts
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
162
src/phases.ts
162
src/phases.ts
@@ -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 };
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user