Files
claude-code/docs/superpowers/plans/2026-06-14-effort-panel-basic.md
claude-code-best 58ee6419b1 feat: dynamic-workflow 来了 (#1271)
* feat(workflow): add workflow engine, /workflows panel, /ultracode skill

将 feat/sdk-backend 分支中 workflow 相关的 20 个 commit 压缩为单 commit:

- 工作流引擎核心:phase / agent / parallel / pipeline 编排原语(packages/workflow-engine/)
- /workflows 面板:三区焦点布局(顶部 run tabs + 左侧 phase 侧栏 + 右侧 agent 列表)
- /ultracode skill:多 agent workflow 编排入口
- 进度存储 / journal / notification 系统
- WorkflowService 生命周期管理 + SentryErrorBoundary
- 脚本沙箱:禁用 dynamic import()、JSON args 防御性归一化
- journal 与 named-workflow 路径统一在 projectRoot
- 错误处理:parallel/pipeline hooks 错误日志、failure routing、semaphore abort
- workflow 工具升级为 core 工具 + PascalCase 命名

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>

* feat(workflow): 复刻 ultracode 手册并修复 worktree/inline/opt-in 三处缺口

围绕 ultracode skill 审查 agent 系统一致性后:
- ultracode.ts: 用系统提示版完整 Workflow 编排手册替换中文精简版
- HIGH#1 isolation:'worktree': claudeCodeBackend.run() 用 createAgentWorktree +
  runWithCwdOverride 包裹 runAgent + finally 清理实现真正的 cwd 隔离;slug 用
  sha256(runId:agentId) 派生以匹配 cleanupStaleAgentWorktrees 清理正则
  (修 runId 为 w+base36 非 UUID 导致的泄漏盲区);worktree.ts 注释同步修正
- HIGH#2 inline 持久化: 新增 persistInlineScript,WorkflowTool + service 两条
  inline 路径对称持久化到 .claude/workflow-runs/<runId>/script.js,返回可复用
  scriptPath(闭环 inline→编辑→scriptPath 重提迭代循环)
- HIGH#3 opt-in 分工: ultracode/WorkflowTool/effort 注明 session reminder 由
  harness 注入,repo 内无 ultracode 信号,保持 feature('WORKFLOW_SCRIPTS') +
  isEnabled 两层 gate,不自造注入
- 测试: 新增 persistInline.test.ts;扩展 claudeCodeBackend(isolation 4 用例)/
  WorkflowTool(inline)/service(scriptPath)/ultracode(harness)

含配套 workflow engine/panel 完善与 run-state-persistence design doc。

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(workflow): run 终态落盘 state.json 支持跨重启恢复

终态 RunProgress(含 returnValue/error)此前只在内存 ProgressStore,进程
重启即丢失。本次让其落盘到 .claude/workflow-runs/<runId>/state.json,使
(a) 重启后可按 runId 取 return、(b) /workflows 面板跨重启展示历史 run。
跨进程 resume 明确不在范围。

- persistence.ts: getRunsDir/writeRunState/readRunState/listPersistedRuns
  + attachRunStatePersistence;原子覆盖写(tmp+rename),读容错(缺文件/
  损坏/schemaVersion 不符 → null),写 best-effort(IO 失败只 log warn)
- progress/store.ts: 加 hydrate(run) 直接注入磁盘 run(已存在 runId 跳过,
  内存优先)
- service.ts: getWorkflowService() 接线 attachRunStatePersistence(bus,
  store) 订阅 run_done(completed/failed/killed 三态共用,shutdown-kill
  也走同路径,无需额外钩子);WorkflowService 加 getRunAsync(id) 内存
  miss→读盘 fallback(不注入内存)+ loadPersistedRuns() 扫盘 hydrate
  (persistedLoaded flag 守护幂等)
- panel/WorkflowsPanel.tsx: mount 时调一次 loadPersistedRuns(重 mount
  不重复)
- ports.ts: runsDir 改用 getRunsDir() 消除拼接重复
- 测试: persistence.test.ts(11)/runStatePersistence.test.ts(5)/
  progressStore(2)/service(5)/WorkflowsPanel(1) 共 24 个新测试;
  precheck 5629 pass / 0 fail

设计偏离: 计划原写 monkey-patch getRunsDir 指向 tmpdir,Bun ESM namespace
不可变不可行;改用可选 runsDirProvider 参数(默认 getRunsDir)DI 注入,
加到 attachRunStatePersistence 与 makeService(cwdOverride 之后第 4 参),
与现有 cwdOverride 模式一致。makeService 的 cwdOverride 保持不变,不破坏
inline 持久化特性。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): 默认并发降为 3 并支持 per-run maxConcurrency 注入

