-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathtmux_utils.ts
More file actions
193 lines (169 loc) · 7.13 KB
/
tmux_utils.ts
File metadata and controls
193 lines (169 loc) · 7.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
/**
* @license
* Copyright 2026 Steven A. Thompson
* SPDX-License-Identifier: MIT
*/
import { execSync } from 'child_process';
import { FileLock } from './file_lock.js';
export const SESSION_NAME = process.env.GEMINI_TMUX_SESSION_NAME || 'gemini-cli';
/**
* Checks if the current environment is running inside the 'gemini-cli' tmux session.
* @returns {boolean} True if the session exists and we are inside it, false otherwise.
*/
export function isInsideTmuxSession(): boolean {
// 1. Check if the TMUX environment variable is set (indicates we are in a tmux client)
if (!process.env.TMUX) {
return false;
}
try {
// 2. Query tmux for the current session name
const currentSessionName = execSync('tmux display-message -p "#S"', { encoding: 'utf-8' }).trim();
return currentSessionName === SESSION_NAME;
} catch (error) {
// If tmux command fails, assume not in a valid session
return false;
}
}
/**
* Waits for the tmux pane to become stable (no content changes) for a specified duration.
* @param target The tmux target (e.g. "session:0.0")
* @param stableDurationMs How long the content must remain unchanged to be considered stable.
* @param pollingIntervalMs How often to check.
* @param timeoutMs Maximum time to wait before giving up.
* @returns {Promise<boolean>} True if stable, false if timed out.
*/
export async function waitForStability(target: string, stableDurationMs: number = 10000, pollingIntervalMs: number = 1000, timeoutMs: number = 300000): Promise<boolean> {
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const requiredChecks = Math.ceil(stableDurationMs / pollingIntervalMs);
let lastContent = '';
let stableChecks = 0;
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
await delay(pollingIntervalMs);
let currentContent = '';
try {
// Capture the full pane content
const textContent = execSync(`tmux capture-pane -p -t ${target}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
// Capture cursor position to detect movement (which text capture misses)
const cursorPosition = execSync(`tmux display-message -p -t ${target} "#{cursor_x},#{cursor_y}"`, { encoding: 'utf-8' }).trim();
// Combine both for the stability signature
currentContent = `${textContent}\n__CURSOR__:${cursorPosition}`;
} catch (e) {
continue;
}
if (currentContent === lastContent) {
stableChecks++;
} else {
stableChecks = 0;
lastContent = currentContent;
}
if (stableChecks >= requiredChecks) {
return true; // Stable
}
}
return false; // Timed out
}
/**
* Sends a notification to the tmux pane.
* Safely waits for a brief moment of stability before typing to avoid interruption.
* Serialized via file lock to prevent garbled output.
* @param target The tmux target.
* @param message The message to send.
* @param skipStabilityCheck If true, skips the stability check. Use only if caller just verified stability.
*/
export async function sendNotification(target: string, message: string, skipStabilityCheck: boolean = false) {
// Use a longer timeout (10 minutes) for the lock to accommodate waitForStability
const lock = new FileLock('gemini-tmux-notification', 500, 1200);
if (await lock.acquire()) {
try {
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Ensure stability before notifying (don't interrupt typing)
if (!skipStabilityCheck) {
await waitForStability(target, 10000, 1000, 300000);
}
// Clear input
try {
execSync(`tmux send-keys -t ${target} Escape`);
await delay(100);
execSync(`tmux send-keys -t ${target} C-u`);
await delay(200);
for (const char of message) {
const escapedChar = char === "'" ? "'\\''" : char;
execSync(`tmux send-keys -t ${target} '${escapedChar}'`);
await delay(20);
}
await delay(500);
execSync(`tmux send-keys -t ${target} Enter`);
} catch (e) {
// Ignore errors if tmux is gone
console.error(`Failed to notify Gemini via tmux: ${e}`);
}
} finally {
lock.release();
}
} else {
console.error('Failed to acquire lock for notification (timeout).');
}
}
/**
* Sends keys to a specific tmux target.
* @param target The tmux target (e.g. "session:0.0", "%1").
* @param keys The keys string to send.
*/
export function sendKeys(target: string, keys: string) {
// Escape single quotes in the keys string to prevent shell injection/breaking
const escapedKeys = keys.replace(/'/g, "'\\''");
execSync(`tmux send-keys -t ${target} '${escapedKeys}'`);
}
/**
* Captures the content of a tmux pane.
* @param target The tmux target.
* @param lines Optional: Number of lines to capture (from the bottom).
* @returns {string} The captured text.
*/
export function capturePane(target: string, lines?: number): string {
const args = ['capture-pane', '-p', '-t', target];
if (lines) {
args.push('-S', `-${lines}`);
}
// execSync returns a Buffer, we decode it.
// Use spawnSync here to handle arguments safer than template literal for execSync
// Actually, execSync is fine if we are careful, but let's stick to the pattern or just execSync with escaping if simple.
// Given the simplicity, direct execSync with careful construction is okay, but `child_process.execSync` takes a command string.
// Let's use the existing execSync import but construct the command carefully.
// For capture-pane, arguments are flags.
let cmd = `tmux capture-pane -p -t ${target}`;
if (lines) {
cmd += ` -S -${lines}`;
}
return execSync(cmd, { encoding: 'utf-8' });
}
/**
* Splits the window and optionally runs a command.
* @param command Optional command to run in the new pane.
* @param direction 'vertical' (split top/bottom) or 'horizontal' (split left/right). Default is vertical.
* @returns {string} The ID of the new pane (e.g. "%2").
*/
export function splitWindow(command?: string, direction: 'vertical' | 'horizontal' = 'vertical'): string {
let cmd = 'tmux split-window -P -F "#{pane_id}"';
if (direction === 'horizontal') {
cmd += ' -h';
} else {
cmd += ' -v';
}
if (command) {
// We need to pass the command as an argument.
// We'll wrap it in single quotes and escape existing single quotes.
const escapedCommand = command.replace(/'/g, "'\\''");
cmd += ` '${escapedCommand}'`;
}
const paneId = execSync(cmd, { encoding: 'utf-8' }).trim();
return paneId;
}
/**
* Kills a specific tmux pane.
* @param target The tmux target (e.g. "%1").
*/
export function killPane(target: string) {
execSync(`tmux kill-pane -t ${target}`);
}