Skip to content

feat(tui): add isolated /btw conversations#748

Merged
anandh8x merged 5 commits into
mainfrom
feat/637-btw-session
Jul 19, 2026
Merged

feat(tui): add isolated /btw conversations#748
anandh8x merged 5 commits into
mainfrom
feat/637-btw-session

Conversation

@anandh8x

@anandh8x anandh8x commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add /btw [question] to fork the current context into an isolated side conversation
  • keep an in-flight main run progressing in the background and route its events back to the main session
  • return with /btw or Ctrl+C without merging side messages into the main session
  • keep side sessions out of /resume and guard commands that would replace the side session
  • document the command and add regression coverage for isolation and lifecycle behavior

Why

This lets users validate assumptions and explore related questions without interrupting or expanding the main conversation context.

Closes #637

Verification

  • make fmt-check
  • go vet ./...
  • go test ./...
  • go test -race ./internal/tui -run TestBTW -count=1
  • go test -race ./internal/sessions -run TestStoreFork -count=1
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • govulncheck ./...
  • git diff HEAD --check

Summary by CodeRabbit

  • New Features
    • Added /btw [question] for an isolated side conversation that returns to the main session when re-run, with clear BTW status in the UI.
  • Bug Fixes
    • --resume / --resumeLatest now skip non-resumable side sessions and reject resuming non-resumable session kinds.
    • Improved BTW controls: hidden routing keeps output separate, updates parent spinner behavior on return, and refines Ctrl+C to avoid losing drafts during in-flight replies.
  • Documentation
    • Updated TUI keybindings/help for BTW (including the updated Ctrl+C meaning) and /btw descriptions.
  • Tests
    • Expanded BTW and resumability test coverage (including Ctrl+C, command blocking, and return/merge behavior).

Add /btw with inline-question support, isolated non-resumable session forks, background main-run routing, and safe return behavior.

Refs #637
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: dc94e60ccd1e
Changed files (15): README.md, internal/cli/exec_sessions.go, internal/cli/exec_sessions_test.go, internal/cli/sessions.go, internal/cli/sessions_test.go, internal/sessions/exec_session.go, internal/sessions/store.go, internal/sessions/store_test.go, internal/tui/btw.go, internal/tui/btw_test.go, internal/tui/commands.go, internal/tui/keybinding_help.go, and 3 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@anandh8x
anandh8x requested review from Vasanthdev2004, gnanam1990 and kevincodex1 and removed request for gnanam1990 July 19, 2026 06:47
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

BTW isolated side conversations

Layer / File(s) Summary
Side session fork contract
internal/sessions/store.go, internal/sessions/store_test.go
Adds validated, tagged, non-resumable side sessions and tests their metadata and filtering.
Resumable session selection
internal/cli/..., internal/sessions/exec_session.go, internal/sessions/store_test.go
Resume flows now select only resumable sessions and reject side sessions.
BTW command and TUI integration
internal/tui/btw.go, internal/tui/model.go, internal/tui/commands.go, internal/tui/session.go
Registers /btw, creates isolated side sessions, routes parent activity, and enforces BTW command and prompt boundaries.
BTW behavior validation and UI
internal/tui/btw_test.go, internal/tui/keybinding_help.go, internal/tui/view.go, README.md
Covers lifecycle, cancellation, routing, failure handling, status indicators, keybindings, and command documentation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant TUI
  participant SessionStore
  participant ParentRun
  User->>TUI: enter /btw [question]
  TUI->>SessionStore: fork active session as side
  SessionStore-->>TUI: return side session metadata
  TUI->>TUI: start isolated side prompt
  ParentRun-->>TUI: emit parent messages
  TUI->>TUI: route parent messages to hidden parent
  User->>TUI: leave BTW
  TUI-->>User: restore parent without merging side events
Loading

Possibly related PRs

  • Gitlawb/zero#83: Both modify TUI message and command routing in internal/tui/model.go.
  • Gitlawb/zero#169: Both extend resumability filtering and latest-resume selection.

Suggested reviewers: kevincodex1, gnanag1990, vasanthdev2004

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Clearly summarizes the new isolated /btw conversation feature.
Linked Issues check ✅ Passed Implements the /btw isolated side conversation, preserves the main session, and blocks merges/resume as requested.
Out of Scope Changes check ✅ Passed All changes stay focused on /btw behavior, docs, and regression coverage.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/637-btw-session

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/tui/btw_test.go (1)

170-181: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing regression test: /btw issued again while active should return to the main session.

