feat: persist full review output to disk and render in expandable view

Review text was truncated to 500 chars in the chat message body with no
way to access the full content — once a review exceeded that limit it was
lost from the UI. Now the full review body is rendered in the expanded
(Ctrl+O) view like the implementation tool-call tree, and can optionally
be persisted to disk per-loop.

Changes:
- Add saveReviews config flag + reviewsDir path option
- Add loop-start prompt to opt into saving reviews to disk when
  auto-review is enabled (skipped when explicitly set in YAML)
- Pass full reviewText via message details instead of inlining a slice
- Extend ralpi-progress renderer to show full review body when expanded,
  dim hint to expand when collapsed
- Add saveReviewToFile() writing to .ralpi/reviews/<prdKey>/<taskId>.md,
  mirroring the per-loop reflections layout so many loops don't collide
This commit is contained in:
2026-07-17 16:03:53 -04:00
parent 008489a91b
commit 6aa3f6bd9f
3 changed files with 124 additions and 15 deletions

View File

@@ -131,7 +131,7 @@ function buildPlanByMode(
async function selectLoopOptions( async function selectLoopOptions(
ctx: ExtensionContext, ctx: ExtensionContext,
config: import("./src/types").RalpiConfig, config: import("./src/types").RalpiConfig,
): Promise<{ autoCommit: boolean; autoReview: boolean }> { ): Promise<{ autoCommit: boolean; autoReview: boolean; saveReviews: boolean }> {
const explicit = config.execution.explicitKeys; const explicit = config.execution.explicitKeys;
// Skip the commit prompt when the YAML explicitly sets it. // Skip the commit prompt when the YAML explicitly sets it.
@@ -149,6 +149,7 @@ async function selectLoopOptions(
} }
let autoReview = false; let autoReview = false;
let saveReviews = false;
if (autoCommit) { if (autoCommit) {
// Skip the review prompt when the YAML explicitly sets it. // Skip the review prompt when the YAML explicitly sets it.
if (explicit?.has("autoReview")) { if (explicit?.has("autoReview")) {
@@ -162,9 +163,27 @@ async function selectLoopOptions(
? reviewChoice.startsWith("Yes") ? reviewChoice.startsWith("Yes")
: config.execution.autoReview; : config.execution.autoReview;
} }
// Only ask to persist reviews when reviews are actually enabled.
if (autoReview) {
if (explicit?.has("saveReviews")) {
saveReviews = config.execution.saveReviews;
} else {
const saveChoice = await ctx.ui.select(
"Save full review output to disk?",
[
"Yes — write each review to .ralpi/reviews/<loop>/<task>.md",
"No — keep reviews in-chat only",
],
);
saveReviews = saveChoice
? saveChoice.startsWith("Yes")
: config.execution.saveReviews;
}
}
} }
return { autoCommit, autoReview }; return { autoCommit, autoReview, saveReviews };
} }
/** /**
@@ -350,6 +369,8 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
| { | {
phase?: string; phase?: string;
toolCalls?: Array<{ name: string; label: string }>; toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
} }
| undefined; | undefined;
@@ -359,6 +380,23 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
// Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)"
lines.push(String(message.content)); lines.push(String(message.content));
// Review body: in expanded mode render the full review text so long
// reviews aren't lost to the 500-char preview. In collapsed mode
// show a dim hint that the review is available via Ctrl+O (the
// header already carries a short tail + saved-path hint).
const hasReview = !!details?.reviewText;
if (hasReview && expanded && details!.reviewText) {
const body = details!.reviewText.split("\n");
for (const line of body) {
lines.push(` ${line}`);
}
} else if (hasReview && !expanded) {
const hint = details?.reviewPath
? `press Ctrl+O for full review · saved to ${details.reviewPath}`
: "press Ctrl+O for full review";
lines.push(theme.fg("dim", ` ├── ${hint}`));
}
// Build tool-call tree // Build tool-call tree
if (details?.toolCalls && details.toolCalls.length > 0) { if (details?.toolCalls && details.toolCalls.length > 0) {
const all = details.toolCalls; const all = details.toolCalls;
@@ -742,16 +780,27 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
// Wraps pi.sendMessage() for posting status to the chat history. // Wraps pi.sendMessage() for posting status to the chat history.
// Uses "ralpi-progress" customType with a "progress" phase so the // Uses "ralpi-progress" customType with a "progress" phase so the
// renderer omits the label prefix entirely (no [INFO] etc.). // renderer omits the label prefix entirely (no [INFO] etc.).
// Accepts an optional meta object with toolCalls for the expandable view. // Accepts an optional meta object with toolCalls for the expandable view,
// and reviewText/reviewPath for review messages so the expanded
// (Ctrl+O) view can render the full review body without truncation.
const sendProgress: SendChatMessage = ( const sendProgress: SendChatMessage = (
content: string, content: string,
meta?: { toolCalls?: Array<{ name: string; label: string }> }, meta?: {
toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
},
) => { ) => {
pi.sendMessage({ pi.sendMessage({
customType: "ralpi-progress", customType: "ralpi-progress",
content, content,
display: true, display: true,
details: { phase: "progress", toolCalls: meta?.toolCalls }, details: {
phase: "progress",
toolCalls: meta?.toolCalls,
reviewText: meta?.reviewText,
reviewPath: meta?.reviewPath,
},
}); });
}; };
@@ -895,9 +944,13 @@ async function handleRun(
const completed = buildCompletedSet(progress, project); const completed = buildCompletedSet(progress, project);
const mode = await selectExecutionMode(ctx, project, taskFile, config); const mode = await selectExecutionMode(ctx, project, taskFile, config);
const { autoCommit, autoReview } = await selectLoopOptions(ctx, config); const { autoCommit, autoReview, saveReviews } = await selectLoopOptions(
ctx,
config,
);
config.execution.autoCommit = autoCommit; config.execution.autoCommit = autoCommit;
config.execution.autoReview = autoReview; config.execution.autoReview = autoReview;
config.execution.saveReviews = saveReviews;
const plan = buildPlanByMode(mode, project, completed); const plan = buildPlanByMode(mode, project, completed);
// Show dependency chain + execution plan before starting // Show dependency chain + execution plan before starting
@@ -1004,9 +1057,13 @@ async function handleResume(
const completed = buildCompletedSet(progress, project); const completed = buildCompletedSet(progress, project);
const mode = await selectExecutionMode(ctx, project, taskFile, config); const mode = await selectExecutionMode(ctx, project, taskFile, config);
const { autoCommit, autoReview } = await selectLoopOptions(ctx, config); const { autoCommit, autoReview, saveReviews } = await selectLoopOptions(
ctx,
config,
);
config.execution.autoCommit = autoCommit; config.execution.autoCommit = autoCommit;
config.execution.autoReview = autoReview; config.execution.autoReview = autoReview;
config.execution.saveReviews = saveReviews;
const plan = buildPlanByMode(mode, project, completed); const plan = buildPlanByMode(mode, project, completed);
// Print remaining batches before executing // Print remaining batches before executing

View File

@@ -27,7 +27,14 @@ import { updateTaskInFile } from "./parser";
export type SendChatMessage = ( export type SendChatMessage = (
content: string, content: string,
/** Extra data passed to the message renderer for the expanded view. */ /** Extra data passed to the message renderer for the expanded view. */
meta?: { toolCalls?: ToolCallEntry[] }, meta?: {
toolCalls?: ToolCallEntry[];
/** Full review body for review messages — renderer shows it in the
* expanded (Ctrl+O) view so long reviews aren't lost to truncation. */
reviewText?: string;
/** Saved file path when the review has been persisted to disk. */
reviewPath?: string;
},
) => void; ) => void;
export interface ToolCallEntry { export interface ToolCallEntry {
@@ -830,14 +837,34 @@ async function executeTask(
if (reviewResult.success) { if (reviewResult.success) {
const reviewText = reviewResult.text.trim(); const reviewText = reviewResult.text.trim();
// Post review as a chat message with tool calls
const preview = // Persist the full review to disk when opted in at loop
reviewText.length > 500 // start. Mirrors the reflections layout so a repo can
? reviewText.slice(0, 500) + "\n... (truncated)" // hold many loops without collisions:
: reviewText; // .ralpi/reviews/<prdKey>/<taskId>.md
let reviewPath: string | undefined;
if (config.execution.saveReviews) {
reviewPath = saveReviewToFile(
projectDir,
config,
task.id,
reviewText,
progress.getKey(),
);
}
// Post review as a chat message. The full body is
// passed via meta.reviewText so the expanded (Ctrl+O)
// view can render it without truncation; the collapsed
// content shows a short tail + a hint to expand.
const lines = reviewText.split("\n").filter((l) => l.trim());
const tail = lines.slice(-3).join("\n");
const savedHint = reviewPath
? ` \u00b7 saved to ${reviewPath}`
: "";
sendChatMessage?.( sendChatMessage?.(
`⚑ review for ${task.id} · ${task.title}\n${preview}`, `⚑ review for ${task.id} · ${task.title}${savedHint}\n${tail}`,
{ toolCalls: reviewToolCalls }, { toolCalls: reviewToolCalls, reviewText, reviewPath },
); );
} else { } else {
sendChatMessage?.( sendChatMessage?.(
@@ -960,6 +987,24 @@ function saveReflectionToFile(
writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
} }
// ─── Save Review Output to File ─────────────────────────────────────────────
// Mirrors saveReflectionToFile's per-loop layout so a repo can hold many
// loops without collisions: .ralpi/reviews/<prdKey>/<taskId>.md
function saveReviewToFile(
sourceDir: string,
config: RalpiConfig,
taskId: string,
reviewText: string,
prdKey: string,
): string {
const reviewsDir = path.join(sourceDir, config.paths.reviewsDir, prdKey);
ensureDir(reviewsDir);
const filePath = path.join(reviewsDir, `${taskId}.md`);
writeFileSafe(filePath, reviewText);
return filePath;
}
// ─── Follow-Up Sessions (Commit / Review) ───────────────────────────────────── // ─── Follow-Up Sessions (Commit / Review) ─────────────────────────────────────
/** /**

View File

@@ -164,6 +164,8 @@ export interface RalpiConfig {
stateDir: string; stateDir: string;
/** Directory for per-task reflections */ /** Directory for per-task reflections */
reflectionsDir: string; reflectionsDir: string;
/** Directory for per-loop review output (mirrors reflectionsDir) */
reviewsDir: string;
}; };
execution: { execution: {
/** Task execution timeout in milliseconds */ /** Task execution timeout in milliseconds */
@@ -176,6 +178,9 @@ export interface RalpiConfig {
autoCommit: boolean; autoCommit: boolean;
/** Spawn a review agent to review the commit against the task description */ /** Spawn a review agent to review the commit against the task description */
autoReview: boolean; autoReview: boolean;
/** Persist the full review output to `.ralpi/reviews/<task-id>.md`.
* Only active when autoReview is true and the user opts in at loop start. */
saveReviews: boolean;
/** Keys under `execution:` explicitly present in a loaded config YAML. /** Keys under `execution:` explicitly present in a loaded config YAML.
* Used to skip interactive prompts for fields the user already set. */ * Used to skip interactive prompts for fields the user already set. */
explicitKeys?: Set<string>; explicitKeys?: Set<string>;
@@ -208,6 +213,7 @@ export const DEFAULT_CONFIG: RalpiConfig = {
paths: { paths: {
stateDir: ".ralpi", stateDir: ".ralpi",
reflectionsDir: ".ralpi/reflections", reflectionsDir: ".ralpi/reflections",
reviewsDir: ".ralpi/reviews",
}, },
execution: { execution: {
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
@@ -215,6 +221,7 @@ export const DEFAULT_CONFIG: RalpiConfig = {
models: [], models: [],
autoCommit: true, autoCommit: true,
autoReview: false, autoReview: false,
saveReviews: false,
commitModel: "", commitModel: "",
reviewModel: "", reviewModel: "",
implModel: "", implModel: "",