- DEFAULT_MAX_CONCURRENCY=3 替代旧的 min(16, cores-2);MAX_CONCURRENCY_CAP=16 保留为用户输入的绝对上限
- 新增 clampMaxConcurrency() 处理 undefined/<1/>CAP 边界
- WorkflowInput schema 新增 maxConcurrency: number.int().min(1).max(16).optional()
- 引擎层 context/runWorkflow 全链路透传:semaphore 容量来自 per-run 入参
- WorkflowTool prompt 增加指引:fan-out 场景先用 AskUserQuestion 与用户确认并发再启动
- 同步 ultracode skill + audit workflow spec 的并发文字(删 cpu-cores 公式)
- 同步 docs/features/workflow-scripts.md 旧公式

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 面板 UI 字符串英文化

WorkflowsPanel 中 4 处面向用户的中文(onDone 错误消息、键位提示行)
改为英文;其他面板组件(AgentList/TabsBar)原本已是英文。代码注释
保留中文,与 workflow 模块惯例一致。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): 中断系统(x 杀单 agent / K 杀整个 workflow,Dialog 二次确认)

- claudeCodeBackend 桥接 ctx.signal → runAgent.override.abortController(修 'x' 无效根因:abort 到不了内部 fetch)
- AbortError 识别为 throw WorkflowAbortedError(不再吞成 dead,workflow 能感知被 kill)
- ports.taskRegistrar 加 registerAgentAbort/unregisterAgentAbort/killAgent;service.killAgent(runId, agentId) 精确中断
- 面板键位:'x' 杀当前 agent(agents 列聚焦时) / 'K' 杀整个 workflow;Dialog 二次确认 + confirm 模式吞导航键防误触
- 新增测试 8 项(backend signal bridge / hooks inject / ports killAgent / service killAgent)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(workflow): ultracode skill 加 model tier 选择指引(haiku/sonnet/opus/best 场景匹配)

补足 agent() 已有 model 参数缺的判断依据:列出 4 个 tier 的成本/延迟量级和典型场景,
明确"无法 articulate 为什么换 tier 就 omit"的 rule of thumb。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): maxConcurrency≠3 必须先 AskUserQuestion(默认 3 推荐值)

把 fan-out 时才问改成任何 maxConcurrency≠3 都必须问。
唯一例外:用户在当前会话已明确说过并发数("use 6" / "maxConcurrency 9")。
prompt (WorkflowTool.ts) + skill (ultracode.ts) + audit spec 三处同步。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): agent 失败自动重试一次(dead 或非 abort throw)

- hooks.agent 包装 invokeBackend:第一次 dead 或非 abort throw → 重试一次
- WorkflowAbortedError(kill)不重试——是用户意图
- registry.resolve 配置错(AdapterNotFoundError 等)在 try 外直接上抛,不走重试——
  配置问题重试无意义且掩盖 bug
- 重试仍失败:dead 保持 dead;throw 降级 dead(不击穿 workflow,
  与 parallel/pipeline null-on-error 契约一致)
- budget 不重复扣:dead 不 addOutputTokens,重试 ok 才扣一次
- 新增 7 项 hooks 层重试测试 + 1 项 service 层降级测试

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 面板 label 截断保留 #数字 后缀(同 dim 多 finding 可区分)

audit workflow 用 verify:\${dim}#\${findingIdx} 命名 verify agent。
旧逻辑 slice(0, 18) 从右切把 #idx 全吃了——同 dimension 多 finding
肉眼无法区分。新逻辑:含 #数字 后缀时保留后缀,前缀截断 + … 省略号。

例:verify:correctness#0 → verify:correctn…#0
   verify:architecture#15 → verify:archite…#15

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): kill 整个 workflow 后立即回主 chat

run_done→store→notifications.ts 的通知路径已有,但 confirmYes 后面板继续
挂着挡住主 chat,用户看不到"已停止"反馈。kill 后调 onDone() 立即退出面板,
让主 chat 的 `Workflow "<name>" was stopped` 通知直接可见。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): agent dead 带 reason/detail + prompt 加压 StructuredOutput

