Skip to content

Commit 35e8fb0

Browse files
committed
Add termnial focus manager
1 parent 592a3aa commit 35e8fb0

File tree

2 files changed

+385
-0
lines changed

2 files changed

+385
-0
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
2+
import { TerminalFocusManager } from '../../lib/TerminalFocusManager';
3+
4+
const mockExec = jest.fn();
5+
jest.mock('child_process', () => ({
6+
exec: (cmd: string, cb: any) => mockExec(cmd, cb)
7+
}));
8+
9+
// Mock getProcessTty - we use requireActual to keep other exports if needed,
10+
// strictly we only need getProcessTty here.
11+
jest.mock('../../util/process', () => ({
12+
getProcessTty: jest.fn(),
13+
}));
14+
import { getProcessTty } from '../../util/process';
15+
16+
describe('TerminalFocusManager', () => {
17+
let manager: TerminalFocusManager;
18+
const mockGetProcessTty = getProcessTty as unknown as jest.Mock;
19+
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
manager = new TerminalFocusManager();
23+
});
24+
25+
describe('findTerminal', () => {
26+
it('should return null if process TTY is missing', async () => {
27+
mockGetProcessTty.mockReturnValue('?');
28+
const result = await manager.findTerminal(123);
29+
expect(result).toBeNull();
30+
});
31+
32+
it('should detect tmux pane', async () => {
33+
mockGetProcessTty.mockReturnValue('ttys001');
34+
35+
mockExec.mockImplementation((cmd: any, callback: any) => {
36+
37+
if (cmd.includes('tmux list-panes')) {
38+
callback(null, { stdout: '/dev/ttys002|session:0.1\n/dev/ttys001|my-session:1.2\n', stderr: '' });
39+
} else {
40+
// Default fallback
41+
callback(null, { stdout: '', stderr: '' });
42+
}
43+
});
44+
45+
const result = await manager.findTerminal(123);
46+
47+
expect(result).toEqual({
48+
type: 'tmux',
49+
identifier: 'my-session:1.2',
50+
tty: '/dev/ttys001'
51+
});
52+
});
53+
54+
it('should detect iTerm2 session', async () => {
55+
mockGetProcessTty.mockReturnValue('ttys001');
56+
57+
mockExec.mockImplementation((cmd: any, callback: any) => {
58+
if (cmd.includes('tmux')) {
59+
// Not found in tmux
60+
callback(null, { stdout: '/dev/ttys002|session:0.1', stderr: '' });
61+
} else if (cmd.includes('pgrep -x iTerm2')) {
62+
callback(null, { stdout: '12345', stderr: '' });
63+
} else if (cmd.includes('osascript') && cmd.includes('tell application "iTerm"')) {
64+
callback(null, { stdout: 'found', stderr: '' });
65+
} else {
66+
callback(null, { stdout: '', stderr: '' });
67+
}
68+
});
69+
70+
const result = await manager.findTerminal(123);
71+
72+
expect(result).toEqual({
73+
type: 'iterm2',
74+
identifier: '/dev/ttys001',
75+
tty: '/dev/ttys001'
76+
});
77+
});
78+
79+
it('should detect Terminal.app window', async () => {
80+
mockGetProcessTty.mockReturnValue('ttys001');
81+
82+
mockExec.mockImplementation((cmd: any, callback: any) => {
83+
if (cmd.includes('tmux')) {
84+
callback(null, { stdout: '', stderr: '' });
85+
} else if (cmd.includes('pgrep -x iTerm2')) {
86+
callback(null, { stdout: 'no', stderr: '' });
87+
} else if (cmd.includes('pgrep -x Terminal')) {
88+
callback(null, { stdout: '54321', stderr: '' });
89+
} else if (cmd.includes('osascript') && cmd.includes('tell application "Terminal"')) {
90+
callback(null, { stdout: 'found', stderr: '' });
91+
} else {
92+
callback(null, { stdout: '', stderr: '' });
93+
}
94+
});
95+
96+
const result = await manager.findTerminal(123);
97+
98+
expect(result).toEqual({
99+
type: 'terminal-app',
100+
identifier: '/dev/ttys001',
101+
tty: '/dev/ttys001'
102+
});
103+
});
104+
105+
it('should return unknown type if no specific terminal found', async () => {
106+
mockGetProcessTty.mockReturnValue('ttys001');
107+
108+
mockExec.mockImplementation((cmd: any, callback: any) => {
109+
if (cmd.includes('tmux')) callback(null, { stdout: '', stderr: '' });
110+
else if (cmd.includes('pgrep')) callback(null, { stdout: 'no', stderr: '' });
111+
else callback(null, { stdout: '', stderr: '' });
112+
});
113+
114+
const result = await manager.findTerminal(123);
115+
116+
expect(result).toEqual({
117+
type: 'unknown',
118+
identifier: '',
119+
tty: '/dev/ttys001'
120+
});
121+
});
122+
});
123+
124+
describe('focusTerminal', () => {
125+
it('should focus tmux pane', async () => {
126+
mockExec.mockImplementation((cmd: any, callback: any) => callback(null, { stdout: '', stderr: '' }));
127+
128+
const result = await manager.focusTerminal({
129+
type: 'tmux',
130+
identifier: 'session:1.1',
131+
tty: '/dev/ttys001'
132+
});
133+
134+
expect(result).toBe(true);
135+
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('tmux switch-client -t session:1.1'), expect.any(Function));
136+
});
137+
138+
it('should focus iTerm2 session', async () => {
139+
mockExec.mockImplementation((cmd: any, callback: any) => callback(null, { stdout: 'true', stderr: '' }));
140+
141+
const result = await manager.focusTerminal({
142+
type: 'iterm2',
143+
identifier: '/dev/ttys001',
144+
tty: '/dev/ttys001'
145+
});
146+
147+
expect(result).toBe(true);
148+
// Verify AppleScript contains activate and selection logic
149+
// Note: in Jest mocks, calls are arrays [args]
150+
const calls = (mockExec as any).mock.calls;
151+
const itermCall = calls.find((args: any[]) => args[0].includes('tell application "iTerm"'));
152+
153+
expect(itermCall).toBeDefined();
154+
if (itermCall) {
155+
expect(itermCall[0]).toContain('activate');
156+
expect(itermCall[0]).toContain('select s');
157+
}
158+
});
159+
160+
it('should focus Terminal.app window', async () => {
161+
mockExec.mockImplementation((cmd: any, callback: any) => callback(null, { stdout: 'true', stderr: '' }));
162+
163+
const result = await manager.focusTerminal({
164+
type: 'terminal-app',
165+
identifier: '/dev/ttys001',
166+
tty: '/dev/ttys001'
167+
});
168+
169+
expect(result).toBe(true);
170+
const calls = (mockExec as any).mock.calls;
171+
const terminalCall = calls.find((args: any[]) => args[0].includes('tell application "Terminal"'));
172+
173+
expect(terminalCall).toBeDefined();
174+
if (terminalCall) {
175+
expect(terminalCall[0]).toContain('activate');
176+
expect(terminalCall[0]).toContain('set selected tab of w to t');
177+
}
178+
});
179+
});
180+
});
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { exec } from 'child_process';
2+
import { promisify } from 'util';
3+
import { getProcessTty } from '../util/process';
4+
5+
const execAsync = promisify(exec);
6+
7+
export interface TerminalLocation {
8+
type: 'tmux' | 'iterm2' | 'terminal-app' | 'unknown';
9+
identifier: string; // e.g., "session:window.pane" for tmux, or TTY for others
10+
tty: string; // e.g., "/dev/ttys030"
11+
}
12+
13+
export class TerminalFocusManager {
14+
/**
15+
* Find the terminal location (emulator info) for a given process ID
16+
*/
17+
async findTerminal(pid: number): Promise<TerminalLocation | null> {
18+
const ttyShort = getProcessTty(pid);
19+
20+
// If no TTY or invalid, we can't find the terminal
21+
if (!ttyShort || ttyShort === '?') {
22+
return null;
23+
}
24+
25+
const fullTty = `/dev/${ttyShort}`;
26+
27+
// 1. Check tmux (most specific if running inside it)
28+
const tmuxLocation = await this.findTmuxPane(fullTty);
29+
if (tmuxLocation) return tmuxLocation;
30+
31+
// 2. Check iTerm2
32+
const itermLocation = await this.findITerm2Session(fullTty);
33+
if (itermLocation) return itermLocation;
34+
35+
// 3. Check Terminal.app
36+
const terminalAppLocation = await this.findTerminalAppWindow(fullTty);
37+
if (terminalAppLocation) return terminalAppLocation;
38+
39+
// 4. Fallback: we know the TTY but not the emulator wrapper
40+
return {
41+
type: 'unknown',
42+
identifier: '',
43+
tty: fullTty
44+
};
45+
}
46+
47+
/**
48+
* Focus the terminal identified by the location
49+
*/
50+
async focusTerminal(location: TerminalLocation): Promise<boolean> {
51+
try {
52+
switch (location.type) {
53+
case 'tmux':
54+
return await this.focusTmuxPane(location.identifier);
55+
case 'iterm2':
56+
return await this.focusITerm2Session(location.tty);
57+
case 'terminal-app':
58+
return await this.focusTerminalAppWindow(location.tty);
59+
default:
60+
return false;
61+
}
62+
} catch (error) {
63+
return false;
64+
}
65+
}
66+
67+
private async findTmuxPane(tty: string): Promise<TerminalLocation | null> {
68+
try {
69+
// List all panes with their TTYs and identifiers
70+
// Format: /dev/ttys001|my-session:1.1
71+
// using | as separator to handle spaces in session names
72+
const { stdout } = await execAsync("tmux list-panes -a -F '#{pane_tty}|#{session_name}:#{window_index}.#{pane_index}'");
73+
74+
const lines = stdout.trim().split('\n');
75+
for (const line of lines) {
76+
if (!line.trim()) continue;
77+
const [paneTty, identifier] = line.split('|');
78+
if (paneTty === tty && identifier) {
79+
return {
80+
type: 'tmux',
81+
identifier,
82+
tty
83+
};
84+
}
85+
}
86+
} catch (error) {
87+
// tmux might not be installed or running
88+
}
89+
return null;
90+
}
91+
92+
private async findITerm2Session(tty: string): Promise<TerminalLocation | null> {
93+
try {
94+
// Check if iTerm2 is running first to avoid launching it
95+
const { stdout: isRunning } = await execAsync('pgrep -x iTerm2 || echo "no"');
96+
if (isRunning.trim() === "no") return null;
97+
98+
const script = `
99+
tell application "iTerm"
100+
repeat with w in windows
101+
repeat with t in tabs of w
102+
repeat with s in sessions of t
103+
if tty of s is "${tty}" then
104+
return "found"
105+
end if
106+
end repeat
107+
end repeat
108+
end repeat
109+
end tell
110+
`;
111+
112+
const { stdout } = await execAsync(`osascript -e '${script}'`);
113+
if (stdout.trim() === "found") {
114+
return {
115+
type: 'iterm2',
116+
identifier: tty,
117+
tty
118+
};
119+
}
120+
} catch (error) {
121+
// iTerm2 not found or script failed
122+
}
123+
return null;
124+
}
125+
126+
private async findTerminalAppWindow(tty: string): Promise<TerminalLocation | null> {
127+
try {
128+
// Check if Terminal is running
129+
const { stdout: isRunning } = await execAsync('pgrep -x Terminal || echo "no"');
130+
if (isRunning.trim() === "no") return null;
131+
132+
const script = `
133+
tell application "Terminal"
134+
repeat with w in windows
135+
repeat with t in tabs of w
136+
if tty of t is "${tty}" then
137+
return "found"
138+
end if
139+
end repeat
140+
end repeat
141+
end tell
142+
`;
143+
144+
const { stdout } = await execAsync(`osascript -e '${script}'`);
145+
if (stdout.trim() === "found") {
146+
return {
147+
type: 'terminal-app',
148+
identifier: tty,
149+
tty
150+
};
151+
}
152+
} catch (error) {
153+
// Terminal not found or script failed
154+
}
155+
return null;
156+
}
157+
158+
private async focusTmuxPane(identifier: string): Promise<boolean> {
159+
try {
160+
await execAsync(`tmux switch-client -t ${identifier}`);
161+
return true;
162+
} catch (error) {
163+
return false;
164+
}
165+
}
166+
167+
private async focusITerm2Session(tty: string): Promise<boolean> {
168+
const script = `
169+
tell application "iTerm"
170+
activate
171+
repeat with w in windows
172+
repeat with t in tabs of w
173+
repeat with s in sessions of t
174+
if tty of s is "${tty}" then
175+
select s
176+
return "true"
177+
end if
178+
end repeat
179+
end repeat
180+
end repeat
181+
end tell
182+
`;
183+
const { stdout } = await execAsync(`osascript -e '${script}'`);
184+
return stdout.trim() === "true";
185+
}
186+
187+
private async focusTerminalAppWindow(tty: string): Promise<boolean> {
188+
const script = `
189+
tell application "Terminal"
190+
activate
191+
repeat with w in windows
192+
repeat with t in tabs of w
193+
if tty of t is "${tty}" then
194+
set index of w to 1
195+
set selected tab of w to t
196+
return "true"
197+
end if
198+
end repeat
199+
end repeat
200+
end tell
201+
`;
202+
const { stdout } = await execAsync(`osascript -e '${script}'`);
203+
return stdout.trim() === "true";
204+
}
205+
}

0 commit comments

Comments
 (0)