@@ -14,6 +14,7 @@ import { updateSessionState } from '../store/session-store.js';
1414const GEMINI_TMP_DIR = join ( homedir ( ) , '.gemini' , 'tmp' ) ;
1515const POLL_INTERVAL_MS = 1500 ; // Balanced: responsive enough without causing state flicker
1616const IDLE_LOCK_MS = 2000 ; // After emitting idle, ignore terminal noise for this long
17+ const RUNNING_LOCK_MS = 5000 ; // After emitting running, don't transition to idle (tool-call gaps)
1718const RETRY_DELAY_MS = 100 ;
1819
1920// ── Path helpers ───────────────────────────────────────────────────────────────
@@ -116,6 +117,8 @@ export interface WatcherState {
116117 idleDebounceTimer ?: ReturnType < typeof setTimeout > ;
117118 /** Timestamp of last idle emit — used for idle lock (ignore terminal noise shortly after idle). */
118119 lastIdleEmitTs ?: number ;
120+ /** Timestamp of last running emit — used for running lock (ignore tool-gap idles). */
121+ lastRunningEmitTs ?: number ;
119122 _lastRotationCheck ?: number ;
120123 _terminalThinkingEmitted ?: boolean ;
121124 lastConversationStatus ?: 'running' | 'idle' | null ;
@@ -147,12 +150,12 @@ async function terminalThinkingCheck(sessionName: string, state: WatcherState):
147150 const status = detectStatus ( lines , 'gemini' ) ;
148151
149152 if ( status === 'idle' ) {
150- // Strong idle: both JSON and terminal agree on idle — skip confirm count, transition faster
153+ // Strong idle: both JSON and terminal agree on idle — skip confirm count + running lock
151154 if ( state . lastConversationStatus === 'idle' ) {
152155 state . idleConfirmCount = 2 ; // force past threshold
153156 if ( state . idleDebounceTimer ) { clearTimeout ( state . idleDebounceTimer ) ; state . idleDebounceTimer = undefined ; }
154157 state . _terminalThinkingEmitted = false ;
155- transitionState ( sessionName , state , 'idle' ) ;
158+ transitionState ( sessionName , state , 'idle' , true ) ; // force bypasses running lock
156159 return ;
157160 }
158161 // Terminal shows idle but JSON doesn't — debounce before transitioning
@@ -191,14 +194,19 @@ async function terminalThinkingCheck(sessionName: string, state: WatcherState):
191194
192195/**
193196 * Unified state transition — all idle/running changes MUST go through here.
194- * Prevents flicker by deduplicating and enforcing idle lock .
197+ * Prevents flicker by deduplicating and enforcing bidirectional locks .
195198 */
196- function transitionState ( sessionName : string , state : WatcherState , next : 'running' | 'idle' ) : void {
199+ function transitionState ( sessionName : string , state : WatcherState , next : 'running' | 'idle' , force = false ) : void {
197200 if ( state . currentState === next ) return ; // already in this state
198- // Idle lock: don't transition to running if we just emitted idle (terminal noise)
199- if ( next === 'running' && state . lastIdleEmitTs && ( Date . now ( ) - state . lastIdleEmitTs ) < IDLE_LOCK_MS ) return ;
201+ if ( ! force ) {
202+ // Idle lock: don't transition to running if we just emitted idle (terminal noise)
203+ if ( next === 'running' && state . lastIdleEmitTs && ( Date . now ( ) - state . lastIdleEmitTs ) < IDLE_LOCK_MS ) return ;
204+ // Running lock: don't transition to idle if we just emitted running (tool-call gaps)
205+ if ( next === 'idle' && state . lastRunningEmitTs && ( Date . now ( ) - state . lastRunningEmitTs ) < RUNNING_LOCK_MS ) return ;
206+ }
200207 state . currentState = next ;
201208 if ( next === 'idle' ) state . lastIdleEmitTs = Date . now ( ) ;
209+ if ( next === 'running' ) state . lastRunningEmitTs = Date . now ( ) ;
202210 timelineEmitter . emit ( sessionName , 'session.state' , { state : next } ) ;
203211 updateSessionState ( sessionName , next ) ;
204212}
0 commit comments