@@ -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" ;
1416import type { TranscriptNode } from "./shared.js" ;
1517import { StatusBar } from "./StatusBar.js" ;
1618import { 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
6571const 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+
398450function 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+
10641178describe ( "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
10931317describe ( "interactive prompt editing and status bar" , ( ) => {
0 commit comments