11import { execFileSync } from "node:child_process" ;
2- import type { Theme } from "@mariozechner/pi-coding-agent" ;
3- import { Container } from "@mariozechner/pi-tui" ;
4- import type { Focusable , TUI } from "@mariozechner/pi-tui" ;
2+ import {
3+ DynamicBorder ,
4+ rawKeyHint ,
5+ type Theme ,
6+ } from "@mariozechner/pi-coding-agent" ;
7+ import {
8+ Container ,
9+ Spacer ,
10+ truncateToWidth ,
11+ visibleWidth ,
12+ } from "@mariozechner/pi-tui" ;
13+ import type { Component , Focusable , TUI } from "@mariozechner/pi-tui" ;
514import * as heartbeat from "./heartbeat.js" ;
615
716const POOL = "_pi-mux" ;
@@ -30,6 +39,8 @@ export class MuxMenu extends Container implements Focusable {
3039 private readonly currentCwd : string ;
3140 private readonly done : ( result : undefined ) => void ;
3241 private refreshTimer ?: ReturnType < typeof setInterval > ;
42+ private poolPanes = new Set < string > ( ) ;
43+ private readonly list : MuxList ;
3344
3445 constructor ( opts : MuxMenuOptions ) {
3546 super ( ) ;
@@ -38,6 +49,8 @@ export class MuxMenu extends Container implements Focusable {
3849 this . currentPaneId = opts . currentPaneId ;
3950 this . currentCwd = opts . currentCwd ;
4051 this . done = opts . done ;
52+ this . list = new MuxList ( this ) ;
53+ this . buildLayout ( ) ;
4154 this . refresh ( ) ;
4255 this . refreshTimer = setInterval ( ( ) => {
4356 this . refresh ( ) ;
@@ -47,29 +60,60 @@ export class MuxMenu extends Container implements Focusable {
4760 this . refreshTimer . unref ( ) ;
4861 }
4962
63+ private buildLayout ( ) : void {
64+ this . clear ( ) ;
65+ this . addChild ( new Spacer ( 1 ) ) ;
66+ this . addChild ( new DynamicBorder ( ( s ) => this . theme . fg ( "accent" , s ) ) ) ;
67+ this . addChild ( new Spacer ( 1 ) ) ;
68+ this . addChild ( this . list ) ;
69+ this . addChild ( new Spacer ( 1 ) ) ;
70+ this . addChild ( new DynamicBorder ( ( s ) => this . theme . fg ( "accent" , s ) ) ) ;
71+ }
72+
5073 dispose ( ) : void {
5174 if ( this . refreshTimer ) {
5275 clearInterval ( this . refreshTimer ) ;
5376 this . refreshTimer = undefined ;
5477 }
5578 }
5679
57- private poolPanes = new Set < string > ( ) ;
58-
5980 private refresh ( ) : void {
6081 this . poolPanes = listPoolPanes ( ) ;
6182 const all = heartbeat . listActive ( ) ;
6283 const inScope =
63- this . scope === "all"
64- ? all
65- : all . filter ( ( e ) => e . cwd === this . currentCwd ) ;
84+ this . scope === "all" ? all : all . filter ( ( e ) => e . cwd === this . currentCwd ) ;
6685 inScope . sort ( ( a , b ) => a . paneId . localeCompare ( b . paneId ) ) ;
6786 this . rows = inScope ;
6887 if ( this . selectedIndex >= this . rows . length ) {
6988 this . selectedIndex = Math . max ( 0 , this . rows . length - 1 ) ;
7089 }
7190 }
7291
92+ getRows ( ) : heartbeat . Heartbeat [ ] {
93+ return this . rows ;
94+ }
95+ getPoolPanes ( ) : Set < string > {
96+ return this . poolPanes ;
97+ }
98+ getSelectedIndex ( ) : number {
99+ return this . selectedIndex ;
100+ }
101+ getMode ( ) : Mode {
102+ return this . mode ;
103+ }
104+ getPendingConfirmMessage ( ) : string {
105+ return this . pendingConfirmMessage ;
106+ }
107+ getScope ( ) : Scope {
108+ return this . scope ;
109+ }
110+ getCurrentPaneId ( ) : string {
111+ return this . currentPaneId ;
112+ }
113+ getTheme ( ) : Theme {
114+ return this . theme ;
115+ }
116+
73117 private killTargets ( ) : heartbeat . Heartbeat [ ] {
74118 return this . rows . filter ( ( r ) => r . paneId !== this . currentPaneId ) ;
75119 }
@@ -167,67 +211,104 @@ export class MuxMenu extends Container implements Focusable {
167211 execFileSync ( "tmux" , [ "kill-pane" , "-t" , paneId ] ) ;
168212 } catch { }
169213 }
214+ }
215+
216+ class MuxList implements Component {
217+ constructor ( private readonly parent : MuxMenu ) { }
218+
219+ invalidate ( ) : void { }
170220
171221 render ( width : number ) : string [ ] {
172- const lines : string [ ] = [ ] ;
173- const scopeLabel = this . scope === "cwd" ? "this folder" : "all folders" ;
174- const header = this . theme . bold (
175- `pi-mux ${ this . theme . fg (
176- "muted" ,
177- `[${ this . rows . length } in ${ scopeLabel } ]` ,
178- ) } `,
222+ const theme = this . parent . getTheme ( ) ;
223+ const rows = this . parent . getRows ( ) ;
224+ const poolPanes = this . parent . getPoolPanes ( ) ;
225+ const currentPaneId = this . parent . getCurrentPaneId ( ) ;
226+ const selectedIndex = this . parent . getSelectedIndex ( ) ;
227+ const mode = this . parent . getMode ( ) ;
228+
229+ const scope = this . parent . getScope ( ) ;
230+ const title = theme . bold ( "pi-mux" ) ;
231+ const scopeText =
232+ scope === "cwd"
233+ ? `${ theme . fg ( "accent" , "◉ Current Folder" ) } ${ theme . fg ( "muted" , " | ○ All" ) } `
234+ : `${ theme . fg ( "muted" , "○ Current Folder | " ) } ${ theme . fg ( "accent" , "◉ All" ) } ` ;
235+ const spacingN = Math . max (
236+ 1 ,
237+ width - visibleWidth ( title ) - visibleWidth ( scopeText ) ,
179238 ) ;
180- lines . push ( truncate ( header , width ) ) ;
239+ const header = title + " " . repeat ( spacingN ) + scopeText ;
240+
241+ const lines : string [ ] = [ ] ;
242+ lines . push ( truncateToWidth ( header , width , "" ) ) ;
181243 lines . push ( "" ) ;
182244
183- if ( this . rows . length === 0 ) {
184- lines . push (
185- this . theme . fg ( "muted" , " (no backgrounded pi sessions)" ) ,
186- ) ;
245+ if ( rows . length === 0 ) {
246+ lines . push ( theme . fg ( "muted" , " (no pi sessions tracked)" ) ) ;
187247 } else {
188- for ( let i = 0 ; i < this . rows . length ; i ++ ) {
189- const row = this . rows [ i ] ! ;
190- const isCurrent = row . paneId === this . currentPaneId ;
191- const isSelected = i === this . selectedIndex ;
192- const inPool = this . poolPanes . has ( row . paneId ) ;
193- const cursor = isSelected ? this . theme . fg ( "accent" , "› " ) : " " ;
194- const cwdShort = row . cwd . replace ( process . env . HOME ?? "" , "~" ) ;
195- const name = row . label ?. trim ( ) || "(empty session)" ;
196- const snippet = name . length > 50 ? `${ name . slice ( 0 , 47 ) } …` : name ;
197- const label = `${ cwdShort } ${ this . theme . fg ( "muted" , snippet ) } ` ;
248+ for ( let i = 0 ; i < rows . length ; i ++ ) {
249+ const row = rows [ i ] ! ;
250+ const isCurrent = row . paneId === currentPaneId ;
251+ const isSelected = i === selectedIndex ;
252+ const inPool = poolPanes . has ( row . paneId ) ;
253+
254+ const cursor = isSelected ? theme . fg ( "accent" , "› " ) : " " ;
255+
256+ const rawName = row . label ?. trim ( ) || "(empty session)" ;
257+ const normalizedName = rawName . replace ( / [ \x00 - \x1f \x7f ] / g, " " ) . trim ( ) ;
258+
198259 const tagParts : string [ ] = [ ] ;
199260 if ( isCurrent ) tagParts . push ( "current" ) ;
200261 else if ( inPool ) tagParts . push ( "backgrounded" ) ;
201262 else tagParts . push ( "open" ) ;
202263 if ( row . busy && ! isCurrent ) tagParts . push ( "busy" ) ;
203- const tags = this . theme . fg ( "muted" , ` [${ tagParts . join ( " · " ) } ]` ) ;
204- let line = cursor + label + tags ;
205- if ( isSelected ) line = this . theme . bg ( "selectedBg" , line ) ;
206- lines . push ( truncate ( line , width ) ) ;
264+ const tagPlain = `[${ tagParts . join ( " · " ) } ]` ;
265+ const tagStyled = theme . fg ( "dim" , tagPlain ) ;
266+
267+ const cwdShort = row . cwd . replace ( process . env . HOME ?? "" , "~" ) ;
268+
269+ const cursorWidth = visibleWidth ( cursor ) ;
270+ const tagWidth = visibleWidth ( tagPlain ) ;
271+ const cwdWidth = visibleWidth ( cwdShort ) ;
272+ const minGap = 6 ;
273+ const availableForName = Math . max (
274+ 5 ,
275+ width - cursorWidth - 1 - tagWidth - minGap - cwdWidth ,
276+ ) ;
277+ const truncatedName = truncateToWidth (
278+ normalizedName ,
279+ availableForName ,
280+ "…" ,
281+ ) ;
282+ const styledName = isCurrent
283+ ? theme . fg ( "accent" , truncatedName )
284+ : truncatedName ;
285+ const boldedName = isSelected ? theme . bold ( styledName ) : styledName ;
286+
287+ const leftPart = `${ cursor } ${ boldedName } ${ tagStyled } ` ;
288+ const leftWidth = visibleWidth ( leftPart ) ;
289+ const spacing = Math . max ( minGap , width - leftWidth - cwdWidth ) ;
290+ let line = leftPart + " " . repeat ( spacing ) + theme . fg ( "dim" , cwdShort ) ;
291+ if ( isSelected ) line = theme . bg ( "selectedBg" , line ) ;
292+ lines . push ( truncateToWidth ( line , width , "" ) ) ;
207293 }
208294 }
209295
210296 lines . push ( "" ) ;
211- if ( this . mode !== "list" ) {
212- lines . push ( this . theme . fg ( "error" , this . pendingConfirmMessage ) ) ;
297+ if ( mode !== "list" ) {
298+ lines . push ( theme . fg ( "error" , this . parent . getPendingConfirmMessage ( ) ) ) ;
213299 } else {
300+ const sep = theme . fg ( "muted" , " · " ) ;
214301 const hints = [
215- "d kill",
216- "D kill all",
217- "tab scope" ,
218- "q close",
219- ] . join ( this . theme . fg ( "muted" , " · " ) ) ;
220- lines . push ( this . theme . fg ( "muted" , hints ) ) ;
302+ rawKeyHint ( "d" , " kill") ,
303+ rawKeyHint ( "D" , " kill all") ,
304+ rawKeyHint ( "tab" , " scope") ,
305+ rawKeyHint ( "q" , " close") ,
306+ ] . join ( sep ) ;
307+ lines . push ( hints ) ;
221308 }
222309
223- return lines . map ( ( l ) => truncate ( l , width ) ) ;
310+ return lines ;
224311 }
225-
226- invalidate ( ) : void { }
227- }
228-
229- function truncate ( s : string , width : number ) : string {
230- return s . length > width * 4 ? s . slice ( 0 , width * 4 ) : s ;
231312}
232313
233314function listPoolPanes ( ) : Set < string > {
0 commit comments