Skip to content

refactor(tui): drop the dormant toggle from the /ss level view - #259

Merged
lpdink merged 3 commits into
developfrom
refactor/ss-drop-dormant
Nov 1, 2026
Merged

lpdink merged 3 commits into
developfrom
refactor/ss-drop-dormant

Conversation

@lpdink

@lpdink lpdink commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

删掉 /ss 面板里的 Ctrl+A(「当前层包含未加载会话」的开关),连同它背后那层「按会话是否在内存裁剪行集」的过滤。

/ss 的行集语义现在只有一条:根层是用户自己创建的会话,一个不少;子会话(父智能体派发的多级 agent)只在 → 之后逐层出现,← 退出。会话在不在内存(被 reaper 逐出与否)不再是面板要看的东西。

Motivation

/ss 是「我要回到哪个会话」的导航面。会话是否在内存是后端的记账——后端之所以还要暴露它、前端之所以还要主动 release,是因为 skills / rules 在初始化时加载而不是实时加载。这条记账被 #258 搬进了导航面,于是必须再造一个开关把被藏掉的行找回来。

用本机真实数据算过:718 条会话里 716 条 inactive(reaper 默认 30 分钟逐出,自动归档要 90 天),603 个根任务里默认只显示 3 行,其余 600 行藏在 ⋯ 600 dormant · Ctrl+A 那行 dim 尾注后面——而这个键在用户文档里根本没写,只能靠尾注发现,窄面板上还会被截断。于是「回到昨天那个会话」变成「先猜到一个键」。逐出策略不该影响用户找会话。

wing ps 不受影响:它的默认口径(今天在跑什么)本来就以存活为答案,--all 照旧放开。

Details

行集:Forest::level(scope) 现在返回该层的全部直接子(借用切片,零分配);存活闭包留给新的 Forest::level_live(scope),只被 wing ps 的默认分支使用(--all 走 level)。wing ps 的四条口径(默认 / --all / --tag / --parent)输出逐字未变。

键位:→ 进层、←(层 ≥ 1 的 Esc)退层、Ctrl+D/U 翻页、打字即搜索——全部保留。Ctrl+A 不再属于面板,落回 composer 的既有口径(带 CONTROL 的字符当普通字符插入),行为就是 #258 之前的那个。

渲染:底部那行 ⋯ N dormant · Ctrl+A 尾注整条删除;顶部保留当前层的名字一行(<层名> · ← back),它原名叫「面包屑」——这个词是黑话,代码与文档里一并改成「层标题行」,类型从 SessionChrome 改为 LevelHeader。

空层:→ 不会再进空层(前提是本层确实有直接子);但「进层之后本层子会话全被归档」仍是可达状态,面板只剩标题行、键位仍归它、← 是出口——这条有回归测试。

根层不涨墙:根层现在列全部任务(本机 603 行),排序(pin → 子树的注意力 → 子树活跃)保证关心的在最上面,打字搜索仍是跨层逃生口。

Changes

