feat: add plan viewer with browser preview (LET-7645) (#1089)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-21 13:39:22 -08:00
committed by GitHub
parent 5ad7094a26
commit 5d8a832c00
7 changed files with 502 additions and 3 deletions

View File

@@ -11791,6 +11791,18 @@ Plan file path: ${planFilePath}`;
"project")
}
showPreview={showApprovalPreview}
planContent={
currentApproval.toolName === "ExitPlanMode"
? _readPlanFile()
: undefined
}
planFilePath={
currentApproval.toolName === "ExitPlanMode"
? (permissionMode.getPlanFilePath() ??
undefined)
: undefined
}
agentName={agentName ?? undefined}
/>
) : ln.kind === "user" ? (
<UserMessage line={ln} prompt={statusLine.prompt} />
@@ -11875,6 +11887,17 @@ Plan file path: ${planFilePath}`;
: (currentApprovalContext?.defaultScope ?? "project")
}
showPreview={showApprovalPreview}
planContent={
currentApproval.toolName === "ExitPlanMode"
? _readPlanFile()
: undefined
}
planFilePath={
currentApproval.toolName === "ExitPlanMode"
? (permissionMode.getPlanFilePath() ?? undefined)
: undefined
}
agentName={agentName ?? undefined}
/>
</Box>
)}

View File

@@ -84,6 +84,11 @@ type Props = {
// External data for FileEdit approvals
precomputedDiff?: AdvancedDiffSuccess;
allDiffs?: Map<string, AdvancedDiffSuccess>;
// Plan viewer data (for ExitPlanMode 'o' key)
planContent?: string;
planFilePath?: string;
agentName?: string;
};
// Parse bash info from approval args
@@ -217,6 +222,9 @@ export const ApprovalSwitch = memo(
allDiffs,
showPreview = true,
defaultScope = "project",
planContent,
planFilePath,
agentName,
}: Props) => {
const toolName = approval.toolName;
@@ -229,6 +237,9 @@ export const ApprovalSwitch = memo(
onKeepPlanning={onPlanKeepPlanning}
onCancel={onCancel ?? (() => {})}
isFocused={isFocused}
planContent={planContent}
planFilePath={planFilePath}
agentName={agentName}
/>
);
}

View File

@@ -1,5 +1,6 @@
import { Box, useInput } from "ink";
import { memo, useState } from "react";
import { memo, useCallback, useState } from "react";
import { generateAndOpenPlanViewer } from "../../web/generate-plan-viewer";
import { useProgressIndicator } from "../hooks/useProgressIndicator";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { useTextInputCursor } from "../hooks/useTextInputCursor";
@@ -12,6 +13,9 @@ type Props = {
onKeepPlanning: (reason: string) => void;
onCancel: () => void; // For CTRL-C to queue denial (like other approval screens)
isFocused?: boolean;
planContent?: string;
planFilePath?: string;
agentName?: string;
};
/**
@@ -32,8 +36,12 @@ export const StaticPlanApproval = memo(
onKeepPlanning,
onCancel,
isFocused = true,
planContent,
planFilePath,
agentName,
}: Props) => {
const [selectedOption, setSelectedOption] = useState(0);
const [browserStatus, setBrowserStatus] = useState("");
const {
text: customReason,
cursorPos,
@@ -43,6 +51,24 @@ export const StaticPlanApproval = memo(
const columns = useTerminalWidth();
useProgressIndicator();
const openInBrowser = useCallback(() => {
if (!planContent || !planFilePath) return;
setBrowserStatus("Opening in browser...");
generateAndOpenPlanViewer(planContent, planFilePath, { agentName })
.then((result) => {
setBrowserStatus(
result.opened
? "Opened in browser"
: `Run: open ${result.filePath}`,
);
setTimeout(() => setBrowserStatus(""), 5000);
})
.catch(() => {
setBrowserStatus("Failed to open browser");
setTimeout(() => setBrowserStatus(""), 5000);
});
}, [planContent, planFilePath, agentName]);
const customOptionIndex = 2;
const maxOptionIndex = customOptionIndex;
const isOnCustomOption = selectedOption === customOptionIndex;
@@ -59,6 +85,16 @@ export const StaticPlanApproval = memo(
return;
}
// O: open plan in browser (only when not typing in custom field)
if (
(input === "o" || input === "O") &&
!isOnCustomOption &&
planContent
) {
openInBrowser();
return;
}
// Arrow navigation always works
if (key.upArrow) {
setSelectedOption((prev) => Math.max(0, prev - 1));
@@ -117,11 +153,12 @@ export const StaticPlanApproval = memo(
);
// Hint text based on state
const browserHint = planContent ? " · O open in browser" : "";
const hintText = isOnCustomOption
? customReason
? "Enter to submit · Esc to clear"
: "Type feedback · Esc to cancel"
: "Enter to select · Esc to cancel";
: `Enter to select${browserHint} · Esc to cancel`;
return (
<Box flexDirection="column">
@@ -206,7 +243,7 @@ export const StaticPlanApproval = memo(
{/* Hint */}
<Box marginTop={1}>
<Text dimColor>{hintText}</Text>
<Text dimColor>{browserStatus || hintText}</Text>
</Box>
</Box>
);

