Merge pull request #156 from amDosion/feat/ultraplan-enablement

feat: enable /ultraplan and harden GrowthBook fallback chain
This commit is contained in:
Dosion
2026-04-06 22:09:28 +08:00
committed by GitHub
parent 35bc4f395d
commit 33949ce5a2
12 changed files with 2849 additions and 3156 deletions

View File

@@ -0,0 +1,244 @@
import * as React from 'react';
import { join } from 'path';
import { writeFile } from 'fs/promises';
import figures from 'figures';
import { Box, Text, useInput, wrapText } from '../../ink.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Select } from '../CustomSelect/select.js';
import { PermissionDialog } from '../permissions/PermissionDialog.js';
import { useSetAppState } from '../../state/AppState.js';
import type { AppState } from '../../state/AppStateStore.js';
import type { Message } from '../../types/message.js';
import { getSessionId } from '../../bootstrap/state.js';
import { clearConversation } from '../../commands/clear/conversation.js';
import { createCommandInputMessage } from '../../utils/messages.js';
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js';
import { updateTaskState } from '../../utils/task/framework.js';
import { archiveRemoteSession } from '../../utils/teleport.js';
import { getCwd } from '../../utils/cwd.js';
import { toRelativePath } from '../../utils/path.js';
import type { UUID } from '../../utils/uuid.js';
import type { FileStateCache } from '../../utils/fileStateCache.js';
/** Maximum visible lines for the plan preview. */
const MAX_VISIBLE_LINES = 24;
/** Lines reserved for chrome around the preview (title bar, options, etc.). */
const CHROME_LINES = 11;
type ChoiceValue = 'here' | 'fresh' | 'cancel';
interface UltraplanChoiceDialogProps {
plan: string;
sessionId: string;
taskId: string;
setMessages: (updater: (prev: Message[]) => Message[]) => void;
readFileState: FileStateCache;
memorySelector?: unknown;
getAppState: () => AppState;
setConversationId?: (id: UUID) => void;
resultDedupState?: unknown;
}
function getDateStamp(): string {
return new Date().toISOString().split('T')[0]!;
}
/**
* Attempt to persist the current transcript before clearing.
* Returns true on success, false on failure (non-fatal).
*/
async function trySaveTranscript(): Promise<boolean> {
try {
// In the official CLI this shares/persists the transcript file.
// Our codebase stubs analytics, so this is a best-effort no-op.
return true;
} catch {
return false;
}
}
export function UltraplanChoiceDialog({
plan,
sessionId,
taskId,
setMessages,
readFileState,
memorySelector,
getAppState,
setConversationId,
resultDedupState,
}: UltraplanChoiceDialogProps): React.ReactNode {
const setAppState = useSetAppState();
const { rows, columns } = useTerminalSize();
// ── Compute visible lines ──────────────────────────────────────────
const visibleHeight = Math.min(MAX_VISIBLE_LINES, Math.max(1, Math.floor(rows / 2) - CHROME_LINES));
const wrappedLines = React.useMemo(
() => wrapText(plan, Math.max(1, columns - 4), 'wrap').split('\n'),
[plan, columns],
);
const maxScroll = Math.max(0, wrappedLines.length - visibleHeight);
const [scrollOffset, setScrollOffset] = React.useState(0);
// Clamp scroll when maxScroll shrinks (e.g. terminal resize).
React.useEffect(() => {
setScrollOffset(prev => Math.min(prev, maxScroll));
}, [maxScroll]);
const isScrollable = wrappedLines.length > visibleHeight;
// ── Scroll input handler ───────────────────────────────────────────
useInput((input, key) => {
if (!isScrollable) return;
const halfPage = Math.max(1, Math.floor(visibleHeight / 2));
if ((key.ctrl && input === 'd') || (key as any).wheelDown) {
const step = (key as any).wheelDown ? 3 : halfPage;
setScrollOffset(prev => Math.min(prev + step, maxScroll));
} else if ((key.ctrl && input === 'u') || (key as any).wheelUp) {
const step = (key as any).wheelUp ? 3 : halfPage;
setScrollOffset(prev => Math.max(prev - step, 0));
}
});
// ── Visible slice ──────────────────────────────────────────────────
const visibleText = wrappedLines.slice(scrollOffset, scrollOffset + visibleHeight).join('\n');
const canScrollUp = scrollOffset > 0;
const canScrollDown = scrollOffset < maxScroll;
// ── Choice handler ─────────────────────────────────────────────────
const handleChoice = React.useCallback(
async (choice: ChoiceValue) => {
switch (choice) {
case 'here': {
enqueuePendingNotification({
value: [
'Ultraplan approved in browser. Here is the plan:',
'',
'<ultraplan>',
plan,
'</ultraplan>',
'',
'The user approved this plan in the remote session. Give them a brief summary, then start implementing.',
].join('\n'),
mode: 'task-notification',
});
break;
}
case 'fresh': {
const previousSessionId = getSessionId();
const transcriptSaved = await trySaveTranscript();
await clearConversation({
setMessages,
readFileState,
getAppState,
setAppState,
setConversationId,
});
if (transcriptSaved) {
setMessages(prev => [
...prev,
createCommandInputMessage(`Previous session saved · resume with: claude --resume ${previousSessionId}`),
]);
}
enqueuePendingNotification({
value: `Here is the approved implementation plan:\n\n${plan}\n\nImplement this plan.`,
mode: 'prompt',
});
break;
}
case 'cancel': {
const savePath = join(getCwd(), `${getDateStamp()}-ultraplan.md`);
await writeFile(savePath, plan, { encoding: 'utf-8' });
setMessages(prev => [
...prev,
createCommandInputMessage(`Ultraplan rejected · Plan saved to ${toRelativePath(savePath)}`),
]);
break;
}
}
// Mark the remote task as completed.
updateTaskState(taskId, setAppState, task =>
task.status !== 'running' ? task : { ...task, status: 'completed', endTime: Date.now() },
);
// Clear the pending-choice state so the dialog unmounts.
setAppState(prev =>
prev.ultraplanPendingChoice
? { ...prev, ultraplanPendingChoice: undefined, ultraplanSessionUrl: undefined }
: prev,
);
// Archive the remote CCR session.
archiveRemoteSession(sessionId);
},
[
plan,
sessionId,
taskId,
setMessages,
readFileState,
memorySelector,
getAppState,
setAppState,
setConversationId,
resultDedupState,
],
);
// ── Menu options ───────────────────────────────────────────────────
const options: Array<{ label: string; value: ChoiceValue; description: string }> = React.useMemo(
() => [
{
label: 'Implement here',
value: 'here' as const,
description: 'Inject plan into the current conversation',
},
{
label: 'Start new session',
value: 'fresh' as const,
description: 'Clear conversation and start with only the plan',
},
{
label: 'Cancel',
value: 'cancel' as const,
description: "Don't implement — save plan and return",
},
],
[],
);
// ── Render ─────────────────────────────────────────────────────────
return (
<PermissionDialog title="Ultraplan approved" subtitle="How should the plan be implemented?">
<Box flexDirection="column" marginBottom={1}>
{/* Plan preview */}
<Box flexDirection="column" marginBottom={1}>
<Text>{visibleText}</Text>
{isScrollable && (
<Text dimColor>
{canScrollUp ? figures.arrowUp : ' '}
{canScrollDown ? figures.arrowDown : ' '} {scrollOffset + 1}
{Math.min(scrollOffset + visibleHeight, wrappedLines.length)}
{' of '}
{wrappedLines.length}
{' · ctrl+u/ctrl+d to scroll'}
</Text>
)}
</Box>
{/* Choice menu */}
<Select<ChoiceValue> options={options} onChange={value => void handleChoice(value)} />
</Box>
</PermissionDialog>
);
}