File Purpose
crates/wing/src/shared/session_tree.rs Level 结构 / dormant_children 删除;level 返回整层切片、新增 level_live(CLI 口径);闭包文档收敛到 CLI
crates/wing/src/ui/popup/command.rs SessionQuery::include_dormant 删除;层投影直接取 forest.level;SessionChrome → LevelHeader(只装层名)
crates/wing/src/ui/popup/selection.rs DormantFootnote / 尾注渲染 / FOOTNOTE_HINT 删除;chrome 行收敛成一行标题行,with_chrome → with_header
crates/wing/src/ui/popup/mod.rs 面板高度只算标题行;删掉随开关一起死掉的 selected_index / select_index
crates/wing/src/app/{modal,popup_state,mod}.rs ToggleDormant 键位 / 状态 / 光标钉位分支删除;层视图状态只剩路径
crates/wing/src/cmd/ps.rs 层视图按 --all 在 level / level_live 之间选;注释与用例口径跟随
crates/wing/src/app/tests/* 键位归属、根层行集、inactive 子层、空层回归、Ctrl+A 落回 composer 的行为断言
docs/dev/session-lineage.md、docs/dev/tui-input.md、docs/dev/http-api.md 契约:行集不按存活过滤、→ 与 --parent 的行集口径一致(只剩边的差异)、键位表去掉 Ctrl+A、术语去黑话
AGENTS.md、.agents/skills/wing-cli/SKILL.md 结构树与 CLI 手册跟随(/ss 不再是「有效父链 + 存活闭包」)

Known Limitations / Risks

  • 行为变更:/ss 根层会列出全部根会话(本机 603 行),包括长期没碰过的;不再有「只看活的」这个形态(要过滤用打字搜索)。wing ps 默认仍是 live-only,--json 零变化。
  • Ctrl+A 会写坏草稿:它在面板里不再被接管,于是落到 composer 的既有口径(插入字母 a → 草稿 /ssa,面板与层位置一起丢)。这与 feat(session): fold session lineage into a level view #258 之前、以及今天 Ctrl+E / Ctrl+K 的行为一致;没有为它加特例,因为那等于给一个已经没有语义的键发明新语义。已有测试钉住这个事实。
  • 术语重命名扩大了 diff:dormant → 「不在内存 / inactive」、SessionChrome → LevelHeader、面包屑 → 「层标题行」。它们与本次删开关同一个主题(把逐出从导航面赶出去),但确实占了 diff 的一部分。

Testing

新增 / 改写的测试(都断言可观察行为):

  • 内核:a_level_lists_every_direct_child_regardless_of_liveness、a_child_level_lists_every_direct_child(层视图一行不少)、the_live_view_keeps_an_inactive_parent_of_a_live_child / ..._drops_a_fully_inactive_subtree / ..._keeps_a_pinned_inactive_root / the_live_view_is_a_subsequence_of_the_level(CLI 存活口径仍是子序列、不重排)、the_levels_partition_every_row_in_total_order。
  • 面板投影:a_level_lists_inactive_children_too、a_level_whose_crew_is_all_inactive_is_still_a_full_level、未知 scope 仍降级为空层且用 id 命名。
  • App 层:test_the_root_level_lists_every_session_the_user_created(本次的核心行为)、test_a_level_lists_its_inactive_children_too、test_a_crew_of_inactive_sessions_is_a_normal_level、test_a_level_whose_children_all_vanished_keeps_its_keys(空层回归)、test_ctrl_a_falls_back_to_the_composer_and_loses_the_level(键位去向)。
  • 渲染:标题行占位与行窗口收缩、名字被截断时提示整段保留、矩形只够标题行时不画行。
  • CLI:wing ps 的默认 / --all / --tag / --parent 用例保持原断言(输出逐字未变)。

make check / make test(python + probe + rust + ts)全绿。

Review

两轮独立审查,发现的问题都在本 PR 内修掉(没有遗留项):

视角 级别 问题 处置
正确性 [B] .agents/skills/wing-cli/SKILL.md 仍把 /ss 写成「有效父链 + 存活闭包」、被逐出会话「默认收起只进尾注」 已改:两条通路行集口径一致,只剩边的差异
架构 [B] 同上(同一处) 同上
正确性 [S] Ctrl+A 不再被接管后落到 composer、把草稿改写成 /ssa,而测试只断言了路由枚举、注释还说「落给候选导航」 已改:注释改事实,另加一条行为断言钉住去向(这是 #258 之前的行为,不加特例)
正确性 [S] modal.rs 的键位表注释仍写「三个 Ctrl 组合」且理由(composer 的 Ctrl 只有 J/M)不成立 已改
正确性 [S] ActivePopup::selected_index / select_index 随开关一起成了死代码 已删
正确性 [S] 「进去是空层不可达」过强:进层后子会话全被归档仍可达,且原测试被删 契约改成「→ 不会进空层」,补回归测试
正确性 [S] 本次最核心的行为(根层无条件列出未加载会话)缺面板级测试 已补根层用例
云端 [S] panel_line1s 高度算了标题行、取行没加偏移(层 ≥ 1 会静默读错行) 已改
云端 [S] Ctrl+A 那条测试从未进层,「层位置一起丢」恒真 已改:先 → 进层再断言
云端 [N] §4.1 键位表只列被接管的键,查不到「其余 Ctrl 字符会发生什么」 已补一段
云端 [N] 内核两处注释仍把 level 说成过滤发生地、把层遍历说成「保留集上的限制」 已改
架构 [N] SessionHeader 名字没表达「当前层标题」 改名 LevelHeader
架构 [N] level 借用 / level_live 拥有,逼出一次 to_vec() 保留(面板热路径零分配、CLI 一次性),在 level_live 文档里写明理由
架构 [N] dormant → 「不在内存」的术语重命名扩大了 diff 保留(与删开关同一主题),见 Known Limitations

The /ss panel's row set depended on whether a session was in memory: the liveness
clip added in #258 hid every session the reaper had evicted (thirty minutes of
idleness by default) behind a footnote and a Ctrl+A toggle that no user-facing
doc ever mentioned. A switcher whose default state is nearly empty, and whose
rows come back only after an undiscoverable key, turns eviction into something
the user has to reason about — it is not one.

The level view now lists a level's direct children and nothing else: a row exists
because the session exists, not because it happens to be loaded. Root level is
the sessions the user created themselves; sub-agents stay one right-arrow away,
which is the part #258 was for.

`Forest::level` hands out the level's slice, `level_live` keeps the liveness
closure for `wing ps` (whose default answers "what is running now" and still
clips inactive sessions; `--all` opens it). The panel loses the footnote, the
`Level` struct, `dormant_children`, `SessionQuery::include_dormant` and the
toggle's cursor pinning. The level header line (the current level's name) stays,
renamed from the breadcrumb/chrome vocabulary for what it actually is.
Review pass over the previous commit:

- `.agents/skills/wing-cli/SKILL.md` still described the /ss drill-in as
  "effective parent chain + liveness closure" with a tail note. Both sides now
  share one row-set rule (a level's direct children, loaded or not); only the
  edge differs, and only on hand-edited cycles or ghost parents.
- Pin the fall-through of the now-unbound Ctrl+A: it reaches the composer,
  which inserts the letter (its long-standing rule for Ctrl+<letter>), so the
  draft becomes /ssa and the level position is lost. That is the pre-#258
  behaviour, not a new exception; a test now says so.
- Restore coverage for the header-only level: a level whose children were all
  archived after entering is still reachable (`LevelView` validates only the
  path's ids), so its keys and the Enter branch stay tested, and the contract
  says "right-arrow never enters an empty level" instead of "empty levels are
  unreachable".
- Add the panel-level test for the behaviour this change is about: a root
  session that is not loaded and not pinned is still listed.
- Drop `ActivePopup::selected_index` / `select_index`, dead since the toggle's
  cursor pinning went away.
- Rename `SessionHeader` to `LevelHeader`, and fix three stale doc sentences
  (`is_live`'s subject, `parent_of`'s consumer, `level`'s unknown-id example).
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

📊 覆盖率

增量覆盖率(门禁)

语言 改动行覆盖 覆盖 / 可执行 门禁 状态
Python(语句行) — — ≥ 90.0% ✅ 无改动行
Rust(行) 99.3% 138 / 139 ≥ 90.0% ✅
未覆盖的改动行(1 行)
文件 未覆盖行
crates/wing/src/ui/popup/mod.rs 156

总量(参考)

范围 指标 覆盖率
Python(wing + wing_sdk) 语句 + 分支 95.6%
Rust(workspace) 行 93.75%
Rust(workspace) 区域 94.02%
Python 分包(171 个文件)
包 覆盖率 语句 分支
wing 95.4% 13143 3672
wing_sdk 99.2% 513 150
Rust 分 crate(222 个文件)
crate 行 区域
wing-api-client 89.42% 88.74%
wing-math 92.46% 91.09%
wing 93.95% 94.32%

⚠️ 注意

  • Rust:净删除测试 56 行(crates/wing/src/app/tests/commands.rs(-56))——删除行不进增量判定,请人工确认。

run 日志 · 本地复现:make test-python-cov / make test-rust-cov 后跑 make patch-cov-python / make patch-cov-rust(基线 PATCH_BASE、阈值 PATCH_COV_MIN,见 docs/dev/coverage.md)

@wing-agent wing-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

总体判断:可以合并——删掉 Ctrl+A dormant 开关这条线是干净的:内核投影、CLI 口径、面板投影、渲染、键位路由、文档五处口径一致,wing ps 四口径逐字未变的等价性我逐行推过(成立)。最大风险不在产品代码,而在本次改动过的两处测试:panel_line1s 的高度/偏移账不一致(现在恰好没触发,以后会静默读错行)、Ctrl+A 那条测试的核心断言恒真(名字声称的行为没被测到)。

🔴 [B] 阻塞

未发现阻塞级问题。

🟡 [S] 建议

  • 🟡 [S] crates/wing/src/app/tests/commands.rs:1038 — panel_line1s 把 header_h 加进了矩形高度,取行时却没加同样的偏移(第 1049 行仍读 i*2);层 ≥ 1 时会读到标题行 / 上一行的第 2 行,断言静默错位。
  • 🟡 [S] crates/wing/src/app/tests/modal.rs:1106 — test_ctrl_a_falls_back_to_the_composer_and_loses_the_level 的「层位置一起丢」恒真:app_with_level_view() 起点 depth() 就是 0,从未进层,实现即使保住层位置也照样通过。

🔵 [N] nit

  • 🔵 [N] crates/wing/src/app/modal.rs:352 — Ctrl+A 现在会改写草稿为 /ssa 并关面板;这是已声明的取舍,但键位契约表没写这个去向(docs/dev/session-lineage.md §4.1 键位表 / docs/dev/tui-input.md 都只列被接管的键)。建议补一行说明「其它 CONTROL 字符落回 composer 当字面字符插入」。
  • 🔵 [N] crates/wing/src/shared/session_tree.rs:308 — 同族注释残留旧口径:第 274 行「收起到某一层只发生在 Self::level」、第 480 行 collect_levels 的「全序在保留集上的限制」,都与新的 level(不删行、不看保留集)不符。
  • 🔵 [N] crates/wing/src/ui/popup/selection.rs:455 — render_header_line 的 hint 形参现在恒为 BACK_HINT(原来两个调用点共用的泛化残留),可内联;留着也无害。
  • 🔵 [N] crates/wing/src/cmd/ps.rs:388 — forest.level(None).to_vec() 可以写成 iter().copied().collect(),省掉一次中间 Vec(一次性调用,纯洁癖)。

已验证项(不是复述 PR 描述,是逐条核对过的)

  • wing ps 四口径未变:level_rows 的 whole_level 分支 --all → level(None) 与旧 level(None, true).rows 等价(include_dormant=true 时不过滤保留集);默认 level_live(None) 与旧 level(None, false).rows 同样等价(同一切片上按 kept 过滤)。--tag / --parent 分支未动。
  • → 不进空层:handle_session_level_key(Descend) 的判据 crew.descendants > 0 与 children[index] 非空在同一个 Forest 上等价(descendants 就是逐子累加),所以 docs/dev/session-lineage.md §4.1 那条「有子就一定有行」成立。
  • Ctrl+A 的真实去向:input_area::dispatch_key 只排除 SUPER/META/HYPER,CONTROL 字符除 Ctrl+J/M 外一律 insert_char —— 测试断言 /ssa 与实现一致,非假测试。
  • 空层仍可表示:session_level_view() 以 height() > 0 为门槛,空层的 1 行来自 LevelHeader::height();ActivePopup::height() 与渲染侧「先扣标题行」是同一份账(矩形 13 行装 6 条已由新测试钉住)。
  • 术语/引用无残留:dormant / include_dormant / SessionChrome / ToggleDormant / 面包屑 / 尾注 / select_index 在 head 全树 0 命中;docs/dev/session-lineage.md 的「两条下钻出口」章节改名后,第 57 行的引用已同步。
  • 行集契约自洽:level 返回借用切片后,ps --all / 面板热路径都拿到 roots / children[i] 的零分配视图;level_live 的拥有型返回只服务 CLI,注释与代码一致。
  • 性能(未测量,靠推理):根层现在每次按键物化约 600 条富行(旧为个位数),但同一条路径上 Forest::build(718 行 HashMap + 排序 + DFS)与搜索态的逐候选 to_lowercase 本来就已经是 O(n) 且带分配,判断不构成数量级回归;本 PR 无 <!-- wing-perf --> 评论可依。若要更稳,可把行物化改成惰性(只物化可见窗口),但不是本次的必要条件。

整体置信度

中高。结论建立在 base/head 两侧代码逐段对照与文档交叉核对上;环境里没有可用的 Rust 工具链,make check / make test 未在本地复跑,两处测试问题的判断(偏移账、恒真断言)来自阅读而非运行。

let chrome_h = chrome.map_or(0, SessionChrome::height);
let area = Rect::new(0, 0, 60, (count * 2).max(1) as u16 + chrome_h);
let header_h = header.map_or(0, LevelHeader::height);
let area = Rect::new(0, 0, 60, (count * 2).max(1) as u16 + header_h);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [S] 高度算了标题行、取行却没有偏移:panel_line1s 在有层标题时会静默读到错的行。

证据:SelectionPopup::render 先把标题画在 area.y(rows.y += 1; rows.height -= 1),行窗口从 y + header_h 开始 —— 所以本层第 i 条的行 1 在 y = header_h + i*2。而下面第 1049 行读的仍是 buf[(x, (i * 2) as u16)]:层 ≥ 1 时 lines[0] 是标题行、lines[1] 是第 0 条的第 2 行……

当前两个调用点(test_a_forty_child_task_is_one_row_at_the_root_level、test_the_search_state_keeps_the_previous_row_shape)都在根层 / 搜索态(header_h == 0),所以现在恰好还对;但它是个静默陷阱:以后任何人拿它在 → 之后断言,测的就不是它想测的行,而且不会报错。

修复建议:.map(|x| buf[(x, header_h + (i * 2) as u16)].symbol().to_string())(或把 header_h 从 area 高度里去掉,二者只留一套账)。

app.handle_key(ctrl(KeyCode::Char('a')));
assert_eq!(app.input.text(), "/ssa", "草稿照既有口径被改写");
assert!(!app.popup.active.session_level_view(), "不再是层视图");
assert_eq!(app.popup.level_view.depth(), 0, "层位置一起丢");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [S] 这条断言恒真,测不到测试名字声称的行为。

证据:app_with_level_view()(第 951-959 行)只做 set_text("/ss") + update_popup_from_input(),没有 Right 也没有 level_view.enter(..),所以按 Ctrl+A 之前 depth() 就已经是 0。于是「层位置一起丢」在 Ctrl+A 之后必然成立 —— 即使实现真的把层位置保住了(比如哪天有人给 CONTROL 字符加了面板级吞键),这条断言也照样通过。

修复建议:先 app.handle_key(&key(KeyCode::Right)) 并断言 depth() == 1(boss 有子 kid,→ 会进层),再按 Ctrl+A,然后断言 depth() == 0。

// 空过滤词 = 「当前层」而不是「搜索态」:一个可打印字符要开启搜索。落到
// composer 会把命令名改写成 `/ssk`——面板与层位置一起丢,「打字即搜索」
// 也就不存在了。
// 也就不存在了。(带 CONTROL 的字符不算「可打印」:它们走上面那条既有口径。)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 [N] 这里的注释把事实写清了,但键位契约表还缺这一格。

Ctrl+A 落回 composer、把草稿改写成 /ssa、面板与层位置一起丢 —— 这是已声明的取舍,也有测试钉住。但本 PR 同时删掉了用户唯一能看见这条键的地方(⋯ N dormant · Ctrl+A 尾注),而 docs/dev/session-lineage.md §4.1 的键位表与 docs/dev/tui-input.md 的那一段都只列被面板接管的键:读者从文档里查不到「层视图里按 Ctrl+A / Ctrl+E / Ctrl+K 会发生什么」。

建议在 §4.1 键位表补一行:| 其它带 CONTROL 的字符 | 不接管,落回 composer 当字面字符插入(草稿被改写 ⇒ 面板与层位置一起丢) |。

(吞成 no-op 也是一种选择,但那是产品取舍、不是必须——这里只提契约完整性。)

/// 闭包与 `dormant_children` 一起退化成 no-op。
pub fn level(&self, scope: Option<&str>, include_dormant: bool) -> Level {
let children: &[usize] = match scope {
/// **不过滤存活**:一层的行集就是拓扑本身。TUI 的会话面板据此保证「用户自己开的

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 [N] 这段新 doc 是对的,但同族注释里还剩两处旧口径,容易被下一个人当契约读:

  • 第 274 行 tree() 的 doc 末句「收起到某一层只发生在 Self::level」——level 现在只做层选择、一行都不删;
  • 第 480 行 collect_levels 的 doc「层序拼接 = 全序在保留集上的限制」——保留集已不参与 level,那个助手如今是「全序在全部行上的限制」。

建议一并改成「层选择」/「全序」,与本段和 docs/dev/session-lineage.md §4 的新措辞对齐。

- `panel_line1s` reserved the header's row when sizing the frame but read row
  `i * 2`; a level ≥ 1 would have read the header and the previous row's second
  line. Now the same offset is applied to both.
- The Ctrl+A fall-through test never entered a level, so "the level position is
  lost too" held vacuously. Enter one first, then assert.
- §4.1's key table only lists the keys the panel takes; say what happens to the
  rest (COM-managed letters reach the composer and rewrite the command name).
- Two kernel comments still described `level` as the place rows get filtered and
  the level walk as a restriction to the kept set; both now match the code.
@lpdink
lpdink merged commit c249b3a into develop Nov 1, 2026
6 checks passed
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.

1 participant