12 agent audit workflow 8 个 dead,journal 只记 {kind:"dead"} 无信息,
事后无法区分 "agent 没产 StructuredOutput" vs "runAgent 抛错"。
证据指向主因:sonnet 长 tool chain 后忘记调 StructuredOutput,
extractStructuredOutput 返回 null 即降级 dead。

- types.ts: AgentRunResult.dead 加可选 reason/detail 字段
  (no-structured-output / runagent-threw / worktree-failed / unknown)
  兼容旧 journal(均 optional)。
- claudeCodeBackend.ts: 三处 dead 填 reason + detail;
  no-structured-output 把 finalized 文本前 200 字符做 detail,
  让日志/面板能立刻看到 agent 最后说了什么。
- claudeCodeBackend.ts: schema 模式 prompt 首尾各放一次
  StructuredOutput 强制要求,针对 sonnet 长 tool chain 后忘记收尾。
- hooks.ts: retry 日志带 reason;retry 仍 throw 时降级 dead 也填
  reason=runagent-threw + detail。
- types.test.ts: 加 reason JSON 往返 + 旧 journal 兼容测试。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): schema 模式弃用 StructuredOutput 工具契约,改鲁棒 JSON 文本解析

上一轮 70a2f76 把"agent 长 tool chain 后忘调 StructuredOutput"当作死因,
加 prompt 头尾双强制。但实测跑 5 个 review agent 4 个 dead,detail 全是
"StructuredOutput tool is not available as a deferred tool"——根因是
该工具从未注入 workflow sub-agent 的工具集(assembleToolPool 默认池不含,
只有 stop_hook 路径 execAgentHook.ts 显式 createStructuredOutputTool())。
prompt 反复要求调一个不可达的工具,agent 困扰、长篇辩解、最终没产 JSON。

- claudeCodeBackend.ts:
  - extractStructuredOutput 重写:括号栈扫描替代 indexOf/lastIndexOf,
    处理嵌套对象、字符串内的括号、转义符;新增 fenced code block
    优先路径(```json / ```),多 JSON 块取第一个 parse 成功的;
    只返回 plain object(拒 array/number/string/null)。不做语法修复
    (尾逗号/单引号/注释)——避免在字符串内误改(如 "http://" 被 // 注释正则吃)。
  - schema 模式 prompt 简化:删首尾双 STRUCTURED OUTPUT 强制(600+ token),
    改成指示 agent 在最后文本块 emit raw JSON;明确告知"StructuredOutput
    is not available in this environment",消除调用幻觉。
- hooks.ts: detail.slice 用 typeof === 'string' 守卫;catch 块用
  e instanceof Error ? e.message : String(e)(旧 journal / 第三方 adapter
  可能写非 string detail,直接 .slice 会抛 TypeError 击穿日志)。
- claudeCodeBackend.test.ts: +9 测试覆盖 fenced / 嵌套 / 字符串内括号 /
  转义引号 / 多块取首 / 类型守卫 / 损坏 JSON。

precheck: 5663 pass / 0 fail。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): 新增 /effort 交互面板设计 spec

设计要点:
- /effort 无参 → 横向 slider 面板(low/medium/high/xhigh/max/ultracode)
- ←/→ 移动光标,Enter 确认,Esc 取消
- ultracode 仅视觉占位,确认后提示走 /ultracode <context>
- env override 时双标记 + 顶部警告
- 模型不支持时面板禁用
- 两阶段交付:先基础面板 commit,再做 ultracode 波纹动画

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): 新增 EffortPanel 基础面板实施计划(第一阶段)

按 TDD 分 6 个 task:纯函数状态 → keybinding 注册 → 组件 → 命令挂载 → 分支测试 → precheck。
波纹动画在第二阶段单独 commit。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): plan 补 q/ctrl+c 取消绑定,对齐 spec §5 状态机

verifier 抓到的 gap:spec §5 写明 Esc / Ctrl+C / q 都是取消事件,
但 plan Task 2.3 只绑了 escape。补上 q 和 ctrl+c → effortPanel:cancel。
同时把 Step 2.2 直接写成 6 个 action 版本(home/end),删除迂回表达。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): plan 修订执行前 review 发现的 5 处 gap

- Task 3.3 EffortPanel.tsx 草稿:Faster/Smarter padEnd 语法错乱重写;
  useKeybindings import 路径从 @anthropic/ink 修正为 ../../keybindings/useKeybinding.js;
  移除冗余 renderSeparatorLine;保留 renderPaddedLine
