Repository navigation
fix(tui/session): esc keeps subagents alive, ctrl+c double-press exit, message menu right-click + revert confirm - #88
yixiao5428 wants to merge 9 commits into
Conversation
|
Hey! Your PR title Please update it to start with one of:
Where See CONTRIBUTING.md for details. |
|
The following comment was made by an LLM, it may be inaccurate: |
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
|
审核基于 1. [P2] 撤销确认的计数和范围与实际行为不一致位置:packages/tui/src/routes/session/dialog-message.tsx:40–46。
已分别用真实渲染组件和服务端实现验证:
建议统一口径:如果沿用横幅的用户消息/轮次计数,就复用相同的边界判断,并明确“包含所选消息”;如果展示全部消息数,也必须包含所选消息并说明口径。至少补“只有一条用户消息”和“user/assistant 交错、多条 assistant”的渲染测试。 2. [P2] 右键复制后仍弹出消息菜单,事件链尚未处理完整位置:packages/tui/src/routes/session/index.tsx:1225,关联 app.tsx:1113。 开启
修复文档认为 down 阶段的 stopPropagation 足以使消息行 copy 分支走不到并“保持一致”,但它不会抑制后续 up 事件。 已在完整应用挂载后的真实组件事件链中复现:选区为 这是既有路径尚未修完整,不是本 PR 新引入的回归。 但本 PR 声明的“右键有选区只复制、无选区才开菜单”仍不成立。建议统一 down/up 处理,或记录本次按下已用于复制,抑制对应的松开;增加覆盖两个事件的测试,单独测试 测试建议:不要允许未确认就已撤销的情况通过dialog-message.test.tsx:161–170 当前允许第一次 Enter 后 建议删除该放宽分支:第一次 Enter 后必须断言确认框可见且请求数为 0,第二次确认后才允许为 1。本轮补充的严格检查通过,未复现一次 Enter 自动确认;这里报告的是现有测试断言缺口,不是已经证实的运行时误确认。 验证范围
|
…enu after copy-on-select (review) Three fixes from the PR #88 review: - The confirm copy now counts what the server actually deletes: the revert boundary lands on the selected user message and cleanup removes it and everything after it (revert.ts cleanup uses order >= 0 without a partID), so the count is all.length - index — including the selected message and the assistants. Copy now reads "N messages (starting with this one)"; a one-message session says 1, not 0. The banner keeps its own user-turn count (pre-existing, different semantics, untouched). - With copy-on-select enabled (Windows default), the right-button down copies and clears the selection, and the following up still reached the message row and opened the menu. The row now records a press consumed by that copy (messageMouseAction gains consumedByCopy) and the matching up does nothing. - Drop the test relaxation branches: the first Enter must show the confirm dialog with zero revert calls, and only the second Enter may revert. The double-fire the branches guarded against was an artifact of the early harness (no fetch stub); the strict assertions pass 3/3 runs. New rendered tests: one-message session, mid-session selection over interleaved turns, strict confirm gate. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks for the careful review — all three points verified and fixed in 9c77a37. 1. Confirm count vs actual deletion (P2) — confirmed, our count was wrong on both axes. Fixed 2. Menu after copy-on-select right click (P2) — confirmed; our fix doc's claim that the down-phase stopPropagation would prevent the menu was wrong (it does not suppress the later up). Fixed with the "press consumed by copy" approach: the message row records on right-button down that the global copy-on-select handler spent the press ( 3. Test relaxation branches — agreed and removed. The double-fire the branches guarded against turned out to be an artifact of our early harness (SDKProvider without a fetch stub silently dropped the revert request and distorted the dialog chain); with the stub in place the strict assertions — first Enter must show the confirm dialog with zero revert calls, only the second Enter reverts — pass 3/3 consecutive runs. Verification for this revision: packages/tui 274 pass / 0 fail; both packages typecheck clean. |
…and #69 (message menu) Three §7.1 fix-design documents, no code changes yet, pending review: - session-fix-abort-cascade: session.abort must not cancel subagent jobs; add cascade switch to SessionRunState.cancel, HTTP abort opts out - tui-fix-exit-confirm: app.exit requires a second ctrl+c inside a 5s window - tui-fix-message-menu: left-click no menu, right-click opens with Copy default, Revert behind DialogConfirm (message count + 7-day warning) Co-Authored-By: Claude <noreply@anthropic.com>
session.abort meant "interrupt this session's own run" but
SessionRunState.cancel unconditionally ran cancelBackgroundJobs, whose
transitive parentSessionId scan cancels every running job in the subtree —
so esc in the TUI killed all background subagents too.
Add CancelOptions.cascade (default true, teardown semantics unchanged) to
SessionRunState.cancel and SessionPrompt.cancel; the HTTP abort handler
passes { cascade: false }. agent_stop, Session.remove and workspace warp
keep the default cascade.
Co-Authored-By: Claude <noreply@anthropic.com>
One ctrl+c with an empty input used to destroy the renderer immediately — in the common case (prompt already sent, agent running) the whole TUI and its background jobs died on a single press. Extract confirmExit (injected clock, 5s window, threshold 2, same shape as the esc interrupt) into util/confirm-exit.ts; the app.exit command run now shows "Press ctrl+c again to exit" on the first press and exits on the second. ctrl+d, <leader>q and /exit share the same run, so every exit entry point confirms. Keybind definitions unchanged. The lifecycle regression test now dispatches twice and asserts the renderer survives the first press; the fixture gains /session/dummy and /part endpoints because the app now lives past the first press and the session route's own session.get fired into the mock for the first time. Co-Authored-By: Claude <noreply@anthropic.com>
) Left-clicking a user message opened the Message Actions menu with the destructive Revert as the first, default-selected item — one accidental click plus Enter reverted the conversation and its file changes with no confirmation. - Extract messageMouseAction(button, selectionText): left click never opens the menu; right click copies a selection or opens the menu when nothing is selected; other buttons do nothing. - Reorder the menu to Copy, Fork, Revert so the default is non-destructive. - Revert now goes through DialogConfirm: the copy names how many messages will be removed, points at <leader>r //redo to undo, and warns when the message is older than the 7-day snapshot retention (prune = 7.days in snapshot/index.ts) that file changes may not be restorable. - The timeline (<leader>g) opens the same DialogMessage, so it inherits both changes. Tests: pure-function coverage for the decision table and confirm copy; rendered tests drive the real DialogSelect/DialogConfirm/keymap chain via testRender, asserting the revert request only fires after Confirm (through the real SDK client against a stub fetch) and not after Cancel. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…enu after copy-on-select (review) Three fixes from the PR #88 review: - The confirm copy now counts what the server actually deletes: the revert boundary lands on the selected user message and cleanup removes it and everything after it (revert.ts cleanup uses order >= 0 without a partID), so the count is all.length - index — including the selected message and the assistants. Copy now reads "N messages (starting with this one)"; a one-message session says 1, not 0. The banner keeps its own user-turn count (pre-existing, different semantics, untouched). - With copy-on-select enabled (Windows default), the right-button down copies and clears the selection, and the following up still reached the message row and opened the menu. The row now records a press consumed by that copy (messageMouseAction gains consumedByCopy) and the matching up does nothing. - Drop the test relaxation branches: the first Enter must show the confirm dialog with zero revert calls, and only the second Enter may revert. The double-fire the branches guarded against was an artifact of the early harness (no fetch stub); the strict assertions pass 3/3 runs. New rendered tests: one-message session, mid-session selection over interleaved turns, strict confirm gate. Co-Authored-By: Claude <noreply@anthropic.com>
9c77a37 to
a8073fa
Compare
…ess guard stays on the key binding only Typing the full /exit slash command is an unambiguous exit intent — there is no mispress to guard against — yet the previous two-press confirmation sat in the app.exit command run, so /exit (and /quit, /q, palette selection, the server-driven tui.command.execute dispatch) all demanded a second ctrl+c. Move the confirmExit state machine out of the command run and into the keymap binding layer: the app_exit bindings from tuiConfig.keybinds.gather() now carry an inline cmd handler that runs the two-press window and shows the "Press ctrl+c again to exit" toast. The guard therefore applies only where a stray keypress can come from (ctrl+c, ctrl+d, <leader>q); user keybind overrides keep working because the handler wraps whatever sequence the config resolves to. The command run now exits directly. Tests: the epilogue regression test dispatches app.exit once and exits; a new keystroke-level test drives the real keymap with mockInput ctrl+c — first press shows the toast (waitForFrame assertion) and keeps the renderer alive, the second press inside the window exits. The keystroke fixture now serves a configured provider: with an empty provider set the app auto-opens the connect-provider dialog, which pushes the keymap into modal mode and the base-mode app_exit binding stops matching keys entirely. docs/fixes/tui-fix-exit-confirm.md gets a revision section (part 4) and the devlog a lessons entry for the modal-mode fixture trap. Closes #87 (point 2, follow-up) Co-Authored-By: Claude <noreply@anthropic.com>
The same two-press guard covers all three app_exit bindings (ctrl+c, ctrl+d, <leader>q), but the toast hardcoded "Press ctrl+c again to exit" — pressing ctrl+d told the user to go press a different key. The binding cmd handler now formats the key from CommandContext.event via stringifyKeyStroke, so the hint always names the stroke that armed the guard. The keystroke test now drives ctrl+d instead of ctrl+c: it asserts "Press ctrl+d again to exit" and explicitly not the ctrl+c wording, which locks in per-key formatting rather than passing vacuously on the one key the old string happened to name. Co-Authored-By: Claude <noreply@anthropic.com>
…ts state the actual selection-clear mechanism Self-review of the review-response commits found two leftovers: 1. The "interleaved turns count assistants" case was named for coverage it did not have — every fixture row was a user message, so a regression that filtered the count back to user messages would still pass (it only caught the off-by-one). Add an assistantMessage builder and make the fixture a real [user, assistant, user(selected), assistant] history; the assertion "2 messages" includes the assistant answer and fails under a user-only count. 2. The right-button down handler was commented as a copy-on-select (Windows-default) patch, but the real mechanism is broader and load- bearing everywhere: the opentui renderer clears the selection after any button-down that is not preventDefaulted, so on the matching up no selection ever exists — the copy must happen on the down on all platforms, and the decision helper's selectionText copy branch is defensive only. Rewrote the onMouseDown/onMouseUp comments and the message-mouse docstring to say that, so nobody later "fixes" the unflagged handler into breaking non-Windows right-click copy. docs/fixes/tui-fix-message-menu.md gains a self-review revision section recording both, with the lesson that explanatory comments are behavior claims too and need source-verified mechanisms behind them. Co-Authored-By: Claude <noreply@anthropic.com>
Issue for this PR
Closes #87
Closes #69
Type of change
What does this PR do?
Three related TUI/session fixes, each with its own correction plan document and its own commit:
1.
session.abortno longer cascades to subagent jobs (#87, part 1) —7d8bfb06cdPressing esc twice in the TUI sends
session.abort.SessionRunState.cancelunconditionally rancancelBackgroundJobs, whose transitiveparentSessionIdscan cancels every running job in the session's subtree — so interrupting the main agent killed all background subagents too. The subagent fibers are forked inBackgroundJob's own scope and are never reached by the runner'sFiber.interrupt; the cascade came entirely from that one call.Fix:
SessionRunState.cancelandSessionPrompt.canceltakeoptions?: { cascade?: boolean }(defaulttrue, so teardown semantics are unchanged); the HTTP abort handler passes{ cascade: false }.agent_stop,Session.removeand workspace warp keep the default cascade. After abort, the subagent keeps running and reports back through the existing delegation notice, which wakes the parent (#53).2.
app.exitrequires a second press inside a 5s window (#87, part 2) —316624afb5One ctrl+c with an empty input destroyed the renderer immediately — in the common case (prompt already sent, agent running) the whole TUI and its background jobs died on a single press. Same shape as the existing esc interrupt double-press (
session.interrupt): extractedconfirmExitintoutil/confirm-exit.ts(injected clock, 5s window, threshold 2); the first press shows aPress ctrl+c again to exittoast, the second exits. ctrl+d,<leader>qand/exitshare the same run. Keybind definitions are unchanged, user overrides still work.3. Message menu needs right-click and confirms before revert (#69) —
56b4866e3bLeft-clicking a user message opened the Message Actions menu with the destructive Revert as the first, default-selected item — one accidental click plus Enter reverted the conversation and its file changes with no confirmation. Changes:
messageMouseAction(button, selectionText)decides (left click never opens the menu; right click copies a selection or opens the menu when nothing is selected); menu reordered to Copy, Fork, Revert so the default is non-destructive; Revert goes throughDialogConfirm, whose copy names the message count, points at<leader>r//redoto undo, and warns when the message is older than the 7-day snapshot retention (prune = 7.days) that file changes may not be restorable. The timeline (<leader>g) opens the same component, so it inherits both changes.Correction plans (§7.1, with backfilled checklists):
docs/fixes/session-fix-abort-cascade.md,docs/fixes/tui-fix-exit-confirm.md,docs/fixes/tui-fix-message-menu.md; verification log:docs/devlog/2026-10-28-tui-esc-exit-message-menu.md.How did you verify your code works?
Unit / rendered tests (all new, plus one adapted regression):
cascade: falseleaves a descendant jobrunning; default options still cancel it (test/session/prompt.test.ts).agent_stop's whole-subtree cancellation stays green (test/agent-management/lifecycle.test.ts, 23 pass).packages/tui/test/util/confirm-exit.test.ts).test/routes/message-mouse.test.ts); rendered tests drive the real DialogSelect/DialogConfirm/keymap chain viatestRender, asserting the revert request fires only after Confirm and not after Cancel, through the real SDK client against a stub fetch (test/cli/tui/dialog-message.test.tsx). The app lifecycle regression now dispatchesapp.exittwice and asserts the renderer survives the first press.packages/tui: 271 pass / 0 fail; both packages typecheck.End-to-end (real TUI in tmux against a real local model):
sleep 120; double-esc interrupted the main agent mid-run; the subagent ran its full 120s and its result was delivered back to the parent session and consumed there.<leader>g: default item is Copy; Revert shows Confirm Revert with the singular-count copy; Cancel leaves message count unchanged (verified in SQLite); Confirm sets revert and the TUI shows "1 message reverted / ctrl+x r or /redo to restore"; left click on a message does nothing.Not e2e-verified (covered by tests instead): a real right-click (pty-injected SGR press/release does not reach opentui's onMouseUp) and the 7-day stale warning copy (no old messages in the e2e session).
Screenshots / recordings
TUI change; key screens captured during e2e:
Press ctrl+c again to exittoast on first press (process alive):1 message reverted/ctrl+x r or /redo to restore.1 agent running · down opens the list, and the subagent's completion notice lands later ("Sleep completed... Elapsed time: 120 seconds").Checklist