Skip to content

Commit ad72a0c

Browse files
egavrindevagent
andcommitted
fix(cli): repair tui cancellation
- Abort active TUI queries instead of reopening the prompt early - Render tool script outcomes and incomplete single-shot statuses - Route palette clear through the shared session reset path Co-Authored-By: devagent <devagent@egavrin>
1 parent c9fb4b9 commit ad72a0c

6 files changed

Lines changed: 288 additions & 28 deletions

File tree

packages/cli/src/main-query.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,10 @@ export function resetTuiLoop(): void {
360360
tuiLoop = null;
361361
}
362362

363+
export function abortTuiQuery(): void {
364+
tuiLoop?.abort();
365+
}
366+
363367
export function updateTuiSystemPrompt(prompt: string): void {
364368
if (tuiLoop) tuiLoop.updateSystemPrompt(prompt);
365369
}

packages/cli/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { dim, formatError, formatSessionSummary, green, isCategoryEnabled, red,
1919
import { setupConfig, setupProvider, validateOllamaAvailability } from "./main-config-setup.js";
2020
import { setupLSP } from "./main-lsp-setup.js";
2121
import {
22+
abortTuiQuery,
2223
buildInteractiveSystemPrompt,
2324
renderStdoutForSingleShot,
2425
resetTuiLoop,
@@ -324,6 +325,7 @@ async function runInteractiveMode(ctx: AgentSessionContext, seed: InteractiveRes
324325
version: getVersion(),
325326
onListSessions: () => listSessionPreviews(ctx.persistence),
326327
onQuery: createInteractiveQueryHandler(ctx, seed, approvalState),
328+
onCancelQuery: abortTuiQuery,
327329
onCycleApprovalMode: createApprovalModeCycler(ctx, seed, approvalState),
328330
onClear: createInteractiveClearHandler(ctx, seed),
329331
});

packages/cli/src/tui/App.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import {
88
App,
99
ITERATION_LIMIT_NOTICE,
1010
TranscriptView,
11+
handleCancelShortcut,
1112
renderResumeCommandOutput,
1213
renderSessionsCommandOutput,
1314
} from "./App.js";
15+
import { SingleShotApp } from "./SingleShotApp.js";
1416
import type { TranscriptNode } from "./shared.js";
1517
import { StatusBar } from "./StatusBar.js";
1618
import { useAgentLog } from "./useAgentLog.js";
@@ -60,6 +62,10 @@ class TestOutput extends Writable {
6062
readAll(): string {
6163
return this.chunks.join("");
6264
}
65+
66+
clear(): void {
67+
this.chunks.length = 0;
68+
}
6369
}
6470

6571
const instances: Array<{ unmount: () => void; cleanup: () => void }> = [];
@@ -395,6 +401,52 @@ function ToolSpecificHarness(): React.ReactElement {
395401
});
396402
}
397403