- Task 5.2 computeConfirmOutcome 改为注入 ApplyFn 模式:
  避免 effortPanelState → effort.tsx → EffortPanel 循环依赖;
  测试可注入 mockApply,无需 mock settings
- Step 5.3 测试代码对齐注入版签名

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* 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 <zai-org@claude-code-best.win>

* feat(keybindings): 注册 EffortPanel context 与 6 个 action

绑定 ←/→/h/l/home/end/enter/escape/q/ctrl+c 到 effortPanel:* action。
与 ModelPicker context 范式一致,避免左右键被全局 keybinding 拦截。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* 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>

* feat(effort): /effort 无参时挂载 EffortPanel 交互面板

- 无参 → <EffortPanelWrapper> 透传 AppState.effortValue
- current/status → 仍显示文本(不变)
- 有参 → 直跳 executeEffort(不变)
- help/-h/--help → 不变

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* test(effort): 补 computeConfirmOutcome 分支测试(注入 mockApply)

- ultracode → kind=ultracode-hint,不调 applyFn
- low → kind=apply,message/effortUpdate 来自 applyFn
- applyFn 返回无 effortUpdate 时 outcome.effortUpdate 为 undefined
- CANCEL_MESSAGE / ULTRACODE_HINT 常量

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 测试里 cursor cast 为 EffortValue,避免 PanelPosition 含 ultracode 触发 TS 错误

computeConfirmOutcome 的 ApplyFn 契约要求 EffortValue,但测试 mockApply 接收 PanelPosition。
实际运行时 computeConfirmOutcome 在 ultracode 档位走 hint 分支不会调 applyFn,
cast 安全。precheck 全量通过:5688 tests / 0 fail。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 面板对齐与配色修复

- 对齐:用 Box width={SEGMENT} + justifyContent="center" 让 ▲ 与档位名严格居中对齐,
  替代之前 string padEnd(11) 与 SEGMENT=12 不一致导致的 1 列偏移
- 配色:所有面板文字改用 theme.claude(Claude Orange rgb(215,119,87)),
  替代终端默认紫;分隔线/副标签/底栏用 theme.subtle;env 警告用 theme.warning
- 光标档位的档位名也加粗,强化视觉焦点

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 面板文字改紫色,ULTRACODE_HINT 英文化

- 颜色:theme.claude(橙)→ theme.purple_FOR_SUBAGENTS_ONLY(Purple 600, rgb(147,51,234)),
  覆盖标题、Faster/Smarter、▲、档位名
- ULTRACODE_HINT:中文 → 英文
  "ultracode is not an effort level. Use /ultracode <context> to start a multi-agent workflow."

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 统一用色版——选中 suggestion(蓝),未选中 subtle(灰)

弃用 purple_FOR_SUBAGENTS_ONLY(subagent 专用)。改与项目其他面板一致:
- 选中档位 + ▲:color="suggestion"(Medium blue rgb(87,105,247))+ bold
- 未选中档位 + 空 ▲ 占位:color="subtle"(Light gray rgb(175,175,175))
- 标题 / Faster / Smarter:color="suggestion"
- 分隔线 / 副标签 / 底栏:color="subtle"

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 终态前补发 phase_done,面板自动退出 running→terminal 转换

runWorkflow:脚本结束时 hook.phase 不会触发最后一个 phase 的 phase_done,
UI 左栏会永远显示 running。三路径(completed/killed/failed)统一在 run_done
之前补发 emitTerminalPhaseDone。

WorkflowsPanel:抽 isRunTerminatedTransition 纯函数判定 running → terminal,
面板 useEffect 检测到转换后自动退出聚焦。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 波纹动画纯函数 pickChar/computeRippleLine/mergeLayers + 18 测试

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): useRippleFrame hook 包装 useAnimationFrame,按需订阅时钟

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): EffortPanel 集成波纹背景——cursor 停在 ultracode 时切换波纹模式

仅在 cursor === 'ultracode' 时启用 useRippleFrame,渲染 5 行波纹背景
+ overlay 文字(Faster/Smarter、分隔线、▲、档位名、副标签)。
其余档位保持原 PlainContent 渲染路径不动。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* refactor(effort): 波纹动画从字符密度改为颜色渐变

按原版风格把波纹背景从 INTENSITY_CHARS 密度字符('·∙░▒▓')改为
suggestion 系颜色渐变(transparent → 暗深紫蓝 → suggestion → 高光):