Coverage here validates leaving via Ctrl+C (leaveBTW) and blocks /new from replacing the side session, but nothing exercises side.dispatchCommand(parseCommand("/btw")) while m.btw.active — the second documented way to return (per README/help text and the blocked-command notice itself). Given the dispatch guard at internal/tui/model.go Lines 4129-4135 runs for every command kind including commandBTW, a test asserting /btw while active returns to the parent session (mirroring TestBTWCreatesIsolatedForkAndReturnsWithoutMerge's assertions) would directly close this gap.

As per path instructions, "**/*_test.go: Keep tests beside their source files, add regression tests for behavior changes, and run affected concurrent code under the race detector."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/btw_test.go` around lines 170 - 181, Add a regression test
beside TestBTWBlocksCommandsThatWouldReplaceItsSession for dispatching
parseCommand("/btw") while m.btw.active. Assert the command returns to the
parent/main session, mirroring the session and BTW-state assertions in
TestBTWCreatesIsolatedForkAndReturnsWithoutMerge, and run the affected
concurrent test under the race detector.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/tui/model.go`:
- Around line 1033-1040: Update the Ctrl+C handling in the m.btw.active branch
to gate composer and suggestion clearing on !m.pending, matching the non-BTW
branch’s in-flight-run behavior; when m.pending is true, proceed directly to
m.leaveBTW() regardless of composer text.

---

Nitpick comments:
In `@internal/tui/btw_test.go`:
- Around line 170-181: Add a regression test beside
TestBTWBlocksCommandsThatWouldReplaceItsSession for dispatching
parseCommand("/btw") while m.btw.active. Assert the command returns to the
parent/main session, mirroring the session and BTW-state assertions in
TestBTWCreatesIsolatedForkAndReturnsWithoutMerge, and run the affected
concurrent test under the race detector.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 30575e1d-744a-465d-9c53-7855e43ce256

📥 Commits

Reviewing files that changed from the base of the PR and between ce4a996 and a223511.

📒 Files selected for processing (8)
  • README.md
  • internal/sessions/store.go
  • internal/sessions/store_test.go
  • internal/tui/btw.go
  • internal/tui/btw_test.go
  • internal/tui/commands.go
  • internal/tui/model.go
  • internal/tui/session.go

Comment thread internal/tui/model.go
Preserve drafts when Ctrl+C is pressed during an active BTW run and cover returning to the parent through the documented /btw toggle.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
internal/tui/model.go (1)

1033-1046: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate duplicated composer-clear conditions.

The complex condition for clearing the composer is now identical in both the BTW and non-BTW branches. Consolidating them reduces duplication and prevents the conditions from diverging in future updates.

♻️ Proposed refactor
-	if m.btw.active {
-		if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
-			m.clearComposer()
-			m.clearSuggestions()
-			return m, nil
-		}
-		return m.leaveBTW()
-	}
 	if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
 		m.clearComposer()
 		m.clearSuggestions()
-		m = m.disarmExitConfirmation()
+		if !m.btw.active {
+			m = m.disarmExitConfirmation()
+		}
 		return m, nil
 	}
