feat(effort): 实现 EffortPanel 组件主体(渲染 + 键盘交互 + 确认/取消分支)

- 横向 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 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-14 14:29:09 +08:00
parent 7663b74559
commit 7806d9f32e
3 changed files with 169 additions and 0 deletions

View File

@@ -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<PanelPosition>(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 (
<Box flexDirection="column" paddingX={1}>
<Text bold>Effort</Text>
{envActive && <Text color="ansi:yellow">{`⚠ CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session`}</Text>}
<Box marginTop={1}>
<Text>{poleLine}</Text>
</Box>
<Text>{renderPaddedLine(cursor)}</Text>
<Text>
{PANEL_POSITIONS.map(p => (p as string).padEnd(11))
.join('')
.trimEnd()}
</Text>
<Text dimColor>{`${' '.repeat(Math.max(0, PANEL_WIDTH - SUBLABEL_ULTRACODE.length))}${SUBLABEL_ULTRACODE}`}</Text>
<Box marginTop={1}>
<Text dimColor>/ adjust · Enter confirm · Esc cancel</Text>
</Box>
</Box>
);
}

View File

@@ -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);
});

View File

@@ -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 <context> 启动多 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,
}
}