rippleAnimation.ts:
- 删除 pickChar / INTENSITY_CHARS / WAVE_PEAK_CHARS / mergeLayers
- 新增 intensityToColor(intensity) → 'transparent' | '#xxxxxx'
- 新增 computeRippleCells 返回 Cell[](每位置 char+color)
- 新增 applyOverlaysToCells(cells, overlays) 替代 mergeLayers
- 新增 cellsToSegments(cells) 合并相邻同色段(减少 Text 节点)

EffortPanel.tsx:
- RippleContent 用 cells→segments→tokens 渲染
- 空格段用 BaseText backgroundColor 染色块(纯色块视觉)
- 文字段用 Text color 染色(亮色突出)
- tokens 按空格/文字二次拆分,避免混合段渲染歧义

测试: 29 个 rippleAnimation 测试覆盖 intensityToColor 边界、
computeRippleCells 长度/震源/衰减、applyOverlaysToCells 覆盖/截断/
防御式拷贝、cellsToSegments 合并逻辑。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 波纹参数调优——铺满左侧 + 速度调慢 + 全面板有底色

用户反馈三个问题:
1. "低峰部分没有颜色变化" → intensity ≤ 0.1 返回 transparent 导致波谷
   位置看不见。改为永不返回 transparent,最低档 #0a0d1a 作为面板
   底色(暗紫黑海洋),波峰在底色上流动。
2. "波浪速度太快" → time 系数 0.012 → 0.004(约 1/3 速)。波峰移动
   速度从 34 cell/s 降到 11 cell/s,每帧颜色变化从 45% 降到 36%。
3. "波浪只到中间部分,没覆盖左侧" → falloff 覆盖半径 40 → 90。
   震源 x=65,左侧 dist=65 < 90,波纹可达最左端(约 30-50% 覆盖)。

色阶调整:
- 删除 transparent 档,新增 #0a0d1a 作最暗档(底色)
- 最高档从 #8aa0ff(高光)改为 #5769F7(suggestion),避免与
  文字 overlay 同色互相吞噬
- 7 档颜色:#0a0d1a → #15182b → #1f2543 → #2a3360 →
  #3a4582 → #4a5bb0 → #5769F7

