diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index a514c8c911..92f947e566 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3250,6 +3250,66 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('/resume opens a picker containing only resumable sessions when none is attached', async () => { + const terminal = new FakeTerminal(); + const resumable = fakeSessionSummary('resumable', '/repo'); + const unavailable = fakeSessionSummary('unavailable', ''); + const driver = new SlashCommandDriver([resumable, unavailable]); + (driver as unknown as { sessionId: string | null }).sessionId = null; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session Current')); + const output = plainTerminalOutput(terminal.output()); + assert.match(output, /resumabl/); + assert.doesNotMatch(output, /unavailable/); + + terminal.input('\r'); + await waitFor(() => driver.resumeCalls === 1); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + + test('/session keeps attachable rows when resume discovery fails for another session', async () => { + const terminal = new FakeTerminal(); + const attachable = fakeSessionSummary('attachable', '/repo'); + const archived = fakeSessionSummary('archived', '/repo'); + const driver = new SlashCommandDriver([attachable, archived]); + driver.getSessionResumeAvailability = async (session) => { + if (session.id === archived.id) throw new Error('session archived'); + return { available: true }; + }; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/session'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session Current')); + assert.match(plainTerminalOutput(terminal.output()), /attachab/); + + terminal.input('\x1b'); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + test('surfaces a notice when the foreign-session scan fails', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver([]); 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 14644c1bc8..90be8127ae 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1514,6 +1514,15 @@ class FakeConnection { goal: this.goalQueryResults.shift() ?? null, } as OperationOutput; } + if (operation === 'turn.resume.query') { + return { + sessionId: (input as OperationInput<'turn.resume.query'>).sessionId, + disposition: 'ready', + sourceRunId: 'source-run-1', + sourceTurnId: 'source-turn-1', + sourceRuntimeEventHighWater: 1, + } as OperationOutput; + } if (operation === 'session.configuration.update') { const update = input as OperationInput<'session.configuration.update'>; return { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index da6ab45f58..4d4a88e97d 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -48,7 +48,7 @@ import { } from '@maka/core/slash-command-catalog'; import { type QueueEnqueueOutcome, type ShellRunUpdate } from '@maka/core/events'; import { - deriveModelSwitchTranscript, + latestAssistantModelId, type SessionSummary, type StoredMessage, } from '@maka/core/session'; @@ -108,6 +108,7 @@ import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout. import { TranscriptViewerOverlay } from './pi-tui-transcript-viewer.js'; import { createShellRunElapsedTicker } from './shell-run-elapsed-ticker.js'; import { createShellRunHydrationController } from './shell-run-hydration.js'; +import { sessionStatusBadge } from './tui-session-status.js'; import { AttentionController, DISABLE_FOCUS_REPORTING, @@ -346,11 +347,31 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } | undefined; let turnRunning = false; + // Monotonic generation for visible agent turns. A mid-turn `/session` + // switch-away (#3380) bumps it to orphan the in-flight drain: every callback + // of that runAgentTurn (events, failures, queue flushes) captured the epoch + // at start and becomes a no-op once superseded, so nothing from the + // abandoned Session reaches the adopted one's transcript. + let turnEpoch = 0; let turnStartedAt: number | undefined; let interruptRequested = false; + // True while a mid-turn detach-switch is in flight: an interrupt issued in + // that window would target the freshly attached Session instead of the Turn + // being left behind. + let detaching = false; + // 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). + let sessionPickerOverlayOpen = false; let lastTurnEscapeAt = 0; let lastIdleEscapeAt = 0; let lastIdleCtrlCAt = 0; + // Mirrors the editor's bracketed-paste buffering at the input seam: between a + // paste start marker and its end marker the editor holds incoming bytes in an + // internal buffer and getText() stays empty, so "editor is empty" must not + // treat that in-flight paste as absent user input (#3475 review). The marker + // matching deliberately mirrors the editor's own per-chunk includes() checks, + // so this flag agrees with what the editor will buffer. + let editorPastePending = false; type AttachedTurnContext = | { readonly kind: 'adopted'; readonly turn: MakaPreparedSessionTurn } | { readonly kind: 'external'; readonly turn: MakaAttachedSessionTurn }; @@ -797,7 +818,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const requestTurnInterrupt = () => { - if (interruptRequested) return; + // A detach in flight is not the running Turn's owner acting on it — the + // driver already points at the next Session, so a stop here would abort + // whatever that Session has attached. Swallow until the handoff settles. + if (interruptRequested || detaching) return; interruptRequested = true; // The convergence window (stop issued, turn not yet terminal) accepts no // new input: submits would race the abort and could open work the user @@ -1112,14 +1136,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Known slash commands typed mid-turn follow the disposition declared on // the command itself (`midTurn`, review finding on turnRunning routing): // 'local' commands answer immediately because their handler is - // independent of the running turn; every other known command is refused - // with a clear message, since it would either mutate session state - // behind the turn's back, open a picker the turn would race, or silently - // no-op on the runControl busy gate. ('intercepted' commands — /exit, - // /swarm, /graph — were claimed by their dedicated checks above and - // reaching the refusal here only means an unrecognized form.) Unknown - // slash-prefixed text still steers: it may be intended prompt text (a - // skill invocation such as `/skill:`, or a path). + // independent of the running turn; 'switch' commands detach this + // client's view from the running Turn and adopt another Session (#3380); + // every other known command is refused with a clear message, since it + // would either mutate session state behind the turn's back, open a + // picker the turn would race, or silently no-op on the runControl busy + // gate. ('intercepted' commands — /exit, /swarm, /graph — were claimed + // by their dedicated checks above and reaching the refusal here only + // means an unrecognized form.) Unknown slash-prefixed text still + // steers: it may be intended prompt text (a skill invocation such as + // `/skill:`, or a path). const commandToken = prompt.trim().split(/\s+/, 1)[0] ?? ''; const knownCommand = slashCommands.find( (candidate) => @@ -1128,7 +1154,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ); if (knownCommand) { editor.addToHistory(prompt); - if (knownCommand.midTurn === 'local') { + // 'switch' dispositions route through like 'local': their handlers are + // busy-aware and detach from the running Turn instead of touching it + // (#3380). + if (knownCommand.midTurn === 'local' || knownCommand.midTurn === 'switch') { handleSlashCommand(prompt, 0); } else { state.entries.push({ @@ -1152,6 +1181,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { authoritativeAttachedTurn?: MakaAttachedSessionTurn, ): Promise { busy = true; + const epoch = ++turnEpoch; + // A mid-turn /session switch-away (#3380) bumps turnEpoch and orphans this + // drain: from that point every callback below must stop touching shared + // runner state — the adopted Session owns it now. + const superseded = () => epoch !== turnEpoch; const activity = beginActivity(); turnRunning = true; turnStartedAt = Date.now(); @@ -1194,6 +1228,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }, onPrepared: async (turn) => { + // Orphaned by a mid-turn detach: this can still fire after the + // switch resolved (preparePrompt was in flight), and the abandoned + // Turn's metadata must not overwrite the adopted Session's view. + if (superseded()) return; if (authoritativeAttachedTurn) { adoptSessionMetadata(authoritativeAttachedTurn.summary); replaceTranscript(authoritativeAttachedTurn.messages); @@ -1208,6 +1246,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (turn.summary) adoptSessionMetadata(turn.summary); }, onSkillInvocation: (skillInvocation) => { + // Same mid-turn detach fence as onPrepared/onEvent: a skill card + // belonging to the abandoned Session must not land on the adopted + // viewport (covers the blocked-invocation path too). + if (superseded()) return; if ( skillInvocation.loaded.length === 0 && skillInvocation.failed.length > 0 && @@ -1220,6 +1262,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { showSkillInvocation(skillInvocation); }, onEvent: (event) => { + // Orphaned by a mid-turn detach: the abandoned Session's stream must + // not reach the adopted Session's transcript or overlays. + if (superseded()) return; if ( (event.type === 'sandbox_boundary_request' || event.type === 'user_question_request') && resolvedInteractionIds.delete(event.requestId) @@ -1252,6 +1297,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // A turn failing is worth pulling the user back, regardless of how long it // ran — a quick failure in a background tab would otherwise stay silent. onFailure: (error) => { + // Orphaned by a mid-turn detach: the abandoned drain ends without a + // terminal event (channel close finishes its queue), which surfaces + // here as "ended without completion" — never report that against the + // adopted Session. + if (superseded()) return; appendTurnFailureToTranscript(state, error); attention.attentionNeeded(); shellRunElapsedTicker.sync(); @@ -1266,6 +1316,22 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { activity.finish(); return outcome; } + if (superseded()) { + // Orphaned by a mid-turn detach (#3380): the Session this turn ran + // on is no longer adopted. Skip every continuation that belongs to + // it — queue flushes would steer the NEW Session, fallback texts + // would refill the editor with abandoned-session context, and a + // failure notice would misreport the still-running Host Turn. Only + // release the slot and hand the freshly attached Turn its start; + // startPendingAttachedTurn no-ops until applySwitchResult has + // installed it and we are idle, and the detach path re-arms it, so + // exactly one side starts it whichever unwinds first. + busy = false; + activity.finish(); + requestRender(); + startPendingAttachedTurn(); + return outcome; + } // Turn boundary flush: CLI-held fallback texts that never reached the // runtime (the enqueue retry never found a live owner) are delivered @@ -1396,7 +1462,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const setModel = async (nextModel: string) => { if (nextModel === model) return; - const previousModel = deriveModelSwitchTranscript(transcriptMessages).lastUsedModel ?? model; + const previousModel = latestAssistantModelId(transcriptMessages) ?? model; await input.driver.setModel(nextModel); model = nextModel; // Same-connection switch: scope the choice lookup to the live connection @@ -1422,7 +1488,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Updates the provider (and thus the thinking variants) and the status line. const setModelChoice = async (choice: ModelChoice) => { if (choice.model === model && choice.connectionSlug === connectionSlug) return; - const previousModel = deriveModelSwitchTranscript(transcriptMessages).lastUsedModel ?? model; + const previousModel = latestAssistantModelId(transcriptMessages) ?? model; const previousConnectionSlug = connectionSlug; const previousChoice = modelChoices?.find( (candidate) => @@ -1524,24 +1590,128 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); }; + // Mid-turn `/session` switch-away (#3380): adopt another Session while a + // Turn is still running on the current one. In Runtime Host mode the Turn is + // Host-owned — this TUI was only its viewport — so detaching the view must + // not stop it (unlike the interrupt path, driver.stop() is never called + // here). Bumping turnEpoch orphans the in-flight drain; its runAgentTurn + // tail unwinds through the superseded branch and releases busy/activity, + // then either that tail or the startPendingAttachedTurn below starts the + // freshly attached Turn, whichever observes an idle runner first. + const switchAwayMidTurn = async (sessionId: string) => { + resolvedInteractionIds.clear(); + detaching = true; + try { + // Fence only after the driver confirms the switch: a failed switch must + // leave the in-flight drain fully live. Events the abandoned queue + // yields between the channel closing inside switchSession and + // replaceTranscript below are wiped by that replacement; everything + // after it hits the superseded fence. + const result = await input.driver.switchSession(sessionId); + turnEpoch += 1; + await applySwitchResult(result); + // Same adoption-time announcement as the idle path: applySwitchResult + // replaced the transcript, so a live durable Goal on the adopted Session + // must be re-announced here rather than silently auto-continuing. + currentGoal = input.driver.getGoal?.() ?? null; + if ( + currentGoal !== null && + (currentGoal.status === 'active' || currentGoal.status === 'waiting') + ) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: goalAttachedNoticeText(currentGoal), + }); + } + if (result.messages.length === 0) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: `Resumed session "${result.summary.name}"`, + }); + } + state.entries.push({ + kind: 'notice', + level: 'info', + text: 'Detached from the running Turn — it keeps running. /session back to reattach.', + }); + requestRender(); + } finally { + detaching = false; + startPendingAttachedTurn(); + } + }; + + // `/session` is view navigation (#3380). Idle, it runs under runControl's + // serial lock like any control action; mid-turn that lock is held by the + // 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 (!turnRunning) { + await runControl(() => switchSession(sessionId)); + 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); + }; + const openSessionPicker = (): Promise => { + if (!turnRunning) return runControl(showSessionList); + // The picker itself is a passive overlay; only its selection detaches. + return showSessionList().catch(reportError); + }; + // Rewind branches the active session to just before the chosen turn and // switches onto the branch (driver.rewindToTurn), then refills the editor with // that turn's prompt. The original session is left intact, so this is // non-destructive and inherits the branch's resume guarantees. const rewindToTurn = async (turnId: string) => { resolvedInteractionIds.clear(); - const result = await input.driver.rewindToTurn(turnId); - await applySwitchResult(result); - // Refill the editor with the discarded turn's prompt so the user can edit - // and resend it. The picker only arms when the editor is neutral (empty - // draft, no autocomplete), so overwriting the text loses no in-progress work. - editor.setText(result.prompt); - state.entries.push({ + // Synchronous feedback before the first await: branching + switching takes + // several serialized runtime-host round trips, and control-busy renders + // nothing in the TUI body, so without this notice the picker's Enter looks + // dead until the branch lands (#3383). replaceTranscript wipes it on + // success; the catch removes it on failure so only the error stays. + const pendingNotice: (typeof state.entries)[number] = { kind: 'notice', level: 'info', - text: '已回退到该轮之前(分支为新任务,原任务保留),该轮 prompt 已回填输入框,可修改后重新发送。', - }); + text: '正在回退到该轮之前…', + }; + state.entries.push(pendingNotice); requestRender(); + try { + const result = await input.driver.rewindToTurn(turnId); + await applySwitchResult(result); + // 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 + // session entered via startup resume or /resume has no entry yet, and + // the notice below promises ↑ recovery (#3475 review). + editor.addToHistory(result.prompt); + // Refill the editor with that prompt so the user can edit and resend it + // — unless newer user input arrived while the switch was in flight. The + // picker's neutral-editor guarantee only holds at open time, so this + // covers both a typed draft and a bracketed paste still being buffered + // (getText() stays empty until its end marker); either wins over the + // refill. + const refill = editor.getText().trim().length === 0 && !editorPastePending; + if (refill) editor.setText(result.prompt); + state.entries.push({ + kind: 'notice', + level: 'info', + text: refill + ? '已回退到该轮之前(分支为新任务,原任务保留),该轮 prompt 已回填输入框,可修改后重新发送。' + : '已回退到该轮之前(分支为新任务,原任务保留)。输入框已有未发送内容,未覆盖;该轮 prompt 已存入输入历史,可按 ↑ 找回。', + }); + requestRender(); + } catch (error) { + const index = state.entries.indexOf(pendingNotice); + if (index >= 0) state.entries.splice(index, 1); + throw error; + } }; const showBottomPicker = (picker: Component): OverlayHandle => @@ -1962,7 +2132,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); }; + let sessionListPromise: Promise | undefined; + const listSessions = (): Promise => { + if (!sessionListPromise) { + sessionListPromise = input.driver.listSessions().finally(() => { + sessionListPromise = undefined; + }); + } + return sessionListPromise; + }; + const resumeSession = async () => { + if (!input.driver.getSessionId()) { + await showSessionList({ onlyResumable: true }); + return; + } if (!input.driver.resumeLatest) { throw new Error('Safe-boundary resume is unavailable on this runtime.'); } @@ -1980,8 +2164,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; - const showSessionList = async () => { - const sessions = await input.driver.listSessions(); + const showSessionList = async (options: { onlyResumable?: boolean } = {}) => { + const sessions = await listSessions(); const sessionTree = projectRevisionLinkedSessionTree( sessions, input.driver.getSessionId() ?? undefined, @@ -1996,14 +2180,28 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const [availabilityEntries, foreignScan] = await Promise.all([ Promise.all( sessions.map(async (session) => { - return [ - session.id, - (await input.driver.getSessionResumeAvailability?.(session)) ?? - (await inspectSessionResumeAvailability(session)), - ] as const; + try { + if (!session.cwd) { + return [session.id, { available: false, reason: 'Missing working directory' }] as const; + } + const availability = options.onlyResumable + ? ((await input.driver.getSessionResumeCandidateAvailability?.(session)) ?? + (await input.driver.getSessionResumeAvailability?.(session)) ?? + (await inspectSessionResumeAvailability(session))) + : ((await input.driver.getSessionResumeAvailability?.(session)) ?? + (await inspectSessionResumeAvailability(session))); + return [session.id, availability] as const; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return [session.id, { available: false, reason: detail }] as const; + } }), ), - input.foreignSessions + // Foreign (Claude Code / Codex) rows are an import flow: it starts a NEW + // Session and hands off a turn, which cannot detach from the running + // one (#3380). Skip the scan mid-turn instead of offering rows whose + // selection would silently no-op on importForeignSession's busy guard. + input.foreignSessions && !turnRunning ? input.foreignSessions.listSessions({ cwd }).then( (summaries) => ({ summaries }), (error: unknown) => ({ error }), @@ -2034,20 +2232,25 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { sessionListScope === 'current' ? projectedSessions.filter(({ session }) => session.cwd === cwd) : projectedSessions; - const items: SelectItem[] = visibleSessions.map(({ session, depth }) => { + const selectableSessions = options.onlyResumable + ? visibleSessions.filter(({ session }) => availability.get(session.id)?.available === true) + : visibleSessions; + const items: SelectItem[] = selectableSessions.map(({ session, depth }) => { const state = availability.get(session.id); + const statusBadge = sessionStatusBadge(session, locale); + const statusDetail = statusBadge ? ` · ${statusBadge}` : ''; const location = sessionListScope === 'all' && session.cwd ? ` ${basename(session.cwd)}` : ''; const childDetail = session.subagentRuntime - ? ` subagent:${session.subagentRuntime.profile} ${session.status}` + ? ` subagent:${session.subagentRuntime.profile}` : ''; return { value: session.id, label: `${depth > 0 ? `${' '.repeat(depth - 1)}↳ ` : ''}${session.name || session.id}`, description: state?.available === false - ? `${shortSessionId(session.id)} ${state.reason}` - : `${shortSessionId(session.id)}${location}${childDetail} ${session.llmConnectionSlug} ${session.model}`, + ? `${shortSessionId(session.id)}${statusDetail} ${state.reason}` + : `${shortSessionId(session.id)}${statusDetail}${location}${childDetail} ${session.llmConnectionSlug} ${session.model}`, }; }); // Foreign sessions are cwd-scoped; show them in both scope views (they @@ -2064,18 +2267,26 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { maxPrimaryColumnWidth: Math.max(20, terminal.columns - 30), }); let overlay: OverlayHandle | undefined; + const closeOverlay = () => { + sessionPickerOverlayOpen = false; + overlay?.hide(); + }; list.onSelect = (item) => { const foreign = foreignByValue.get(item.value); if (foreign) { - overlay?.hide(); + closeOverlay(); void importForeignSession(foreign); return; } if (availability.get(item.value)?.available === false) return; - overlay?.hide(); - void runControl(() => switchSession(item.value)); + closeOverlay(); + void (async () => { + await goToSession(item.value); + if (options.onlyResumable) await runControl(resumeSession); + })().catch(reportError); }; - list.onCancel = () => overlay?.hide(); + list.onCancel = () => closeOverlay(); + sessionPickerOverlayOpen = true; overlay = showBottomPicker( new PickerOverlay(list, { title: 'Resume Session', @@ -2094,6 +2305,29 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { renderScope(); }; + const announceResumeAvailability = async (): Promise => { + const sessionId = input.driver.getSessionId(); + try { + if (!input.driver.getSessionResumeCandidateAvailability) return; + const sessions = await listSessions(); + const session = + sessions.find((candidate) => candidate.id === sessionId) ?? + sessions.find((candidate) => candidate.cwd === cwd); + if (!session) return; + const availability = await input.driver.getSessionResumeCandidateAvailability(session); + if (availability.available) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: 'This session has an interrupted run — /resume to continue from the safe boundary.', + }); + requestRender(); + } + } catch { + // Resume discovery is advisory and must never prevent the TUI from starting. + } + }; + const showRewindPicker = async () => { const targets = await input.driver.listRewindTargets(); if (targets.length === 0) { @@ -2114,6 +2348,19 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { 'Rewind', items, (item) => { + // runControl drops the action silently when busy is already held (e.g. + // a Goal auto-continuation started while the picker was open). The + // overlay is already closed at this point, so say so instead of + // leaving a dead Enter — same contract as /goal's busy guard. + if (busy) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: '无法回退:当前有正在进行的操作 — 请等待其完成,或中断(Esc)后重试。', + }); + requestRender(); + return; + } void runControl(() => rewindToTurn(item.value)); }, { @@ -2994,10 +3241,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }, session: { description: primaryGuidance.commands.session, - midTurn: 'refuse', + // View navigation, not a session mutation: mid-turn it detaches from the + // running Turn instead of touching it (#3380), so it is allowed through + // where mutating commands are refused. + midTurn: 'switch', run: (parts: string[]) => { if (parts.length === 1) { - void runControl(showSessionList); + void openSessionPicker(); return; } const sessionId = parts.length === 2 ? parts[1] : undefined; @@ -3010,7 +3260,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); return; } - void runControl(() => switchSession(sessionId)); + void goToSession(sessionId); }, }, graph: { @@ -3067,6 +3317,18 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { refreshEditorCwd(cwd); tui.addInputListener((data) => { + // Track bracketed pastes before any consuming branch: this must observe + // every chunk the editor could buffer, regardless of what the rest of the + // listener decides (#3475 review). + if (data.includes('\x1b[200~')) { + // A paste begins; it is only complete when the end marker follows, here + // or in a later chunk. + editorPastePending = !data.slice(data.indexOf('\x1b[200~') + 6).includes('\x1b[201~'); + } else if (editorPastePending && data.includes('\x1b[201~')) { + // The paste ends; bytes after the end marker may start another paste. + const remainder = data.slice(data.indexOf('\x1b[201~') + 6); + editorPastePending = remainder.includes('\x1b[200~'); + } // Once closing has begun, swallow any buffered input that reaches the // listener while the terminal is being torn down. if (closed) return { consume: true }; @@ -3154,6 +3416,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // boundary branch so Escape keeps meaning "deny" while a prompt is // pending, and it only arms while a prompt turn is actually running. if (turnRunning && matchesKey(data, Key.escape)) { + // The mid-turn /session picker owns Escape while it is open — closing + // it must never arm an interrupt for the Turn being left running. + if (sessionPickerOverlayOpen) return undefined; // Once an interrupt is issued, swallow further Escapes until the turn // ends so a still-settling stop is not requested twice. A rejected stop // re-arms interruption so the user can retry within the same turn. @@ -3245,6 +3510,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // line discipline and leaks onto the screen as a stray `^[[I` on launch. terminal.write(ENABLE_FOCUS_REPORTING); if (input.firstRun) void showSetupWizard(); + setTimeout(() => void announceResumeAvailability(), 0); } catch (error) { beginClose(error instanceof Error ? error : new Error(String(error))); } diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 859e2d21b1..5f5897bb69 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -253,10 +253,20 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { .map(({ session }) => session); } - getSessionResumeAvailability(session: SessionSummary): Promise { + async getSessionResumeAvailability(session: SessionSummary): Promise { return inspectRuntimeHostSessionResumeAvailability(session, this.#executionLocation); } + async getSessionResumeCandidateAvailability( + session: SessionSummary, + ): Promise { + if (!session.cwd) return { available: false, reason: 'Missing working directory' }; + const plan = await this.#request('turn.resume.query', { sessionId: session.id }); + return plan.disposition === 'ready' + ? { available: true } + : { available: false, reason: plan.reason }; + } + async preparePrompt( prompt: string, options: MakaPreparePromptOptions = {}, @@ -1163,9 +1173,8 @@ function inspectRuntimeHostSessionResumeAvailability( if (!summary.cwd) { return Promise.resolve({ available: false, reason: 'Missing working directory' }); } - return location.kind === 'host' - ? Promise.resolve({ available: true }) - : inspectSessionResumeAvailability(summary); + if (location.kind !== 'host') return inspectSessionResumeAvailability(summary); + return Promise.resolve({ available: true }); } async function assertSessionResumeAvailable( diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 86194ee017..c952d60f47 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -91,6 +91,9 @@ export class SkillInvocationBlockedError extends Error { export interface MakaSessionDriver { listSessions(): Promise; getSessionResumeAvailability?(session: SessionSummary): Promise; + getSessionResumeCandidateAvailability?( + session: SessionSummary, + ): Promise; preparePrompt( prompt: string, options?: MakaPreparePromptOptions,