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, + } +}