测试:删除 transparent 期望,改为期望具体颜色(#0a0d1a 等)。
新增"覆盖半径扩大"测试验证 dist=65 仍有非最暗颜色。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 波纹 v3 — 去黑边 + 删中心高频涟漪 + y 轴覆盖快捷键行

用户反馈三个问题:
1. "黑色边感觉不太对" — 最暗档 #0a0d1a (rgb 10,13,26) 太接近纯黑,
   远端波谷看起来像硬黑边。改为 #1a1f3a (rgb 26,31,58),紫蓝感
   更强而非纯黑。
2. "中心的快速波纹有点奇怪" — 删除震源附近 dist<6 的高频涟漪叠加
   (time*0.02,5 倍主波纹频率)。原本想让震源附近"水波感"更强,
   实际效果像"快速闪烁"反而突兀。主波纹已经足够,无需叠加。
3. "y 方向覆盖快捷键" — RippleContent 新增 y=2 行渲染快捷键 overlay
   ("←/→ adjust · Enter confirm · Esc cancel")。PlainContent 路径
   保持原 Box marginTop=1 + Text 渲染。

色阶调整(紫蓝感更强):
- #1a1f3a (原 #0a0d1a) — 最暗档
- #1f2543 / #252c55 / #2e3870 / #3a4582 / #4a5bb0 / #5769F7
  (中间档略调亮度,保持平滑过渡)

测试:震源点测试更新为"time=0 时波谷最暗,time 推进后扫过波峰变亮",
反映删除高频涟漪后的纯主波纹行为。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore(workflow): 工作流相关代码中文文案全部英文化

源码(src/workflow/ + packages/workflow-engine/src/)的中文注释、
用户可见错误消息、字符串字面量;测试文件的标题与注释;同步 6 条
硬编码断言到英文化后的错误消息。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 波纹 v4 — 平滑波 + 全色环旋转 + 淡入淡出 + 宽度自适应

- 波函数改 (sin+1)/2:消除 max(0,sin) 平直暗带(约 6 行宽)
- 主色相连续旋转(0.03°/ms,12s/圈全色环):蓝→紫→品红→红→橙→黄→绿→青
- 文字 overlay 同步色相旋转(rotateHue 应用到 Faster/▲/档位名/分隔线/副标签)
- 淡入淡出动画:fadeColor/fadeCells + fade 状态机 ~300ms 进出过渡
- 副标签固定 ultracode 段下方,不跟随光标移动
- 顶部/底部各加一行纯波纹行,视觉一致
- 宽度自适应终端列数:窄则 72,宽则铺满(computeSegment/computeRippleSourceX)
- 快捷键改 plain Text,不参与波纹背景渲染
- 新增 18 测试(fadeColor/fadeCells/rotateHue/getHueShiftAtTime)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* refactor: remove CYBER_RISK_MITIGATION_REMINDER from FileReadTool

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>

* fix: prevent ReDoS in extractMeta regex by anchoring to splice boundary

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>

* chore: 更新脚本

---------

Co-authored-by: glm-5.1 <zai-org@claude-code-best.win>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
2026-06-14 18:13:49 +08:00

898 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 === ''` 时返回 `<EffortPanel>`;其他路径不变 |
**不修改的文件:** `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('getInitialCursorenv override 存在时返回 env 值(若是合法档位)', () => {
expect(getInitialCursor({ envOverride: 'high', appStateEffort: 'medium', displayed: 'high' })).toBe('high')
})
test('getInitialCursorenv 为 nullunset时用 displayed', () => {
expect(getInitialCursor({ envOverride: null, appStateEffort: undefined, displayed: 'medium' })).toBe('medium')
})
test('getInitialCursorenv undefined 时用 displayed', () => {
expect(getInitialCursor({ envOverride: undefined, appStateEffort: 'high', displayed: 'high' })).toBe('high')
})
test('getInitialCursorenv 是数值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 都不是合法 PanelPositionultracode 由面板内部产生)。
*/
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_POSITIONSlow → medium → high → xhigh → max → ultracode
- moveLeft/moveRight边界钳制low 不再左移、ultracode 不再右移)
- getInitialCursorenv override > displayed level
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
```
---
## Task 2注册 EffortPanel keybinding context
**Files:**
- Modify: `src/keybindings/schema.ts`(在 `KeybindingAction` 联合类型追加 6 个 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 追加 6 个 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:home',
'effortPanel:end',
'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',
q: 'effortPanel:cancel',
'ctrl+c': 'effortPanel:cancel',
},
},
```
注意:
- `q``escape` / `ctrl+c` 都映射到 `effortPanel:cancel`,与 spec §5 状态机一致。
- Ink 的 useInput 默认在 ctrl+c 时退出进程;但项目 useKeybindings 系统会先拦截 ctrl+c参考 `useInput` 源码中 `if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC)` 分支)。若实施时发现 ctrl+c 仍直接退出进程,**降级为只绑 q + escape**,并在 commit message 里注明。
- Step 2.2 的 6 个 action`home/end`)与此处的 8 个绑定一一对应。
- [ ] **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 <zai-org@claude-code-best.win>
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(<EffortPanel {...baseProps} appStateEffort={undefined} />)
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(<EffortPanel {...baseProps} appStateEffort="high" />)
// 找到 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 } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import {
type EffortValue,
getDisplayedEffortLevel,
getEffortEnvOverride,
} from '../../utils/effort.js'
import {
type PanelPosition,
getInitialCursor,
isUltracode,
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
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)
if (isUltracode(cursor)) {
onDone(
'ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。',
)
return
}
const result = 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
// 两极文字行:左 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="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 - 'xhigh + workflows'.length))}
xhigh + workflows
</Text>
<Box marginTop={1}>
<Text dimColor>/ adjust · Enter confirm · Esc cancel</Text>
</Box>
</Box>
)
}
```
> ⚠️ 对齐是粗糙实现padEnd 11 假设每档名宽度 ≤ 11实际 'ultracode' = 9 字符OK'xhigh' = 5。第一版允许略微错位视觉精度在第二阶段调优。重点是标题、6 档名、底栏提示、▲ 标记必须出现。
> **Step 3.3 备注(如无 ink render helper** Step 5 走纯函数抽取方案测分支;渲染层只做"包含字符串"断言。
- [ ] **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 <zai-org@claude-code-best.win>
EOF
)"
```
---
## Task 4改造 `/effort` 命令挂载面板
**Files:**
- Modify: `src/commands/effort/effort.tsx`
- [ ] **Step 4.1: 阅读现状**
Run: `cat src/commands/effort/effort.tsx`
确认 `call()` 当前签名与 `ShowCurrentEffort` / `ApplyEffortAndClose` 组件结构。无参分支当前走 `<ShowCurrentEffort>`
- [ ] **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<React.ReactNode> {
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 <EffortPanelWrapper onDone={onDone} />
}
if (args === 'current' || args === 'status') {
return <ShowCurrentEffort onDone={onDone} />
}
const result = executeEffort(args)
return <ApplyEffortAndClose result={result} onDone={onDone} />
}
```
在文件底部追加 `EffortPanelWrapper`(桥接面板到 AppState 与 onDone
```tsx
function EffortPanelWrapper({
onDone,
}: {
onDone: (result: string) => void
}): React.ReactNode {
const effortValue = useAppState(s => s.effortValue)
return <EffortPanel appStateEffort={effortValue} onDone={onDone} />
}
```
注意:`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 交互面板
- 无参 → <EffortPanelWrapper> 透传 AppState.effortValue
- current/status → 仍显示文本(不变)
- 有参 → 直跳 executeEffort不变
- help/-h/--help → 不变
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
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: 抽取确认/取消为可测纯函数(注入 applyFn 避免循环依赖)**
`handleConfirm`/`handleCancel` 的决策逻辑抽到 `effortPanelState.ts`**接受 `applyFn` 作为参数注入**,避免 `effortPanelState.ts``effort.tsx``EffortPanel.tsx``effortPanelState.ts` 的循环依赖,也避免测试触碰真实 settings。
`effortPanelState.ts` 末尾追加:
```ts
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,
}
}
```
然后在 `EffortPanel.tsx` 里改用:
```tsx
// 顶部 import 新增
import {
type PanelPosition,
computeConfirmOutcome,
getInitialCursor,
isUltracode, // 不再需要computeConfirmOutcome 内部已用
moveLeft,
moveRight,
PANEL_POSITIONS,
} from './effortPanelState.js'
import { executeEffort } from '../../commands/effort/effort.js'
// handleConfirm 改为
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])
// handleCancel 改为
const handleCancel = React.useCallback(() => {
if (done) return
setDone(true)
onDone(CANCEL_MESSAGE)
}, [done, onDone])
```
注意 import 里也加 `CANCEL_MESSAGE`
- [ ] **Step 5.3: 写分支测试(用注入版纯函数)**
`effortPanelState.test.ts` 末尾追加:
```ts
import {
CANCEL_MESSAGE,
computeConfirmOutcome,
ULTRACODE_HINT,
type ApplyFn,
} from '../effortPanelState.js'
describe('computeConfirmOutcome', () => {
const mockApply: ApplyFn = cursor => ({
message: `applied:${cursor}`,
effortUpdate: { value: cursor as any },
})
test('ultracode → kind=ultracode-hint含 /ultracode 引导', () => {
const out = computeConfirmOutcome('ultracode', mockApply)
expect(out.kind).toBe('ultracode-hint')
if (out.kind === 'ultracode-hint') {
expect(out.message).toBe(ULTRACODE_HINT)
expect(out.message).toContain('/ultracode')
}
})
test('low → kind=applymessage 来自 applyFneffortUpdate 透传', () => {
const out = computeConfirmOutcome('low', mockApply)
expect(out.kind).toBe('apply')
if (out.kind === 'apply') {
expect(out.message).toBe('applied:low')
expect(out.effortUpdate?.value).toBe('low')
}
})
test('high → apply 路径不调 ultracode 分支', () => {
const out = computeConfirmOutcome('high', mockApply)
expect(out.kind).toBe('apply')
})
})
test('常量字符串', () => {
expect(CANCEL_MESSAGE).toBe('Effort unchanged.')
expect(ULTRACODE_HINT).toContain('/ultracode <context>')
})
```
注意:因注入 mockApply**完全不需要 mock settings**——这是注入方案的最大红利。
- [ ] **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 <zai-org@claude-code-best.win>
EOF
)"
```
---
## Task 6precheck 全量 + 验收
**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: ...`
- 把光标移到 ultracodeEnter → 输出引导文案
- 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 分支 A5 档 Enter 调 executeEffort
- [ ] §5 分支 Bultracode 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 中标为可选增强,本计划不做)
这些不影响主功能,第一版以"能用、稳定、可提交"为目标。