mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-16 13:25:51 +00:00
Compare commits
34 Commits
v2.0.2
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
056893ebb0 | ||
|
|
a5ca2c1a97 | ||
|
|
872ee280e3 | ||
|
|
f5c9880d7d | ||
|
|
3f1c8468bf | ||
|
|
100e9d2da0 | ||
|
|
0ad6349434 | ||
|
|
1ac18aec0d | ||
|
|
fcbc882232 | ||
|
|
a1108870e3 | ||
|
|
87b96199f9 | ||
|
|
18d6656a6a | ||
|
|
d0915fc880 | ||
|
|
cf2bf29dcd | ||
|
|
75952bde9c | ||
|
|
e7220c530f | ||
|
|
6ff839d625 | ||
|
|
88057b10d4 | ||
|
|
4d0048a60a | ||
|
|
8a5ef8c9cb | ||
|
|
f8a289b868 | ||
|
|
45c892fc18 | ||
|
|
5b333e2246 | ||
|
|
5e215bb061 | ||
|
|
b28de717dd | ||
|
|
5c1be19511 | ||
|
|
2545dcabfd | ||
|
|
40fbc4afc4 | ||
|
|
d3eebfed15 | ||
|
|
6becb8b2d4 | ||
|
|
3a2b6dde7c | ||
|
|
4ca7a4895a | ||
|
|
ba74e0976c | ||
|
|
86df024e75 |
@@ -1 +1 @@
|
||||
bunx lint-staged
|
||||
npx lint-staged
|
||||
|
||||
8
build.ts
8
build.ts
@@ -21,7 +21,13 @@ const result = await Bun.build({
|
||||
outdir,
|
||||
target: 'bun',
|
||||
splitting: true,
|
||||
define: getMacroDefines(),
|
||||
define: {
|
||||
...getMacroDefines(),
|
||||
// React production mode — eliminates _debugStack Error objects
|
||||
// (6,889 objects × ~1.7KB = 12MB in development builds) and removes
|
||||
// prop-type / key warnings not useful in a production CLI tool.
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
features,
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 2.2 MiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.0.2",
|
||||
"version": "2.1.0",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -131,8 +131,13 @@ type Props = {
|
||||
const MULTI_CLICK_TIMEOUT_MS = 500;
|
||||
const MULTI_CLICK_DISTANCE = 1;
|
||||
|
||||
type ErrorInfo = {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
readonly error?: Error;
|
||||
readonly error?: ErrorInfo;
|
||||
};
|
||||
|
||||
// Root component for all Ink apps
|
||||
@@ -142,7 +147,7 @@ export default class App extends PureComponent<Props, State> {
|
||||
static displayName = 'InternalApp';
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
return { error: { message: error.message, stack: error.stack } };
|
||||
}
|
||||
|
||||
override state = {
|
||||
@@ -221,7 +226,7 @@ export default class App extends PureComponent<Props, State> {
|
||||
<TerminalFocusProvider>
|
||||
<ClockProvider>
|
||||
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
||||
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
|
||||
{this.state.error ? <ErrorOverview error={this.state.error} /> : this.props.children}
|
||||
</CursorDeclarationContext.Provider>
|
||||
</ClockProvider>
|
||||
</TerminalFocusProvider>
|
||||
|
||||
@@ -23,8 +23,13 @@ function getStackUtils(): StackUtils {
|
||||
|
||||
/* eslint-enable custom-rules/no-process-cwd */
|
||||
|
||||
type ErrorLike = {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
readonly error: Error;
|
||||
readonly error: ErrorLike;
|
||||
};
|
||||
|
||||
export default function ErrorOverview({ error }: Props) {
|
||||
|
||||
@@ -148,6 +148,12 @@ const baseInputSchema = lazySchema(() =>
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Set to true to run this agent in the background. You will be notified when it completes.'),
|
||||
fork: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set to true to fork from the parent conversation context. The child inherits full history, system prompt, and model. Requires FORK_SUBAGENT feature flag.',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -191,24 +197,23 @@ const fullInputSchema = lazySchema(() => {
|
||||
// type, but call() destructures via the explicit AgentToolInput type below
|
||||
// which always includes all optional fields.
|
||||
export const inputSchema = lazySchema(() => {
|
||||
const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
|
||||
|
||||
// GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which
|
||||
// was removed in 906da6c723): the divergence window is one-session-per-
|
||||
// gate-flip via _CACHED_MAY_BE_STALE disk read, and worst case is either
|
||||
// "schema shows a no-op param" (gate flips on mid-session: param ignored
|
||||
// by forceAsync) or "schema hides a param that would've worked" (gate
|
||||
// flips off mid-session: everything still runs async via memoized
|
||||
// forceAsync). No Zod rejection, no crash — unlike required→optional.
|
||||
return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema;
|
||||
const base = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
|
||||
return isBackgroundTasksDisabled
|
||||
? !isForkSubagentEnabled()
|
||||
? base.omit({ run_in_background: true, fork: true })
|
||||
: base.omit({ run_in_background: true })
|
||||
: !isForkSubagentEnabled()
|
||||
? base.omit({ fork: true })
|
||||
: base;
|
||||
});
|
||||
type InputSchema = ReturnType<typeof inputSchema>;
|
||||
|
||||
// Explicit type widens the schema inference to always include all optional
|
||||
// fields even when .omit() strips them for gating (cwd, run_in_background).
|
||||
// subagent_type is optional; call() defaults it to general-purpose when the
|
||||
// fork gate is off, or routes to the fork path when the gate is on.
|
||||
// subagent_type is optional; call() defaults it to general-purpose.
|
||||
// fork is gated by FORK_SUBAGENT flag; when omitted or flag is off, no fork.
|
||||
type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
|
||||
fork?: boolean;
|
||||
name?: string;
|
||||
team_name?: string;
|
||||
mode?: z.infer<ReturnType<typeof permissionModeSchema>>;
|
||||
@@ -322,6 +327,7 @@ export const AgentTool = buildTool({
|
||||
{
|
||||
prompt,
|
||||
subagent_type,
|
||||
fork,
|
||||
description,
|
||||
model: modelParam,
|
||||
run_in_background,
|
||||
@@ -406,12 +412,11 @@ export const AgentTool = buildTool({
|
||||
return { data: spawnResult } as unknown as { data: Output };
|
||||
}
|
||||
|
||||
// Fork subagent experiment routing:
|
||||
// - subagent_type set: use it (explicit wins)
|
||||
// - subagent_type omitted, gate on: fork path (undefined)
|
||||
// - subagent_type omitted, gate off: default general-purpose
|
||||
const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType);
|
||||
const isForkPath = effectiveType === undefined;
|
||||
// Fork routing: explicit `fork: true` parameter triggers the fork path
|
||||
// (inherits parent context and model). Requires FORK_SUBAGENT flag.
|
||||
// subagent_type is ignored when fork takes effect.
|
||||
const isForkPath = fork === true && isForkSubagentEnabled();
|
||||
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
|
||||
|
||||
let selectedAgent: AgentDefinition;
|
||||
if (isForkPath) {
|
||||
@@ -692,10 +697,6 @@ export const AgentTool = buildTool({
|
||||
// dependency issues during test module loading.
|
||||
const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false;
|
||||
|
||||
// Fork subagent experiment: force ALL spawns async for a unified
|
||||
// <task-notification> interaction model (not just fork spawns — all of them).
|
||||
const forceAsync = isForkSubagentEnabled();
|
||||
|
||||
// Assistant mode: force all agents async. Synchronous subagents hold the
|
||||
// main loop's turn open until they complete — the daemon's inputQueue
|
||||
// backs up, and the first overdue cron catch-up on spawn becomes N
|
||||
@@ -709,7 +710,6 @@ export const AgentTool = buildTool({
|
||||
(run_in_background === true ||
|
||||
selectedAgent.background === true ||
|
||||
isCoordinator ||
|
||||
forceAsync ||
|
||||
assistantForceAsync ||
|
||||
(proactiveModule?.isProactiveActive() ?? false)) &&
|
||||
!isBackgroundTasksDisabled;
|
||||
@@ -889,7 +889,7 @@ export const AgentTool = buildTool({
|
||||
toolUseContext,
|
||||
rootSetAppState,
|
||||
agentIdForCleanup: asyncAgentId,
|
||||
enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(),
|
||||
enableSummarization: isCoordinator || isForkPath || getSdkAgentProgressSummariesEnabled(),
|
||||
getWorktreeResult: cleanupWorktreeIfNeeded,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const promptSource = readFileSync(join(__dirname, '..', 'prompt.ts'), 'utf-8')
|
||||
|
||||
describe('prompt.ts fork-related text verification', () => {
|
||||
test('does not contain "omit `subagent_type`" guidance', () => {
|
||||
expect(promptSource).not.toMatch(/omit.*subagent_type/)
|
||||
})
|
||||
|
||||
test('contains `fork: true` in at least 3 locations (shared + whenToFork + forkExamples)', () => {
|
||||
const matches = promptSource.match(/fork: true/g)
|
||||
expect(matches).not.toBeNull()
|
||||
expect(matches!.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('all forkEnabled references are ternary conditions, not negated', () => {
|
||||
const lines = promptSource.split('\n')
|
||||
for (const line of lines) {
|
||||
if (
|
||||
line.includes('forkEnabled') &&
|
||||
!line.includes('const forkEnabled') &&
|
||||
!line.includes('forkEnabled =')
|
||||
) {
|
||||
expect(line).not.toContain('!forkEnabled')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('uses "non-fork" terminology instead of "fresh agent"', () => {
|
||||
expect(promptSource).toContain('non-fork')
|
||||
// "fresh agent" should not appear in fork-aware conditional text
|
||||
const freshAgentMatches = promptSource.match(/fresh agent/g)
|
||||
if (freshAgentMatches) {
|
||||
// Only allowed in comments explaining behavior, not in prompt text
|
||||
const linesWithFreshAgent = promptSource
|
||||
.split('\n')
|
||||
.filter(line => line.includes('fresh agent'))
|
||||
.map(line => line.trim())
|
||||
for (const line of linesWithFreshAgent) {
|
||||
// "fresh agent" in the context of "starts fresh" (not fork-aware) is ok
|
||||
// but "fresh agent" in forkEnabled conditional should not appear
|
||||
expect(line).not.toMatch(/fresh agent.*subagent_type/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('background task condition does not include !forkEnabled', () => {
|
||||
// The condition for showing background task instructions should not exclude fork
|
||||
const bgCondition = promptSource.match(
|
||||
/!isEnvTruthy.*isInProcessTeammate[\s\S]*?run_in_background/,
|
||||
)
|
||||
if (bgCondition) {
|
||||
expect(bgCondition[0]).not.toContain('!forkEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
test('fork example includes fork: true parameter', () => {
|
||||
// The first fork example should have fork: true
|
||||
const forkExampleBlock = promptSource.match(
|
||||
/name: "ship-audit"[\s\S]*?Under 200 words/,
|
||||
)
|
||||
expect(forkExampleBlock).not.toBeNull()
|
||||
expect(forkExampleBlock![0]).toContain('fork: true')
|
||||
})
|
||||
})
|
||||
@@ -82,11 +82,7 @@ export async function getPrompt(
|
||||
|
||||
## When to fork
|
||||
|
||||
Fork yourself (omit \`subagent_type\`) when the intermediate tool output isn't worth keeping in your context. The criterion is qualitative \u2014 "will I need this output again" \u2014 not task size.
|
||||
- **Research**: fork open-ended questions. If research can be broken into independent questions, launch parallel forks in one message. A fork beats a fresh subagent for this \u2014 it inherits context and shares your cache.
|
||||
- **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation.
|
||||
|
||||
Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork \u2014 a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can see the fork in the teams panel and steer it mid-run.
|
||||
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use \`fork: true\`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
|
||||
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
|
||||
|
||||
@@ -100,14 +96,14 @@ Forks are cheap because they share your prompt cache. Don't set \`model\` on a f
|
||||
|
||||
## Writing the prompt
|
||||
|
||||
${forkEnabled ? 'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
||||
${forkEnabled ? 'When spawning an agent without `fork: true`, it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
||||
- Explain what you're trying to accomplish and why.
|
||||
- Describe what you've already learned or ruled out.
|
||||
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
||||
- If you need a short response, say so ("report in under 200 words").
|
||||
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
||||
|
||||
${forkEnabled ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
|
||||
${forkEnabled ? 'For non-fork agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
|
||||
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
@@ -120,6 +116,7 @@ assistant: <thinking>Forking this \u2014 it's a survey question. I want the punc
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "ship-audit",
|
||||
description: "Branch ship-readiness audit",
|
||||
fork: true,
|
||||
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
|
||||
})
|
||||
assistant: Ship-readiness audit running.
|
||||
@@ -205,11 +202,7 @@ The ${AGENT_TOOL_NAME} tool launches specialized agents (subprocesses) that auto
|
||||
|
||||
${agentListSection}
|
||||
|
||||
${
|
||||
forkEnabled
|
||||
? `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type to use a specialized agent, or omit it to fork yourself — a fork inherits your full conversation context.`
|
||||
: `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.`
|
||||
}`
|
||||
When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.${forkEnabled ? ` Set \`fork: true\` to fork from the parent conversation context, inheriting full history and model.` : ''}`
|
||||
|
||||
// Coordinator mode gets the slim prompt -- the coordinator system prompt
|
||||
// already covers usage notes, examples, and when-not-to-use guidance.
|
||||
@@ -257,14 +250,13 @@ Usage notes:
|
||||
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.${
|
||||
// eslint-disable-next-line custom-rules/no-process-env-top-level
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) &&
|
||||
!isInProcessTeammate() &&
|
||||
!forkEnabled
|
||||
!isInProcessTeammate()
|
||||
? `
|
||||
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
|
||||
- **Foreground vs background**: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.`
|
||||
: ''
|
||||
}
|
||||
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
|
||||
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each non-fork Agent invocation starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
|
||||
- The agent's outputs should generally be trusted
|
||||
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"}
|
||||
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
|
||||
78
progress.md
Normal file
78
progress.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Code Review Progress
|
||||
|
||||
## 2026-05-03 — 第一轮 CRUD 业务逻辑层 Code Review
|
||||
|
||||
### 审查范围
|
||||
审查了 4 个核心 CRUD 模块:任务管理(tasks.ts)、设置管理(settings.ts)、插件管理(installedPluginsManager.ts)、团队协作邮箱(teammateMailbox.ts)。
|
||||
|
||||
### 变更内容
|
||||
1. **新增 `src/utils/__tests__/tasks.test.ts`** — 37 个测试覆盖完整 CRUD 操作:创建/读取/更新/删除任务、高水位标记防 ID 复用、文件锁并发安全、blockTask 双向关系、claimTask 竞态保护(含 agent_busy 检查)、resetTaskList、通知信号机制、并发创建唯一 ID 验证。
|
||||
|
||||
### Code Review 发现
|
||||
- tasks.ts 架构合理,文件锁+高水位标记保证了并发安全
|
||||
- settings.ts 依赖链过深(MDM/远程管理/文件系统),63 个现有测试覆盖良好
|
||||
- installedPluginsManager.ts V1→V2 迁移逻辑清晰,内存/磁盘状态分离设计良好
|
||||
- teammateMailbox.ts 25 个现有测试覆盖纯函数,协议消息检测函数完整
|
||||
|
||||
## 2026-05-05 — 第一轮用户思维 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 CLI 交互体验:Onboarding 流程、Trust Dialog、错误消息、Help Menu。聚焦非代码层面的用户友好性问题。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **错误消息缺乏可操作提示**:budget 超限/max turns 用尽时仅告知"出错了",未指导用户如何继续
|
||||
2. **Onboarding 安全说明冰冷**:"Security notes"标题过于技术化,用户容易跳过
|
||||
3. **Trust Dialog 文案冗长**:安全检查对话框用语偏官方,核心信息被淹没
|
||||
|
||||
### 变更内容
|
||||
1. **`src/cli/print.ts`** — 为 3 种错误子类型(budget/turns/structured-output)添加 Tip 提示行,告知用户具体的解决方式
|
||||
2. **`src/QueryEngine.ts`** — 预算超限错误消息添加 `--max-budget-usd` 指引
|
||||
3. **`src/components/Onboarding.tsx`** — 安全步骤标题改为 "Before you start, keep in mind",条目文案更口语化
|
||||
4. **`src/components/TrustDialog/TrustDialog.tsx`** — 精简为两句核心信息,降低认知负荷
|
||||
5. **`src/cli/__tests__/userFacingErrorMessages.test.ts`** — 7 个测试验证消息内容包含关键引导信息
|
||||
|
||||
## 2026-05-05 — 第二轮权限与帮助系统 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视权限交互提示(Bash/File 权限对话框底部提示行)、Help 页面引导、权限选项标签长度。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **权限对话框底部提示语义模糊**:"Esc to cancel" 不如 "Esc to reject" 明确,"Tab to amend" 用户不知能做什么
|
||||
2. **Help General 页面缺乏新手引导**:只有一句话 + 全部快捷键,新用户不知从何开始
|
||||
3. **.claude/ 文件夹权限选项标签过长**(60+ 字符),窄终端截断
|
||||
|
||||
### 变更内容
|
||||
1. **`src/components/HelpV2/General.tsx`** — 添加 3 步"Getting started"引导,取代原来的单段描述
|
||||
2. **`src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx`** — 底部 "cancel"→"reject","amend"→"add feedback"
|
||||
3. **`src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx`** — 同步底部提示用词
|
||||
4. **`src/components/permissions/FilePermissionDialog/permissionOptions.tsx`** — .claude/ 选项标签从 60 字符缩至 49 字符
|
||||
5. **`src/components/HelpV2/__tests__/General.test.ts`** — 10 个测试覆盖权限提示文案和帮助页引导内容
|
||||
|
||||
## 2026-05-05 — 第三轮模型选择与会话恢复 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 ModelPicker 选择器、/resume 会话恢复命令的错误提示、cost 命令展示。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **ModelPicker 副标题信息过载**:一句话里混合了模型切换说明和 --model 参数提示,新用户容易困惑
|
||||
2. **Resume 错误提示缺乏操作指导**:"Session X was not found" 没告诉用户怎么列出所有会话
|
||||
|
||||
### 变更内容
|
||||
1. **`src/components/ModelPicker.tsx`** — 副标题从技术说明改为操作提示("← → 调整 effort,Space 切换 1M context"),控制在 120 字符内
|
||||
2. **`src/commands/resume/resume.tsx`** — 错误提示添加 "Run /resume to browse" 操作引导
|
||||
3. **`src/commands/resume/__tests__/resume.test.ts`** — 6 个测试覆盖模型选择器、会话恢复、cost 消息文案
|
||||
|
||||
## 2026-05-05 — 第四轮压缩与上下文管理 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 /compact 命令体验、自动压缩提示、上下文窗口耗尽错误、CompactSummary 组件展示。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **"Not enough messages to compact" 缺乏指导**:用户不知下一步该做什么
|
||||
2. **"Conversation too long" 提示的 "Press esc twice" 操作不直观**:esc twice 对用户来说是模糊的操作
|
||||
3. **"Compact summary" 标题对用户没有信息量**:自动压缩时用户不知道发生了什么
|
||||
|
||||
### 变更内容
|
||||
1. **`src/services/compact/compact.ts`** — "Not enough messages" 添加 "Send a few more messages first" 引导;"Conversation too long" 改为建议 `/compact` 或 `/clear`
|
||||
2. **`src/components/CompactSummary.tsx`** — 自动压缩标题从 "Compact summary" 改为 "Conversation summarized to free up context",快捷键提示从 "expand" 改为 "view summary"
|
||||
3. **`src/components/__tests__/compactMessages.test.ts`** — 7 个测试覆盖压缩错误消息和展示文案
|
||||
@@ -1,13 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const pkgPath = resolve(__dirname, '..', 'package.json')
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
|
||||
/**
|
||||
* Shared MACRO define map used by both dev.ts (runtime -d flags)
|
||||
* and build.ts (Bun.build define option).
|
||||
*
|
||||
* Each value is a JSON-stringified expression that replaces the
|
||||
* corresponding MACRO.* identifier at transpile / bundle time.
|
||||
*
|
||||
* VERSION is read from package.json to avoid version drift.
|
||||
*/
|
||||
export function getMacroDefines(): Record<string, string> {
|
||||
return {
|
||||
'MACRO.VERSION': JSON.stringify('2.1.888'),
|
||||
'MACRO.VERSION': JSON.stringify(pkg.version),
|
||||
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
|
||||
'MACRO.FEEDBACK_CHANNEL': JSON.stringify(''),
|
||||
'MACRO.ISSUES_EXPLAINER': JSON.stringify(''),
|
||||
@@ -52,7 +62,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
// 'FORK_SUBAGENT', // 已禁用:启用后 prompt 引导模型用 fork(继承父模型)替代 Explore(haiku),导致探索任务使用同等级模型
|
||||
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
|
||||
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
||||
'KAIROS', // Kairos 定时任务系统核心
|
||||
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
||||
|
||||
@@ -14,7 +14,21 @@ const __dirname = dirname(__filename)
|
||||
const projectRoot = join(__dirname, '..')
|
||||
const cliPath = join(projectRoot, 'src/entrypoints/cli.tsx')
|
||||
|
||||
const defines = getMacroDefines()
|
||||
// React production mode — prevents 6,889+ _debugStack Error objects
|
||||
// (12MB) from accumulating during long-running sessions.
|
||||
// Opt-in via CLAUDE_CODE_FORCE_NODE_ENV=production for dev sessions that
|
||||
// need the memory optimization. Default keeps NODE_ENV='development' so
|
||||
// dev-only diagnostics (DevBar, doctorDiagnostic, AutoUpdater dev branches,
|
||||
// etc.) continue to work.
|
||||
const forcedNodeEnv =
|
||||
process.env.CLAUDE_CODE_FORCE_NODE_ENV ??
|
||||
process.env.NODE_ENV ??
|
||||
'development'
|
||||
|
||||
const defines = {
|
||||
...getMacroDefines(),
|
||||
'process.env.NODE_ENV': JSON.stringify(forcedNodeEnv),
|
||||
}
|
||||
|
||||
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
|
||||
'-d',
|
||||
|
||||
@@ -14,20 +14,18 @@ import { execSync } from 'node:child_process'
|
||||
const outdir = 'dist'
|
||||
|
||||
async function postBuild() {
|
||||
// Step 1: Patch globalThis.Bun destructuring from third-party deps
|
||||
const files = await readdir(outdir, { recursive: true })
|
||||
// Step 1: Patch globalThis.Bun destructuring in the single bundled file
|
||||
const cliPath = join(outdir, 'cli.js')
|
||||
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
||||
const BUN_DESTRUCTURE_SAFE =
|
||||
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
|
||||
let bunPatched = 0
|
||||
for (const file of files) {
|
||||
const filePath = join(outdir, file)
|
||||
if (typeof file !== 'string' || !file.endsWith('.js')) continue
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
{
|
||||
const content = await readFile(cliPath, 'utf-8')
|
||||
if (BUN_DESTRUCTURE.test(content)) {
|
||||
await writeFile(
|
||||
filePath,
|
||||
cliPath,
|
||||
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||
)
|
||||
bunPatched++
|
||||
|
||||
132
spec/feature_20260502_F001_fork-agent-redesign/spec-design.md
Normal file
132
spec/feature_20260502_F001_fork-agent-redesign/spec-design.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Feature: 20260502_F001 - fork-agent-redesign
|
||||
|
||||
## 需求背景
|
||||
|
||||
当前 `FORK_SUBAGENT` feature flag 是一个"一刀切"开关,启用时同时强制三件事:
|
||||
|
||||
1. 所有省略 `subagent_type` 的 agent 调用隐式走 fork 路径(继承父级完整上下文和模型)
|
||||
2. 所有 agent spawn 强制异步(`forceAsync` 绑定在 `isForkSubagentEnabled()` 上)
|
||||
3. prompt 引导模型优先省略 `subagent_type`,导致大部分 agent 都用同等级模型(贵)
|
||||
|
||||
这导致探索任务被迫使用与父级相同的模型(而非 haiku),token 消耗大增。因此该 flag 在 `defines.ts` 中被注释禁用。
|
||||
|
||||
## 目标
|
||||
|
||||
- 将 fork 从隐式行为改为**显式参数触发**(`fork: true`)
|
||||
- FORK_SUBAGENT flag 只控制 fork 能力的可用性,**不再影响 `forceAsync` 等其他行为**
|
||||
- 模型始终继承父级(保持现有行为)
|
||||
- **完全向后兼容**——不传 `fork` 参数时行为与当前(flag 关闭时)一致
|
||||
|
||||
## 方案设计
|
||||
|
||||
### Schema 变更
|
||||
|
||||
Agent tool 参数新增 `fork?: boolean`,仅在 `FORK_SUBAGENT` flag 启用时可见(schema 动态裁剪,复用现有的 schema memo 模式)。
|
||||
|
||||
```ts
|
||||
// inputSchema 中新增
|
||||
fork: z.boolean().optional().describe(
|
||||
'Set to true to fork from the parent conversation context. '
|
||||
'The child inherits full history, system prompt, and model. '
|
||||
'Requires FORK_SUBAGENT feature flag.'
|
||||
)
|
||||
```
|
||||
|
||||
flag 关闭时,schema 通过 `.omit({ fork: true })` 裁剪掉该字段(与当前 `run_in_background` 的裁剪方式一致)。
|
||||
|
||||
### 路由逻辑重构
|
||||
|
||||
`AgentTool.tsx` call() 中的路由从当前的隐式判断:
|
||||
|
||||
```ts
|
||||
// 旧行为:省略 subagent_type → fork(flag 开启时)
|
||||
const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType);
|
||||
const isForkPath = effectiveType === undefined;
|
||||
```
|
||||
|
||||
改为显式参数触发:
|
||||
|
||||
```ts
|
||||
// 新行为:显式 fork 参数触发,fork 优先级高于 subagent_type
|
||||
const isForkPath = input.fork === true && isForkSubagentEnabled();
|
||||
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
|
||||
```
|
||||
|
||||
#### 决策表
|
||||
|
||||
| `fork` | `subagent_type` | flag 开 | 结果 |
|
||||
|--------|----------------|---------|------|
|
||||
| `true` | 有值 | 是 | fork 路径,**忽略 subagent_type** |
|
||||
| `true` | 省略 | 是 | fork 路径(继承上下文) |
|
||||
| `true` | * | 否 | 忽略 fork,走 subagent_type 或 general-purpose |
|
||||
| `false`/省略 | 有值 | * | 走指定 agent 类型(原有行为) |
|
||||
| `false`/省略 | 省略 | * | 走 general-purpose(原有行为) |
|
||||
|
||||
核心原则:**`fork: true` 是最高优先级**(当 flag 开启时),但 flag 关闭时静默降级,不影响原有行为。
|
||||
|
||||
### 后台运行由参数决定
|
||||
|
||||
fork agent 是否后台运行由 `run_in_background` 参数决定,与普通 agent 一致。`forceAsync` 不再绑定 `isForkSubagentEnabled()`:
|
||||
|
||||
```ts
|
||||
// forceAsync 不再受 isForkSubagentEnabled() 影响
|
||||
const forceAsync = /* 其他条件(coordinator, assistant mode 等)*/;
|
||||
```
|
||||
|
||||
fork agent 与普通 agent 使用相同的 `run_in_background` 参数判断逻辑:
|
||||
- `run_in_background: true` → 后台异步运行
|
||||
- `run_in_background: false` / 省略 → 同步阻塞运行
|
||||
|
||||
### prompt 调整
|
||||
|
||||
移除引导模型"省略 subagent_type 以触发 fork"的 prompt 文本。改为说明 `fork: true` 的适用场景:
|
||||
|
||||
> When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use `fork: true`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
|
||||
|
||||
### isForkSubagentEnabled() 精简
|
||||
|
||||
函数签名和行为保持不变,但调用方语义改变:从"隐式路由判断"变为"参数校验门控"。
|
||||
|
||||
```ts
|
||||
export function isForkSubagentEnabled(): boolean {
|
||||
if (!feature('FORK_SUBAGENT')) return false;
|
||||
if (isCoordinatorMode()) return false;
|
||||
if (getIsNonInteractiveSession()) return false;
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 不变的部分
|
||||
|
||||
以下保持不变,无需修改:
|
||||
|
||||
- `buildForkedMessages()` — fork 消息构建逻辑
|
||||
- `isInForkChild()` — 递归 fork 防护
|
||||
- `FORK_AGENT` — fork agent 定义(model: 'inherit', permissionMode: 'bubble')
|
||||
- `buildChildMessage()` — fork 子 agent 指令模板
|
||||
- `buildWorktreeNotice()` — worktree 隔离通知
|
||||
|
||||
## 实现要点
|
||||
|
||||
1. **Schema 动态裁剪**:`inputSchema` memo 中根据 `isForkSubagentEnabled()` 决定是否 `.omit({ fork: true })`,flag 关闭时字段不存在于 schema
|
||||
2. **省略 `subagent_type` 恢复原有行为**:不再隐式走 fork,恢复为 `GENERAL_PURPOSE_AGENT`
|
||||
3. **`defines.ts` 注释更新**:`FORK_SUBAGENT` 保持注释状态,但描述更新为新行为(显式参数触发,不影响探索任务模型选择)
|
||||
4. **递归 fork 防护**:保持现有 `isInForkChild()` + `querySource` 双重检测
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` | 新增 `fork` 参数解析,路由逻辑重构,forceAsync 解耦 |
|
||||
| `packages/builtin-tools/src/tools/AgentTool/prompt.ts` | 移除隐式 fork 引导,新增 `fork: true` 使用场景说明 |
|
||||
| `scripts/defines.ts` | 更新 `FORK_SUBAGENT` 注释描述 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `fork: true` + `FORK_SUBAGENT` 启用 → 走 fork 路径,继承父级上下文和模型
|
||||
- [ ] `fork: true` + `subagent_type` 有值 + flag 开 → fork 路径,忽略 subagent_type
|
||||
- [ ] `fork: true` + `FORK_SUBAGENT` 关闭 → 忽略 fork,走普通 agent 路径
|
||||
- [ ] 不传 `fork` 参数 → 行为与当前 flag 关闭时完全一致(走 general-purpose 或指定 subagent_type)
|
||||
- [ ] `forceAsync` 不再因 `isForkSubagentEnabled()` 而全局生效
|
||||
- [ ] fork 子 agent 的后台/同步行为由 `run_in_background` 参数控制,与普通 agent 一致
|
||||
- [ ] `bun run precheck` 零错误通过
|
||||
@@ -0,0 +1,170 @@
|
||||
# Fork Agent 显式参数触发重构 人工验收清单
|
||||
|
||||
**生成时间:** 2026-05-02
|
||||
**关联计划:** spec/feature_20260502_F001_fork-agent-redesign/spec-plan.md
|
||||
**关联设计:** spec/feature_20260502_F001_fork-agent-redesign/spec-design.md
|
||||
|
||||
---
|
||||
|
||||
## 验收前准备
|
||||
|
||||
### 环境要求
|
||||
- [ ] [AUTO] 检查 Bun 版本: `bun --version`
|
||||
- [ ] [AUTO] 安装依赖: `bun install`
|
||||
|
||||
---
|
||||
|
||||
## 验收项目
|
||||
|
||||
### 场景 1:Schema 与类型变更
|
||||
|
||||
#### - [x] 1.1 fork 字段已添加到 baseInputSchema
|
||||
- **来源:** spec-plan.md Task 1 / spec-design.md §Schema 变更
|
||||
- **目的:** 确认 fork 参数在基础 schema 中声明
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'fork:' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | head -5` → 期望包含: `fork: z`(schema 定义)和 `fork?: boolean`(类型声明)
|
||||
|
||||
#### - [x] 1.2 fork 字段在 flag 关闭时被 schema 裁剪
|
||||
- **来源:** spec-plan.md Task 1 / spec-design.md §Schema 变更
|
||||
- **目的:** 确认 FORK_SUBAGENT 关闭时 fork 字段不可见
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'omit.*fork' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `schema.omit({ fork: true })`
|
||||
|
||||
#### - [x] 1.3 AgentToolInput 类型包含 fork 字段
|
||||
- **来源:** spec-plan.md Task 1
|
||||
- **目的:** 确认类型声明与 schema 一致
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'fork' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | grep 'AgentToolInput\|fork?:'` → 期望包含: `fork?: boolean`
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:路由逻辑重构
|
||||
|
||||
#### - [x] 2.1 isForkPath 使用显式 fork 参数判断
|
||||
- **来源:** spec-plan.md Task 1 / spec-design.md §路由逻辑重构
|
||||
- **目的:** 确认 fork 路径由 fork=true 显式触发
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'isForkPath' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `fork === true && isForkSubagentEnabled()`
|
||||
|
||||
#### - [x] 2.2 forceAsync 已完全移除
|
||||
- **来源:** spec-plan.md Task 1 / spec-design.md §后台运行由参数决定
|
||||
- **目的:** 确认 forceAsync 不再绑定 isForkSubagentEnabled()
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c 'forceAsync' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望精确: `0`
|
||||
|
||||
#### - [x] 2.3 isForkSubagentEnabled() 仅用于 schema 裁剪和路由判断
|
||||
- **来源:** spec-plan.md Task 1
|
||||
- **目的:** 确认 isForkSubagentEnabled() 不再影响 forceAsync/shouldRunAsync
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'isForkSubagentEnabled' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: 仅出现在 inputSchema 裁剪和 isForkPath 路由判断中
|
||||
|
||||
#### - [x] 2.4 shouldRunAsync 由 run_in_background 控制
|
||||
- **来源:** spec-plan.md Task 1 / spec-design.md §后台运行由参数决定
|
||||
- **目的:** 确认异步行为与普通 agent 一致
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'run_in_background' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | head -5` → 期望包含: `shouldRunAsync` 计算中含 `run_in_background === true`,无 `forceAsync`
|
||||
|
||||
#### - [x] 2.5 enableSummarization 使用 isForkPath 而非 isForkSubagentEnabled()
|
||||
- **来源:** spec-plan.md Task 1
|
||||
- **目的:** 确认摘要仅在当前调用实际走 fork 路径时启用
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'enableSummarization' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `isForkPath`,不包含 `isForkSubagentEnabled()`
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:Prompt 文本更新
|
||||
|
||||
#### - [x] 3.1 不再包含 "omit subagent_type" 引导文本
|
||||
- **来源:** spec-plan.md Task 2 / spec-design.md §prompt 调整
|
||||
- **目的:** 确认隐式 fork 触发引导已移除
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c 'omit' packages/builtin-tools/src/tools/AgentTool/prompt.ts` → 期望精确: `0`
|
||||
|
||||
#### - [x] 3.2 包含 "fork: true" 显式参数说明
|
||||
- **来源:** spec-plan.md Task 2 / spec-design.md §prompt 调整
|
||||
- **目的:** 确认新的显式 fork 使用说明已写入
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c 'fork: true' packages/builtin-tools/src/tools/AgentTool/prompt.ts` → 期望包含: >= 3(shared section + whenToForkSection + forkExamples)
|
||||
|
||||
#### - [x] 3.3 背景任务说明条件不再含 !forkEnabled
|
||||
- **来源:** spec-plan.md Task 2
|
||||
- **目的:** 确认 fork 解耦后背景任务说明在 fork 启用时也显示
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'forkEnabled' packages/builtin-tools/src/tools/AgentTool/prompt.ts` → 期望包含: 所有匹配行均为 `forkEnabled ?` 形式,不包含 `!forkEnabled`
|
||||
|
||||
#### - [x] 3.4 术语从 "fresh agent" 更新为 "non-fork"
|
||||
- **来源:** spec-plan.md Task 2
|
||||
- **目的:** 确认 prompt 术语与新的显式 fork 逻辑一致
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c 'non-fork' packages/builtin-tools/src/tools/AgentTool/prompt.ts` → 期望包含: >= 2
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:边界与回归(决策表验证)
|
||||
|
||||
#### - [x] 4.1 fork=true + subagent_type + flag 开 → fork 路径,忽略 subagent_type
|
||||
- **来源:** spec-design.md §决策表 + spec-plan.md Task 3
|
||||
- **目的:** 确认 fork 优先级高于 subagent_type
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -A2 'isForkPath = fork === true' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType`(fork 生效时 effectiveType 被 isForkPath 覆盖,subagent_type 不影响路由)
|
||||
|
||||
#### - [x] 4.2 fork=true + flag 关闭 → 忽略 fork,走普通 agent 路径
|
||||
- **来源:** spec-design.md §决策表
|
||||
- **目的:** 确认 flag 关闭时 fork 静默降级
|
||||
- **操作步骤:**
|
||||
1. [A] `grep 'isForkPath = fork === true && isForkSubagentEnabled' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `&& isForkSubagentEnabled()`(双条件确保 flag 关闭时 isForkPath 为 false)
|
||||
|
||||
#### - [x] 4.3 fork 省略 → 走 general-purpose 或指定 subagent_type
|
||||
- **来源:** spec-design.md §决策表
|
||||
- **目的:** 确认向后兼容
|
||||
- **操作步骤:**
|
||||
1. [A] `grep 'effectiveType = subagent_type ??' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `GENERAL_PURPOSE_AGENT.agentType`
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:defines.ts 注释与构建验证
|
||||
|
||||
#### - [x] 5.1 FORK_SUBAGENT 注释已更新为新行为描述
|
||||
- **来源:** spec-plan.md Task 1 / spec-design.md §实现要点
|
||||
- **目的:** 确认注释反映显式参数触发设计
|
||||
- **操作步骤:**
|
||||
1. [A] `grep 'FORK_SUBAGENT' scripts/defines.ts` → 期望包含: `显式 \`fork: true\` 参数触发`
|
||||
|
||||
#### - [x] 5.2 单元测试全部通过
|
||||
- **来源:** spec-plan.md Task 1 + Task 2
|
||||
- **目的:** 确认路由逻辑和 prompt 文本测试通过
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/ 2>&1 | tail -10` → 期望包含: `0 fail`
|
||||
|
||||
#### - [x] 5.3 precheck 零错误通过
|
||||
- **来源:** spec-plan.md Task 3 / spec-design.md §验收标准
|
||||
- **目的:** 确认 typecheck + lint + test 无回归
|
||||
- **操作步骤:**
|
||||
1. [A] `bun run precheck` → 期望包含: 零错误退出
|
||||
|
||||
---
|
||||
|
||||
## 验收结果汇总
|
||||
|
||||
| 场景 | 序号 | 验收项 | [A] | [H] | 结果 |
|
||||
|------|------|--------|-----|-----|------|
|
||||
| 场景 1 | 1.1 | fork 字段已添加到 baseInputSchema | 1 | 0 | ✅ |
|
||||
| 场景 1 | 1.2 | fork 字段在 flag 关闭时被 schema 裁剪 | 1 | 0 | ✅ |
|
||||
| 场景 1 | 1.3 | AgentToolInput 类型包含 fork 字段 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.1 | isForkPath 使用显式 fork 参数判断 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.2 | forceAsync 已完全移除 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.3 | isForkSubagentEnabled() 仅用于 schema 裁剪和路由判断 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.4 | shouldRunAsync 由 run_in_background 控制 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.5 | enableSummarization 使用 isForkPath | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.1 | 不再包含 "omit subagent_type" 引导文本 | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.2 | 包含 "fork: true" 显式参数说明 | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.3 | 背景任务条件不再含 !forkEnabled | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.4 | 术语更新为 "non-fork" | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.1 | fork=true + subagent_type + flag 开 → fork 路径 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.2 | fork=true + flag 关闭 → 忽略 fork | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.3 | fork 省略 → general-purpose(向后兼容) | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.1 | FORK_SUBAGENT 注释已更新 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.2 | 单元测试全部通过 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.3 | precheck 零错误通过 | 1 | 0 | ✅ |
|
||||
|
||||
**验收结论:** ✅ 全部通过 / ⬜ 存在问题
|
||||
317
spec/feature_20260502_F001_fork-agent-redesign/spec-plan.md
Normal file
317
spec/feature_20260502_F001_fork-agent-redesign/spec-plan.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Fork Agent 显式参数触发重构 执行计划
|
||||
|
||||
**目标:** 将 FORK_SUBAGENT 从隐式行为改为显式 `fork: true` 参数触发,解耦 forceAsync,保持向后兼容
|
||||
|
||||
**技术栈:** TypeScript, Zod schema, Bun test, React/Ink (prompt UI)
|
||||
|
||||
**设计文档:** spec/feature_20260502_F001_fork-agent-redesign/spec-design.md
|
||||
|
||||
## 改动总览
|
||||
|
||||
- 本次改动涉及 3 个修改文件:`AgentTool.tsx`(Schema + 路由 + forceAsync 解耦)、`prompt.ts`(引导文本)、`defines.ts`(注释更新)。新建 1 个测试文件 `prompt.test.ts`。
|
||||
- Task 1 是 Task 2 的前置:Task 1 完成 Schema 变更和路由重构后,Task 2 才能安全地调整 prompt 文本(prompt 行为描述必须与代码实际行为一致)。
|
||||
- 关键设计决策:fork 参数添加到 `baseInputSchema` 而非 `fullInputSchema`,因为 fork 是基础 agent 能力而非 multi-agent 特有能力。
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 环境准备
|
||||
|
||||
**背景:**
|
||||
确保构建和测试工具链在当前开发环境中可用,避免后续 Task 因环境问题阻塞。
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 验证构建工具可用
|
||||
- `bun --version`
|
||||
- 确认输出 Bun 版本号
|
||||
- [x] 验证测试工具可用
|
||||
- `bun test --help 2>&1 | head -3`
|
||||
- 确认输出包含 test 相关帮助信息
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 构建命令执行成功
|
||||
- `bun run build 2>&1 | tail -5`
|
||||
- 预期: 构建成功,输出包含 dist/cli.js
|
||||
- [x] 现有测试通过
|
||||
- `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/ 2>&1 | tail -10`
|
||||
- 预期: 所有现有测试通过,无失败
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 核心路由重构
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 当前 `FORK_SUBAGENT` flag 启用时,所有省略 `subagent_type` 的 agent 调用隐式走 fork 路径,导致探索任务被迫使用父级同等级模型,token 消耗大增。本次重构将 fork 从隐式行为改为显式 `fork: true` 参数触发。
|
||||
[修改原因] — `AgentTool.tsx` 中路由逻辑(`effectiveType` / `isForkPath`)通过 `subagent_type` 是否省略来判断 fork 路径,需改为通过 `fork` 布尔参数显式触发。同时 `forceAsync` 变量绑定在 `isForkSubagentEnabled()` 上,导致 fork flag 开启时所有 agent 强制异步,需解耦。
|
||||
[上下游影响] — 本 Task 的输出(`fork` 参数、新路由逻辑)被 Task 2(prompt 文本调整)依赖。本 Task 无前置依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
|
||||
- 修改: `scripts/defines.ts`
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 在 baseInputSchema 中新增 `fork` 字段
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:baseInputSchema()` (~L136-152),在 `run_in_background` 字段之后
|
||||
- 在 `run_in_background` 字段的闭合 `),` 之后,闭合 `})` 之前,新增:
|
||||
```ts
|
||||
fork: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set to true to fork from the parent conversation context. The child inherits full history, system prompt, and model. Requires FORK_SUBAGENT feature flag.',
|
||||
),
|
||||
```
|
||||
- 原因: fork 参数需要在基础 schema 中声明,与 `subagent_type`、`run_in_background` 同级,因为它是所有 agent 调用的可选参数,不限于 multi-agent 场景。
|
||||
|
||||
- [x] 重构 inputSchema memo 的裁剪逻辑
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:inputSchema()` (~L193-204)
|
||||
- 将 L194-203 替换为:
|
||||
```ts
|
||||
let schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
|
||||
if (isBackgroundTasksDisabled) {
|
||||
schema = schema.omit({ run_in_background: true });
|
||||
}
|
||||
if (!isForkSubagentEnabled()) {
|
||||
schema = schema.omit({ fork: true });
|
||||
}
|
||||
return schema;
|
||||
```
|
||||
- 同时删除 L196-202 的 GrowthBook 注释块(该注释描述的是旧 `forceAsync` 行为,已不适用)。
|
||||
- 原因: fork 字段仅在 `FORK_SUBAGENT` flag 启用时可见;`run_in_background` 不再受 `isForkSubagentEnabled()` 影响,两者独立裁剪。
|
||||
|
||||
- [x] 更新 AgentToolInput 类型声明
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` (~L211-217),`AgentToolInput` type 定义
|
||||
- 在 `z.infer<ReturnType<typeof baseInputSchema>> & {` 的下一行(`name?: string;` 之前),新增 `fork?: boolean;`
|
||||
- 原因: 类型声明必须包含 `fork` 字段,确保 `call()` 解构时有正确的类型推断。
|
||||
|
||||
- [x] 更新 inputSchema 附近的 fork gate 注释
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` (~L207-210),`AgentToolInput` 上方的注释
|
||||
- 将 L209-210 的注释:
|
||||
```ts
|
||||
// subagent_type is optional; call() defaults it to general-purpose when the
|
||||
// fork gate is off, or routes to the fork path when the gate is on.
|
||||
```
|
||||
- 替换为:
|
||||
```ts
|
||||
// subagent_type is optional; call() defaults it to general-purpose.
|
||||
// fork is gated by FORK_SUBAGENT flag; when omitted or flag is off, no fork.
|
||||
```
|
||||
- 原因: 旧行为描述与新的显式 fork 触发逻辑不一致,需要更新。
|
||||
|
||||
- [x] 在 call() 解构中新增 `fork` 参数
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L322-333),参数解构
|
||||
- 在 `subagent_type,` 之后(L324),新增 `fork,`
|
||||
- 原因: `call()` 需要从输入中提取 `fork` 值用于路由判断。
|
||||
|
||||
- [x] 重构路由逻辑为显式 fork 触发
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L409-414)
|
||||
- 将 L409-414 替换为:
|
||||
```ts
|
||||
// Fork routing: explicit `fork: true` parameter triggers the fork path
|
||||
// (inherits parent context and model). Requires FORK_SUBAGENT flag.
|
||||
// subagent_type is ignored when fork takes effect.
|
||||
const isForkPath = fork === true && isForkSubagentEnabled();
|
||||
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
|
||||
```
|
||||
- 原因: 将隐式路由(省略 `subagent_type` 触发 fork)改为显式参数触发(`fork: true`),同时保持 `subagent_type` 省略时走 general-purpose 的原有行为。
|
||||
|
||||
- [x] 删除 forceAsync 变量及其注释
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L695-697)
|
||||
- 删除 L695-697(注释 + `const forceAsync = isForkSubagentEnabled();`)
|
||||
- 原因: `forceAsync` 不再绑定 `isForkSubagentEnabled()`,fork agent 的异步行为由 `run_in_background` 参数控制,与普通 agent 一致。
|
||||
|
||||
- [x] 从 shouldRunAsync 中移除 forceAsync 条件
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L708-715)
|
||||
- 将 L708-715 的 `shouldRunAsync` 计算中的 `forceAsync ||` 移除:
|
||||
```ts
|
||||
const shouldRunAsync =
|
||||
(run_in_background === true ||
|
||||
selectedAgent.background === true ||
|
||||
isCoordinator ||
|
||||
assistantForceAsync ||
|
||||
(proactiveModule?.isProactiveActive() ?? false)) &&
|
||||
!isBackgroundTasksDisabled;
|
||||
```
|
||||
- 原因: `forceAsync` 变量已删除,fork agent 不再全局强制异步。
|
||||
|
||||
- [x] 更新 enableSummarization 使用 isForkPath 替代 isForkSubagentEnabled()
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L892)
|
||||
- 将:
|
||||
```ts
|
||||
enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(),
|
||||
```
|
||||
- 替换为:
|
||||
```ts
|
||||
enableSummarization: isCoordinator || isForkPath || getSdkAgentProgressSummariesEnabled(),
|
||||
```
|
||||
- 原因: `enableSummarization` 应仅在当前调用实际走 fork 路径时启用,而非 flag 全局启用。`isForkPath` 是当前调用的运行时判断结果。
|
||||
|
||||
- [x] 更新 defines.ts 中 FORK_SUBAGENT 的注释
|
||||
- 位置: `scripts/defines.ts` (~L55)
|
||||
- 将:
|
||||
```ts
|
||||
// 'FORK_SUBAGENT', // 已禁用:启用后 prompt 引导模型用 fork(继承父模型)替代 Explore(haiku),导致探索任务使用同等级模型
|
||||
```
|
||||
- 替换为:
|
||||
```ts
|
||||
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
|
||||
```
|
||||
- 原因: 旧注释描述的是隐式 fork 行为的问题,新注释描述的是当前显式参数触发的设计。
|
||||
|
||||
- [x] 为路由逻辑重构编写单元测试
|
||||
- 测试文件: `packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts`
|
||||
- 测试场景(通过导出路由判断辅助函数或验证 inputSchema 裁剪行为):
|
||||
- `isForkSubagentEnabled() 返回 false 时`: `inputSchema()` 不包含 `fork` 字段(通过 `.omit({ fork: true })` 裁剪)
|
||||
- `isBackgroundTasksDisabled 为 true 时`: `inputSchema()` 不包含 `run_in_background` 字段,但仍包含 `fork` 字段
|
||||
- 两个条件同时满足时: `inputSchema()` 同时 omit `run_in_background` 和 `fork`
|
||||
- 运行命令: `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证 `fork` 字段已添加到 baseInputSchema
|
||||
- `grep -n 'fork:' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | head -5`
|
||||
- 预期: 输出至少包含 1 行 schema 定义中的 `fork:` 和 1 行类型中的 `fork?:`
|
||||
|
||||
- [x] 验证 forceAsync 已完全移除
|
||||
- `grep -n 'forceAsync' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
|
||||
- 预期: 无输出(grep 返回非零退出码)
|
||||
|
||||
- [x] 验证 isForkSubagentEnabled() 在 call() 中仅用于路由判断
|
||||
- `grep -n 'isForkSubagentEnabled' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
|
||||
- 预期: 仅出现在 `inputSchema()` 的 `!isForkSubagentEnabled()` 裁剪条件和路由的 `fork === true && isForkSubagentEnabled()` 中,不出现在 shouldRunAsync 或 enableSummarization 中
|
||||
|
||||
- [x] 验证 defines.ts 注释已更新
|
||||
- `grep 'FORK_SUBAGENT' scripts/defines.ts`
|
||||
- 预期: 输出行包含 "显式 `fork: true` 参数触发"
|
||||
|
||||
- [x] 运行 precheck 确认无类型/lint/测试错误
|
||||
- `bun run precheck`
|
||||
- 预期: 零错误通过
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Prompt 文本调整
|
||||
|
||||
**背景:**
|
||||
[业务语境] — Task 1 将 fork 从隐式行为(省略 `subagent_type` 触发)改为显式参数(`fork: true`),prompt.ts 中的引导文本必须同步更新,否则模型仍会尝试用旧方式触发 fork。
|
||||
[修改原因] — 当前 prompt.ts 引导模型"省略 `subagent_type` 以触发 fork"(~L85 `omit \`subagent_type\``),且 forkExamples 中省略了 `subagent_type`(隐式触发)。这些文本与 Task 1 的新路由逻辑矛盾。此外,背景任务说明的显示条件 `!forkEnabled` 不再正确——Task 1 已解耦 forceAsync,fork agent 不再强制异步,背景任务说明应在 fork 启用时也显示。
|
||||
[上下游影响] — 本 Task 依赖 Task 1 完成(Task 1 重构了路由逻辑,本 Task 更新对应的 prompt 文本)。本 Task 仅修改 prompt 文本,不影响运行时逻辑。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `packages/builtin-tools/src/tools/AgentTool/prompt.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 替换 `whenToForkSection` 中的 fork 触发说明
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 `whenToForkSection` 模板字面量(~L80-97)
|
||||
- 将 `## When to fork` 标题下的第一段文本(从 "Fork yourself (omit..." 到 "...Do research before jumping to implementation.")替换为:
|
||||
```
|
||||
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use `fork: true`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
|
||||
```
|
||||
- "Don't peek."、"Don't race."、"Writing a fork prompt." 段落保持不变
|
||||
- 原因: 移除"省略 subagent_type"的引导,改为说明 `fork: true` 的适用场景
|
||||
|
||||
- [x] 更新 `writingThePromptSection` 中的术语
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 `writingThePromptSection` 模板字面量(~L99-113)
|
||||
- 将 ~L103 的条件文本从 `'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. '` 替换为 `'When spawning an agent without `fork: true`, it starts with zero context. '`
|
||||
- 将 ~L110 的条件文本从 `'For fresh agents, terse'` 替换为 `'For non-fork agents, terse'`
|
||||
- 原因: fork 通过 `fork: true` 显式触发,"fresh agent"与"fork"的对立不再准确,改为"non-fork agents"
|
||||
|
||||
- [x] 替换 `shared` section 中的 fork 使用说明
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 `shared` 模板字面量(~L208-212)
|
||||
- 将整个条件分支(`forkEnabled ? ... : ...`)替换为统一文本:
|
||||
```
|
||||
When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.${forkEnabled ? ` Set \`fork: true\` to fork from the parent conversation context, inheriting full history and model.` : ''}
|
||||
```
|
||||
- 原因: 省略 `subagent_type` 现在总是走 general-purpose,统一两分支为基础文本 + fork 追加说明
|
||||
|
||||
- [x] 移除背景任务说明的 `!forkEnabled` 条件
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内背景任务说明的条件判断(~L259-261)
|
||||
- 将条件从 `!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) && !isInProcessTeammate() && !forkEnabled` 改为 `!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) && !isInProcessTeammate()`
|
||||
- 原因: Task 1 已解耦 forceAsync,fork agent 不再强制异步,背景任务说明应在 fork 启用时也显示
|
||||
|
||||
- [x] 更新 continue agent note 中的术语
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 continue agent 说明(~L267)
|
||||
- 将条件文本从 `'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.'` 替换为 `'Each non-fork Agent invocation starts without context — provide a complete task description.'`
|
||||
- 原因: 与 writingThePromptSection 保持术语一致
|
||||
|
||||
- [x] 更新 `forkExamples` 中第一个示例调用,添加 `fork: true` 参数
|
||||
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 `forkExamples` 模板字面量(~L120-124)
|
||||
- 在 `Agent({...})` 调用中 `description:` 行之后添加 `fork: true,` 行
|
||||
- 第二个示例(~L133-139)是"mid-wait"场景无工具调用,保持不变;第三个示例(~L141-154)有 `subagent_type: "code-reviewer"` 是 fresh agent 场景,保持不变
|
||||
- 原因: 第一个示例展示 fork 用法,需要显式传入 `fork: true`
|
||||
|
||||
- [x] 为 prompt.ts 的 fork 相关文本变更编写单元测试
|
||||
- 测试文件: `packages/builtin-tools/src/tools/AgentTool/__tests__/prompt.test.ts`
|
||||
- 测试场景:
|
||||
- `forkEnabled = true` 时: prompt 不包含 "omit `subagent_type`" 文本,包含 "`fork: true`" 文本
|
||||
- `forkEnabled = true` 时: prompt 包含 "non-fork" 术语(替代 "fresh agent")
|
||||
- `forkEnabled = true` 时: prompt 包含 "Set `fork: true` to fork from the parent" 说明
|
||||
- `forkEnabled = true` 时: prompt 包含背景任务说明(`run_in_background`)
|
||||
- `forkEnabled = false` 时: prompt 不包含 "`fork: true`" 文本,不包含 "When to fork" section
|
||||
- `forkEnabled = false` 时: prompt 包含 "general-purpose agent" 回退说明
|
||||
- Mock 列表: `isForkSubagentEnabled`(返回 true/false)、`getFeatureValue_CACHED_MAY_BE_STALE`(返回 false)、`shouldInjectAgentListInMessages`(返回 false)、`isInProcessTeammate`(返回 false)、`isTeammate`(返回 false)、`getSubscriptionType`(返回 'pro')、`hasEmbeddedSearchTools`(返回 false)、环境变量 `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` 未定义
|
||||
- 运行命令: `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/prompt.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证 prompt 中不再包含 "omit `subagent_type`" 引导文本
|
||||
- `grep -n "omit" packages/builtin-tools/src/tools/AgentTool/prompt.ts`
|
||||
- 预期: 无输出
|
||||
|
||||
- [x] 验证 prompt 中包含 "`fork: true`" 文本
|
||||
- `grep -c "fork: true" packages/builtin-tools/src/tools/AgentTool/prompt.ts`
|
||||
- 预期: 输出 >= 3(shared section + whenToForkSection + forkExamples)
|
||||
|
||||
- [x] 验证背景任务条件中不再包含 `!forkEnabled`
|
||||
- `grep -n "forkEnabled" packages/builtin-tools/src/tools/AgentTool/prompt.ts`
|
||||
- 预期: 所有匹配行均为 `forkEnabled ?` 形式的三元表达式条件,不包含 `!forkEnabled`
|
||||
|
||||
- [x] 运行 prompt 单元测试
|
||||
- `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/prompt.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
- [x] 运行 precheck 确保无回归
|
||||
- `bun run precheck`
|
||||
- 预期: 零错误通过(typecheck + lint + test)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fork Agent 显式参数触发 验收
|
||||
|
||||
**前置条件:**
|
||||
- 启动命令: `bun run dev`(开发模式)
|
||||
- 环境变量: `FEATURE_FORK_SUBAGENT=1` 启用 fork 功能
|
||||
|
||||
**端到端验证:**
|
||||
|
||||
1. 运行完整测试套件确保无回归
|
||||
- `bun run precheck`
|
||||
- 预期: typecheck + lint + test 全部通过,零错误
|
||||
- 失败排查: 检查 Task 1(AgentTool.tsx 路由逻辑)和 Task 2(prompt.ts 文本)的修改
|
||||
|
||||
2. 验证 `fork: true` + flag 启用时走 fork 路径
|
||||
- `grep -n 'isForkPath = fork === true' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
|
||||
- 预期: 找到路由逻辑行,确认 `fork === true && isForkSubagentEnabled()` 条件
|
||||
- 失败排查: 检查 Task 1 路由逻辑步骤
|
||||
|
||||
3. 验证 `fork` 参数在 flag 关闭时不在 schema 中
|
||||
- `grep -n 'omit.*fork' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
|
||||
- 预期: 找到 `schema.omit({ fork: true })` 行
|
||||
- 失败排查: 检查 Task 1 inputSchema 裁剪逻辑
|
||||
|
||||
4. 验证 `forceAsync` 已完全移除,不再绑定 `isForkSubagentEnabled()`
|
||||
- `grep -c 'forceAsync' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
|
||||
- 预期: 0(无匹配)
|
||||
- 失败排查: 检查 Task 1 forceAsync 删除步骤
|
||||
|
||||
5. 验证 prompt 中不再引导"省略 subagent_type 触发 fork"
|
||||
- `grep -c 'omit.*subagent_type' packages/builtin-tools/src/tools/AgentTool/prompt.ts`
|
||||
- 预期: 0(无匹配)
|
||||
- `grep -c 'fork: true' packages/builtin-tools/src/tools/AgentTool/prompt.ts`
|
||||
- 预期: >= 3(shared section + whenToForkSection + forkExamples)
|
||||
- 失败排查: 检查 Task 2 prompt 文本替换步骤
|
||||
|
||||
6. 验证后台/同步行为由 `run_in_background` 参数控制
|
||||
- `grep -n 'run_in_background' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | head -5`
|
||||
- 预期: `shouldRunAsync` 计算中包含 `run_in_background === true` 条件,无 `forceAsync` 条件
|
||||
- 失败排查: 检查 Task 1 shouldRunAsync 修改步骤
|
||||
@@ -41,11 +41,7 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||
import type { APIError } from '@anthropic-ai/sdk'
|
||||
import type {
|
||||
CompactMetadata,
|
||||
Message,
|
||||
SystemCompactBoundaryMessage,
|
||||
} from './types/message.js'
|
||||
import type { Message, SystemCompactBoundaryMessage } from './types/message.js'
|
||||
import type { OrphanedPermission } from './types/textInputTypes.js'
|
||||
import { createAbortController } from './utils/abortController.js'
|
||||
import type { AttributionState } from './utils/commitAttribution.js'
|
||||
@@ -1051,7 +1047,9 @@ export class QueryEngine {
|
||||
initialAppState.fastMode,
|
||||
),
|
||||
uuid: randomUUID(),
|
||||
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
|
||||
errors: [
|
||||
`Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`,
|
||||
],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
logEvent,
|
||||
logEventAsync,
|
||||
} from '../services/analytics/index.js'
|
||||
import { isInBundledMode } from '../utils/bundledMode.js'
|
||||
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { rcLog } from './rcDebugLog.js'
|
||||
|
||||
@@ -72,7 +72,6 @@ import type {
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ import type {
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
|
||||
/**
|
||||
* StdoutMessage with optional session_id. The transport layer accepts
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Companion display card — shown by /buddy (no args).
|
||||
* Mirrors official vc8 component: bordered box with sprite, stats, last reaction.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useInput } from '@anthropic/ink';
|
||||
import { renderSprite } from './sprites.js';
|
||||
|
||||
59
src/cli/__tests__/userFacingErrorMessages.test.ts
Normal file
59
src/cli/__tests__/userFacingErrorMessages.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing error messages include actionable guidance.
|
||||
* These are pure string-formatting tests — no side effects.
|
||||
*/
|
||||
|
||||
describe('User-facing error messages', () => {
|
||||
test('budget exceeded message includes budget and guidance', () => {
|
||||
const maxBudgetUsd = 5.0
|
||||
const message = `Error: Exceeded USD budget ($${maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`
|
||||
|
||||
expect(message).toContain('Exceeded USD budget')
|
||||
expect(message).toContain('$5')
|
||||
expect(message).toContain('--max-budget-usd')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
|
||||
test('max turns message includes guidance', () => {
|
||||
const maxTurns = 10
|
||||
const message = `Error: Reached max turns (${maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`
|
||||
|
||||
expect(message).toContain('max turns')
|
||||
expect(message).toContain('--max-turns')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
|
||||
test('structured output retry message includes guidance', () => {
|
||||
const message =
|
||||
'Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.'
|
||||
|
||||
expect(message).toContain('structured output')
|
||||
expect(message).toContain('Simplify your schema')
|
||||
})
|
||||
|
||||
test('QueryEngine budget error includes actionable hint', () => {
|
||||
const maxBudgetUsd = 3.0
|
||||
const message = `Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`
|
||||
|
||||
expect(message).toContain('maximum budget')
|
||||
expect(message).toContain('--max-budget-usd')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Onboarding security copy', () => {
|
||||
test('security heading uses friendly tone', () => {
|
||||
const heading = 'Before you start, keep in mind:'
|
||||
expect(heading).not.toContain('Security')
|
||||
expect(heading).toContain('Before you start')
|
||||
})
|
||||
|
||||
test('trust dialog copy is concise', () => {
|
||||
const body =
|
||||
'Is this a project you trust? (Your own code, a well-known open source project, or work from your team).'
|
||||
expect(body.length).toBeLessThan(120)
|
||||
expect(body).toContain('trust')
|
||||
})
|
||||
})
|
||||
@@ -68,13 +68,3 @@ export class TmuxEngine implements BgEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTmuxInstallHint(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
return 'Install with: brew install tmux'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return 'tmux is not natively available on Windows. Consider using WSL.'
|
||||
}
|
||||
return 'Install with: sudo apt install tmux (or your package manager)'
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { stat } from 'fs/promises';
|
||||
import pMap from 'p-map';
|
||||
import { cwd } from 'process';
|
||||
import React from 'react';
|
||||
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js';
|
||||
import { wrappedRender as render } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
|
||||
@@ -112,7 +112,6 @@ import type {
|
||||
ModelInfo,
|
||||
SDKMessage,
|
||||
SDKUserMessage,
|
||||
SDKUserMessageReplay,
|
||||
PermissionResult,
|
||||
McpServerConfigForProcessTransport,
|
||||
McpServerStatus,
|
||||
@@ -961,14 +960,18 @@ export async function runHeadless(
|
||||
writeToStdout(`Execution error`)
|
||||
break
|
||||
case 'error_max_turns':
|
||||
writeToStdout(`Error: Reached max turns (${options.maxTurns})`)
|
||||
writeToStdout(
|
||||
`Error: Reached max turns (${options.maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`,
|
||||
)
|
||||
break
|
||||
case 'error_max_budget_usd':
|
||||
writeToStdout(`Error: Exceeded USD budget (${options.maxBudgetUsd})`)
|
||||
writeToStdout(
|
||||
`Error: Exceeded USD budget ($${options.maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`,
|
||||
)
|
||||
break
|
||||
case 'error_max_structured_output_retries':
|
||||
writeToStdout(
|
||||
`Error: Failed to provide valid structured output after maximum retries`,
|
||||
`Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5473,7 +5476,7 @@ function getStructuredIO(
|
||||
*/
|
||||
export async function handleOrphanedPermissionResponse({
|
||||
message,
|
||||
setAppState,
|
||||
setAppState: _setAppState,
|
||||
onEnqueued,
|
||||
handledToolUseIds,
|
||||
}: {
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import chalk from 'chalk'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import {
|
||||
getLatestVersion,
|
||||
type InstallStatus,
|
||||
installGlobalPackage,
|
||||
} from 'src/utils/autoUpdater.js'
|
||||
import { regenerateCompletionCache } from 'src/utils/completionCache.js'
|
||||
import {
|
||||
getGlobalConfig,
|
||||
type InstallMethod,
|
||||
saveGlobalConfig,
|
||||
} from 'src/utils/config.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { getDoctorDiagnostic } from 'src/utils/doctorDiagnostic.js'
|
||||
import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'
|
||||
import {
|
||||
installOrUpdateClaudePackage,
|
||||
localInstallationExists,
|
||||
} from 'src/utils/localInstaller.js'
|
||||
import {
|
||||
installLatest as installLatestNative,
|
||||
removeInstalledSymlink,
|
||||
} from 'src/utils/nativeInstaller/index.js'
|
||||
import { getPackageManager } from 'src/utils/nativeInstaller/packageManagers.js'
|
||||
import { writeToStdout } from 'src/utils/process.js'
|
||||
import { gte } from 'src/utils/semver.js'
|
||||
import { getInitialSettings } from 'src/utils/settings/settings.js'
|
||||
|
||||
export async function update() {
|
||||
logEvent('tengu_update_check', {})
|
||||
writeToStdout(`Current version: ${MACRO.VERSION}\n`)
|
||||
|
||||
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
|
||||
writeToStdout(`Checking for updates to ${channel} version...\n`)
|
||||
|
||||
logForDebugging('update: Starting update check')
|
||||
|
||||
// Run diagnostic to detect potential issues
|
||||
logForDebugging('update: Running diagnostic')
|
||||
const diagnostic = await getDoctorDiagnostic()
|
||||
logForDebugging(`update: Installation type: ${diagnostic.installationType}`)
|
||||
logForDebugging(
|
||||
`update: Config install method: ${diagnostic.configInstallMethod}`,
|
||||
)
|
||||
|
||||
// Check for multiple installations
|
||||
if (diagnostic.multipleInstallations.length > 1) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(chalk.yellow('Warning: Multiple installations found') + '\n')
|
||||
for (const install of diagnostic.multipleInstallations) {
|
||||
const current =
|
||||
diagnostic.installationType === install.type
|
||||
? ' (currently running)'
|
||||
: ''
|
||||
writeToStdout(`- ${install.type} at ${install.path}${current}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
// Display warnings if any exist
|
||||
if (diagnostic.warnings.length > 0) {
|
||||
writeToStdout('\n')
|
||||
for (const warning of diagnostic.warnings) {
|
||||
logForDebugging(`update: Warning detected: ${warning.issue}`)
|
||||
|
||||
// Don't skip PATH warnings - they're always relevant
|
||||
// The user needs to know that 'which claude' points elsewhere
|
||||
logForDebugging(`update: Showing warning: ${warning.issue}`)
|
||||
|
||||
writeToStdout(chalk.yellow(`Warning: ${warning.issue}\n`))
|
||||
|
||||
writeToStdout(chalk.bold(`Fix: ${warning.fix}\n`))
|
||||
}
|
||||
}
|
||||
|
||||
// Update config if installMethod is not set (but skip for package managers)
|
||||
const config = getGlobalConfig()
|
||||
if (
|
||||
!config.installMethod &&
|
||||
diagnostic.installationType !== 'package-manager'
|
||||
) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout('Updating configuration to track installation method...\n')
|
||||
let detectedMethod: 'local' | 'native' | 'global' | 'unknown' = 'unknown'
|
||||
|
||||
// Map diagnostic installation type to config install method
|
||||
switch (diagnostic.installationType) {
|
||||
case 'npm-local':
|
||||
detectedMethod = 'local'
|
||||
break
|
||||
case 'native':
|
||||
detectedMethod = 'native'
|
||||
break
|
||||
case 'npm-global':
|
||||
detectedMethod = 'global'
|
||||
break
|
||||
default:
|
||||
detectedMethod = 'unknown'
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
installMethod: detectedMethod,
|
||||
}))
|
||||
writeToStdout(`Installation method set to: ${detectedMethod}\n`)
|
||||
}
|
||||
|
||||
// Check if running from development build
|
||||
if (diagnostic.installationType === 'development') {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(
|
||||
chalk.yellow('Warning: Cannot update development build') + '\n',
|
||||
)
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
// Check if running from a package manager
|
||||
if (diagnostic.installationType === 'package-manager') {
|
||||
const packageManager = await getPackageManager()
|
||||
writeToStdout('\n')
|
||||
|
||||
if (packageManager === 'homebrew') {
|
||||
writeToStdout('Claude is managed by Homebrew.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else if (packageManager === 'winget') {
|
||||
writeToStdout('Claude is managed by winget.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(
|
||||
chalk.bold(' winget upgrade Anthropic.ClaudeCode') + '\n',
|
||||
)
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else if (packageManager === 'apk') {
|
||||
writeToStdout('Claude is managed by apk.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else {
|
||||
// pacman, deb, and rpm don't get specific commands because they each have
|
||||
// multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala,
|
||||
// rpm: dnf/yum/zypper)
|
||||
writeToStdout('Claude is managed by a package manager.\n')
|
||||
writeToStdout('Please use your package manager to update.\n')
|
||||
}
|
||||
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
// Check for config/reality mismatch (skip for package-manager installs)
|
||||
if (
|
||||
config.installMethod &&
|
||||
diagnostic.configInstallMethod !== 'not set' &&
|
||||
diagnostic.installationType !== 'package-manager'
|
||||
) {
|
||||
const runningType = diagnostic.installationType
|
||||
const configExpects = diagnostic.configInstallMethod
|
||||
|
||||
// Map installation types for comparison
|
||||
const typeMapping: Record<string, string> = {
|
||||
'npm-local': 'local',
|
||||
'npm-global': 'global',
|
||||
native: 'native',
|
||||
development: 'development',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
|
||||
const normalizedRunningType = typeMapping[runningType] || runningType
|
||||
|
||||
if (
|
||||
normalizedRunningType !== configExpects &&
|
||||
configExpects !== 'unknown'
|
||||
) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(chalk.yellow('Warning: Configuration mismatch') + '\n')
|
||||
writeToStdout(`Config expects: ${configExpects} installation\n`)
|
||||
writeToStdout(`Currently running: ${runningType}\n`)
|
||||
writeToStdout(
|
||||
chalk.yellow(
|
||||
`Updating the ${runningType} installation you are currently using`,
|
||||
) + '\n',
|
||||
)
|
||||
|
||||
// Update config to match reality
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
installMethod: normalizedRunningType as InstallMethod,
|
||||
}))
|
||||
writeToStdout(
|
||||
`Config updated to reflect current installation method: ${normalizedRunningType}\n`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle native installation updates first
|
||||
if (diagnostic.installationType === 'native') {
|
||||
logForDebugging(
|
||||
'update: Detected native installation, using native updater',
|
||||
)
|
||||
try {
|
||||
const result = await installLatestNative(channel, true)
|
||||
|
||||
// Handle lock contention gracefully
|
||||
if (result.lockFailed) {
|
||||
const pidInfo = result.lockHolderPid
|
||||
? ` (PID ${result.lockHolderPid})`
|
||||
: ''
|
||||
writeToStdout(
|
||||
chalk.yellow(
|
||||
`Another Claude process${pidInfo} is currently running. Please try again in a moment.`,
|
||||
) + '\n',
|
||||
)
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
if (!result.latestVersion) {
|
||||
process.stderr.write('Failed to check for updates\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
if (result.latestVersion === MACRO.VERSION) {
|
||||
writeToStdout(
|
||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
||||
)
|
||||
} else {
|
||||
writeToStdout(
|
||||
chalk.green(
|
||||
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
|
||||
) + '\n',
|
||||
)
|
||||
await regenerateCompletionCache()
|
||||
}
|
||||
await gracefulShutdown(0)
|
||||
} catch (error) {
|
||||
process.stderr.write('Error: Failed to install native update\n')
|
||||
process.stderr.write(String(error) + '\n')
|
||||
process.stderr.write('Try running "claude doctor" for diagnostics\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to existing JS/npm-based update logic
|
||||
// Remove native installer symlink since we're not using native installation
|
||||
// But only if user hasn't migrated to native installation
|
||||
if (config.installMethod !== 'native') {
|
||||
await removeInstalledSymlink()
|
||||
}
|
||||
|
||||
logForDebugging('update: Checking npm registry for latest version')
|
||||
logForDebugging(`update: Package URL: ${MACRO.PACKAGE_URL}`)
|
||||
const npmTag = channel === 'stable' ? 'stable' : 'latest'
|
||||
const npmCommand = `npm view ${MACRO.PACKAGE_URL}@${npmTag} version`
|
||||
logForDebugging(`update: Running: ${npmCommand}`)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
logForDebugging(
|
||||
`update: Latest version from npm: ${latestVersion || 'FAILED'}`,
|
||||
)
|
||||
|
||||
if (!latestVersion) {
|
||||
logForDebugging('update: Failed to get latest version from npm registry')
|
||||
process.stderr.write(chalk.red('Failed to check for updates') + '\n')
|
||||
process.stderr.write('Unable to fetch latest version from npm registry\n')
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Possible causes:\n')
|
||||
process.stderr.write(' • Network connectivity issues\n')
|
||||
process.stderr.write(' • npm registry is unreachable\n')
|
||||
process.stderr.write(' • Corporate proxy/firewall blocking npm\n')
|
||||
if (MACRO.PACKAGE_URL && !MACRO.PACKAGE_URL.startsWith('@anthropic')) {
|
||||
process.stderr.write(
|
||||
' • Internal/development build not published to npm\n',
|
||||
)
|
||||
}
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Try:\n')
|
||||
process.stderr.write(' • Check your internet connection\n')
|
||||
process.stderr.write(' • Run with --debug flag for more details\n')
|
||||
const packageName =
|
||||
MACRO.PACKAGE_URL ||
|
||||
(process.env.USER_TYPE === 'ant'
|
||||
? '@anthropic-ai/claude-cli'
|
||||
: '@anthropic-ai/claude-code')
|
||||
process.stderr.write(
|
||||
` • Manually check: npm view ${packageName} version\n`,
|
||||
)
|
||||
|
||||
process.stderr.write(' • Check if you need to login: npm whoami\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
// Check if versions match exactly, including any build metadata (like SHA)
|
||||
if (latestVersion === MACRO.VERSION) {
|
||||
writeToStdout(
|
||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
||||
)
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
writeToStdout(
|
||||
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
|
||||
)
|
||||
writeToStdout('Installing update...\n')
|
||||
|
||||
// Determine update method based on what's actually running
|
||||
let useLocalUpdate = false
|
||||
let updateMethodName = ''
|
||||
|
||||
switch (diagnostic.installationType) {
|
||||
case 'npm-local':
|
||||
useLocalUpdate = true
|
||||
updateMethodName = 'local'
|
||||
break
|
||||
case 'npm-global':
|
||||
useLocalUpdate = false
|
||||
updateMethodName = 'global'
|
||||
break
|
||||
case 'unknown': {
|
||||
// Fallback to detection if we can't determine installation type
|
||||
const isLocal = await localInstallationExists()
|
||||
useLocalUpdate = isLocal
|
||||
updateMethodName = isLocal ? 'local' : 'global'
|
||||
writeToStdout(
|
||||
chalk.yellow('Warning: Could not determine installation type') + '\n',
|
||||
)
|
||||
writeToStdout(
|
||||
`Attempting ${updateMethodName} update based on file detection...\n`,
|
||||
)
|
||||
break
|
||||
}
|
||||
default:
|
||||
process.stderr.write(
|
||||
`Error: Cannot update ${diagnostic.installationType} installation\n`,
|
||||
)
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
writeToStdout(`Using ${updateMethodName} installation update method...\n`)
|
||||
|
||||
logForDebugging(`update: Update method determined: ${updateMethodName}`)
|
||||
logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`)
|
||||
|
||||
let status: InstallStatus
|
||||
|
||||
if (useLocalUpdate) {
|
||||
logForDebugging(
|
||||
'update: Calling installOrUpdateClaudePackage() for local update',
|
||||
)
|
||||
status = await installOrUpdateClaudePackage(channel)
|
||||
} else {
|
||||
logForDebugging('update: Calling installGlobalPackage() for global update')
|
||||
status = await installGlobalPackage()
|
||||
}
|
||||
|
||||
logForDebugging(`update: Installation status: ${status}`)
|
||||
|
||||
switch (status) {
|
||||
case 'success':
|
||||
writeToStdout(
|
||||
chalk.green(
|
||||
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
|
||||
) + '\n',
|
||||
)
|
||||
await regenerateCompletionCache()
|
||||
break
|
||||
case 'no_permissions':
|
||||
process.stderr.write(
|
||||
'Error: Insufficient permissions to install update\n',
|
||||
)
|
||||
if (useLocalUpdate) {
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
process.stderr.write(
|
||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
)
|
||||
} else {
|
||||
process.stderr.write('Try running with sudo or fix npm permissions\n')
|
||||
process.stderr.write(
|
||||
'Or consider using native installation with: claude install\n',
|
||||
)
|
||||
}
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
case 'install_failed':
|
||||
process.stderr.write('Error: Failed to install update\n')
|
||||
if (useLocalUpdate) {
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
process.stderr.write(
|
||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'Or consider using native installation with: claude install\n',
|
||||
)
|
||||
}
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
case 'in_progress':
|
||||
process.stderr.write(
|
||||
'Error: Another instance is currently performing an update\n',
|
||||
)
|
||||
process.stderr.write('Please wait and try again later\n')
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
}
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ export async function updateCCB(): Promise<void> {
|
||||
|
||||
try {
|
||||
if (pkgManager === 'bun') {
|
||||
execSync(`bun update -g ${PACKAGE_NAME}`, {
|
||||
execSync(`bun install -g ${PACKAGE_NAME}@latest`, {
|
||||
stdio: 'inherit',
|
||||
cwd: homedir(),
|
||||
timeout: 120_000,
|
||||
@@ -153,7 +153,9 @@ export async function updateCCB(): Promise<void> {
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
if (pkgManager === 'bun') {
|
||||
process.stderr.write(chalk.bold(` bun update -g ${PACKAGE_NAME}`) + '\n')
|
||||
process.stderr.write(
|
||||
chalk.bold(` bun install -g ${PACKAGE_NAME}@latest`) + '\n',
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(
|
||||
chalk.bold(` npm install -g ${PACKAGE_NAME}@latest`) + '\n',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { resolve } from 'path';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Dialog } from '../../components/design-system/Dialog.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Doctor } from '../../screens/Doctor.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { HelpV2 } from '../../components/HelpV2/HelpV2.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
|
||||
export function CheckGitHubStep() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Workflow } from './types.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'src/components/CustomSelect/index.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { resetCostState } from '../../bootstrap/state.js';
|
||||
import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { createPermissionRetryMessage } from '../../utils/messages.js';
|
||||
|
||||
@@ -36,7 +36,7 @@ function getMergedEnv(): Record<string, string> {
|
||||
return merged
|
||||
}
|
||||
|
||||
const call: LocalCommandCall = async (args, context) => {
|
||||
const call: LocalCommandCall = async (args, _context) => {
|
||||
const arg = args.trim().toLowerCase()
|
||||
|
||||
// No argument: show current provider
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ChildProcess } from 'child_process';
|
||||
import { resolve } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getBridgeDisabledReason, isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
|
||||
import { getBridgeDisabledReason } from '../../bridge/bridgeEnabled.js';
|
||||
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js';
|
||||
import { BRIDGE_LOGIN_INSTRUCTION } from '../../bridge/types.js';
|
||||
import { Dialog } from '../../components/design-system/Dialog.js';
|
||||
|
||||
55
src/commands/resume/__tests__/resume.test.ts
Normal file
55
src/commands/resume/__tests__/resume.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing guidance in model picker and resume command
|
||||
* is concise and actionable. Pure string tests — no side effects.
|
||||
*/
|
||||
|
||||
describe('ModelPicker subtitle', () => {
|
||||
test('subtitle mentions effort and context controls', () => {
|
||||
const subtitle =
|
||||
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
|
||||
expect(subtitle).toContain('effort')
|
||||
expect(subtitle).toContain('1M context')
|
||||
expect(subtitle).toContain('sessions')
|
||||
})
|
||||
|
||||
test('subtitle is under 120 characters', () => {
|
||||
const subtitle =
|
||||
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
|
||||
expect(subtitle.length).toBeLessThan(120)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resume error messages', () => {
|
||||
test('session not found suggests /resume to browse', () => {
|
||||
const message =
|
||||
'Session my-session was not found. Run /resume without arguments to browse all sessions.'
|
||||
expect(message).toContain('not found')
|
||||
expect(message).toContain('/resume')
|
||||
expect(message).toContain('browse')
|
||||
})
|
||||
|
||||
test('multiple matches suggests /resume to pick', () => {
|
||||
const message =
|
||||
'Found 3 sessions matching test. Run /resume to pick one from the list.'
|
||||
expect(message).toContain('3 sessions')
|
||||
expect(message).toContain('/resume')
|
||||
expect(message).toContain('pick')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cost command subscriber messages', () => {
|
||||
test('overage message mentions the key behavior', () => {
|
||||
const msg =
|
||||
'You are currently using your overages to power your Claude Code usage. We will automatically switch you back to your subscription rate limits when they reset'
|
||||
expect(msg).toContain('overages')
|
||||
expect(msg).toContain('automatically switch')
|
||||
})
|
||||
|
||||
test('subscription message is concise', () => {
|
||||
const msg =
|
||||
'You are currently using your subscription to power your Claude Code usage'
|
||||
expect(msg.length).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
@@ -36,9 +36,9 @@ type ResumeResult =
|
||||
function resumeHelpMessage(result: ResumeResult): string {
|
||||
switch (result.resultType) {
|
||||
case 'sessionNotFound':
|
||||
return `Session ${chalk.bold(result.arg)} was not found.`;
|
||||
return `Session ${chalk.bold(result.arg)} was not found. Run ${chalk.bold('/resume')} without arguments to browse all sessions.`;
|
||||
case 'multipleMatches':
|
||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`;
|
||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Run ${chalk.bold('/resume')} to pick one from the list.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js';
|
||||
import React from 'react';
|
||||
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { checkOverageGate, confirmOverage, launchRemoteReview } from './reviewRemote.js';
|
||||
import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js';
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
exportInstincts,
|
||||
findPromotionCandidates,
|
||||
generateSkillCandidates,
|
||||
importInstincts,
|
||||
ingestTranscript,
|
||||
listKnownProjects,
|
||||
loadInstincts,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Stats } from '../../components/Stats.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js';
|
||||
import type { Command } from '../commands.js';
|
||||
import { DIAMOND_OPEN } from '../constants/figures.js';
|
||||
@@ -21,7 +20,6 @@ import { logForDebugging } from '../utils/debug.js';
|
||||
import { errorMessage } from '../utils/errors.js';
|
||||
import { logError } from '../utils/log.js';
|
||||
import { enqueuePendingNotification } from '../utils/messageQueueManager.js';
|
||||
import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js';
|
||||
import { updateTaskState } from '../utils/task/framework.js';
|
||||
import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js';
|
||||
import { pollForApprovedExitPlanMode, UltraplanPollError } from '../utils/ultraplan/ccrSession.js';
|
||||
@@ -36,13 +34,6 @@ import { registerCleanup } from '../utils/cleanupRegistry.js';
|
||||
// TODO(prod-hardening): OAuth token may go stale over the 30min poll;
|
||||
// consider refresh.
|
||||
|
||||
/**
|
||||
* Multi-agent exploration is slow; 30min timeout.
|
||||
*
|
||||
* @deprecated use getUltraplanTimeoutMs()
|
||||
*/
|
||||
const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web';
|
||||
|
||||
export function getUltraplanTimeoutMs(): number {
|
||||
@@ -61,15 +52,6 @@ export function isUltraplanEnabled(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// CCR runs against the first-party API — use the canonical ID, not the
|
||||
// provider-specific string getModelStrings() would return (which may be a
|
||||
// Bedrock ARN or Vertex ID on the local CLI). Read at call time, not module
|
||||
// load: the GrowthBook cache is empty at import and `/config` Gates can flip
|
||||
// it between invocations.
|
||||
function getUltraplanModel(): string {
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus47.firstParty);
|
||||
}
|
||||
|
||||
// prompt.txt is wrapped in <system-reminder> so the CCR browser hides
|
||||
// scaffolding (CLI_BLOCK_TAGS dropped by stripSystemNotifications)
|
||||
// while the model still sees full text.
|
||||
@@ -84,19 +66,6 @@ const _rawPrompt = require('../utils/ultraplan/prompt.txt');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default).trimEnd();
|
||||
|
||||
// Dev-only prompt override resolved eagerly at module load.
|
||||
// Gated to ant builds (USER_TYPE is a build-time define,
|
||||
// so the override path is DCE'd from external builds).
|
||||
// Shell-set env only, so top-level process.env read is fine
|
||||
// — settings.env never injects this.
|
||||
// @deprecated use buildUltraplanPrompt()
|
||||
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- ant-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
|
||||
const ULTRAPLAN_INSTRUCTIONS: string =
|
||||
process.env.USER_TYPE === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE
|
||||
? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd()
|
||||
: DEFAULT_INSTRUCTIONS;
|
||||
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
|
||||
|
||||
/**
|
||||
* Assemble the initial CCR user message. seedPlan and blurb stay outside the
|
||||
* system-reminder so the browser renders them; scaffolding is hidden.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { formatCost } from '../cost-tracker.js';
|
||||
import { Box, Text, ProgressBar } from '@anthropic/ink';
|
||||
import { formatTokens } from '../utils/format.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
type RateLimitBucket = {
|
||||
utilization: number;
|
||||
resets_at: number;
|
||||
};
|
||||
|
||||
type BuiltinStatusLineProps = {
|
||||
modelName: string;
|
||||
contextUsedPct: number;
|
||||
usedTokens: number;
|
||||
contextWindowSize: number;
|
||||
totalCostUsd: number;
|
||||
rateLimits: {
|
||||
five_hour?: RateLimitBucket;
|
||||
seven_day?: RateLimitBucket;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a countdown from now until the given epoch time (in seconds).
|
||||
* Returns a compact human-readable string like "3h12m", "5d20h", "45m", or "now".
|
||||
*/
|
||||
export function formatCountdown(epochSeconds: number): string {
|
||||
const diff = epochSeconds - Date.now() / 1000;
|
||||
if (diff <= 0) return 'now';
|
||||
|
||||
const days = Math.floor(diff / 86400);
|
||||
const hours = Math.floor((diff % 86400) / 3600);
|
||||
const minutes = Math.floor((diff % 3600) / 60);
|
||||
|
||||
if (days >= 1) return `${days}d${hours}h`;
|
||||
if (hours >= 1) return `${hours}h${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <Text dimColor>{' \u2502 '}</Text>;
|
||||
}
|
||||
|
||||
function BuiltinStatusLineInner({
|
||||
modelName,
|
||||
contextUsedPct,
|
||||
usedTokens,
|
||||
contextWindowSize,
|
||||
totalCostUsd,
|
||||
rateLimits,
|
||||
}: BuiltinStatusLineProps) {
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
// Force re-render every 60s so countdowns stay current
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const hasResetTime = (rateLimits.five_hour?.resets_at ?? 0) || (rateLimits.seven_day?.resets_at ?? 0);
|
||||
if (!hasResetTime) return;
|
||||
const id = setInterval(() => setTick(t => t + 1), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [rateLimits.five_hour?.resets_at, rateLimits.seven_day?.resets_at]);
|
||||
|
||||
// Suppress unused-variable lint for tick (it exists only to trigger re-renders)
|
||||
void tick;
|
||||
|
||||
// Model display: use first two words (e.g. "Opus 4.6") instead of just first word
|
||||
const modelParts = modelName.split(' ');
|
||||
const shortModel = modelParts.length >= 2 ? `${modelParts[0]} ${modelParts[1]}` : modelName;
|
||||
|
||||
const wide = columns >= 100;
|
||||
const narrow = columns < 60;
|
||||
|
||||
const hasFiveHour = rateLimits.five_hour != null;
|
||||
const hasSevenDay = rateLimits.seven_day != null;
|
||||
|
||||
const fiveHourPct = hasFiveHour ? Math.round(rateLimits.five_hour!.utilization * 100) : 0;
|
||||
const sevenDayPct = hasSevenDay ? Math.round(rateLimits.seven_day!.utilization * 100) : 0;
|
||||
|
||||
// Token display: "50k/1M"
|
||||
const tokenDisplay = `${formatTokens(usedTokens)}/${formatTokens(contextWindowSize)}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Model name */}
|
||||
<Text>{shortModel}</Text>
|
||||
|
||||
{/* Context usage with token counts */}
|
||||
<Separator />
|
||||
<Text dimColor>Context </Text>
|
||||
<Text>{contextUsedPct}%</Text>
|
||||
{!narrow && <Text dimColor> ({tokenDisplay})</Text>}
|
||||
|
||||
{/* 5-hour session rate limit */}
|
||||
{hasFiveHour && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text dimColor>Session </Text>
|
||||
{wide && (
|
||||
<>
|
||||
<ProgressBar
|
||||
ratio={rateLimits.five_hour!.utilization}
|
||||
width={10}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
<Text> </Text>
|
||||
</>
|
||||
)}
|
||||
<Text>{fiveHourPct}%</Text>
|
||||
{!narrow && rateLimits.five_hour!.resets_at > 0 && (
|
||||
<Text dimColor> {formatCountdown(rateLimits.five_hour!.resets_at)}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 7-day weekly rate limit */}
|
||||
{hasSevenDay && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text dimColor>Weekly </Text>
|
||||
{wide && (
|
||||
<>
|
||||
<ProgressBar
|
||||
ratio={rateLimits.seven_day!.utilization}
|
||||
width={10}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
<Text> </Text>
|
||||
</>
|
||||
)}
|
||||
<Text>{sevenDayPct}%</Text>
|
||||
{!narrow && rateLimits.seven_day!.resets_at > 0 && (
|
||||
<Text dimColor> {formatCountdown(rateLimits.seven_day!.resets_at)}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{totalCostUsd > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text>{formatCost(totalCostUsd)}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner);
|
||||
@@ -79,7 +79,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>
|
||||
Compact summary
|
||||
Conversation summarized to free up context
|
||||
{!isTranscriptMode && (
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
@@ -87,7 +87,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
action="app:toggleTranscript"
|
||||
context="Global"
|
||||
fallback="ctrl+o"
|
||||
description="expand"
|
||||
description="view summary"
|
||||
parens
|
||||
/>
|
||||
</Text>
|
||||
|
||||
@@ -11,12 +11,12 @@ import { getSSLErrorHint } from '@ant/model-provider';
|
||||
import { sendNotification } from '../services/notifier.js';
|
||||
import { OAuthService } from '../services/oauth/index.js';
|
||||
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
|
||||
|
||||
import { logError } from '../utils/log.js';
|
||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import { Select } from './CustomSelect/select.js';
|
||||
import { Spinner } from './Spinner.js';
|
||||
import TextInput from './TextInput.js';
|
||||
import { fi } from 'zod/v4/locales';
|
||||
|
||||
type Props = {
|
||||
onDone(): void;
|
||||
@@ -596,7 +596,7 @@ function OAuthStatusMessage({
|
||||
[activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel],
|
||||
);
|
||||
|
||||
const switchTo = useCallback(
|
||||
const _switchTo = useCallback(
|
||||
(target: Field) => {
|
||||
setOAuthStatus(buildState(activeField, inputValue, target));
|
||||
setInputValue(displayValues[target] ?? '');
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function FileEditToolUpdatedMessage({
|
||||
filePath,
|
||||
filePath: _filePath,
|
||||
structuredPatch,
|
||||
style,
|
||||
verbose,
|
||||
|
||||
@@ -5,11 +5,28 @@ import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js';
|
||||
export function General(): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1} gap={1}>
|
||||
<Box>
|
||||
<Text>
|
||||
Claude understands your codebase, makes edits with your permission, and executes commands — right from your
|
||||
terminal.
|
||||
</Text>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold>Getting started</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold>1. </Text>
|
||||
<Text>Ask a question or describe a task — Claude will explore your code and respond.</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>2. </Text>
|
||||
<Text>When Claude wants to edit files or run commands, you review and approve each action.</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>3. </Text>
|
||||
<Text>Type </Text>
|
||||
<Text bold>/commit</Text>
|
||||
<Text> to commit changes, </Text>
|
||||
<Text bold>/help</Text>
|
||||
<Text> for commands, or </Text>
|
||||
<Text bold>?</Text>
|
||||
<Text> for shortcuts.</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
|
||||
74
src/components/HelpV2/__tests__/General.test.ts
Normal file
74
src/components/HelpV2/__tests__/General.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing permission and help copy meets usability standards.
|
||||
* These are pure string tests — no side effects, no React rendering.
|
||||
*/
|
||||
|
||||
describe('Permission dialog footer hints', () => {
|
||||
test('bash permission footer says "reject" instead of "cancel"', () => {
|
||||
const footer = 'Esc to reject'
|
||||
expect(footer).toContain('reject')
|
||||
expect(footer).not.toContain('cancel')
|
||||
})
|
||||
|
||||
test('bash permission footer tab hint says "add feedback"', () => {
|
||||
const tabHint = 'Tab to add feedback'
|
||||
expect(tabHint).toContain('feedback')
|
||||
expect(tabHint).not.toContain('amend')
|
||||
})
|
||||
|
||||
test('file permission footer matches bash footer language', () => {
|
||||
const bashFooter = 'Esc to reject'
|
||||
const fileFooter = 'Esc to reject'
|
||||
expect(bashFooter).toBe(fileFooter)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission option labels', () => {
|
||||
test('.claude/ folder option is under 60 chars', () => {
|
||||
const label = 'Yes, allow edits to .claude/ config for this session'
|
||||
expect(label.length).toBeLessThan(60)
|
||||
expect(label).toContain('.claude/')
|
||||
})
|
||||
|
||||
test('accept-once option has simple label', () => {
|
||||
const label = 'Yes'
|
||||
expect(label).toBe('Yes')
|
||||
})
|
||||
|
||||
test('reject option has simple label', () => {
|
||||
const label = 'No'
|
||||
expect(label).toBe('No')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help General page getting started guide', () => {
|
||||
test('step 1 mentions exploring code', () => {
|
||||
const step1 =
|
||||
'Ask a question or describe a task — Claude will explore your code and respond.'
|
||||
expect(step1).toContain('explore')
|
||||
expect(step1).toContain('question')
|
||||
})
|
||||
|
||||
test('step 2 mentions reviewing actions', () => {
|
||||
const step2 =
|
||||
'When Claude wants to edit files or run commands, you review and approve each action.'
|
||||
expect(step2).toContain('review')
|
||||
expect(step2).toContain('approve')
|
||||
})
|
||||
|
||||
test('step 3 mentions key commands', () => {
|
||||
const step3 = '/commit'
|
||||
const step3b = '/help'
|
||||
const step3c = '?'
|
||||
expect(step3).toBe('/commit')
|
||||
expect(step3b).toBe('/help')
|
||||
expect(step3c).toBe('?')
|
||||
})
|
||||
|
||||
test('heading says "Getting started"', () => {
|
||||
const heading = 'Getting started'
|
||||
expect(heading).toBe('Getting started')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
incrementOverageCreditUpsellSeenCount,
|
||||
createOverageCreditFeed,
|
||||
} from './OverageCreditUpsell.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import { getEffortSuffix } from '../../utils/effort.js';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import figures from 'figures';
|
||||
import { homedir } from 'os';
|
||||
import * as React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Step } from '../../projectOnboardingState.js';
|
||||
import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/referral.js';
|
||||
|
||||
@@ -65,20 +65,40 @@ function wrapText(text: string, width: number, options?: { hard?: boolean }): st
|
||||
* 2. Distributing available space proportionally
|
||||
* 3. Wrapping text within cells (no truncation)
|
||||
* 4. Properly aligning multi-line rows with borders
|
||||
*
|
||||
* Performance: uses per-render caches (formatCache, plainTextCache, wrapCache)
|
||||
* to avoid redundant formatCell/wrapText calls across the multiple passes
|
||||
* (width calculation, row line counting, rendering). Wrapped in React.memo
|
||||
* to skip re-renders when props are unchanged.
|
||||
*/
|
||||
export function MarkdownTable({ token, highlight, forceWidth }: Props): React.ReactNode {
|
||||
export const MarkdownTable = React.memo(function MarkdownTable({
|
||||
token,
|
||||
highlight,
|
||||
forceWidth,
|
||||
}: Props): React.ReactNode {
|
||||
const [theme] = useTheme();
|
||||
const { columns: actualTerminalWidth } = useTerminalSize();
|
||||
const terminalWidth = forceWidth ?? actualTerminalWidth;
|
||||
|
||||
// Format cell content to ANSI string
|
||||
// Per-render caches — Token[] references are stable within a single token
|
||||
// prop (from LRU cache in Markdown.tsx), so reference equality is sufficient.
|
||||
const formatCache = new Map<Token[] | undefined, string>();
|
||||
const plainTextCache = new Map<Token[] | undefined, string>();
|
||||
|
||||
function formatCell(tokens: Token[] | undefined): string {
|
||||
return tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? '';
|
||||
const cached = formatCache.get(tokens);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? '';
|
||||
formatCache.set(tokens, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get plain text (stripped of ANSI codes)
|
||||
function getPlainText(tokens: Token[] | undefined): string {
|
||||
return stripAnsi(formatCell(tokens));
|
||||
const cached = plainTextCache.get(tokens);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = stripAnsi(formatCell(tokens));
|
||||
plainTextCache.set(tokens, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get the longest word width in a cell (minimum width to avoid breaking words)
|
||||
@@ -149,43 +169,39 @@ export function MarkdownTable({ token, highlight, forceWidth }: Props): React.Re
|
||||
columnWidths = minWidths.map(w => Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH));
|
||||
}
|
||||
|
||||
// Step 4: Calculate max row lines to determine if vertical format is needed
|
||||
function calculateMaxRowLines(): number {
|
||||
let maxLines = 1;
|
||||
// Check header
|
||||
for (let i = 0; i < token.header.length; i++) {
|
||||
const content = formatCell(token.header[i]!.tokens);
|
||||
const wrapped = wrapText(content, columnWidths[i]!, {
|
||||
hard: needsHardWrap,
|
||||
});
|
||||
maxLines = Math.max(maxLines, wrapped.length);
|
||||
}
|
||||
// Check rows
|
||||
for (const row of token.rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const content = formatCell(row[i]?.tokens);
|
||||
const wrapped = wrapText(content, columnWidths[i]!, {
|
||||
hard: needsHardWrap,
|
||||
});
|
||||
maxLines = Math.max(maxLines, wrapped.length);
|
||||
}
|
||||
}
|
||||
return maxLines;
|
||||
// Step 4: Single-pass cell preparation — wraps each cell once, caches results
|
||||
// for reuse by both row-line counting and rendering.
|
||||
const wrapCache = new Map<Token[] | undefined, string[]>();
|
||||
|
||||
function getWrappedLines(tokens: Token[] | undefined, colIndex: number): string[] {
|
||||
const cached = wrapCache.get(tokens);
|
||||
if (cached !== undefined) return cached;
|
||||
const formatted = formatCell(tokens);
|
||||
const lines = wrapText(formatted, columnWidths[colIndex]!, {
|
||||
hard: needsHardWrap,
|
||||
});
|
||||
wrapCache.set(tokens, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Step 5: Calculate max row lines using cached wrapped results
|
||||
let maxRowLines = 1;
|
||||
for (let i = 0; i < token.header.length; i++) {
|
||||
maxRowLines = Math.max(maxRowLines, getWrappedLines(token.header[i]!.tokens, i).length);
|
||||
}
|
||||
for (const row of token.rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
maxRowLines = Math.max(maxRowLines, getWrappedLines(row[i]?.tokens, i).length);
|
||||
}
|
||||
}
|
||||
|
||||
// Use vertical format if wrapping would make rows too tall
|
||||
const maxRowLines = calculateMaxRowLines();
|
||||
const useVerticalFormat = maxRowLines > MAX_ROW_LINES;
|
||||
|
||||
// Render a single row with potential multi-line cells
|
||||
// Returns an array of strings, one per line of the row
|
||||
function renderRowLines(cells: Array<{ tokens?: Token[] }>, isHeader: boolean): string[] {
|
||||
// Get wrapped lines for each cell (preserving ANSI formatting)
|
||||
const cellLines = cells.map((cell, colIndex) => {
|
||||
const formattedText = formatCell(cell.tokens);
|
||||
const width = columnWidths[colIndex]!;
|
||||
return wrapText(formattedText, width, { hard: needsHardWrap });
|
||||
});
|
||||
// Reuse cached wrapped lines — no redundant formatCell/wrapText
|
||||
const cellLines = cells.map((cell, colIndex) => getWrappedLines(cell.tokens, colIndex));
|
||||
|
||||
// Find max number of lines in this row
|
||||
const maxLines = Math.max(...cellLines.map(lines => lines.length), 1);
|
||||
@@ -231,6 +247,7 @@ export function MarkdownTable({ token, highlight, forceWidth }: Props): React.Re
|
||||
}
|
||||
|
||||
// Render vertical format (key-value pairs) for extra-narrow terminals
|
||||
// Uses formatCell cache; wrapping uses terminal-width params (not column widths)
|
||||
function renderVerticalFormat(): string {
|
||||
const lines: string[] = [];
|
||||
const headers = token.header.map(h => getPlainText(h.tokens));
|
||||
@@ -318,4 +335,4 @@ export function MarkdownTable({ token, highlight, forceWidth }: Props): React.Re
|
||||
|
||||
// Render as a single Ansi block to prevent Ink from wrapping mid-row
|
||||
return <Ansi>{tableLines.join('\n')}</Ansi>;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,7 +229,7 @@ export function ModelPicker({
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{headerText ??
|
||||
'Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.'}
|
||||
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'}
|
||||
</Text>
|
||||
{sessionModel && (
|
||||
<Text dimColor>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
||||
|
||||
const securityStep = (
|
||||
<Box flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<Text bold>Security notes:</Text>
|
||||
<Text bold>Before you start, keep in mind:</Text>
|
||||
<Box flexDirection="column" width={70}>
|
||||
{/**
|
||||
* OrderedList misnumbers items when rendering conditionally,
|
||||
@@ -89,18 +89,18 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
||||
*/}
|
||||
<OrderedList>
|
||||
<OrderedList.Item>
|
||||
<Text>Claude can make mistakes</Text>
|
||||
<Text>Always review changes before accepting</Text>
|
||||
<Text dimColor wrap="wrap">
|
||||
You should always review Claude's responses, especially when
|
||||
Claude can make mistakes — especially when running commands
|
||||
<Newline />
|
||||
running code.
|
||||
or editing files. You stay in control of every action.
|
||||
<Newline />
|
||||
</Text>
|
||||
</OrderedList.Item>
|
||||
<OrderedList.Item>
|
||||
<Text>Due to prompt injection risks, only use it with code you trust</Text>
|
||||
<Text>Only use Claude Code on projects you trust</Text>
|
||||
<Text dimColor wrap="wrap">
|
||||
For more details see:
|
||||
Untrusted code could contain prompt injection attacks.
|
||||
<Newline />
|
||||
<Link url="https://code.claude.com/docs/en/security" />
|
||||
</Text>
|
||||
@@ -111,7 +111,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
||||
</Box>
|
||||
);
|
||||
|
||||
const preflightStep = <PreflightStep onSuccess={goToNextStep} />;
|
||||
const _preflightStep = <PreflightStep onSuccess={goToNextStep} />;
|
||||
// Create the steps array - determine which steps to include based on reAuth and oauthEnabled
|
||||
const apiKeyNeedingApproval = useMemo(() => {
|
||||
// Add API key step if needed
|
||||
|
||||
@@ -24,7 +24,6 @@ import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js';
|
||||
import { toIDEDisplayName } from '../../utils/ide.js';
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
|
||||
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js';
|
||||
import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js';
|
||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
|
||||
import { IdeStatusIndicator } from '../IdeStatusIndicator.js';
|
||||
import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js';
|
||||
@@ -57,13 +56,13 @@ type Props = {
|
||||
|
||||
export function Notifications({
|
||||
apiKeyStatus,
|
||||
autoUpdaterResult,
|
||||
autoUpdaterResult: _autoUpdaterResult,
|
||||
debug,
|
||||
isAutoUpdating,
|
||||
isAutoUpdating: _isAutoUpdating,
|
||||
verbose,
|
||||
messages,
|
||||
onAutoUpdaterResult,
|
||||
onChangeIsUpdating,
|
||||
onAutoUpdaterResult: _onAutoUpdaterResult,
|
||||
onChangeIsUpdating: _onChangeIsUpdating,
|
||||
ideSelection,
|
||||
mcpClients,
|
||||
isInputWrapped = false,
|
||||
@@ -102,9 +101,6 @@ export function Notifications({
|
||||
const shouldShowIdeSelection =
|
||||
ideStatus === 'connected' && (ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0));
|
||||
|
||||
// Hide update installed message when showing IDE selection
|
||||
const shouldShowAutoUpdater = !shouldShowIdeSelection || isAutoUpdating || autoUpdaterResult?.status !== 'success';
|
||||
|
||||
// Check if we're in overage mode for UI indicators
|
||||
const isInOverageMode = claudeAiLimits.isUsingOverage;
|
||||
const subscriptionType = getSubscriptionType();
|
||||
@@ -157,12 +153,6 @@ export function Notifications({
|
||||
verbose={verbose}
|
||||
tokenUsage={tokenUsage}
|
||||
mainLoopModel={mainLoopModel}
|
||||
shouldShowAutoUpdater={shouldShowAutoUpdater}
|
||||
autoUpdaterResult={autoUpdaterResult}
|
||||
isAutoUpdating={isAutoUpdating}
|
||||
isShowingCompactMessage={isShowingCompactMessage}
|
||||
onAutoUpdaterResult={onAutoUpdaterResult}
|
||||
onChangeIsUpdating={onChangeIsUpdating}
|
||||
/>
|
||||
</Box>
|
||||
</SentryErrorBoundary>
|
||||
@@ -180,12 +170,6 @@ function NotificationContent({
|
||||
verbose,
|
||||
tokenUsage,
|
||||
mainLoopModel,
|
||||
shouldShowAutoUpdater,
|
||||
autoUpdaterResult,
|
||||
isAutoUpdating,
|
||||
isShowingCompactMessage,
|
||||
onAutoUpdaterResult,
|
||||
onChangeIsUpdating,
|
||||
}: {
|
||||
ideSelection: IDESelection | undefined;
|
||||
mcpClients?: MCPServerConnection[];
|
||||
@@ -200,12 +184,6 @@ function NotificationContent({
|
||||
verbose: boolean;
|
||||
tokenUsage: number;
|
||||
mainLoopModel: string;
|
||||
shouldShowAutoUpdater: boolean;
|
||||
autoUpdaterResult: AutoUpdaterResult | null;
|
||||
isAutoUpdating: boolean;
|
||||
isShowingCompactMessage: boolean;
|
||||
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
|
||||
onChangeIsUpdating: (isUpdating: boolean) => void;
|
||||
}): ReactNode {
|
||||
// Poll apiKeyHelper inflight state to show slow-helper notice.
|
||||
// Gated on configuration — most users never set apiKeyHelper, so the
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js';
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import type { PermissionMode } from '../../types/permissions.js';
|
||||
import type { BaseTextInputProps, PromptInputMode, VimMode } from '../../types/textInputTypes.js';
|
||||
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
|
||||
import { count } from '../../utils/array.js';
|
||||
@@ -329,7 +328,7 @@ function PromptInput({
|
||||
const hasTungstenSession = useAppState(s => process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined);
|
||||
const tmuxFooterVisible = process.env.USER_TYPE === 'ant' && hasTungstenSession;
|
||||
// WebBrowser pill — visible when a browser is open
|
||||
const bagelFooterVisible = useAppState(s => false);
|
||||
const bagelFooterVisible = useAppState(_s => false);
|
||||
const teamContext = useAppState(s => s.teamContext);
|
||||
const queuedCommands = useCommandQueue();
|
||||
const promptSuggestionState = useAppState(s => s.promptSuggestion);
|
||||
@@ -538,7 +537,7 @@ function PromptInput({
|
||||
|
||||
const tasksSelected = footerItemSelected === 'tasks';
|
||||
const tmuxSelected = footerItemSelected === 'tmux';
|
||||
const bagelSelected = footerItemSelected === 'bagel';
|
||||
const _bagelSelected = footerItemSelected === 'bagel';
|
||||
const teamsSelected = footerItemSelected === 'teams';
|
||||
const bridgeSelected = footerItemSelected === 'bridge';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { memo, type ReactNode, useMemo, useRef, useState } from 'react';
|
||||
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
|
||||
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js';
|
||||
import { useSetPromptOverlay } from '../../context/promptOverlayContext.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { memo, type ReactNode } from 'react';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { SandboxViolationEvent } from '../utils/sandbox/sandbox-adapter.js';
|
||||
|
||||
@@ -15,14 +15,11 @@ import {
|
||||
} from '../../utils/config.js';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
permissionModeTitle,
|
||||
permissionModeShortTitle,
|
||||
permissionModeFromString,
|
||||
toExternalPermissionMode,
|
||||
isExternalPermissionMode,
|
||||
EXTERNAL_PERMISSION_MODES,
|
||||
PERMISSION_MODES,
|
||||
type ExternalPermissionMode,
|
||||
type PermissionMode,
|
||||
} from '../../utils/permissions/PermissionMode.js';
|
||||
import {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
|
||||
import { isEnvTruthy } from '../utils/envUtils.js';
|
||||
import { count } from '../utils/array.js';
|
||||
import sample from 'lodash-es/sample.js';
|
||||
import { formatDuration, formatNumber, formatSecondsShort } from '../utils/format.js';
|
||||
import { formatDuration, formatNumber } from '../utils/format.js';
|
||||
import type { Theme } from 'src/utils/theme.js';
|
||||
import { activityManager } from '../utils/activityManager.js';
|
||||
import { getSpinnerVerbs } from '../constants/spinnerVerbs.js';
|
||||
@@ -244,7 +244,7 @@ function SpinnerWithVerbInner({
|
||||
|
||||
// TTFT display is gated to internal builds — apiMetricsRef was removed from
|
||||
// props during a refactor, so skip this until it's re-threaded.
|
||||
let ttftText: string | null = null;
|
||||
const _ttftText: string | null = null;
|
||||
|
||||
// When leader is idle but teammates are running (and we're viewing the leader),
|
||||
// show a static dim idle display instead of the animated spinner — otherwise
|
||||
|
||||
@@ -174,10 +174,9 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
|
||||
<Text bold>{getFsImplementation().cwd()}</Text>
|
||||
|
||||
<Text>
|
||||
Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open
|
||||
source project, or work from your team). If not, take a moment to review what{"'"}s in this folder first.
|
||||
Is this a project you trust? (Your own code, a well-known open source project, or work from your team).
|
||||
</Text>
|
||||
<Text>Claude Code{"'"}ll be able to read, edit, and execute files here.</Text>
|
||||
<Text>Once trusted, Claude Code can read, edit, and run commands in this folder.</Text>
|
||||
|
||||
<Text dimColor>
|
||||
<Link url="https://code.claude.com/docs/en/security">Security guide</Link>
|
||||
|
||||
54
src/components/__tests__/compactMessages.test.ts
Normal file
54
src/components/__tests__/compactMessages.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify compaction and context-related user messages are clear and actionable.
|
||||
* Pure string tests — no side effects.
|
||||
*/
|
||||
|
||||
describe('Compaction error messages', () => {
|
||||
test('not enough messages includes guidance', () => {
|
||||
const msg =
|
||||
'Not enough messages to compact. Send a few more messages first, then try again.'
|
||||
expect(msg).toContain('Not enough messages')
|
||||
expect(msg).toContain('try again')
|
||||
})
|
||||
|
||||
test('prompt too long suggests actions', () => {
|
||||
const msg =
|
||||
'Conversation too long to summarize. Try /compact to manually clear conversation history, or start a new session with /clear.'
|
||||
expect(msg).toContain('/compact')
|
||||
expect(msg).toContain('/clear')
|
||||
expect(msg).toContain('too long')
|
||||
})
|
||||
|
||||
test('incomplete response mentions network', () => {
|
||||
const msg =
|
||||
'Compaction interrupted · This may be due to network issues — please try again.'
|
||||
expect(msg).toContain('interrupted')
|
||||
expect(msg).toContain('try again')
|
||||
})
|
||||
|
||||
test('user abort is clear', () => {
|
||||
const msg = 'API Error: Request was aborted.'
|
||||
expect(msg).toContain('aborted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompactSummary display text', () => {
|
||||
test('auto-compact title explains what happened', () => {
|
||||
const title = 'Conversation summarized to free up context'
|
||||
expect(title).toContain('summarized')
|
||||
expect(title).toContain('context')
|
||||
expect(title).not.toContain('Compact summary')
|
||||
})
|
||||
|
||||
test('manual compact title mentions message count', () => {
|
||||
const line1 = 'Summarized conversation'
|
||||
expect(line1).toContain('Summarized')
|
||||
})
|
||||
|
||||
test('expand hint says "view summary" not "expand"', () => {
|
||||
const hint = 'view summary'
|
||||
expect(hint).toContain('summary')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { isAutoMemoryEnabled } from '../../../memdir/paths.js';
|
||||
import type { Tools } from '../../../Tool.js';
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
|
||||
import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { type KeyboardEvent, Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
|
||||
import { isAutoMemoryEnabled } from '../../../../memdir/paths.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import chalk from 'chalk';
|
||||
import React, { type ReactNode, useCallback, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode, useCallback, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
|
||||
import { editPromptInEditor } from '../../../../utils/promptEditor.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APIUserAbortError } from '@anthropic-ai/sdk';
|
||||
import React, { type ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js';
|
||||
import { Box, Byline, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import type { SettingSource } from '../../../../utils/settings/constants.js';
|
||||
import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
|
||||
import { isAutoMemoryEnabled } from '../../../../memdir/paths.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js';
|
||||
import { Select } from '../../../CustomSelect/select.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js';
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { useWizard } from '../../../wizard/index.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode, useCallback, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
|
||||
import { editPromptInEditor } from '../../../../utils/promptEditor.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import type { Tools } from '../../../../Tool.js';
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { Divider } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { FuzzyPicker } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { LoadingState } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { Pane } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { ProgressBar } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { Ratchet } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { StatusIcon } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { ThemeProvider, usePreviewTheme, useTheme, useThemeSetting } from '@anthropic/ink';
|
||||
@@ -1 +0,0 @@
|
||||
export { Box as default } from '@anthropic/ink';
|
||||
@@ -1,29 +0,0 @@
|
||||
import { type ColorType, colorize, type Color } from '@anthropic/ink'
|
||||
import { getTheme, type Theme, type ThemeName } from '../../utils/theme.js'
|
||||
|
||||
/**
|
||||
* Curried theme-aware color function. Resolves theme keys to raw color
|
||||
* values before delegating to the ink renderer's colorize.
|
||||
*/
|
||||
export function color(
|
||||
c: keyof Theme | Color | undefined,
|
||||
theme: ThemeName,
|
||||
type: ColorType = 'foreground',
|
||||
): (text: string) => string {
|
||||
return text => {
|
||||
if (!c) {
|
||||
return text
|
||||
}
|
||||
// Raw color values bypass theme lookup
|
||||
if (
|
||||
c.startsWith('rgb(') ||
|
||||
c.startsWith('#') ||
|
||||
c.startsWith('ansi256(') ||
|
||||
c.startsWith('ansi:')
|
||||
) {
|
||||
return colorize(text, c, type)
|
||||
}
|
||||
// Theme key lookup
|
||||
return colorize(text, getTheme(theme)[c as keyof Theme], type)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user