Repository navigation
refactor(tui): drop the dormant toggle from the /ss level view - #259
Conversation
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).
📊 覆盖率增量覆盖率(门禁)
未覆盖的改动行(1 行)
总量(参考)
Python 分包(171 个文件)
Rust 分 crate(222 个文件)
|
There was a problem hiding this comment.
总体判断:可以合并——删掉 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); |
There was a problem hiding this comment.
🟡 [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, "层位置一起丢"); |
There was a problem hiding this comment.
🟡 [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 的字符不算「可打印」:它们走上面那条既有口径。) |
There was a problem hiding this comment.
🔵 [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 的会话面板据此保证「用户自己开的 |
There was a problem hiding this comment.
🔵 [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.
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
crates/wing/src/shared/session_tree.rsLevel结构 /dormant_children删除;level返回整层切片、新增level_live(CLI 口径);闭包文档收敛到 CLIcrates/wing/src/ui/popup/command.rsSessionQuery::include_dormant删除;层投影直接取forest.level;SessionChrome→LevelHeader(只装层名)crates/wing/src/ui/popup/selection.rsDormantFootnote/ 尾注渲染 /FOOTNOTE_HINT删除;chrome 行收敛成一行标题行,with_chrome→with_headercrates/wing/src/ui/popup/mod.rsselected_index/select_indexcrates/wing/src/app/{modal,popup_state,mod}.rsToggleDormant键位 / 状态 / 光标钉位分支删除;层视图状态只剩路径crates/wing/src/cmd/ps.rs--all在level/level_live之间选;注释与用例口径跟随crates/wing/src/app/tests/*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/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的行为一致;没有为它加特例,因为那等于给一个已经没有语义的键发明新语义。已有测试钉住这个事实。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 命名。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(键位去向)。wing ps的默认 /--all/--tag/--parent用例保持原断言(输出逐字未变)。make check/make test(python + probe + rust + ts)全绿。Review
两轮独立审查,发现的问题都在本 PR 内修掉(没有遗留项):
.agents/skills/wing-cli/SKILL.md仍把/ss写成「有效父链 + 存活闭包」、被逐出会话「默认收起只进尾注」Ctrl+A不再被接管后落到 composer、把草稿改写成/ssa,而测试只断言了路由枚举、注释还说「落给候选导航」modal.rs的键位表注释仍写「三个 Ctrl 组合」且理由(composer 的 Ctrl 只有J/M)不成立ActivePopup::selected_index/select_index随开关一起成了死代码→不会进空层」,补回归测试panel_line1s高度算了标题行、取行没加偏移(层 ≥ 1 会静默读错行)Ctrl+A那条测试从未进层,「层位置一起丢」恒真→进层再断言level说成过滤发生地、把层遍历说成「保留集上的限制」SessionHeader名字没表达「当前层标题」LevelHeaderlevel借用 /level_live拥有,逼出一次to_vec()level_live文档里写明理由dormant→ 「不在内存」的术语重命名扩大了 diff