From 7806d9f32e042c33122408484fd95e9f1a6538b8 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 14 Jun 2026 14:29:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(effort):=20=E5=AE=9E=E7=8E=B0=20EffortPane?= =?UTF-8?q?l=20=E7=BB=84=E4=BB=B6=E4=B8=BB=E4=BD=93=EF=BC=88=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=20+=20=E9=94=AE=E7=9B=98=E4=BA=A4=E4=BA=92=20+=20?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4/=E5=8F=96=E6=B6=88=E5=88=86=E6=94=AF?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 横向 slider 布局:Faster ↔ Smarter 两极,6 档刻度 - useKeybindings 注册 EffortPanel context(←/→/h/l/home/end/enter/escape/q/ctrl+c) - Enter 在 5 档之一 → 调 executeEffort 写 settings + AppState - Enter 在 ultracode → 输出引导文案,不写状态 - Esc/q → "Effort unchanged." - env override 时顶部黄色警告 - computeConfirmOutcome 注入 ApplyFn,便于测试(Task 5 补测试) Co-Authored-By: glm-5.2 --- src/components/EffortPanel/EffortPanel.tsx | 110 ++++++++++++++++++ .../__tests__/EffortPanel.test.tsx | 24 ++++ .../EffortPanel/effortPanelState.ts | 35 ++++++ 3 files changed, 169 insertions(+) create mode 100644 src/components/EffortPanel/EffortPanel.tsx create mode 100644 src/components/EffortPanel/__tests__/EffortPanel.test.tsx diff --git a/src/components/EffortPanel/EffortPanel.tsx b/src/components/EffortPanel/EffortPanel.tsx new file mode 100644 index 000000000..b79555e0e --- /dev/null +++ b/src/components/EffortPanel/EffortPanel.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { type EffortValue, getDisplayedEffortLevel, getEffortEnvOverride } from '../../utils/effort.js'; +import { + type PanelPosition, + CANCEL_MESSAGE, + computeConfirmOutcome, + getInitialCursor, + moveLeft, + moveRight, + PANEL_POSITIONS, +} from './effortPanelState.js'; +import { executeEffort } from '../../commands/effort/effort.js'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { useSetAppState } from '../../state/AppState.js'; + +// 终端 ≥ 80 cols 时使用;窄屏适配第二阶段处理 +const PANEL_WIDTH = 76; +const SUBLABEL_ULTRACODE = 'xhigh + workflows'; + +type Props = { + appStateEffort: EffortValue | undefined; + onDone: (message: string) => void; +}; + +// ▲ 落在每档中心列:均匀分布 +function cursorColumn(cursor: PanelPosition): number { + const segment = Math.floor(PANEL_WIDTH / PANEL_POSITIONS.length); + const idx = PANEL_POSITIONS.indexOf(cursor); + return segment * idx + Math.floor(segment / 2); +} + +function renderPaddedLine(cursor: PanelPosition): string { + const col = cursorColumn(cursor); + // ▲ 上方的"分隔线 + 光标"行:左侧 ─,到列处 ▲,右侧继续 ─ + return `${'─'.repeat(col)}▲${'─'.repeat(Math.max(0, PANEL_WIDTH - col - 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); + const outcome = computeConfirmOutcome(cursor, executeEffort); + if (outcome.kind === 'apply' && outcome.effortUpdate) { + setAppState(prev => ({ + ...prev, + effortValue: outcome.effortUpdate!.value, + })); + } + onDone(outcome.message); + }, [cursor, done, onDone, setAppState]); + + const handleCancel = React.useCallback(() => { + if (done) return; + setDone(true); + onDone(CANCEL_MESSAGE); + }, [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; + + // 两极文字行:左 Faster + 中间空格 + 右 Smarter + const fasterLen = 'Faster'.length; + const smarterLen = 'Smarter'.length; + const gap = Math.max(0, PANEL_WIDTH - fasterLen - smarterLen); + const poleLine = `Faster${' '.repeat(gap)}Smarter`; + + return ( + + Effort + {envActive && {`⚠ CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session`}} + + {poleLine} + + {renderPaddedLine(cursor)} + + {PANEL_POSITIONS.map(p => (p as string).padEnd(11)) + .join('') + .trimEnd()} + + {`${' '.repeat(Math.max(0, PANEL_WIDTH - SUBLABEL_ULTRACODE.length))}${SUBLABEL_ULTRACODE}`} + + ←/→ adjust · Enter confirm · Esc cancel + + + ); +} diff --git a/src/components/EffortPanel/__tests__/EffortPanel.test.tsx b/src/components/EffortPanel/__tests__/EffortPanel.test.tsx new file mode 100644 index 000000000..3c1023db5 --- /dev/null +++ b/src/components/EffortPanel/__tests__/EffortPanel.test.tsx @@ -0,0 +1,24 @@ +import { expect, test } from 'bun:test'; +import React from 'react'; +import { EffortPanel } from '../EffortPanel.js'; + +// EffortPanel 是 UI 组件,渲染依赖链(useMainLoopModel / GrowthBook / settings) +// 在测试环境模拟成本高且脆化。本文件只做"组件契约"sanity check: +// 1) 默认导出为有效 React 组件 +// 2) 接收正确 props 类型(编译期保证) +// 3) onDone 类型为 (message: string) => void +// +// 渲染输出与键盘交互通过 Step 6.2 手动验收覆盖; +// 确认/取消分支通过 computeConfirmOutcome 纯函数测试覆盖(见 effortPanelState.test.ts)。 + +test('EffortPanel 是有效 React 组件', () => { + expect(typeof EffortPanel).toBe('function'); +}); + +test('EffortPanel 接受 props 并返回 React element(不挂载)', () => { + const element = React.createElement(EffortPanel, { + appStateEffort: undefined, + onDone: () => {}, + }); + expect(React.isValidElement(element)).toBe(true); +}); diff --git a/src/components/EffortPanel/effortPanelState.ts b/src/components/EffortPanel/effortPanelState.ts index 858a89438..9bbd75b33 100644 --- a/src/components/EffortPanel/effortPanelState.ts +++ b/src/components/EffortPanel/effortPanelState.ts @@ -89,3 +89,38 @@ export function getInitialCursor(args: { // displayed 已经是 EffortLevel(不含 ultracode),合法 return args.displayed } + +// ---- 确认/取消决策(注入 ApplyFn 避免循环依赖 + 便于测试)---- + +export type ConfirmOutcome = + | { + kind: 'apply' + message: string + effortUpdate?: { value: EffortValue | undefined } + } + | { kind: 'ultracode-hint'; message: string } + +export type ApplyFn = (cursor: PanelPosition) => { + message: string + effortUpdate?: { value: EffortValue | undefined } +} + +export const ULTRACODE_HINT = + 'ultracode 不是 effort 档位。请使用 /ultracode 启动多 agent workflow。' + +export const CANCEL_MESSAGE = 'Effort unchanged.' + +export function computeConfirmOutcome( + cursor: PanelPosition, + applyFn: ApplyFn, +): ConfirmOutcome { + if (isUltracode(cursor)) { + return { kind: 'ultracode-hint', message: ULTRACODE_HINT } + } + const result = applyFn(cursor) + return { + kind: 'apply', + message: result.message, + effortUpdate: result.effortUpdate, + } +}