404+
function ToolScriptHarness({ success }: { readonly success: boolean }): React.ReactElement {
405+
const bus = useMemo(() => new EventBus(), []);
406+
const { transcriptNodes, startTurn, completeTurn, nextId } = useAgentLog({
407+
bus,
408+
model: "test-model",
409+
collapseFailures: true,
410+
});
411+
412+
useEffect(() => {
413+
startTurn(nextId("turn"), "Audit with script", Date.now());
414+
bus.emit("tool:before", {
415+
name: "execute_tool_script",
416+
params: { script: "print('done')" },
417+
callId: "call-script-1",
418+
});
419+
bus.emit("tool:after", {
420+
name: "execute_tool_script",
421+
callId: "call-script-1",
422+
durationMs: 20,
423+
result: {
424+
success,
425+
output: success ? "compact answer" : "",
426+
error: success ? null : "No output printed: call print(...) with the synthesized final answer.",
427+
artifacts: [],
428+
metadata: {
429+
toolScript: {
430+
toolCallCount: 3,
431+
innerOutputChars: 12000,
432+
finalOutputChars: success ? 14 : 0,
433+
durationMs: 20,
434+
timedOut: false,
435+
truncated: false,
436+
},
437+
},
438+
},
439+
});
440+
completeTurn(nextId("summary"), makeTurnSummaryPart({ iterations: 1, toolCalls: 1, cost: 0, elapsedMs: 20 }));
441+
}, [bus, completeTurn, nextId, startTurn, success]);
442+
443+
return React.createElement(TranscriptView, {
444+
showWelcome: false,
445+
transcriptNodes,
446+
model: "test-model",
447+
});
448+
}
449+
398450
function LargeCreateHarness(): React.ReactElement {
399451
const bus = useMemo(() => new EventBus(), []);
400452
const { transcriptNodes, startTurn, completeTurn, nextId } = useAgentLog({
@@ -860,6 +912,27 @@ describe("interactive transcript typed rows", () => {
860912
expect(plain).not.toContain("↵");
861913
});
862914

915+
it("renders execute_tool_script telemetry in the TUI transcript", async () => {
916+
const view = renderForTest(React.createElement(ToolScriptHarness, { success: true }));
917+
918+
await settle();
919+
920+
const output = stripAnsi(view.stdout.readAll());
921+
expect(output).toContain("execute_tool_script");
922+
expect(output).toContain("3 inner call(s), 12000 hidden chars -> 14 stdout chars");
923+
});
924+
925+
it("renders execute_tool_script failures instead of hiding them in collapsed failures", async () => {
926+
const view = renderForTest(React.createElement(ToolScriptHarness, { success: false }));
927+
928+
await settle();
929+
930+
const output = stripAnsi(view.stdout.readAll());
931+
expect(output).toContain("execute_tool_script");
932+
expect(output).toContain("No output printed");
933+
expect(output).not.toContain("1 calls failed");
934+
});
935+
863936
it("caps large snapshot diffs in condensed mode", async () => {
864937
const view = renderForTest(React.createElement(LargeCreateHarness));
865938

@@ -1061,6 +1134,47 @@ describe("interactive incomplete query status", () => {
10611134
});
10621135
});
10631136

1137+
describe("single-shot incomplete query status", () => {
1138+
it.each([
1139+
{
1140+
name: "empty model response",
1141+
status: "empty_response",
1142+
notice: "Model returned no final response. Type /continue to retry",
1143+
},
1144+
{
1145+
name: "aborted run",
1146+
status: "aborted",
1147+
notice: "Run stopped before completion. Type /continue to retry",
1148+
},
1149+
] as const)("surfaces an $name as an incomplete turn", async ({ status, notice }) => {
1150+
let finalOutput: string | null = null;
1151+
const view = renderForTest(
1152+
React.createElement(SingleShotApp, {
1153+
bus: new EventBus(),
1154+
query: "single shot",
1155+
model: "test-model",
1156+
onFinalOutput: (text) => {
1157+
finalOutput = text;
1158+
},
1159+
onQuery: async () => ({
1160+
iterations: 1,
1161+
toolCalls: 0,
1162+
lastText: null,
1163+
status,
1164+
}),
1165+
}),
1166+
);
1167+
1168+
await waitForRenders();
1169+
1170+
const output = stripAnsi(view.stdout.readAll());
1171+
expect(output).toContain("╭─ error single shot");
1172+
expect(output).toContain(notice);
1173+
expect(output).not.toContain("╭─ completed single shot");
1174+
expect(finalOutput).toBeNull();
1175+
});
1176+
});
1177+
10641178
describe("interactive prompt commands", () => {
10651179
it("keeps prompt scrollback stable after idle slash-command output", async () => {
10661180
const view = renderForTest(
@@ -1088,6 +1202,116 @@ describe("interactive prompt commands", () => {
10881202
expect(countPromptPlaceholders(output)).toBe(2);
10891203
expect(output).toContain("Commands: /clear (reset)");
10901204
});
1205+
1206+
it("keeps the active run open while Ctrl+C requests cancellation", () => {
1207+
let cancelCalls = 0;
1208+
const runningStates: boolean[] = [];
1209+
const cancelStates: boolean[] = [];
1210+
const spinnerMessages: Array<string | undefined> = [];
1211+
const appendIds: string[] = [];
1212+
1213+
handleCancelShortcut({
1214+
appendStandalonePart: (id) => {
1215+
appendIds.push(id);
1216+
},
1217+
cancelPending: false,
1218+
exit: () => {},
1219+
handleApproval: () => {},
1220+
nextId: (prefix) => `${prefix}-1`,
1221+
onCancelQuery: () => {
1222+
cancelCalls += 1;
1223+
},
1224+
pendingApproval: null,
1225+
running: true,
1226+
setCancelPending: (value) => {
1227+
cancelStates.push(typeof value === "function" ? value(false) : value);
1228+
},
1229+
setRunning: (value) => {
1230+
runningStates.push(typeof value === "function" ? value(true) : value);
1231+
},
1232+
setShowCommandPalette: () => {},
1233+
setSpinnerMessage: (value) => {
1234+
spinnerMessages.push(typeof value === "function" ? value(undefined) : value);
1235+
},
1236+
showCommandPalette: false,
1237+
});
1238+
1239+
expect(cancelCalls).toBe(1);
1240+
expect(cancelStates).toEqual([true]);
1241+
expect(spinnerMessages).toEqual(["Cancelling..."]);
1242+
expect(runningStates).toEqual([]);
1243+
expect(appendIds).toEqual([]);
1244+
1245+
handleCancelShortcut({
1246+
appendStandalonePart: () => {},
1247+
cancelPending: true,
1248+
exit: () => {},
1249+
handleApproval: () => {},
1250+
nextId: (prefix) => `${prefix}-1`,
1251+
onCancelQuery: () => {
1252+
cancelCalls += 1;
1253+
},
1254+
pendingApproval: null,
1255+
running: true,
1256+
setCancelPending: () => {},
1257+
setRunning: () => {},
1258+
setShowCommandPalette: () => {},
1259+
setSpinnerMessage: () => {},
1260+
showCommandPalette: false,
1261+
});
1262+
1263+
expect(cancelCalls).toBe(1);
1264+
});
1265+
1266+
it("uses the same clear behavior from the command palette as /clear", async () => {
1267+
let clearCalls = 0;
1268+
const bus = new EventBus();
1269+
const view = renderForTest(
1270+
React.createElement(App, {
1271+
bus,
1272+
model: "test-model",
1273+
approvalMode: "autopilot",
1274+
cwd: "/tmp/devagent",
1275+
onClear: () => {
1276+
clearCalls += 1;
1277+
},
1278+
onCycleApprovalMode: () => {},
1279+
onQuery: async () => ({
1280+
iterations: 0,
1281+
toolCalls: 0,
1282+
lastText: null,
1283+
status: "success" as const,
1284+
}),
1285+
}),
1286+
);
1287+
1288+
bus.emit("iteration:start", {
1289+
iteration: 7,
1290+
maxIterations: 10,
1291+
estimatedTokens: 42_000,
1292+
maxContextTokens: 100_000,
1293+
});
1294+
bus.emit("cost:update", {
1295+
inputTokens: 42_000,
1296+
outputTokens: 100,
1297+
totalCost: 0.1234,
1298+
model: "test-model",
1299+
});
1300+
await waitForRenders();
1301+
view.stdout.clear();
1302+
view.stdin.write("\x0b");
1303+
await settle();
1304+
view.stdin.write("\r");
1305+
await waitForRenders();
1306+
1307+
const output = stripAnsi(view.stdout.readAll());
1308+
const clearedFrame = output.slice(output.lastIndexOf("Context cleared."));
1309+
expect(clearCalls).toBe(1);
1310+
expect(output).toContain("Context cleared.");
1311+
expect(clearedFrame).not.toContain("$0.123");
1312+
expect(clearedFrame).not.toContain("42k/100k");
1313+
expect(clearedFrame).not.toContain("iter 7/10");
1314+
});
10911315
});
10921316

10931317
describe("interactive prompt editing and status bar", () => {

0 commit comments

Comments
 (0)