feat: add plan viewer with browser preview (LET-7645) (#1089)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
65
src/web/generate-plan-viewer.ts
Normal file
65
src/web/generate-plan-viewer.ts
Normal 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
5
src/web/html.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
351
src/web/plan-viewer-template.txt
Normal file
351
src/web/plan-viewer-template.txt
Normal file
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user