From 5d8a832c00a6a1e5f91ec84a96154f25859c2ed6 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sat, 21 Feb 2026 13:39:22 -0800 Subject: [PATCH] feat: add plan viewer with browser preview (LET-7645) (#1089) Co-authored-by: Letta --- src/cli/App.tsx | 23 ++ src/cli/components/ApprovalSwitch.tsx | 11 + src/cli/components/StaticPlanApproval.tsx | 43 ++- src/web/generate-plan-viewer.ts | 65 ++++ src/web/html.d.ts | 5 + src/web/plan-viewer-template.txt | 351 ++++++++++++++++++++++ src/web/types.ts | 7 + 7 files changed, 502 insertions(+), 3 deletions(-) create mode 100644 src/web/generate-plan-viewer.ts create mode 100644 src/web/plan-viewer-template.txt diff --git a/src/cli/App.tsx b/src/cli/App.tsx index fa08d81..39cea8e 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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" ? ( @@ -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} /> )} diff --git a/src/cli/components/ApprovalSwitch.tsx b/src/cli/components/ApprovalSwitch.tsx index 576192b..7f14875 100644 --- a/src/cli/components/ApprovalSwitch.tsx +++ b/src/cli/components/ApprovalSwitch.tsx @@ -84,6 +84,11 @@ type Props = { // External data for FileEdit approvals precomputedDiff?: AdvancedDiffSuccess; allDiffs?: Map; + + // 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} /> ); } diff --git a/src/cli/components/StaticPlanApproval.tsx b/src/cli/components/StaticPlanApproval.tsx index 2010070..65f56b7 100644 --- a/src/cli/components/StaticPlanApproval.tsx +++ b/src/cli/components/StaticPlanApproval.tsx @@ -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 ( @@ -206,7 +243,7 @@ export const StaticPlanApproval = memo( {/* Hint */} - {hintText} + {browserStatus || hintText} ); diff --git a/src/web/generate-plan-viewer.ts b/src/web/generate-plan-viewer.ts new file mode 100644 index 0000000..cf44660 --- /dev/null +++ b/src/web/generate-plan-viewer.ts @@ -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 { + const data: PlanViewerData = { + agent: { name: options?.agentName ?? "" }, + planContent, + planFilePath, + generatedAt: new Date().toISOString(), + }; + + // Safely embed JSON - escape < to \u003c to prevent injection + const jsonPayload = JSON.stringify(data).replace(/", + () => 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 }; +} diff --git a/src/web/html.d.ts b/src/web/html.d.ts index 56302c2..1cdab9f 100644 --- a/src/web/html.d.ts +++ b/src/web/html.d.ts @@ -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; +} diff --git a/src/web/plan-viewer-template.txt b/src/web/plan-viewer-template.txt new file mode 100644 index 0000000..b7c9584 --- /dev/null +++ b/src/web/plan-viewer-template.txt @@ -0,0 +1,351 @@ + + + + + +Plan | Letta Code + + + +
+
+

Plan

+
+ +
+
+
+
+
+
+ +
+
+ +
+
+

+  
+ +
+
+ + + + + + diff --git a/src/web/types.ts b/src/web/types.ts index 88a42a4..1fb0b0e 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -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;