Skip to content

fix(tui/session): esc keeps subagents alive, ctrl+c double-press exit, message menu right-click + revert confirm - #88

Open
yixiao5428 wants to merge 9 commits into
devfrom
fix/tui-interrupt-exit-and-message-menu
Open

yixiao5428 wants to merge 9 commits into
devfrom
fix/tui-interrupt-exit-and-message-menu

Conversation

@yixiao5428

@yixiao5428 yixiao5428 commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Issue for this PR

Closes #87
Closes #69

Type of change

  • Bug fix

What does this PR do?

Three related TUI/session fixes, each with its own correction plan document and its own commit:

1. session.abort no longer cascades to subagent jobs (#87, part 1) — 7d8bfb06cd

Pressing esc twice in the TUI sends session.abort. SessionRunState.cancel unconditionally ran cancelBackgroundJobs, whose transitive parentSessionId scan cancels every running job in the session's subtree — so interrupting the main agent killed all background subagents too. The subagent fibers are forked in BackgroundJob's own scope and are never reached by the runner's Fiber.interrupt; the cascade came entirely from that one call.

Fix: SessionRunState.cancel and SessionPrompt.cancel take options?: { cascade?: boolean } (default true, so teardown semantics are unchanged); the HTTP abort handler passes { cascade: false }. agent_stop, Session.remove and 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.exit requires a second press inside a 5s window (#87, part 2) — 316624afb5

One 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): extracted confirmExit into util/confirm-exit.ts (injected clock, 5s window, threshold 2); the first press shows a Press ctrl+c again to exit toast, the second exits. ctrl+d, <leader>q and /exit share the same run. Keybind definitions are unchanged, user overrides still work.

3. Message menu needs right-click and confirms before revert (#69) — 56b4866e3b

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. 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 through DialogConfirm, whose copy names the message count, points at <leader>r / /redo to 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: abort with cascade: false leaves a descendant job running; 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).
  • confirmExit: first press arms, second within window exits, boundary and expired-window rearms (packages/tui/test/util/confirm-exit.test.ts).
  • message menu: pure-function decision table and confirm-copy wording (test/routes/message-mouse.test.ts); rendered tests drive the real DialogSelect/DialogConfirm/keymap chain via testRender, 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 dispatches app.exit twice 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):

  • esc cascade: subagent running 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.
  • ctrl+c: single press toasts and survives; press after the window rearms; double press inside the window exits.
  • message menu via <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 exit toast on first press (process alive):
                                                                                                          ┃  Press ctrl+c again to exit  ┃
  • Confirm Revert dialog after choosing Revert:
                                               Confirm Revert                                       esc
  This will remove the 1 message after this one and
  restore the file changes made in it. You can restore
  with <leader>r (or /redo) before sending a new message.
                                                                                  Cancel  Confirm
  • After Confirm: 1 message reverted / ctrl+x r or /redo to restore.
  • After double-esc with a subagent running: footer still shows 1 agent running · down opens the list, and the subagent's completion notice lands later ("Sleep completed... Elapsed time: 120 seconds").

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions

Copy link
Copy Markdown

Hey! Your PR title fix(tui/session): esc keeps subagents alive, ctrl+c double-press exit, message menu right-click + revert confirm doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown

The following comment was made by an LLM, it may be inaccurate:

@github-actions

Copy link
Copy Markdown

Thanks for updating your PR! It now meets our contributing guidelines. 👍

Copy link
Copy Markdown
Owner

审核基于 746e5d6c8a25c1ea4ffe87a46df2d89e98f66f41,对照基座 d9d7de31fba2578f3646b2f422a34860e6440f6d。建议小修后合并:主要取消/退出逻辑未发现阻断问题,下面两处 UI 行为需要修正。

1. [P2] 撤销确认的计数和范围与实际行为不一致

位置:packages/tui/src/routes/session/dialog-message.tsx:40–46。

revertedCount 使用 all.length - index - 1,文案也写成 “messages after this one”,排除了所选消息。但服务端按所选消息建立 revert 边界,当前消息同样被撤销;后续 cleanup 的无 partID 分支使用 order >= 0,会移除边界消息本身。

已分别用真实渲染组件和服务端实现验证:

  • 会话只有一条用户消息时,确认框显示 “This will remove the 0 messages after this one”。
  • 对这一条消息执行 SessionRevert.revert 并 cleanup,消息数实际为 1 → 0。
  • 对一般会话,确认框计数包含 assistant 消息,而现有撤销横幅只统计边界及之后的 user 消息,和“matching what the revert banner reports”的注释不一致。

