@dinhtungdu/watcher 1.0.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.
@@ -0,0 +1,435 @@
1
+ import { AgentPane, AgentStatus, STATUS_RANK, SwitcherSnapshot, isRunningAgentStatus } from './model.js';
2
+ import { activationTargetLabel } from './activation.js';
3
+ import { terminalTargetCommand, terminalTargetCwd, terminalTargetPid } from './terminalTarget.js';
4
+ import { basename, bold, dim, fit, formatAge, line, selected, shortPath, singleLine } from './text.js';
5
+
6
+ export type LayoutMode = 'narrow' | 'medium' | 'wide';
7
+
8
+ export interface SwitcherRenderState {
9
+ selectedPaneId?: string;
10
+ scroll?: number;
11
+ useColor?: boolean;
12
+ layoutOverride?: LayoutMode;
13
+ home?: string;
14
+ frameIndex?: number;
15
+ }
16
+
17
+ interface PaneGroupInfo {
18
+ repoKey: string;
19
+ repoTitle: string;
20
+ worktreeKey: string;
21
+ worktreeTitle: string;
22
+ branch?: string;
23
+ path: string;
24
+ isGit: boolean;
25
+ }
26
+
27
+ export interface WorktreeGroup {
28
+ key: string;
29
+ title: string;
30
+ branch?: string;
31
+ path: string;
32
+ isGit: boolean;
33
+ panes: AgentPane[];
34
+ }
35
+
36
+ export interface RepoGroup {
37
+ key: string;
38
+ title: string;
39
+ isGit: boolean;
40
+ worktrees: WorktreeGroup[];
41
+ }
42
+
43
+ const DISCOVERY_FALLBACK_ACTION = 'tmux/process discovery fallback';
44
+
45
+ const WORKING_SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
46
+
47
+ const statusColors: Record<AgentStatus, string> = {
48
+ needs_input: '\x1b[33m',
49
+ stalled: '\x1b[35m',
50
+ working: '\x1b[36m',
51
+ unknown: '\x1b[90m',
52
+ idle: '\x1b[90m',
53
+ };
54
+
55
+ export function chooseLayout(width: number, override?: LayoutMode): LayoutMode {
56
+ if (override) return override;
57
+ if (width < 78) return 'narrow';
58
+ if (width < 116) return 'medium';
59
+ return 'wide';
60
+ }
61
+
62
+ function statusDot(status: AgentStatus, useColor: boolean, frameIndex = 0): string {
63
+ const glyph = status === 'working' ? WORKING_SPINNER[frameIndex % WORKING_SPINNER.length]! : '●';
64
+ return useColor ? `${statusColors[status]}${glyph}\x1b[0m` : glyph;
65
+ }
66
+
67
+ function paneGroup(pane: AgentPane, home?: string): PaneGroupInfo {
68
+ if (pane.git?.repo && pane.git.worktreePath) {
69
+ return {
70
+ repoKey: `git:${pane.git.repo}`,
71
+ repoTitle: pane.git.repo,
72
+ worktreeKey: `git:${pane.git.repo}:${pane.git.worktreePath}`,
73
+ worktreeTitle: `${pane.git.branch || 'unknown'} ${shortPath(pane.git.worktreePath, home)}`,
74
+ branch: pane.git.branch || 'unknown',
75
+ path: pane.git.worktreePath,
76
+ isGit: true,
77
+ };
78
+ }
79
+ const path = pane.cwd || terminalTargetCwd(pane.target) || '(unknown path)';
80
+ return {
81
+ repoKey: 'path-fallback',
82
+ repoTitle: 'Path fallback',
83
+ worktreeKey: `path:${path}`,
84
+ worktreeTitle: shortPath(path, home),
85
+ path,
86
+ isGit: false,
87
+ };
88
+ }
89
+
90
+ function ageSeconds(pane: AgentPane, now: number): number {
91
+ return Math.max(0, Math.floor((now - pane.updatedAt) / 1000));
92
+ }
93
+
94
+ function paneSort(now: number): (a: AgentPane, b: AgentPane) => number {
95
+ return (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status] || b.updatedAt - a.updatedAt || a.id.localeCompare(b.id);
96
+ }
97
+
98
+ function collectionRank(items: AgentPane[]): number {
99
+ return Math.min(...items.map((pane) => STATUS_RANK[pane.status]));
100
+ }
101
+
102
+ function collectionNewest(items: AgentPane[]): number {
103
+ return Math.max(...items.map((pane) => pane.updatedAt));
104
+ }
105
+
106
+ export function groupPanes(panes: AgentPane[], now: number = Date.now(), home?: string): RepoGroup[] {
107
+ const repos = new Map<string, Omit<RepoGroup, 'worktrees'> & { worktrees: Map<string, WorktreeGroup> }>();
108
+ for (const pane of panes.filter((candidate) => isRunningAgentStatus(candidate.status))) {
109
+ const group = paneGroup(pane, home);
110
+ let repo = repos.get(group.repoKey);
111
+ if (!repo) {
112
+ repo = { key: group.repoKey, title: group.repoTitle, isGit: group.isGit, worktrees: new Map() };
113
+ repos.set(group.repoKey, repo);
114
+ }
115
+ let worktree = repo.worktrees.get(group.worktreeKey);
116
+ if (!worktree) {
117
+ worktree = { key: group.worktreeKey, title: group.worktreeTitle, branch: group.branch, path: group.path, isGit: group.isGit, panes: [] };
118
+ repo.worktrees.set(group.worktreeKey, worktree);
119
+ }
120
+ worktree.panes.push(pane);
121
+ }
122
+
123
+ return [...repos.values()]
124
+ .map((repo) => {
125
+ const worktrees = [...repo.worktrees.values()]
126
+ .map((worktree) => ({ ...worktree, panes: [...worktree.panes].sort(paneSort(now)) }))
127
+ .sort((a, b) => collectionRank(a.panes) - collectionRank(b.panes) || collectionNewest(b.panes) - collectionNewest(a.panes));
128
+ return { key: repo.key, title: repo.title, isGit: repo.isGit, worktrees };
129
+ })
130
+ .sort((a, b) => {
131
+ const aPanes = a.worktrees.flatMap((worktree) => worktree.panes);
132
+ const bPanes = b.worktrees.flatMap((worktree) => worktree.panes);
133
+ return collectionRank(aPanes) - collectionRank(bPanes) || collectionNewest(bPanes) - collectionNewest(aPanes);
134
+ });
135
+ }
136
+
137
+ export function selectablePanes(groups: RepoGroup[]): AgentPane[] {
138
+ return groups.flatMap((repo) => repo.worktrees.flatMap((worktree) => worktree.panes));
139
+ }
140
+
141
+ export function moveSelection(panes: AgentPane[], currentPaneId: string | undefined, delta: number): string | undefined {
142
+ if (panes.length === 0) return undefined;
143
+ const index = Math.max(0, panes.findIndex((pane) => pane.id === currentPaneId));
144
+ return panes[(index + delta + panes.length) % panes.length]?.id;
145
+ }
146
+
147
+ function headerLines(width: number, groups: RepoGroup[], layout: LayoutMode, useColor: boolean): string[] {
148
+ const worktreeCount = groups.reduce((count, repo) => count + repo.worktrees.length, 0);
149
+ const paneCount = selectablePanes(groups).length;
150
+ return [
151
+ fit(`${bold('Watcher', useColor)} ${dim(`${groups.length} repos · ${worktreeCount} worktrees · ${paneCount} running agents · repo > worktree/branch > sessions · ${layout}`, useColor)}`, width, useColor),
152
+ fit(dim(line(width), useColor), width, useColor),
153
+ ];
154
+ }
155
+
156
+ function emptyLines(snapshot: SwitcherSnapshot, width: number, height: number, useColor: boolean): string[] {
157
+ const reason = !snapshot.tmuxAvailable
158
+ ? 'tmux is not available or no tmux server is running.'
159
+ : !snapshot.daemonAvailable
160
+ ? 'No Watcher Daemon snapshot is available yet.'
161
+ : 'No running Agent Panes found.';
162
+ const help = !snapshot.tmuxAvailable
163
+ ? 'Start tmux and run agent panes there; Watcher is local-tmux only.'
164
+ : !snapshot.daemonAvailable
165
+ ? 'Run watcher daemon or install integrations; unintegrated discovery arrives in the full switcher.'
166
+ : 'Start pi, claude, codex, or opencode in a tmux pane and try again.';
167
+ const body = [
168
+ '',
169
+ bold('Nothing to activate', useColor),
170
+ reason,
171
+ help,
172
+ '',
173
+ ];
174
+ const topPad = Math.max(0, Math.floor((height - body.length) / 2));
175
+ return [...Array.from({ length: topPad }, () => ''), ...body].slice(0, height).map((value) => fit(value, width, useColor));
176
+ }
177
+
178
+ function repoHeader(repo: RepoGroup, width: number, useColor: boolean): string {
179
+ return fit(bold(repo.title, useColor), width, useColor);
180
+ }
181
+
182
+ function worktreeHeader(worktree: WorktreeGroup, width: number, useColor: boolean, home?: string): string {
183
+ if (worktree.isGit) {
184
+ return fit(` ${bold(worktree.branch || 'unknown', useColor)} ${dim(shortPath(worktree.path, home), useColor)}`, width, useColor);
185
+ }
186
+ return fit(` ${bold(shortPath(worktree.path, home), useColor)}`, width, useColor);
187
+ }
188
+
189
+ function paneRow(pane: AgentPane, width: number, layout: LayoutMode, selectedPane: boolean, useColor: boolean, frameIndex: number): string {
190
+ const dot = selectedPane ? statusDot(pane.status, false, frameIndex) : statusDot(pane.status, useColor, frameIndex);
191
+ const summary = pane.summary || '(no summary yet)';
192
+ const row = layout === 'narrow'
193
+ ? `${dot} ${fit(summary, Math.max(8, width - 8), useColor)}`
194
+ : `${dot} ${fit(pane.agentType, 7, useColor)} ${fit(summary, Math.max(18, width - 18), useColor)}`;
195
+ const padded = fit(` ${row}`, width, useColor);
196
+ return selectedPane ? selected(padded, useColor) : padded;
197
+ }
198
+
199
+ function listLines(groups: RepoGroup[], width: number, layout: LayoutMode, selectedPaneId: string | undefined, useColor: boolean, home: string | undefined, frameIndex: number): { lines: string[]; selectedLineIndex: number } {
200
+ const lines: string[] = [];
201
+ let selectedLineIndex = 0;
202
+ for (const repo of groups) {
203
+ lines.push(repoHeader(repo, width, useColor));
204
+ for (const worktree of repo.worktrees) {
205
+ lines.push(worktreeHeader(worktree, width, useColor, home));
206
+ for (const pane of worktree.panes) {
207
+ if (pane.id === selectedPaneId) selectedLineIndex = lines.length;
208
+ lines.push(paneRow(pane, width, layout, pane.id === selectedPaneId, useColor, frameIndex));
209
+ }
210
+ }
211
+ }
212
+ return { lines, selectedLineIndex };
213
+ }
214
+
215
+ function pagedList(groups: RepoGroup[], width: number, height: number, layout: LayoutMode, state: SwitcherRenderState, selectedPaneId: string | undefined): string[] {
216
+ const useColor = state.useColor ?? false;
217
+ const { lines, selectedLineIndex } = listLines(groups, width, layout, selectedPaneId, useColor, state.home, state.frameIndex ?? 0);
218
+ let scroll = state.scroll ?? 0;
219
+ if (selectedLineIndex < scroll) scroll = selectedLineIndex;
220
+ if (selectedLineIndex >= scroll + height) scroll = selectedLineIndex - height + 1;
221
+ scroll = Math.max(0, Math.min(scroll, Math.max(0, lines.length - height)));
222
+ state.scroll = scroll;
223
+ const visible = lines.slice(scroll, scroll + height);
224
+ if (scroll > 0 && visible.length > 0) visible[0] = fit(dim(`↑ ${scroll} rows hidden`, useColor), width, useColor);
225
+ const hiddenBelow = lines.length - (scroll + height);
226
+ if (hiddenBelow > 0 && visible.length > 1) visible[visible.length - 1] = fit(dim(`↓ ${hiddenBelow} rows hidden`, useColor), width, useColor);
227
+ while (visible.length < height) visible.push(' '.repeat(width));
228
+ return visible.map((value) => fit(value, width, useColor));
229
+ }
230
+
231
+ function tmuxTarget(pane: AgentPane): string {
232
+ return activationTargetLabel(pane);
233
+ }
234
+
235
+ function comparableDetailText(value: string): string {
236
+ return value.replace(/[.…]+$/u, '').trim();
237
+ }
238
+
239
+ function isDuplicateDetailText(candidate: string, existing: string[]): boolean {
240
+ const comparable = comparableDetailText(candidate);
241
+ return existing.some((value) => {
242
+ const other = comparableDetailText(value);
243
+ return comparable === other || comparable.startsWith(other) || other.startsWith(comparable);
244
+ });
245
+ }
246
+
247
+ function uniqueDetailText(values: Array<string | undefined>): string[] {
248
+ const lines: string[] = [];
249
+ for (const value of values) {
250
+ const text = singleLine(value ?? '').trim();
251
+ if (!text || isDuplicateDetailText(text, lines)) continue;
252
+ lines.push(text);
253
+ }
254
+ return lines;
255
+ }
256
+
257
+ function wrapText(value: string | undefined, width: number, maxLines = 4): string[] {
258
+ const text = singleLine(value ?? '').trim();
259
+ if (!text || width <= 0 || maxLines <= 0) return [];
260
+ const words = text.split(/\s+/);
261
+ const lines: string[] = [];
262
+ let current = '';
263
+ for (const word of words) {
264
+ const next = current ? `${current} ${word}` : word;
265
+ if (next.length <= width) {
266
+ current = next;
267
+ continue;
268
+ }
269
+ if (current) lines.push(current);
270
+ current = word.length > width ? `${word.slice(0, Math.max(1, width - 1))}…` : word;
271
+ if (lines.length >= maxLines) break;
272
+ }
273
+ if (current && lines.length < maxLines) lines.push(current);
274
+ if (lines.length === maxLines && words.join(' ').length > lines.join(' ').length) {
275
+ lines[maxLines - 1] = `${lines[maxLines - 1]!.replace(/[.…]+$/u, '').slice(0, Math.max(1, width - 1))}…`;
276
+ }
277
+ return lines;
278
+ }
279
+
280
+ function labelledLine(label: string, value: string | undefined): string | undefined {
281
+ const text = singleLine(value ?? '').trim();
282
+ return text ? `${label.padEnd(10)}${text}` : undefined;
283
+ }
284
+
285
+ function detailSection(title: string, lines: Array<string | undefined>, useColor: boolean): string[] {
286
+ const content = lines.filter((value): value is string => Boolean(value));
287
+ return content.length > 0 ? [bold(title, useColor), ...content] : [];
288
+ }
289
+
290
+ function previewLines(value: string | undefined, width: number, maxLines = 6): string[] {
291
+ if (!value || width <= 0 || maxLines <= 0) return [];
292
+ const lines = value
293
+ .split('\n')
294
+ .map((line) => singleLine(line).trimEnd())
295
+ .filter(Boolean);
296
+ return lines.slice(Math.max(0, lines.length - maxLines)).map((line) => fit(line, width, false));
297
+ }
298
+
299
+ function spacedSections(sections: string[][]): string[] {
300
+ return sections.filter((section) => section.length > 0).flatMap((section, index) => index === 0 ? section : ['', ...section]);
301
+ }
302
+
303
+ function detailContent(pane: AgentPane, now: number, home: string | undefined, width: number, useColor: boolean): string[] {
304
+ const group = paneGroup(pane, home);
305
+ const lastMessage = singleLine(pane.lastMessage ?? '').trim();
306
+ const fallbackSummary = singleLine(pane.summary || '(no summary yet)').trim();
307
+ const userMessage = singleLine(pane.userMessage ?? '').trim();
308
+ const summary = userMessage || fallbackSummary;
309
+ const placeholderSummary = !userMessage && (fallbackSummary === 'Waiting for first task' || fallbackSummary.startsWith('Detected ') || fallbackSummary === 'Finished');
310
+ const showSummary = summary && !placeholderSummary && (userMessage || !isDuplicateDetailText(summary, lastMessage ? [lastMessage] : []));
311
+ const messageWidth = Math.max(12, width - 4);
312
+ const fallbackDiscovered = pane.currentAction === DISCOVERY_FALLBACK_ACTION;
313
+ const taskLines = showSummary && !fallbackDiscovered ? wrapText(summary, messageWidth, 4).map((value) => `${bold('▸', useColor)} ${value}`) : [];
314
+ const activityLines = (pane.status === 'working' || pane.status === 'needs_input')
315
+ ? (pane.activityItems ?? []).flatMap((item) => {
316
+ const marker = item.kind === 'tool' ? '⚙' : '▌';
317
+ const state = item.state && item.kind === 'tool' ? ` ${item.state}` : '';
318
+ const label = `${item.label}${state}`;
319
+ const lines = wrapText(item.text || label, messageWidth, 2);
320
+ return lines.length > 0
321
+ ? lines.map((line, index) => index === 0 ? `${bold(marker, useColor)} ${label} ${line}` : ` ${line}`)
322
+ : [`${bold(marker, useColor)} ${label}`];
323
+ })
324
+ : [];
325
+ const assistantValues = fallbackDiscovered ? [lastMessage] : activityLines.length > 0 ? [] : [lastMessage, pane.currentAction];
326
+ const assistantLines = uniqueDetailText(assistantValues)
327
+ .flatMap((value) => wrapText(value, messageWidth, 5).map((line) => `${bold('▌', useColor)} ${line}`));
328
+ const command = terminalTargetCommand(pane.target);
329
+ const pid = terminalTargetPid(pane.target);
330
+ const cwd = pane.cwd ? shortPath(pane.cwd, home) : undefined;
331
+ const locationPath = shortPath(group.path, home);
332
+ const cwdLine = cwd && cwd !== locationPath ? labelledLine('cwd', cwd) : undefined;
333
+ return spacedSections([
334
+ detailSection('Status', [
335
+ `${statusDot(pane.status, useColor, now)} ${pane.agentType} · ${pane.status} · updated ${formatAge(ageSeconds(pane, now))} ago`,
336
+ fallbackDiscovered ? 'discovered by tmux process scan; no integration events received yet' : undefined,
337
+ pane.reportedStatus && pane.reportedStatus !== pane.status ? `reported ${pane.reportedStatus}` : undefined,
338
+ ], useColor),
339
+ detailSection('User message', taskLines, useColor),
340
+ detailSection('Activity', activityLines, useColor),
341
+ detailSection('Assistant', assistantLines, useColor),
342
+ detailSection(group.isGit ? 'Git worktree' : 'Path fallback', [
343
+ group.isGit ? labelledLine('repo', group.repoTitle) : labelledLine('path', shortPath(group.path, home)),
344
+ group.isGit ? labelledLine('branch', group.branch) : undefined,
345
+ group.isGit ? labelledLine('worktree', locationPath) : undefined,
346
+ cwdLine,
347
+ ], useColor),
348
+ detailSection('Terminal', [
349
+ labelledLine('target', tmuxTarget(pane)),
350
+ labelledLine('backend', pane.target.backend),
351
+ labelledLine('command', command),
352
+ pid === undefined ? undefined : labelledLine('pid', String(pid)),
353
+ ], useColor),
354
+ detailSection('Actions', ['enter activate pane', 'q quit'], useColor),
355
+ ]);
356
+ }
357
+
358
+ function boxed(title: string, content: string[], width: number, height: number, useColor: boolean): string[] {
359
+ if (height <= 0) return [];
360
+ if (width < 24 || height < 3) return content.slice(0, height).map((value) => fit(value, width, useColor));
361
+ const topLabel = ` ${title} `;
362
+ const top = `┌${topLabel}${'─'.repeat(Math.max(0, width - topLabel.length - 2))}┐`;
363
+ const bottom = `└${'─'.repeat(Math.max(0, width - 2))}┘`;
364
+ const innerWidth = width - 2;
365
+ const body = content.slice(0, height - 2).map((value) => `│${fit(value, innerWidth, useColor)}│`);
366
+ while (body.length < height - 2) body.push(`│${' '.repeat(innerWidth)}│`);
367
+ return [fit(dim(top, useColor), width, useColor), ...body, fit(dim(bottom, useColor), width, useColor)];
368
+ }
369
+
370
+ function renderWide(groups: RepoGroup[], width: number, height: number, layout: LayoutMode, state: SwitcherRenderState, selectedPaneId: string, now: number): string[] {
371
+ const useColor = state.useColor ?? false;
372
+ const rightWidth = Math.max(56, Math.floor((width - 3) / 2));
373
+ const leftWidth = width - rightWidth - 3;
374
+ const left = pagedList(groups, leftWidth, height, layout, state, selectedPaneId);
375
+ const pane = selectablePanes(groups).find((candidate) => candidate.id === selectedPaneId) ?? selectablePanes(groups)[0]!;
376
+ const previewContentWidth = rightWidth - 4;
377
+ const hasPreview = previewLines(pane.terminalPreview, previewContentWidth, 1).length > 0;
378
+ const previewHeight = hasPreview ? Math.min(16, Math.max(8, Math.floor(height * 0.38))) : 0;
379
+ const detailsHeight = hasPreview ? Math.max(3, height - previewHeight - 1) : height;
380
+ const details = boxed('details', detailContent(pane, now, state.home, rightWidth - 2, useColor), rightWidth, detailsHeight, useColor);
381
+ const preview = hasPreview
382
+ ? ['', ...boxed('terminal preview', previewLines(pane.terminalPreview, previewContentWidth, Math.max(1, previewHeight - 2)), rightWidth, previewHeight, useColor)]
383
+ : [];
384
+ const right = [...details, ...preview];
385
+ return Array.from({ length: height }, (_, index) => `${fit(left[index] || '', leftWidth, useColor)} ${dim('│', useColor)} ${fit(right[index] || '', rightWidth, useColor)}`);
386
+ }
387
+
388
+ function stateModeLabel(layout: LayoutMode): string {
389
+ return `${layout}:auto`;
390
+ }
391
+
392
+ function helpLines(width: number, layout: LayoutMode, selectedPane: AgentPane | undefined, useColor: boolean, home?: string): string[] {
393
+ const keys = width < 72 ? '↑/↓ select · enter activate · q quit' : '↑/↓ or j/k select · enter activate · q quit';
394
+ if (layout === 'wide' || !selectedPane) return [fit(dim(line(width), useColor), width, useColor), fit(dim(keys, useColor), width, useColor)];
395
+ const group = paneGroup(selectedPane, home);
396
+ const label = group.isGit ? `${group.repoTitle} · ${group.branch} · ${shortPath(group.path, home)}` : `${shortPath(group.path, home)}`;
397
+ const mode = stateModeLabel(layout);
398
+ const selectedState = `${tmuxTarget(selectedPane)} · ${selectedPane.agentType} · ${selectedPane.status} · ${label} · ${mode}`;
399
+ return [
400
+ fit(dim(line(width), useColor), width, useColor),
401
+ fit(`${bold('selected', useColor)} ${selectedState}`, width, useColor),
402
+ fit(dim(keys, useColor), width, useColor),
403
+ ];
404
+ }
405
+
406
+ function finalizeFrame(lines: string[], width: number, height: number, useColor: boolean): string[] {
407
+ const frame = lines.slice(0, height).map((value) => fit(value, width, useColor));
408
+ while (frame.length < height) frame.push(' '.repeat(width));
409
+ return frame;
410
+ }
411
+
412
+ export function renderSwitcherFrame(snapshot: SwitcherSnapshot, width: number, height: number, state: SwitcherRenderState = {}): string[] {
413
+ width = Math.max(24, width);
414
+ height = Math.max(10, height);
415
+ const useColor = state.useColor ?? false;
416
+ const now = snapshot.now ?? Date.now();
417
+ const layout = chooseLayout(width, state.layoutOverride);
418
+ const groups = groupPanes(snapshot.panes, now, state.home);
419
+ const header = headerLines(width, groups, layout, useColor);
420
+ if (groups.length === 0) {
421
+ const help = [fit(dim(line(width), useColor), width, useColor), fit(dim('q / Esc / Ctrl-C quits', useColor), width, useColor)];
422
+ const body = emptyLines(snapshot, width, Math.max(1, height - header.length - help.length), useColor);
423
+ return finalizeFrame([...header, ...body, ...help], width, height, useColor);
424
+ }
425
+ const panes = selectablePanes(groups);
426
+ const selectedPaneId = panes.some((pane) => pane.id === state.selectedPaneId) ? state.selectedPaneId! : panes[0]!.id;
427
+ state.selectedPaneId = selectedPaneId;
428
+ const selectedPane = panes.find((pane) => pane.id === selectedPaneId);
429
+ const help = helpLines(width, layout, selectedPane, useColor, state.home);
430
+ const bodyHeight = Math.max(1, height - header.length - help.length);
431
+ const body = layout === 'wide'
432
+ ? renderWide(groups, width, bodyHeight, layout, state, selectedPaneId, now)
433
+ : pagedList(groups, width, bodyHeight, layout, state, selectedPaneId);
434
+ return finalizeFrame([...header, ...body, ...help], width, height, useColor);
435
+ }
@@ -0,0 +1,58 @@
1
+ import { AgentPane, TerminalTarget, TmuxTarget } from './model.js';
2
+ import { canonicalSurfaceKey, surfaceFromTarget } from './surfaceIdentity.js';
3
+
4
+ export function terminalTargetId(target: TerminalTarget): string {
5
+ return target.id;
6
+ }
7
+
8
+ export function terminalTargetCwd(target: TerminalTarget): string | undefined {
9
+ return target.cwd ?? target.paneCurrentPath;
10
+ }
11
+
12
+ export function terminalTargetTitle(target: TerminalTarget): string | undefined {
13
+ return target.title ?? target.paneTitle ?? target.windowName;
14
+ }
15
+
16
+ export function terminalTargetPid(target: TerminalTarget): number | undefined {
17
+ return target.pid ?? target.panePid;
18
+ }
19
+
20
+ export function terminalTargetCommand(target: TerminalTarget): string | undefined {
21
+ return target.currentCommand ?? target.paneCurrentCommand;
22
+ }
23
+
24
+ export function terminalTargetLabel(target: TerminalTarget): string {
25
+ const session = target.sessionName ?? '?';
26
+ const window = target.windowIndex ?? '?';
27
+ const pane = target.paneIndex ?? '?';
28
+ return `${session}:${window}.${pane} (${target.paneId})`;
29
+ }
30
+
31
+ export function tmuxTarget(fields: Omit<TmuxTarget, 'backend' | 'id'> & { id?: string }): TmuxTarget {
32
+ return {
33
+ backend: 'tmux',
34
+ id: fields.id ?? fields.paneId,
35
+ cwd: fields.cwd ?? fields.paneCurrentPath,
36
+ title: fields.title ?? fields.paneTitle ?? fields.windowName,
37
+ pid: fields.pid ?? fields.panePid,
38
+ currentCommand: fields.currentCommand ?? fields.paneCurrentCommand,
39
+ ...fields,
40
+ };
41
+ }
42
+
43
+ type LegacyAgentPane = Omit<AgentPane, 'target'> & { target?: TerminalTarget; tmux?: TmuxTarget };
44
+
45
+ function paneWithCanonicalTarget(pane: AgentPane | LegacyAgentPane, target: TerminalTarget): AgentPane {
46
+ return { ...pane, id: canonicalSurfaceKey(surfaceFromTarget(target)), target } as AgentPane;
47
+ }
48
+
49
+ export function normalizeAgentPaneTarget(pane: AgentPane | LegacyAgentPane): AgentPane | undefined {
50
+ const candidate = pane as LegacyAgentPane;
51
+ if (candidate.target) return paneWithCanonicalTarget(candidate, candidate.target);
52
+ if (candidate.tmux) return paneWithCanonicalTarget(candidate, tmuxTarget(candidate.tmux));
53
+ return undefined;
54
+ }
55
+
56
+ export function paneTargetLabel(pane: AgentPane): string {
57
+ return terminalTargetLabel(pane.target);
58
+ }
package/src/text.ts ADDED
@@ -0,0 +1,114 @@
1
+ const ANSI_PATTERN = /\x1b\[[0-9;?]*[A-Za-z]/g;
2
+
3
+ export const ansi = {
4
+ reset: '\x1b[0m',
5
+ bold: '\x1b[1m',
6
+ dim: '\x1b[2m',
7
+ inverse: '\x1b[7m',
8
+ selected: '\x1b[1;7m',
9
+ red: '\x1b[31m',
10
+ yellow: '\x1b[33m',
11
+ magenta: '\x1b[35m',
12
+ cyan: '\x1b[36m',
13
+ green: '\x1b[32m',
14
+ gray: '\x1b[90m',
15
+ };
16
+
17
+ export function stripAnsi(value: string): string {
18
+ return String(value).replace(ANSI_PATTERN, '');
19
+ }
20
+
21
+ function charWidth(char: string): number {
22
+ const codePoint = char.codePointAt(0) ?? 0;
23
+ if (codePoint === 0) return 0;
24
+ if (codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0;
25
+ if (/\p{Mark}/u.test(char)) return 0;
26
+ if (/\p{Emoji_Presentation}/u.test(char)) return 2;
27
+ if (
28
+ (codePoint >= 0x1100 && codePoint <= 0x115f)
29
+ || (codePoint >= 0x2329 && codePoint <= 0x232a)
30
+ || (codePoint >= 0x2e80 && codePoint <= 0xa4cf)
31
+ || (codePoint >= 0xac00 && codePoint <= 0xd7a3)
32
+ || (codePoint >= 0xf900 && codePoint <= 0xfaff)
33
+ || (codePoint >= 0xfe10 && codePoint <= 0xfe19)
34
+ || (codePoint >= 0xfe30 && codePoint <= 0xfe6f)
35
+ || (codePoint >= 0xff00 && codePoint <= 0xff60)
36
+ || (codePoint >= 0xffe0 && codePoint <= 0xffe6)
37
+ ) return 2;
38
+ return 1;
39
+ }
40
+
41
+ export function visibleLength(value: string): number {
42
+ return [...stripAnsi(value)].reduce((total, char) => total + charWidth(char), 0);
43
+ }
44
+
45
+ export function paint(code: string, value: unknown, useColor: boolean): string {
46
+ return useColor ? `${code}${String(value)}${ansi.reset}` : String(value);
47
+ }
48
+
49
+ export function bold(value: unknown, useColor: boolean): string {
50
+ return paint(ansi.bold, value, useColor);
51
+ }
52
+
53
+ export function dim(value: unknown, useColor: boolean): string {
54
+ return paint(ansi.dim, value, useColor);
55
+ }
56
+
57
+ export function selected(value: unknown, useColor: boolean): string {
58
+ return paint(ansi.selected, value, useColor);
59
+ }
60
+
61
+ export function singleLine(value: unknown): string {
62
+ return String(value).replace(/[\r\n\t]+/g, ' ');
63
+ }
64
+
65
+ export function clip(value: unknown, width: number, useColor = false): string {
66
+ if (width <= 0) return '';
67
+ const text = singleLine(value);
68
+ if (visibleLength(text) <= width) return text;
69
+ if (width === 1) return '…';
70
+ let out = '';
71
+ let length = 0;
72
+ for (let index = 0; index < text.length;) {
73
+ if (text[index] === '\x1b') {
74
+ const match = text.slice(index).match(/^\x1b\[[0-9;?]*[A-Za-z]/);
75
+ if (match) {
76
+ out += match[0];
77
+ index += match[0].length;
78
+ continue;
79
+ }
80
+ }
81
+ const char = Array.from(text.slice(index))[0] ?? '';
82
+ if (length >= width - 1) break;
83
+ out += char;
84
+ length += 1;
85
+ index += char.length;
86
+ }
87
+ return useColor ? `${out}…${ansi.reset}` : `${out}…`;
88
+ }
89
+
90
+ export function fit(value: unknown, width: number, useColor = false): string {
91
+ const clipped = clip(value, width, useColor);
92
+ return clipped + ' '.repeat(Math.max(0, width - visibleLength(clipped)));
93
+ }
94
+
95
+ export function line(width: number): string {
96
+ return '─'.repeat(Math.max(0, width));
97
+ }
98
+
99
+ export function basename(path: string): string {
100
+ const cleaned = path.replace(/\/+$/, '');
101
+ return cleaned.split('/').filter(Boolean).at(-1) || path;
102
+ }
103
+
104
+ export function shortPath(path: string, home = process.env.HOME): string {
105
+ if (home && path.startsWith(home)) return `~${path.slice(home.length)}`;
106
+ return path;
107
+ }
108
+
109
+ export function formatAge(seconds: number): string {
110
+ if (seconds < 60) return `${Math.max(0, Math.floor(seconds))}s`;
111
+ const minutes = Math.floor(seconds / 60);
112
+ if (minutes < 60) return `${minutes}m`;
113
+ return `${Math.floor(minutes / 60)}h`;
114
+ }
package/src/tmux.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export interface CommandRunner {
7
+ execFile(file: string, args: string[], options?: { timeout?: number; env?: NodeJS.ProcessEnv }): Promise<{ stdout: string; stderr: string }>;
8
+ }
9
+
10
+ export const nodeCommandRunner: CommandRunner = {
11
+ async execFile(file, args, options) {
12
+ const result = await execFileAsync(file, args, { timeout: options?.timeout ?? 2000, env: options?.env, encoding: 'utf8' });
13
+ return { stdout: String(result.stdout), stderr: String(result.stderr) };
14
+ },
15
+ };
16
+
17
+ export async function hasTmuxServer(runner: CommandRunner = nodeCommandRunner): Promise<boolean> {
18
+ try {
19
+ await runner.execFile('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { timeout: 1000 });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }