Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 36 additions & 14 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions src/cli/ui/turn-interrupt.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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";
}
1 change: 1 addition & 0 deletions src/i18n/EN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <question> — ask a side question without polluting the conversation context.",
btwHeader: "≫ btw",
btwFailed: "/btw failed",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/JA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,8 @@ export const JA: TranslationSchema = {
commandFailed: "! コマンドが失敗しました",
steerInjected: "▸ ステアリングをキューに入れました — 現在のステップの後に追加されます",
steerCommandRejected: "▸ ビジー状態のターンを操作中はコマンドが無効です",
submitQueuedAfterAbort:
"▸ フォローアップをキューに入れました — 中断中のターン停止後に実行します",
btwUsage: "▸ /btw <question> — 会話コンテキストを汚さずに脇道の質問をします。",
btwHeader: "≫ btw",
btwFailed: "/btw が失敗しました",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Frage> — eine Randfrage stellen, ohne den Gesprächskontext zu verschmutzen.",
btwHeader: "≫ btw",
restoreCodeOnly: "▸ /restore ist nur im Code-Modus verfügbar",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export interface TranslationSchema {
commandFailed: string;
steerInjected: string;
steerCommandRejected: string;
submitQueuedAfterAbort: string;
btwUsage: string;
btwHeader: string;
btwFailed: string;
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ export const zhCN: TranslationSchema = {
commandFailed: "! 命令失败",
steerInjected: "▸ 已加入引导队列 — 将在当前步骤后注入",
steerCommandRejected: "▸ 当前轮次忙碌时不能提交命令,只能输入普通引导消息",
submitQueuedAfterAbort: "▸ 追问已排队 — 会在被中断轮次停止后执行",
btwUsage: "▸ /btw <问题> — 顺便问个题外话,不会写入当前会话上下文。",
btwHeader: "≫ btw",
btwFailed: "/btw 调用失败",
Expand Down
50 changes: 49 additions & 1 deletion tests/turn-interrupt.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Loading