Files
claude-code/docs/superpowers/plans/2026-06-14-effort-panel-basic.md
claude-code-best 206bc80e4e docs(effort): plan 补 q/ctrl+c 取消绑定,对齐 spec §5 状态机
verifier 抓到的 gap:spec §5 写明 Esc / Ctrl+C / q 都是取消事件,
但 plan Task 2.3 只绑了 escape。补上 q 和 ctrl+c → effortPanel:cancel。
同时把 Step 2.2 直接写成 6 个 action 版本(home/end),删除迂回表达。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
2026-06-14 14:19:13 +08:00

821 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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('getInitialCursorenv override 存在时返回 env 值(若是合法档位)', () => {
expect(getInitialCursor({ envOverride: 'high', appStateEffort: 'medium', displayed: 'high' })).toBe('high')
})
test('getInitialCursorenv 为 nullunset时用 displayed', () => {
expect(getInitialCursor({ envOverride: null, appStateEffort: undefined, displayed: 'medium' })).toBe('medium')
})
test('getInitialCursorenv undefined 时用 displayed', () => {
expect(getInitialCursor({ envOverride: undefined, appStateEffort: 'high', displayed: 'high' })).toBe('high')
})
test('getInitialCursorenv 是数值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`:
```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 都不是合法 PanelPositionultracode 由面板内部产生)。
*/
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**
```bash
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_POSITIONSlow → medium → high → xhigh → max → ultracode
- moveLeft/moveRight边界钳制low 不再左移、ultracode 不再右移)
- getInitialCursorenv 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` 联合类型追加 6 个 action
- Modify: `src/keybindings/defaultBindings.ts`(追加 `EffortPanel` context 块)
- [ ] **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 追加 6 个 action**
打开 `src/keybindings/schema.ts`,找到 `// Model picker actions (ant-only)` 块(约 line 153-156在它**后面**追加:
```ts
// Effort panel actions (slash /effort without args)
'effortPanel:decrease',
'effortPanel:increase',
'effortPanel:home',
'effortPanel:end',
'effortPanel:confirm',
'effortPanel:cancel',
```
- [ ] **Step 2.3: 在 defaultBindings.ts 追加 EffortPanel context**
打开 `src/keybindings/defaultBindings.ts`,找到 `ModelPicker` 块(约 line 320-328在它**后面**`Select` 块之前)追加:
```ts
// 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',
q: 'effortPanel:cancel',
'ctrl+c': 'effortPanel:cancel',
},
},
```
注意:
- `q``escape` / `ctrl+c` 都映射到 `effortPanel:cancel`,与 spec §5 状态机一致。
- Ink 的 useInput 默认在 ctrl+c 时退出进程;但项目 useKeybindings 系统会先拦截 ctrl+c参考 `useInput` 源码中 `if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC)` 分支)。若实施时发现 ctrl+c 仍直接退出进程,**降级为只绑 q + escape**,并在 commit message 里注明。
- Step 2.2 的 6 个 action`home/end`)与此处的 8 个绑定一一对应。
- [ ] **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**
```bash
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`:
```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:
```bash
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`:
```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**
```bash
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
```tsx
import { EffortPanel } from '../../components/EffortPanel/EffortPanel.js'
```
`call()` 改为(替换无参分支):
```tsx
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
```tsx
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:
```bash
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:
```bash
bun test src/commands/effort/ 2>/dev/null
bun test tests/integration/message-pipeline* 2>/dev/null
```
Expected: 已有测试不破。
- [ ] **Step 4.6: Commit**
```bash
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`
```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` 中:
```ts
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=applyresult.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` 写盘。测试需 mock `settings/settings.js`(参考 `tests/mocks/`)。
- [ ] **Step 5.4: 跑测试**
Run: `bun test src/components/EffortPanel/__tests__/`
Expected: PASS
- [ ] **Step 5.5: Commit**
```bash
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 6precheck 全量 + 验收
**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: ...`
- 把光标移到 ultracodeEnter → 输出引导文案
- 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 分支 A5 档 Enter 调 executeEffort
- [ ] §5 分支 Bultracode 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 再调:
1. `▲` 与档位文字的对齐(窄屏 / 不同终端宽度下可能错位)
2. 双标记 `(high) active` 的精确渲染(首版可只显示 cursor `▲`env override 顶部警告保证用户知情)
3. 模型不支持时的禁用态(首版可允许面板仍可操作,但顶部加提示)
4. 终端 < 60 cols 的垂直布局退化
5. 数字键 1-6 快速跳转spec 中标为可选增强,本计划不做)
这些不影响主功能,第一版以"能用、稳定、可提交"为目标。