Skip to content

Commit 25518db

Browse files
authored
fix(tui): restore early-aborted prompt to composer (#325)
* fix(tui): restore early-aborted prompt to composer * fix(tui): preserve restored submission edits and metadata
1 parent b47eb90 commit 25518db

18 files changed

Lines changed: 1291 additions & 55 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ Inside the TUI, use `/sessions` to find previous sessions and `/help` to see all
145145
| Reference a workspace file or directory | `@` |
146146
| Toggle Plan Mode | `Shift+Tab` |
147147
| Switch permission modes | `Alt+M` |
148-
| Close a panel or interrupt a running task | `Esc` |
148+
| Close a panel or interrupt a running task; interrupting before the model replies returns the message to the composer | `Esc` |
149149

150150
## Uninstall
151151

‎README_ZH.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ mcode --session
145145
| 引用工作区文件或目录 | `@` |
146146
| 切换 Plan Mode | `Shift+Tab` |
147147
| 切换权限模式 | `Alt+M` |
148-
| 关闭面板或中断正在运行的任务 | `Esc` |
148+
| 关闭面板或中断正在运行的任务;在模型回复之前中断会把消息放回输入框 | `Esc` |
149149

150150
## 卸载
151151

‎packages/tui/src/application/run-coordinator.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export interface TuiRunCoordinatorOptions {
8787
nowMs?: () => number;
8888
}
8989

90-
const DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 1_000;
90+
export const DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 1_000;
9191

9292
export class TuiRunCoordinator {
9393
private activeRun?: ActiveRun;

‎packages/tui/src/tui/app.ts‎

Lines changed: 194 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { TranscriptView } from './transcript/view.js';
1717
import { TuiChatController, type TuiChatSnapshot } from './controller/chat-controller.js';
1818
import { TuiChromeFlow } from './controller/product/chrome-flow.js';
1919
import type { TuiDraftLifecycle } from './features/composer/draft-lifecycle.js';
20+
import { retainAvailableAttachmentPlaceholders } from './features/composer/draft-recovery.js';
21+
import type { TuiSubmissionSnapshot } from './features/composer/submission.js';
22+
import { statSync } from 'node:fs';
23+
import type { TuiTransportAttachment } from '../types/invocation.js';
2024
import { TuiRunProjection } from './state/run-projection.js';
2125
import { createTuiState, TuiEffectRunner, TuiStateStore } from './state/index.js';
2226
import { FeedbackFlow as Feedback } from './controller/product/feedback-flow.js';
@@ -358,6 +362,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
358362
keybindings: options.keybindings,
359363
isStopped: () => stopped,
360364
});
365+
// Turns whose prompt was already returned to the composer by the abort
366+
// restore; keys are runtime turn ids, unique for the app's lifetime.
367+
const restoredAbortTurnIds = new Set<string>();
361368
abortLiveTurn = createTuiAbortLiveTurn({
362369
controller,
363370
runProjection,
@@ -371,6 +378,125 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
371378
if (!stopped) tui.requestRender();
372379
},
373380
append: appendLocalCell,
381+
onLiveTurnAborted: ({ turnId }) => {
382+
// Return the aborted prompt to the composer only when the turn produced
383+
// no user-visible output: any assistant text, tool call, or steer
384+
// message means the user was interacting with a live response, not
385+
// regretting a fresh submission. Thinking is internal model process and
386+
// does not disqualify: it is the most common regret moment, and nothing
387+
// of value is discarded by resending.
388+
if (sessionFlow.isSideModeActive()) return;
389+
const cells = transcript.snapshot().filter((cell) => cell.turnId === turnId);
390+
const userCell = transcript.get(`user:${turnId}`);
391+
if (!userCell) return; // runtime-owned or retry-continuation turns
392+
// A second Esc in the settle window can reach the runtime-owned branch
393+
// with the same turnId; track restored turns so a repeat concatenates
394+
// nothing. (Cell status cannot key this: abort-time markTurn also
395+
// cancels still-pending user cells before the first restore.)
396+
if (restoredAbortTurnIds.has(turnId)) return;
397+
const hasIrreversibleActivity = cells.some((cell) => {
398+
if (cell.id === `user:${turnId}`) return false;
399+
if (cell.kind === 'thinking' || cell.kind === 'turn-duration') return false;
400+
// markTurn('cancelled') synthesizes an empty assistant placeholder for
401+
// turns with no assistant output before this callback can run.
402+
if ((cell.kind === 'assistant' || cell.kind === 'assistant-preamble') && !cell.content) {
403+
return false;
404+
}
405+
return true;
406+
});
407+
if (hasIrreversibleActivity) return;
408+
// Preferred path: replay the ORIGINAL submission retained for this turn
409+
// (complete attachment set, transport content, client intent, editor
410+
// state) instead of reconstructing a lossy copy from the display cell.
411+
const retained = commandFlow.getRetainedSubmission(turnId);
412+
const retainedFiltered = retained
413+
? filterRetainedSubmissionForRestore(retained)
414+
: undefined;
415+
if (retained && retainedFiltered) {
416+
commandFlow.restoreSubmission(retainedFiltered);
417+
commandFlow.markAbortSubmissionRestored(retained.submissionId);
418+
commandFlow.dropRetainedSubmission(turnId);
419+
restoredAbortTurnIds.add(turnId);
420+
markAbortedUserRowCancelled(transcript, turnId);
421+
draftLifecycle?.recordRestoredSubmission(retainedFiltered);
422+
chromeFlow?.setHint('Stopped · message restored to the Composer.');
423+
updateChrome(controller.snapshot());
424+
tui.requestRender();
425+
return;
426+
}
427+
const text = userCell.content;
428+
const draftAttachments = (userCell.attachments ?? []).flatMap((attachment) =>
429+
attachment.filePath && typeof attachment.sizeBytes === 'number'
430+
? [
431+
{
432+
type: attachment.type,
433+
fileName: attachment.fileName,
434+
mimeType: attachment.mimeType,
435+
sizeBytes: attachment.sizeBytes,
436+
filePath: attachment.filePath,
437+
},
438+
]
439+
: [],
440+
);
441+
// Fallback path: reconstruct from the display cell. The transport list
442+
// must carry BOTH halves of a mixed row (asset-backed AND file-backed
443+
// entries): submission-time selection is `transportAttachments ??
444+
// attachments`, so a partial transport list would silently drop the
445+
// file-backed attachments on resubmit.
446+
const transportAttachments: TuiTransportAttachment[] = (userCell.attachments ?? []).flatMap(
447+
(attachment): TuiTransportAttachment[] =>
448+
attachment.assetId
449+
? [
450+
{
451+
type: attachment.type,
452+
fileName: attachment.fileName,
453+
mimeType: attachment.mimeType,
454+
...(attachment.filePath ? { filePath: attachment.filePath } : {}),
455+
assetId: attachment.assetId,
456+
},
457+
]
458+
: attachment.filePath && typeof attachment.sizeBytes === 'number'
459+
? [
460+
{
461+
type: attachment.type,
462+
fileName: attachment.fileName,
463+
mimeType: attachment.mimeType,
464+
filePath: attachment.filePath,
465+
},
466+
]
467+
: [],
468+
);
469+
if (!text.trim() && draftAttachments.length === 0 && transportAttachments.length === 0) {
470+
return;
471+
}
472+
const restoredSubmission: TuiSubmissionSnapshot = {
473+
submissionId: `abort-restore:${turnId}`,
474+
sessionId: controller.snapshot().session?.sessionId,
475+
content: text,
476+
attachments: draftAttachments,
477+
...(transportAttachments.length > 0 ? { transportAttachments } : {}),
478+
createdAtMs: Date.now(),
479+
editor: {
480+
schemaVersion: 1,
481+
text,
482+
cursor: text.length,
483+
pastes: [],
484+
pasteCounter: 0,
485+
},
486+
};
487+
commandFlow.restoreSubmission(restoredSubmission);
488+
restoredAbortTurnIds.add(turnId);
489+
markAbortedUserRowCancelled(transcript, turnId);
490+
// The restored text already carries the submission's content; a pending
491+
// retry for it would merge the same text again on the next hydrate.
492+
draftLifecycle?.recordRestoredSubmission(restoredSubmission);
493+
chromeFlow?.setHint('Stopped · message restored to the Composer.');
494+
// The funnel's updateChrome tail ran during the settle wait, before this
495+
// hint was set; push the chrome once more so the hint is not left
496+
// unpresented.
497+
updateChrome(controller.snapshot());
498+
tui.requestRender();
499+
},
374500
});
375501
const planModeFlow = new TuiPlanModeFlow({
376502
currentSession: () => controller.snapshot().session,
@@ -447,8 +573,11 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
447573
},
448574
followBottom: () => layout.forceFollowBottom(),
449575
requestWelcomeRebuild: () => tui.requestImmediateRender(),
450-
switchComposerDraft: (sessionKey) =>
451-
draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(),
576+
switchComposerDraft: (sessionKey) => {
577+
// Retained turn submissions belong to the previous session's turns.
578+
commandFlow.clearRetainedSubmissions();
579+
return draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve();
580+
},
452581
detachForegroundObserver: () => runtimeEventFlow?.detachForegroundObserver(),
453582
adoptForegroundRun: () =>
454583
sessionLifecycle.adoptForegroundRun(
@@ -730,8 +859,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
730859
activity.dispose();
731860
widgets.imagePreview.dispose();
732861
renderer.prepareTranscriptExit();
733-
disposeComponents(surfaceHost, editor, transcriptView, status, goal);
734862
draftStopPromise ??= draftLifecycle?.stop() ?? Promise.resolve();
863+
disposeComponents(surfaceHost, editor, transcriptView, status, goal);
735864
if (stopOptions.abortActiveTurn !== false) {
736865
const snapshot = controller.snapshot();
737866
if (snapshot.activeTurnId) void controller.abort().catch(() => undefined);
@@ -808,8 +937,70 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
808937
updateChromeAndRequestRender();
809938
},
810939
submit: (input) => commandFlow.submit(input).then(() => undefined),
940+
/** Exposed for integration tests that submit with an explicit seed. */
941+
commandFlow,
811942
abortTurn: abortLiveTurn,
812943
leaveUi,
813944
stop,
814945
};
815946
}
947+
948+
function fileExists(filePath: string): boolean {
949+
try {
950+
return statSync(filePath).isFile();
951+
} catch {
952+
return false;
953+
}
954+
}
955+
956+
/**
957+
* Filters a retained submission snapshot for abort-time restore: drops
958+
* attachments whose backing file disappeared (asset-backed entries stay),
959+
* and prunes editor placeholders accordingly. Returns undefined when nothing
960+
* restorable remains.
961+
*/
962+
function filterRetainedSubmissionForRestore(
963+
snapshot: TuiSubmissionSnapshot,
964+
): TuiSubmissionSnapshot | undefined {
965+
const attachments = snapshot.attachments.filter((attachment) =>
966+
fileExists(attachment.filePath),
967+
);
968+
const transportAttachments = (snapshot.transportAttachments ?? []).filter((attachment) =>
969+
attachment.assetId ? true : attachment.filePath ? fileExists(attachment.filePath) : false,
970+
);
971+
if (!snapshot.content.trim() && attachments.length === 0 && transportAttachments.length === 0) {
972+
return undefined;
973+
}
974+
return {
975+
...snapshot,
976+
submissionId: `abort-restore:${snapshot.submissionId}`,
977+
attachments,
978+
...(snapshot.transportAttachments ? { transportAttachments } : {}),
979+
editor: retainAvailableAttachmentPlaceholders(
980+
// Some submission paths (automation results, test seeds) submit with a
981+
// payload but an empty editor draft; restoring that verbatim would
982+
// leave the composer blank, so fall back to the visible content.
983+
snapshot.editor.text.trim()
984+
? snapshot.editor
985+
: { ...snapshot.editor, text: snapshot.content, cursor: snapshot.content.length },
986+
attachments,
987+
),
988+
};
989+
}
990+
991+
/** Marks the aborted turn's user row cancelled so it stays visible in history. */
992+
function markAbortedUserRowCancelled(transcript: TranscriptStore, turnId: string): void {
993+
const userCell = transcript.get(`user:${turnId}`);
994+
if (!userCell) return;
995+
// markTurn does not rewrite the user cell once the runtime echo flipped it
996+
// to 'succeeded', so upsert explicitly. createdAtMs is force-preserved by
997+
// the store's merge for existing cells and is intentionally omitted.
998+
transcript.upsert({
999+
id: `user:${turnId}`,
1000+
kind: 'user',
1001+
status: 'cancelled',
1002+
...(userCell.content ? { content: userCell.content } : {}),
1003+
updatedAtMs: Date.now(),
1004+
...(userCell.attachments ? { attachments: userCell.attachments } : {}),
1005+
});
1006+
}

‎packages/tui/src/tui/controller/chat-controller-types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,10 @@ export interface TuiSubmitOptions {
7070
beforeTurnAdmission?: (sessionId: string) => void | Promise<void>;
7171
onRuntimeAccepted?: (sessionId: string) => void;
7272
optimisticRequestId?: string;
73+
/**
74+
* Fired once a new turn has been minted and projected (before the first
75+
* await), so the caller can retain per-turn state such as the original
76+
* submission snapshot. Not fired for steer paths or retry continuations.
77+
*/
78+
onTurnStarted?: (turnId: string) => void;
7379
}

‎packages/tui/src/tui/controller/chat-controller.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,9 @@ export class TuiChatController {
389389
errorRetryable: undefined,
390390
});
391391
if (!optimisticCell && !isRetryContinuation) this.onUserSubmissionProjected?.();
392+
// Before the first await: the turn id is final, so the caller can retain
393+
// the original submission snapshot for this turn.
394+
if (!isRetryContinuation) options.onTurnStarted?.(turnId);
392395

393396
try {
394397
await this.requireLoginForAgentAction();

0 commit comments

Comments
 (0)