+
+	if m.btw.active {
+		return m.leaveBTW()
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/model.go` around lines 1033 - 1046, Consolidate the duplicated
composer-clearing condition in the surrounding BTW handling logic: evaluate the
shared !m.pending, composerValue, noBlockingModal, transcriptDetailed, and
subchat checks once, then retain the branch-specific behavior of calling
leaveBTW for active BTW and disarmExitConfirmation for non-BTW before returning.
Keep clearComposer and clearSuggestions executed only when the shared condition
passes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/tui/model.go`:
- Around line 1033-1046: Consolidate the duplicated composer-clearing condition
in the surrounding BTW handling logic: evaluate the shared !m.pending,
composerValue, noBlockingModal, transcriptDetailed, and subchat checks once,
then retain the branch-specific behavior of calling leaveBTW for active BTW and
disarmExitConfirmation for non-BTW before returning. Keep clearComposer and
clearSuggestions executed only when the shared condition passes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 53f57c0a-29c0-456c-8bb2-be185d35176a

📥 Commits

Reviewing files that changed from the base of the PR and between a223511 and 5a0fefd.

📒 Files selected for processing (2)
  • internal/tui/btw_test.go
  • internal/tui/model.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026
gnanam1990
gnanam1990 previously approved these changes Jul 19, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE — two minors worth addressing before merge. The isolation (the whole point) is airtight: Store.Fork writes only to the fork, leaveBTW does zero store writes, side prompts persist only to the fork, side→parent message routing can't leak (side runIDs start at parent.runID+btwRunIDGap and only increase; parent routing requires 0 < runID < sideRunIDBase), shallow-copy aliasing is reset to fresh maps/slices, and the edge cases are guarded (nesting blocked, no-session rejected, compaction refused, return-while-pending blocked, side excluded from the resume picker). All 8 new BTW tests + the store fork test pass.

Findings:

  • [Minor] /exit inside a /btw conversation bypasses the parent run's cancel+flush protection — the BTW guard at model.go:4129 only blocks session-changing kinds, so commandExit reaches the exit path and quits without draining the hidden parent's in-flight run. Add a BTW-aware guard on commandExit (cancel+drain the parent, or block /exit with a hint) when m.btw.active and the parent is pending.
  • [Minor] The working spinner / elapsed timer freezes after returning from /btw while the main run is still in-flight — spinner.TickMsg carries no runID so routeBTWParentMessage doesn't re-arm it. In leaveBTW, when the restored parent is pending/compacting, batch a spinner tick so the run doesn't look hung.
  • [Nit] Opening /btw permanently stops the main session's active /loop (clearLoopsForSessionSwitch runs before the parent is captured) — snapshot + re-arm on leave, or document it.
  • [Nit] btwRunIDGap is a magic 1,000,000 boundary with no assertion — add a comment/guard that sideRunIDBase must stay above any achievable parent runID.

Merge is kevin's call per the program gate.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Really like this. The hard part is the concurrency, and it holds up: capturing the parent model, keeping its in-flight run progressing in the background, and routing its messages back by the runID boundary (side IDs starting a million above the parent's counter). I went through the routing carefully looking for a parent message that btwMessageRunID doesn't cover leaking onto the side surface, and for a side message mis-routed back to the parent, and couldn't find one. The fork/store changes are clean, IsResumableKind correctly hides side sessions from the TUI resume picker, and the "nothing merged into the main session" guarantee genuinely holds for the transcript and the session events.

Two isolation gaps to close before merge, though, and one disk note.

1. Config commands leak out of the isolated conversation (the main one). btwCommandChangesSession only blocks the session-replacing commands (/new /resume /retitle /spec /loop). Everything else runs against the side model and is meant to be discarded on return, but a few of those commands persist to disk and process env, which leaveBTW never unwinds (it restores the in-memory parent snapshot only). Concretely: open a side conversation, run /model some-other-model. That calls SetActiveProviderEnv (os.Setenv ZERO_ACTIVE_PROVIDER), persistSelectedModel (writes the config file), and recordRecentModels. Return with /btw, and the restored parent shows the OLD model in memory while the env var and the on-disk config are the NEW one. So the parent's still-running background turns keep using the old provider client, but any sub-agent or swarm member it spawns inherits ZERO_ACTIVE_PROVIDER = the new one (wrong-provider billing, possible auth failure), and on the next launch or /resume the model chosen in a throwaway side conversation silently wins. /turns and /profile do the same via ZERO_MAX_TURNS, and /theme / /config recaps write their preference to disk too. That directly contradicts the return notice, "Its messages were not added to this session." Simplest fix is to extend btwCommandChangesSession to also block the config-mutating commands (/model, /provider, /turns, /profile, /theme, /config), same as /new. /effort, /style, /selfcorrect are in-memory only and revert cleanly, so those are fine to leave.

2. --resume-latest can resume a discarded side session. The TUI picker filters by IsResumableKind, but store.Latest() doesn't, and the CLI zero exec --resume-latest path uses Latest() directly (exec_session.go, and the preflight twin in cli/exec_sessions.go). So: in the TUI run /btw explain X with an inline question, which runs the side fork and bumps its UpdatedAt to newest, then return. Later from a shell, zero exec --resume-latest "keep going" resolves Latest() to that discarded side session and appends the new turn into the abandoned isolated branch instead of the real main session. It is partly a pre-existing gap (child/spec kinds are unfiltered by Latest() too), but /btw is the first thing that makes a user-created non-resumable session become "latest," so it is now reachable. Routing the resume-latest path through IsResumableKind (or a LatestResumable) closes it.

3. (minor, fine as a follow-up) Each /btw permanently duplicates the session on disk. Fork copies every parent event plus a full physical copy of every checkpoint blob, and side sessions are never deleted (no GC in the sessions package), so a long session with repeated /btw grows disk without bound. Not a blocker, but worth either cleaning up the side session on return or skipping the blob copy for side kinds down the line.

The /loop stop on entry is fine by the way, I checked it, it is the documented session-switch behavior and the "Stopped N loop(s)" line lands in the restored parent transcript, so it is not silent.

