按 TDD 分 6 个 task:纯函数状态 → keybinding 注册 → 组件 → 命令挂载 → 分支测试 → precheck。 波纹动画在第二阶段单独 commit。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
29 KiB
EffortPanel 基础面板实施计划(第一阶段)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 把 /effort 无参调用升级为横向 slider 选择面板,覆盖 low/medium/high/xhigh/max/ultracode 六档,←/→ 移动光标、Enter 确认、Esc 取消。
Architecture: 新增自包含 EffortPanel React 组件 + 纯函数状态模块;键盘交互走项目既有的 useKeybindings + 自定义 EffortPanel keybinding context(与 ModelPicker 范式一致);不修改 src/utils/effort.ts,复用其纯函数;改造 src/commands/effort/effort.tsx 的 call(),仅无参时挂载面板。
Tech Stack: Bun + TypeScript + React (Ink via @anthropic/ink) + bun:test + Biome
Spec: docs/superpowers/specs/2026-06-14-effort-panel-design.md
范围: 仅第一阶段(基础面板 + 键盘交互 + env override 警告 + ultracode 文案分支)。波纹动画在第二阶段单独 commit,不在本计划内。
文件结构
| 文件 | 状态 | 责任 |
|---|---|---|
src/components/EffortPanel/effortPanelState.ts |
新增 | PanelPosition 类型 + 纯函数(moveLeft/moveRight/home/end/getInitialCursor/PANEL_POSITIONS),可独立单测 |
src/components/EffortPanel/EffortPanel.tsx |
新增 | 面板 React 组件:渲染布局 + useKeybindings + Enter/Esc 分支 + 调 executeEffort |
src/components/EffortPanel/__tests__/effortPanelState.test.ts |
新增 | 纯函数单测 |
src/components/EffortPanel/__tests__/EffortPanel.test.tsx |
新增 | 组件渲染 + 分支测试 |
src/keybindings/schema.ts |
修改 | 在 KeybindingAction 联合类型里追加 4 个 effortPanel:* action |
src/keybindings/defaultBindings.ts |
修改 | 追加 EffortPanel context 绑定(←/→/enter/escape/home/end) |
src/keybindings/__tests__/(如已有 schema/defaultBindings 测试) |
修改(如有) | 追加新 context 的回归断言 |
src/commands/effort/effort.tsx |
修改 | call() 在 args === '' 时返回 <EffortPanel>;其他路径不变 |
不修改的文件: src/utils/effort.ts、src/commands/effort/index.ts、src/state/AppState.tsx。
Task 1:纯函数状态模块(TDD)
Files:
-
Create:
src/components/EffortPanel/effortPanelState.ts -
Test:
src/components/EffortPanel/__tests__/effortPanelState.test.ts -
Step 1.1: 写失败测试(基础导出与边界)
Create src/components/EffortPanel/__tests__/effortPanelState.test.ts:
import { describe, expect, test } from 'bun:test'
import {
END_POSITION,
HOME_POSITION,
PANEL_POSITIONS,
type PanelPosition,
getInitialCursor,
isUltracode,
moveLeft,
moveRight,
} from '../effortPanelState.js'
describe('effortPanelState', () => {
test('PANEL_POSITIONS 顺序为 low → ultracode', () => {
expect(PANEL_POSITIONS).toEqual([
'low',
'medium',
'high',
'xhigh',
'max',
'ultracode',
])
})
test('moveLeft 在 low 处保持 low', () => {
expect(moveLeft('low')).toBe('low')
})
test('moveLeft 正常左移', () => {
expect(moveLeft('high')).toBe('medium')
expect(moveLeft('ultracode')).toBe('max')
})
test('moveRight 在 ultracode 处保持 ultracode', () => {
expect(moveRight('ultracode')).toBe('ultracode')
})
test('moveRight 正常右移', () => {
expect(moveRight('medium')).toBe('high')
expect(moveRight('max')).toBe('ultracode')
})
test('HOME_POSITION 等于 low', () => {
expect(HOME_POSITION).toBe('low')
})
test('END_POSITION 等于 ultracode', () => {
expect(END_POSITION).toBe('ultracode')
})
test('isUltracode 守卫', () => {
expect(isUltracode('ultracode')).toBe(true)
expect(isUltracode('max')).toBe(false)
})
test('getInitialCursor:env override 存在时返回 env 值(若是合法档位)', () => {
expect(getInitialCursor({ envOverride: 'high', appStateEffort: 'medium', displayed: 'high' })).toBe('high')
})
test('getInitialCursor:env 为 null(unset)时用 displayed', () => {
expect(getInitialCursor({ envOverride: null, appStateEffort: undefined, displayed: 'medium' })).toBe('medium')
})
test('getInitialCursor:env undefined 时用 displayed', () => {
expect(getInitialCursor({ envOverride: undefined, appStateEffort: 'high', displayed: 'high' })).toBe('high')
})
test('getInitialCursor:env 是数值(ant-only)时落回 displayed', () => {
// 数值不是合法 PanelPosition,回退
expect(getInitialCursor({ envOverride: 75, appStateEffort: 'medium', displayed: 'medium' })).toBe('medium')
})
test('PanelPosition 类型编译期检查(隐式)', () => {
const p: PanelPosition = 'xhigh'
expect(p).toBe('xhigh')
})
})
- Step 1.2: 运行测试,确认失败
Run: bun test src/components/EffortPanel/__tests__/effortPanelState.test.ts
Expected: FAIL,错误形如 Cannot find module '../effortPanelState.js'
- Step 1.3: 实现纯函数模块
Create src/components/EffortPanel/effortPanelState.ts:
import type { EffortValue } from '../../../utils/effort.js'
/**
* 光标在面板上的位置。仅面板内部使用,不进入 AppState / settings / API。
* 'ultracode' 不是 EffortLevel;它在本面板里仅作视觉占位与文案引导。
*/
export type PanelPosition =
| 'low'
| 'medium'
| 'high'
| 'xhigh'
| 'max'
| 'ultracode'
export const PANEL_POSITIONS: readonly PanelPosition[] = [
'low',
'medium',
'high',
'xhigh',
'max',
'ultracode',
] as const
export const HOME_POSITION: PanelPosition = 'low'
export const END_POSITION: PanelPosition = 'ultracode'
const NON_ULTRACODE_POSITIONS: readonly PanelPosition[] = PANEL_POSITIONS.filter(
p => p !== 'ultracode',
)
/**
* 判断一个 EffortValue 是否可作为面板光标位置。
* 数值(ant-only)和 ultracode 都不是合法 PanelPosition(ultracode 由面板内部产生)。
*/
function isPanelPosition(value: unknown): value is PanelPosition {
return typeof value === 'string' && (PANEL_POSITIONS as readonly string[]).includes(value)
}
/**
* 把非 ultracode 的 string EffortValue 收窄为 PanelPosition 的前 5 档。
* 用于 env override 与 appState 的归一化。
*/
function normalizeToPanelPosition(value: EffortValue | null | undefined): PanelPosition | undefined {
if (value === null || value === undefined) return undefined
if (typeof value === 'number') return undefined
if (isPanelPosition(value) && value !== 'ultracode') {
return value
}
return undefined
}
export function moveLeft(cursor: PanelPosition): PanelPosition {
const idx = PANEL_POSITIONS.indexOf(cursor)
if (idx <= 0) return PANEL_POSITIONS[0]
return PANEL_POSITIONS[idx - 1]
}
export function moveRight(cursor: PanelPosition): PanelPosition {
const idx = PANEL_POSITIONS.indexOf(cursor)
if (idx === -1 || idx >= PANEL_POSITIONS.length - 1) {
return PANEL_POSITIONS[PANEL_POSITIONS.length - 1]
}
return PANEL_POSITIONS[idx + 1]
}
export function isUltracode(cursor: PanelPosition): boolean {
return cursor === 'ultracode'
}
/**
* 决定面板挂载时的初始光标位置。
* 优先级:env override(若是合法档位)> displayed level(已是 fallback 'high' 之后)
*
* @param envOverride getEffortEnvOverride() 的返回值:EffortValue | null | undefined
* @param appStateEffort AppState.effortValue
* @param displayed getDisplayedEffortLevel(model, appStateEffort) —— 必传,避免此处再依赖 model
*/
export function getInitialCursor(args: {
envOverride: EffortValue | null | undefined
appStateEffort: EffortValue | undefined
displayed: PanelPosition
}): PanelPosition {
const fromEnv = normalizeToPanelPosition(args.envOverride)
if (fromEnv !== undefined) return fromEnv
// displayed 已经是 EffortLevel(不含 ultracode),合法
return args.displayed
}
// 保留导出,便于将来测试扩展
export { NON_ULTRACODE_POSITIONS }
- Step 1.4: 运行测试,确认通过
Run: bun test src/components/EffortPanel/__tests__/effortPanelState.test.ts
Expected: PASS(所有 11 个 test 通过)
- Step 1.5: 类型 + lint 检查
Run: bunx tsc --noEmit && bunx biome check src/components/EffortPanel/
Expected: 0 errors
- Step 1.6: Commit
git add src/components/EffortPanel/effortPanelState.ts src/components/EffortPanel/__tests__/effortPanelState.test.ts
git commit -m "$(cat <<'EOF'
feat(effort): 新增 EffortPanel 纯函数状态模块(PanelPosition + 移动/初始光标)
仅含纯函数与类型,无 React/Ink 依赖,便于单测。
- PANEL_POSITIONS:low → medium → high → xhigh → max → ultracode
- moveLeft/moveRight:边界钳制(low 不再左移、ultracode 不再右移)
- getInitialCursor:env override > displayed level
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
Task 2:注册 EffortPanel keybinding context
Files:
-
Modify:
src/keybindings/schema.ts(在KeybindingAction联合类型追加 4 个 action) -
Modify:
src/keybindings/defaultBindings.ts(追加EffortPanelcontext 块) -
Step 2.1: 检查 schema.ts 现有结构与校验测试
Run: grep -n "modelPicker:" src/keybindings/schema.ts
Expected: 看到三行 modelPicker:decreaseEffort/increaseEffort/toggle1M,附近就是合适的插入位置。
Run: ls src/keybindings/__tests__/ 2>/dev/null
Expected: 查看是否有 schema/defaultBindings 的回归测试文件(决定是否需要补断言)。
- Step 2.2: 在 schema.ts 追加 4 个 action
打开 src/keybindings/schema.ts,找到 // Model picker actions (ant-only) 块(约 line 153-156),在它后面追加:
// Effort panel actions (slash /effort without args)
'effortPanel:decrease',
'effortPanel:increase',
'effortPanel:confirm',
'effortPanel:cancel',
- Step 2.3: 在 defaultBindings.ts 追加 EffortPanel context
打开 src/keybindings/defaultBindings.ts,找到 ModelPicker 块(约 line 320-328),在它后面(Select 块之前)追加:
// Effort panel (slash /effort without args)
{
context: 'EffortPanel',
bindings: {
left: 'effortPanel:decrease',
right: 'effortPanel:increase',
h: 'effortPanel:decrease',
l: 'effortPanel:increase',
home: 'effortPanel:home',
end: 'effortPanel:end',
enter: 'effortPanel:confirm',
escape: 'effortPanel:cancel',
},
},
注意:这里多绑了 home/end 两个 action,所以 schema 也要追加。回到 Step 2.2 把那段改成:
// Effort panel actions (slash /effort without args)
'effortPanel:decrease',
'effortPanel:increase',
'effortPanel:home',
'effortPanel:end',
'effortPanel:confirm',
'effortPanel:cancel',
- Step 2.4: 类型 + lint 检查
Run: bunx tsc --noEmit
Expected: 0 errors(如果 schema 校验是 type-level 的,新增 action 会被识别)
Run: bun test src/keybindings/ 2>/dev/null
Expected: 已有测试不破。
- Step 2.5: Commit
git add src/keybindings/schema.ts src/keybindings/defaultBindings.ts
git commit -m "$(cat <<'EOF'
feat(keybindings): 注册 EffortPanel context 与 6 个 action
绑定 ←/→/h/l/home/end/enter/escape 到 effortPanel:* action。
与 ModelPicker context 范式一致,避免左右键被全局 keybinding 拦截。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
Task 3:实现 EffortPanel React 组件
Files:
-
Create:
src/components/EffortPanel/EffortPanel.tsx -
Create:
src/components/EffortPanel/__tests__/EffortPanel.test.tsx -
Step 3.1: 写失败测试(渲染基础形态)
Create src/components/EffortPanel/__tests__/EffortPanel.test.tsx:
import { describe, expect, mock, test } from 'bun:test'
import React from 'react'
import { render } from '../../../test-utils/ink-render.js'
import { EffortPanel } from '../EffortPanel.js'
// 复用项目共享 mock(避免 bootstrap/state 副作用)
mock.module('src/utils/log.ts', () => {
const { logMock } = require('../../../../tests/mocks/log')
return logMock()
})
const baseProps = {
model: 'claude-opus-4-7',
appStateEffort: undefined as undefined | string,
onDone: () => {},
}
describe('EffortPanel 渲染', () => {
test('显示标题 Effort、两极 Faster/Smarter、6 个档位、底栏提示', () => {
const { stdout } = render(<EffortPanel {...baseProps} appStateEffort={undefined} />)
const out = stdout.join('')
expect(out).toContain('Effort')
expect(out).toContain('Faster')
expect(out).toContain('Smarter')
expect(out).toContain('low')
expect(out).toContain('medium')
expect(out).toContain('high')
expect(out).toContain('xhigh')
expect(out).toContain('max')
expect(out).toContain('ultracode')
expect(out).toContain('xhigh + workflows')
expect(out).toContain('←/→ adjust')
expect(out).toContain('Enter confirm')
expect(out).toContain('Esc cancel')
})
test('光标 ▲ 初始指向当前生效档(high)', () => {
const { stdout } = render(<EffortPanel {...baseProps} appStateEffort="high" />)
// 找到 high 那一行上方有 ▲
expect(stdout.join('')).toContain('▲')
})
})
注:
ink-render.js路径在 Step 3.2 探查;如项目无现成 helper,退化为不依赖渲染的纯逻辑测试(仅测 onDone 分支回调)。
- Step 3.2: 探查 Ink 测试 helper
Run:
find src packages -name "*.ts*" -path "*test*" -exec grep -l "render.*Ink\|@anthropic/ink" {} \; 2>/dev/null | head -5
grep -rn "render(" src/components/**/__tests__/*.tsx 2>/dev/null | head -10
Expected:要么找到现成 helper(用之),要么确认项目里 Ink 组件测试都用"调用 onDone 回调断言"而非 ink render。如果后者,Step 3.1 改写为回调断言式测试(见 Step 3.3 备注)。
- Step 3.3: 实现组件
Create src/components/EffortPanel/EffortPanel.tsx:
import * as React from 'react'
import { Box, Text, useKeybindings } from '@anthropic/ink'
import { type EffortValue, getDisplayedEffortLevel, getEffortEnvOverride } from '../../utils/effort.js'
import { type PanelPosition, getInitialCursor, isUltracode, moveLeft, moveRight } from './effortPanelState.js'
import { executeEffort, type EffortCommandResult } from '../../commands/effort/effort.js'
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
import { useAppState, useSetAppState } from '../../state/AppState.js'
const PANEL_WIDTH = 76 // 终端 ≥ 80 cols 时使用;窄屏适配第二阶段处理
type Props = {
appStateEffort: EffortValue | undefined
onDone: (message: string) => void
}
const POSITION_LABELS: ReadonlyArray<{ pos: PanelPosition; label: string }> = [
{ pos: 'low', label: 'low' },
{ pos: 'medium', label: 'medium' },
{ pos: 'high', label: 'high' },
{ pos: 'xhigh', label: 'xhigh' },
{ pos: 'max', label: 'max' },
{ pos: 'ultracode', label: 'ultracode' },
]
const SUBLABEL_ULTRACODE = 'xhigh + workflows'
function renderCursorLine(cursor: PanelPosition, width: number): string {
// 在 cursor 对应位置生成 ▲,对齐到下方档位文字
// 简化实现:均匀分布 6 档,▲ 落在 cursor 档的中心列
const segment = Math.floor(width / POSITION_LABELS.length)
const idx = POSITION_LABELS.findIndex(p => p.pos === cursor)
const col = segment * idx + Math.floor(segment / 2)
return ' '.repeat(col) + '▲'
}
function renderSeparatorLine(width: number, cursorCol: number): string {
return '─'.repeat(cursorCol) + '▲' + '─'.repeat(Math.max(0, width - cursorCol - 1))
}
export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode {
const setAppState = useSetAppState()
const model = useMainLoopModel()
const envOverride = getEffortEnvOverride()
const displayed = getDisplayedEffortLevel(model, appStateEffort)
const initialCursor = getInitialCursor({ envOverride, appStateEffort, displayed })
const [cursor, setCursor] = React.useState<PanelPosition>(initialCursor)
const [done, setDone] = React.useState(false)
const handleConfirm = React.useCallback(() => {
if (done) return
setDone(true)
if (isUltracode(cursor)) {
onDone('ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。')
return
}
const result: EffortCommandResult = executeEffort(cursor)
if (result.effortUpdate) {
setAppState(prev => ({ ...prev, effortValue: result.effortUpdate!.value }))
}
onDone(result.message)
}, [cursor, done, onDone, setAppState])
const handleCancel = React.useCallback(() => {
if (done) return
setDone(true)
onDone('Effort unchanged.')
}, [done, onDone])
useKeybindings(
{
'effortPanel:decrease': () => setCursor(c => moveLeft(c)),
'effortPanel:increase': () => setCursor(c => moveRight(c)),
'effortPanel:home': () => setCursor('low'),
'effortPanel:end': () => setCursor('ultracode'),
'effortPanel:confirm': handleConfirm,
'effortPanel:cancel': handleCancel,
},
{ context: 'EffortPanel' },
)
const envActive = envOverride !== null && envOverride !== undefined
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
return (
<Box flexDirection="column" paddingX={1}>
<Text bold>Effort</Text>
{envActive && (
<Text color="yellow">⚠ CLAUDE_CODE_EFFORT_LEVEL={envRaw} overrides this session</Text>
)}
<Box marginTop={1}>
<Text>
{'Faster'.padEnd(PANEL_WIDTH - 'Smarter'.length)}Smarter'.replace(/Smarter'.*/, 'Smarter')}
</Text>
</Box>
<Text>{'─'.repeat(PANEL_WIDTH)}</Text>
<Text>{renderCursorLine(cursor, PANEL_WIDTH)}</Text>
<Text>{POSITION_LABELS.map(p => p.label.padEnd(11)).join('').trimEnd()}</Text>
<Text dimColor>{' '.repeat(PANEL_WIDTH - SUBLABEL_ULTRACODE.length)}{SUBLABEL_ULTRACODE}</Text>
<Box marginTop={1}>
<Text dimColor>←/→ adjust · Enter confirm · Esc cancel</Text>
</Box>
</Box>
)
}
⚠️ 上面的字符串 padEnd/对齐是占位实现,第一版允许略微错位(视觉精度在第二阶段调优)。重点是:标题、6 档名、底栏提示、▲ 标记必须出现。
Step 3.3 备注(如无 ink render helper): 把 Step 3.1 的渲染断言测试替换为:
- 用
@anthropic/ink的render直接 render,并通过键盘事件触发 callback- 或抽出
handleConfirm/handleCancel为可单测的纯函数(接收 cursor 与 deps,返回 onDone 参数)优先采用抽出纯函数的方案,避免依赖 Ink 测试基础设施。
- Step 3.4: 运行测试,确认通过
Run: bun test src/components/EffortPanel/__tests__/EffortPanel.test.tsx
Expected: PASS
如失败:检查 useKeybindings import 路径、executeEffort 是否能从 effort.tsx 导出(必要时在 effort.tsx 加 export)、useMainLoopModel hook 是否在测试环境工作(可能需要 mock)。
- Step 3.5: 类型 + lint 检查
Run: bunx tsc --noEmit && bunx biome check src/components/EffortPanel/
Expected: 0 errors(如有 lint 警告,按提示修;useKeybindings 未使用变量之类的需移除)
- Step 3.6: Commit
git add src/components/EffortPanel/EffortPanel.tsx src/components/EffortPanel/__tests__/EffortPanel.test.tsx
git commit -m "$(cat <<'EOF'
feat(effort): 实现 EffortPanel 组件主体(渲染 + 键盘交互 + 确认/取消分支)
- 横向 slider 布局:Faster ↔ Smarter 两极,6 档刻度
- useKeybindings 注册 EffortPanel context,←/→/h/l/home/end/enter/escape
- Enter 在 5 档之一 → 调 executeEffort 写 settings + AppState
- Enter 在 ultracode → 输出引导文案,不写状态
- Esc → "Effort unchanged."
- env override 时顶部黄色警告
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
Task 4:改造 /effort 命令挂载面板
Files:
-
Modify:
src/commands/effort/effort.tsx -
Step 4.1: 阅读现状
Run: cat src/commands/effort/effort.tsx
确认 call() 当前签名与 ShowCurrentEffort / ApplyEffortAndClose 组件结构。无参分支当前走 <ShowCurrentEffort>。
- Step 4.2: 改造 call() 无参分支
打开 src/commands/effort/effort.tsx,找到 call() 函数(约 line 153-169)。在文件顶部新增 import:
import { EffortPanel } from '../../components/EffortPanel/EffortPanel.js'
把 call() 改为(替换无参分支):
export async function call(
onDone: LocalJSXCommandOnDone,
_context: unknown,
args?: string,
): Promise<React.ReactNode> {
args = args?.trim() || ''
if (COMMON_HELP_ARGS.includes(args)) {
onDone(
'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extended reasoning beyond high, short of max; including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning; maps to xhigh for ChatGPT Codex models\n- auto: Use the default effort level for your model',
)
return
}
// 无参 / /effort current / /effort status:原行为是显示当前档位;
// 现在拆分:完全无参 → 打开面板;current/status → 仍显示文本
if (args === '') {
return <EffortPanelWrapper onDone={onDone} />
}
if (args === 'current' || args === 'status') {
return <ShowCurrentEffort onDone={onDone} />
}
const result = executeEffort(args)
return <ApplyEffortAndClose result={result} onDone={onDone} />
}
在文件底部追加 EffortPanelWrapper(桥接面板到 AppState 与 onDone):
function EffortPanelWrapper({
onDone,
}: {
onDone: (result: string) => void
}): React.ReactNode {
const effortValue = useAppState(s => s.effortValue)
return <EffortPanel appStateEffort={effortValue} onDone={onDone} />
}
注意:EffortPanel 内部已经自己读 model + env override + 写 AppState,所以 wrapper 只是把 effortValue 透传。
- Step 4.3: 类型 + lint 检查
Run: bunx tsc --noEmit && bunx biome check src/commands/effort/
Expected: 0 errors
- Step 4.4: 手动验证(pipe mode 快速跑)
Run:
echo "/effort" | bun run src/entrypoints/cli.tsx -p 2>&1 | head -30
Expected:看到面板渲染输出(标题 Effort、6 档、底栏提示)。pipe 模式下键盘交互不能测,只验证渲染。
如果 pipe 模式不渲染面板(因为非交互式 TTY),改成
bun run dev手测。
- Step 4.5: 跑相关测试
Run:
bun test src/commands/effort/ 2>/dev/null
bun test tests/integration/message-pipeline* 2>/dev/null
Expected: 已有测试不破。
- Step 4.6: Commit
git add src/commands/effort/effort.tsx
git commit -m "$(cat <<'EOF'
feat(effort): /effort 无参时挂载 EffortPanel 交互面板
- 无参 → <EffortPanelWrapper> 透传 AppState.effortValue
- current/status → 仍显示文本(不变)
- 有参 → 直跳 executeEffort(不变)
- help/-h/--help → 不变
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
Task 5:补集成测试(键盘交互 + 分支)
Files:
-
Modify/Create:
src/components/EffortPanel/__tests__/EffortPanel.test.tsx(在 Task 3 基础上追加) -
Step 5.1: 决定测试路径(二选一)
Ink 组件键盘测试在项目里没有现成 helper(已通过 Task 3.2 探查确认)。直接走 Step 5.2 的纯函数抽取方案——把确认/取消决策逻辑抽到 effortPanelState.ts,用纯函数测试覆盖分支。键盘 → handler 的连接由 useKeybindings 注册保证,不单独测(与 ModelPicker 测试策略一致)。
- Step 5.2: 抽取确认/取消为可测纯函数(可选重构)
如 Step 5.1 测试无法直接做,把 handleConfirm/handleCancel 抽出到 effortPanelState.ts:
// 在 effortPanelState.ts 末尾追加
import type { EffortCommandResult } from '../../commands/effort/effort.js'
export type ConfirmOutcome =
| { kind: 'apply'; result: EffortCommandResult }
| { kind: 'ultracode-hint'; message: string }
export function computeConfirmOutcome(cursor: PanelPosition): ConfirmOutcome {
if (isUltracode(cursor)) {
return {
kind: 'ultracode-hint',
message: 'ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。',
}
}
return { kind: 'apply', result: executeEffort(cursor) }
}
export const CANCEL_MESSAGE = 'Effort unchanged.'
然后在 EffortPanel.tsx 里改用 computeConfirmOutcome(cursor)。
注意:
executeEffort在effort.tsx里,从 effortPanelState import 会形成循环依赖(effort.tsx 已 import EffortPanel)。解决:把executeEffort抽到独立文件src/commands/effort/executeEffort.ts,或者把computeConfirmOutcome放在EffortPanel.tsx内部并接受executeEffort作为参数注入。优先选第二种(不增加文件、不引入循环)。
- Step 5.3: 写分支测试(用纯函数版本)
在 effortPanelState.test.ts 或新建 EffortPanel.test.tsx 中:
import { computeConfirmOutcome, CANCEL_MESSAGE } from '../effortPanelState.js'
describe('computeConfirmOutcome', () => {
test('ultracode → kind=ultracode-hint,含引导文案', () => {
const out = computeConfirmOutcome('ultracode')
expect(out.kind).toBe('ultracode-hint')
expect(out.kind === 'ultracode-hint' && out.message).toContain('/ultracode')
})
test('low → kind=apply,result.message 非空', () => {
const out = computeConfirmOutcome('low')
expect(out.kind).toBe('apply')
// executeEffort 在测试环境可能因为 settings 不可写而失败,但仍要返回 message
if (out.kind === 'apply') {
expect(typeof out.result.message).toBe('string')
expect(out.result.message.length).toBeGreaterThan(0)
}
})
})
test('CANCEL_MESSAGE', () => {
expect(CANCEL_MESSAGE).toBe('Effort unchanged.')
})
注意:
computeConfirmOutcome调executeEffort,后者会触发updateSettingsForSource写盘。测试需 mocksettings/settings.js(参考tests/mocks/)。
- Step 5.4: 跑测试
Run: bun test src/components/EffortPanel/__tests__/
Expected: PASS
- Step 5.5: Commit
git add src/components/EffortPanel/
git commit -m "$(cat <<'EOF'
test(effort): 补 EffortPanel 分支测试(ultracode 引导 / 取消文案 / apply 路径)
抽 computeConfirmOutcome 为纯函数便于测试,避开 Ink 键盘事件模拟。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
Task 6:precheck 全量 + 验收
Files: 无修改
- Step 6.1: 跑 precheck
Run: bun run precheck
Expected: typecheck + lint fix + test 全绿,零错误
如有失败:按错误信息修,不要用 as any 或 // biome-ignore 绕过(除非确实是反编译代码遗留问题)。
- Step 6.2: 手动验收
Run: bun run dev
输入 /effort,确认:
-
面板出现,光标
▲停在当前生效档 -
←/→移动光标,到边界(low / ultracode)不再继续 -
Enter 在 high 时输出
Set effort level to high: ... -
把光标移到 ultracode,Enter → 输出引导文案
-
Esc → 输出
Effort unchanged. -
设
CLAUDE_CODE_EFFORT_LEVEL=high bun run dev,再/effort→ 顶部黄色警告 -
/effort low、/effort auto、/effort current、/effort help仍按原行为工作 -
Step 6.3: 推送(可选,等用户决定)
Run: git log --oneline -10 检查 commit 历史
Run: git push (仅在用户确认后)
Self-Review 清单
实施完毕后,对照 spec 自检:
- §4 文件结构:
EffortPanel/、effortPanelState.ts、测试文件都存在 - §5 交互:←/→/Home/End/Enter/Esc/q 全部实现;触发与初始光标正确
- §5 分支 A:5 档 Enter 调 executeEffort
- §5 分支 B:ultracode Enter 输出引导文案
- §5 取消:
Effort unchanged. - §6 视觉:标题、Faster/Smarter、6 档、ultracode 副标签、底栏提示
- §6 双标记:env override 时 cursor
▲与 active(high) active同时显示(如未实现双标记,作为已知缺陷,第二阶段补) - §6 模型不支持:禁用面板,仅 Esc 可退出(如未实现,第二阶段补,但 spec 写明要实现)
- §9 边界:env override、模型不支持、settings 写入失败(沿用 executeEffort 现有错误路径)
- §10 测试:纯函数 + 组件 + 分支
- precheck 零错误
- 两阶段切分清晰:本计划只做基础,波纹动画第二阶段
已知首版可接受简化
为了控制首版范围,以下细节允许暂时不完美,第二阶段或后续 commit 再调:
▲与档位文字的对齐(窄屏 / 不同终端宽度下可能错位)- 双标记
(high) active的精确渲染(首版可只显示 cursor▲,env override 顶部警告保证用户知情) - 模型不支持时的禁用态(首版可允许面板仍可操作,但顶部加提示)
- 终端 < 60 cols 的垂直布局退化
- 数字键 1-6 快速跳转(spec 中标为可选增强,本计划不做)
这些不影响主功能,第一版以"能用、稳定、可提交"为目标。