mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
110
src/components/EffortPanel/EffortPanel.tsx
Normal file
110
src/components/EffortPanel/EffortPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/EffortPanel/__tests__/EffortPanel.test.tsx
Normal file
24
src/components/EffortPanel/__tests__/EffortPanel.test.tsx
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user