ai-code-connect 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,1341 @@
1
+ import { spawn, execSync } from 'child_process';
2
+ import { createInterface } from 'readline';
3
+ import * as pty from 'node-pty';
4
+ import { marked } from 'marked';
5
+ import TerminalRenderer from 'marked-terminal';
6
+ import { stripAnsi } from './utils.js';
7
+ import { getDefaultTool, setDefaultTool } from './config.js';
8
+ /**
9
+ * Get the version of a CLI tool
10
+ */
11
+ function getToolVersion(command) {
12
+ try {
13
+ const output = execSync(`${command} -v 2>/dev/null`, { encoding: 'utf-8' }).trim();
14
+ // Extract version number (first line, clean up)
15
+ const firstLine = output.split('\n')[0];
16
+ // Handle formats like "2.0.59 (Claude Code)" or just "0.19.1"
17
+ const version = firstLine.split(' ')[0];
18
+ return version || null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ // Configure marked to render markdown for terminal with colors
25
+ marked.setOptions({
26
+ // @ts-ignore - marked-terminal types not fully compatible
27
+ renderer: new TerminalRenderer({
28
+ // Customize colors
29
+ codespan: (code) => `\x1b[93m${code}\x1b[0m`, // Yellow for inline code
30
+ strong: (text) => `\x1b[1m${text}\x1b[0m`, // Bold
31
+ em: (text) => `\x1b[3m${text}\x1b[0m`, // Italic
32
+ })
33
+ });
34
+ // Detach key codes - multiple options for compatibility across terminals
35
+ // Traditional raw control characters (used by Terminal.app and others)
36
+ const DETACH_KEYS = {
37
+ CTRL_BRACKET: 0x1d, // Ctrl+] = 0x1D = 29
38
+ CTRL_BACKSLASH: 0x1c, // Ctrl+\ = 0x1C = 28
39
+ CTRL_CARET: 0x1e, // Ctrl+^ = 0x1E = 30 (Ctrl+Shift+6 on US keyboards)
40
+ CTRL_UNDERSCORE: 0x1f, // Ctrl+_ = 0x1F = 31 (Ctrl+Shift+- on US keyboards)
41
+ ESCAPE: 0x1b, // Escape = 0x1B = 27
42
+ };
43
+ // CSI u sequences - modern keyboard protocol used by iTerm2
44
+ // Format: ESC [ <keycode> ; <modifiers> u
45
+ // Modifier 5 = Ctrl (4) + 1
46
+ const CSI_U_DETACH_SEQS = [
47
+ '\x1b[93;5u', // Ctrl+] (keycode 93 = ])
48
+ '\x1b[92;5u', // Ctrl+\ (keycode 92 = \)
49
+ '\x1b[54;5u', // Ctrl+^ / Ctrl+6 (keycode 54 = 6)
50
+ '\x1b[45;5u', // Ctrl+_ / Ctrl+- (keycode 45 = -)
51
+ '\x1b[54;6u', // Ctrl+Shift+6 (modifier 6 = Ctrl+Shift)
52
+ '\x1b[45;6u', // Ctrl+Shift+- (modifier 6 = Ctrl+Shift)
53
+ ];
54
+ // Terminal sequences to filter out
55
+ // Focus reporting - sent by terminals when window gains/loses focus
56
+ const FOCUS_IN_SEQ = '\x1b[I'; // ESC [ I - Focus gained
57
+ const FOCUS_OUT_SEQ = '\x1b[O'; // ESC [ O - Focus lost
58
+ // Regex to match terminal response sequences we want to filter
59
+ // These include Device Attributes responses, cursor position reports, etc.
60
+ const TERMINAL_RESPONSE_REGEX = /\x1b\[\??[\d;]*[a-zA-Z]/g;
61
+ // For backwards compatibility
62
+ const DETACH_KEY = '\x1d';
63
+ // Spinner frames
64
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
65
+ // ANSI cursor control
66
+ const cursor = {
67
+ show: '\x1b[?25h',
68
+ hide: '\x1b[?25l',
69
+ blockBlink: '\x1b[1 q',
70
+ blockSteady: '\x1b[2 q',
71
+ underlineBlink: '\x1b[3 q',
72
+ underlineSteady: '\x1b[4 q',
73
+ barBlink: '\x1b[5 q',
74
+ barSteady: '\x1b[6 q',
75
+ };
76
+ // ANSI Color codes
77
+ const colors = {
78
+ reset: '\x1b[0m',
79
+ bold: '\x1b[1m',
80
+ dim: '\x1b[2m',
81
+ // Foreground
82
+ cyan: '\x1b[36m',
83
+ magenta: '\x1b[35m',
84
+ yellow: '\x1b[33m',
85
+ green: '\x1b[32m',
86
+ blue: '\x1b[34m',
87
+ red: '\x1b[31m',
88
+ white: '\x1b[37m',
89
+ gray: '\x1b[90m',
90
+ // Bright foreground
91
+ brightCyan: '\x1b[96m',
92
+ brightMagenta: '\x1b[95m',
93
+ brightYellow: '\x1b[93m',
94
+ brightGreen: '\x1b[92m',
95
+ brightBlue: '\x1b[94m',
96
+ brightWhite: '\x1b[97m',
97
+ // Background
98
+ bgBlue: '\x1b[44m',
99
+ bgMagenta: '\x1b[45m',
100
+ bgCyan: '\x1b[46m',
101
+ };
102
+ // Rainbow colors for animated effect
103
+ const RAINBOW_COLORS = [
104
+ '\x1b[91m', // bright red
105
+ '\x1b[93m', // bright yellow
106
+ '\x1b[92m', // bright green
107
+ '\x1b[96m', // bright cyan
108
+ '\x1b[94m', // bright blue
109
+ '\x1b[95m', // bright magenta
110
+ ];
111
+ /**
112
+ * Apply rainbow gradient to text (static)
113
+ */
114
+ function rainbowText(text, offset = 0) {
115
+ let result = '';
116
+ for (let i = 0; i < text.length; i++) {
117
+ const colorIndex = (i + offset) % RAINBOW_COLORS.length;
118
+ result += RAINBOW_COLORS[colorIndex] + text[i];
119
+ }
120
+ return result + colors.reset;
121
+ }
122
+ /**
123
+ * Animate rainbow text in place
124
+ */
125
+ function animateRainbow(text, duration = 600) {
126
+ return new Promise((resolve) => {
127
+ let offset = 0;
128
+ const startTime = Date.now();
129
+ const animate = () => {
130
+ const elapsed = Date.now() - startTime;
131
+ if (elapsed >= duration) {
132
+ // Final render
133
+ process.stdout.write('\r' + rainbowText(text, offset) + ' ');
134
+ resolve();
135
+ return;
136
+ }
137
+ process.stdout.write('\r' + rainbowText(text, offset));
138
+ offset = (offset + 1) % RAINBOW_COLORS.length;
139
+ setTimeout(animate, 50);
140
+ };
141
+ animate();
142
+ });
143
+ }
144
+ // Get terminal width (with fallback)
145
+ function getTerminalWidth() {
146
+ return process.stdout.columns || 80;
147
+ }
148
+ // Create a full-width horizontal line
149
+ function fullWidthLine(char = '═', color = colors.dim) {
150
+ const width = getTerminalWidth();
151
+ return `${color}${char.repeat(width)}${colors.reset}`;
152
+ }
153
+ // ASCII Art banner for AIC² (larger version)
154
+ const AIC_BANNER = `
155
+ ${colors.brightCyan} ██████╗ ${colors.brightMagenta}██╗${colors.brightYellow} ██████╗${colors.reset} ${colors.dim}²${colors.reset}
156
+ ${colors.brightCyan} ██╔══██╗ ${colors.brightMagenta}██║${colors.brightYellow}██╔════╝${colors.reset}
157
+ ${colors.brightCyan} ███████║ ${colors.brightMagenta}██║${colors.brightYellow}██║ ${colors.reset}
158
+ ${colors.brightCyan} ██╔══██║ ${colors.brightMagenta}██║${colors.brightYellow}██║ ${colors.reset}
159
+ ${colors.brightCyan} ██║ ██║ ${colors.brightMagenta}██║${colors.brightYellow}╚██████╗${colors.reset}
160
+ ${colors.brightCyan} ╚═╝ ╚═╝ ${colors.brightMagenta}╚═╝${colors.brightYellow} ╚═════╝${colors.reset}
161
+ `;
162
+ const VERSION = 'v1.0.0';
163
+ const AVAILABLE_TOOLS = [
164
+ { name: 'claude', displayName: 'Claude Code', color: colors.brightCyan },
165
+ { name: 'gemini', displayName: 'Gemini CLI', color: colors.brightMagenta },
166
+ // Add new tools here, e.g.:
167
+ // { name: 'codex', displayName: 'Codex CLI', color: colors.brightGreen },
168
+ ];
169
+ function getToolConfig(name) {
170
+ return AVAILABLE_TOOLS.find(t => t.name === name);
171
+ }
172
+ function getToolColor(name) {
173
+ return getToolConfig(name)?.color || colors.white;
174
+ }
175
+ function getToolDisplayName(name) {
176
+ return getToolConfig(name)?.displayName || name;
177
+ }
178
+ // AIC command definitions (single slash for AIC commands)
179
+ const AIC_COMMANDS = [
180
+ { value: '/claude', name: `${rainbowText('/claude')} Switch to Claude Code`, description: 'Switch to Claude Code' },
181
+ { value: '/gemini', name: `${rainbowText('/gemini', 1)} Switch to Gemini CLI`, description: 'Switch to Gemini CLI' },
182
+ { value: '/i', name: `${rainbowText('/i', 2)} Enter interactive mode`, description: 'Enter interactive mode (Ctrl+] or Ctrl+\\ to detach)' },
183
+ { value: '/forward', name: `${rainbowText('/forward', 3)} Forward last response`, description: 'Forward response: /forward [tool] [msg]' },
184
+ { value: '/fwd', name: `${rainbowText('/fwd', 4)} Forward (alias)`, description: 'Forward response: /fwd [tool] [msg]' },
185
+ { value: '/history', name: `${rainbowText('/history', 4)} Show conversation`, description: 'Show conversation history' },
186
+ { value: '/status', name: `${rainbowText('/status', 5)} Show running processes`, description: 'Show daemon status' },
187
+ { value: '/default', name: `${rainbowText('/default', 0)} Set default tool`, description: 'Set default tool: /default <claude|gemini>' },
188
+ { value: '/help', name: `${rainbowText('/help', 1)} Show help`, description: 'Show available commands' },
189
+ { value: '/clear', name: `${rainbowText('/clear', 2)} Clear sessions`, description: 'Clear sessions and history' },
190
+ { value: '/quit', name: `${rainbowText('/quit', 3)} Exit`, description: 'Exit AIC' },
191
+ { value: '/cya', name: `${rainbowText('/cya', 4)} Exit (alias)`, description: 'Exit AIC' },
192
+ ];
193
+ function drawBox(content, width = 50) {
194
+ const top = `${colors.gray}╭${'─'.repeat(width - 2)}╮${colors.reset}`;
195
+ const bottom = `${colors.gray}╰${'─'.repeat(width - 2)}╯${colors.reset}`;
196
+ const lines = content.map(line => {
197
+ const padding = width - 4 - stripAnsiLength(line);
198
+ return `${colors.gray}│${colors.reset} ${line}${' '.repeat(Math.max(0, padding))} ${colors.gray}│${colors.reset}`;
199
+ });
200
+ return [top, ...lines, bottom].join('\n');
201
+ }
202
+ function stripAnsiLength(str) {
203
+ return str.replace(/\x1b\[[0-9;]*m/g, '').length;
204
+ }
205
+ function colorize(text, color) {
206
+ return `${color}${text}${colors.reset}`;
207
+ }
208
+ class Spinner {
209
+ intervalId = null;
210
+ frameIndex = 0;
211
+ message;
212
+ constructor(message = 'Thinking') {
213
+ this.message = message;
214
+ }
215
+ start() {
216
+ this.frameIndex = 0;
217
+ // Clear any garbage on the current line before starting spinner
218
+ process.stdout.write('\x1b[2K\r');
219
+ process.stdout.write(`\n${SPINNER_FRAMES[0]} ${this.message}...`);
220
+ this.intervalId = setInterval(() => {
221
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
222
+ // Move cursor back and overwrite
223
+ process.stdout.write(`\r${SPINNER_FRAMES[this.frameIndex]} ${this.message}...`);
224
+ }, 80);
225
+ }
226
+ stop() {
227
+ if (this.intervalId) {
228
+ clearInterval(this.intervalId);
229
+ this.intervalId = null;
230
+ // Clear the spinner line
231
+ process.stdout.write('\r' + ' '.repeat(this.message.length + 15) + '\r');
232
+ }
233
+ }
234
+ }
235
+ /**
236
+ * Session with persistent interactive mode support
237
+ * - Regular messages: uses -p (print mode) with --continue/--resume
238
+ * - Interactive mode: persistent PTY process, detach with Ctrl+]
239
+ */
240
+ export class SDKSession {
241
+ isRunning = false;
242
+ activeTool;
243
+ conversationHistory = [];
244
+ // Session tracking (for print mode)
245
+ claudeHasSession = false;
246
+ geminiHasSession = false;
247
+ // Persistent PTY processes for interactive mode
248
+ runningProcesses = new Map();
249
+ // Buffer to capture interactive mode output for forwarding
250
+ interactiveOutputBuffer = new Map();
251
+ // Working directory
252
+ cwd;
253
+ // Readline interface for input with history
254
+ rl = null;
255
+ inputHistory = [];
256
+ constructor(cwd) {
257
+ this.cwd = cwd || process.cwd();
258
+ // Load default tool from config (or env var)
259
+ this.activeTool = getDefaultTool();
260
+ }
261
+ async start() {
262
+ // Ensure cursor is visible
263
+ process.stdout.write(cursor.show + cursor.blockBlink);
264
+ const width = getTerminalWidth();
265
+ // Get tool versions to check availability
266
+ const claudeVersion = getToolVersion('claude');
267
+ const geminiVersion = getToolVersion('gemini');
268
+ const claudeAvailable = claudeVersion !== null;
269
+ const geminiAvailable = geminiVersion !== null;
270
+ const availableCount = (claudeAvailable ? 1 : 0) + (geminiAvailable ? 1 : 0);
271
+ // Handle no tools available
272
+ if (availableCount === 0) {
273
+ console.log('');
274
+ console.log(`${colors.red}✗ No AI tools found!${colors.reset}`);
275
+ console.log('');
276
+ console.log(`${colors.dim}AIC² bridges multiple AI CLI tools. Please install both:${colors.reset}`);
277
+ console.log('');
278
+ console.log(` ${colors.brightCyan}Claude Code${colors.reset}: npm install -g @anthropic-ai/claude-code`);
279
+ console.log(` ${colors.brightMagenta}Gemini CLI${colors.reset}: npm install -g @google/gemini-cli`);
280
+ console.log('');
281
+ process.exit(1);
282
+ }
283
+ // Handle only one tool available
284
+ if (availableCount === 1) {
285
+ const availableTool = claudeAvailable ? 'Claude Code' : 'Gemini CLI';
286
+ const availableCmd = claudeAvailable ? 'claude' : 'gemini';
287
+ const missingTool = claudeAvailable ? 'Gemini CLI' : 'Claude Code';
288
+ const missingInstall = claudeAvailable
289
+ ? 'npm install -g @google/gemini-cli'
290
+ : 'npm install -g @anthropic-ai/claude-code';
291
+ console.log('');
292
+ console.log(`${colors.yellow}⚠ Only ${availableTool} found${colors.reset}`);
293
+ console.log('');
294
+ console.log(`${colors.dim}AIC² bridges multiple AI tools - you need both installed.${colors.reset}`);
295
+ console.log(`${colors.dim}Install ${missingTool}:${colors.reset}`);
296
+ console.log(` ${missingInstall}`);
297
+ console.log('');
298
+ console.log(`${colors.dim}Or use ${availableTool} directly:${colors.reset} ${availableCmd}`);
299
+ console.log('');
300
+ process.exit(1);
301
+ }
302
+ // Clear screen and show splash
303
+ console.clear();
304
+ // Top separator
305
+ console.log('');
306
+ console.log(fullWidthLine('═'));
307
+ console.log('');
308
+ // Banner with title and connected tools on the right side
309
+ const bannerLines = AIC_BANNER.trim().split('\n');
310
+ const titleLines = [
311
+ `${colors.brightCyan}A${colors.brightMagenta}I${colors.reset} ${colors.brightYellow}C${colors.white}ode${colors.reset} ${colors.brightYellow}C${colors.white}onnect${colors.reset} ${colors.dim}${VERSION}${colors.reset}`,
312
+ '',
313
+ `${colors.dim}Connected Tools:${colors.reset}`,
314
+ claudeVersion
315
+ ? `✅ ${colors.brightCyan}Claude Code${colors.reset} ${colors.dim}v${claudeVersion}${colors.reset}`
316
+ : `❌ ${colors.dim}Claude Code (not found)${colors.reset}`,
317
+ geminiVersion
318
+ ? `✅ ${colors.brightMagenta}Gemini CLI${colors.reset} ${colors.dim}v${geminiVersion}${colors.reset}`
319
+ : `❌ ${colors.dim}Gemini CLI (not found)${colors.reset}`,
320
+ '',
321
+ `${colors.dim}📁 ${this.cwd}${colors.reset}`,
322
+ ];
323
+ // Print banner and title side by side, centered
324
+ const maxLines = Math.max(bannerLines.length, titleLines.length);
325
+ const bannerWidth = 30; // Approximate width of banner
326
+ const gap = 10;
327
+ const maxTitleWidth = Math.max(...titleLines.map(l => stripAnsiLength(l)));
328
+ const totalContentWidth = bannerWidth + gap + maxTitleWidth;
329
+ const leftPadding = Math.max(2, Math.floor((width - totalContentWidth) / 2));
330
+ for (let i = 0; i < maxLines; i++) {
331
+ const bannerLine = bannerLines[i] || '';
332
+ const titleLine = titleLines[i] || '';
333
+ console.log(`${' '.repeat(leftPadding)}${bannerLine}${' '.repeat(Math.max(0, bannerWidth - stripAnsiLength(bannerLine) + gap))}${titleLine}`);
334
+ }
335
+ console.log('');
336
+ console.log(fullWidthLine('─'));
337
+ console.log('');
338
+ // Commands in a wider layout (single slash = AIC commands, double slash = tool commands via interactive mode)
339
+ const commandsLeft = [
340
+ ` ${rainbowText('/claude')} Switch to Claude Code`,
341
+ ` ${rainbowText('/gemini', 1)} Switch to Gemini CLI`,
342
+ ` ${rainbowText('/i', 2)} Enter interactive mode`,
343
+ ` ${rainbowText('/forward', 3)} Forward response ${colors.dim}[tool] [msg]${colors.reset}`,
344
+ ];
345
+ const commandsRight = [
346
+ ` ${rainbowText('/history', 4)} Show conversation`,
347
+ ` ${rainbowText('/status', 5)} Show running processes`,
348
+ ` ${rainbowText('/clear', 0)} Clear sessions`,
349
+ ` ${rainbowText('/quit', 1)} Exit ${colors.dim}(or /cya)${colors.reset}`,
350
+ ];
351
+ // Print commands side by side if terminal is wide enough
352
+ if (width >= 100) {
353
+ const colWidth = Math.floor(width / 2) - 5;
354
+ for (let i = 0; i < commandsLeft.length; i++) {
355
+ const left = commandsLeft[i] || '';
356
+ const right = commandsRight[i] || '';
357
+ const leftPadded = left + ' '.repeat(Math.max(0, colWidth - stripAnsiLength(left)));
358
+ console.log(`${leftPadded}${right}`);
359
+ }
360
+ }
361
+ else {
362
+ // Single column for narrow terminals
363
+ commandsLeft.forEach(cmd => console.log(cmd));
364
+ commandsRight.forEach(cmd => console.log(cmd));
365
+ }
366
+ console.log('');
367
+ // Tips section
368
+ console.log(` ${colors.dim}💡 ${colors.brightYellow}//command${colors.dim} opens interactive mode & sends the command. ${colors.white}Use ${colors.brightYellow}Ctrl+]${colors.white}, ${colors.brightYellow}Ctrl+\\${colors.white}, or ${colors.brightYellow}Esc Esc${colors.white} to return to aic²${colors.reset}`);
369
+ console.log(` ${colors.dim}💡 ${colors.brightYellow}Tab${colors.dim}: autocomplete ${colors.brightYellow}↑/↓${colors.dim}: history${colors.reset}`);
370
+ console.log('');
371
+ // Show active tool with full width separator
372
+ const toolColor = this.activeTool === 'claude' ? colors.brightCyan : colors.brightMagenta;
373
+ const toolName = this.activeTool === 'claude' ? 'Claude Code' : 'Gemini CLI';
374
+ console.log(fullWidthLine('═'));
375
+ console.log(` ${colors.green}●${colors.reset} Active: ${toolColor}${toolName}${colors.reset}`);
376
+ console.log(fullWidthLine('─'));
377
+ console.log('');
378
+ this.isRunning = true;
379
+ await this.runLoop();
380
+ }
381
+ getPrompt() {
382
+ const toolColor = this.activeTool === 'claude' ? colors.brightCyan : colors.brightMagenta;
383
+ const toolName = this.activeTool === 'claude' ? 'claude' : 'gemini';
384
+ return `${toolColor}❯ ${toolName}${colors.reset} ${colors.dim}→${colors.reset} `;
385
+ }
386
+ /**
387
+ * Tab completion for / commands
388
+ */
389
+ completer(line) {
390
+ const commands = ['/claude', '/gemini', '/i', '/forward', '/fwd', '/history', '/status', '/default', '/help', '/clear', '/quit', '/cya'];
391
+ // Only complete if line starts with /
392
+ if (line.startsWith('/')) {
393
+ const hits = commands.filter(c => c.startsWith(line));
394
+ // Show all commands if no specific match, or show matches
395
+ return [hits.length ? hits : commands, line];
396
+ }
397
+ // No completion for regular input
398
+ return [[], line];
399
+ }
400
+ setupReadline() {
401
+ this.rl = createInterface({
402
+ input: process.stdin,
403
+ output: process.stdout,
404
+ completer: this.completer.bind(this),
405
+ history: this.inputHistory,
406
+ historySize: 100,
407
+ prompt: this.getPrompt(),
408
+ });
409
+ // Handle Ctrl+C gracefully
410
+ this.rl.on('SIGINT', () => {
411
+ console.log('\n');
412
+ this.rl?.close();
413
+ this.cleanup().then(() => {
414
+ console.log(`${colors.brightYellow}👋 Goodbye!${colors.reset}\n`);
415
+ process.exit(0);
416
+ });
417
+ });
418
+ }
419
+ async runLoop() {
420
+ this.setupReadline();
421
+ await this.promptLoop();
422
+ }
423
+ async promptLoop() {
424
+ while (this.isRunning) {
425
+ const input = await this.readInput();
426
+ if (!input || !input.trim())
427
+ continue;
428
+ const trimmed = input.trim();
429
+ // Add to history (readline handles this, but we track for persistence)
430
+ if (trimmed && !this.inputHistory.includes(trimmed)) {
431
+ this.inputHistory.push(trimmed);
432
+ // Keep history manageable
433
+ if (this.inputHistory.length > 100) {
434
+ this.inputHistory.shift();
435
+ }
436
+ }
437
+ // Handle double slash - enter interactive mode and send the command
438
+ // e.g., //status -> enters interactive mode, sends /status, user stays in control
439
+ if (trimmed.startsWith('//')) {
440
+ const slashCmd = trimmed.slice(1); // e.g., "/status"
441
+ const toolName = this.activeTool === 'claude' ? 'Claude Code' : 'Gemini CLI';
442
+ // Show rainbow "Entering Interactive Mode" message
443
+ await animateRainbow(`Entering Interactive Mode for ${toolName}...`, 500);
444
+ process.stdout.write('\n');
445
+ await this.enterInteractiveModeWithCommand(slashCmd);
446
+ continue;
447
+ }
448
+ // Handle AIC meta commands (single slash)
449
+ if (trimmed.startsWith('/')) {
450
+ // Readline already echoed the command - just process it, no extra output
451
+ await this.handleMetaCommand(trimmed.slice(1));
452
+ continue;
453
+ }
454
+ // Send regular input to active tool
455
+ await this.sendToTool(trimmed);
456
+ }
457
+ }
458
+ readInput() {
459
+ return new Promise((resolve) => {
460
+ // Update prompt in case tool changed
461
+ this.rl?.setPrompt(this.getPrompt());
462
+ this.rl?.prompt();
463
+ const lineHandler = (line) => {
464
+ // Filter out terminal garbage that may have leaked into the input
465
+ let cleaned = line
466
+ .replace(/\x1b\[I/g, '')
467
+ .replace(/\x1b\[O/g, '')
468
+ .replace(/\^\[\[I/g, '')
469
+ .replace(/\^\[\[O/g, '')
470
+ .replace(/\x1b\[[\d;]*[a-zA-Z]/g, '');
471
+ // AGGRESSIVE STRIP: Remove Device Attribute response suffixes
472
+ // e.g. "/cya;1;2;...;52c" -> "/cya"
473
+ // Pattern: semicolon followed by numbers/semicolons ending in c at the end of string
474
+ cleaned = cleaned.replace(/;[\d;]+c$/, '');
475
+ // Also strip if it's just the garbage on its own line
476
+ cleaned = cleaned.replace(/^\d*u?[\d;]+c$/, '');
477
+ cleaned = cleaned.trim();
478
+ // If the line was ONLY garbage (and now empty), ignore it
479
+ if (line.length > 0 && cleaned.length === 0) {
480
+ this.rl?.prompt();
481
+ return;
482
+ }
483
+ // Valid input
484
+ this.rl?.removeListener('line', lineHandler);
485
+ resolve(cleaned);
486
+ };
487
+ this.rl?.on('line', lineHandler);
488
+ });
489
+ }
490
+ async handleMetaCommand(cmd) {
491
+ const parts = cmd.split(/\s+/);
492
+ const command = parts[0].toLowerCase();
493
+ switch (command) {
494
+ case 'quit':
495
+ case 'exit':
496
+ case 'cya':
497
+ await this.cleanup();
498
+ console.log(`\n${colors.brightYellow}👋 Goodbye!${colors.reset}\n`);
499
+ this.isRunning = false;
500
+ process.exit(0);
501
+ break;
502
+ case 'claude':
503
+ this.activeTool = 'claude';
504
+ console.log(`${colors.green}●${colors.reset} Switched to ${colors.brightCyan}Claude Code${colors.reset}`);
505
+ break;
506
+ case 'gemini':
507
+ this.activeTool = 'gemini';
508
+ console.log(`${colors.green}●${colors.reset} Switched to ${colors.brightMagenta}Gemini CLI${colors.reset}`);
509
+ break;
510
+ case 'forward':
511
+ case 'fwd':
512
+ await this.handleForward(parts.slice(1).join(' '));
513
+ break;
514
+ case 'interactive':
515
+ case 'shell':
516
+ case 'i':
517
+ await this.enterInteractiveMode();
518
+ break;
519
+ case 'history':
520
+ this.showHistory();
521
+ break;
522
+ case 'status':
523
+ this.showStatus();
524
+ break;
525
+ case 'clear':
526
+ await this.cleanup();
527
+ this.claudeHasSession = false;
528
+ this.geminiHasSession = false;
529
+ this.conversationHistory = [];
530
+ console.log('Sessions and history cleared.');
531
+ break;
532
+ case 'default':
533
+ const toolArg = parts[1];
534
+ if (toolArg) {
535
+ // Set new default
536
+ const result = setDefaultTool(toolArg);
537
+ if (result.success) {
538
+ console.log(`${colors.green}✓${colors.reset} ${result.message}`);
539
+ }
540
+ else {
541
+ console.log(`${colors.red}✗${colors.reset} ${result.message}`);
542
+ }
543
+ }
544
+ else {
545
+ // Show current default
546
+ const currentDefault = getDefaultTool();
547
+ console.log(`${colors.dim}Current default tool:${colors.reset} ${colors.brightYellow}${currentDefault}${colors.reset}`);
548
+ console.log(`${colors.dim}Usage:${colors.reset} /default <claude|gemini>`);
549
+ }
550
+ break;
551
+ case 'help':
552
+ case '?':
553
+ this.showHelp();
554
+ break;
555
+ default:
556
+ console.log(`${colors.red}✗${colors.reset} Unknown AIC command: ${colors.brightYellow}/${command}${colors.reset}`);
557
+ console.log(`${colors.dim} Type ${colors.brightYellow}/help${colors.dim} to see available commands.${colors.reset}`);
558
+ console.log(`${colors.dim} To send /${command} to the tool, use ${colors.brightYellow}//${command}${colors.reset}`);
559
+ }
560
+ }
561
+ showHelp() {
562
+ console.log('');
563
+ console.log(`${colors.brightCyan}A${colors.brightMagenta}I${colors.reset} ${colors.brightYellow}C${colors.white}ode${colors.reset} ${colors.brightYellow}C${colors.white}onnect${colors.reset}² ${colors.dim}- Commands${colors.reset}`);
564
+ console.log('');
565
+ console.log(`${colors.white}Session Commands:${colors.reset}`);
566
+ console.log(` ${rainbowText('/claude')} Switch to Claude Code`);
567
+ console.log(` ${rainbowText('/gemini')} Switch to Gemini CLI`);
568
+ console.log(` ${rainbowText('/i')} Enter interactive mode ${colors.dim}(Ctrl+] or Ctrl+\\ to detach)${colors.reset}`);
569
+ console.log(` ${rainbowText('/forward')} Forward last response ${colors.dim}[tool] [msg]${colors.reset}`);
570
+ console.log(` ${rainbowText('/history')} Show conversation history`);
571
+ console.log(` ${rainbowText('/status')} Show running processes`);
572
+ console.log(` ${rainbowText('/default')} Set default tool ${colors.dim}<claude|gemini>${colors.reset}`);
573
+ console.log(` ${rainbowText('/clear')} Clear sessions and history`);
574
+ console.log(` ${rainbowText('/help')} Show this help`);
575
+ console.log(` ${rainbowText('/quit')} Exit ${colors.dim}(or /cya)${colors.reset}`);
576
+ console.log('');
577
+ console.log(`${colors.white}Tool Commands:${colors.reset}`);
578
+ console.log(` ${colors.brightYellow}//command${colors.reset} Send /command to the active tool`);
579
+ console.log(` ${colors.dim} Opens interactive mode, sends command, Ctrl+] or Ctrl+\\ to return${colors.reset}`);
580
+ console.log('');
581
+ console.log(`${colors.white}Tips:${colors.reset}`);
582
+ console.log(` ${colors.dim}•${colors.reset} ${colors.brightYellow}Tab${colors.reset} Autocomplete commands`);
583
+ console.log(` ${colors.dim}•${colors.reset} ${colors.brightYellow}↑/↓${colors.reset} Navigate history`);
584
+ console.log(` ${colors.dim}•${colors.reset} ${colors.brightYellow}Ctrl+]${colors.reset}, ${colors.brightYellow}Ctrl+\\${colors.reset}, ${colors.brightYellow}Ctrl+^${colors.reset}, or ${colors.brightYellow}Ctrl+_${colors.reset} Detach from interactive mode`);
585
+ console.log(` ${colors.dim}•${colors.reset} ${colors.brightYellow}Esc Esc${colors.reset} Detach (press Escape twice quickly)`);
586
+ console.log('');
587
+ }
588
+ async sendToTool(message) {
589
+ // Record user message
590
+ this.conversationHistory.push({
591
+ tool: this.activeTool,
592
+ role: 'user',
593
+ content: message,
594
+ });
595
+ try {
596
+ let response;
597
+ if (this.activeTool === 'claude') {
598
+ response = await this.sendToClaude(message);
599
+ }
600
+ else {
601
+ response = await this.sendToGemini(message);
602
+ }
603
+ // Record assistant response
604
+ this.conversationHistory.push({
605
+ tool: this.activeTool,
606
+ role: 'assistant',
607
+ content: response,
608
+ });
609
+ }
610
+ catch (error) {
611
+ console.error(`\nError: ${error instanceof Error ? error.message : error}\n`);
612
+ // Remove the user message if failed
613
+ this.conversationHistory.pop();
614
+ }
615
+ }
616
+ sendToClaude(message) {
617
+ return new Promise((resolve, reject) => {
618
+ const args = ['-p']; // Print mode for regular messages
619
+ // Continue session if we have one
620
+ if (this.claudeHasSession) {
621
+ args.push('--continue');
622
+ }
623
+ // Add the message
624
+ args.push(message);
625
+ // Start spinner
626
+ const spinner = new Spinner(`${colors.brightCyan}Claude${colors.reset} is thinking`);
627
+ spinner.start();
628
+ const proc = spawn('claude', args, {
629
+ cwd: this.cwd,
630
+ stdio: ['ignore', 'pipe', 'pipe'],
631
+ env: process.env,
632
+ });
633
+ let stdout = '';
634
+ let stderr = '';
635
+ proc.stdout.on('data', (data) => {
636
+ stdout += data.toString();
637
+ });
638
+ proc.stderr.on('data', (data) => {
639
+ stderr += data.toString();
640
+ });
641
+ proc.on('close', (code) => {
642
+ spinner.stop();
643
+ if (code !== 0) {
644
+ reject(new Error(`Claude exited with code ${code}: ${stderr || stdout}`));
645
+ }
646
+ else {
647
+ // Render the response with markdown formatting
648
+ console.log('');
649
+ const rendered = marked.parse(stdout.trim());
650
+ process.stdout.write(rendered);
651
+ console.log('');
652
+ this.claudeHasSession = true;
653
+ resolve(stripAnsi(stdout).trim());
654
+ }
655
+ });
656
+ proc.on('error', (err) => {
657
+ spinner.stop();
658
+ reject(err);
659
+ });
660
+ });
661
+ }
662
+ sendToGemini(message) {
663
+ return new Promise((resolve, reject) => {
664
+ const args = [];
665
+ // Resume session if we have one
666
+ if (this.geminiHasSession) {
667
+ args.push('--resume', 'latest');
668
+ }
669
+ // Add the message
670
+ args.push(message);
671
+ // Start spinner
672
+ const spinner = new Spinner(`${colors.brightMagenta}Gemini${colors.reset} is thinking`);
673
+ spinner.start();
674
+ const proc = spawn('gemini', args, {
675
+ cwd: this.cwd,
676
+ stdio: ['ignore', 'pipe', 'pipe'],
677
+ env: process.env,
678
+ });
679
+ let stdout = '';
680
+ let stderr = '';
681
+ proc.stdout.on('data', (data) => {
682
+ stdout += data.toString();
683
+ });
684
+ proc.stderr.on('data', (data) => {
685
+ stderr += data.toString();
686
+ });
687
+ proc.on('close', (code) => {
688
+ spinner.stop();
689
+ if (code !== 0) {
690
+ reject(new Error(`Gemini exited with code ${code}: ${stderr || stdout}`));
691
+ }
692
+ else {
693
+ // Render the response with markdown formatting
694
+ console.log('');
695
+ const rendered = marked.parse(stdout.trim());
696
+ process.stdout.write(rendered);
697
+ console.log('');
698
+ this.geminiHasSession = true;
699
+ resolve(stripAnsi(stdout).trim());
700
+ }
701
+ });
702
+ proc.on('error', (err) => {
703
+ spinner.stop();
704
+ reject(err);
705
+ });
706
+ });
707
+ }
708
+ /**
709
+ * Enter full interactive mode with the active tool.
710
+ * - If a process is already running, re-attach to it
711
+ * - If not, spawn a new one
712
+ * - Press Ctrl+] to detach (process keeps running)
713
+ * - Use /exit in the tool to terminate the process
714
+ */
715
+ async enterInteractiveMode() {
716
+ const toolName = this.activeTool === 'claude' ? 'Claude Code' : 'Gemini CLI';
717
+ const toolColor = this.activeTool === 'claude' ? colors.brightCyan : colors.brightMagenta;
718
+ const command = this.activeTool;
719
+ // Check if we already have a running process
720
+ let ptyProcess = this.runningProcesses.get(this.activeTool);
721
+ const isReattach = ptyProcess !== undefined;
722
+ // Pause readline to prevent interference with raw input
723
+ this.rl?.pause();
724
+ if (isReattach) {
725
+ console.log(`\n${colors.green}↩${colors.reset} Re-attaching to ${toolColor}${toolName}${colors.reset}...`);
726
+ }
727
+ else {
728
+ console.log(`\n${colors.green}▶${colors.reset} Starting ${toolColor}${toolName}${colors.reset} interactive mode...`);
729
+ }
730
+ console.log(`${colors.dim}Press ${colors.brightYellow}Ctrl+]${colors.dim} or ${colors.brightYellow}Ctrl+\\${colors.dim} to detach • ${colors.white}/exit${colors.dim} to terminate${colors.reset}\n`);
731
+ // Clear the output buffer for fresh capture
732
+ this.interactiveOutputBuffer.set(this.activeTool, '');
733
+ // Interactive mode takes over stdin
734
+ return new Promise((resolve) => {
735
+ // Spawn new process if needed
736
+ if (!ptyProcess) {
737
+ const args = [];
738
+ // Continue/resume session if we have history from print mode
739
+ if (this.activeTool === 'claude' && this.claudeHasSession) {
740
+ args.push('--continue');
741
+ }
742
+ else if (this.activeTool === 'gemini' && this.geminiHasSession) {
743
+ args.push('--resume', 'latest');
744
+ }
745
+ ptyProcess = pty.spawn(command, args, {
746
+ name: 'xterm-256color',
747
+ cols: process.stdout.columns || 80,
748
+ rows: process.stdout.rows || 24,
749
+ cwd: this.cwd,
750
+ env: process.env,
751
+ });
752
+ // Store the process
753
+ this.runningProcesses.set(this.activeTool, ptyProcess);
754
+ // Handle process exit (user typed /exit in the tool)
755
+ ptyProcess.onExit(({ exitCode }) => {
756
+ console.log(`\n${colors.dim}${toolName} exited (code ${exitCode})${colors.reset}`);
757
+ this.runningProcesses.delete(this.activeTool);
758
+ // Mark session as having history
759
+ if (this.activeTool === 'claude') {
760
+ this.claudeHasSession = true;
761
+ }
762
+ else {
763
+ this.geminiHasSession = true;
764
+ }
765
+ });
766
+ }
767
+ // Handle resize
768
+ const onResize = () => {
769
+ ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
770
+ };
771
+ process.stdout.on('resize', onResize);
772
+ // Pipe PTY output to terminal AND capture for forwarding
773
+ const outputDisposable = ptyProcess.onData((data) => {
774
+ // Filter out terminal response sequences (DA responses, etc.)
775
+ // These can cause garbage like "0u64;1;2;4;6;..." to appear
776
+ let filteredData = data;
777
+ // Filter focus sequences from output too
778
+ filteredData = filteredData.split(FOCUS_IN_SEQ).join('').split(FOCUS_OUT_SEQ).join('');
779
+ if (filteredData.length > 0) {
780
+ process.stdout.write(filteredData);
781
+ }
782
+ // Capture output for potential forwarding
783
+ const current = this.interactiveOutputBuffer.get(this.activeTool) || '';
784
+ this.interactiveOutputBuffer.set(this.activeTool, current + data);
785
+ });
786
+ // Set up stdin forwarding with detach key detection
787
+ if (process.stdin.isTTY) {
788
+ process.stdin.setRawMode(true);
789
+ }
790
+ process.stdin.resume();
791
+ let detached = false;
792
+ let lastEscapeTime = 0;
793
+ const performDetach = () => {
794
+ if (detached)
795
+ return;
796
+ detached = true;
797
+ cleanup();
798
+ // Save captured output to conversation history for forwarding
799
+ const capturedOutput = this.interactiveOutputBuffer.get(this.activeTool);
800
+ if (capturedOutput) {
801
+ const cleanedOutput = stripAnsi(capturedOutput).trim();
802
+ if (cleanedOutput.length > 50) { // Only save meaningful output
803
+ this.conversationHistory.push({
804
+ tool: this.activeTool,
805
+ role: 'assistant',
806
+ content: cleanedOutput,
807
+ });
808
+ }
809
+ // Clear buffer after saving
810
+ this.interactiveOutputBuffer.set(this.activeTool, '');
811
+ }
812
+ // Clear any pending terminal responses before showing detach message
813
+ process.stdout.write('\x1b[2K\r'); // Clear current line
814
+ console.log(`\n\n${colors.yellow}⏸${colors.reset} Detached from ${toolColor}${toolName}${colors.reset} ${colors.dim}(still running)${colors.reset}`);
815
+ console.log(`${colors.dim}Use ${colors.brightYellow}/i${colors.dim} to re-attach • ${colors.brightGreen}/forward${colors.dim} to send to other tool${colors.reset}`);
816
+ console.log(`${colors.dim}Press ${colors.brightYellow}Enter${colors.dim} to continue${colors.reset}\n`);
817
+ resolve();
818
+ };
819
+ // Debug mode - set AIC_DEBUG=1 to see key codes
820
+ const debugKeys = process.env.AIC_DEBUG === '1';
821
+ const onStdinData = (data) => {
822
+ let str = data.toString();
823
+ // Debug output to see what keys are being received
824
+ if (debugKeys) {
825
+ const hexBytes = Array.from(data).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' ');
826
+ console.log(`\n[DEBUG] Received ${data.length} bytes: ${hexBytes}`);
827
+ }
828
+ // Filter out terminal focus reporting sequences (sent when window gains/loses focus)
829
+ // These cause ^[[I and ^[[O to appear in the terminal
830
+ if (str === FOCUS_IN_SEQ || str === FOCUS_OUT_SEQ) {
831
+ if (debugKeys)
832
+ console.log('[DEBUG] Filtered focus event');
833
+ return; // Don't forward to PTY
834
+ }
835
+ // Also filter if focus sequences are embedded in the data
836
+ str = str.split(FOCUS_IN_SEQ).join('').split(FOCUS_OUT_SEQ).join('');
837
+ if (str.length === 0) {
838
+ return; // Nothing left after filtering
839
+ }
840
+ // Check for CSI u sequences (modern keyboard protocol used by iTerm2)
841
+ for (const seq of CSI_U_DETACH_SEQS) {
842
+ if (str === seq || str.includes(seq)) {
843
+ if (debugKeys)
844
+ console.log(`[DEBUG] Detected CSI u detach sequence: ${seq.replace('\x1b', 'ESC')}`);
845
+ performDetach();
846
+ return;
847
+ }
848
+ }
849
+ // Check for detach keys by examining raw bytes (traditional terminals)
850
+ // This is more reliable than string comparison for control characters
851
+ for (let i = 0; i < data.length; i++) {
852
+ const byte = data[i];
853
+ // Ctrl+] (0x1D = 29) - primary detach key
854
+ if (byte === DETACH_KEYS.CTRL_BRACKET) {
855
+ if (debugKeys)
856
+ console.log('[DEBUG] Detected Ctrl+]');
857
+ performDetach();
858
+ return;
859
+ }
860
+ // Ctrl+\ (0x1C = 28) - alternative detach key
861
+ if (byte === DETACH_KEYS.CTRL_BACKSLASH) {
862
+ if (debugKeys)
863
+ console.log('[DEBUG] Detected Ctrl+\\');
864
+ performDetach();
865
+ return;
866
+ }
867
+ // Ctrl+^ (0x1E = 30) - another alternative (Ctrl+Shift+6)
868
+ if (byte === DETACH_KEYS.CTRL_CARET) {
869
+ if (debugKeys)
870
+ console.log('[DEBUG] Detected Ctrl+^');
871
+ performDetach();
872
+ return;
873
+ }
874
+ // Ctrl+_ (0x1F = 31) - another alternative (Ctrl+Shift+-)
875
+ if (byte === DETACH_KEYS.CTRL_UNDERSCORE) {
876
+ if (debugKeys)
877
+ console.log('[DEBUG] Detected Ctrl+_');
878
+ performDetach();
879
+ return;
880
+ }
881
+ }
882
+ // Handle escape sequences for double-escape detection
883
+ // Check for immediate double escape (0x1B 0x1B) anywhere in buffer
884
+ if (data.length >= 2) {
885
+ for (let i = 0; i < data.length - 1; i++) {
886
+ if (data[i] === DETACH_KEYS.ESCAPE && data[i + 1] === DETACH_KEYS.ESCAPE) {
887
+ if (debugKeys)
888
+ console.log('[DEBUG] Detected double-Escape');
889
+ performDetach();
890
+ return;
891
+ }
892
+ }
893
+ }
894
+ // Single Escape (0x1B) - track for double-escape detection
895
+ const isSingleEscape = data.length === 1 && data[0] === DETACH_KEYS.ESCAPE;
896
+ if (isSingleEscape) {
897
+ const now = Date.now();
898
+ if (now - lastEscapeTime < 500) {
899
+ // Double escape detected - detach!
900
+ if (debugKeys)
901
+ console.log('[DEBUG] Detected double-Escape (timed)');
902
+ performDetach();
903
+ return;
904
+ }
905
+ lastEscapeTime = now;
906
+ // Still forward the escape to the PTY
907
+ ptyProcess.write(str);
908
+ return;
909
+ }
910
+ // Reset escape timer if not an escape key
911
+ if (!isSingleEscape) {
912
+ lastEscapeTime = 0;
913
+ }
914
+ // Forward filtered data to PTY
915
+ ptyProcess.write(str);
916
+ };
917
+ process.stdin.on('data', onStdinData);
918
+ // Handle process exit while attached
919
+ const exitHandler = () => {
920
+ if (!detached) {
921
+ cleanup();
922
+ console.log(`\n${colors.dim}Returned to ${colors.brightYellow}aic${colors.reset}\n`);
923
+ resolve();
924
+ }
925
+ };
926
+ ptyProcess.onExit(exitHandler);
927
+ // Cleanup function
928
+ let cleanedUp = false;
929
+ const cleanup = () => {
930
+ if (cleanedUp)
931
+ return;
932
+ cleanedUp = true;
933
+ process.stdin.removeListener('data', onStdinData);
934
+ process.stdout.removeListener('resize', onResize);
935
+ outputDisposable.dispose();
936
+ // Clear the current line
937
+ process.stdout.write('\x1b[2K\r');
938
+ // CRITICAL FIX: Explicitly disable terminal features that cause garbage
939
+ process.stdout.write('\x1b[?1004l'); // Disable focus reporting (stops ^[[I / ^[[O)
940
+ process.stdout.write('\x1b[?2004l'); // Disable bracketed paste
941
+ process.stdout.write('\x1b[>0u'); // Reset keyboard enhancement to legacy mode (CSI u)
942
+ process.stdout.write('\x1b[?25h'); // Ensure cursor is visible
943
+ if (process.stdin.isTTY) {
944
+ process.stdin.setRawMode(false);
945
+ }
946
+ // Recreate readline to ensure clean state after PTY interaction
947
+ this.rl?.close();
948
+ this.setupReadline();
949
+ // Clear any garbage after a brief delay
950
+ setTimeout(() => {
951
+ process.stdout.write('\x1b[2K\r');
952
+ }, 100);
953
+ };
954
+ });
955
+ }
956
+ /**
957
+ * Enter interactive mode and automatically send a slash command
958
+ * User stays in interactive mode to see output and interact, then Ctrl+] to return
959
+ */
960
+ async enterInteractiveModeWithCommand(command) {
961
+ const toolName = this.activeTool === 'claude' ? 'Claude Code' : 'Gemini CLI';
962
+ const toolColor = this.activeTool === 'claude' ? colors.brightCyan : colors.brightMagenta;
963
+ const toolCmd = this.activeTool;
964
+ // Check if we already have a running process
965
+ let ptyProcess = this.runningProcesses.get(this.activeTool);
966
+ const isReattach = ptyProcess !== undefined;
967
+ // Pause readline to prevent interference with raw input
968
+ this.rl?.pause();
969
+ console.log(`${colors.dim}Sending ${colors.brightYellow}${command}${colors.dim}... Press ${colors.brightYellow}Ctrl+]${colors.dim} or ${colors.brightYellow}Ctrl+\\${colors.dim} to return${colors.reset}\n`);
970
+ // Clear the output buffer for fresh capture
971
+ this.interactiveOutputBuffer.set(this.activeTool, '');
972
+ return new Promise((resolve) => {
973
+ // Spawn new process if needed
974
+ if (!ptyProcess) {
975
+ const args = [];
976
+ // Continue/resume session if we have history from print mode
977
+ if (this.activeTool === 'claude' && this.claudeHasSession) {
978
+ args.push('--continue');
979
+ }
980
+ else if (this.activeTool === 'gemini' && this.geminiHasSession) {
981
+ args.push('--resume', 'latest');
982
+ }
983
+ ptyProcess = pty.spawn(toolCmd, args, {
984
+ name: 'xterm-256color',
985
+ cols: process.stdout.columns || 80,
986
+ rows: process.stdout.rows || 24,
987
+ cwd: this.cwd,
988
+ env: process.env,
989
+ });
990
+ // Store the process
991
+ this.runningProcesses.set(this.activeTool, ptyProcess);
992
+ // Handle process exit (user typed /exit in the tool)
993
+ ptyProcess.onExit(({ exitCode }) => {
994
+ console.log(`\n${colors.dim}${toolName} exited (code ${exitCode})${colors.reset}`);
995
+ this.runningProcesses.delete(this.activeTool);
996
+ // Mark session as having history
997
+ if (this.activeTool === 'claude') {
998
+ this.claudeHasSession = true;
999
+ }
1000
+ else {
1001
+ this.geminiHasSession = true;
1002
+ }
1003
+ });
1004
+ }
1005
+ // Track if we've sent the command
1006
+ let commandSent = false;
1007
+ // Handle resize
1008
+ const onResize = () => {
1009
+ ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
1010
+ };
1011
+ process.stdout.on('resize', onResize);
1012
+ // Function to send the command
1013
+ const sendCommand = () => {
1014
+ if (commandSent)
1015
+ return;
1016
+ commandSent = true;
1017
+ // Type command character by character for reliability
1018
+ let i = 0;
1019
+ const fullCommand = command + '\r';
1020
+ const typeNextChar = () => {
1021
+ if (i < fullCommand.length) {
1022
+ ptyProcess.write(fullCommand[i]);
1023
+ i++;
1024
+ setTimeout(typeNextChar, 20);
1025
+ }
1026
+ };
1027
+ typeNextChar();
1028
+ };
1029
+ // Pipe PTY output to terminal AND capture for forwarding
1030
+ const outputDisposable = ptyProcess.onData((data) => {
1031
+ // Filter out terminal response sequences (DA responses, etc.)
1032
+ // These can cause garbage like "0u64;1;2;4;6;..." to appear
1033
+ let filteredData = data;
1034
+ // Filter focus sequences from output too
1035
+ filteredData = filteredData.split(FOCUS_IN_SEQ).join('').split(FOCUS_OUT_SEQ).join('');
1036
+ if (filteredData.length > 0) {
1037
+ process.stdout.write(filteredData);
1038
+ }
1039
+ // Capture output for potential forwarding
1040
+ const current = this.interactiveOutputBuffer.get(this.activeTool) || '';
1041
+ this.interactiveOutputBuffer.set(this.activeTool, current + data);
1042
+ });
1043
+ // For reattach, send command quickly. For new process, wait for it to initialize.
1044
+ const sendDelay = isReattach ? 100 : 2500;
1045
+ const fallbackTimer = setTimeout(() => {
1046
+ if (!commandSent) {
1047
+ sendCommand();
1048
+ }
1049
+ }, sendDelay);
1050
+ // Set up stdin forwarding with detach key detection
1051
+ if (process.stdin.isTTY) {
1052
+ process.stdin.setRawMode(true);
1053
+ }
1054
+ process.stdin.resume();
1055
+ let detached = false;
1056
+ let lastEscapeTime = 0;
1057
+ // Debug mode - set AIC_DEBUG=1 to see key codes
1058
+ const debugKeys = process.env.AIC_DEBUG === '1';
1059
+ const performDetach = () => {
1060
+ if (detached)
1061
+ return;
1062
+ detached = true;
1063
+ cleanup();
1064
+ // Save captured output to conversation history for forwarding
1065
+ const capturedOutput = this.interactiveOutputBuffer.get(this.activeTool);
1066
+ if (capturedOutput) {
1067
+ const cleanedOutput = stripAnsi(capturedOutput).trim();
1068
+ if (cleanedOutput.length > 50) { // Only save meaningful output
1069
+ this.conversationHistory.push({
1070
+ tool: this.activeTool,
1071
+ role: 'assistant',
1072
+ content: cleanedOutput,
1073
+ });
1074
+ }
1075
+ // Clear buffer after saving
1076
+ this.interactiveOutputBuffer.set(this.activeTool, '');
1077
+ }
1078
+ // Clear any pending terminal responses before showing detach message
1079
+ process.stdout.write('\x1b[2K\r'); // Clear current line
1080
+ console.log(`\n\n${colors.yellow}⏸${colors.reset} Detached from ${toolColor}${toolName}${colors.reset} ${colors.dim}(still running)${colors.reset}`);
1081
+ console.log(`${colors.dim}Use ${colors.brightYellow}/i${colors.dim} to re-attach • ${colors.brightGreen}/forward${colors.dim} to send to other tool${colors.reset}`);
1082
+ console.log(`${colors.dim}Press ${colors.brightYellow}Enter${colors.dim} to continue${colors.reset}\n`);
1083
+ resolve();
1084
+ };
1085
+ const onStdinData = (data) => {
1086
+ let str = data.toString();
1087
+ // Debug output to see what keys are being received
1088
+ if (debugKeys) {
1089
+ const hexBytes = Array.from(data).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' ');
1090
+ console.log(`\n[DEBUG] Received ${data.length} bytes: ${hexBytes}`);
1091
+ }
1092
+ // Filter out terminal focus reporting sequences (sent when window gains/loses focus)
1093
+ // These cause ^[[I and ^[[O to appear in the terminal
1094
+ if (str === FOCUS_IN_SEQ || str === FOCUS_OUT_SEQ) {
1095
+ if (debugKeys)
1096
+ console.log('[DEBUG] Filtered focus event');
1097
+ return; // Don't forward to PTY
1098
+ }
1099
+ // Also filter if focus sequences are embedded in the data
1100
+ str = str.split(FOCUS_IN_SEQ).join('').split(FOCUS_OUT_SEQ).join('');
1101
+ if (str.length === 0) {
1102
+ return; // Nothing left after filtering
1103
+ }
1104
+ // Check for CSI u sequences (modern keyboard protocol used by iTerm2)
1105
+ for (const seq of CSI_U_DETACH_SEQS) {
1106
+ if (str === seq || str.includes(seq)) {
1107
+ if (debugKeys)
1108
+ console.log(`[DEBUG] Detected CSI u detach sequence: ${seq.replace('\x1b', 'ESC')}`);
1109
+ performDetach();
1110
+ return;
1111
+ }
1112
+ }
1113
+ // Check for detach keys by examining raw bytes (traditional terminals)
1114
+ // This is more reliable than string comparison for control characters
1115
+ for (let i = 0; i < data.length; i++) {
1116
+ const byte = data[i];
1117
+ // Ctrl+] (0x1D = 29) - primary detach key
1118
+ if (byte === DETACH_KEYS.CTRL_BRACKET) {
1119
+ if (debugKeys)
1120
+ console.log('[DEBUG] Detected Ctrl+]');
1121
+ performDetach();
1122
+ return;
1123
+ }
1124
+ // Ctrl+\ (0x1C = 28) - alternative detach key
1125
+ if (byte === DETACH_KEYS.CTRL_BACKSLASH) {
1126
+ if (debugKeys)
1127
+ console.log('[DEBUG] Detected Ctrl+\\');
1128
+ performDetach();
1129
+ return;
1130
+ }
1131
+ // Ctrl+^ (0x1E = 30) - another alternative (Ctrl+Shift+6)
1132
+ if (byte === DETACH_KEYS.CTRL_CARET) {
1133
+ if (debugKeys)
1134
+ console.log('[DEBUG] Detected Ctrl+^');
1135
+ performDetach();
1136
+ return;
1137
+ }
1138
+ // Ctrl+_ (0x1F = 31) - another alternative (Ctrl+Shift+-)
1139
+ if (byte === DETACH_KEYS.CTRL_UNDERSCORE) {
1140
+ if (debugKeys)
1141
+ console.log('[DEBUG] Detected Ctrl+_');
1142
+ performDetach();
1143
+ return;
1144
+ }
1145
+ }
1146
+ // Handle escape sequences for double-escape detection
1147
+ // Check for immediate double escape (0x1B 0x1B) anywhere in buffer
1148
+ if (data.length >= 2) {
1149
+ for (let i = 0; i < data.length - 1; i++) {
1150
+ if (data[i] === DETACH_KEYS.ESCAPE && data[i + 1] === DETACH_KEYS.ESCAPE) {
1151
+ if (debugKeys)
1152
+ console.log('[DEBUG] Detected double-Escape');
1153
+ performDetach();
1154
+ return;
1155
+ }
1156
+ }
1157
+ }
1158
+ // Single Escape (0x1B) - track for double-escape detection
1159
+ const isSingleEscape = data.length === 1 && data[0] === DETACH_KEYS.ESCAPE;
1160
+ if (isSingleEscape) {
1161
+ const now = Date.now();
1162
+ if (now - lastEscapeTime < 500) {
1163
+ // Double escape detected - detach!
1164
+ if (debugKeys)
1165
+ console.log('[DEBUG] Detected double-Escape (timed)');
1166
+ performDetach();
1167
+ return;
1168
+ }
1169
+ lastEscapeTime = now;
1170
+ // Still forward the escape to the PTY
1171
+ ptyProcess.write(str);
1172
+ return;
1173
+ }
1174
+ // Reset escape timer if not an escape key
1175
+ if (!isSingleEscape) {
1176
+ lastEscapeTime = 0;
1177
+ }
1178
+ // Forward filtered data to PTY
1179
+ ptyProcess.write(str);
1180
+ };
1181
+ process.stdin.on('data', onStdinData);
1182
+ // Handle process exit while attached
1183
+ const exitHandler = () => {
1184
+ if (!detached) {
1185
+ cleanup();
1186
+ console.log(`\n${colors.dim}Returned to ${colors.brightYellow}aic${colors.reset}\n`);
1187
+ resolve();
1188
+ }
1189
+ };
1190
+ ptyProcess.onExit(exitHandler);
1191
+ // Cleanup function
1192
+ let cleanedUp = false;
1193
+ const cleanup = () => {
1194
+ if (cleanedUp)
1195
+ return;
1196
+ cleanedUp = true;
1197
+ clearTimeout(fallbackTimer);
1198
+ process.stdin.removeListener('data', onStdinData);
1199
+ process.stdout.removeListener('resize', onResize);
1200
+ outputDisposable.dispose();
1201
+ // Clear the current line
1202
+ process.stdout.write('\x1b[2K\r');
1203
+ // Explicitly disable terminal features that cause garbage
1204
+ process.stdout.write('\x1b[?1004l'); // Disable focus reporting
1205
+ process.stdout.write('\x1b[?2004l'); // Disable bracketed paste
1206
+ process.stdout.write('\x1b[>0u'); // Reset keyboard enhancement to legacy mode (CSI u)
1207
+ process.stdout.write('\x1b[?25h'); // Ensure cursor is visible
1208
+ // Save captured output to conversation history
1209
+ const capturedOutput = this.interactiveOutputBuffer.get(this.activeTool);
1210
+ if (capturedOutput) {
1211
+ const cleanedOutput = stripAnsi(capturedOutput).trim();
1212
+ if (cleanedOutput.length > 50) {
1213
+ this.conversationHistory.push({
1214
+ tool: this.activeTool,
1215
+ role: 'assistant',
1216
+ content: cleanedOutput,
1217
+ });
1218
+ }
1219
+ this.interactiveOutputBuffer.set(this.activeTool, '');
1220
+ }
1221
+ if (process.stdin.isTTY) {
1222
+ process.stdin.setRawMode(false);
1223
+ }
1224
+ // Recreate readline to ensure clean state after PTY interaction
1225
+ this.rl?.close();
1226
+ this.setupReadline();
1227
+ // Clear any garbage after a brief delay
1228
+ setTimeout(() => {
1229
+ process.stdout.write('\x1b[2K\r');
1230
+ }, 100);
1231
+ };
1232
+ });
1233
+ }
1234
+ showStatus() {
1235
+ console.log('');
1236
+ const statusLines = AVAILABLE_TOOLS.map(tool => {
1237
+ const isRunning = this.runningProcesses.has(tool.name);
1238
+ const hasSession = tool.name === 'claude' ? this.claudeHasSession : this.geminiHasSession;
1239
+ const icon = tool.name === 'claude' ? '◆' : '◇';
1240
+ return `${tool.color}${icon} ${tool.displayName.padEnd(12)}${colors.reset} ${isRunning ? `${colors.green}● Running${colors.reset}` : `${colors.dim}○ Stopped${colors.reset}`} ${hasSession ? `${colors.dim}(has history)${colors.reset}` : ''}`;
1241
+ });
1242
+ console.log(drawBox(statusLines, 45));
1243
+ console.log('');
1244
+ }
1245
+ async handleForward(argsString) {
1246
+ // Find the last assistant response
1247
+ const lastResponse = [...this.conversationHistory]
1248
+ .reverse()
1249
+ .find(m => m.role === 'assistant');
1250
+ if (!lastResponse) {
1251
+ console.log('No response to forward yet.');
1252
+ return;
1253
+ }
1254
+ const sourceTool = lastResponse.tool;
1255
+ const otherTools = AVAILABLE_TOOLS
1256
+ .map(t => t.name)
1257
+ .filter(t => t !== sourceTool);
1258
+ // Parse args: first word might be a tool name
1259
+ const parts = argsString.trim().split(/\s+/).filter(p => p);
1260
+ let targetTool;
1261
+ let additionalMessage;
1262
+ if (parts.length > 0 && otherTools.includes(parts[0].toLowerCase())) {
1263
+ // First arg is a tool name
1264
+ targetTool = parts[0].toLowerCase();
1265
+ additionalMessage = parts.slice(1).join(' ');
1266
+ }
1267
+ else {
1268
+ // No tool specified - auto-select if only one other tool
1269
+ if (otherTools.length === 1) {
1270
+ targetTool = otherTools[0];
1271
+ additionalMessage = argsString;
1272
+ }
1273
+ else {
1274
+ // Multiple tools available - require explicit selection
1275
+ console.log(`${colors.yellow}Multiple tools available.${colors.reset} Please specify target:`);
1276
+ console.log(` ${colors.brightGreen}/forward${colors.reset} <${otherTools.join('|')}> [message]`);
1277
+ return;
1278
+ }
1279
+ }
1280
+ // Validate target tool exists and is not the source
1281
+ if (targetTool === sourceTool) {
1282
+ console.log(`Cannot forward to the same tool (${sourceTool}).`);
1283
+ return;
1284
+ }
1285
+ // Switch to target tool
1286
+ this.activeTool = targetTool;
1287
+ const sourceDisplayName = getToolDisplayName(sourceTool);
1288
+ const targetDisplayName = getToolDisplayName(targetTool);
1289
+ const sourceColor = getToolColor(sourceTool);
1290
+ const targetColor = getToolColor(targetTool);
1291
+ console.log('');
1292
+ console.log(`${colors.dim}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
1293
+ console.log(`${colors.green}↗${colors.reset} Forwarding from ${sourceColor}${sourceDisplayName}${colors.reset} → ${targetColor}${targetDisplayName}${colors.reset}`);
1294
+ console.log(`${colors.dim}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
1295
+ console.log(`${targetColor}${targetDisplayName} responds:${colors.reset}`);
1296
+ // Build forward prompt
1297
+ let forwardPrompt = `Another AI assistant (${sourceDisplayName}) provided this response. Please review and share your thoughts:\n\n---\n${lastResponse.content}\n---`;
1298
+ if (additionalMessage.trim()) {
1299
+ forwardPrompt += `\n\nAdditional context: ${additionalMessage.trim()}`;
1300
+ }
1301
+ await this.sendToTool(forwardPrompt);
1302
+ }
1303
+ showHistory() {
1304
+ if (this.conversationHistory.length === 0) {
1305
+ console.log(`\n${colors.dim}No conversation history yet.${colors.reset}\n`);
1306
+ return;
1307
+ }
1308
+ console.log(`\n${colors.bold}Conversation History${colors.reset}`);
1309
+ console.log(`${colors.dim}${'─'.repeat(50)}${colors.reset}`);
1310
+ for (let i = 0; i < this.conversationHistory.length; i++) {
1311
+ const msg = this.conversationHistory[i];
1312
+ const isUser = msg.role === 'user';
1313
+ const toolColor = msg.tool === 'claude' ? colors.brightCyan : colors.brightMagenta;
1314
+ let roleDisplay;
1315
+ if (isUser) {
1316
+ roleDisplay = `${colors.yellow}You${colors.reset}`;
1317
+ }
1318
+ else {
1319
+ roleDisplay = `${toolColor}${msg.tool}${colors.reset}`;
1320
+ }
1321
+ const preview = msg.content.length > 80
1322
+ ? msg.content.slice(0, 80) + '...'
1323
+ : msg.content;
1324
+ console.log(`${colors.dim}${String(i + 1).padStart(2)}.${colors.reset} ${roleDisplay}: ${colors.white}${preview}${colors.reset}`);
1325
+ }
1326
+ console.log(`${colors.dim}${'─'.repeat(50)}${colors.reset}\n`);
1327
+ }
1328
+ async cleanup() {
1329
+ // Kill any running processes
1330
+ for (const [tool, proc] of this.runningProcesses) {
1331
+ console.log(`Stopping ${tool}...`);
1332
+ proc.kill();
1333
+ }
1334
+ this.runningProcesses.clear();
1335
+ }
1336
+ }
1337
+ export async function startSDKSession() {
1338
+ const session = new SDKSession();
1339
+ await session.start();
1340
+ }
1341
+ //# sourceMappingURL=sdk-session.js.map