View File

@@ -0,0 +1,65 @@
/**
* Plan Viewer Generator
*
* Creates a self-contained HTML file that renders a plan's markdown content
* in the browser, reusing the Memory Palace's visual language. Writes to
* ~/.letta/viewers/ and opens in the default browser.
*/
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import planViewerTemplate from "./plan-viewer-template.txt";
import type { PlanViewerData } from "./types";
const VIEWERS_DIR = join(homedir(), ".letta", "viewers");
export interface GeneratePlanResult {
filePath: string;
opened: boolean;
}
export async function generateAndOpenPlanViewer(
planContent: string,
planFilePath: string,
options?: { agentName?: string },
): Promise<GeneratePlanResult> {
const data: PlanViewerData = {
agent: { name: options?.agentName ?? "" },
planContent,
planFilePath,
generatedAt: new Date().toISOString(),
};
// Safely embed JSON - escape < to \u003c to prevent </script> injection
const jsonPayload = JSON.stringify(data).replace(/</g, "\\u003c");
const html = planViewerTemplate.replace(
"<!--LETTA_PLAN_DATA_PLACEHOLDER-->",
() => jsonPayload,
);
// Write to ~/.letta/viewers/ with owner-only permissions
if (!existsSync(VIEWERS_DIR)) {
mkdirSync(VIEWERS_DIR, { recursive: true, mode: 0o700 });
}
try {
chmodSync(VIEWERS_DIR, 0o700);
} catch {}
const filePath = join(VIEWERS_DIR, "plan.html");
writeFileSync(filePath, html);
chmodSync(filePath, 0o600);
// Open in browser (skip inside tmux)
const isTmux = Boolean(process.env.TMUX);
if (!isTmux) {
try {
const { default: openUrl } = await import("open");
await openUrl(filePath, { wait: false });
} catch {
throw new Error(`Could not open browser. Run: open ${filePath}`);
}
}
return { filePath, opened: !isTmux };
}

5
src/web/html.d.ts vendored
View File

@@ -2,3 +2,8 @@ declare module "*memory-viewer-template.txt" {
const content: string;
export default content;
}
declare module "*plan-viewer-template.txt" {
const content: string;
export default content;
}

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,13 @@ export interface MemoryFile {
content: string; // raw markdown body (after frontmatter)
}
export interface PlanViewerData {
agent: { name: string };
planContent: string;
planFilePath: string;
generatedAt: string; // ISO 8601 timestamp
}
export interface MemoryCommit {
hash: string;
shortHash: string;