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
+
+
+
+
+
+
+
+
+
+
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;