From 7ef6e3d9a2d4cc58c35c779246ebc9084bbe34cf Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 9 Aug 2026 20:18:22 -0400 Subject: [PATCH] feat: compact vs. verbose chat settings --- README.md | 5 ++ index.ts | 194 +++++++++++++++++++++++++++++++++++++++++++++++- src/executor.ts | 27 +++++++ src/types.ts | 7 ++ 4 files changed, 232 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 51d842e..1ba8051 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ execution: reviewTimeoutMs: 0 # timeout for auto-review agent sessions (0 = inherit) loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit; checked between batches) worktrees: parallel # "never" | "parallel" (default) | "always" — git worktree isolation + chatStyle: compact # "compact" (default) | "verbose" — per-event tool-call stream prompts: projectContext: "Additional context for all tasks" reflectionPrompt: "" # custom suffix for reflection extraction @@ -251,6 +252,10 @@ them. > (or parallel mode with no `models` list) the parent pi session's model is used, > unless `implModel` is set. +> `execution.chatStyle` controls how sub-agent tool calls appear in the chat during task execution: +> - **compact** (default): a single completion message per task with an expandable tool-call tree (collapsed shows the last 3 calls, expanded via Ctrl+O shows all). +> - **verbose**: each tool event is streamed live as its own chat line (`[01 · task-name] → bash ...` / `← (ok)`), like piolium/pygienium's per-event stream. + #### Auto-review and Auto-commit At loop startup the review question is asked FIRST. When `autoReview` is diff --git a/index.ts b/index.ts index 47c99fb..ea81d3b 100644 --- a/index.ts +++ b/index.ts @@ -19,7 +19,7 @@ import { loadTaskManagerPrompt } from "./src/task-manager-prompt"; import { formatReflections } from "./src/reflection"; import { verdictGlyph, verdictSummary, formatFindings } from "./src/review"; import type { ReviewResult } from "./src/types"; -import { executeBatch, type SendChatMessage } from "./src/executor"; +import { executeBatch, type SendChatMessage, setStreamForwarder } from "./src/executor"; import { cleanupStaleWorktrees, finalizeCommittedWorktrees, @@ -469,9 +469,134 @@ function makeSendProgress(pi: ExtensionAPI): SendChatMessage { }; } +/** 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; + 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; +} + +/** 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) + ) + return String((item as { text?: unknown }).text ?? ""); + return JSON.stringify(item); + }) + .join("\n"); + } + if (typeof result !== "object") return ""; + const obj = result as Record; + 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)}…`; +} + +/** Extract joined text from an assistant message's content blocks. */ +function extractAssistantTextFromContent(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(""); +} + +/** + * Build a stream-event forwarder that posts ralpi-stream messages (one chat + * line per tool start/end and assistant turn) into the chat. Only used when + * execution.chatStyle is "verbose". + */ +function makeStreamForwarder(pi: ExtensionAPI): (phase: string, event: import("@earendil-works/pi-coding-agent").AgentSessionEvent) => void { + const send = (details: { + kind: "tool-start" | "tool-end" | "tool-error" | "assistant"; + phase: string; + toolName?: string; + body?: string; + }, fallback: string) => { + pi.sendMessage({ + customType: "ralpi-stream", + content: fallback, + display: true, + details, + }); + }; + + return (phase: string, event: import("@earendil-works/pi-coding-agent").AgentSessionEvent) => { + 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 = 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 = extractAssistantTextFromContent(message.content).trim(); + if (!text) return; + const head = compactLine(text, 240); + send({ kind: "assistant", phase, body: head }, `[${phase}] ${head}`); + return; + } + } + }; +} + // ─── Extension Entry ──────────────────────────────────────────────────────── export default function ralpiLoopExtension(pi: ExtensionAPI): void { + // Wire the verbose stream forwarder — posts each tool event as its own + // chat message via the ralpi-stream renderer. Enabled per-run by + // `execution.chatStyle: verbose` in the config YAML. + setStreamForwarder(makeStreamForwarder(pi)); + // Register custom message renderer for ralpi progress messages. // Renders an expandable tool-call tree: collapsed shows last 3 + "N more", // expanded (Ctrl+O) shows every tool call. @@ -572,6 +697,73 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void { }, ); + // ─── Verbose tool-event stream renderer ───────────────────────────── + // + // When execution.chatStyle is "verbose", each tool_execution_start/end and + // assistant turn is posted as its own chat message — the piolium/pygienium + // per-event stream. When "compact" (default), only the completion message + // with its expandable tool-call tree shows (the existing ralpi-progress + // renderer above). + + type StreamLineKind = "tool-start" | "tool-end" | "tool-error" | "assistant"; + + interface StreamLineDetails { + kind: StreamLineKind; + phase: string; + toolName?: string; + body?: string; + } + + pi.registerMessageRenderer( + "ralpi-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; + 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); + }, + ); + // ─── Reload detection: resume interrupted loops when session reloads ── // // ralpi runs task agent sessions in-process (createAgentSession), so they diff --git a/src/executor.ts b/src/executor.ts index 5805440..e4f195c 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -12,6 +12,7 @@ import type { ProgressTracker } from "./progress"; import type { ExtensionContext, ModelRuntime, + AgentSessionEvent, } from "@earendil-works/pi-coding-agent"; import { buildTaskPrompt, @@ -56,6 +57,28 @@ import { } from "./utils"; import { updateTaskInFile } from "./parser"; +// ─── Stream Forwarder (verbose chat style) ──────────────────────────────────── + +/** + * Module-level callback for verbose per-event chat streaming. Set by + * `index.ts` at extension startup via {@link setStreamForwarder} when the + * config's `execution.chatStyle` is "verbose". `runTask`'s event callback + * checks this and forwards each tool_execution_start/end + message_end as + * its own chat message — the piolium/pygienium per-event stream. When null + * (compact mode, the default), only the completion message with its + * expandable tool-call tree shows. + */ +let _streamForwarder: + | ((phase: string, event: AgentSessionEvent) => void) + | null = null; + +/** Register the verbose stream forwarder (called by index.ts at startup). */ +export function setStreamForwarder( + fn: ((phase: string, event: AgentSessionEvent) => void) | null, +): void { + _streamForwarder = fn; +} + /** Optional callback to post a progress message into the chat history. */ export type SendChatMessage = ( content: string, @@ -336,6 +359,10 @@ export async function runTask( projectDir, timeoutMs, (event) => { + // Forward to the verbose stream when enabled. + if (_streamForwarder && config.execution.chatStyle === "verbose") { + _streamForwarder(`${task.id} · ${task.title}`, event); + } if (event.type === "tool_execution_start") { const label = formatToolArg(event.toolName, event.args); toolCalls.push({ diff --git a/src/types.ts b/src/types.ts index 5b6f9e0..ffa93ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -262,6 +262,12 @@ export interface RalpiConfig { * - "parallel": only when maxParallel > 1 and mode is parallel * - "always": every task gets its own worktree */ worktrees: "always" | "parallel" | "never"; + /** Chat rendering style for tool calls during task execution. + * - "compact": single completion message per task with an expandable + * tool-call tree (collapsed shows last 3, expanded shows all). + * - "verbose": per-event stream — each tool start/end and assistant + * turn is its own chat line (piolium/pygienium-style). */ + chatStyle: "compact" | "verbose"; }; prompts: { /** Additional context injected into every task prompt */ @@ -310,6 +316,7 @@ export const DEFAULT_CONFIG: RalpiConfig = { loopTimeoutMs: 0, // 0 = no limit worktrees: "parallel", // worktree isolation for parallel tasks by default maxSameModelAttempts: 5, // retry the same model up to 5 times before cycling to the next + chatStyle: "compact", // compact = completion message with tool-call tree; verbose = per-event stream }, prompts: { projectContext: "",