Skip to content

Commit bef1983

Browse files
committed
update /mux ui
1 parent 9e55480 commit bef1983

1 file changed

Lines changed: 130 additions & 49 deletions

File tree

src/mux-menu.ts

Lines changed: 130 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { 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";
514
import * as heartbeat from "./heartbeat.js";
615

716
const 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

233314
function listPoolPanes(): Set<string> {

0 commit comments

Comments
 (0)