Merge PR #3496: fix(tui): guard streaming Esc cancellation (@roboomp)

This commit is contained in:
can1357
2026-06-27 01:39:35 +02:00
6 changed files with 248 additions and 4 deletions
@@ -125,6 +125,7 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
// deliberate human double-tap is always tens of milliseconds apart.
const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40;
const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500;
const STREAMING_ESCAPE_CANCEL_WINDOW_MS = 2_000;
export class InputController {
constructor(
@@ -149,6 +150,16 @@ export class InputController {
// (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
// #detectLeftDoubleTap.
#leftTapCount = 0;
// Streaming turns use a two-step Esc: first press arms this token, second press
// within the window aborts the same live assistant turn. The token is a per-turn
// sentinel minted lazily on demand and reset on every `agent_start`/`agent_end`
// (see setupKeyHandlers), so it survives `message_start`/`message_update`
// transitions inside a single turn but cannot leak across turn boundaries.
#streamingEscapeTurnSentinel: object | undefined;
#streamingEscapeArmedToken: object | undefined;
#streamingEscapeArmedUntil = 0;
#streamingEscapeTimer: NodeJS.Timeout | undefined;
#streamingEscapeSessionSubscribed = false;
// Sequential index for `local://attachment-N` references created by large-paste and
// pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
#attachmentCounter = 0;
@@ -198,8 +209,50 @@ export class InputController {
const unsubscribe = tinyTitleClient.onProgress(update);
}
#clearStreamingEscapeArm(): void {
this.#streamingEscapeArmedToken = undefined;
this.#streamingEscapeArmedUntil = 0;
if (this.#streamingEscapeTimer) {
clearTimeout(this.#streamingEscapeTimer);
this.#streamingEscapeTimer = undefined;
}
}
#handleStreamingEscape(): void {
if (!this.#streamingEscapeTurnSentinel) {
this.#streamingEscapeTurnSentinel = {};
}
const token = this.#streamingEscapeTurnSentinel;
const now = Date.now();
if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) {
this.#clearStreamingEscapeArm();
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
return;
}
this.#clearStreamingEscapeArm();
this.#streamingEscapeArmedToken = token;
this.#streamingEscapeArmedUntil = now + STREAMING_ESCAPE_CANCEL_WINDOW_MS;
this.#streamingEscapeTimer = setTimeout(() => {
if (this.#streamingEscapeArmedToken === token && Date.now() >= this.#streamingEscapeArmedUntil) {
this.#clearStreamingEscapeArm();
}
}, STREAMING_ESCAPE_CANCEL_WINDOW_MS);
this.#streamingEscapeTimer.unref?.();
this.ctx.showStatus("Press Esc again within 2s to cancel streaming.");
}
setupKeyHandlers(): void {
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
if (!this.#streamingEscapeSessionSubscribed && typeof this.ctx.session.subscribe === "function") {
this.#streamingEscapeSessionSubscribed = true;
this.ctx.session.subscribe(event => {
if (event.type === "agent_start" || event.type === "agent_end") {
this.#streamingEscapeTurnSentinel = undefined;
this.#clearStreamingEscapeArm();
}
});
}
if (!this.#focusedLeftTapListenerInstalled) {
this.#focusedLeftTapListenerInstalled = true;
this.ctx.ui.addInputListener(data => {
@@ -275,7 +328,7 @@ export class InputController {
if (this.ctx.loopModeEnabled) {
this.ctx.pauseLoop();
if (this.ctx.session.isStreaming) {
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
this.#handleStreamingEscape();
} else {
this.ctx.cancelPendingSubmission();
}
@@ -326,12 +379,13 @@ export class InputController {
this.ctx.isPythonMode = false;
this.ctx.updateEditorBorderColor();
} else if (this.ctx.session.isStreaming) {
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
this.#handleStreamingEscape();
} else if (this.ctx.editor.getText().trim()) {
// Esc with typed text clears the draft instead of (or before) any double-Esc action
this.ctx.editor.setText("");
this.ctx.ui.requestRender();
this.ctx.lastEscapeTime = 0;
this.#clearStreamingEscapeArm();
} else {
// Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
const action = settings.get("doubleEscapeAction");