@@ -18,6 +18,13 @@ const POOL = "_pi-mux";
1818type Scope = "cwd" | "all" ;
1919type Mode = "list" | "confirm-kill" | "confirm-kill-all" ;
2020
21+ interface TreeRow {
22+ hb : heartbeat . Heartbeat ;
23+ depth : number ;
24+ isLast : boolean ;
25+ ancestorContinues : boolean [ ] ;
26+ }
27+
2128export interface MuxMenuOptions {
2229 tui : TUI ;
2330 theme : Theme ;
@@ -31,7 +38,7 @@ export class MuxMenu extends Container implements Focusable {
3138 private scope : Scope = "cwd" ;
3239 private mode : Mode = "list" ;
3340 private selectedIndex = 0 ;
34- private rows : heartbeat . Heartbeat [ ] = [ ] ;
41+ private rows : TreeRow [ ] = [ ] ;
3542 private pendingConfirmMessage = "" ;
3643 private readonly tui : TUI ;
3744 private readonly theme : Theme ;
@@ -82,14 +89,13 @@ export class MuxMenu extends Container implements Focusable {
8289 const all = heartbeat . listActive ( ) ;
8390 const inScope =
8491 this . scope === "all" ? all : all . filter ( ( e ) => e . cwd === this . currentCwd ) ;
85- inScope . sort ( ( a , b ) => a . paneId . localeCompare ( b . paneId ) ) ;
86- this . rows = inScope ;
92+ this . rows = buildHeartbeatTree ( inScope ) ;
8793 if ( this . selectedIndex >= this . rows . length ) {
8894 this . selectedIndex = Math . max ( 0 , this . rows . length - 1 ) ;
8995 }
9096 }
9197
92- getRows ( ) : heartbeat . Heartbeat [ ] {
98+ getRows ( ) : TreeRow [ ] {
9399 return this . rows ;
94100 }
95101 getPoolPanes ( ) : Set < string > {
@@ -115,11 +121,13 @@ export class MuxMenu extends Container implements Focusable {
115121 }
116122
117123 private killTargets ( ) : heartbeat . Heartbeat [ ] {
118- return this . rows . filter ( ( r ) => r . paneId !== this . currentPaneId ) ;
124+ return this . rows
125+ . map ( ( r ) => r . hb )
126+ . filter ( ( hb ) => hb . paneId !== this . currentPaneId ) ;
119127 }
120128
121129 private selectedRow ( ) : heartbeat . Heartbeat | undefined {
122- return this . rows [ this . selectedIndex ] ;
130+ return this . rows [ this . selectedIndex ] ?. hb ;
123131 }
124132
125133 handleInput ( data : string ) : void {
@@ -265,12 +273,15 @@ class MuxList implements Component {
265273 lines . push ( theme . fg ( "muted" , " (no pi sessions tracked)" ) ) ;
266274 } else {
267275 for ( let i = 0 ; i < rows . length ; i ++ ) {
268- const row = rows [ i ] ! ;
276+ const node = rows [ i ] ! ;
277+ const row = node . hb ;
269278 const isCurrent = row . paneId === currentPaneId ;
270279 const isSelected = i === selectedIndex ;
271280 const inPool = poolPanes . has ( row . paneId ) ;
272281
273282 const cursor = isSelected ? theme . fg ( "accent" , "› " ) : " " ;
283+ const prefixPlain = buildTreePrefix ( node ) ;
284+ const prefixStyled = theme . fg ( "dim" , prefixPlain ) ;
274285
275286 const rawName = row . label ?. trim ( ) || "(empty session)" ;
276287 const normalizedName = rawName . replace ( / [ \x00 - \x1f \x7f ] / g, " " ) . trim ( ) ;
@@ -286,12 +297,13 @@ class MuxList implements Component {
286297 const cwdShort = row . cwd . replace ( process . env . HOME ?? "" , "~" ) ;
287298
288299 const cursorWidth = visibleWidth ( cursor ) ;
300+ const prefixWidth = visibleWidth ( prefixPlain ) ;
289301 const tagWidth = visibleWidth ( tagPlain ) ;
290302 const cwdWidth = visibleWidth ( cwdShort ) ;
291303 const minGap = 6 ;
292304 const availableForName = Math . max (
293305 5 ,
294- width - cursorWidth - 1 - tagWidth - minGap - cwdWidth ,
306+ width - cursorWidth - prefixWidth - 1 - tagWidth - minGap - cwdWidth ,
295307 ) ;
296308 const truncatedName = truncateToWidth (
297309 normalizedName ,
@@ -303,7 +315,7 @@ class MuxList implements Component {
303315 : truncatedName ;
304316 const boldedName = isSelected ? theme . bold ( styledName ) : styledName ;
305317
306- const leftPart = `${ cursor } ${ boldedName } ${ tagStyled } ` ;
318+ const leftPart = `${ cursor } ${ prefixStyled } ${ boldedName } ${ tagStyled } ` ;
307319 const leftWidth = visibleWidth ( leftPart ) ;
308320 const spacing = Math . max ( minGap , width - leftWidth - cwdWidth ) ;
309321 let line = leftPart + " " . repeat ( spacing ) + theme . fg ( "dim" , cwdShort ) ;
@@ -316,6 +328,64 @@ class MuxList implements Component {
316328 }
317329}
318330
331+ interface TreeNode {
332+ hb : heartbeat . Heartbeat ;
333+ children : TreeNode [ ] ;
334+ }
335+
336+ function buildHeartbeatTree ( hbs : heartbeat . Heartbeat [ ] ) : TreeRow [ ] {
337+ const byPath = new Map < string , TreeNode > ( ) ;
338+ for ( const hb of hbs ) {
339+ byPath . set ( hb . sessionFile , { hb, children : [ ] } ) ;
340+ }
341+ const roots : TreeNode [ ] = [ ] ;
342+ for ( const hb of hbs ) {
343+ const node = byPath . get ( hb . sessionFile ) ! ;
344+ const parent = hb . parentSessionFile
345+ ? byPath . get ( hb . parentSessionFile )
346+ : undefined ;
347+ if ( parent ) parent . children . push ( node ) ;
348+ else roots . push ( node ) ;
349+ }
350+
351+ const sortNodes = ( nodes : TreeNode [ ] ) => {
352+ nodes . sort ( ( a , b ) => a . hb . paneId . localeCompare ( b . hb . paneId ) ) ;
353+ for ( const node of nodes ) sortNodes ( node . children ) ;
354+ } ;
355+ sortNodes ( roots ) ;
356+
357+ const result : TreeRow [ ] = [ ] ;
358+ const walk = (
359+ node : TreeNode ,
360+ depth : number ,
361+ ancestorContinues : boolean [ ] ,
362+ isLast : boolean ,
363+ ) => {
364+ result . push ( { hb : node . hb , depth, isLast, ancestorContinues } ) ;
365+ for ( let i = 0 ; i < node . children . length ; i ++ ) {
366+ const childIsLast = i === node . children . length - 1 ;
367+ const continues = depth > 0 ? ! isLast : false ;
368+ walk (
369+ node . children [ i ] ! ,
370+ depth + 1 ,
371+ [ ...ancestorContinues , continues ] ,
372+ childIsLast ,
373+ ) ;
374+ }
375+ } ;
376+ for ( let i = 0 ; i < roots . length ; i ++ ) {
377+ walk ( roots [ i ] ! , 0 , [ ] , i === roots . length - 1 ) ;
378+ }
379+ return result ;
380+ }
381+
382+ function buildTreePrefix ( node : TreeRow ) : string {
383+ if ( node . depth === 0 ) return "" ;
384+ const parts = node . ancestorContinues . map ( ( c ) => ( c ? "│ " : " " ) ) ;
385+ const branch = node . isLast ? "└─ " : "├─ " ;
386+ return parts . join ( "" ) + branch ;
387+ }
388+
319389function listPoolPanes ( ) : Set < string > {
320390 try {
321391 const out = execFileSync (
0 commit comments