Skip to content

Switch chats between Chat UI and a terminal without forking - #31595

Open
brennanb2025 wants to merge 157 commits into
mainfrom
brennanb2025/flip-codex-switch
Open

brennanb2025 wants to merge 157 commits into
mainfrom
brennanb2025/flip-codex-switch

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 174 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​17370 $\color{#cf222e}{\Huge{\mathbf{−}}}$​2180 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​15190
Prod 518 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​19502 $\color{#cf222e}{\Huge{\mathbf{−}}}$​5272 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​14230

What changes

Switch between Chat UI and a terminal while continuing the same agent conversation. The destination replaces the source in the same tab position. Closing one source pane preserves its split siblings. Session History can open the existing conversation and select its workspace with one click.

The switch appears in the pane header, tab hover card, tab menu and Session History only while the Chat UI setting is on. Claude, Codex, Grok, OpenCode, OpenCode 2 and Pi are enabled when their execution host supports the switch. omp remains disabled. The display toggle reads Show as terminal / Show as chat.

When the agent is replying, Orca asks Stop and switch. Cancel keeps the reply and current surface. Confirm uses ordinary Stop, proves the source agent exited, imports terminal conversation changes where needed, acquires the destination through ordinary admission, then publishes and selects it. A suspended agent also requires confirmed physical exit. User-entered commands and configured agent commands remain unchanged.

Returning to chat makes the saved transcript readable immediately after import and publication. Provider startup is observed separately; the user does not wait for it to finish to read the conversation. The source presentation retires through its ordinary close owner. The next send uses ordinary agent startup and writer admission. The switch does not replay the interrupted prompt automatically.

Failures name the agent and give an action without provider or transport details. Failed destination creation leaves a readable recovery path and no empty chat tab. The client wait covers the host deadline and refreshes actual state if it cannot confirm the result. Invocation identity and the existing resource owners prevent a stale effect from claiming a newer destination or stopping unrelated work.

Compatibility and differences

Intended: local and paired execution use the same host-owned operation, ordinary Stop, process-exit evidence and destination owners. Optional metadata on existing messages carries captured placement to the actual layout writer; it is not saved in session layouts. Independently updated peers keep their existing compatible path. A peer without confirmed-exit support cannot be treated as proof that the source stopped; its fallback and actionable failure remain visible. There is no minimum server version.

Intended: one-pane switching follows ordinary pane-close semantics; split siblings remain intact. A destination already used or moved keeps its current placement. Navigation made after the click takes precedence over delayed selection. The common pattern supplies ordinary lifecycle and presentation owners; the explicit transfer between a native conversation and a user shell requires the additional captured source identity and host authority.

Temporary — pin state: a switched tab can return unpinned. Tracked in STA-12006.

Verification and current state

The correction is still in review and is not ready. The latest placement candidate has independent source agreement. Focused tests exercise actual snapshot, frame, insertion and selection writers, same-slot restoration, coalesced client tabs, split siblings, cross-workspace History, newer navigation, optional-message compatibility and exclusion of transient metadata from saved layouts. The current correction also requires production-trigger N/10N operation-count contracts.

Fresh CI and the remaining targeted Mac and Windows Claude/Codex lifecycle runs are pending on the corrected published head. Historical failures and qualified evidence remain in the single review status comment; they are not a current-head pass. Final screenshots, exact-head CI, runtime results, corpus timings and reference comparison will be recorded there after completion.

The ownership prerequisite tracked in #30930 is merged; this PR targets main. Tracked in the chat-terminal-flip lane.

Brennan requested an ad hoc build from the finished branch to try it. Do not merge this PR. The separate proposal to keep the reply going across the switch is on hold.

Author: @BrennanKB5

…uses to start while a terminal writes its conversation

Each chat link now carries its role (head or earlier) and key; Session History names
the chat continuing a conversation first, duplicate adoption checks only head links in
both the pre-commit check and the store transaction, and terminal resumes stay refused
exactly as before. The terminal-writer check now also runs inside acquireOwner before
the spawn, refusing with the typed pre-spawn reason conversationOpenInTerminal.
…l reports it

An in-memory registry of managed terminal starts per conversation, registered in the
same step as the resume admission (terminal.send with a resume command, createTerminal
with a command), bound to the PTY execution that runs it, and ended only when that
terminal's hooks report the conversation or the execution is gone. A chat's agent
start refuses while an entry may hold its conversation.
… an open chat continues

Resume recipes built from a pane's saved facts (cold restore, deferred and late reattach,
sleeping wake, host mobile materialization) carry resumeOrigin 'saved'. The execution host
decides before any command is issued: while an open chat continues the conversation, or a
chat may be writing it, the pane starts its plain shell with the started-fresh notice and
its saved claim is forgotten; with unreadable records only a saved restore is refused and
its record kept. An admitted resume registers its managed start in the same step, so the
chat's agent start refuses until the terminal reports it or exits. Clearing the last saved
record now reaches disk.
…s terminal in place

One enablement source (Codex) drives same-id terminal resume, the per-agent
switch capability and History. agentSession.switchToTerminal admits only an
idle chat, registers the switch claim in the chat's ordered step, stops the
agent with proven exit while the tab stays, and returns the resume recipe.
The claim refuses a chat start until the switch terminal's own spawn binds it;
the bound proof ends only when the agent that terminal ran is proven exited
and the pane is shell alone.
…nal in place

Switch to CLI (tab menu, hover card, chat top-right icon, History) asks the
chat's host to stop the idle chat and opens the same-id resume in the chat's
slot, then closes the chat tab. Switch to native chat opens the chat in the
terminal's slot, or reveals the chat that already holds the conversation, and
closes the terminal pane. Hosts without the agent's switch capability keep the
new-tab entries, worded Resume in New Chat / Resume in New Terminal; the view
toggle reads Show as chat / Show as terminal.
Covers the host op stopping an idle chat while a racing send is refused, the
switch claim lifetime (unaccepted lapse, accepted spawn past the window, bind,
identity-only replay, second switch refused), the switch spawn claiming before
its first await, and the renderer refusing hosts that do not advertise the
switch. Updates label and owner-reveal expectations for the new wording.
…ches back to it

Switching a terminal's conversation into the chat that already holds it now
starts that chat's agent on its host (agentSession.switchToChat), so the
provider open catches up on the terminal's turns before the user sends. The
host uses the ordinary operation start and answers success only when that
same child is ready; an ended child's typed failure, such as the conversation
still open elsewhere, is the answer. The renderer sends it only to hosts that
advertise the switch, retires the terminal pane only after the start succeeded
and the chat's tab is really active, and keeps the terminal on any refusal.
…k it

A switch claim is now owned by the terminal spawn attempt that accepted it
(its host session id, else its pane). Only one attempt may hold a
conversation: a different attempt is refused while one is accepted or a
switched terminal may still hold it, and a retry of the same attempt joins
it. Settlement follows the spawn's real result, including a reattach; a
spawn that ended without a reply keeps its claim until the host's own
session list proves that session absent, so a lost reply can no longer let
a chat start beside the terminal.

Also from review: a paired Orca server that switches the agent in place now
offers the switch back from its terminals, and the paired create names its
new tab so the switch back is offered before the first prompt; the
presentation seed binds to the switched pane's leaf and PTY and ends when
that PTY is replaced or the pane goes, so a split's plain shell is never
switched or closed; and an unreadable Windows process identity reads as
unverifiable, with only a fresh process table's absence or a different
start time proving exit.
…roves the start over

The chat to terminal switch now carries one generation and the owner that
creates the terminal. The daemon session and the local process are bound to
that generation before any later step can fail, and only their own end, or a
proved agent exit with that exact shell alone, releases the conversation.
The accepted start is saved on the chat it left, so a restart still knows
it may exist; a lost owner never speaks for it.

The caller-side settlement and the renderer's guessed tab seed are gone.
A switched terminal's conversation is presented from the host's launch
identity, stamped with the exact PTY and incarnation, and the switch back
retires only the pane that still holds that execution.
… just its shell, are proved over

- Every release needs each recorded agent proved exited; the owner's root exit alone no longer clears it, and the daemon reports startup settled only once its write gate flushes.
- Descriptor writes are compare-and-set on generation, so a retired switch's late evidence never replaces or resurrects one.
- After full owner loss, a host reboot since acceptance ends a start with no recorded identity; evidence a save missed is retried.
- An owner that cannot answer fails the routed read and holds; only every reachable owner answering without it counts as loss.
- The local owner binds the process before any callback or cancellation cleanup can throw.
- Snapshots carry the launch stamp, the paired projection drops a stamp naming a replaced incarnation, and the switch back retires a pane only while its host still reports the incarnation it started with.
…reattached switch terminal

The saved switch keeps the host boot as its id where the OS has one plus the boot time derived
from uptime, so Windows can also prove a later boot (by more than 60 seconds). A reattached
switch terminal regains its exact-execution stamp from the saved switch, without persisting it.
…tirement, owner-recovered restamp

- Only a changed OS boot id proves a later boot; Windows (no id) never releases on a wall clock.
- A paired switch back closes its terminal through the host's terminal.close under the
  incarnation it started with; the host refuses a replacement under the same handle.
- After a restart, a reattached switch terminal asks the original owner for its binding, saves it,
  and restamps only while that exact execution is still the pane's.
- Saved evidence is a copy, so the frozen journal row never freezes the live claim's processes.
- A failed local commit callback stops that exact process; the switch ends only on its exit.
… on every platform

A saved switch records the host boot id (Linux boot_id, macOS boot session, or the Windows
BootId through one read-only reg.exe query) and the uptime at acceptance. After owner loss, a
different boot id or an uptime lower than recorded proves a reboot; wall-clock time never does,
and macOS uptime, which Node derives from the wall clock, is not used.
@greptile-apps

greptile-apps Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 1/5

[High impact] The PR changes conversation switching across shared chat, terminal, and process-lifecycle paths.

The PR is not ready to merge because four earlier blocking findings remain unresolved.

Findings

  1. P1 Failed launches block recovery ▶
  2. P1 Paginated chats cannot return ▶
  3. P1 Cross-workspace returns close the terminal ▶
  4. P1 Switching reuses a closed terminal ▶
  5. P2 Empty daemon never retires ▶

Summary

This PR lets users move the same conversation between Chat UI and a terminal without making a copy.

  • A conversation switches between Chat UI and terminal in the same tab.
  • Supported agents can switch surfaces while keeping selection and placement tied to the current operation.

The PR description explicitly says not to merge.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[User requests switch] --> B[Stop source]
  B --> C[Confirm process exited]
  C --> D[Import conversation if needed]
  D --> E[Open and publish destination]
  E --> F[Retire source presentation]
  F --> G[Observe provider startup separately]
Loading

Reviews (12) · Last reviewed commit: "Align switch recovery and UI test owner ..." · Reviewed by Greptile

Comment on lines +169 to +178
const result = await this.client.request<CreateOrAttachResult>(
'createOrAttach',
{
sessionId: dispatchedSessionId,
cols: context.effectiveCols,
rows: context.effectiveRows,
attachOnly: true
},
5000
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Failed launches block recovery

When a switch uses an older daemon’s ordinary creation path, a create rejected before any child starts leaves its completion promise unresolved. The later attachOnly request throws SessionNotFoundError, but this branch never settles or removes the retained operation. Later native starts keep waiting, and Restart Terminal Daemon fails at settleSwitchWorkForReplacement.

Settle explicit creation failures through the original request’s outcome. Do not treat an uncertain missing session as proof of exit.

Comment on lines +30 to +35
const prepared = await prepareLegacyTranscriptImport({
agent: source.agent,
piAfterEntry,
sessionId: source.sessionId,
options: { filePath: source.filePath }
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Paginated chats cannot return

This call passes only filePath, so a paginated Codex conversation with history_base cannot return to chat. prepareLegacyTranscriptImport follows that base through readCodexLocalHistory, but the resolver throws without account roots. Every Codex return calls this helper, even after the source shell has stopped.

Forward the source’s account storage so the importer can read the earlier history.

Comment thread src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts Outdated
Comment on lines 73 to 83
@@ -66,14 +82,14 @@ export class DaemonPtySpawnPreparations {
this.finish(sessionId, preparation)
}, 720_000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Empty daemon never retires

If a prepare reply is lost, the daemon keeps an entry in retained that nobody can release. The client only records recovery after receiving the anchor. The daemon's expiry callback converts the entry into a completed record but does not delete it. Since hasOwedOutcomes() counts every retained record, idle shutdown stays blocked even after all terminals and clients leave.

This leaves an empty daemon running indefinitely and adds retained records on each repeat. Keep enough request identity to release an unanswered prepare, or discard abandoned preparations that never started a child.

Comment on lines +140 to +142
// Shutdown already accepted must finish even when the observing request expires.
try {
await host.trackSwitchPhysical(sessionId, close.stop())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Cross-workspace returns close the terminal

When the conversation already has a native chat in another workspace, this path still stops the source terminal and later calls close.retire(). switchRetainedOwner can select that other workspace, but neither close checks the destination workspace. The source terminal disappears instead of staying open as the PR describes.

Compare the source and destination workspaces before stopping the source, and skip its retirement for this case.

@brennanb2025 brennanb2025 changed the title Switch a Codex chat to its terminal and back on the same conversation Switch chats between Chat UI and a terminal without forking Oct 29, 2026
Comment on lines +51 to +56
(prior.direction === direction &&
(!prior.settled ||
(prior.result?.status === 'completed' &&
prior.deadline.remaining() > 0 &&
!prior.deadline.signal.aborted &&
(!prior.workCurrent || prior.workCurrent()))))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Switching reuses a closed terminal

After a user closes a switched terminal, a fresh “Show as terminal” click from Session History can return the earlier success without opening anything. This branch reuses completed same-direction commands for up to 12 minutes. Its workCurrent check watches chat sends and the switch generation, but not whether the destination terminal still exists.

Reuse completed results only for the original invocationId, or check and reveal the surviving destination before sharing them.

This branch has not been deployed

No deployments
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.

2 participants