diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 5d5c6daec..acd439f09 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -208,7 +208,7 @@ import { VerboseContext } from "./state/verbose-context.js"; import { ThemeProvider } from "./theme/context.js"; import { FG, SURFACE, type ThemeName, listThemeNames } from "./theme/tokens.js"; import { TickerProvider, useSlowTick } from "./ticker.js"; -import { handleTurnInterrupt } from "./turn-interrupt.js"; +import { decideBusySubmit, handleTurnInterrupt } from "./turn-interrupt.js"; import { codeUndoContextMessage, codeUndoInfo } from "./undo-context.js"; import { useCompletionPickers } from "./useCompletionPickers.js"; import { useEditHistory } from "./useEditHistory.js"; @@ -2266,16 +2266,29 @@ function AppInner({ }; }, submitPrompt: (text: string): SubmitResult => { - if (busyRef.current) { - if (isBusyPromptCommand(text)) { + const busySubmit = decideBusySubmit(text, { + busy: busyRef.current, + submitting: submittingRef.current, + aborted: abortedThisTurn.current, + isCommand: isBusyPromptCommand, + }); + if (busySubmit !== "idle") { + if (busySubmit === "reject-command") { return { accepted: false, reason: "commands are disabled while steering a busy turn", }; } - // Steer into current turn instead of rejecting - loop.steer(text); - return { accepted: true, reason: "steered" }; + if (busySubmit === "queue-after-abort") { + setQueuedSubmit(text); + return { accepted: true, reason: "queued-after-abort" }; + } + if (busySubmit === "steer") { + // Steer into current turn instead of rejecting. + loop.steer(text); + return { accepted: true, reason: "steered" }; + } + return { accepted: false, reason: "loop is busy" }; } const fn = handleSubmitRef.current; if (!fn) return { accepted: false, reason: "TUI not ready" }; @@ -2814,17 +2827,26 @@ function AppInner({ if (incoming.handled) { return; } - if (busy || submittingRef.current) { - if (busy && text.trim()) { - if (isBusyPromptCommand(text)) { - log.pushInfo(t("app.steerCommandRejected")); - return; - } + const busySubmit = decideBusySubmit(text, { + busy, + submitting: submittingRef.current, + aborted: abortedThisTurn.current, + isCommand: isBusyPromptCommand, + }); + if (busySubmit !== "idle") { + if (busySubmit === "reject-command") { + log.pushInfo(t("app.steerCommandRejected")); + } else if (busySubmit === "steer" || busySubmit === "queue-after-abort") { setInput(""); resetCursor(); pushHistory(text); - loop.steer(text); - log.pushInfo(t("app.steerInjected")); + if (busySubmit === "queue-after-abort") { + setQueuedSubmit(text); + log.pushInfo(t("app.submitQueuedAfterAbort")); + } else { + loop.steer(text); + log.pushInfo(t("app.steerInjected")); + } log.pushInfo(text, "ghost"); } return; diff --git a/src/cli/ui/turn-interrupt.ts b/src/cli/ui/turn-interrupt.ts index de20bca9a..be30924db 100644 --- a/src/cli/ui/turn-interrupt.ts +++ b/src/cli/ui/turn-interrupt.ts @@ -1,5 +1,18 @@ export type TurnInterruptKey = "escape" | "ctrl-c"; export type TurnInterruptOutcome = "aborted" | "already-aborted" | "stopped-loop" | "idle" | "quit"; +export type BusySubmitDecision = + | "idle" + | "ignore" + | "reject-command" + | "queue-after-abort" + | "steer"; + +export interface BusySubmitState { + busy: boolean; + submitting: boolean; + aborted: boolean; + isCommand: (text: string) => boolean; +} export interface TurnInterruptController { turnActiveRef: { readonly current: boolean }; @@ -44,3 +57,13 @@ export function handleTurnInterrupt( return "idle"; } + +export function decideBusySubmit(text: string, state: BusySubmitState): BusySubmitDecision { + if (!state.busy && !state.submitting) return "idle"; + const trimmed = text.trim(); + if (!trimmed) return "ignore"; + if (state.isCommand(trimmed)) return "reject-command"; + if (state.aborted) return "queue-after-abort"; + if (!state.busy) return "ignore"; + return "steer"; +} diff --git a/src/i18n/EN.ts b/src/i18n/EN.ts index fe3a50648..c9f81b737 100644 --- a/src/i18n/EN.ts +++ b/src/i18n/EN.ts @@ -648,6 +648,7 @@ export const EN: TranslationSchema = { commandFailed: "! command failed", steerInjected: "▸ steering queued — will be added after the current step", steerCommandRejected: "▸ commands are disabled while steering a busy turn", + submitQueuedAfterAbort: "▸ follow-up queued — will run after the interrupted turn stops", btwUsage: "▸ /btw — ask a side question without polluting the conversation context.", btwHeader: "≫ btw", btwFailed: "/btw failed", diff --git a/src/i18n/JA.ts b/src/i18n/JA.ts index 91c4fd830..09d3596a6 100644 --- a/src/i18n/JA.ts +++ b/src/i18n/JA.ts @@ -736,6 +736,8 @@ export const JA: TranslationSchema = { commandFailed: "! コマンドが失敗しました", steerInjected: "▸ ステアリングをキューに入れました — 現在のステップの後に追加されます", steerCommandRejected: "▸ ビジー状態のターンを操作中はコマンドが無効です", + submitQueuedAfterAbort: + "▸ フォローアップをキューに入れました — 中断中のターン停止後に実行します", btwUsage: "▸ /btw — 会話コンテキストを汚さずに脇道の質問をします。", btwHeader: "≫ btw", btwFailed: "/btw が失敗しました", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index fa7e57e13..af4abdd2b 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -641,6 +641,8 @@ export const de: TranslationSchema = { verboseOff: "▸ Ausführlicher Modus aus — head/tail-Kürzung wiederhergestellt", steerInjected: "▸ Steuerung in Warteschlange — wird nach dem aktuellen Schritt hinzugefügt", steerCommandRejected: "▸ Befehle sind deaktiviert, während ein Turn gesteuert wird", + submitQueuedAfterAbort: + "▸ Folgefrage in Warteschlange — läuft, sobald der unterbrochene Turn stoppt", btwUsage: "▸ /btw — eine Randfrage stellen, ohne den Gesprächskontext zu verschmutzen.", btwHeader: "≫ btw", restoreCodeOnly: "▸ /restore ist nur im Code-Modus verfügbar", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 19732ae33..6b56b6720 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -201,6 +201,7 @@ export interface TranslationSchema { commandFailed: string; steerInjected: string; steerCommandRejected: string; + submitQueuedAfterAbort: string; btwUsage: string; btwHeader: string; btwFailed: string; diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index f3f03151e..59b38cb35 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -625,6 +625,7 @@ export const zhCN: TranslationSchema = { commandFailed: "! 命令失败", steerInjected: "▸ 已加入引导队列 — 将在当前步骤后注入", steerCommandRejected: "▸ 当前轮次忙碌时不能提交命令,只能输入普通引导消息", + submitQueuedAfterAbort: "▸ 追问已排队 — 会在被中断轮次停止后执行", btwUsage: "▸ /btw <问题> — 顺便问个题外话,不会写入当前会话上下文。", btwHeader: "≫ btw", btwFailed: "/btw 调用失败", diff --git a/tests/turn-interrupt.test.ts b/tests/turn-interrupt.test.ts index 08d760c37..08f2c240f 100644 --- a/tests/turn-interrupt.test.ts +++ b/tests/turn-interrupt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { handleTurnInterrupt } from "../src/cli/ui/turn-interrupt.js"; +import { decideBusySubmit, handleTurnInterrupt } from "../src/cli/ui/turn-interrupt.js"; describe("handleTurnInterrupt", () => { it("aborts an active Ctrl+C turn without quitting the process", () => { @@ -90,3 +90,51 @@ describe("handleTurnInterrupt", () => { expect(quitProcess).not.toHaveBeenCalled(); }); }); + +describe("decideBusySubmit", () => { + const isCommand = (text: string) => text.startsWith("/"); + + it("steers ordinary text into an active non-aborted turn", () => { + expect( + decideBusySubmit("please adjust that", { + busy: true, + submitting: true, + aborted: false, + isCommand, + }), + ).toBe("steer"); + }); + + it("queues ordinary text once the active turn has already been interrupted", () => { + expect( + decideBusySubmit("actually do this instead", { + busy: true, + submitting: true, + aborted: true, + isCommand, + }), + ).toBe("queue-after-abort"); + }); + + it("queues interrupted-turn text even if React busy state already dropped", () => { + expect( + decideBusySubmit("actually do this instead", { + busy: false, + submitting: true, + aborted: true, + isCommand, + }), + ).toBe("queue-after-abort"); + }); + + it("still rejects commands while busy", () => { + expect( + decideBusySubmit("/new", { + busy: true, + submitting: true, + aborted: true, + isCommand, + }), + ).toBe("reject-command"); + }); +});