11import * as pty from 'node-pty'
22import { BrowserWindow } from 'electron'
3+ import { execFile } from 'child_process'
34import { log } from './debug'
45import { cleanupTerminalLog } from './hooks'
56
@@ -9,10 +10,17 @@ interface PtyInstance {
910 pty : pty . IPty
1011 status : PtyStatus
1112 windowId : number
13+ isShell : boolean
14+ activityActive : boolean
15+ activityProcess ?: string
16+ hasBeenIdle : boolean
1217}
1318
19+ const SHELL_ACTIVITY_POLL_MS = 1500
20+
1421export class PtyManager {
1522 private ptys = new Map < string , PtyInstance > ( )
23+ private activityTimer : NodeJS . Timeout | null = null
1624
1725 hasTerminal ( id : string ) : boolean {
1826 return this . ptys . has ( id )
@@ -24,7 +32,8 @@ export class PtyManager {
2432 command : string ,
2533 args : string [ ] ,
2634 window : BrowserWindow ,
27- extraEnv ?: Record < string , string >
35+ extraEnv ?: Record < string , string > ,
36+ isShell : boolean = false
2837 ) : void {
2938 log ( 'pty' , `create id=${ id } cmd=${ command } args=${ JSON . stringify ( args ) } cwd=${ cwd } ` )
3039 if ( this . ptys . has ( id ) ) {
@@ -57,7 +66,10 @@ export class PtyManager {
5766 const instance : PtyInstance = {
5867 pty : ptyProcess ,
5968 status : 'processing' ,
60- windowId : window . id
69+ windowId : window . id ,
70+ isShell,
71+ activityActive : false ,
72+ hasBeenIdle : false
6173 }
6274
6375 ptyProcess . onData ( ( data : string ) => {
@@ -80,6 +92,76 @@ export class PtyManager {
8092 } )
8193
8294 this . ptys . set ( id , instance )
95+ if ( isShell ) this . ensureActivityPoller ( )
96+ }
97+
98+ private ensureActivityPoller ( ) : void {
99+ if ( this . activityTimer ) return
100+ this . activityTimer = setInterval ( ( ) => this . pollShellActivity ( ) , SHELL_ACTIVITY_POLL_MS )
101+ }
102+
103+ private pollShellActivity ( ) : void {
104+ const shellInstances : Array < [ string , PtyInstance ] > = [ ]
105+ for ( const entry of this . ptys ) {
106+ if ( entry [ 1 ] . isShell ) shellInstances . push ( entry )
107+ }
108+ if ( shellInstances . length === 0 ) {
109+ if ( this . activityTimer ) {
110+ clearInterval ( this . activityTimer )
111+ this . activityTimer = null
112+ }
113+ return
114+ }
115+
116+ execFile ( 'ps' , [ '-A' , '-o' , 'pid=,ppid=,comm=' ] , ( err , stdout ) => {
117+ if ( err ) return
118+ // Build ppid → first child comm map (walk once)
119+ const childrenByPpid = new Map < number , string [ ] > ( )
120+ for ( const line of stdout . split ( '\n' ) ) {
121+ const trimmed = line . trim ( )
122+ if ( ! trimmed ) continue
123+ const match = trimmed . match ( / ^ ( \d + ) \s + ( \d + ) \s + ( .+ ) $ / )
124+ if ( ! match ) continue
125+ const ppid = parseInt ( match [ 2 ] , 10 )
126+ const comm = match [ 3 ] . trim ( )
127+ const list = childrenByPpid . get ( ppid )
128+ if ( list ) list . push ( comm )
129+ else childrenByPpid . set ( ppid , [ comm ] )
130+ }
131+
132+ for ( const [ id , instance ] of shellInstances ) {
133+ const shellPid = instance . pty . pid
134+ if ( ! shellPid ) continue
135+
136+ const directChildren = childrenByPpid . get ( shellPid ) || [ ]
137+ const rawActive = directChildren . length > 0
138+
139+ // Arm detection only after we've seen the shell quiescent at least
140+ // once. This skips login-shell init (nvm, starship, git subprocs)
141+ // without needing a hardcoded timer.
142+ if ( ! instance . hasBeenIdle ) {
143+ if ( ! rawActive ) instance . hasBeenIdle = true
144+ continue
145+ }
146+
147+ const active = rawActive
148+ const processName = active ? directChildren [ 0 ] : undefined
149+ if (
150+ active !== instance . activityActive ||
151+ processName !== instance . activityProcess
152+ ) {
153+ instance . activityActive = active
154+ instance . activityProcess = processName
155+ const win = BrowserWindow . fromId ( instance . windowId )
156+ if ( win && ! win . isDestroyed ( ) ) {
157+ win . webContents . send ( 'terminal:shell-activity' , id , {
158+ active,
159+ processName
160+ } )
161+ }
162+ }
163+ }
164+ } )
83165 }
84166
85167 write ( id : string , data : string ) : void {
@@ -129,6 +211,10 @@ export class PtyManager {
129211 for ( const [ id ] of this . ptys ) {
130212 this . kill ( id , signal )
131213 }
214+ if ( this . activityTimer ) {
215+ clearInterval ( this . activityTimer )
216+ this . activityTimer = null
217+ }
132218 }
133219
134220 /** Get the window that owns a terminal, for routing status updates */
0 commit comments