mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
Add Ultraplan Feature for Advanced Multi-Agent Planning (#232)
* feat: add ultraplan feature for advanced multi-agent planning Implement ultraplan command with web-based planning interface, supporting multiple prompt modes and interactive plan approval. * chore: add semi * chore: add semi
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { join } from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { stat, writeFile } from 'fs/promises';
|
||||
import figures from 'figures';
|
||||
import { Box, Text, useInput, wrapText } from '@anthropic/ink';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Select } from '../CustomSelect/select.js';
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js';
|
||||
import { Dialog } from '../design-system/Dialog.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 { createSystemMessage } from '../../utils/messages.js';
|
||||
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js';
|
||||
import { updateTaskState } from '../../utils/task/framework.js';
|
||||
import { archiveRemoteSession } from '../../utils/teleport.js';
|
||||
@@ -19,6 +19,8 @@ import { getCwd } from '../../utils/cwd.js';
|
||||
import { toRelativePath } from '../../utils/path.js';
|
||||
import type { UUID } from 'crypto';
|
||||
import type { FileStateCache } from '../../utils/fileStateCache.js';
|
||||
import { getTranscriptPath } from 'src/utils/sessionStorage.js';
|
||||
import { useRegisterOverlay } from 'src/context/overlayContext.js';
|
||||
|
||||
/** Maximum visible lines for the plan preview. */
|
||||
const MAX_VISIBLE_LINES = 24;
|
||||
@@ -43,49 +45,40 @@ 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,
|
||||
memorySelector: _memorySelector,
|
||||
getAppState,
|
||||
setConversationId,
|
||||
resultDedupState,
|
||||
resultDedupState: _resultDedupState,
|
||||
}: UltraplanChoiceDialogProps): React.ReactNode {
|
||||
useRegisterOverlay('ultraplan-choice')
|
||||
|
||||
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 visibleHeight = React.useMemo(
|
||||
() => Math.min(MAX_VISIBLE_LINES, Math.max(1, Math.floor(rows / 2) - CHROME_LINES)),
|
||||
[rows],
|
||||
)
|
||||
|
||||
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 maxOffset = Math.max(0, wrappedLines.length - visibleHeight);
|
||||
const [scrollOffset, setScrollOffset] = React.useState(0);
|
||||
|
||||
// Clamp scroll when maxScroll shrinks (e.g. terminal resize).
|
||||
// Clamp scroll when maxOffset shrinks (e.g. terminal resize).
|
||||
React.useEffect(() => {
|
||||
setScrollOffset(prev => Math.min(prev, maxScroll));
|
||||
}, [maxScroll]);
|
||||
setScrollOffset(prev => Math.min(prev, maxOffset));
|
||||
}, [maxOffset]);
|
||||
|
||||
const isScrollable = wrappedLines.length > visibleHeight;
|
||||
|
||||
@@ -96,7 +89,7 @@ export function UltraplanChoiceDialog({
|
||||
|
||||
if ((key.ctrl && input === 'd') || (key as any).wheelDown) {
|
||||
const step = (key as any).wheelDown ? 3 : halfPage;
|
||||
setScrollOffset(prev => Math.min(prev + step, maxScroll));
|
||||
setScrollOffset(prev => Math.min(prev + step, maxOffset));
|
||||
} 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));
|
||||
@@ -107,13 +100,13 @@ export function UltraplanChoiceDialog({
|
||||
const visibleText = wrappedLines.slice(scrollOffset, scrollOffset + visibleHeight).join('\n');
|
||||
|
||||
const canScrollUp = scrollOffset > 0;
|
||||
const canScrollDown = scrollOffset < maxScroll;
|
||||
const canScrollDown = scrollOffset < maxOffset;
|
||||
|
||||
// ── Choice handler ─────────────────────────────────────────────────
|
||||
const handleChoice = React.useCallback(
|
||||
async (choice: ChoiceValue) => {
|
||||
switch (choice) {
|
||||
case 'here': {
|
||||
case 'here':
|
||||
enqueuePendingNotification({
|
||||
value: [
|
||||
'Ultraplan approved in browser. Here is the plan:',
|
||||
@@ -127,11 +120,9 @@ export function UltraplanChoiceDialog({
|
||||
mode: 'task-notification',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'fresh': {
|
||||
case 'fresh':
|
||||
const previousSessionId = getSessionId();
|
||||
const transcriptSaved = await trySaveTranscript();
|
||||
const transcriptSaved = await stat(getTranscriptPath()).then(() => true, () => false)
|
||||
|
||||
await clearConversation({
|
||||
setMessages,
|
||||
@@ -144,7 +135,7 @@ export function UltraplanChoiceDialog({
|
||||
if (transcriptSaved) {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
createCommandInputMessage(`Previous session saved · resume with: claude --resume ${previousSessionId}`),
|
||||
createSystemMessage(`Previous session saved · resume with: claude --resume ${previousSessionId}`, 'suggestion'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -153,14 +144,12 @@ export function UltraplanChoiceDialog({
|
||||
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)}`),
|
||||
createSystemMessage(`Ultraplan rejected · Plan saved to ${toRelativePath(savePath)}`, 'suggestion'),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
@@ -186,12 +175,10 @@ export function UltraplanChoiceDialog({
|
||||
sessionId,
|
||||
taskId,
|
||||
setMessages,
|
||||
readFileState,
|
||||
memorySelector,
|
||||
getAppState,
|
||||
setAppState,
|
||||
readFileState,
|
||||
setConversationId,
|
||||
resultDedupState,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -219,7 +206,12 @@ export function UltraplanChoiceDialog({
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<PermissionDialog title="Ultraplan approved" subtitle="How should the plan be implemented?">
|
||||
<Dialog
|
||||
title="Ultraplan approved"
|
||||
subtitle="How should the plan be implemented?"
|
||||
onCancel={() => {}}
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{/* Plan preview */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
@@ -239,6 +231,6 @@ export function UltraplanChoiceDialog({
|
||||
{/* Choice menu */}
|
||||
<Select<ChoiceValue> options={options} onChange={value => void handleChoice(value)} />
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Text, Link } from '@anthropic/ink';
|
||||
import { Select } from '../CustomSelect/select.js';
|
||||
import { PermissionDialog } from '../permissions/PermissionDialog.js';
|
||||
import { Dialog } from '../design-system/Dialog.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { CCR_TERMS_URL } from '../../commands/ultraplan.js';
|
||||
import { getPromptIdentifier, getDialogConfig, type PromptIdentifier } from 'src/utils/ultraplan/prompt.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -16,54 +17,31 @@ interface UltraplanLaunchDialogProps {
|
||||
onChoice: (
|
||||
choice: ChoiceValue,
|
||||
opts: {
|
||||
disconnectedBridge?: boolean;
|
||||
promptIdentifier?: string;
|
||||
disconnectedBridge: boolean;
|
||||
promptIdentifier: PromptIdentifier;
|
||||
},
|
||||
) => 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();
|
||||
function dispatchShowTermsLink(){
|
||||
return !getGlobalConfig().hasSeenUltraplanTerms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: '~10–30 min',
|
||||
};
|
||||
function dispatchPromptIdentifier() {
|
||||
return getPromptIdentifier();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UltraplanLaunchDialog({ onChoice }: UltraplanLaunchDialogProps): React.ReactNode {
|
||||
// Whether the user has never seen the ultraplan terms before
|
||||
const [showTermsLink] = React.useState(() => !getGlobalConfig().hasSeenUltraplanTerms);
|
||||
const [showTermsLink] = React.useState(dispatchShowTermsLink);
|
||||
|
||||
// Stable prompt identifier for this dialog instance
|
||||
const [promptIdentifier] = React.useState(() => generatePromptIdentifier());
|
||||
const [promptIdentifier] = React.useState(dispatchPromptIdentifier);
|
||||
|
||||
// Dialog copy derived from the prompt identifier
|
||||
const config = React.useMemo(() => getUltraplanLaunchConfig(promptIdentifier), [promptIdentifier]);
|
||||
const dialogConfig = React.useMemo(() => {
|
||||
return getDialogConfig(promptIdentifier);
|
||||
}, [promptIdentifier])
|
||||
|
||||
// Whether the remote-control bridge is currently active
|
||||
const isBridgeEnabled = useAppState(state => state.replBridgeEnabled);
|
||||
@@ -74,15 +52,16 @@ export function UltraplanLaunchDialog({ onChoice }: UltraplanLaunchDialogProps):
|
||||
// Choice handler
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const handleChoice = React.useCallback(
|
||||
(value: ChoiceValue) => {
|
||||
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;
|
||||
setAppState((prev) => {
|
||||
if (!prev.replBridgeEnabled) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
replBridgeEnabled: false,
|
||||
@@ -97,56 +76,60 @@ export function UltraplanLaunchDialog({ onChoice }: UltraplanLaunchDialogProps):
|
||||
saveGlobalConfig(prev => (prev.hasSeenUltraplanTerms ? prev : { ...prev, hasSeenUltraplanTerms: true }));
|
||||
}
|
||||
|
||||
onChoice(value, { disconnectedBridge, promptIdentifier });
|
||||
onChoice(value, { disconnectedBridge, promptIdentifier});
|
||||
},
|
||||
[onChoice, promptIdentifier, isBridgeEnabled, setAppState, showTermsLink],
|
||||
);
|
||||
[onChoice, isBridgeEnabled, setAppState, showTermsLink],
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Menu options
|
||||
// ------------------------------------------------------------------
|
||||
const handleCancel = React.useCallback(() => {
|
||||
handleChoice('cancel')
|
||||
}, [handleChoice])
|
||||
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
const options = [
|
||||
{
|
||||
label: 'Run ultraplan',
|
||||
value: 'run' as const,
|
||||
description: runDescription,
|
||||
},
|
||||
{ label: 'Not now', value: 'cancel' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<PermissionDialog title="Run ultraplan in the cloud?" subtitle={config.timeEstimate}>
|
||||
<Dialog
|
||||
title="Run ultraplan in the cloud?"
|
||||
subtitle={dialogConfig.timeEstimate}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<Text dimColor>{dialogConfig.dialogBody}</Text>
|
||||
{
|
||||
showTermsLink
|
||||
? (
|
||||
<Text dimColor>
|
||||
For more information on Claude Code on the web:
|
||||
<Link url={CCR_TERMS_URL}>{CCR_TERMS_URL}</Link>
|
||||
</Text>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</Box>
|
||||
|
||||
{/* Pipeline description (hidden when bridge will be disconnected) */}
|
||||
{!isBridgeEnabled && <Text dimColor>{config.dialogPipeline}</Text>}
|
||||
<Text dimColor>
|
||||
{isBridgeEnabled ? 'This will disable Remote Control for this session.' : dialogConfig.dialogPipeline}
|
||||
</Text>
|
||||
|
||||
{/* Action menu */}
|
||||
<Select options={options} onChange={handleChoice} />
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleChoice}
|
||||
/>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user