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