@sandropadin/tend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pick.ts ADDED
@@ -0,0 +1,224 @@
1
+ // Interactive live dashboard: a grouped list of agents across all tmux sessions
2
+ // that re-scans on an interval AND lets you act on it. Arrow keys (or j/k) move
3
+ // the cursor; Enter jumps to that pane; o cycles where the jump opens; r
4
+ // refreshes; q / Esc / Ctrl-C quits.
5
+ //
6
+ // Jump target: by default Enter opens the agent in *this* window (the one the
7
+ // dashboard runs in), keeping the dashboard alive in its own pane. Press `o` to
8
+ // cycle the target to another attached client (another terminal window), so you
9
+ // can watch here and open blocked agents over there — the dashboard never moves.
10
+ // (When targeting this window from a plain terminal — not inside tmux — jumping
11
+ // means `tmux attach`, which takes over the terminal, so there we exit.)
12
+
13
+ import { scan, type ScanOptions } from "./scan.ts";
14
+ import { insideTmux, jumpToPane, listClients, selfClientTty, type TmuxClient } from "./nav.ts";
15
+ import { agentRow, bold, dim, groupBySession, summaryLine } from "./render.ts";
16
+ import type { PaneMemory } from "./detect.ts";
17
+ import type { AgentStatus } from "./types.ts";
18
+
19
+ const ALT_ON = "\x1b[?1049h";
20
+ const ALT_OFF = "\x1b[?1049l";
21
+ const HIDE_CURSOR = "\x1b[?25l";
22
+ const SHOW_CURSOR = "\x1b[?25h";
23
+ const CURSOR_HOME = "\x1b[H"; // move to top-left without clearing (no flicker)
24
+ const CLEAR_EOL = "\x1b[K"; // erase from cursor to end of *line*
25
+ const CLEAR_BELOW = "\x1b[J"; // erase from cursor to end of screen
26
+ const ANIM_MS = 90; // spinner tick — faster than the scan interval
27
+
28
+ const shortTty = (tty: string) => tty.replace(/^\/dev\//, "");
29
+
30
+ export async function runPicker(
31
+ opts: ScanOptions,
32
+ intervalMs: number,
33
+ initialTarget?: string,
34
+ ): Promise<void> {
35
+ let statuses: AgentStatus[] = [];
36
+ let memory = new Map<string, PaneMemory>();
37
+ let cursorPaneId: string | null = null; // track selection by id across rescans
38
+ let clients: TmuxClient[] = [];
39
+ let targetTty: string | null = initialTarget ?? null; // null = this window
40
+ let selfTty: string | null = null;
41
+ let done = false;
42
+ let statusMsg = ""; // transient line (e.g. "sent … to …")
43
+ let frame = 0; // spinner animation frame
44
+ let timer: ReturnType<typeof setInterval> | undefined;
45
+ let animTimer: ReturnType<typeof setInterval> | undefined;
46
+
47
+ const out = process.stdout;
48
+ const input = process.stdin;
49
+
50
+ // Flattened, display-ordered list of selectable agent rows (grouped order).
51
+ const selectable = (): AgentStatus[] => groupBySession(statuses).flatMap((g) => g.agents);
52
+ const otherClients = (): TmuxClient[] => clients.filter((c) => c.tty !== selfTty);
53
+
54
+ const currentIndex = (): number => {
55
+ const list = selectable();
56
+ if (list.length === 0) return -1;
57
+ const i = list.findIndex((s) => s.pane.id === cursorPaneId);
58
+ return i >= 0 ? i : 0;
59
+ };
60
+
61
+ const moveCursor = (delta: number) => {
62
+ const list = selectable();
63
+ if (list.length === 0) return;
64
+ const next = Math.min(list.length - 1, Math.max(0, currentIndex() + delta));
65
+ cursorPaneId = list[next]!.pane.id;
66
+ statusMsg = ""; // clear the note once you start moving again
67
+ };
68
+
69
+ // Cycle the jump target: this window → each other client → back.
70
+ const cycleTarget = () => {
71
+ const others = otherClients();
72
+ if (others.length === 0) {
73
+ // Nothing to target — tell the user how to get a second window instead of
74
+ // silently doing nothing.
75
+ statusMsg =
76
+ clients.length <= 1
77
+ ? "no other tmux client — run `tmux attach` in another terminal window"
78
+ : "no other client detected (couldn't tell this window apart)";
79
+ render();
80
+ return;
81
+ }
82
+ const cycle: Array<string | null> = [null, ...others.map((c) => c.tty)];
83
+ let idx = cycle.indexOf(targetTty);
84
+ if (idx < 0) idx = 0;
85
+ targetTty = cycle[(idx + 1) % cycle.length] ?? null;
86
+ statusMsg = "";
87
+ render();
88
+ };
89
+
90
+ const targetLabel = (): string => {
91
+ if (!targetTty) return "this window";
92
+ const c = clients.find((x) => x.tty === targetTty);
93
+ return shortTty(targetTty) + (c ? ` · ${c.session}` : "");
94
+ };
95
+
96
+ const render = () => {
97
+ const groups = groupBySession(statuses);
98
+ const selectedId = selectable()[currentIndex()]?.pane.id ?? null;
99
+ const others = otherClients().length;
100
+ const lines: string[] = [];
101
+ lines.push(
102
+ bold("tend") + dim(" ↑/↓ move · enter jump · o target · r refresh · q quit"),
103
+ );
104
+ // Show where Enter opens, plus how many other windows are available to target.
105
+ const windowsNote = others > 0 ? ` (${others} other window${others > 1 ? "s" : ""})` : "";
106
+ lines.push(dim(`opens in: ${targetLabel()}${windowsNote}`) + (statusMsg ? dim(` ${statusMsg}`) : ""));
107
+ if (groups.length === 0) {
108
+ lines.push(dim("No AI agents detected in any tmux session."));
109
+ } else {
110
+ for (const { session, agents } of groups) {
111
+ const blocked = agents.filter((a) => a.state === "blocked").length;
112
+ const suffix = blocked ? ` \x1b[31m· ${blocked} blocked\x1b[0m` : "";
113
+ lines.push(bold(`▸ ${session}`) + dim(` (${agents.length})`) + suffix);
114
+ for (const a of agents) {
115
+ lines.push(agentRow(a, a.pane.id === selectedId, frame));
116
+ }
117
+ lines.push("");
118
+ }
119
+ }
120
+ lines.push(summaryLine(statuses));
121
+ // Home + overwrite, erasing each line's tail (CLEAR_EOL) so a line that got
122
+ // shorter doesn't leave ghost text (e.g. a stale "· 1 blocked" suffix), plus
123
+ // CLEAR_BELOW for when this frame has fewer lines. No full-screen wipe, so
124
+ // the ~90ms spinner repaints stay flicker-free.
125
+ out.write(CURSOR_HOME + lines.map((l) => l + CLEAR_EOL).join("\n") + "\n" + CLEAR_BELOW);
126
+ };
127
+
128
+ const refresh = async () => {
129
+ const result = await scan(opts, memory);
130
+ memory = result.memory;
131
+ statuses = result.statuses;
132
+ clients = await listClients();
133
+ // If our chosen target client went away, fall back to this window.
134
+ if (targetTty && !clients.some((c) => c.tty === targetTty)) targetTty = null;
135
+ if (cursorPaneId === null) cursorPaneId = selectable()[0]?.pane.id ?? null;
136
+ if (!done) render();
137
+ };
138
+
139
+ const cleanup = () => {
140
+ if (timer) clearInterval(timer);
141
+ if (animTimer) clearInterval(animTimer);
142
+ input.setRawMode?.(false);
143
+ input.pause();
144
+ input.removeListener("data", onKey);
145
+ out.write(SHOW_CURSOR + ALT_OFF);
146
+ };
147
+
148
+ const jump = async () => {
149
+ const target = selectable()[currentIndex()];
150
+ if (!target) return;
151
+
152
+ // Targeting another client: move that window, keep the dashboard put.
153
+ if (targetTty) {
154
+ try {
155
+ await jumpToPane(target.pane, { client: targetTty });
156
+ statusMsg = `→ sent ${target.agent} (${target.pane.id}) to ${shortTty(targetTty)}`;
157
+ } catch {
158
+ statusMsg = `⚠ ${shortTty(targetTty)} unavailable`;
159
+ }
160
+ render();
161
+ return;
162
+ }
163
+
164
+ // Targeting this window, inside tmux: switch our client but keep running.
165
+ if (insideTmux()) {
166
+ await jumpToPane(target.pane);
167
+ statusMsg = `→ jumped to ${target.agent} in ${target.pane.sessionName} (${target.pane.id})`;
168
+ render();
169
+ return;
170
+ }
171
+
172
+ // This window, outside tmux: attach takes over the terminal → tear down.
173
+ done = true;
174
+ cleanup();
175
+ await jumpToPane(target.pane);
176
+ process.exit(0);
177
+ };
178
+
179
+ function onKey(buf: Buffer) {
180
+ const key = buf.toString("utf8");
181
+ if (key === "\x03" || key === "q" || key === "\x1b") {
182
+ done = true;
183
+ cleanup();
184
+ process.exit(0);
185
+ } else if (key === "\r" || key === "\n") {
186
+ void jump();
187
+ } else if (key === "\x1b[A" || key === "k") {
188
+ moveCursor(-1);
189
+ render();
190
+ } else if (key === "\x1b[B" || key === "j") {
191
+ moveCursor(1);
192
+ render();
193
+ } else if (key === "o" || key === "\t") {
194
+ cycleTarget();
195
+ } else if (key === "r") {
196
+ void refresh();
197
+ }
198
+ }
199
+
200
+ // Setup terminal. We only have a "self" client to exclude when we're actually
201
+ // running inside a tmux client; from a plain terminal every attached client is
202
+ // a legitimate target (and tmux would otherwise report one of them as "self").
203
+ selfTty = insideTmux() ? await selfClientTty() : null;
204
+ out.write(ALT_ON + HIDE_CURSOR);
205
+ input.setRawMode?.(true);
206
+ input.resume();
207
+ input.setEncoding?.("utf8");
208
+ input.on("data", onKey);
209
+ process.on("SIGINT", () => {
210
+ done = true;
211
+ cleanup();
212
+ process.exit(0);
213
+ });
214
+
215
+ await refresh();
216
+ timer = setInterval(() => void refresh(), intervalMs);
217
+ // Advance the spinner only while something is working, so an all-idle board
218
+ // stays quiet (no needless repaints).
219
+ animTimer = setInterval(() => {
220
+ if (done || !statuses.some((s) => s.state === "working")) return;
221
+ frame++;
222
+ render();
223
+ }, ANIM_MS);
224
+ }
package/src/regions.ts ADDED
@@ -0,0 +1,65 @@
1
+ // Region extractors: pure string functions that slice the captured pane text
2
+ // down to the part a rule cares about. Keeping these tiny and pure is what makes
3
+ // the rule engine testable.
4
+
5
+ import type { Region } from "./types.ts";
6
+
7
+ // A "horizontal rule" line is one made mostly of box-drawing horizontals — the
8
+ // borders Claude/Codex draw around their input box and section separators.
9
+ const RULE_CHARS = /[─━╌╍]/g;
10
+
11
+ function isRuleLine(line: string): boolean {
12
+ const matches = line.match(RULE_CHARS);
13
+ return matches !== null && matches.length >= 3;
14
+ }
15
+
16
+ function indicesOfRuleLines(lines: string[]): number[] {
17
+ const idx: number[] = [];
18
+ for (let i = 0; i < lines.length; i++) {
19
+ if (isRuleLine(lines[i]!)) idx.push(i);
20
+ }
21
+ return idx;
22
+ }
23
+
24
+ // Everything below the last horizontal rule — isolates the live prompt area
25
+ // from scrollback above it.
26
+ export function afterLastHorizontalRule(lines: string[]): string[] {
27
+ const rules = indicesOfRuleLines(lines);
28
+ if (rules.length === 0) return lines;
29
+ const last = rules[rules.length - 1]!;
30
+ return lines.slice(last + 1);
31
+ }
32
+
33
+ // The interior of the input box: the lines between the last two horizontal
34
+ // rules (the box's top and bottom borders). Falls back to the tail if there
35
+ // aren't two borders to bracket.
36
+ export function promptBoxBody(lines: string[]): string[] {
37
+ const rules = indicesOfRuleLines(lines);
38
+ if (rules.length < 2) return bottomNonEmptyLines(lines, 3);
39
+ const bottom = rules[rules.length - 1]!;
40
+ const top = rules[rules.length - 2]!;
41
+ return lines.slice(top + 1, bottom);
42
+ }
43
+
44
+ // The last N lines that aren't blank.
45
+ export function bottomNonEmptyLines(lines: string[], n: number): string[] {
46
+ const out: string[] = [];
47
+ for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
48
+ if (lines[i]!.trim().length > 0) out.push(lines[i]!);
49
+ }
50
+ return out.reverse();
51
+ }
52
+
53
+ export function extractRegion(lines: string[], region: Region): string {
54
+ if (typeof region === "object") {
55
+ return bottomNonEmptyLines(lines, region.bottom_non_empty_lines).join("\n");
56
+ }
57
+ switch (region) {
58
+ case "full":
59
+ return lines.join("\n");
60
+ case "after_last_horizontal_rule":
61
+ return afterLastHorizontalRule(lines).join("\n");
62
+ case "prompt_box_body":
63
+ return promptBoxBody(lines).join("\n");
64
+ }
65
+ }
package/src/render.ts ADDED
@@ -0,0 +1,139 @@
1
+ // Terminal rendering: agents grouped by tmux session, with colored state dots
2
+ // and a git column. No dependencies — raw ANSI. Falls back to plain text when
3
+ // stdout isn't a TTY (piped) or NO_COLOR is set.
4
+
5
+ import type { AgentState, AgentStatus, GitStatus } from "./types.ts";
6
+
7
+ const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
8
+
9
+ export const color = (code: string, s: string) =>
10
+ useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
11
+ export const dim = (s: string) => color("2", s);
12
+ export const bold = (s: string) => color("1", s);
13
+
14
+ // Braille spinner frames for the "working" state (classic dots animation).
15
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
+
17
+ // The state indicator: a distinct *shape* per state (so it reads even without
18
+ // color) plus color. "working" animates through the spinner; `frame` advances it.
19
+ export function stateGlyph(state: AgentState, frame = 0): string {
20
+ switch (state) {
21
+ case "blocked":
22
+ return color("31", "●"); // red filled — needs you
23
+ case "working":
24
+ return color("33", SPINNER[frame % SPINNER.length]!); // yellow spinner
25
+ case "idle":
26
+ return color("32", "○"); // green hollow — waiting for input
27
+ case "unknown":
28
+ return color("90", "◌"); // grey dotted — unknown
29
+ }
30
+ }
31
+
32
+ export function gitCell(git: GitStatus | null): string {
33
+ if (!git || !git.branch) return dim("—");
34
+ let s = git.branch;
35
+ const marks: string[] = [];
36
+ if (git.ahead) marks.push(`↑${git.ahead}`);
37
+ if (git.behind) marks.push(`↓${git.behind}`);
38
+ if (git.dirty) marks.push(color("33", "✱"));
39
+ if (marks.length) s += " " + marks.join(" ");
40
+ return s;
41
+ }
42
+
43
+ // Pad by *visible* width, ignoring ANSI escapes.
44
+ export function pad(s: string, width: number): string {
45
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, "").length;
46
+ return s + " ".repeat(Math.max(0, width - visible));
47
+ }
48
+
49
+ const STATE_ORDER: Record<AgentState, number> = {
50
+ blocked: 0,
51
+ working: 1,
52
+ idle: 2,
53
+ unknown: 3,
54
+ };
55
+
56
+ export interface SessionGroup {
57
+ session: string;
58
+ agents: AgentStatus[];
59
+ }
60
+
61
+ // Group agents by tmux session (insertion order = tmux's session order), with
62
+ // each session's agents sorted blocked→working→idle so the ones needing you
63
+ // float to the top.
64
+ export function groupBySession(statuses: AgentStatus[]): SessionGroup[] {
65
+ const groups = new Map<string, AgentStatus[]>();
66
+ for (const s of statuses) {
67
+ const key = s.pane.sessionName;
68
+ let list = groups.get(key);
69
+ if (!list) {
70
+ list = [];
71
+ groups.set(key, list);
72
+ }
73
+ list.push(s);
74
+ }
75
+ return [...groups.entries()].map(([session, agents]) => ({
76
+ session,
77
+ agents: agents.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]),
78
+ }));
79
+ }
80
+
81
+ // One agent row: state glyph + pane id + agent + git. The glyph's shape+color
82
+ // carries the state (● red blocked, spinner yellow working, ○ green idle); the
83
+ // pane id is the jump reference. `cursor` renders a caret; `frame` animates the
84
+ // working spinner.
85
+ export function agentRow(s: AgentStatus, cursor = false, frame = 0): string {
86
+ const caret = cursor ? bold("❯ ") : " ";
87
+ return (
88
+ caret +
89
+ `${stateGlyph(s.state, frame)} ` +
90
+ pad(s.pane.id, 6) +
91
+ pad(bold(s.agent), 10) +
92
+ gitCell(s.git)
93
+ );
94
+ }
95
+
96
+ export function renderGrouped(statuses: AgentStatus[], frame = 0): string {
97
+ if (statuses.length === 0) {
98
+ return dim("No AI agents detected in any tmux session.");
99
+ }
100
+ const lines: string[] = [];
101
+ for (const { session, agents } of groupBySession(statuses)) {
102
+ const blocked = agents.filter((a) => a.state === "blocked").length;
103
+ const suffix = blocked ? color("31", ` · ${blocked} blocked`) : "";
104
+ lines.push(bold(`▸ ${session}`) + dim(` (${agents.length})`) + suffix);
105
+ for (const a of agents) lines.push(agentRow(a, false, frame));
106
+ lines.push("");
107
+ }
108
+ if (lines[lines.length - 1] === "") lines.pop();
109
+ return lines.join("\n");
110
+ }
111
+
112
+ export function renderJson(statuses: AgentStatus[]): string {
113
+ return JSON.stringify(
114
+ statuses.map((s) => ({
115
+ pane: s.pane.id,
116
+ agent: s.agent,
117
+ state: s.state,
118
+ matchedRule: s.matchedRule,
119
+ session: s.pane.sessionName,
120
+ window: s.pane.windowName,
121
+ path: s.pane.path,
122
+ git: s.git,
123
+ })),
124
+ null,
125
+ 2,
126
+ );
127
+ }
128
+
129
+ export const CLEAR_SCREEN = "\x1b[2J\x1b[H";
130
+
131
+ export function summaryLine(statuses: AgentStatus[]): string {
132
+ const counts = { blocked: 0, working: 0, idle: 0, unknown: 0 };
133
+ for (const s of statuses) counts[s.state]++;
134
+ const sessions = new Set(statuses.map((s) => s.pane.sessionName)).size;
135
+ return dim(
136
+ `${statuses.length} agent(s) across ${sessions} session(s) — ` +
137
+ `${counts.blocked} blocked, ${counts.working} working, ${counts.idle} idle`,
138
+ );
139
+ }
package/src/scan.ts ADDED
@@ -0,0 +1,58 @@
1
+ // One detection pass over every pane. Shared by the list, watch, and picker
2
+ // modes. Threads per-pane memory across calls so "working" (content changed
3
+ // since last look) and idle-debounce work in the live modes.
4
+
5
+ import { capturePane, listPanes } from "./tmux.ts";
6
+ import { createGitCache } from "./git.ts";
7
+ import { resolveState, type PaneMemory } from "./detect.ts";
8
+ import type { AgentStatus } from "./types.ts";
9
+
10
+ export interface ScanOptions {
11
+ all: boolean;
12
+ }
13
+
14
+ export async function scan(
15
+ opts: ScanOptions,
16
+ memory: Map<string, PaneMemory>,
17
+ ): Promise<{ statuses: AgentStatus[]; memory: Map<string, PaneMemory> }> {
18
+ const panes = await listPanes({ all: opts.all });
19
+ const gitCache = createGitCache();
20
+ const nextMemory = new Map<string, PaneMemory>();
21
+
22
+ // Resolve concurrently but keep results in tmux pane order (session → window →
23
+ // pane) so the grouped view is stable across refreshes rather than ordered by
24
+ // whichever capture-pane happened to finish first.
25
+ const resolved = await Promise.all(
26
+ panes.map(async (pane): Promise<AgentStatus | null> => {
27
+ const lines = await capturePane(pane.id);
28
+ const resolution = resolveState(pane, lines, memory.get(pane.id));
29
+ if (!resolution) return null; // not an agent pane
30
+ nextMemory.set(pane.id, resolution.memory);
31
+ const git = await gitCache(pane.path);
32
+ return {
33
+ pane,
34
+ agent: resolution.agent,
35
+ state: resolution.state,
36
+ matchedRule: resolution.matchedRule,
37
+ git,
38
+ };
39
+ }),
40
+ );
41
+ const statuses = resolved.filter((s): s is AgentStatus => s !== null);
42
+ return { statuses, memory: nextMemory };
43
+ }
44
+
45
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
46
+
47
+ // Fire-once: sample twice with a short delay so "working" can be detected via
48
+ // content change even without a persistent watcher.
49
+ export async function scanOnce(
50
+ opts: ScanOptions,
51
+ delayMs: number,
52
+ ): Promise<AgentStatus[]> {
53
+ let memory = new Map<string, PaneMemory>();
54
+ ({ memory } = await scan(opts, memory));
55
+ await sleep(delayMs);
56
+ const { statuses } = await scan(opts, memory);
57
+ return statuses;
58
+ }
package/src/tmux.ts ADDED
@@ -0,0 +1,173 @@
1
+ // tmux glue. tmux has already done the hard part for us: #{pane_current_command}
2
+ // is the foreground process's comm, and capture-pane hands us the rendered
3
+ // screen for free. We just ask nicely.
4
+
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import type { PaneInfo } from "./types.ts";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ async function tmux(args: string[]): Promise<string> {
12
+ const { stdout } = await exec("tmux", args, { maxBuffer: 16 * 1024 * 1024 });
13
+ return stdout;
14
+ }
15
+
16
+ // tmux sanitizes ANY control character in -F output (including our separator and
17
+ // even newlines) to "_", so we can't pack multiple free-text fields into one
18
+ // formatted line. Instead we query one field per call, each paired with the
19
+ // pane id. pane_id is always "%<digits>" — it can't contain the "|" delimiter or
20
+ // a newline — so we split once on the first "|" and the value may then contain
21
+ // anything (spaces, "|", unicode) without ambiguity. Keying the merge by id
22
+ // makes it robust to the pane set changing between calls.
23
+ async function column(
24
+ scope: string[],
25
+ field: string,
26
+ ): Promise<Array<[string, string]>> {
27
+ const stdout = await tmux(["list-panes", ...scope, "-F", `#{pane_id}|${field}`]);
28
+ const rows: Array<[string, string]> = [];
29
+ for (const line of stdout.split("\n")) {
30
+ if (!line) continue;
31
+ const i = line.indexOf("|");
32
+ if (i < 0) continue;
33
+ rows.push([line.slice(0, i), line.slice(i + 1)]);
34
+ }
35
+ return rows;
36
+ }
37
+
38
+ export async function isServerRunning(): Promise<boolean> {
39
+ try {
40
+ await tmux(["list-panes", "-a"]);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ // Enumerate panes. Scope defaults to the current session when run inside tmux,
48
+ // or all sessions with `all: true` (or when run outside tmux).
49
+ export async function listPanes(opts: { all: boolean }): Promise<PaneInfo[]> {
50
+ const insideTmux = Boolean(process.env.TMUX);
51
+ const scope = opts.all || !insideTmux ? ["-a"] : ["-s"];
52
+
53
+ const [commands, pids, paths, titles, sessions, windows, windowIdx, actives] =
54
+ await Promise.all([
55
+ column(scope, "#{pane_current_command}"),
56
+ column(scope, "#{pane_pid}"),
57
+ column(scope, "#{pane_current_path}"),
58
+ column(scope, "#{pane_title}"),
59
+ column(scope, "#{session_name}"),
60
+ column(scope, "#{window_name}"),
61
+ column(scope, "#{window_index}"),
62
+ column(scope, "#{pane_active}"),
63
+ ]);
64
+
65
+ // Seed one PaneInfo per id from the first column, then fill from the rest.
66
+ // Keyed by id, so a pane appearing/vanishing mid-scan can't misalign fields.
67
+ const byId = new Map<string, PaneInfo>();
68
+ for (const [id] of commands) {
69
+ if (!byId.has(id)) {
70
+ byId.set(id, {
71
+ id,
72
+ command: "",
73
+ pid: 0,
74
+ path: "",
75
+ title: "",
76
+ sessionName: "",
77
+ windowName: "",
78
+ windowIndex: "",
79
+ active: false,
80
+ });
81
+ }
82
+ }
83
+ const fill = (rows: Array<[string, string]>, set: (p: PaneInfo, v: string) => void) => {
84
+ for (const [id, value] of rows) {
85
+ const p = byId.get(id);
86
+ if (p) set(p, value);
87
+ }
88
+ };
89
+ fill(commands, (p, v) => (p.command = v));
90
+ fill(pids, (p, v) => (p.pid = Number(v) || 0));
91
+ fill(paths, (p, v) => (p.path = v));
92
+ fill(titles, (p, v) => (p.title = v));
93
+ fill(sessions, (p, v) => (p.sessionName = v));
94
+ fill(windows, (p, v) => (p.windowName = v));
95
+ fill(windowIdx, (p, v) => (p.windowIndex = v));
96
+ fill(actives, (p, v) => (p.active = v === "1"));
97
+
98
+ return [...byId.values()];
99
+ }
100
+
101
+ // Capture the visible screen of a pane as plain text lines (no escape codes).
102
+ // -p prints to stdout; trailing blank lines trimmed.
103
+ export async function capturePane(paneId: string): Promise<string[]> {
104
+ const stdout = await tmux(["capture-pane", "-p", "-t", paneId]);
105
+ return stdout.replace(/\n+$/, "").split("\n");
106
+ }
107
+
108
+ // Move the *currently attached* tmux client to a pane: switch its session, then
109
+ // select the window and pane. A pane id (%N) resolves up to its window/session,
110
+ // so we only need the session name for switch-client. Use when already inside
111
+ // tmux (process.env.TMUX set).
112
+ export async function switchToPane(sessionName: string, paneId: string): Promise<void> {
113
+ await tmux(["switch-client", "-t", sessionName]);
114
+ await tmux(["select-window", "-t", paneId]);
115
+ await tmux(["select-pane", "-t", paneId]);
116
+ }
117
+
118
+ // Like switchToPane, but moves a *specific* client (by its tty) rather than the
119
+ // current one. This is how the dashboard in one window drives another window:
120
+ // select-window/select-pane set the agent session's active window+pane server-
121
+ // side, and switch-client -c points that other client at it.
122
+ export async function switchClientToPane(
123
+ clientTty: string,
124
+ sessionName: string,
125
+ paneId: string,
126
+ ): Promise<void> {
127
+ await tmux(["switch-client", "-c", clientTty, "-t", sessionName]);
128
+ await tmux(["select-window", "-t", paneId]);
129
+ await tmux(["select-pane", "-t", paneId]);
130
+ }
131
+
132
+ export interface TmuxClient {
133
+ tty: string; // client_tty, e.g. "/dev/ttys004" — the client's identity
134
+ session: string; // session it's currently attached to
135
+ }
136
+
137
+ // All attached clients. Uses the same "id | value" split trick as listPanes to
138
+ // dodge tmux's control-character sanitization; client_tty never contains "|".
139
+ export async function listClients(): Promise<TmuxClient[]> {
140
+ let stdout = "";
141
+ try {
142
+ stdout = await tmux(["list-clients", "-F", "#{client_tty}|#{client_session}"]);
143
+ } catch {
144
+ return [];
145
+ }
146
+ const clients: TmuxClient[] = [];
147
+ for (const line of stdout.split("\n")) {
148
+ if (!line) continue;
149
+ const i = line.indexOf("|");
150
+ if (i < 0) continue;
151
+ clients.push({ tty: line.slice(0, i), session: line.slice(i + 1) });
152
+ }
153
+ return clients;
154
+ }
155
+
156
+ // The tty of the client this process is running under (best effort). Used to
157
+ // tell "this window" apart from the others when choosing a jump target.
158
+ export async function selfClientTty(): Promise<string | null> {
159
+ try {
160
+ const tty = (await tmux(["display-message", "-p", "#{client_tty}"])).trim();
161
+ return tty || null;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ // Pre-select the target window/pane on the server (no client needed), so a
168
+ // subsequent `tmux attach` lands with that pane active. Use when NOT inside
169
+ // tmux — the caller then execs `tmux attach-session -t <sessionName>`.
170
+ export async function preselectPane(paneId: string): Promise<void> {
171
+ await tmux(["select-window", "-t", paneId]);
172
+ await tmux(["select-pane", "-t", paneId]);
173
+ }