mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-24 09:05:50 +00:00
feat: /goal命令能力支持,参考codex实现 (#1261)
* feat: /goal命令能力支持,参考codex实现 * fix: 修复promp和提示词不一致的问题 * fix: 修复 goal 功能多项 AI 审查问题 - prompt 中 update 行为描述与运行时不一致(no-op → error) - src/commands/goal/ 使用相对路径导入,改为 src/* 别名 - /goal 命令标记 bridgeSafe 但含交互式对话框,改为 false - useGoalContinuation 中 origin 使用 as unknown as string 强转,改为直接传字符串 - ResumeConversation 路径缺少 goal hydration,补齐恢复逻辑 - onCancel 在非查询状态下误暂停 goal,加 queryGuard 守卫 - resumeGoal 允许从终态恢复,收紧为仅允许 paused 状态 - buildGoalContextBlock 生成畸形 XML 属性,改为合法 budget 属性 * fix: 修复剩余AI审查的问题 * fix: 防止goal状态丢失 * fix: 修复Biome规范错误问题 * fix: 修复部分情况下goal无法启动的问题 * fix: 增加断网后状态默认设置为PAUSE机制、完成暂停-恢复状态切换,且正常进行前端渲染。设置达到max turn后处理逻辑。 * fix: 修复终端异常断开情况,resume续跑;修复用户消息排队信息被goal输出信息覆盖的问题。 * fix: apply biome formatting to pass CI lint check Co-authored-by: Cursor <cursoragent@cursor.com> * fix: skip slash command echo in setUserInputOnProcessing to prevent UI flash Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: moyu <moyu@kingsoft.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
75
src/commands/goal/GoalReplaceConfirmDialog.tsx
Normal file
75
src/commands/goal/GoalReplaceConfirmDialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Confirmation dialog shown when the user runs `/goal <objective>`
|
||||
* while a non-complete goal is already active.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
import type { GoalState } from 'src/types/logs.js';
|
||||
import { Select } from 'src/components/CustomSelect/index.js';
|
||||
import { PermissionDialog } from 'src/components/permissions/PermissionDialog.js';
|
||||
import { formatGoalElapsed, formatGoalStatusLabel } from 'src/services/goal/goalState.js';
|
||||
|
||||
type Props = {
|
||||
currentGoal: GoalState;
|
||||
newObjective: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function GoalReplaceConfirmDialog({ currentGoal, newObjective, onConfirm, onCancel }: Props): React.ReactNode {
|
||||
function handleResponse(value: 'yes' | 'no'): void {
|
||||
if (value === 'yes') onConfirm();
|
||||
else onCancel();
|
||||
}
|
||||
|
||||
const tokensDisplay =
|
||||
currentGoal.tokenBudget !== null
|
||||
? `${currentGoal.tokensUsed} / ${currentGoal.tokenBudget}`
|
||||
: `${currentGoal.tokensUsed}`;
|
||||
|
||||
return (
|
||||
<PermissionDialog color="warning" title="Replace active goal?">
|
||||
<Box flexDirection="column" marginTop={1} paddingX={1}>
|
||||
<Text>A goal is already in progress. Replacing it will reset all progress and counters.</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>Current goal:</Text>
|
||||
<Text>
|
||||
<Text dimColor>· Objective: </Text>
|
||||
{currentGoal.objective}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>· Status: </Text>
|
||||
{formatGoalStatusLabel(currentGoal.status)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>· Time: </Text>
|
||||
{formatGoalElapsed(currentGoal)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>· Tokens: </Text>
|
||||
{tokensDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>New objective:</Text>
|
||||
<Text>{newObjective}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Yes, replace the goal', value: 'yes' as const },
|
||||
{ label: 'No, keep the current goal', value: 'no' as const },
|
||||
]}
|
||||
onChange={handleResponse}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
);
|
||||
}
|
||||
207
src/commands/goal/goal.tsx
Normal file
207
src/commands/goal/goal.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* `/goal` slash command — set, view, or control the persistent thread
|
||||
* goal that drives auto-continuation across turns.
|
||||
*
|
||||
* Subcommands
|
||||
* -----------
|
||||
* `/goal` -> show current status
|
||||
* `/goal status` -> alias of bare `/goal`
|
||||
* `/goal clear` -> remove the active goal (persists tombstone)
|
||||
* `/goal pause` -> pause auto-continuation
|
||||
* `/goal resume` -> resume from paused state
|
||||
* `/goal continue` -> reset turn counter after max-turns and continue
|
||||
* `/goal complete` -> mark complete (manual override; tools usually do this)
|
||||
* `/goal <objective>` -> set a new goal; if one is already active and not
|
||||
* complete, a confirmation dialog appears first.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
|
||||
import type { LocalJSXCommandContext } from 'src/commands.js';
|
||||
import {
|
||||
clearGoal,
|
||||
completeGoal,
|
||||
continueGoalFromMaxTurns,
|
||||
formatGoalElapsed,
|
||||
formatGoalStatusLabel,
|
||||
getGoal,
|
||||
incrementGoalTurns,
|
||||
MAX_GOAL_TURNS,
|
||||
pauseGoal,
|
||||
resumeGoal,
|
||||
setGoal,
|
||||
} from 'src/services/goal/goalState.js';
|
||||
import { persistCurrentGoal, persistGoalClear } from 'src/services/goal/goalStorage.js';
|
||||
import type { LocalJSXCommandOnDone } from 'src/types/command.js';
|
||||
import { removeByFilter } from 'src/utils/messageQueueManager.js';
|
||||
import { GoalReplaceConfirmDialog } from './GoalReplaceConfirmDialog.js';
|
||||
|
||||
const MAX_OBJECTIVE_CHARS = 4000;
|
||||
const MAX_DISPLAY_CHARS = 80;
|
||||
|
||||
function truncateForDisplay(objective: string): string {
|
||||
const firstLine = objective.split('\n')[0] ?? objective;
|
||||
if (firstLine.length <= MAX_DISPLAY_CHARS) return firstLine;
|
||||
return firstLine.slice(0, MAX_DISPLAY_CHARS) + '…';
|
||||
}
|
||||
|
||||
function drainGoalContinuationQueue(): void {
|
||||
removeByFilter(cmd => cmd.origin === 'goal-continuation' || cmd.origin === 'goal-budget-limit');
|
||||
}
|
||||
|
||||
function formatGoalStatus(): string {
|
||||
const goal = getGoal();
|
||||
if (!goal) {
|
||||
return 'No active goal. Set one with `/goal <objective>`.';
|
||||
}
|
||||
const tokens = goal.tokenBudget !== null ? `${goal.tokensUsed} / ${goal.tokenBudget}` : `${goal.tokensUsed}`;
|
||||
const lines = [
|
||||
`Goal: ${goal.objective}`,
|
||||
`Status: ${formatGoalStatusLabel(goal.status)}`,
|
||||
`Time: ${formatGoalElapsed(goal)}`,
|
||||
`Tokens: ${tokens}`,
|
||||
`Continuation turns: ${goal.turnsExecuted}`,
|
||||
];
|
||||
|
||||
if (goal.status === 'max_turns') {
|
||||
lines.push(
|
||||
`Hint: Max continuation turns reached (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset and continue.`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function applySetGoal(objective: string): string {
|
||||
setGoal(objective);
|
||||
incrementGoalTurns();
|
||||
persistCurrentGoal();
|
||||
return 'Goal set.';
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const trimmed = args.trim();
|
||||
|
||||
if (!trimmed || trimmed.toLowerCase() === 'status') {
|
||||
onDone(formatGoalStatus(), { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (lower === 'clear') {
|
||||
const cleared = clearGoal();
|
||||
if (cleared) {
|
||||
persistGoalClear();
|
||||
drainGoalContinuationQueue();
|
||||
}
|
||||
onDone(cleared ? 'Goal cleared.' : 'No active goal to clear.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lower === 'pause') {
|
||||
const g = pauseGoal();
|
||||
if (g) {
|
||||
persistCurrentGoal();
|
||||
drainGoalContinuationQueue();
|
||||
}
|
||||
onDone(g ? 'Goal paused.' : 'No active goal to pause.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lower === 'resume') {
|
||||
const current = getGoal();
|
||||
if (current?.status === 'max_turns') {
|
||||
onDone(
|
||||
`Goal reached max continuation turns (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset turn counter and continue.`,
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const g = resumeGoal();
|
||||
if (g) persistCurrentGoal();
|
||||
onDone(g ? 'Goal resumed.' : 'No paused goal to resume.', {
|
||||
display: 'system',
|
||||
shouldQuery: Boolean(g),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lower === 'continue') {
|
||||
const g = continueGoalFromMaxTurns();
|
||||
if (g) persistCurrentGoal();
|
||||
onDone(
|
||||
g
|
||||
? `Goal continuation counter reset (0/${MAX_GOAL_TURNS}). Continuing...`
|
||||
: 'Current goal is not in max-turns state.',
|
||||
{
|
||||
display: 'system',
|
||||
shouldQuery: Boolean(g),
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lower === 'complete') {
|
||||
const g = completeGoal();
|
||||
if (g) {
|
||||
persistCurrentGoal();
|
||||
drainGoalContinuationQueue();
|
||||
}
|
||||
onDone(g ? 'Goal marked complete.' : 'No active goal to complete.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_OBJECTIVE_CHARS) {
|
||||
onDone(
|
||||
`Goal objective is too long (${trimmed.length} chars; limit ${MAX_OBJECTIVE_CHARS}). Save the detailed instructions to a file and reference it from a shorter objective.`,
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = getGoal();
|
||||
const needsConfirmation = existing && existing.status !== 'complete';
|
||||
|
||||
if (!needsConfirmation) {
|
||||
const summary = applySetGoal(trimmed);
|
||||
onDone(summary, {
|
||||
display: 'system',
|
||||
shouldQuery: true,
|
||||
displayArgs: truncateForDisplay(trimmed),
|
||||
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GoalReplaceConfirmDialog
|
||||
currentGoal={existing}
|
||||
newObjective={trimmed}
|
||||
onConfirm={() => {
|
||||
drainGoalContinuationQueue();
|
||||
const summary = applySetGoal(trimmed);
|
||||
onDone(summary, {
|
||||
display: 'system',
|
||||
shouldQuery: true,
|
||||
displayArgs: truncateForDisplay(trimmed),
|
||||
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
onDone('Kept the current goal. New objective discarded.', {
|
||||
display: 'system',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
src/commands/goal/index.ts
Normal file
13
src/commands/goal/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Command } from 'src/commands.js'
|
||||
|
||||
const goal = {
|
||||
type: 'local-jsx',
|
||||
name: 'goal',
|
||||
description:
|
||||
'Set or view a persistent goal that drives auto-continuation across turns',
|
||||
argumentHint: '[<objective> | status | clear | pause | resume | complete]',
|
||||
bridgeSafe: false,
|
||||
load: () => import('./goal.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default goal
|
||||
Reference in New Issue
Block a user