44 */
55
66import { EventEmitter } from "node:events" ;
7- import { createCliRenderer , type CliRenderer } from "@opentui/core" ;
7+ import {
8+ createCliRenderer ,
9+ type CliRenderer ,
10+ type ProgramStatus ,
11+ } from "@opentui/core" ;
812
913import { createLiveSessionPort } from "./live-session-port.js" ;
1014import { checkWidthContract , widthContractNotice } from "./width-contract.js" ;
@@ -18,6 +22,7 @@ import type { ShellOutputFeed } from "../session/shell-output-feed.js";
1822import { openAddProviderOverlay , openModelPickerOverlay } from "./overlays.js" ;
1923import { wireGates } from "./gate-wire.js" ;
2024import { createSystemClipboard } from "./system-clipboard.js" ;
25+ import { programStatusForChrome } from "./program-status.js" ;
2126import {
2227 AGENTS_PANEL_LINGER_MS ,
2328 agentsChromeNeedsSticky ,
@@ -260,6 +265,13 @@ export interface ProductHostConfig {
260265 * resends wheel input as arrow keys; with it, drag-select is OpenTUI-owned
261266 * and auto-copies on mouse-up (Alt+M returns to native selection). */
262267 readonly useMouse ?: boolean ;
268+ /** OSC 7501 sink. Default calls `renderer.setProgramStatus` and ignores a
269+ * false return (terminal inactive, output full, or destroyed). */
270+ readonly programStatus ?: ProgramStatusPort ;
271+ }
272+
273+ export interface ProgramStatusPort {
274+ set ( status : ProgramStatus ) : boolean ;
263275}
264276
265277export interface ProductHost {
@@ -384,17 +396,41 @@ export async function mountProductHost(
384396 const agentsPanelLingerMs =
385397 config . agentsPanelLingerMs ?? AGENTS_PANEL_LINGER_MS ;
386398 let chromeState : ChromeLiveState | null = config . chrome ?? null ;
399+ const programStatus : ProgramStatusPort = config . programStatus ?? {
400+ set : ( status ) => {
401+ try {
402+ return renderer . setProgramStatus ( status ) ;
403+ } catch {
404+ return false ;
405+ }
406+ } ,
407+ } ;
408+ let openGates = 0 ;
409+ let lastProgramStatus = "" ;
410+ const publishProgramStatus = ( ) : void => {
411+ const status = programStatusForChrome ( {
412+ tasks : chromeState ?. task ,
413+ agents : chromeState ?. agents ,
414+ stalled : chromeState ?. stalled ?? bridge . isTurnStalled ( ) ,
415+ gateOpen : openGates > 0 ,
416+ } ) ;
417+ const key = JSON . stringify ( status ) ;
418+ if ( key === lastProgramStatus ) return ;
419+ if ( programStatus . set ( status ) ) lastProgramStatus = key ;
420+ } ;
387421 const paintChromeZones = ( ) : void => {
388422 if ( chromeState === null ) {
389423 setChromeZones ( shell , { task : null , agents : null } ) ;
390- return ;
424+ } else {
425+ setChromeZones (
426+ shell ,
427+ formatChromeZones ( chromeState , Date . now ( ) , agentsPanelLingerMs ) ,
428+ ) ;
391429 }
392- setChromeZones (
393- shell ,
394- formatChromeZones ( chromeState , Date . now ( ) , agentsPanelLingerMs ) ,
395- ) ;
430+ publishProgramStatus ( ) ;
396431 } ;
397432 if ( chromeState !== null ) paintChromeZones ( ) ;
433+ else publishProgramStatus ( ) ;
398434
399435 let disposed = false ;
400436 let resolveExit : ( ( ) => void ) | undefined ;
@@ -443,6 +479,8 @@ export async function mountProductHost(
443479 // formatAgentsPanel returns null after linger.
444480 if ( stickyNeeded || stickyWasNeeded ) {
445481 paintChromeZones ( ) ;
482+ } else {
483+ publishProgramStatus ( ) ;
446484 }
447485 stickyWasNeeded = stickyNeeded ;
448486 } catch {
@@ -467,6 +505,11 @@ export async function mountProductHost(
467505 config . eventEmitter . off ( "compaction" , onCompaction ) ;
468506 config . eventEmitter . off ( "workflow" , onWorkflow ) ;
469507 bridge . dispose ( ) ;
508+ try {
509+ programStatus . set ( { state : "clear" } ) ;
510+ } catch {
511+ // renderer already gone
512+ }
470513 // Cancels any flash still counting down: its expiry repaints into a
471514 // destroyed text buffer after teardown.
472515 setStatusFlash ( shell , null ) ;
@@ -559,8 +602,16 @@ export async function mountProductHost(
559602 let disposeGates : ( ) => void ;
560603 try {
561604 disposeGates = wireGates ( config . eventEmitter , shell , {
562- onGateOpened : ( ) => bridge . gateOpened ( ) ,
563- onGateClosed : ( ) => bridge . gateClosed ( ) ,
605+ onGateOpened : ( ) => {
606+ openGates += 1 ;
607+ bridge . gateOpened ( ) ;
608+ publishProgramStatus ( ) ;
609+ } ,
610+ onGateClosed : ( ) => {
611+ openGates = Math . max ( 0 , openGates - 1 ) ;
612+ bridge . gateClosed ( ) ;
613+ publishProgramStatus ( ) ;
614+ } ,
564615 onPrimaryOperatorAdmitted : ( event ) =>
565616 setOperatorInputRequiredGate ( shell , event ) ,
566617 onPrimaryOperatorSettled : ( event ) =>
0 commit comments