Skip to content

feat(tuiv2): Phase 10 — 全新快捷键系统(三层键位/命令行/搜索)#730

Open
pionxe wants to merge 11 commits into
1024XEngineer:mainfrom
pionxe:feat/tuiv2-phase10-keybinding
Open

feat(tuiv2): Phase 10 — 全新快捷键系统(三层键位/命令行/搜索)#730
pionxe wants to merge 11 commits into
1024XEngineer:mainfrom
pionxe:feat/tuiv2-phase10-keybinding

Conversation

@pionxe

@pionxe pionxe commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

概述

实现 TUI v2 的 Phase 10:全新快捷键系统。在已有的三层模式骨架(Input / Normal / Leader)上补全规划文档要求的全部能力,并完成按键分层路由、命令行、stream 搜索、模式指示配色、app.go 拆分。

Closes #720


新增能力

Input Mode(行编辑)

  • Ctrl+A / Ctrl+E — 行首 / 行尾
  • Ctrl+K — 删除到行尾(killToEnd
  • Ctrl+W — 删除前一个词(deleteWordBack,按空格/标点分词)
  • Ctrl+D — 上下文分发:输入为空时 EOF 退出;非空时删除光标后字符
  • Esc — 进入 Normal Mode(由 app 层拦截,不泄漏给 prompt)

Normal Mode(导航与命令)

  • g 到顶(单键)、G 到底
  • Ctrl+F / Ctrl+B — 整页下翻 / 上翻
  • : — 命令行输入:q/quit/exit(退出)、debughelpcompactmode、未知命令提示
  • / — 搜索:回车全量扫描 stream(append-only,索引稳定)、n/N 循环跳转、Esc 清除、stream 增长标记 Stale 提示

Leader Mode(Space 后)

  • Space m — 模型选择器(原 toggle mode 迁移到 :mode
  • Space c — 取消当前运行(原 compact 迁移到 :compact
  • Space r — 重试上次运行(无历史时提示)
  • Space Space — 切换上一会话(无上一会话时提示)
  • 保留:p/n/s/h/q/f/l
  • 非后缀键或超时(1s)→ 立即静默回到 Normal

视觉与状态

  • 状态行模式指示配色:[input]=FG、[normal]=Subtle、[leader]=Accent 加粗
  • Overlay 用 typed 常量OverlayType)收敛,消除字符串字面量

架构改进

按键分层约定

app.handleKeyMsg
 ├─ [层0] Overlay 激活:Esc 关闭(全局逃生),其余路由 overlay 组件
 └─ [层1] 按 Mode 分发:
     ├─ LeaderMode  → 独占捕获(非后缀键/超时静默回 Normal)
     ├─ NormalMode  → 拦截模式切换键(i/Space/:/ ) + n/N + 导航
     └─ InputMode   → 拦截模式切换键(Esc/Ctrl+D空/Ctrl+C) + 其余委派 prompt

模式切换键在 app 层拦截,不传 promptprompt.handleInputKey 删除 esc/i 分支,只保留编辑。

app.go 拆分(满足 <800 行):

  • app.go(785)— App 结构、Init/Update/View、核心装配
  • app_normal.go(182)— Normal 路由 + Ex/Search/n/N 辅助
  • app_leader.go(126)— Leader 路由 + 动作辅助
  • app_commands.go(234)— Gateway tea.Cmd 工厂与消息类型
  • app_view.go(139)— 视图布局与尺寸适配

事件清理RunStarted / 会话切换清 Search/Ex;stream 增长标记 Search.Stale


提交历史(11 个规范中文 commit)

582d9541 fix(tuiv2): Phase 10 第 2 轮审查修复 — 重复注释清理与 keymap 一致性
1e52a9e6 fix(tuiv2): Phase 10 第 1 轮审查修复 — 死代码/退化/注释 + 覆盖率盲区
f7fd3ab9 docs(tuiv2): Phase 10 Step 9 — 记录 Phase 9/10 交付进展
330d645c refactor(tuiv2): Phase 10 Step 8 — 拆分 app.go 回到 <800 行约束
6a8113ff feat(tuiv2): Phase 10 Step 8 — Ctrl+D 分层与 Ex/Search/n/N 路由
84972388 feat(tuiv2): Phase 10 Step 7 — Leader 动作调整为规划键位
9aefd016 feat(tuiv2): Phase 10 Step 6 — stream 新增 Ctrl+F/Ctrl+B 整页翻页
6b4ea78f feat(tuiv2): Phase 10 Step 5 — prompt 行编辑能力与模式指示配色
4d14d0c7 feat(tuiv2): Phase 10 Step 4 — 新增 cmdline 组件处理 : 命令与 / 搜索
fc737e00 feat(tuiv2): Phase 10 Step 2+3 — Overlay typed 常量与 Search/Ex 状态
13252233 feat(tuiv2): Phase 10 Step 1 — 扩展 keymap 键位映射

破坏性变更

  • Leader m:toggle Agent mode → 模型选择器(迁移到 :mode / /mode
  • Leader c:manual compact → 取消运行(迁移到 :compact / /compact
  • LeaderHelp 已标注迁移提示。g 保持单键到顶(未改成 vim 双键 gg)。

详细手工验证指引

准备

cd ~/Projects/neo-code
git checkout feat/tuiv2-phase10-keybinding
go build ./...                      # 确认编译通过
go test ./internal/tuiv2/...        # 确认测试全绿
go vet ./internal/tuiv2/...         # 确认 vet 干净

启动 TUI(fake 后端,无需真实 Gateway)

go run ./cmd/neocode-tuiv2 --backend fake --scenario default

手工验证(按顺序操作,观察底部状态行 [input]/[normal]/[leader]

1. Input Mode 编辑(默认 [input]

  • 输入 hello world
  • Ctrl+A → 光标到行首;Ctrl+E → 光标到行尾
  • Ctrl+W → 删除 "world"(剩 "hello ")
  • Ctrl+K → 光标移到中间后按,删除到行尾
  • Ctrl+D(非空)→ 删光标后字符;清空后再按 Ctrl+D → 退出程序(EOF)

2. 模式切换

  • Esc → 进入 [normal]i → 回 [input]

3. Normal Mode 导航

  • i 进 Input,输入 test 回车(让 stream 有内容)
  • Esc 进 Normal,j/k 滚动
  • g → 到顶;G → 到底
  • Ctrl+F → 整页下翻;Ctrl+B → 整页上翻
  • Ctrl+D → 半页下翻;Ctrl+U → 半页上翻

4. : 命令行

  • Normal 下 : → 底部出现 : 输入行
  • debug 回车 → stream 显示 Debug: true
  • help 回车 → 打开帮助(Esc 关闭)
  • compact 回车 → Compact triggered
  • mode 回车 → Agent mode: build
  • q 回车 → 退出
  • bogus 回车 → Unknown ex command: bogus

5. / 搜索

  • Normal 下 / → 底部 / 输入行
  • 输入 error(或已有词)回车 → 跳首个匹配
  • n → 下一个;N → 上一个(循环)
  • 让 Agent 继续输出后,观察 results may be stale — press / to refresh
  • /zzz 回车 → No matches: zzz
  • Esc → 清除搜索

6. Leader Mode

  • Normal 下 Space → 进入 [leader](Accent 粗体)
  • Space m → 模型选择器;Space s → 会话选择器;Space h → 帮助
  • Space c(运行中)→ 取消;(空闲)→ 静默
  • Space r(有历史)→ 重试;(无)→ No previous run to retry
  • Space Space(有上一会话)→ 切换;(无)→ No previous session to switch
  • Space n → 新建会话;Space q → 退出
  • Space x(非后缀)→ 静默回 Normal

7. Ctrl+C 双退

  • 运行中按 Ctrl+C → 取消运行
  • 空闲按 Ctrl+CPress Ctrl+C again to quit;2 秒内再按 → 退出

pionxe added 11 commits June 23, 2026 20:21
为三层键位系统补充 Phase 10 规划所需的动作与映射:

- Input Mode 新增编辑动作:ActionLineStart/LineEnd/KillLine/DeleteWord
  分别绑定 Ctrl+A(行首)/Ctrl+E(行尾)/Ctrl+K(删到行尾)/Ctrl+W(删前一词)
- Normal Mode 新增整页翻页动作:ActionFullPageDown/FullPageUp
  绑定 Ctrl+F/Ctrl+B;g 保持单键到顶、G 到底
- Leader 新增动作:ActionLeaderRetry/CancelRun/LastSession/ModelPicker
  供 Step 7 改造 Leader handler 使用
- MatchInputKey 增加 ctrl+a/e/k/w 映射;ctrl+d 不映射
  (由 app 层按输入框空否决定 EOF/DeleteChar),并补充注释说明
- Help 文案同步更新:Ctrl+A/E/K/W、Ctrl+F/B、Space r/Space Space
  LeaderHelp 标注 compact/toggle-mode 迁移提示
- 校验帮助文案不再出现 "g g" 双键描述

注:ActionLeaderToggleMode/Compact 暂保留(app.go 仍引用),
Step 7 改造 Leader handler 时统一删除。
收敛浮层字面量为类型化常量,并为搜索/命令行功能预留状态字段:

- 新增 OverlayType 类型与 OverlayNone/Palette/Help/SessionPicker/
  ModelPicker/Confirm/Ex/Search 常量,消除散落在 app.go 与各组件
  中的字符串字面量(符合 AGENTS.md "不硬编码业务语义字符串")
- OverlayState.Active 由 string 改为 OverlayType,openOverlay
  签名同步调整,编译器保证引用完整
- 新增 SearchState{Active,Query,Matches,MatchIndex,Stale}:
  Matches 存放 Stream 全局索引(append-only 保证稳定),Stale
  在 stream 增长后置位提示用户重新搜索
- 新增 ExState{Active,Input} 供 : 命令行输入使用
- reducer.cloneViewState 增加 Search.Matches 深拷贝,避免共享切片
- 同步更新所有组件与测试文件中的 overlay 字面量引用
抽离 Normal Mode 下的命令行与搜索输入逻辑为独立组件,避免 app.go 膨胀:

- 新增 CmdLine 组件(tea.Model),处理 Ex(:) 与 Search(/) 输入:
  - 字符追加、Backspace、Enter 提交、Esc/Ctrl+C 取消
  - 导航键(j/k 等)在输入时忽略,不触发滚动
  - 通过返回 ExCommandMsg/SearchSubmitMsg/CmdLineCancelMsg 让 app 层
    执行副作用,组件本身无副作用,职责单一
- RunSearch 全量扫描 Stream 返回匹配 entry 全局索引(append-only
  保证索引稳定),大小写不敏感,空查询返回 nil
- View 渲染 : 或 / 前缀输入行 + 光标,搜索结果过时时显示 stale 提示
- 测试覆盖:Ex 输入/提交/取消、Search 输入/提交、View 渲染(含 stale)、
  RunSearch 匹配/空查询/无匹配

cmdline 组件先于 prompt 改造完工(plan-v4 修正后的顺序),后续
Step 5 prompt 将委托渲染给它。
为 Input Mode 补充 Readline 风格行编辑,并按模式着色状态行指示:

- handleInputKey 新增 Ctrl+A(行首)/Ctrl+E(行尾)/Ctrl+K(删到行尾)/
  Ctrl+W(删前一词) 编辑动作
- 抽取 killToEnd() 与 deleteWordBack() 辅助函数:Ctrl+W 按空格与
  常见标点分词,符合 Readline 语义
- modeLine 左侧模式指示按模式着色:input=BaseStyle(FG)、
  normal=SubtleStyle、leader=AccentStyle 加粗(不加闪烁),
  右侧会话/模型信息保持 SubtleStyle
- 新增 modeIndicatorStyle 辅助函数收敛样式选择

注:esc/i 的模式切换分支暂保留,Step 8 app 层接管后再迁移删除。
测试覆盖 Ctrl+A/E/K/W 全流程与三种模式指示配色。
Normal Mode 下补充整页翻页能力(vim Ctrl+F/Ctrl+B 语义):

- AgentStream.Update 新增 ctrl+b(整页上翻)/ctrl+f(整页下翻),
  步长为 visibleLineCount(整页),复用 clampScroll 与 AutoScroll
  逻辑,与现有 ctrl+u/ctrl+d(半页) 一致
- 空流下 ctrl+f/b 经 clampScroll(maxOffset=0) 保护,不崩溃
- 测试覆盖:底部 Ctrl+B 上翻、Ctrl+F 下翻、空流边界
按 Phase 10 规划调整 Leader 后缀绑定,并实现新动作:

- Leader m 由 toggle Agent mode 改为打开 model_picker(规划要求)
- Leader c 由 manual compact 改为取消当前运行(规划要求),
  抽 cancelCurrentRun 辅助:运行中返回 cancel cmd,空闲静默 no-op
- 新增 Leader r:重试上次运行(retryLastRun),用 lastUserText 重发,
  无历史时提示 "No previous run to retry"
- 新增 Leader Space:切换上一会话(switchToLastSession),用 prevSessionID,
  无上一会话时提示 "No previous session to switch"
- App 新增私有字段 lastUserText/prevSessionID(不入 ViewState):
  handleSubmitMessage 记录 lastUserText,handleSessionSelect 切换前
  存当前会话到 prevSessionID
- Leader 非后缀键静默回 Normal(独占捕获,不泄漏给 Normal handler)
- 删除已废弃的 ActionLeaderToggleMode/ActionLeaderCompact 常量,
  MatchLeaderKey 与测试同步更新
- 测试覆盖 m/c(运行+空闲)/r(有+无历史)/space(有+无上一会话)/非后缀键

compact 与 toggle-mode 功能保留在 palette(:compact / :mode),
LeaderHelp 已在 Step 1 标注迁移提示。
完成 Normal/Input 模式按键分层路由与命令行/搜索功能整合:

- handleInputModeKey 拦截 Ctrl+D 按上下文分发:输入空→EOF 退出程序,
  非空→删除光标后字符(委派 prompt);Ctrl+D 不进 MatchInputKey
- handleNormalModeKey 新增::→打开 Ex overlay、/→打开 Search overlay、
  n/N→搜索匹配循环跳转、Ctrl+F/B 整页路由 stream
- Update 新增消息处理:ExCommandMsg(执行:命令并关 overlay)、
  SearchSubmitMsg(全量扫描+跳首个)、CmdLineCancelMsg(Esc 取消)
- overlay 为 Ex/Search 时按键路由给 cmdLine 组件
- gatewayEventMsg: stream 增长时标记 Search.Stale=true;
  RunStarted 时清理 Search/Ex 子状态
- View: Ex/Search overlay 时用 cmdLine.View()+modeLine 替代 prompt 输入区
- 新增辅助: enterInputFromNormal/clearSearchAndEx/openEx/openSearch/
  executeExCommand/executeSearch/jumpSearchMatch/scrollToStreamIndex
- prompt.handleInputKey 删除 esc/i 模式切换分支(已由 app 层接管),
  prompt 只保留编辑;新增导出 ModeLine() 供 app 复用状态行
- 测试覆盖: Ctrl+D 三态、Ex 命令(q/debug/help/compact/mode/未知)、
  Ex/Search overlay 流程、搜索提交+n/N+空查询+无匹配、stream 增长 stale

注: app.go 当前 1405 行,下一步拆分为 app_normal/app_leader 等
回到 <800 行(plan-v4 Step 8 拆分部分)。
app.go 内联路由/命令行/搜索后膨胀至 1405 行,违反 AGENTS.md 单文件
<800 行约束,按职责拆分为同包多文件:

- app.go (785行):保留 App 结构、Init/Update/View、handleKeyMsg/
  handleInputModeKey/handleCtrlC、toggle/trigger 辅助、appendStream/
  bindComponents 等核心装配
- app_normal.go (215行):handleNormalModeKey + Ex/Search/n/N 辅助
  (enterInputFromNormal/openEx/openSearch/clearSearchAndEx/
  executeExCommand/executeSearch/jumpSearchMatch/scrollToStreamIndex)
- app_leader.go (132行):handleLeaderKey + cancelCurrentRun/
  retryLastRun/switchToLastSession
- app_commands.go (254行):Gateway 客户端 tea.Cmd 工厂与消息类型
  (loadInitialCmd/submitMessageCmd/cancelRunCmd/loadSessionCmd/
  createSessionCmd 等 + initialLoadedMsg/gatewayEventMsg 等)
- app_view.go (147行):视图布局与尺寸适配
  (applyWindowSize/mainArea/separatorLine/fitViewToTerminal/fitLine/
  debugLine/applyInitialLoaded)

纯代码移动,无逻辑变更,全量测试通过。
在实施指南末尾追加"后续 Phase 进展"段落,记录 tui-v2-issues 规划下
已交付的 Phase 9(交互增强)与 Phase 10(全新快捷键系统):

- Phase 10 三层键位补全:Input 行编辑、Normal 整页翻页与 stream 搜索、
  : 命令行、Leader 动作调整、模式指示配色、Ctrl+D 上下文分发、
  Overlay typed 常量
- 指向规划文档 phase-10-keybinding-system.md 查阅详细键位与验收标准
- 注明 Phase 10 不引入 ModeChangedMsg(遵循 AGENTS.md 代码为准原则)
依据 tmux 1/2 Claude Code 第 1 轮审查(两方一致结论)修复:

必须修复:
- 删除未映射的 ActionSearchBackward 常量(? 键无消费者,原 Normal 下 ?
  打开 help 的行为已由 Space h / :help 覆盖,保留常量是死代码/退化)
- scrollToStreamIndex 删除不可能触发的 estimated<0 死代码分支
  (targetIndex 已被上方 guard 限制在 [0,len),estimated 恒 >0)
- 清理拆分残留的重复/孤儿注释(cancelCurrentRun 重复注释、
  app_normal.go 中 handleLeaderKey 孤儿注释)

测试盲区补齐(增量覆盖率≥80% 硬性要求):
- CmdLine.Init() 0%→100%、handleBackspace 60%→100%(Search overlay Backspace)
- CommandPrompt.ModeLine() 0%→100%(导出方法)
- cancelCurrentRun 71.4%→100%(client==nil 分支)
- scrollToStreamIndex 71.4%→100%(负数/越界/空流边界)
- leaderTimeoutCmd 50%→100%(超时回退 + 非 Leader no-op)
- loadInitialCmd 失败分支(离线/关闭 client)
- 空 stream 搜索、空 ex 命令边界

覆盖率提升:app 包 90.6%→92.0%,components 89.7%→90.0%。
依据 tmux 1/2 Claude Code 第 2 轮复审(两方一致结论)修复:

- 清理 app.go 拆分残留的重复注释块(23 处):app_normal.go(7)、
  app_leader.go(2)、app_commands.go(10)、app_view.go(4),函数注释
  不再重复出现两次
- 删除 keymap 中未被 app 层消费的 4 个行编辑 Action:
  ActionLineStart/LineEnd/KillLine/DeleteWord 及其 ctrl+a/e/k/w 映射
  (这些编辑键由 prompt 组件内部直接处理,保留 keymap Action 会造成
  架构不一致——keymap 定义了但无人消费);MatchInputKey 注释补充说明
  编辑键归属 prompt 层以保持内聚
- TestLoadInitialCmdErrorPaths 阻塞风险经实测确认为假阳性
  (fakegateway Close 后 Health 返回 error 而非挂起,15s 超时测试通过)

全量测试通过,覆盖率保持。
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two correctness issues in the new TUI v2 keybinding flow. The existing go test ./internal/tuiv2/... suite passes, but these paths are not covered by the added tests.

a.state.Layout.AutoScroll = false
// 反向估算:偏移越大表示越靠顶部。目标越靠后(索引大)越接近底部,偏移越小。
// targetIndex 已被上方 guard 限制在 [0, len),故 estimated 恒 > 0,无需再判负。
a.state.Layout.ScrollOffset = len(a.state.Stream) - targetIndex

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This offset uses the stream entry index as if it were a rendered-line offset, and it is off by one even for one-line entries. AgentStream.visibleLines treats ScrollOffset as the number of rendered lines hidden below the viewport (end := len(lines) - ScrollOffset), so with 20 one-line entries and a 4-line viewport, jumping to match index 19 sets offset 1 and renders entries 15-18, leaving the matched last entry just below the viewport. Multi-line messages, timestamps, and separators make the mismatch larger. Search navigation should map the target entry to its rendered line position, or expose a stream helper that scrolls to an entry and accounts for the renderer's line model.

switch c.state.Overlay.Active {
case state.OverlayEx:
if len(c.state.Ex.Input) > 0 {
c.state.Ex.Input = c.state.Ex.Input[:len(c.state.Ex.Input)-1]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backspace is slicing one byte, not one rune. If the user types a non-ASCII search term or command argument, for example , the first backspace leaves an invalid UTF-8 string ( is three bytes), so the prompt/search query renders as replacement characters and the submitted query can be corrupted. The main prompt already edits []rune; this cmdline path should do the same for both Ex.Input and Search.Query.

@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

phase-10-keybinding-system

1 participant