forked from wanjochan/.github
-
Notifications
You must be signed in to change notification settings - Fork 1
TUI
datakk edited this page May 19, 2026
·
3 revisions
#!/usr/bin/env bun
const PREVIEW_DELAY = 1000;
const PREVIEW_LINES = 80;
// ── AnsiScreen ──
class AnsiScreen {
private ESC = "\x1b[";
private RESET = `${this.ESC}0m`;
private MOUSE_ON = "\x1b[?1006h\x1b[?1000h";
private MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
wrap(code: string) { return (s: string) => `${this.ESC}${code}m${s}${this.RESET}`; }
dim = this.wrap("2");
bold = this.wrap("1");
inv = this.wrap("7");
cyan = this.wrap("36");
yellow = this.wrap("33");
gold = this.wrap("40;93");
gray = this.wrap("90");
write(s: string) { process.stdout.write(s); }
clear() { this.write(`${this.ESC}2J${this.ESC}H`); }
cursorAt(r: number, c: number) { this.write(`${this.ESC}${r};${c}H`); }
hideCursor() { this.write(`${this.ESC}?25l`); }
showCursor() { this.write(`${this.ESC}?25h`); }
enableMouse() { this.write(this.MOUSE_ON); }
disableMouse() { this.write(this.MOUSE_OFF); }
getSize(): [number, number] {
return [process.stdout.columns || 80, process.stdout.rows || 24];
}
}
const screen = new AnsiScreen();
// ── 纯工具函数 ──
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
function stripAnsi(s: string): string {
return s.replace(ANSI_RE, "");
}
function charW(cp: number): number {
if (
(cp >= 0x1100 && cp <= 0x115f) ||
(cp >= 0x2e80 && cp <= 0x303e) ||
(cp >= 0x3041 && cp <= 0x33ff) ||
(cp >= 0x3400 && cp <= 0x4dbf) ||
(cp >= 0x4e00 && cp <= 0x9fff) ||
(cp >= 0xa000 && cp <= 0xa4cf) ||
(cp >= 0xac00 && cp <= 0xd7a3) ||
(cp >= 0xf900 && cp <= 0xfaff) ||
(cp >= 0xfe30 && cp <= 0xfe4f) ||
(cp >= 0xff00 && cp <= 0xff60) ||
(cp >= 0xffe0 && cp <= 0xffe6)
)
return 2;
return 1;
}
function visW(s: string): number {
const t = stripAnsi(s);
let w = 0;
for (const ch of t) w += charW(ch.codePointAt(0)!);
return w;
}
function truncVis(s: string, max: number): string {
const t = stripAnsi(s);
let w = 0;
let out = "";
for (const ch of t) {
const cw = charW(ch.codePointAt(0)!);
if (w + cw > max) break;
out += ch;
w += cw;
}
return out;
}
function padVis(s: string, target: number): string {
const w = visW(s);
if (w >= target) return s;
return s + " ".repeat(target - w);
}
function tmux(args: string[]): string {
const out = Bun.spawnSync(["tmux", ...args]);
if (out.exitCode !== 0) {
process.stderr.write(`[tmux ${args.join(" ")}] exit=${out.exitCode} ${out.stderr.toString()}\n`);
}
return out.stdout.toString();
}
// === tmux API:所有 tmux 调用统一入口;target 一律用 `=NAME[:IDX]` 精确语法 ===
const tmuxApi = {
// sessions / windows
listSessions: (fmt = "#{session_name}"): string[] =>
tmux(["list-sessions", "-F", fmt]).trim().split("\n").filter(Boolean),
listWindows: (sess: string, fmt: string): string[] =>
tmux(["list-windows", "-t", `=${sess}`, "-F", fmt]).trim().split("\n").filter(Boolean),
newSession: (name: string) => tmux(["new-session", "-d", "-s", name]),
newGroupedSession: (name: string, srcSess: string) =>
tmux(["new-session", "-d", "-s", name, "-t", `=${srcSess}:`]),
killSession: (name: string) => tmux(["kill-session", "-t", `=${name}`]),
killWindow: (target: string) => tmux(["kill-window", "-t", target]),
renameSession: (oldName: string, newName: string) =>
tmux(["rename-session", "-t", `=${oldName}`, newName]),
renameWindow: (target: string, newName: string) =>
tmux(["rename-window", "-t", target, newName]),
newWindow: (sess: string, name: string) =>
tmux(["new-window", "-d", "-t", `=${sess}:`, "-n", name]),
selectWindow: (target: string) => tmux(["select-window", "-t", target]),
attach: (name: string) =>
Bun.spawnSync(["tmux", "attach-session", "-t", `=${name}`], {
stdin: "inherit", stdout: "inherit", stderr: "inherit",
}),
capturePane: (target: string, startN: number, endArg: string) =>
Bun.spawn(["tmux", "capture-pane", "-p", "-t", target, "-S", `-${startN}`, "-E", endArg]),
// options
getGlobalOption: (key: string): string =>
tmux(["show-options", "-gv", key]).trim(),
setGlobalOption: (key: string, val: string) =>
tmux(["set-option", "-g", key, val]),
// tmux 3.5a quirk: `set-option -t '=NAME' ...` 静默失败(kill/has/attach-session 支持 `=` 精确匹配,
// 唯独 set-option 不支持,stderr 报 "no such session: =NAME" 被 tmux() 吞掉)。
// 统一剥掉 `=` 前缀,否则 viewer 的 6 个 session 选项全部 no-op,沉浸式底栏永远不显示。
setSessionOption: (target: string, key: string, val: string) =>
tmux(["set-option", "-t", target.replace(/^=/, ""), key, val]),
unsetSessionOption: (target: string, key: string) =>
tmux(["set-option", "-u", "-t", target.replace(/^=/, ""), key]),
showSessionRaw: (sessTarget: string, key: string): string =>
tmux(["show-options", "-t", sessTarget, key]).trim(),
showWindowRaw: (winTarget: string, key: string): string =>
tmux(["show-options", "-wt", winTarget, key]).trim(),
setSessUserOption: (sessTarget: string, key: string, val: string) =>
tmux(["set-option", "-t", sessTarget, key, val]),
unsetSessUserOption: (sessTarget: string, key: string) =>
tmux(["set-option", "-u", "-t", sessTarget, key]),
setWinUserOption: (winTarget: string, key: string, val: string) =>
tmux(["set-option", "-w", "-t", winTarget, key, val]),
unsetWinUserOption: (winTarget: string, key: string) =>
tmux(["set-option", "-u", "-w", "-t", winTarget, key]),
// key bindings
bindKey: (table: string | null, key: string, cmd: string) =>
table ? tmux(["bind-key", "-T", table, key, cmd]) : tmux(["bind-key", "-n", key, cmd]),
unbindKeyRoot: (key: string) => tmux(["unbind-key", "-n", key]),
// raw fallback for special list cases
rawSpawnSync: (args: string[]) =>
Bun.spawnSync(["tmux", ...args], { stdout: "pipe", stderr: "pipe" }),
};
// ── 数据层 ──
interface TreeNode {
label: string;
target: string;
indent: number;
type: "session" | "window";
sessionName: string;
remark?: string;
}
function readRemark(node: { type: "session" | "window"; target: string; sessionName: string }): string {
// 仅取本 scope 的值,避免 global @remark 泄漏到所有 session/window
// show-options 不带 -v 时,未设置则输出空;设置则输出 `@remark "value"`
// 关键:target 必须用 `=NAME:` 精确匹配,否则 tmux 对短名/数字名做模糊匹配,
// 会把所有 session 的写入都打到同一目标,造成"全局污染"假象。
const sessTarget = `=${node.sessionName}:`;
const winTarget = node.type === "window" ? `=${node.sessionName}:${node.target.split(":")[1] ?? ""}` : sessTarget;
const raw = node.type === "session"
? tmuxApi.showSessionRaw(sessTarget, "@remark")
: tmuxApi.showWindowRaw(winTarget, "@remark");
if (!raw) return "";
const m = raw.match(/^@remark\s+(?:"((?:[^"\\]|\\.)*)"|(\S.*))$/);
if (!m) return "";
const v = m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : m[2];
return v.trim();
}
function writeRemark(node: TreeNode, value: string) {
// 关键:用 `=NAME:` / `=NAME:IDX` 精确目标,避免 tmux 对 "1"、短名 做模糊匹配导致跨 session 污染
const sessTarget = `=${node.sessionName}:`;
if (node.type === "session") {
if (value) tmuxApi.setSessUserOption(sessTarget, "@remark", value);
else tmuxApi.unsetSessUserOption(sessTarget, "@remark");
} else {
const idx = node.target.split(":")[1] ?? "";
const winTarget = `=${node.sessionName}:${idx}`;
if (value) tmuxApi.setWinUserOption(winTarget, "@remark", value);
else tmuxApi.unsetWinUserOption(winTarget, "@remark");
}
}
function isViewerSession(name: string): boolean {
return name === "__tui_viewer__" || name.startsWith("__tui_viewer__:");
}
function getTree(): TreeNode[] {
const nodes: TreeNode[] = [];
const sessions = tmuxApi.listSessions();
for (const sess of sessions) {
if (isViewerSession(sess)) continue;
const sessNode: TreeNode = {
label: `# ${sess}`,//sess,
target: sess,
indent: 0,
type: "session",
sessionName: sess,
};
sessNode.remark = readRemark(sessNode);
nodes.push(sessNode);
const wins = tmuxApi.listWindows(sess, "#{window_index}|#{window_name}|#{window_active}");
for (let i = 0; i < wins.length; i++) {
const [idx, name, active] = wins[i].split("|");
const marker = "" //active === "1" ? "●" : "○";
const branch = i === wins.length - 1 ? "└" : "├";
const winNode: TreeNode = {
label: `${branch} ${name}`,//`${branch} ${marker} ${idx}: ${name}`,
target: `${sess}:${idx}`,
indent: 1,
type: "window",
sessionName: sess,
};
winNode.remark = readRemark(winNode);
nodes.push(winNode);
}
}
return nodes;
}
async function getPreview(target: string): Promise<string> {
const previewH = getPreviewH();
const startN = previewH + state.scrollOffset;
const endArg = state.scrollOffset === 0 ? "-" : `-${state.scrollOffset}`;
const proc = tmuxApi.capturePane(target, startN, endArg);
const text = await new Response(proc.stdout).text();
await proc.exited;
return text;
}
// ── 状态 ──
interface InputMode {
prompt: string;
value: string;
callback: (v: string | null) => void;
}
// 布局常量:header 1 行(row 1),body 从 row 2 起,footer 1 行(row = rows)
const HEADER_H = 1;
const FOOTER_H = 1;
const BODY_START_ROW = HEADER_H + 1; // 2
class TuiState {
tree: TreeNode[] = getTree();
cursor = 0;
viewOffset = 0;
preview = "";
previewTimer: ReturnType<typeof setTimeout> | null = null;
previewFetchId = 0;
previewDoneId = 0;
previewTarget = "";
inputMode: InputMode | null = null;
scrollOffset = 0;
seenMax = 0;
selectMode = false;
clampView(bodyH: number) {
if (this.cursor < this.viewOffset) this.viewOffset = this.cursor;
else if (this.cursor >= this.viewOffset + bodyH) this.viewOffset = this.cursor - bodyH + 1;
if (this.viewOffset < 0) this.viewOffset = 0;
}
}
const state = new TuiState();
function getPreviewH(): number {
const [, rows] = screen.getSize();
return Math.max(1, rows - HEADER_H - FOOTER_H);
}
function getLayout(cols: number, rows: number) {
const leftW = Math.min(Math.max(Math.floor(cols * 0.2), 12), 30);
const rightW = cols - leftW - 1;
const bodyH = rows - HEADER_H - FOOTER_H;
return { leftW, rightW, bodyH };
}
// ── 渲染 ──
function render() {
const [cols, rows] = screen.getSize();
const { leftW, rightW, bodyH } = getLayout(cols, rows);
state.clampView(bodyH);
screen.clear();
screen.hideCursor();
// header
screen.cursorAt(1, 1);
const sc = state.tree.reduce((n, x) => n + (x.type === "session" ? 1 : 0), 0);
const title = ' TMUX 驾驶舱 '//` TMUX驾驶舱 (${sc} 会话) `;
// 注意:按可见宽度 padEnd,且保留最后一列空白避免触发终端 autowrap 将光标推到 row 2
const titleVis = truncVis(title, cols - 1);
screen.write(screen.inv(screen.bold(padVis(titleVis, cols - 1))));
// body
const allPLines = state.preview.split("\n");
// 取最后 bodyH 行(capture 末尾即最新输出)
const pLines = allPLines.length > bodyH ? allPLines.slice(allPLines.length - bodyH) : allPLines;
const blankLeft = " ".repeat(leftW - 1);
// 滚动条计算:右侧让出 1 列绘制
const textW = Math.max(0, rightW - 1);
const previewH = bodyH;
const curDepth = state.scrollOffset + previewH;
if (curDepth > state.seenMax) state.seenMax = curDepth;
const total = Math.max(state.seenMax, previewH);
const thumbH = Math.max(1, Math.round((previewH * previewH) / total));
const thumbStart = total <= previewH
? 0
: Math.max(0, Math.min(previewH - thumbH,
Math.round((previewH * (total - state.scrollOffset - previewH)) / total)));
for (let i = 0; i < bodyH; i++) {
const row = i + BODY_START_ROW;
const treeIdx = i + state.viewOffset;
// left: tree
screen.cursorAt(row, 1);
const cap = leftW - 1;
if (treeIdx < state.tree.length) {
const node = state.tree[treeIdx];
const rk = node.remark;
const pending =
treeIdx === state.cursor &&
node.type === "window" &&
state.previewDoneId < state.previewFetchId;
const base = node.label;
const baseVis = truncVis(base, cap);
const baseW = visW(baseVis);
const remarkPart = rk ? ` ${rk}` : "";
const pendPart = pending ? " **" : "";
const tail = truncVis(remarkPart + pendPart, Math.max(0, cap - baseW));
const tailW = visW(tail);
const padN = Math.max(0, cap - baseW - tailW);
const padStr = " ".repeat(padN);
if (treeIdx === state.cursor) {
screen.write(screen.inv(screen.cyan(baseVis)));
if (tail) screen.write(screen.inv(screen.cyan(tail)));
if (padN) screen.write(screen.inv(padStr));
} else {
if (node.type === "session") screen.write(screen.bold(baseVis));
else screen.write(baseVis);
if (tail) screen.write(screen.cyan(tail));
if (padN) screen.write(padStr);
}
} else {
screen.write(blankLeft);
}
// divider (固定列 leftW)
screen.cursorAt(row, leftW);
screen.write(screen.dim("│"));
// right: preview (let out 1 col for scrollbar)
screen.cursorAt(row, leftW + 1);
if (state.previewTarget && i === 0) {
screen.write(screen.dim(`⏳ ${state.previewTarget} ...`).slice(0, textW));
} else {
screen.write((pLines[i] || "").slice(0, textW));
}
// scrollbar (最右一列)
if (rightW >= 1) {
screen.cursorAt(row, leftW + 1 + textW);
const inThumb = i >= thumbStart && i < thumbStart + thumbH;
screen.write(`\x1b[90m${inThumb ? "▓" : "░"}\x1b[0m`);
}
}
// footer
screen.cursorAt(rows, 1);
if (state.inputMode) {
const line = ` ${state.inputMode.prompt}: ${state.inputMode.value}█ `;
screen.write(screen.gold(padVis(truncVis(line, cols - 1), cols - 1)));
screen.showCursor();
} else {
if (state.selectMode) {
const line = " [选择模式 s:退出] 鼠标可在 preview 框选复制;键盘 j/k/Enter 仍可用 ";
screen.write(screen.inv(screen.wrap("93")(padVis(truncVis(line, cols - 1), cols - 1))));
} else {
const scrollInd = state.scrollOffset > 0 ? ` ↕scroll:${state.scrollOffset}` : "";
const help =
"上下:移动, C-右:进, C-左:回, sessio(n), (w)indow, (d)elete, (r)ename, re(m)ark, re(f)resh, (q)uit" + scrollInd;
screen.write(screen.gold(padVis(truncVis(help, cols - 1), cols - 1)));
}
}
}
// ── Ctrl-Left 支持:detach 返回 tree mode ──
const TUI_KEYTABLE = "tui_empty";
const installCtrlQ = () => {
tmuxApi.bindKey(TUI_KEYTABLE, "C-Left", "detach-client");
tmuxApi.bindKey(TUI_KEYTABLE, "M-Left", "detach-client");
tmuxApi.bindKey(null, "C-Left", "detach-client");
tmuxApi.bindKey(null, "M-Left", "detach-client");
};
const uninstallCtrlQ = () => {
tmuxApi.unbindKeyRoot("C-Left");
tmuxApi.unbindKeyRoot("M-Left");
};
// ── 外层 tmux 鼠标穿透 ──
// 若 tui 在 tmux 内运行,外层 tmux 的 mouse on 会先吃掉 SGR 序列,
// 导致内层 tui 收不到点击。启动时关掉,退出/attach 时恢复。
const insideTmux = !!process.env.TMUX;
let savedOuterMouse: string | null = null;
function enterSelectMode() {
// 关闭 SGR 鼠标,让终端原生选择接管
screen.disableMouse();
restoreOuterMouse();
state.selectMode = true;
render();
}
function exitSelectMode() {
state.selectMode = false;
disableOuterMouse();
screen.enableMouse();
render();
}
function disableOuterMouse() {
if (!insideTmux) return;
savedOuterMouse = tmuxApi.getGlobalOption("mouse") || "off";
if (savedOuterMouse === "on") tmuxApi.setGlobalOption("mouse", "off");
}
function restoreOuterMouse() {
if (!insideTmux || savedOuterMouse === null) return;
tmuxApi.setGlobalOption("mouse", savedOuterMouse);
}
// ── 输入框 ──
function startInput(prompt: string, callback: (v: string | null) => void) {
state.inputMode = { prompt, value: "", callback };
render();
}
function handleInputKey(s: string): void {
if (!state.inputMode) return;
const mode = state.inputMode;
// Escape / Ctrl-c → 取消
if (s === "\x1b" || s === "\x03") {
state.inputMode = null;
mode.callback(null);
render();
return;
}
// Enter → 确认(部分终端 raw 模式发 \n 或 \r\n)
if (s === "\r" || s === "\n" || s === "\r\n") {
state.inputMode = null;
mode.callback(mode.value);
render();
return;
}
// Backspace
if (s === "\x7f" || s === "\b") {
mode.value = mode.value.slice(0, -1);
render();
return;
}
// 可打印字符(含一次性粘贴的多字符)
const printable = s.replace(/[\r\n]/g, "");
if (printable.length > 0 && [...printable].every((c) => c >= " ")) {
mode.value += printable;
render();
}
}
// ── 操作 ──
function newSession() {
startInput("新 Session 名称", (raw) => {
const name = raw?.trim().replace(/\s+/g, "-");
if (name) {
tmuxApi.newSession(name);
refreshAll();
const idx = state.tree.findIndex(
(n) => n.type === "session" && n.target === name,
);
if (idx >= 0) state.cursor = idx;
}
refreshPreview();
});
}
function newWindow() {
if (state.tree.length === 0) return;
const node = state.tree[state.cursor];
const sess = node.sessionName;
if (isViewerSession(sess)) return;
startInput(`在 [${sess}] 新建 Window 名称`, (raw) => {
const name = raw?.trim();
if (name) {
tmuxApi.newWindow(sess, name);
refreshAll();
}
refreshPreview();
});
}
function deleteCurrent() {
if (state.tree.length === 0) return;
const node = state.tree[state.cursor];
const what =
node.type === "session"
? `session [${node.target}]`
: `window [${node.target}]`;
startInput(`删除 ${node.type} ${what}? (y/n)`, (ans) => {
if (ans?.toLowerCase() === "y") {
if (node.type === "session") {
tmuxApi.killSession(node.target);
}
else tmuxApi.killWindow(node.target);
}
refreshAll();
});
}
function renameCurrent() {
if (state.tree.length === 0) return;
const node = state.tree[state.cursor];
startInput(`rename ${node.type} [${node.target}]`, (raw) => {
const name = raw?.trim().replace(/\s+/g, "-");
if (name) {
if (node.type === "session") {
tmuxApi.renameSession(node.sessionName, name);
} else {
tmuxApi.renameWindow(node.target, name);
}
}
refreshAll();
});
}
function remarkCurrent() {
if (state.tree.length === 0) return;
const node = state.tree[state.cursor];
startInput(`remark [${node.target}] (空=删除)`, (raw) => {
if (raw === null) {
render();
return;
}
writeRemark(node, raw.trim());
refreshAll();
});
}
//const VIEWER_SESSION = `__tui_viewer__:${process.pid}`;
const VIEWER_SESSION = `__tui_viewer__`;
// === status guard:强制所有 session status off(留 viewer 显示提示栏),退出后恢复 ===
interface StatusSnapshot {
sessions: Array<{ id: string; name: string; val: string }>;
}
function snapshotAndDisableStatus(): StatusSnapshot {
// 只关 session-level status,不碰 global status。
// session-level status-left/right/style 会覆盖全局 setting,
// 所以 viewer 的 session 自定义提示栏不受其他 session/global 影响。
// 注意:必须按 session_name 跳过 viewer(旧代码拿 session_id 比字面名永远不等,
// 反而把刚 createViewer 设好的 status=on 又改回 off,沉浸式底栏因此消失)。
const lines = tmuxApi.listSessions("#{session_id}\t#{session_name}\t#{status}");
const sessions = lines
.map((l) => l.trim()).filter(Boolean)
.map((l) => { const [id, name, val] = l.split("\t"); return { id, name, val: val || "" }; });
for (const s of sessions) {
if (s.id && s.name !== VIEWER_SESSION) {
tmuxApi.setSessionOption(s.id, "status", "off");
}
}
return { sessions };
}
function restoreStatus(snap: StatusSnapshot) {
for (const s of snap.sessions) {
if (!s.id || s.name === VIEWER_SESSION) continue;
if (s.val === "") tmuxApi.unsetSessionOption(s.id, "status"); // 恢复默认(unset)
else tmuxApi.setSessionOption(s.id, "status", s.val);
}
}
// === viewer session helper:创建/销毁屏蔽 byobu 的 grouped viewer ===
function createViewer(sess: string, idx?: string) {
tmuxApi.killSession(VIEWER_SESSION);
tmuxApi.newGroupedSession(VIEWER_SESSION, sess);
const vTarget = `=${VIEWER_SESSION}`;
// 屏蔽 byobu 全局快捷键:切到自定义空 key-table + 关掉 prefix
tmuxApi.setSessionOption(vTarget, "key-table", TUI_KEYTABLE);
tmuxApi.setSessionOption(vTarget, "prefix", "None");
tmuxApi.setSessionOption(vTarget, "prefix2", "None");
// byobu 风格底部只读提示栏:ctrl-← 返回 tree mode
// session-level status-left/right/style 覆盖全局设置,其他 session 已设 status=off
tmuxApi.setSessionOption(vTarget, "mouse", "off"); // viewer 内鼠标不被 tmux 捕获,允许原生文本选择
if (idx) tmuxApi.selectWindow(`${vTarget}:${idx}`);
tmuxApi.setSessionOption(vTarget, "status-position", "top");
tmuxApi.setSessionOption(vTarget, "status", "on");
tmuxApi.setSessionOption(vTarget, "status-left", " #[bold]ctrl-左: 返回 ");
tmuxApi.setSessionOption(vTarget, "status-left-length", "30");
tmuxApi.setSessionOption(vTarget, "status-right", `驾驶分舱:${idx||''}`);
tmuxApi.setSessionOption(vTarget, "window-status-format", "");//for windows list in middle
tmuxApi.setSessionOption(vTarget, "window-status-current-format", "");//for active win in middle
tmuxApi.setSessionOption(vTarget, "window-status-separator", "");
tmuxApi.setSessionOption(vTarget, "status-justify", "centre");
tmuxApi.setSessionOption(vTarget, "status-style", "bg=black,fg=yellow");
}
function attach(target: string) {
// target = "<sess>:<idx>"
const [sess, idx] = target.split(":");
screen.showCursor();
screen.disableMouse();
process.stdin.setRawMode(false);
// 不调 restoreOuterMouse — viewer 内需要鼠标不被 tmux 捕获才能原生 select
installCtrlQ();
createViewer(sess, idx);
const statusSnap = snapshotAndDisableStatus();
try {
tmuxApi.attach(VIEWER_SESSION);
} finally {
tmuxApi.killSession(VIEWER_SESSION);
restoreStatus(statusSnap);
}
uninstallCtrlQ();
process.stdin.setRawMode(true);
disableOuterMouse();
screen.enableMouse();
refreshAll();
}
// ── preview 调度 ──
function refreshAll() {
state.tree = getTree();
if (state.cursor >= state.tree.length) state.cursor = Math.max(0, state.tree.length - 1);
refreshPreview();
}
async function refreshPreview() {
if (
state.tree.length > 0 &&
state.cursor < state.tree.length &&
state.tree[state.cursor].type === "window"
) {
const id = state.previewFetchId;
const target = state.tree[state.cursor].target;
state.previewTarget = target;
render(); // loading indicator
const text = await getPreview(target);
if (id !== state.previewFetchId) return; // discard stale
state.preview = text;
state.previewTarget = "";
state.previewDoneId = id;
} else {
state.preview = "";
state.previewTarget = "";
state.previewDoneId = state.previewFetchId;
}
render();
}
function schedulePreview() {
if (state.previewTimer) clearTimeout(state.previewTimer);
state.scrollOffset = 0;
state.seenMax = 0;
// session 节点无 pane,直接清空、不标 pending
if (
state.tree.length === 0 ||
state.cursor >= state.tree.length ||
state.tree[state.cursor].type !== "window"
) {
state.preview = "";
state.previewTarget = "";
state.previewDoneId = state.previewFetchId;
render();
return;
}
state.previewFetchId++; // 立即标记 pending
render(); // 光标立即响应
state.previewTimer = setTimeout(refreshPreview, PREVIEW_DELAY);
}
// ── 按键 ──
let lastClickY = -1;
let lastClickT = 0;
function handleMouse(btn: number, x: number, y: number, press: boolean) {
const [cols, rows] = screen.getSize();
const { leftW } = getLayout(cols, rows);
// 滚轮
if (btn === 64 || btn === 65) {
if (!press) return;
if (x >= leftW) {
// 右栏 preview 滚动
const previewH = getPreviewH();
if (btn === 64) state.scrollOffset += 3;
else state.scrollOffset = Math.max(0, state.scrollOffset - 3);
// 上限:tmux history-limit 通常 2000;放宽到一个合理值
const maxScroll = Math.max(0, 5000 - previewH);
if (state.scrollOffset > maxScroll) state.scrollOffset = maxScroll;
refreshPreview();
} else {
if (btn === 64 && state.cursor > 0) { state.cursor--; schedulePreview(); }
else if (btn === 65 && state.cursor < state.tree.length - 1) { state.cursor++; schedulePreview(); }
}
return;
}
if (btn !== 0 || !press) return;
if (y > rows - FOOTER_H) return; // footer 占 FOOTER_H 行
if (y < BODY_START_ROW) return; // header
if (x >= leftW) return; // 右栏忽略
const idx = (y - BODY_START_ROW) + state.viewOffset;
if (idx < 0 || idx >= state.tree.length) return;
const now = Date.now();
const dbl = lastClickY === idx && now - lastClickT < 500;
lastClickY = idx;
lastClickT = now;
state.cursor = idx;
if (dbl) {
if (state.tree[state.cursor]?.type !== "window") return;
attach(state.tree[state.cursor].target);
return;
}
render();
schedulePreview();
}
function handleKey(data: Buffer) {
let s = data.toString();
// 抽取 SGR 鼠标序列(选择模式下应该收不到,但安全起见仍过滤掉)
if (s.indexOf("\x1b[<") >= 0) {
const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
if (!state.selectMode) {
let m: RegExpExecArray | null;
while ((m = re.exec(s)) !== null) {
handleMouse(parseInt(m[1]), parseInt(m[2]), parseInt(m[3]), m[4] === "M");
}
}
s = s.replace(re, "");
if (!s) return;
}
if (state.inputMode) {
handleInputKey(s);
return;
}
if (s === "s") {
if (state.selectMode) exitSelectMode();
else enterSelectMode();
return;
}
if (s === "\x03" || s === "q") {
if (state.selectMode) exitSelectMode();
screen.showCursor();
screen.clear();
process.exit(0);
} else if (s === "\x1b[A" || s === "k") {
if (state.cursor > 0) {
state.cursor--;
schedulePreview();
}
} else if (s === "\x1b[B" || s === "j") {
if (state.cursor < state.tree.length - 1) {
state.cursor++;
schedulePreview();
}
} else if (s === "\r" || s === "\x1b[1;5C" || s === "\x1b[1;3C" || s === "\x1b[1;9C" || s === "\x1b\x1b[C" || s === "\x1bOC") {
if (state.tree.length > 0) {
if (state.tree[state.cursor]?.type !== "window") return;
const wasSelectMode = state.selectMode;
attach(state.tree[state.cursor].target);
// attach 返回后 screen.enableMouse + disableOuterMouse 破坏了 selectMode,重新进入
if (wasSelectMode) enterSelectMode();
}
} else if (s === "n") {
newSession();
} else if (s === "w") {
newWindow();
} else if (s === "d") {
deleteCurrent();
} else if (s === "r") {
renameCurrent();
} else if (s === "m") {
remarkCurrent();
} else if (s === "f") {
//refreshPreview();
refreshAll();
} else if (process.env.DEBUG_KEYS && s.length > 0 && (s.length > 1 || s < " " || s === "\x1b")) {
const hex = [...s].map(c => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0")).join("");
require("fs").appendFileSync("/tmp/tui_debug_keys.log", `[${new Date().toISOString()}] unhandled seq (len=${s.length}): ${hex}\n`);
}
}
// ── 启动 ──
if (state.tree.length === 0) {
tmuxApi.newSession("main");
state.tree = getTree();
if (state.tree.length === 0) {
console.log("tmux 不可用,请先安装 tmux。");
process.exit(1);
}
}
process.stdin.setRawMode(true);
process.stdin.resume();
disableOuterMouse();
screen.enableMouse(); // SGR 鼠标
process.stdin.on("data", handleKey);
process.on("SIGWINCH", () => refreshPreview());
process.on("exit", () => {
tmuxApi.killSession(VIEWER_SESSION);
screen.disableMouse();
restoreOuterMouse();
screen.showCursor();
screen.write("\x1b[0m");
console.log("TMUX驾驶舱已经离开,用 tui 重新进入");
});
process.on("SIGINT", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
refreshPreview();