From 0ef2eb3c292ea1f275dcef34a5ee4e1b4af62570 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 11:51:10 +0800 Subject: [PATCH 1/2] feat(tui): add side conversation switching Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 43 ++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 108 ++++++++++--- .../runtime-host-session-driver.test.ts | 32 +++- packages/cli/src/pi-transcript.ts | 25 ++- packages/cli/src/pi-tui-runner.ts | 146 ++++++++++++++++-- .../cli/src/runtime-host-session-channel.ts | 11 ++ .../cli/src/runtime-host-session-driver.ts | 134 +++++++++++++++- packages/cli/src/session-driver.ts | 13 ++ 8 files changed, 469 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..e2c15c0779 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -195,6 +195,49 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(stripAnsi(renderMakaPiStatusLine({ ...meta(), goal: null }, 120)), /goal/); }); + test('renders side conversation status in English for every UI locale', () => { + assert.equal( + stripAnsi( + renderMakaPiStatusLine( + { ...meta(), uiLocale: 'zh', sideConversation: { view: 'side' } }, + 200, + ), + ), + 'Side from main thread · Ctrl+/ to switch · Ctrl+C to close', + ); + for (const [parentStatus, label] of [ + ['needs_input', 'main needs input'], + ['needs_approval', 'main needs approval'], + ['failed', 'main failed'], + ['interrupted', 'main interrupted'], + ['closed', 'main closed'], + ['finished', 'main finished'], + ] as const) { + assert.equal( + stripAnsi( + renderMakaPiStatusLine( + { + ...meta(), + uiLocale: 'zh', + sideConversation: { view: 'side', parentStatus }, + }, + 200, + ), + ), + `Side from main thread · ${label} · Ctrl+/ to switch · Ctrl+C to close`, + ); + } + assert.match( + stripAnsi( + renderMakaPiStatusLine( + { ...meta(), uiLocale: 'zh', sideConversation: { view: 'parent' } }, + 200, + ), + ), + /Ctrl\+\/ for side/, + ); + }); + test('status line degrades to ctx ?/window when the window is known but usage is not (#3371)', () => { // No usage object at all: the window is known, so degrade explicitly. assert.match( diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 69c1f8d731..6cd58a6f8d 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -49,6 +49,7 @@ import type { MakaAttachedSessionTurn, MakaSessionMoveResult, MakaSessionDriver, + MakaSideConversationParentStatus, MakaSessionRewindResult, MakaSessionSwitchOptions, MakaSessionSwitchResult, @@ -6398,7 +6399,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('blocks Session identity changes until the side conversation closes', async () => { + test('Ctrl+/ toggles side views, preserves drafts, and projects parent status in English', async () => { const terminal = new FakeTerminal(); const driver = new SideConversationDriver(); const run = runMakaPiTui({ @@ -6408,38 +6409,82 @@ describe('Maka Pi TUI runner', () => { model: 'claude-sonnet-4-5', connectionSlug: 'claude-subscription', permissionMode: 'ask', + locale: 'zh', terminal, }); terminal.input('/side'); terminal.input('\r'); await waitFor(() => driver.getSessionId() === 'side-1'); + terminal.input('side draft'); + terminal.input('\x1f'); + await waitFor(() => driver.getSessionId() === 'session-1'); + assert.equal(editorInputText(terminal), ''); - terminal.input('/session session-2'); - terminal.input('\r'); + terminal.input('parent draft'); + terminal.input('\x1b[47;5u'); + await waitFor(() => driver.getSessionId() === 'side-1'); + assert.equal(editorInputText(terminal), 'side draft'); + + driver.publishParentStatus('needs_approval'); await waitFor(() => - plainTerminalOutput(terminal.output()).includes( - 'Close the side conversation before switching Sessions.', + plainTerminalOutput(terminal.screenOutput()).includes( + 'Side from main thread · main needs approval · Ctrl+/ to switch · Ctrl+C to close', ), ); - terminal.input('/new'); + + exitMaka(terminal); + await run; + }); + + test('Ctrl+/ detaches from a running side Turn without stopping it', async () => { + const terminal = new FakeTerminal(); + const driver = new RunningSideConversationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/side keep checking'); terminal.input('\r'); - await waitFor(() => - plainTerminalOutput(terminal.output()).includes( - 'Close the side conversation before starting a new Session.', - ), - ); - terminal.input('/rewind'); + await driver.sideStarted.promise; + terminal.input('\x1f'); + await waitFor(() => driver.getSessionId() === 'session-1'); + + assert.equal(driver.stopCalls, 0); + exitMaka(terminal); + await run; + }); + + test('/session outside the pair discards its retained side conversation', async () => { + const terminal = new FakeTerminal(); + const driver = new SideConversationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/side'); terminal.input('\r'); - await waitFor(() => - plainTerminalOutput(terminal.output()).includes( - 'Close the side conversation before rewinding.', - ), - ); + await waitFor(() => driver.getSessionId() === 'side-1'); + terminal.input('\x1f'); + await waitFor(() => driver.getSessionId() === 'session-1'); + terminal.input('/session session-2'); + terminal.input('\r'); + await waitFor(() => driver.discardedSides.length === 1); - assert.equal(driver.getSessionId(), 'side-1'); - assert.deepEqual(driver.sessionIds, []); - assert.equal(driver.startNewSessionCalls, 0); + assert.deepEqual(driver.discardedSides, ['side-1']); + assert.equal(driver.getSessionId(), 'session-2'); exitMaka(terminal); await run; @@ -7762,6 +7807,10 @@ class SideConversationDriver extends SlashCommandDriver { readonly openedFrom: string[] = []; readonly closedSides: Array<{ sideSessionId: string; parentSessionId: string }> = []; readonly promptSessionIds: string[] = []; + readonly discardedSides: string[] = []; + parentStatusListener: + | ((status: MakaSideConversationParentStatus | undefined) => void) + | undefined; override preparePrompt( prompt: string, @@ -7793,6 +7842,25 @@ class SideConversationDriver extends SlashCommandDriver { this.sessionId = parentSessionId; return { ...switchResult(fakeSessionSummary(parentSessionId)), cleanup: 'removed' as const }; } + + async observeSideConversationParent( + _parentSessionId: string, + listener: (status: MakaSideConversationParentStatus | undefined) => void, + ) { + this.parentStatusListener = listener; + return async () => { + if (this.parentStatusListener === listener) this.parentStatusListener = undefined; + }; + } + + publishParentStatus(status: MakaSideConversationParentStatus | undefined): void { + this.parentStatusListener?.(status); + } + + async discardSideConversation(sideSessionId: string) { + this.discardedSides.push(sideSessionId); + return 'removed' as const; + } } class RunningParentSideConversationDriver extends SideConversationDriver { diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index b163b6689f..8ad0dfab6f 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -44,7 +44,11 @@ import { createRuntimeHostMakaSessionDriver, type RuntimeHostMakaSessionDriverInput, } from '../runtime-host-session-driver.js'; -import { SkillInvocationBlockedError, type MakaAttachedSessionTurn } from '../session-driver.js'; +import { + SkillInvocationBlockedError, + type MakaAttachedSessionTurn, + type MakaSideConversationParentStatus, +} from '../session-driver.js'; import { WAIT_BUDGET_MS } from './tui-terminal-mock.js'; describe('Runtime Host Maka Session driver', () => { @@ -1319,6 +1323,32 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('observes actionable and terminal parent status from the Host projection', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ interactions: { pending: [pendingPermission()] } }), + Promise.resolve([]), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: new FakeConnection([subscription]).value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const statuses: Array = []; + + const stop = await driver.observeSideConversationParent!('session-1', (status) => { + statuses.push(status); + }); + assert.equal(statuses.at(-1), 'needs_approval'); + + subscription.push(projectionFrame(1, runningTurn('turn-2', 'run-2'), 2)); + await waitFor(() => statuses.at(-1) === undefined); + subscription.push(projectionFrame(2, completedTurn('turn-2', 'run-2'), 3)); + await waitFor(() => statuses.at(-1) === 'finished'); + + await stop(); + }); + test('reopens a failed Session channel before starting the next turn', async () => { const first = new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve([])); const second = new FakeSubscription( diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 35de5bbd80..b514cb5f78 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -45,7 +45,7 @@ import { } from '@maka/core/tool-result-status'; import { type ShellRunUpdate } from '@maka/core/events'; import { homedir } from 'node:os'; -import type { MakaSessionDriver } from './session-driver.js'; +import type { MakaSessionDriver, MakaSideConversationParentStatus } from './session-driver.js'; import { BoundedChunkBuffer } from './bounded-chunk-buffer.js'; import { ansi } from './tui-ansi.js'; import { @@ -205,6 +205,10 @@ export interface MakaPiTranscriptMetadata { * terminal goals leave no segment, matching the desktop chip. */ goal?: GoalProjection | null; + sideConversation?: { + view: 'parent' | 'side'; + parentStatus?: MakaSideConversationParentStatus; + }; } export function createMakaPiTranscriptState(): MakaPiTranscriptState { @@ -1328,12 +1332,18 @@ export function permissionModeLabel(mode: string): string { export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width: number): string { const safeWidth = Math.max(1, width); + if (metadata.sideConversation?.view === 'side') { + return fitLine(ansi.dim(sideConversationStatusLineText(metadata.sideConversation)), safeWidth); + } const sep = ansi.dim(' · '); const parts: string[] = [ ansi.bold(metadata.title), ansi.dim(permissionModeLabel(metadata.permissionMode)), ansi.dim(metadata.model), ]; + if (metadata.sideConversation?.view === 'parent') { + parts.push(ansi.dim(sideConversationStatusLineText(metadata.sideConversation))); + } // #1064: omit thinking:default — it is noise before the user explicitly // changes the level. Only a non-default, explicitly set level shows. if (metadata.thinkingLevel) { @@ -1398,6 +1408,19 @@ export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width return fitLine(parts.join(sep), safeWidth); } +function sideConversationStatusLineText( + side: NonNullable, +): string { + if (side.view === 'parent') return 'Ctrl+/ for side'; + const status = side.parentStatus?.replaceAll('_', ' '); + return [ + 'Side from main thread', + ...(status ? [`main ${status}`] : []), + 'Ctrl+/ to switch', + 'Ctrl+C to close', + ].join(' · '); +} + /** * One-line activity strip shown between the transcript and the editor. * Renders `Working… ` while a turn runs, or a blank reserved row when idle diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 1958aa528a..01e6e078f6 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -83,6 +83,7 @@ import { type MakaAttachedSessionTurn, type MakaPreparedSessionTurn, type MakaSessionDriver, + type MakaSideConversationParentStatus, type MakaSessionSwitchResult, } from './session-driver.js'; import { @@ -367,7 +368,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // being left behind. let detaching = false; let sideConversation: - | { readonly parentSessionId: string; readonly sideSessionId: string } + | { + readonly parentSessionId: string; + readonly sideSessionId: string; + parentDraft: string; + sideDraft: string; + parentStatus?: MakaSideConversationParentStatus; + stopParentObserver?: () => Promise; + } | undefined; // True while the /session picker is open mid-turn: Escape must close the // overlay, not arm the double-Escape interrupt for the running Turn (#3380). @@ -463,6 +471,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { providerRetry: state.providerRetry, uiLocale: locale, goal: input.driver.getGoal?.() ?? null, + ...(sideConversation + ? { + sideConversation: { + view: + input.driver.getSessionId() === sideConversation.sideSessionId ? 'side' : 'parent', + ...(sideConversation.parentStatus + ? { parentStatus: sideConversation.parentStatus } + : {}), + } as const, + } + : {}), }); const transcript = new MakaTranscriptComponent(state, metadata); @@ -720,6 +739,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { removeProcessHandlers(); unsubscribeSessionTitleChanges(); unsubscribeGoalChanges?.(); + void sideConversation?.stopParentObserver?.(); unsubscribeStartedTurns(); unsubscribeResolvedInteractions(); unsubscribeTranscriptReplacements(); @@ -1658,15 +1678,78 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; - const blockIdentityChangeWhileSideOpen = (action: string): boolean => { - if (!sideConversation) return false; - state.entries.push({ - kind: 'notice', - level: 'error', - text: `Close the side conversation before ${action}.`, - }); + const stopSideParentObserver = async ( + pair: NonNullable, + ): Promise => { + const stop = pair.stopParentObserver; + pair.stopParentObserver = undefined; + await stop?.(); + }; + + const startSideParentObserver = async ( + pair: NonNullable, + ): Promise => { + if (!input.driver.observeSideConversationParent || pair.stopParentObserver) return; + let stop: () => Promise; + try { + stop = await input.driver.observeSideConversationParent(pair.parentSessionId, (status) => { + if (sideConversation !== pair) return; + pair.parentStatus = status; + requestRender(); + }); + } catch { + if (sideConversation === pair) pair.parentStatus = 'closed'; + requestRender(); + return; + } + if (sideConversation !== pair || input.driver.getSessionId() !== pair.sideSessionId) { + await stop(); + return; + } + pair.stopParentObserver = stop; + }; + + const discardCurrentSidePair = async (): Promise => { + const pair = sideConversation; + if (!pair) return; + await stopSideParentObserver(pair); + const cleanup = input.driver.discardSideConversation + ? await input.driver.discardSideConversation(pair.sideSessionId) + : 'pending'; + if (sideConversation === pair) sideConversation = undefined; + if (cleanup === 'pending') { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Side conversation closed; cleanup will be retried on the next launch.', + }); + } requestRender(); - return true; + }; + + const toggleSideConversation = async (): Promise => { + const pair = sideConversation; + if (!pair || detaching || (busy && !turnRunning)) return; + const fromSide = input.driver.getSessionId() === pair.sideSessionId; + const targetSessionId = fromSide ? pair.parentSessionId : pair.sideSessionId; + const currentDraft = editor.getText(); + const switchView = async () => { + if (turnRunning) await switchAwayMidTurn(targetSessionId); + else await switchSession(targetSessionId); + if (sideConversation !== pair) return; + if (fromSide) { + pair.sideDraft = currentDraft; + editor.setText(pair.parentDraft); + await stopSideParentObserver(pair); + } else { + pair.parentDraft = currentDraft; + editor.setText(pair.sideDraft); + await startSideParentObserver(pair); + } + requestRender(); + }; + if (turnRunning) await switchView().catch(reportError); + else await runControl(switchView); }; // `/session` is view navigation (#3380). Idle, it runs under runControl's @@ -1674,16 +1757,31 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // running Turn, so the switch goes through the detach path instead of // silently no-oping on the busy gate. const goToSession = async (sessionId: string): Promise => { - if (blockIdentityChangeWhileSideOpen('switching Sessions')) return; + const pair = sideConversation; + if ( + pair && + sessionId !== input.driver.getSessionId() && + (sessionId === pair.parentSessionId || sessionId === pair.sideSessionId) + ) { + await toggleSideConversation(); + return; + } + const leavesPair = + pair !== undefined && sessionId !== pair.parentSessionId && sessionId !== pair.sideSessionId; if (!turnRunning) { - await runControl(() => switchSession(sessionId)); + await runControl(async () => { + await switchSession(sessionId); + if (leavesPair) await discardCurrentSidePair(); + }); return; } // One detach at a time (#3380): a second mid-turn switch while the first // is still handing the view over would clear `detaching` early, reopen // the interrupt window, and double-apply the adoption. if (detaching) return; - await switchAwayMidTurn(sessionId).catch(reportError); + await switchAwayMidTurn(sessionId) + .then(() => (leavesPair ? discardCurrentSidePair() : undefined)) + .catch(reportError); }; const openSideConversation = async (prompt: string): Promise => { @@ -1714,7 +1812,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { sideConversation = { parentSessionId: result.parentSessionId, sideSessionId: result.sideSessionId, + parentDraft: editor.getText(), + sideDraft: '', }; + await startSideParentObserver(sideConversation); opened = true; state.entries.push({ kind: 'notice', @@ -1750,6 +1851,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { pair.parentSessionId, ); await applySwitchResult(result); + await stopSideParentObserver(pair); sideConversation = undefined; if (result.cleanup === 'pending') { state.entries.push({ @@ -1793,6 +1895,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { try { const result = await input.driver.rewindToTurn(turnId); await applySwitchResult(result); + await discardCurrentSidePair(); // Record the discarded turn's prompt in the editor history before // deciding on the refill: prompts submitted in this TUI process are // already there (addToHistory dedupes consecutive duplicates), but a @@ -2405,7 +2508,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const showRewindPicker = async () => { - if (blockIdentityChangeWhileSideOpen('rewinding')) return; const targets = await input.driver.listRewindTargets(); if (targets.length === 0) { state.entries.push({ @@ -2448,8 +2550,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ); }; - const newSession = () => { - if (blockIdentityChangeWhileSideOpen('starting a new Session')) return; + const newSession = async (): Promise => { input.driver.startNewSession(); // A fresh session is not bound by the previous one's boundary. Falling back // to the *current* label would keep the previous Session's mode, including @@ -2466,6 +2567,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // non-empty and suppress it. replaceTranscript([]); shellRunElapsedTicker.sync(); + await discardCurrentSidePair(); requestRender(); }; @@ -2479,7 +2581,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // short line shows in the transcript. const importForeignSession = async (summary: ForeignSessionSummary): Promise => { if (busy || input.foreignSessions === undefined) return; - if (blockIdentityChangeWhileSideOpen('importing another Session')) return; busy = true; const activity = beginActivity(); editor.disableSubmit = true; @@ -2487,7 +2588,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { try { const digest = await input.foreignSessions.readDigest(summary); if (closed) return; - newSession(); + await newSession(); void runAgentTurn({ kind: 'external', prompt: foreignSessionHandoffDisplayText(digest), @@ -3469,6 +3570,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (isKeyRelease(data)) return undefined; if ( sideConversation && + input.driver.getSessionId() === sideConversation.sideSessionId && matchesKey(data, Key.ctrl('c')) && !isKeyRepeat(data) && editor.getText().length === 0 && @@ -3490,6 +3592,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return { consume: true }; } if (tui.hasOverlay()) return undefined; + if (matchesSideConversationToggle(data)) { + if (!isKeyRepeat(data)) void toggleSideConversation(); + return { consume: true }; + } const pendingSandboxBoundary = activeSandboxBoundaryRequest(state); if (pendingSandboxBoundary && !matchesKey(data, Key.ctrl('c'))) { if ( @@ -3862,6 +3968,12 @@ function isExitPrompt(prompt: string): boolean { return trimmed === 'quit' || trimmed === 'exit' || trimmed === '/quit' || trimmed === '/exit'; } +function matchesSideConversationToggle(data: string): boolean { + // Legacy terminals encode Ctrl+/ as the C0 unit-separator byte (the same + // byte often named Ctrl+_); Kitty/modifyOtherKeys can preserve the slash. + return data === '\x1f' || matchesKey(data, Key.ctrl('/')); +} + // Two Escapes this close together read as one deliberate "stop the turn". const DOUBLE_ESCAPE_INTERRUPT_WINDOW_MS = 600; const DOUBLE_CTRL_C_EXIT_WINDOW_MS = 1_000; diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 01714f225f..3937b371ad 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -81,6 +81,10 @@ export interface RuntimeHostSessionChannelOptions { * channel for goal state — the same one the desktop observer diffs. */ onGoalChanged: (goal: GoalProjection | null) => void; + /** Optional read-only projection observer; the channel remains the sole folder. */ + onSnapshotChanged?: (snapshot: SessionContinuitySnapshot) => void; + /** Fired only after the channel's bounded recovery policy is exhausted. */ + onFailed?: (error: Error) => void; onRecovered: () => void; } @@ -97,6 +101,8 @@ export class RuntimeHostSessionChannel { readonly #onTranscriptSettlement: (turnId: string) => void; readonly #onTranscriptReplaced: (turnId: string, messages: readonly StoredMessage[]) => void; readonly #onGoalChanged: (goal: GoalProjection | null) => void; + readonly #onSnapshotChanged: ((snapshot: SessionContinuitySnapshot) => void) | undefined; + readonly #onFailed: ((error: Error) => void) | undefined; readonly #onRecovered: () => void; readonly #turns = new Map(); readonly #pendingFrames: SubscriptionFrame[] = []; @@ -135,6 +141,8 @@ export class RuntimeHostSessionChannel { this.#onTranscriptSettlement = options.onTranscriptSettlement; this.#onTranscriptReplaced = options.onTranscriptReplaced; this.#onGoalChanged = options.onGoalChanged; + this.#onSnapshotChanged = options.onSnapshotChanged; + this.#onFailed = options.onFailed; this.#onRecovered = options.onRecovered; } @@ -441,6 +449,7 @@ export class RuntimeHostSessionChannel { this.#now, this.#subscription.activeAssistantStreams, ); + this.#onSnapshotChanged?.(structuredClone(this.#projector.snapshot)); // A canonical replacement is a sequence cut. No queued event from the // retired subscription may replay after the transcript/snapshot has // established newer state; active, terminal, and interaction state is @@ -598,6 +607,7 @@ export class RuntimeHostSessionChannel { const update = this.#projector?.accept(frame); if (!update || !this.#projector) return; const snapshot = this.#projector.snapshot; + this.#onSnapshotChanged?.(structuredClone(snapshot)); if (!sameGoalProjection(previousGoal, snapshot.goal)) { // Clone like the canonical-replacement path above: listeners receive // their own copy, so a mutating listener cannot corrupt the live @@ -669,6 +679,7 @@ export class RuntimeHostSessionChannel { if (this.#failure) return; this.#failure = error instanceof Error ? error : new Error(String(error)); for (const queue of this.#turns.values()) queue.fail(this.#failure); + this.#onFailed?.(this.#failure); } } diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 99c7bee950..065305eb04 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -69,6 +69,7 @@ import { WorkspaceTarget, type GoalControlAction, type GoalProjection, + type SessionContinuitySnapshot, } from '@maka/runtime-host/protocol'; import { RuntimeHostSessionChannel, @@ -79,6 +80,7 @@ import type { MakaAttachedSessionTurn, MakaSideConversationCloseResult, MakaSideConversationOpenResult, + MakaSideConversationParentStatus, MakaPreparePromptOptions, MakaPreparedSessionTurn, MakaSessionDriver, @@ -685,13 +687,85 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { throw new Error('The active Session is not the side conversation being closed.'); } const parent = await this.switchSession(parentSessionId); - const cleanup = await this.#requireSessionCopyCleanup() - .cleanup(sideSessionId) - .then(() => 'removed' as const) - .catch(() => 'pending' as const); + const cleanup = await this.#cleanupSideConversation(sideSessionId); return { ...parent, cleanup }; } + async observeSideConversationParent( + parentSessionId: string, + listener: (status: MakaSideConversationParentStatus | undefined) => void, + ): Promise<() => Promise> { + let closed = false; + let observedLiveRunId: string | undefined; + const drains = new Set>(); + const publish = (snapshot: SessionContinuitySnapshot): void => { + if (isLiveSessionTurn(snapshot.rootTurn)) observedLiveRunId = snapshot.rootTurn.runId; + if (!closed) listener(sideConversationParentStatus(snapshot, observedLiveRunId)); + }; + const drain = (turn: MakaPreparedSessionTurn): void => { + const task = (async () => { + try { + for await (const _event of turn.events) { + // The observer consumes the channel queue only to keep its Host + // projection live; the active Session remains the transcript owner. + } + } catch { + // onFailed publishes the user-visible closed state. + } + })().finally(() => drains.delete(task)); + drains.add(task); + }; + const opened = await RuntimeHostSessionChannel.open({ + connection: this.#connection, + sessionId: parentSessionId, + now: this.#now, + onTurnStarted: drain, + onRuntimeResourceChanged: () => undefined, + onInteractionPending: () => undefined, + onInteractionResolved: () => undefined, + onTranscriptSettlement: () => undefined, + onTranscriptReplaced: () => undefined, + onGoalChanged: () => undefined, + onSnapshotChanged: publish, + onFailed: () => { + if (!closed) listener('closed'); + }, + onRecovered: () => undefined, + }); + if (opened.attachedTurnId) { + drain({ + sessionId: parentSessionId, + turnId: opened.attachedTurnId, + events: opened.channel.eventsForTurn(opened.attachedTurnId), + }); + opened.channel.activate(opened.attachedTurnId); + } else { + opened.channel.activate(); + } + return async () => { + if (closed) return; + closed = true; + await opened.channel.close(); + await Promise.allSettled(drains); + }; + } + + async discardSideConversation(sideSessionId: string): Promise<'removed' | 'pending'> { + await this.#stopSessionTurn(sideSessionId).catch(() => undefined); + return this.#cleanupSideConversation(sideSessionId); + } + + async #cleanupSideConversation(sideSessionId: string): Promise<'removed' | 'pending'> { + const cleanup = this.#requireSessionCopyCleanup(); + try { + await cleanup.cleanup(sideSessionId); + return 'removed'; + } catch { + await cleanup.schedule(sideSessionId).catch(() => undefined); + return 'pending'; + } + } + async cleanupOwnedSideConversations(): Promise { await this.#requireSessionCopyCleanup().abandonOwner('tui-side'); } @@ -1087,6 +1161,30 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { throw new Error(`Session kept changing while removing: ${sessionId}`); } + async #stopSessionTurn(sessionId: string): Promise { + const subscription = await this.#connection.openSessionSubscription({ + sessionId, + transcript: { kind: 'none' }, + }); + const draining = (async () => { + for await (const _frame of subscription) { + // Keep the bounded subscription healthy until turn.stop settles. + } + })(); + try { + const turn = subscription.snapshot.rootTurn; + if (!turn || isTerminalTurn(turn)) return; + await this.#request('turn.stop', { + sessionId, + turnId: turn.turnId, + runId: turn.runId, + }); + } finally { + await subscription.close().catch(() => undefined); + await draining.catch(() => undefined); + } + } + #publishStartedTurn(turn: MakaPreparedSessionTurn, sessionGeneration: number): void { if (this.#claimedTurnIds.delete(turn.turnId)) return; const sourceChannel = this.#channel; @@ -1316,6 +1414,34 @@ function representableSession(item: SessionCatalogItem): SessionCatalogProjectio return 'kind' in item ? [] : [item]; } +function sideConversationParentStatus( + snapshot: SessionContinuitySnapshot, + observedLiveRunId: string | undefined, +): MakaSideConversationParentStatus | undefined { + if (snapshot.session.isArchived) return 'closed'; + const pendingKinds = new Set( + snapshot.interactions.pending.map((interaction) => interaction.request.kind), + ); + if (pendingKinds.has('permission') || pendingKinds.has('sandbox_boundary')) { + return 'needs_approval'; + } + if (pendingKinds.has('question')) return 'needs_input'; + if (!snapshot.rootTurn || snapshot.rootTurn.runId !== observedLiveRunId) return undefined; + if (snapshot.rootTurn.status === 'failed') return 'failed'; + if (snapshot.rootTurn.status === 'cancelled') return 'interrupted'; + if (snapshot.rootTurn.status === 'completed') return 'finished'; + return undefined; +} + +function isLiveSessionTurn( + turn: SessionContinuitySnapshot['rootTurn'], +): turn is Exclude< + NonNullable, + { status: 'completed' | 'failed' | 'cancelled' } +> { + return turn !== null && !isTerminalTurn(turn); +} + function isTuiProcessActive(ownerProcessId: string): boolean { const match = /^tui:(\d+)$/.exec(ownerProcessId); if (!match) return false; diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index e9fe200a6e..e6e253953e 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -69,6 +69,14 @@ export interface MakaSideConversationCloseResult extends MakaSessionSwitchResult cleanup: 'removed' | 'pending'; } +export type MakaSideConversationParentStatus = + | 'needs_input' + | 'needs_approval' + | 'failed' + | 'interrupted' + | 'closed' + | 'finished'; + export interface MakaPreparedSessionTurn { sessionId: string; turnId: string; @@ -129,6 +137,11 @@ export interface MakaSessionDriver { sideSessionId: string, parentSessionId: string, ): Promise; + observeSideConversationParent?( + parentSessionId: string, + listener: (status: MakaSideConversationParentStatus | undefined) => void, + ): Promise<() => Promise>; + discardSideConversation?(sideSessionId: string): Promise<'removed' | 'pending'>; subscribeStartedTurns?(listener: (turn: MakaAttachedSessionTurn) => void): () => void; subscribeResolvedInteractions?( listener: (sessionId: string, requestId: string) => void, From 62bb17c0b33c88ae94e26a87f6501cff346d7c5d Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 12:24:29 +0800 Subject: [PATCH 2/2] fix(tui): preserve side conversation state Generated-by: Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 67 +++++++++++++++++++ .../runtime-host-session-driver.test.ts | 37 ++++++++++ packages/cli/src/pi-tui-runner.ts | 3 +- .../cli/src/runtime-host-session-driver.ts | 4 +- 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 6cd58a6f8d..dfcbed939f 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -6437,6 +6437,64 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('closing an empty side conversation restores the retained parent draft', async () => { + const terminal = new FakeTerminal(); + const driver = new SideConversationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + terminal.input('\x1f'); + await waitFor(() => driver.getSessionId() === 'session-1'); + terminal.input('parent draft'); + terminal.input('\x1f'); + await waitFor(() => driver.getSessionId() === 'side-1'); + assert.equal(editorInputText(terminal), ''); + + terminal.input('\x03'); + await waitFor(() => editorInputText(terminal) === 'parent draft'); + assert.equal(driver.getSessionId(), 'session-1'); + + exitMaka(terminal); + await run; + }); + + test('does not report main closed when the parent observer cannot open', async () => { + const terminal = new FakeTerminal(); + const driver = new FailingParentObserverSideConversationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes( + 'Side from main thread · Ctrl+/ to switch · Ctrl+C to close', + ), + ); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /main closed/u); + + exitMaka(terminal); + await run; + }); + test('Ctrl+/ detaches from a running side Turn without stopping it', async () => { const terminal = new FakeTerminal(); const driver = new RunningSideConversationDriver(); @@ -7863,6 +7921,15 @@ class SideConversationDriver extends SlashCommandDriver { } } +class FailingParentObserverSideConversationDriver extends SideConversationDriver { + override async observeSideConversationParent( + _parentSessionId: string, + _listener: (status: MakaSideConversationParentStatus | undefined) => void, + ): Promise<() => Promise> { + throw new Error('parent observer unavailable'); + } +} + class RunningParentSideConversationDriver extends SideConversationDriver { readonly parentStarted = deferred(); readonly #releaseParent = deferred(); diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 8ad0dfab6f..69467fbcbd 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1349,6 +1349,43 @@ describe('Runtime Host Maka Session driver', () => { await stop(); }); + test('clears parent status when observer recovery is exhausted', async () => { + const snapshot = continuitySnapshot({ interactions: { pending: [pendingPermission()] } }); + const initial = new FakeSubscription(snapshot, Promise.resolve([])); + const ended = Array.from({ length: 8 }, (_, index) => { + const subscription = new FakeSubscription( + { ...snapshot, projectionRevision: index + 2 }, + Promise.resolve([]), + `subscription-${index + 2}`, + ); + void subscription.close(); + return subscription; + }); + const connection = new FakeConnection([initial, ...ended], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const statuses: Array = []; + const cleared = deferred(); + + await driver.observeSideConversationParent!('session-1', (status) => { + statuses.push(status); + if (status === undefined) cleared.resolve(); + }); + assert.equal(statuses.at(-1), 'needs_approval'); + + await initial.close(); + await Promise.race([ + cleared.promise, + delay(3_000).then(() => assert.fail('Timed out waiting for observer recovery exhaustion')), + ]); + assert.equal(connection.openedSubscriptions, 9); + assert.equal(statuses.at(-1), undefined); + }); + test('reopens a failed Session channel before starting the next turn', async () => { const first = new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve([])); const second = new FakeSubscription( diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 01e6e078f6..d9b1fe517b 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -1698,7 +1698,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }); } catch { - if (sideConversation === pair) pair.parentStatus = 'closed'; + if (sideConversation === pair) pair.parentStatus = undefined; requestRender(); return; } @@ -1851,6 +1851,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { pair.parentSessionId, ); await applySwitchResult(result); + editor.setText(pair.parentDraft); await stopSideParentObserver(pair); sideConversation = undefined; if (result.cleanup === 'pending') { diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 065305eb04..b65ba1a172 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -710,7 +710,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { // projection live; the active Session remains the transcript owner. } } catch { - // onFailed publishes the user-visible closed state. + // onFailed clears the user-visible state once recovery is exhausted. } })().finally(() => drains.delete(task)); drains.add(task); @@ -728,7 +728,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { onGoalChanged: () => undefined, onSnapshotChanged: publish, onFailed: () => { - if (!closed) listener('closed'); + if (!closed) listener(undefined); }, onRecovered: () => undefined, });