Requesting changes for 1 (and ideally 2). The design is solid, these are gaps in the isolation boundary rather than anything structural.

@anandh8x
anandh8x dismissed stale reviews from gnanam1990 and coderabbitai[bot] via 34eb2d4 July 19, 2026 13:11

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cli/exec_sessions_test.go`:
- Around line 10-20: The existing TestPreflightResumeLatestSkipsSideSessions
covers only resumeLatest; add regression coverage for explicit resume by passing
the side-session ID through execOptions resume handling and asserting
preflightExecSession rejects it. Add corresponding coverage for PrepareExec so
both CLI preflight and execution preparation enforce that side sessions are not
resumable.

In `@internal/sessions/exec_session.go`:
- Line 92: Apply the non-resumable-session validation to the explicit Resume
branch in internal/sessions/exec_session.go at lines 92-92, and mirror the same
check in the CLI preflight in internal/cli/exec_sessions.go at lines 76-76. Add
a regression test for explicitly resuming a side session in
internal/cli/exec_sessions_test.go at lines 10-20, verifying it is rejected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 75b49eb0-5c9c-4411-902c-bde260842fe4

📥 Commits

Reviewing files that changed from the base of the PR and between 5a0fefd and 34eb2d4.

📒 Files selected for processing (7)
  • internal/cli/exec_sessions.go
  • internal/cli/exec_sessions_test.go
  • internal/sessions/exec_session.go
  • internal/sessions/store_test.go
  • internal/tui/btw.go
  • internal/tui/btw_test.go
  • internal/tui/model.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/sessions/store_test.go
  • internal/tui/btw.go
  • internal/tui/model.go

Comment thread internal/cli/exec_sessions_test.go Outdated
Comment thread internal/sessions/exec_session.go
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026
@anandh8x
anandh8x requested a review from Vasanthdev2004 July 19, 2026 13:45
Vasanthdev2004
Vasanthdev2004 previously approved these changes Jul 19, 2026

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed on the new head. Both isolation gaps are closed, and cleanly.

The config-command leak is fixed the way I hoped: /model, /provider, /turns, /profile, /theme, /config are now blocked inside a side conversation, so they can't persist to disk or env behind the reverted parent. And the resume side went further than I asked, both PrepareExec and the CLI preflight now use LatestResumable(), and an explicit --resume <side-id> is rejected with "not resumable" rather than silently resuming the discarded branch. Nice. The two extra fixes you folded in are good catches too: restarting the parent's spinner tick chain on return so it doesn't freeze when the parent is still running, and blocking /exit while the main session is live.

One small thing in the same family, and it's a one-liner: /stt-model (commandSTTModel) also persists to the user config (SetSTTProvider/SetSTTModel via the STT picker, plus SetSTTLocalEngine on the dictation-download path), so it has the identical disk-vs-in-memory divergence that /theme did. It just needs commandSTTModel added to btwCommandChangesSession alongside the others. Not worth another round on its own, so I'm approving; please fold it in before this lands (or as a quick follow-up) so the block list covers every disk-persisting config command.

The per-/btw disk duplication (full history + checkpoint blob copy with no GC) is still there, but as I said before that one is fine as a separate follow-up, not part of this.

Good work turning these around. Approving.

@anandh8x
anandh8x requested a review from gnanam1990 July 19, 2026 14:13

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

REQUEST CHANGES — two blockers, both in the isolation boundary rather than the design.

Re-reviewed at a439a1e. The follow-up commits genuinely closed what I raised last round: /exit now drains the hidden parent (model.go:4129, and the len(flushRunIDs) > 0 disjunct is the right call — checking only pending would have missed the post-cancel state), Ctrl+C now carries the !m.pending guard term-for-term (model.go:1034), and the resume rule landed on all four paths with tests. IsResumableKind + ListResumable/LatestResumable is the right shape. The core routing still holds up: side IDs above sideRunIDBase, parent routing requiring 0 < runID < sideRunIDBase, shallow-copy aliasing reset to fresh maps. Nice work.

Two things block, though.


1. [Blocker] Side run IDs collide across /btw re-entry — a cancelled run hijacks the next one (internal/tui/btw.go:95)

side.runID = parent.runID + btwRunIDGap is recomputed from the restored parent on every entry, and leaveBTW (btw.go:148) restores the snapshot — it never writes side.runID back. So every side conversation in a session where the main run is idle gets the identical run-ID sequence. A run cancelled in side conversation #1 is still unwinding in its goroutine; when its agentResponseMsg lands during side conversation #2, model.go:2122 sees msg.runID == m.activeRunID and takes the run-finished branch: clears pending, calls runCancel() on the second run's context, and renders the first question's answer under the second question.

Reproduced against the branch:

side run #1 activeRunID = 1000001 (sideRunIDBase=1000000)
after cancel: pending=false activeRunID=0 flushRunIDs=map[1000001:...]
restored parent runID = 0
side run #2 activeRunID = 1000001 (sideRunIDBase=1000000)
*** COLLISION CONFIRMED ***
*** HIJACK CONFIRMED: stale run #1 response terminated in-flight run #2 ***
--- side #2 transcript ---
  [1] second side question
  [2] answer to the FIRST question

The Esc-Esc cancel this depends on is the exact gesture btw.go:142 instructs users to use, so it is not an exotic path. Fix: carry the side counter forward instead of deriving it from the parent each time — keep a monotonic btwRunIDSeq that leaveBTW writes back from side.runID, or make sideRunIDBase strictly greater than any previously issued side ID.

2. [Blocker] /rewind inside a BTW conversation reverts the main session's workspace files (internal/tui/btw.go:172)

commandRewind is not in btwCommandChangesSession, and model.go:4137 is the only gate, so /rewind dispatches normally to model.go:4352. Store.Fork deliberately copies the parent's checkpoint events and physically copies its blobs — with the comment "so a rewind on the fork can restore file content" (store.go:461-467) — so the side session resolves the main session's checkpoints against real blobs. handleRewindCommand then calls ApplyRewind(m.activeSession.SessionID, m.cwd, target) (session_controls.go:643), and side.cwd == parent.cwd. The main run's file edits are reverted on disk, then leaveBTW prints "Its messages were not added to this session."

Worse, handleRewindCommand's own safety guards are scoped to the wrong model: m.pending (:629) and len(m.flushRunIDs) (:635) read the side model's fields, which handleBTWCommand zeroed at btw.go:92,96. So the rewind is permitted while the hidden main run is still in flight writing the very files being restored — the guards that exist specifically to prevent that are blind here.

Add commandRewind (and commandCompact) to btwCommandChangesSession. If /rewind must stay available, it has to consult m.btw.parent.pending / m.btw.parent.flushRunIDs.


Majors

  • /resume <side-id> in the TUI is unguarded (internal/tui/session.go:276). resolveResumeSession resolves an explicit id via a bare sessionStore.Get with no IsResumableKind check. The latest branch and both pickers filter; the CLI is guarded at exec_sessions.go:75 and exec_session.go:108. The TUI — where /btw actually lives — was missed. The id is discoverable (zero sessions list uses unfiltered List()). Resuming it puts the user in a session with no BTW banner and no return affordance, while sessionPrompt keys the isolation preamble off SessionKind (session.go:254) not m.btw.active — so every prompt is silently prefixed with "Do not modify workspace state…" and the agent hedges on ordinary edits for no visible reason. One line mirrors exec_sessions.go:75.
  • leaveBTW ignores the side model's flushRunIDs (btw.go:141). It gates on pending and compactInFlight only. An Esc-Esc-cancelled side run leaves pending == false with flushRunIDs non-empty; the side model is then discarded, so the late agentResponseMsg finds no entry at model.go:2131 and the run's checkpoint blobs are never referenced by any event. This PR already treats exactly that as a busy signal for the parent at model.go:4130 — the omission is inconsistent with its own contract.
  • /btw bypasses the m.exiting guard (btw.go:134). model.go:4100 is scoped to command.kind == commandPrompt, but /btw <question> is commandBTW and reaches side.launchPrompt, so it forks a session and starts a paid run during the deferred-quit window after Ctrl+C-Ctrl+C. side.exiting is also never reset among the ~30 fields handleBTWCommand clears, so plain follow-up prompts in the side surface are then silently swallowed by that same model.go:4100 guard, with no transcript message.
  • No persistent BTW indicator. Entry is announced by one scrollable transcript row; footerView and statusLine carry no BTW chip, and the (BTW) title never reaches the chrome. After a few exchanges nothing on screen distinguishes the isolated surface from the main session — and work typed there is discarded on return. The status line already treats hidden state this way for loops ("so a running loop is always visible", view.go:236-242); this needs the same.
  • Terminal resizes during /btw are lost. tea.WindowSizeMsg carries no runID so btwMessageRunID doesn't route it; the parent snapshot keeps its entry-time geometry and leaveBTW restores it verbatim. Bubble Tea won't re-emit, so the main session stays wrapped at the old width until the user resizes again.

Minors / nits

  • The spinner fix is half-done (btw.go:160). The !parent.reducedMotion conjunct is wrong — no other arming site gates on it (model.go:4625, session_controls.go:594), and the stock TickMsg handler deliberately keeps the chain alive under reduced motion. Under ZERO_REDUCED_MOTION or no-TTY the elapsed timer still freezes on return, and there the clock is the only liveness cue by design (model.go:3280-3286). Deleting the conjunct fixes it.
  • Config blocklist has two gaps and one over-reach. /sttmodel (stt_model_picker.go:220,244) and /mcp (command_views.go:110) still write user config from inside a side conversation and leave the restored parent stale relative to disk. Conversely, gating by commandKind now also blocks the read-only forms — bare /turns, /config, /provider, /profile status all return "unavailable in a BTW conversation", and asking "what model am I on?" is a reasonable thing to want in a side chat.
  • Side spend is billed to the main session. usageTracker is a *usage.Tracker; parent := m and side := m copy the same pointer and leaveBTW never unwinds it, so an expensive side turn shows up in the main session's cost readout and /context. That this is per-conversation state is established by startNewSession calling Reset(). Give the side model its own tracker and drop it on return.
  • zero sessions list --kind side is rejected (internal/cli/sessions.go:219) even though the same command prints [side] rows. Add the kind to parseSessionKindFlag and the --kind help text.
  • A failed /btw still kills the user's loops (btw.go:52). The clear happens before the fork is attempted, so both error returns hand back a model whose loops are gone — with "Stopped 1 loop(s) before opening the BTW conversation." directly above "Could not open BTW conversation: …". Move the clear onto the side copy after the fork succeeds. (The loop-stop-on-entry behavior itself I'm fine with, as discussed — but the notice is written to the parent transcript and only becomes visible after return, which is the wrong moment to see it.)
  • ? overlay still says Ctrl+C means "cancel the run, then quit" (keybinding_help.go:41), which is false in BTW. README was updated; the in-app overlay wasn't, and nothing gates the overlay on m.btw.active.
  • btwRunIDGap is still a bare 1_000_000 with no stated invariant. Worth a comment — and note the real invariant is relative ("the parent must not advance by ≥ gap during one side conversation"), not absolute.
  • Disk growth (store.go:447-457, checkpoint.go:218-228): every /btw copies the full event log plus a byte copy of every blob, with no GC anywhere. Fine as a follow-up, but the resume-hiding makes these orphans unreachable through every product surface while still consuming disk. Blobs are content-addressed and immutable, so os.Link in copyBlobs would make the blob half free.

Things I looked at and cleared

zero exec --fork <side-id> producing a resumable fork is pre-existing --fork semantics applied uniformly to child/spec-draft/spec-impl already — not something this PR broke. Same for /add-dir, "always allow" permission grants, and /stop reaching process-scoped state: all real mechanisms, all pre-existing and out of scope for the promise this PR makes ("nothing from this side conversation will be merged into the main session"), which does hold for transcript and session events. Worth separate tickets at most.

Verification

gofmt clean, go vet clean, go test -race ./internal/tui -run TestBTW -count=2 and -race ./internal/sessions -run TestStoreFork -count=2 both pass. The internal/cli failures I hit locally (TestRunAddDir…, TestRunDoctor…, TestRunSandboxCheck…) are sandbox/network artifacts of my environment, unrelated to this diff — CI is green on all 9 checks.

To reproduce blocker 1, drop this in internal/tui/ and run go test ./internal/tui/ -run TestBTWRunIDReuse -v -count=1:

func TestBTWRunIDReuse(t *testing.T) {
	m := newBTWTestModel(t)
	m.provider = &fakeProvider{}
	m.runID = 0
	side1, _ := m.handleBTWCommand("first side question")
	first := side1.activeRunID
	side1.cancelRun()
	back, _ := side1.leaveBTW()
	side2, _ := back.handleBTWCommand("second side question")
	if first != side2.activeRunID {
		t.Fatalf("no collision: %d vs %d", first, side2.activeRunID)
	}
	updated, _ := side2.updateModel(agentResponseMsg{
		runID: first,
		rows:  []transcriptRow{{kind: rowAssistant, text: "answer to the FIRST question"}},
	})
	if transcriptContains(updated.(model).transcript, "answer to the FIRST question") {
		t.Fatalf("stale run #1 answer rendered under side conversation #2")
	}
}

Merge is kevin's call per the program gate.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
internal/tui/btw.go (1)

75-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Long manual field-reset block is a drift risk.

Building side (and preserving parent) relies on ~35 explicit field resets. Any future model field added for run/composer/usage state that isn't added here will silently leak between the hidden parent and the isolated side session (or fail to restore on leaveBTW), and no test will catch the omission until it manifests as a real isolation break. Consider centralizing this into a model.resetForkedRunState()-style helper (or a small struct listing "session-scoped" fields) so new fields have one obvious place to be accounted for.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/btw.go` around lines 75 - 155, The BTW setup in the function
containing the side-session initialization should replace the long manual reset
block with a centralized helper such as model.resetForkedRunState(), covering
all run, composer, streaming, usage, permission, and loop-scoped state while
preserving session-specific fields and the existing BTW initialization. Update
leaveBTW restoration to use the same centralized state definition so future
fields cannot leak between parent and side sessions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/tui/btw.go`:
- Around line 75-155: The BTW setup in the function containing the side-session
initialization should replace the long manual reset block with a centralized
helper such as model.resetForkedRunState(), covering all run, composer,
streaming, usage, permission, and loop-scoped state while preserving
session-specific fields and the existing BTW initialization. Update leaveBTW
restoration to use the same centralized state definition so future fields cannot
leak between parent and side sessions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1de94e7f-b47f-4349-8662-37a6655a55b4

📥 Commits

Reviewing files that changed from the base of the PR and between a439a1e and dc94e60.

📒 Files selected for processing (8)
  • internal/cli/sessions.go
  • internal/cli/sessions_test.go
  • internal/tui/btw.go
  • internal/tui/btw_test.go
  • internal/tui/keybinding_help.go
  • internal/tui/model.go
  • internal/tui/session.go
  • internal/tui/view.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/tui/model.go

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE. Re-reviewed at dc94e60. Both blockers are genuinely fixed — I re-ran my repros against the new head rather than reading the diff — and 14 of the 15 items from the last round are closed. The one remaining is the disk-growth follow-up we'd all already agreed was out of scope.

Blockers — verified fixed, not just patched

Run-ID collision. The fix is the right shape: a monotonic btwRunIDSeq on the model, side.runID = maxInt(parent.runID+btwRunIDGap, parent.btwRunIDSeq) on entry and parent.btwRunIDSeq = maxInt(parent.btwRunIDSeq, m.runID) on return, so the counter survives the parent snapshot instead of being re-derived from it. Re-ran the exact repro from my last review:

side run #1 activeRunID = 1000001 (base=1000000, seq=0)
after cancel: pending=false flushRunIDs=map[1000001:...]
leave while flushing -> still in btw? true
after late response: flushRunIDs=map[]
restored parent: runID=0 btwRunIDSeq=1000001
side run #2 activeRunID = 1000002 (base=1000001)
OK: no collision
OK: stale run #1 response ignored (pending still true)

Previously this produced second side question / answer to the FIRST question. Now the stale response is correctly ignored and the in-flight run survives.

/rewind in a side conversation. commandRewind and commandCompact are both in the blocklist now and covered by TestBTWBlocksPersistentConfigurationCommands. That closes the path where a side-conversation rewind reverted the main session's workspace files.

The rest

All confirmed present at dc94e60:

  • resolveResumeSession now gates on IsResumableKind (session.go:283) — the TUI finally matches the two CLI paths.
  • leaveBTW refuses while len(m.flushRunIDs) > 0, so a cancelled side run's checkpoint events aren't dropped.
  • handleBTWCommand refuses when m.exiting, and resets side.exiting = false — you caught the second half, which was the part that silently swallowed follow-up prompts.
  • Persistent amber BTW · chip in statusLine, threaded through all the takeover branches (tiny tier, exit-confirm, cancel-confirm, dictation) rather than just the default one.
  • resizeBTWParent mirrors the geometry half of the real WindowSizeMsg handler field-for-field. Skipping the headerPrinted/flushQueue scrollback write is correct for a hidden model, and leaveBTW already re-arms the spinner, so nothing is missed by not calling ensureSpinnerTick.
  • !parent.reducedMotion dropped from the spinner re-arm, so the elapsed timer no longer freezes under ZERO_REDUCED_MOTION / no-TTY.
  • commandSTTModel and commandMCP added, plus independent usage.NewTracker for the side model with lastUsage, unpricedRequests/Tokens, compactRequests, and the turn-latency/TTFT counters all reset alongside it — more thorough than what I flagged.
  • --kind side accepted, error text and --kind help updated.
  • Loop clearing moved after the fork and resumeEvents succeed, so a failed /btw no longer destroys the user's loops — and the notice now also lands in the side transcript, which fixes the "you only find out after you return" half I mentioned.
  • ? overlay Ctrl+C text is conditional on m.btw.active.
  • btwRunIDGap now documents its invariant, and correctly states the relative one.

Two things I liked more than what I proposed:

  • btwCommandUnavailable taking the whole parsedCommand and allowing the read-only forms (/model list, /provider status, bare /turns, /profile status, /theme list, bare /config) is a better answer than my "key off mutating args" hand-wave, and it fixes the over-reach I introduced in the previous round's suggestion.
  • Deferring the loop clear until after the fork succeeds fixes the error-path leak and the wrong-surface notice in one move.

Checked and clean

  • /exit while the side run is still flushing sets exiting = true and returns no command — it arms the deferred quit rather than quitting and dropping the flush. So the new flushRunIDs guard doesn't create a trap: /btw and Ctrl+C hold you in the side surface, /exit defers, and nothing loses the pending checkpoint events.
  • usage.NewTracker(usage.TrackerOptions{Registry: &side.modelCatalog, ...}) takes a pointer into a local copy, which I wanted to be sure of — but newModel does exactly the same thing at model.go:756, and modelregistry.Registry is only ever read via Resolve. Harmless and consistent.

One forward-looking note (not blocking)

btwCommandUnavailable now encodes per-command argument allowlists inline (arg != "list", arg != "status", …). That's the right behavior today, but it will rot silently as subcommands are added — a future /theme preview or /config get x gets blocked with no signal that the allowlist is the reason. A short comment naming the rule ("only argument forms that render text and touch no disk/env state are allowed") or a table-driven test enumerating allowed/blocked forms per command would keep it honest.

Still open, by agreement

Unbounded disk growth per /btw (store.go:447-457 full event copy, checkpoint.go:218-228 byte-copied blobs, no GC). Both Vasanth and I flagged this as acceptable as a follow-up. Worth a tracking issue before this merges so it doesn't get lost — os.Link in copyBlobs would make the blob half free, since blobs are content-addressed and immutable.

Verification

gofmt clean, go vet clean. go test -race ./internal/tui -run TestBTW -count=2 and -race ./internal/sessions -run TestStoreFork -count=2 both pass, as do the new internal/cli session-kind tests. Eight new tests landed and map 1:1 onto the findings, including TestBTWCancelledRunFlushesBeforeReturnAndRunIDsStayUnique, TestBTWResizeUpdatesHiddenParent, TestBTWUsesIndependentUsageTracker, and TestBTWFailedForkKeepsMainSessionLoops.

Nice turnaround on this — the run-ID fix in particular is the correct structural answer rather than a patch over the symptom. Merge is kevin's call per the program gate.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed the hardening pass at dc94e60. My earlier approval was auto-dismissed by the new commit, so re-confirming.

My one open note from last round is handled, and better than I asked: /stt-model is blocked now, but instead of just adding it flat you moved to btwCommandUnavailable(command) and kept the read-only forms usable (/model list, /provider status, bare /turns, /profile status, /theme list, bare /config). Blocking only the disk/env-mutating variants while leaving inspection available is the right call.

I traced the two structural fixes that landed in this pass rather than just reading the summary:

  • btwRunIDSeq is the correct answer to BTW re-entry, not a patch. Because it lives on the model and survives the parent snapshot (side.runID = maxInt(parent.runID+gap, parent.btwRunIDSeq) on entry, parent.btwRunIDSeq = maxInt(..., m.runID) on return), a straggler from a discarded side run always has a runID below the next side's base, so it routes to the hidden parent rather than being mistaken for a live side message. That closes the collision cleanly.
  • Deferring the loop clear until after the fork and resumeEvents succeed means a failed /btw can no longer destroy the main session's loops, and folding the notice into the side transcript fixes the wrong-surface half in the same move.
  • The !reducedMotion drop on the spinner re-arm is correct, not a regression: the tick handler already freezes the phase increment under reduced motion, so the glyph stays static while the elapsed timer keeps advancing, which is what you want.

The independent usage.NewTracker for the side model plus resetting the latency/TTFT/compact counters is more isolation than strictly required and a good instinct.

Non-blocking, seconding gnanam: the inline per-command argument allowlists in btwCommandUnavailable will rot quietly as subcommands are added (a future /theme preview gets blocked with no signal why). A table-driven test enumerating allowed vs blocked forms per command would keep it honest. And the per-/btw disk duplication is still the agreed follow-up, worth a tracking issue before this lands, os.Link in copyBlobs would make the blob copy free since those blobs are content-addressed and immutable.

CI green, gnanam re-verified against the same head. Approving.

@anandh8x
anandh8x merged commit 2e267bd into main Jul 19, 2026
9 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.

/btw (by the way) analogue

3 participants