View File

@@ -0,0 +1,153 @@
import * as React from 'react';
import { Box, Text, Link } from '../../ink.js';
import { Select } from '../CustomSelect/select.js';
import { PermissionDialog } from '../permissions/PermissionDialog.js';
import { useAppState, useSetAppState } from '../../state/AppState.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { CCR_TERMS_URL } from '../../commands/ultraplan.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ChoiceValue = 'run' | 'cancel';
interface UltraplanLaunchDialogProps {
onChoice: (
choice: ChoiceValue,
opts: {
disconnectedBridge?: boolean;
promptIdentifier?: string;
},
) => void;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Generates a unique prompt identifier for this launch.
* In the official build this comes from a GrowthBook-gated helper (`Zc8`);
* we use `crypto.randomUUID()` as a drop-in replacement.
*/
function generatePromptIdentifier(): string {
return crypto.randomUUID();
}
/**
* Returns dialog copy for the ultraplan launch dialog.
* The official build resolves this from a GrowthBook feature gate (`Gc8`);
* we return reasonable defaults.
*/
function getUltraplanLaunchConfig(_identifier: string) {
return {
dialogBody:
'Ultraplan sends your task to Claude Code on the web for deep exploration. ' +
'Claude will research, draft a detailed plan, and return it here for your review ' +
'before any code is changed.',
dialogPipeline: 'Your prompt → Claude Code on the web → Plan review → Implementation',
timeEstimate: '~1030 min',
};
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function UltraplanLaunchDialog({ onChoice }: UltraplanLaunchDialogProps): React.ReactNode {
// Whether the user has never seen the ultraplan terms before
const [showTermsLink] = React.useState(() => !getGlobalConfig().hasSeenUltraplanTerms);
// Stable prompt identifier for this dialog instance
const [promptIdentifier] = React.useState(() => generatePromptIdentifier());
// Dialog copy derived from the prompt identifier
const config = React.useMemo(() => getUltraplanLaunchConfig(promptIdentifier), [promptIdentifier]);
// Whether the remote-control bridge is currently active
const isBridgeEnabled = useAppState(state => state.replBridgeEnabled);
const setAppState = useSetAppState();
// ------------------------------------------------------------------
// Choice handler
// ------------------------------------------------------------------
const handleChoice = React.useCallback(
(value: ChoiceValue) => {
// If the user chose "run" while the bridge is enabled, disconnect it
// first so the ultraplan session doesn't collide with remote control.
const disconnectedBridge = value === 'run' && isBridgeEnabled;
if (disconnectedBridge) {
setAppState(prev => {
if (!prev.replBridgeEnabled) return prev;
return {
...prev,
replBridgeEnabled: false,
replBridgeExplicit: false,
replBridgeOutboundOnly: false,
};
});
}
// Persist that the user has now seen the ultraplan terms
if (value !== 'cancel' && showTermsLink) {
saveGlobalConfig(prev => (prev.hasSeenUltraplanTerms ? prev : { ...prev, hasSeenUltraplanTerms: true }));
}
onChoice(value, { disconnectedBridge, promptIdentifier });
},
[onChoice, promptIdentifier, isBridgeEnabled, setAppState, showTermsLink],
);
// ------------------------------------------------------------------
// Menu options
// ------------------------------------------------------------------
const runDescription = isBridgeEnabled
? 'Disable remote control and launch in Claude Code on the web'
: 'launch in Claude Code on the web';
const options = React.useMemo(
() => [
{
label: 'Run ultraplan',
value: 'run' as const,
description: runDescription,
},
{ label: 'Not now', value: 'cancel' as const },
],
[runDescription],
);
// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------
return (
<PermissionDialog title="Run ultraplan in the cloud?" subtitle={config.timeEstimate}>
<Box flexDirection="column" gap={1}>
{/* Body + optional warnings */}
<Box flexDirection="column">
<Text dimColor>{config.dialogBody}</Text>
{isBridgeEnabled && <Text dimColor>This will disable Remote Control for this session.</Text>}
{showTermsLink && (
<Text dimColor>
For more information on Claude Code on the web: <Link url={CCR_TERMS_URL}>{CCR_TERMS_URL}</Link>
</Text>
)}
</Box>
{/* Pipeline description (hidden when bridge will be disconnected) */}
{!isBridgeEnabled && <Text dimColor>{config.dialogPipeline}</Text>}
{/* Action menu */}
<Select options={options} onChange={handleChoice} />
</Box>
</PermissionDialog>
);
}
export default UltraplanLaunchDialog;