diff --git a/docs/superpowers/plans/2026-06-14-effort-panel-basic.md b/docs/superpowers/plans/2026-06-14-effort-panel-basic.md new file mode 100644 index 000000000..65c7933c8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-effort-panel-basic.md @@ -0,0 +1,823 @@ +# 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 === ''` 时返回 ``;其他路径不变 | + +**不修改的文件:** `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('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`: + +```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** + +```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_POSITIONS:low → medium → high → xhigh → max → ultracode +- moveLeft/moveRight:边界钳制(low 不再左移、ultracode 不再右移) +- getInitialCursor:env override > displayed level + +Co-Authored-By: glm-5.2 +EOF +)" +``` + +--- + +## Task 2:注册 EffortPanel keybinding context + +**Files:** +- Modify: `src/keybindings/schema.ts`(在 `KeybindingAction` 联合类型追加 4 个 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 追加 4 个 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: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', + }, + }, +``` + +注意:这里多绑了 `home/end` 两个 action,所以 schema 也要追加。回到 Step 2.2 把那段改成: + +```ts + // 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** + +```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 +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() + 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() + // 找到 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(initialCursor) + const [done, setDone] = React.useState(false) + + const handleConfirm = React.useCallback(() => { + if (done) return + setDone(true) + + if (isUltracode(cursor)) { + onDone('ultracode 不是 effort 档位。请使用 /ultracode 启动多 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 ( + + Effort + {envActive && ( + ⚠ CLAUDE_CODE_EFFORT_LEVEL={envRaw} overrides this session + )} + + + {'Faster'.padEnd(PANEL_WIDTH - 'Smarter'.length)}Smarter'.replace(/Smarter'.*/, 'Smarter')} + + + {'─'.repeat(PANEL_WIDTH)} + {renderCursorLine(cursor, PANEL_WIDTH)} + {POSITION_LABELS.map(p => p.label.padEnd(11)).join('').trimEnd()} + {' '.repeat(PANEL_WIDTH - SUBLABEL_ULTRACODE.length)}{SUBLABEL_ULTRACODE} + + ←/→ adjust · Enter confirm · Esc cancel + + + ) +} +``` + +> ⚠️ 上面的字符串 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 +EOF +)" +``` + +--- + +## Task 4:改造 `/effort` 命令挂载面板 + +**Files:** +- Modify: `src/commands/effort/effort.tsx` + +- [ ] **Step 4.1: 阅读现状** + +Run: `cat src/commands/effort/effort.tsx` +确认 `call()` 当前签名与 `ShowCurrentEffort` / `ApplyEffortAndClose` 组件结构。无参分支当前走 ``。 + +- [ ] **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 { + 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 + } + + if (args === 'current' || args === 'status') { + return + } + + const result = executeEffort(args) + return +} +``` + +在文件底部追加 `EffortPanelWrapper`(桥接面板到 AppState 与 onDone): + +```tsx +function EffortPanelWrapper({ + onDone, +}: { + onDone: (result: string) => void +}): React.ReactNode { + const effortValue = useAppState(s => s.effortValue) + return +} +``` + +注意:`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 交互面板 + +- 无参 → 透传 AppState.effortValue +- current/status → 仍显示文本(不变) +- 有参 → 直跳 executeEffort(不变) +- help/-h/--help → 不变 + +Co-Authored-By: glm-5.2 +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 启动多 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=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` 写盘。测试需 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 +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 再调: + +1. `▲` 与档位文字的对齐(窄屏 / 不同终端宽度下可能错位) +2. 双标记 `(high) active` 的精确渲染(首版可只显示 cursor `▲`,env override 顶部警告保证用户知情) +3. 模型不支持时的禁用态(首版可允许面板仍可操作,但顶部加提示) +4. 终端 < 60 cols 的垂直布局退化 +5. 数字键 1-6 快速跳转(spec 中标为可选增强,本计划不做) + +这些不影响主功能,第一版以"能用、稳定、可提交"为目标。