Skip to content

Commit b4d014d

Browse files
committed
render fork tree in /mux
1 parent 3ac8cb2 commit b4d014d

3 files changed

Lines changed: 87 additions & 9 deletions

File tree

src/heartbeat.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface Heartbeat {
2020
busy: boolean;
2121
/** Session display name (user-set via /name) or first user message. */
2222
label?: string;
23+
/** Path of the session this one was forked from, if any. */
24+
parentSessionFile?: string;
2325
}
2426

2527
const DIR = join(homedir(), ".pi-mux", "heartbeats");
@@ -140,6 +142,10 @@ function readEntry(full: string): Heartbeat | undefined {
140142
owner: data.owner,
141143
busy: typeof data.busy === "boolean" ? data.busy : false,
142144
label: typeof data.label === "string" ? data.label : undefined,
145+
parentSessionFile:
146+
typeof data.parentSessionFile === "string"
147+
? data.parentSessionFile
148+
: undefined,
143149
};
144150
}
145151
} catch {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ export default function (pi: ExtensionAPI) {
251251
return;
252252
}
253253
const pane = process.env.TMUX_PANE!;
254+
const parentSessionFile = ctx.sessionManager.getHeader()?.parentSession;
254255
heartbeat.start({
255256
paneId: pane,
256257
sessionFile,
@@ -259,6 +260,7 @@ export default function (pi: ExtensionAPI) {
259260
owner: resolveOwner(pane),
260261
busy: false,
261262
label: computeLabel(ctx.sessionManager),
263+
parentSessionFile,
262264
});
263265
ctx.ui.notify("pi-mux active", "info");
264266
signalReady();

src/mux-menu.ts

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ const POOL = "_pi-mux";
1818
type Scope = "cwd" | "all";
1919
type 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+
2128
export 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+
319389
function listPoolPanes(): Set<string> {
320390
try {
321391
const out = execFileSync(

0 commit comments

Comments
 (0)