建议统一口径:如果沿用横幅的用户消息/轮次计数,就复用相同的边界判断,并明确“包含所选消息”;如果展示全部消息数,也必须包含所选消息并说明口径。至少补“只有一条用户消息”和“user/assistant 交错、多条 assistant”的渲染测试。

2. [P2] 右键复制后仍弹出消息菜单,事件链尚未处理完整

位置:packages/tui/src/routes/session/index.tsx:1225,关联 app.tsx:1113。

开启 OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT 时(Windows 默认开启),一次右键操作会经历:

  1. 外层 onMouseDown 调用 Selection.copy,复制并清空选区,然后阻止本次 down 事件传播。
  2. 随后的 up 是另一个事件,仍进入消息行的 onMouseUp。
  3. 此时选区已为空,messageMouseAction 返回 menu,于是复制后又弹出 Message Actions。

修复文档认为 down 阶段的 stopPropagation 足以使消息行 copy 分支走不到并“保持一致”,但它不会抑制后续 up 事件。

已在完整应用挂载后的真实组件事件链中复现:选区为 SELECT_THI,剪贴板写入一次相同文本,同时 menuOpened === true。该复现使用真实 renderer 选区与直接派发的 MouseEvent,未覆盖终端鼠标协议/坐标命中。

这是既有路径尚未修完整,不是本 PR 新引入的回归。 但本 PR 声明的“右键有选区只复制、无选区才开菜单”仍不成立。建议统一 down/up 处理,或记录本次按下已用于复制,抑制对应的松开;增加覆盖两个事件的测试,单独测试 messageMouseAction 无法发现这个问题。

测试建议:不要允许未确认就已撤销的情况通过

dialog-message.test.tsx:161–170 当前允许第一次 Enter 后 revertCalls 已非空时直接跳过确认步骤,这与“必须显式二次确认”的目标相反。

建议删除该放宽分支:第一次 Enter 后必须断言确认框可见且请求数为 0,第二次确认后才允许为 1。本轮补充的严格检查通过,未复现一次 Enter 自动确认;这里报告的是现有测试断言缺口,不是已经证实的运行时误确认。

验证范围

  • 当前 head 的 GitHub CI 检查全绿。
  • 本地真实 Bun 1.3.14 环境下,相关 21 个 TUI 测试全部通过。
  • 4 个定向 session/agent-management 测试全部通过:主 run 记录中断错误、cascade:false 保留后台子 agent、默认取消仍级联、agent_stop 停整棵子树。
  • 额外边界检查复现了上述两个 UI 问题;服务端“撤销包含所选消息”检查与严格二次确认检查通过。
  • 未运行完整测试套件或真实模型端到端测试。未发现明显新增性能劣化;本轮没有进行性能基准测量。

yixiao5428 added a commit that referenced this pull request Oct 30, 2026
…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>
@yixiao5428

Copy link
Copy Markdown
Collaborator Author

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 revertedCount to all.length - index so it matches the server deletion set (boundary on the selected user message, cleanup order >= 0 removes it and everything after, assistants included), and the copy now reads "N messages (starting with this one)" so inclusion is explicit. Added the two rendered cases you named: a one-message session asserts "1 message (starting with this one)" and not "0 messages", and a mid-session selection over interleaved messages asserts the includes-self count. Also removed the wrong "matching what the revert banner reports" comment. The banner itself keeps its user-turn count — we read that as a separate pre-existing behavior (turn count, not message count) and have not touched it or assumed it should change; happy to align it in a follow-up if you consider it a bug.

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 (messageMouseAction gains consumedByCopy), and the matching up returns none and clears the flag. Covered by a pure-function case; the full two-event chain through the terminal mouse protocol is still only covered at the decision-function level (same harness limitation as before — injected SGR press/release does not reach opentui's onMouseUp in our test setup), so we'd appreciate a real-Windows sanity check if you have one handy.

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.

yixiao5428 and others added 6 commits November 5, 2026 15:55
…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>
@yixiao5428
yixiao5428 force-pushed the fix/tui-interrupt-exit-and-message-menu branch from 9c77a37 to a8073fa Compare November 5, 2026 08:06
yixiao5428 and others added 3 commits November 5, 2026 18:29
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants