11import { afterEach , describe , expect , test } from "bun:test" ;
2+ import { EventEmitter } from "node:events" ;
23import { rgbToHex , type CapturedSpan , type RGBA } from "@opentui/core" ;
34
5+ import type { PendingAskWake } from "../subagent/fleet-report.js" ;
46import {
57 PRIMARY_ASK_OPERATOR_SOURCE ,
68 type OperatorGateEvent ,
79} from "./gate-events.js" ;
10+ import { wireGates } from "./gate-wire.js" ;
811import { withTestRenderer , type Harness } from "./harness.js" ;
912import { BORDER } from "./prompt-border.js" ;
13+ import { repaintShellForTheme } from "./runner/settings.js" ;
1014import {
15+ appendStreamRow ,
1116 clearOperatorInputRequired ,
17+ paintChrome ,
1218 relayout ,
1319 setOperatorInputRequiredGate ,
20+ setStatusFlash ,
21+ setWorkerWaitAsks ,
1422} from "./shell/chrome.js" ;
1523import { createAppShell } from "./shell/index.js" ;
1624import type { AppShell } from "./shell/internals.js" ;
@@ -28,6 +36,29 @@ function gate(id: string, question: string): OperatorGateEvent {
2836 } ;
2937}
3038
39+ const WAITING_WORKER : PendingAskWake = {
40+ sessionId : "sess-builder" ,
41+ agentId : "builder" ,
42+ description : "copy assets" ,
43+ question : "Which destination path should I use?" ,
44+ questionId : "q1" ,
45+ } ;
46+
47+ /**
48+ * Wire an emitter to the shell the way mountProductHost does, so gate events
49+ * reach the strip through the real admission/settlement hooks.
50+ */
51+ function wirePrimaryHost ( shell : AppShell , emitter : EventEmitter ) : ( ) => void {
52+ return wireGates ( emitter , shell , {
53+ onGateOpened : ( ) => undefined ,
54+ onGateClosed : ( ) => undefined ,
55+ onPrimaryOperatorAdmitted : ( event ) =>
56+ setOperatorInputRequiredGate ( shell , event ) ,
57+ onPrimaryOperatorSettled : ( event ) =>
58+ clearOperatorInputRequired ( shell , event . id ) ,
59+ } ) ;
60+ }
61+
3162async function withShell (
3263 fn : ( shell : AppShell , h : Harness ) => Promise < void > ,
3364 size = { columns : 120 , rows : 30 } ,
@@ -148,4 +179,164 @@ describe("operator input required strip", () => {
148179 } ) ;
149180 } ) ;
150181 }
182+
183+ test ( "a live theme switch repaints the strip in the new palette" , async ( ) => {
184+ setTheme ( "corbits-dark" ) ;
185+ await withShell ( async ( shell , h ) => {
186+ setOperatorInputRequiredGate (
187+ shell ,
188+ gate ( "one" , "Which branch should I use?" ) ,
189+ ) ;
190+ await frameRows ( h ) ;
191+ setTheme ( "corbits-light" ) ;
192+ repaintShellForTheme ( shell ) ;
193+ await frameRows ( h ) ;
194+ const label = spanWith ( h , "INPUT REQUIRED" ) ;
195+ expect ( label === undefined ? null : hex ( label . fg ) ) . toBe (
196+ UI . action . toLowerCase ( ) ,
197+ ) ;
198+ } ) ;
199+ } ) ;
200+
201+ test ( "the strip persists through transcript growth, scrolling, typing, flashes and redraws" , async ( ) => {
202+ await withShell ( async ( shell , h ) => {
203+ setOperatorInputRequiredGate (
204+ shell ,
205+ gate ( "one" , "Which branch should I use?" ) ,
206+ ) ;
207+ const before = await frameRows ( h ) ;
208+ const painted = before [ inputRow ( before ) ] ;
209+ for ( let i = 0 ; i < 60 ; i ++ ) {
210+ appendStreamRow ( shell , { role : "assistant" , text : `row ${ i } ` } ) ;
211+ }
212+ shell . transcript . scrollTo ( 0 ) ;
213+ shell . prompt . value = "hold on, let me check the path" ;
214+ setStatusFlash ( shell , "copied" ) ;
215+ paintChrome ( shell ) ;
216+ paintChrome ( shell , { force : true } ) ;
217+ relayout ( shell ) ;
218+ const after = await frameRows ( h ) ;
219+ expect ( after [ inputRow ( after ) ] ) . toBe ( painted ) ;
220+ expect ( shell . layout . heights . input_required ) . toBe ( 1 ) ;
221+ } ) ;
222+ } ) ;
223+
224+ test ( "the strip stays visible while an operator modal is open" , async ( ) => {
225+ await withShell ( async ( shell , h ) => {
226+ const emitter = new EventEmitter ( ) ;
227+ const disposeGates = wirePrimaryHost ( shell , emitter ) ;
228+ try {
229+ emitter . emit ( "operator.gate" , {
230+ id : "one" ,
231+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
232+ question : "Which branch should I use?" ,
233+ options : [ "main" , "dev" ] ,
234+ resolve : ( ) => undefined ,
235+ } ) ;
236+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
237+ const rows = await frameRows ( h ) ;
238+ expect ( shell . layout . heights . input_required ) . toBe ( 1 ) ;
239+ expect ( rows . some ( ( row ) => row . includes ( "INPUT REQUIRED" ) ) ) . toBe ( true ) ;
240+ } finally {
241+ disposeGates ( ) ;
242+ }
243+ } ) ;
244+ } ) ;
245+
246+ test ( "settling one of two leaves the other strip in place" , async ( ) => {
247+ await withShell ( async ( shell , h ) => {
248+ setOperatorInputRequiredGate (
249+ shell ,
250+ gate ( "one" , "Which branch should I use?" ) ,
251+ ) ;
252+ setOperatorInputRequiredGate (
253+ shell ,
254+ gate ( "two" , "Should I include tests?" ) ,
255+ ) ;
256+ await frameRows ( h ) ;
257+ clearOperatorInputRequired ( shell , "one" ) ;
258+ const rows = await frameRows ( h ) ;
259+ expect ( rows . some ( ( row ) => row . includes ( "INPUT REQUIRED" ) ) ) . toBe ( true ) ;
260+ expect ( rows . some ( ( row ) => row . includes ( "Should I include tests?" ) ) ) . toBe (
261+ true ,
262+ ) ;
263+ expect ( shell . layout . heights . input_required ) . toBe ( 1 ) ;
264+ clearOperatorInputRequired ( shell , "two" ) ;
265+ const restored = await frameRows ( h ) ;
266+ expect ( shell . layout . heights . input_required ) . toBe ( 0 ) ;
267+ expect ( restored . some ( ( row ) => row . includes ( "INPUT REQUIRED" ) ) ) . toBe (
268+ false ,
269+ ) ;
270+ } ) ;
271+ } ) ;
272+
273+ test ( "WORKER WAITING and INPUT REQUIRED sit together with correct order" , async ( ) => {
274+ await withShell ( async ( shell , h ) => {
275+ setWorkerWaitAsks ( shell , [ WAITING_WORKER ] ) ;
276+ setOperatorInputRequiredGate (
277+ shell ,
278+ gate ( "one" , "Which branch should I use?" ) ,
279+ ) ;
280+ const rows = await frameRows ( h ) ;
281+ const worker = rows . findIndex ( ( row ) => row . includes ( "WORKER WAITING" ) ) ;
282+ const input = rows . findIndex ( ( row ) => row . includes ( "INPUT REQUIRED" ) ) ;
283+ expect ( worker ) . toBeGreaterThanOrEqual ( 0 ) ;
284+ expect ( input ) . toBeGreaterThan ( worker ) ;
285+ expect ( rows . filter ( ( row ) => row . includes ( "WORKER WAITING" ) ) ) . toHaveLength (
286+ 1 ,
287+ ) ;
288+ expect ( rows . filter ( ( row ) => row . includes ( "INPUT REQUIRED" ) ) ) . toHaveLength (
289+ 1 ,
290+ ) ;
291+ const w = shell . layout . regions . worker_wait ;
292+ const i = shell . layout . regions . input_required ;
293+ const p = shell . layout . regions . prompt ;
294+ expect ( w !== undefined && i !== undefined && p !== undefined ) . toBe ( true ) ;
295+ if ( w === undefined || i === undefined || p === undefined ) return ;
296+ expect ( w . y ) . toBeLessThan ( i . y ) ;
297+ expect ( i . y + i . height ) . toBe ( p . y ) ;
298+ } ) ;
299+ } ) ;
300+
301+ test ( "MCP-trust and permission gates never raise an INPUT REQUIRED row" , async ( ) => {
302+ await withShell ( async ( shell , h ) => {
303+ const emitter = new EventEmitter ( ) ;
304+ const disposeGates = wirePrimaryHost ( shell , emitter ) ;
305+ try {
306+ emitter . emit ( "permission.gate" , {
307+ id : "perm-1" ,
308+ request : {
309+ tool : "run_shell" ,
310+ action : "Run shell command" ,
311+ subject : "bun test" ,
312+ scopes : [ ] ,
313+ } ,
314+ resolve : ( ) => undefined ,
315+ } ) ;
316+ emitter . emit ( "operator.gate" , {
317+ id : "mcp-trust" ,
318+ question : "Trust this local MCP server?" ,
319+ options : [ "Trust and connect" , "Cancel" ] ,
320+ resolve : ( ) => undefined ,
321+ } ) ;
322+ const rows = await frameRows ( h ) ;
323+ expect ( shell . overlayKind ) . toBe ( "permissions" ) ;
324+ expect ( shell . layout . heights . input_required ) . toBe ( 0 ) ;
325+ expect ( rows . some ( ( row ) => row . includes ( "INPUT REQUIRED" ) ) ) . toBe ( false ) ;
326+ } finally {
327+ disposeGates ( ) ;
328+ }
329+ } ) ;
330+ } ) ;
331+
332+ test ( "worker ask_director waiting and OAuth-style notices never raise an INPUT REQUIRED row" , async ( ) => {
333+ await withShell ( async ( shell , h ) => {
334+ setWorkerWaitAsks ( shell , [ WAITING_WORKER ] ) ;
335+ setStatusFlash ( shell , "OAuth token refresh needed" ) ;
336+ const rows = await frameRows ( h ) ;
337+ expect ( rows . some ( ( row ) => row . includes ( "WORKER WAITING" ) ) ) . toBe ( true ) ;
338+ expect ( shell . layout . heights . input_required ) . toBe ( 0 ) ;
339+ expect ( rows . some ( ( row ) => row . includes ( "INPUT REQUIRED" ) ) ) . toBe ( false ) ;
340+ } ) ;
341+ } ) ;
151342} ) ;
0 commit comments