tinyagent 1.0.5 → 1.1.2

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.
@@ -45,6 +45,7 @@ const ora_1 = __importDefault(require("ora"));
45
45
  const http_1 = __importDefault(require("http"));
46
46
  const https_1 = __importDefault(require("https"));
47
47
  const net_1 = __importDefault(require("net"));
48
+ const qr_generator_1 = require("./qr-generator");
48
49
  const shared_types_1 = require("./shared-types");
49
50
  const tunnel_manager_1 = require("./tunnel-manager");
50
51
  class ShellClient {
@@ -61,6 +62,7 @@ class ShellClient {
61
62
  terminalBuffer = '';
62
63
  exposedPorts = new Set();
63
64
  lastKnownPorts = new Set();
65
+ portActivity = {};
64
66
  stdinListener;
65
67
  lastInputSource = 'local';
66
68
  lastInputTime = Date.now();
@@ -68,6 +70,16 @@ class ShellClient {
68
70
  local: { cols: 80, rows: 24 },
69
71
  mobile: { cols: 80, rows: 24 }
70
72
  };
73
+ connectedClients = 1; // Start with 1 (self)
74
+ clientTypes = { mobile: 0, shell: 0, host: 0 };
75
+ statusLineInterval;
76
+ isShowingQR = false;
77
+ bufferedOutput = [];
78
+ currentCommand = '';
79
+ commandCheckInterval;
80
+ viewMode = 'desktop'; // For manual toggle
81
+ statusBarEnabled = true; // Toggle for status bar
82
+ scrollbackMode = false; // Track if we're in scrollback mode
71
83
  constructor(options) {
72
84
  this.options = options;
73
85
  if (options.createTunnel !== false) {
@@ -80,6 +92,8 @@ class ShellClient {
80
92
  rows: process.stdout.rows || 24
81
93
  };
82
94
  }
95
+ // Start status line updates
96
+ this.startStatusLine();
83
97
  // Handle process exit to clean up terminal
84
98
  process.on('exit', () => this.cleanup());
85
99
  process.on('SIGINT', () => {
@@ -97,10 +111,15 @@ class ShellClient {
97
111
  cols: process.stdout.columns || 80,
98
112
  rows: process.stdout.rows || 24
99
113
  };
100
- // If local was the last input source, resize the PTY
101
- if (this.lastInputSource === 'local' && this.ptyProcess) {
102
- this.ptyProcess.resize(this.terminalDimensions.local.cols, this.terminalDimensions.local.rows);
114
+ // Only resize PTY if we're in desktop view mode
115
+ if (this.viewMode === 'desktop' && this.ptyProcess) {
116
+ const ptyRows = this.statusBarEnabled
117
+ ? Math.max(1, this.terminalDimensions.local.rows - 1) // Reserve line for status bar
118
+ : this.terminalDimensions.local.rows; // Use full height
119
+ this.ptyProcess.resize(this.terminalDimensions.local.cols, ptyRows);
103
120
  }
121
+ // Update status line with new width
122
+ this.updateStatusLine();
104
123
  }
105
124
  });
106
125
  }
@@ -117,6 +136,15 @@ class ShellClient {
117
136
  timestamp: Date.now(),
118
137
  clientType: 'shell'
119
138
  });
139
+ // If password is provided, send it immediately after SESSION_INIT
140
+ if (this.options.password) {
141
+ this.sendMessage({
142
+ type: shared_types_1.MessageType.SESSION_AUTH,
143
+ sessionId: this.options.sessionId,
144
+ timestamp: Date.now(),
145
+ password: this.options.password
146
+ });
147
+ }
120
148
  this.startHeartbeat();
121
149
  });
122
150
  this.ws.on('message', (data) => {
@@ -137,6 +165,7 @@ class ShellClient {
137
165
  switch (message.type) {
138
166
  case shared_types_1.MessageType.SESSION_READY:
139
167
  console.log(chalk_1.default.green('Session ready'));
168
+ this.setupTerminalWithStatusBar();
140
169
  this.startShell();
141
170
  if (this.options.serverCommand) {
142
171
  this.startServer();
@@ -152,13 +181,8 @@ class ShellClient {
152
181
  // Mark mobile as last input source
153
182
  this.lastInputSource = 'mobile';
154
183
  this.lastInputTime = Date.now();
155
- // Use mobile dimensions if it was the last input
184
+ // Just write the data without resizing - user controls dimensions with Ctrl+R
156
185
  if (this.ptyProcess) {
157
- const { cols, rows } = this.terminalDimensions.mobile;
158
- if (process.env.DEBUG || process.argv.includes('--verbose')) {
159
- console.log(chalk_1.default.magenta(`[INPUT SOURCE: MOBILE] Input received, resizing to mobile dimensions: ${cols}x${rows}`));
160
- }
161
- this.ptyProcess.resize(cols, rows);
162
186
  this.ptyProcess.write(dataMsg.data);
163
187
  }
164
188
  break;
@@ -175,19 +199,17 @@ class ShellClient {
175
199
  cols: resizeMsg.cols,
176
200
  rows: resizeMsg.rows
177
201
  };
178
- // Apply resize if:
179
- // 1. Mobile was the last input source, OR
180
- // 2. This is the first resize from mobile (mobile dimensions were default 80x24)
181
- const isFirstMobileResize = previousMobileDimensions.cols === 80 && previousMobileDimensions.rows === 24;
182
- if ((this.lastInputSource === 'mobile' || isFirstMobileResize) && this.ptyProcess) {
183
- if (process.env.DEBUG || process.argv.includes('--verbose')) {
184
- console.log(chalk_1.default.green(`[TERMINAL RESIZE] Applying mobile dimensions: ${resizeMsg.cols}x${resizeMsg.rows}`));
185
- }
186
- this.ptyProcess.resize(resizeMsg.cols, resizeMsg.rows);
187
- }
188
- else {
202
+ // Always resize to mobile dimensions when receiving resize from mobile
203
+ if (this.ptyProcess) {
204
+ const ptyRows = this.statusBarEnabled
205
+ ? Math.max(1, resizeMsg.rows - 1) // Reserve line for status bar
206
+ : resizeMsg.rows; // Use full height
207
+ this.ptyProcess.resize(resizeMsg.cols, ptyRows);
208
+ // Update view mode to mobile since we're resizing to mobile dimensions
209
+ this.viewMode = 'mobile';
210
+ this.updateStatusLine();
189
211
  if (process.env.DEBUG || process.argv.includes('--verbose')) {
190
- console.log(chalk_1.default.red(`[TERMINAL RESIZE] Not applying - last input was from ${this.lastInputSource}`));
212
+ console.log(chalk_1.default.green(`[TERMINAL RESIZE] Applied mobile dimensions: ${resizeMsg.cols}x${resizeMsg.rows}`));
191
213
  }
192
214
  }
193
215
  break;
@@ -202,6 +224,33 @@ class ShellClient {
202
224
  const httpMsg = message;
203
225
  this.handleHttpRequest(httpMsg);
204
226
  break;
227
+ case shared_types_1.MessageType.CLIENT_COUNT:
228
+ const countMsg = message;
229
+ // Subtract 1 to exclude the current shell client from the count
230
+ this.connectedClients = Math.max(0, countMsg.count - 1);
231
+ this.clientTypes = countMsg.clientTypes || { mobile: 0, shell: 0, host: 0 };
232
+ // Check if mobile client just connected
233
+ if (this.clientTypes.mobile > 0) {
234
+ // If we're showing QR code, hide it automatically
235
+ if (this.isShowingQR) {
236
+ this.hideQRCode();
237
+ }
238
+ // Switch to mobile view if in desktop mode
239
+ if (this.viewMode === 'desktop') {
240
+ console.log(chalk_1.default.green('\nMobile client connected, switching to mobile view'));
241
+ this.viewMode = 'mobile';
242
+ // Apply mobile dimensions if we have them
243
+ if (this.ptyProcess && this.terminalDimensions.mobile.cols > 0) {
244
+ const { cols, rows } = this.terminalDimensions.mobile;
245
+ const ptyRows = this.statusBarEnabled
246
+ ? Math.max(1, rows - 1) // Reserve line for status bar
247
+ : rows; // Use full height
248
+ this.ptyProcess.resize(cols, ptyRows);
249
+ }
250
+ }
251
+ }
252
+ this.updateStatusLine();
253
+ break;
205
254
  }
206
255
  }
207
256
  startShell() {
@@ -235,25 +284,37 @@ class ShellClient {
235
284
  const initialDimensions = this.lastInputSource === 'mobile'
236
285
  ? this.terminalDimensions.mobile
237
286
  : this.terminalDimensions.local;
287
+ // Reserve one line for status bar if enabled
288
+ const ptyRows = this.statusBarEnabled
289
+ ? Math.max(1, initialDimensions.rows - 1)
290
+ : initialDimensions.rows;
238
291
  if (process.env.DEBUG || process.argv.includes('--verbose')) {
239
- console.log(chalk_1.default.cyan(`[PTY INIT] Creating PTY with dimensions: ${initialDimensions.cols}x${initialDimensions.rows} (source: ${this.lastInputSource})`));
292
+ console.log(chalk_1.default.cyan(`[PTY INIT] Creating PTY with dimensions: ${initialDimensions.cols}x${ptyRows} (source: ${this.lastInputSource}, reserved 1 line for status)`));
240
293
  }
241
294
  this.ptyProcess = pty.spawn(this.options.shell, shellArgs, {
242
295
  name: 'xterm-256color',
243
296
  cols: initialDimensions.cols,
244
- rows: initialDimensions.rows,
297
+ rows: ptyRows,
245
298
  env: minimalEnv,
246
299
  cwd: process.cwd()
247
300
  });
248
301
  // Handle PTY output
249
302
  this.ptyProcess.onData((data) => {
303
+ // Detect commands in the output
304
+ this.detectCommand(data);
250
305
  // Only log in debug mode or with --verbose flag
251
306
  if (process.env.DEBUG || process.argv.includes('--verbose')) {
252
307
  console.log(chalk_1.default.gray(`[SHELL OUTPUT] ${JSON.stringify(data)}`));
253
308
  }
254
309
  else {
255
- // Write directly to stdout to preserve terminal control sequences
256
- process.stdout.write(data);
310
+ // If showing QR code, buffer the output
311
+ if (this.isShowingQR) {
312
+ this.bufferedOutput.push(data);
313
+ }
314
+ else {
315
+ // Write directly to stdout to preserve terminal control sequences
316
+ process.stdout.write(data);
317
+ }
257
318
  }
258
319
  // Store in buffer for late-joining clients
259
320
  this.terminalBuffer += data;
@@ -271,17 +332,29 @@ class ShellClient {
271
332
  console.log(chalk_1.default.yellow(`Shell exited with code ${exitCode}`));
272
333
  this.disconnect();
273
334
  });
274
- // Send initial buffer if any
335
+ // Send initial buffer if any (last 5000 chars to avoid overwhelming mobile)
275
336
  if (this.terminalBuffer) {
276
337
  setTimeout(() => {
338
+ // Send only the last portion of the buffer for mobile clients
339
+ const bufferToSend = this.terminalBuffer.length > 5000
340
+ ? '...' + this.terminalBuffer.slice(-5000)
341
+ : this.terminalBuffer;
277
342
  this.sendMessage({
278
343
  type: shared_types_1.MessageType.SHELL_DATA,
279
344
  sessionId: this.options.sessionId,
280
345
  timestamp: Date.now(),
281
- data: this.terminalBuffer
346
+ data: bufferToSend
282
347
  });
283
348
  }, 100);
284
349
  }
350
+ // Auto-start Claude if enabled
351
+ if (this.options.autoStartClaude) {
352
+ setTimeout(() => {
353
+ this.checkAndStartClaude();
354
+ }, 1000); // Wait a second for shell to initialize
355
+ }
356
+ // Start monitoring for running commands
357
+ this.startCommandMonitoring();
285
358
  }
286
359
  async startServer() {
287
360
  if (!this.options.serverCommand)
@@ -357,11 +430,17 @@ class ShellClient {
357
430
  async handleHttpRequest(request) {
358
431
  // Use targetPort from request, or try to extract from headers, or default to 3000
359
432
  const port = request.targetPort || 3000;
360
- console.log(chalk_1.default.magenta(`[HTTP] ${request.method} ${request.path} → localhost:${port}`));
433
+ // Track port activity
434
+ if (!this.portActivity[port]) {
435
+ this.portActivity[port] = { lastAccess: Date.now(), requestCount: 0 };
436
+ }
437
+ this.portActivity[port].lastAccess = Date.now();
438
+ this.portActivity[port].requestCount++;
439
+ // Silently handle HTTP request
361
440
  const makeRequest = (options, redirectCount = 0) => {
362
441
  const protocol = options.port === 443 ? https_1.default : http_1.default;
363
442
  const req = protocol.request(options, (res) => {
364
- console.log(chalk_1.default.blue(`[HTTP] Response ${res.statusCode} for ${request.path}`));
443
+ // Response received
365
444
  // Handle redirects (3xx status codes)
366
445
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
367
446
  if (redirectCount >= 5) {
@@ -382,7 +461,7 @@ class ShellClient {
382
461
  return;
383
462
  }
384
463
  const location = res.headers.location;
385
- console.log(chalk_1.default.yellow(`[HTTP] Following redirect to ${location}`));
464
+ // Following redirect
386
465
  // Parse the redirect URL
387
466
  let redirectUrl;
388
467
  try {
@@ -407,7 +486,7 @@ class ShellClient {
407
486
  }
408
487
  // Only follow redirects to localhost
409
488
  if (redirectUrl.hostname !== 'localhost' && redirectUrl.hostname !== '127.0.0.1') {
410
- console.log(chalk_1.default.yellow(`[HTTP] Not following external redirect to ${redirectUrl.hostname}`));
489
+ // Not following external redirect
411
490
  // Send the redirect response as-is
412
491
  this.sendMessage({
413
492
  type: shared_types_1.MessageType.HTTP_RESPONSE,
@@ -523,64 +602,76 @@ class ShellClient {
523
602
  makeRequest(initialOptions);
524
603
  }
525
604
  startPortDetection() {
605
+ if (this.options.verbose) {
606
+ console.log(chalk_1.default.gray('[HTTP] Starting port detection...'));
607
+ }
526
608
  // Check for active ports every 5 seconds
527
609
  this.portCheckInterval = setInterval(() => {
528
610
  this.detectActivePorts();
529
611
  }, 5000);
530
- // Initial check
531
- this.detectActivePorts();
612
+ // Initial check with a small delay to ensure services are ready
613
+ setTimeout(() => {
614
+ if (this.options.verbose) {
615
+ console.log(chalk_1.default.gray('[HTTP] Initial port scan...'));
616
+ }
617
+ this.detectActivePorts();
618
+ }, 1000);
532
619
  }
533
620
  async detectActivePorts() {
534
- const commonPorts = [3000, 3001, 4000, 4200, 5000, 5173, 8000, 8080, 8081, 9000];
621
+ const commonPorts = [3000, 3001, 3002, 3003, 3004, 3005, 4000, 4200, 5000, 5173, 8000, 8080, 8081, 9000];
535
622
  const activePorts = new Set();
623
+ const portsWithActivity = new Set();
536
624
  for (const port of commonPorts) {
537
625
  if (await this.isPortActive(port)) {
538
626
  activePorts.add(port);
627
+ // Check if this port has recent activity (within last 30 seconds)
628
+ if (this.portActivity[port] &&
629
+ (Date.now() - this.portActivity[port].lastAccess) < 30000) {
630
+ portsWithActivity.add(port);
631
+ }
632
+ }
633
+ }
634
+ // Clean up old port activity data
635
+ Object.keys(this.portActivity).forEach(port => {
636
+ const portNum = parseInt(port);
637
+ if (!activePorts.has(portNum) ||
638
+ (Date.now() - this.portActivity[portNum].lastAccess) > 60000) {
639
+ delete this.portActivity[portNum];
539
640
  }
641
+ });
642
+ // Debug logging
643
+ if (process.env.DEBUG_PORTS) {
644
+ console.log(chalk_1.default.gray(`[DEBUG] Active ports found: ${[...activePorts].join(', ') || 'none'}`));
645
+ console.log(chalk_1.default.gray(`[DEBUG] Ports with recent activity: ${[...portsWithActivity].join(', ') || 'none'}`));
540
646
  }
541
647
  // Check if ports have changed
542
648
  const portsChanged = activePorts.size !== this.lastKnownPorts.size ||
543
- [...activePorts].some(port => !this.lastKnownPorts.has(port));
649
+ [...activePorts].some(port => !this.lastKnownPorts.has(port)) ||
650
+ [...this.lastKnownPorts].some(port => !activePorts.has(port));
544
651
  if (portsChanged) {
545
652
  this.lastKnownPorts = new Set(activePorts);
546
653
  this.exposedPorts = new Set(activePorts);
547
- // Send updated port list to relay
654
+ // Send updated port list to relay with activity information
548
655
  this.sendMessage({
549
656
  type: shared_types_1.MessageType.REGISTER_PORT,
550
657
  sessionId: this.options.sessionId,
551
658
  timestamp: Date.now(),
552
- ports: [...activePorts]
659
+ ports: [...activePorts],
660
+ portActivity: this.portActivity
553
661
  });
554
- if (activePorts.size > 0) {
555
- console.log(chalk_1.default.green(`[HTTP] Exposing ports: ${[...activePorts].join(', ')}`));
556
- console.log(chalk_1.default.green(`[HTTP] Access your dev server at:`));
557
- // Determine if connecting to production or local
558
- const isProduction = this.options.relayUrl.includes('tinyagent.app');
559
- const isLocal = this.options.relayUrl.includes('localhost') ||
560
- this.options.relayUrl.includes('127.0.0.1') ||
561
- this.options.relayUrl.match(/192\.168\.|10\.|172\./);
562
- if (isProduction) {
563
- // Production URLs with subdomain-based port routing
564
- [...activePorts].forEach(port => {
565
- console.log(chalk_1.default.cyan(` https://${this.options.sessionId}-${port}.tinyagent.app/`));
566
- });
567
- }
568
- else if (isLocal) {
569
- // Local development URLs with nip.io
570
- const relayHost = new URL(this.options.relayUrl).hostname;
571
- const relayPort = new URL(this.options.relayUrl).port || '8080';
572
- const ipForNipIo = relayHost.replace(/\./g, '-');
573
- [...activePorts].forEach(port => {
574
- console.log(chalk_1.default.cyan(` http://${this.options.sessionId}-${port}.${ipForNipIo}.nip.io:${relayPort}/`));
575
- });
662
+ // Log detected ports only when they change
663
+ if (portsWithActivity.size > 0) {
664
+ console.log(chalk_1.default.blue(`[HTTP] Active ports: ${[...portsWithActivity].join(', ')}`));
665
+ console.log(chalk_1.default.blue(`[HTTP] Access your dev server at:`));
666
+ console.log(chalk_1.default.blue(` https://tinyagent.app/${this.options.sessionId}/`));
667
+ }
668
+ else if (this.options.verbose) {
669
+ // Only show verbose logs in verbose mode
670
+ if (activePorts.size > 0) {
671
+ console.log(chalk_1.default.gray(`[HTTP] Ports detected (${[...activePorts].join(', ')}), waiting for activity...`));
576
672
  }
577
- else {
578
- // Custom domain
579
- const publicUrl = process.env.PUBLIC_URL || 'https://tinyagent.app';
580
- const hostname = new URL(publicUrl).hostname;
581
- [...activePorts].forEach(port => {
582
- console.log(chalk_1.default.cyan(` https://${this.options.sessionId}-${port}.${hostname}/`));
583
- });
673
+ else if (this.lastKnownPorts.size > 0) {
674
+ console.log(chalk_1.default.gray(`[HTTP] No active ports detected`));
584
675
  }
585
676
  }
586
677
  }
@@ -588,7 +679,7 @@ class ShellClient {
588
679
  isPortActive(port) {
589
680
  return new Promise((resolve) => {
590
681
  const socket = new net_1.default.Socket();
591
- socket.setTimeout(100);
682
+ socket.setTimeout(300); // Increased timeout for better detection
592
683
  socket.on('connect', () => {
593
684
  socket.destroy();
594
685
  resolve(true);
@@ -615,14 +706,7 @@ class ShellClient {
615
706
  // Mark local as last input source
616
707
  this.lastInputSource = 'local';
617
708
  this.lastInputTime = Date.now();
618
- // Resize PTY to local dimensions when typing locally
619
- if (this.ptyProcess && this.terminalDimensions.local) {
620
- const { cols, rows } = this.terminalDimensions.local;
621
- if (process.env.DEBUG || process.argv.includes('--verbose')) {
622
- console.log(chalk_1.default.green(`[INPUT SOURCE: LOCAL] Input received, resizing to local dimensions: ${cols}x${rows}`));
623
- }
624
- this.ptyProcess.resize(cols, rows);
625
- }
709
+ // Don't auto-resize on input - user controls with Ctrl+R
626
710
  // Send to local PTY
627
711
  if (this.ptyProcess) {
628
712
  this.ptyProcess.write(data);
@@ -633,15 +717,20 @@ class ShellClient {
633
717
  this.disconnect();
634
718
  process.exit(0);
635
719
  }
636
- // Debug: Manual resize test with Ctrl+R (only in verbose mode)
637
- if (data === '\x12' && (process.env.DEBUG || process.argv.includes('--verbose'))) { // Ctrl+R
638
- console.log(chalk_1.default.yellow('\nManual resize test - switching to mobile dimensions'));
639
- this.lastInputSource = 'mobile';
640
- if (this.ptyProcess) {
641
- const { cols, rows } = this.terminalDimensions.mobile;
642
- console.log(chalk_1.default.green(`Resizing to mobile: ${cols}x${rows}`));
643
- this.ptyProcess.resize(cols, rows);
644
- }
720
+ // Ctrl+S to show QR code (S for Show)
721
+ if (data === '\x13') { // Ctrl+S
722
+ this.showQRCode();
723
+ return; // Don't pass to PTY
724
+ }
725
+ // Ctrl+F to toggle between desktop and mobile view (F for Fit)
726
+ if (data === '\x06') { // Ctrl+F
727
+ this.toggleViewMode();
728
+ return; // Don't pass to PTY
729
+ }
730
+ // Ctrl+B to toggle status bar (B for Bar)
731
+ if (data === '\x02') { // Ctrl+B
732
+ this.toggleStatusBar();
733
+ return; // Don't pass to PTY
645
734
  }
646
735
  });
647
736
  };
@@ -650,6 +739,11 @@ class ShellClient {
650
739
  process.stdin.resume();
651
740
  }
652
741
  cleanup() {
742
+ // Reset scrolling region
743
+ if (process.stdout.isTTY) {
744
+ process.stdout.write('\x1b[r'); // Reset scroll region
745
+ process.stdout.write('\x1b[?25h'); // Show cursor
746
+ }
653
747
  // Restore terminal settings
654
748
  if (process.stdin.isTTY) {
655
749
  process.stdin.setRawMode(false);
@@ -664,6 +758,12 @@ class ShellClient {
664
758
  if (this.portCheckInterval) {
665
759
  clearInterval(this.portCheckInterval);
666
760
  }
761
+ if (this.statusLineInterval) {
762
+ clearInterval(this.statusLineInterval);
763
+ }
764
+ if (this.commandCheckInterval) {
765
+ clearInterval(this.commandCheckInterval);
766
+ }
667
767
  if (this.ptyProcess) {
668
768
  this.ptyProcess.kill();
669
769
  }
@@ -674,6 +774,288 @@ class ShellClient {
674
774
  this.tunnelManager.closeTunnel();
675
775
  }
676
776
  }
777
+ checkAndStartClaude() {
778
+ if (this.ptyProcess) {
779
+ // Simple approach: just try to run claude
780
+ // If it exists, it will start. If not, the error will show in terminal
781
+ console.log(chalk_1.default.green('\n✓ Attempting to start Claude...'));
782
+ // Just run claude without clearing - keep QR code visible
783
+ setTimeout(() => {
784
+ if (this.ptyProcess) {
785
+ const claudeCommand = this.options.resumeClaude ? 'claude --resume' : 'claude';
786
+ this.ptyProcess.write(`${claudeCommand}\n`);
787
+ // Set command as 'claude' when we start it
788
+ this.currentCommand = claudeCommand;
789
+ this.sendCommandUpdate(claudeCommand);
790
+ }
791
+ }, 500);
792
+ }
793
+ }
794
+ startStatusLine() {
795
+ // Update status line every second
796
+ this.statusLineInterval = setInterval(() => {
797
+ this.updateStatusLine();
798
+ }, 1000);
799
+ // Initial update
800
+ this.updateStatusLine();
801
+ }
802
+ setupTerminalWithStatusBar() {
803
+ if (!process.stdout.isTTY)
804
+ return;
805
+ // Clear screen and set up scrolling region
806
+ process.stdout.write('\x1b[2J'); // Clear entire screen
807
+ process.stdout.write('\x1b[1;1H'); // Move to top
808
+ if (this.statusBarEnabled) {
809
+ // Set scrolling region to exclude top line (ANSI escape: CSI r)
810
+ const rows = process.stdout.rows || 24;
811
+ process.stdout.write(`\x1b[2;${rows}r`); // Set scroll region from line 2 to bottom
812
+ // Move cursor to second line
813
+ process.stdout.write('\x1b[2;1H');
814
+ // Initial status line update
815
+ this.updateStatusLine();
816
+ }
817
+ else {
818
+ // No scroll region restriction
819
+ process.stdout.write('\x1b[r'); // Reset scroll region to full terminal
820
+ }
821
+ }
822
+ updateStatusLine() {
823
+ if (!process.stdout.isTTY || !this.statusBarEnabled)
824
+ return;
825
+ const cols = process.stdout.columns || 80;
826
+ const sessionInfo = `tinyagent session: ${this.options.sessionId}`;
827
+ const viewInfo = `[${this.viewMode}]`;
828
+ const mobileStatus = this.clientTypes.mobile > 0 ? '📱' : '';
829
+ const clientInfo = `${this.connectedClients} client${this.connectedClients !== 1 ? 's' : ''} connected ${mobileStatus}`;
830
+ const shortcuts = `[Ctrl+S: Show QR | Ctrl+F: Fit | Ctrl+B: Toggle Bar]`;
831
+ // For smaller terminals, use abbreviated version
832
+ let middlePart = shortcuts;
833
+ if (cols < 100) {
834
+ middlePart = '[^S:QR ^F:Fit ^B:Bar]';
835
+ }
836
+ else if (cols < 80) {
837
+ middlePart = '[^S ^F ^B]';
838
+ }
839
+ // Calculate spacing
840
+ const leftPart = `${sessionInfo} ${viewInfo}`;
841
+ const rightPart = clientInfo;
842
+ const totalLength = leftPart.length + middlePart.length + rightPart.length + 2; // +2 for surrounding spaces
843
+ const spacing = Math.max(2, cols - totalLength);
844
+ const leftSpacing = Math.floor(spacing / 2);
845
+ const rightSpacing = spacing - leftSpacing;
846
+ // Build status line (exactly cols width, no padding)
847
+ const content = ` ${leftPart}${' '.repeat(leftSpacing)}${middlePart}${' '.repeat(rightSpacing)}${rightPart} `;
848
+ const statusLine = chalk_1.default.bgBlue.white.bold(content.substring(0, cols));
849
+ // Save cursor position, move to top line (outside scroll region), print status, restore cursor
850
+ process.stdout.write('\x1b7'); // Save cursor position
851
+ process.stdout.write('\x1b[1;1H'); // Move to line 1, column 1
852
+ process.stdout.write(statusLine); // Write status
853
+ process.stdout.write('\x1b[K'); // Clear to end of line (in case terminal is wider)
854
+ process.stdout.write('\x1b8'); // Restore cursor position
855
+ }
856
+ hideQRCode() {
857
+ if (!this.isShowingQR)
858
+ return;
859
+ // Clear flag
860
+ this.isShowingQR = false;
861
+ // Switch back to main screen buffer (restores previous content)
862
+ process.stdout.write('\x1b[?1049l');
863
+ // Restore status line
864
+ this.updateStatusLine();
865
+ // Replay any buffered output that came in while QR was shown
866
+ if (this.bufferedOutput.length > 0) {
867
+ this.bufferedOutput.forEach(output => {
868
+ process.stdout.write(output);
869
+ });
870
+ this.bufferedOutput = [];
871
+ }
872
+ }
873
+ showQRCode() {
874
+ // Set flag to start buffering
875
+ this.isShowingQR = true;
876
+ this.bufferedOutput = [];
877
+ // Save cursor position before clearing
878
+ process.stdout.write('\x1b[?1049h'); // Switch to alternate screen buffer
879
+ // Clear screen and show QR code
880
+ console.clear();
881
+ this.updateStatusLine();
882
+ (0, qr_generator_1.generateConnectionQR)(this.options.sessionId, this.options.relayUrl);
883
+ console.log(chalk_1.default.gray('Press any key to return to your session...'));
884
+ // Set up a temporary stdin handler for returning from QR view
885
+ const qrExitHandler = (data) => {
886
+ // Remove this handler
887
+ process.stdin.removeListener('data', qrExitHandler);
888
+ // Hide QR code
889
+ this.hideQRCode();
890
+ // Re-enable the normal stdin handler
891
+ if (this.stdinListener) {
892
+ this.stdinListener();
893
+ }
894
+ };
895
+ // Remove the normal stdin listener temporarily
896
+ process.stdin.removeAllListeners('data');
897
+ // Add the QR exit handler
898
+ process.stdin.on('data', qrExitHandler);
899
+ }
900
+ toggleViewMode() {
901
+ // Toggle between desktop and mobile view
902
+ this.viewMode = this.viewMode === 'desktop' ? 'mobile' : 'desktop';
903
+ // Get the dimensions for the selected view
904
+ const dimensions = this.viewMode === 'desktop'
905
+ ? this.terminalDimensions.local
906
+ : this.terminalDimensions.mobile;
907
+ if (this.ptyProcess && dimensions) {
908
+ const { cols, rows } = dimensions;
909
+ const ptyRows = this.statusBarEnabled ? Math.max(1, rows - 1) : rows; // Only reserve line if status bar enabled
910
+ console.log(chalk_1.default.yellow(`\nSwitched to ${this.viewMode} view: ${cols}x${rows}`));
911
+ this.ptyProcess.resize(cols, ptyRows);
912
+ // Update status line to show current mode
913
+ if (this.statusBarEnabled) {
914
+ this.updateStatusLine();
915
+ }
916
+ // Don't clear screen - just let the resize take effect
917
+ // The terminal will naturally adjust to the new size
918
+ }
919
+ }
920
+ toggleStatusBar() {
921
+ this.statusBarEnabled = !this.statusBarEnabled;
922
+ if (!this.statusBarEnabled) {
923
+ // Clear the status bar and reset scroll region
924
+ if (process.stdout.isTTY) {
925
+ process.stdout.write('\x1b[r'); // Reset scroll region
926
+ process.stdout.write('\x1b7'); // Save cursor
927
+ process.stdout.write('\x1b[1;1H'); // Move to top
928
+ process.stdout.write('\x1b[K'); // Clear line
929
+ process.stdout.write('\x1b8'); // Restore cursor
930
+ }
931
+ // Resize PTY to use full terminal height
932
+ if (this.ptyProcess && process.stdout.isTTY) {
933
+ const dimensions = this.viewMode === 'desktop'
934
+ ? this.terminalDimensions.local
935
+ : this.terminalDimensions.mobile;
936
+ this.ptyProcess.resize(dimensions.cols, dimensions.rows);
937
+ }
938
+ console.log(chalk_1.default.gray('\nStatus bar disabled. Press Ctrl+B to re-enable.'));
939
+ }
940
+ else {
941
+ // Re-enable status bar
942
+ this.setupTerminalWithStatusBar();
943
+ // Resize PTY to account for status bar
944
+ if (this.ptyProcess && process.stdout.isTTY) {
945
+ const dimensions = this.viewMode === 'desktop'
946
+ ? this.terminalDimensions.local
947
+ : this.terminalDimensions.mobile;
948
+ const ptyRows = Math.max(1, dimensions.rows - 1);
949
+ this.ptyProcess.resize(dimensions.cols, ptyRows);
950
+ }
951
+ console.log(chalk_1.default.gray('\nStatus bar enabled.'));
952
+ }
953
+ }
954
+ startCommandMonitoring() {
955
+ // Also periodically check what's actually running
956
+ this.commandCheckInterval = setInterval(() => {
957
+ this.checkRunningProcess();
958
+ }, 5000); // Check every 5 seconds
959
+ }
960
+ checkRunningProcess() {
961
+ if (!this.ptyProcess)
962
+ return;
963
+ // Get the PID of the shell's child process
964
+ try {
965
+ const ptyPid = this.ptyProcess.pid;
966
+ if (ptyPid) {
967
+ // Use ps to check what's running under our PTY
968
+ const { execSync } = require('child_process');
969
+ try {
970
+ // Get immediate children of the PTY process
971
+ const output = execSync(`ps -o pid,ppid,comm -p ${ptyPid} && ps -o pid,ppid,comm | grep "^[[:space:]]*[0-9]\+[[:space:]]\+${ptyPid}"`, { encoding: 'utf8' });
972
+ const lines = output.split('\n').filter((line) => line.trim());
973
+ // Look for child processes
974
+ for (const line of lines) {
975
+ const parts = line.trim().split(/\s+/);
976
+ if (parts.length >= 3 && parts[1] === String(ptyPid)) {
977
+ const processName = parts.slice(2).join(' ');
978
+ // Skip shell processes
979
+ if (!processName.includes('bash') && !processName.includes('zsh') && !processName.includes('sh')) {
980
+ const command = processName.split('/').pop() || processName;
981
+ if (command !== this.currentCommand) {
982
+ this.currentCommand = command;
983
+ this.sendCommandUpdate(command);
984
+ }
985
+ return;
986
+ }
987
+ }
988
+ }
989
+ // If we get here, no child process found - shell is idle
990
+ if (this.currentCommand !== 'idle') {
991
+ this.currentCommand = 'idle';
992
+ this.sendCommandUpdate('idle');
993
+ }
994
+ }
995
+ catch (e) {
996
+ // ps command failed, ignore
997
+ }
998
+ }
999
+ }
1000
+ catch (e) {
1001
+ // Error getting process info
1002
+ }
1003
+ }
1004
+ detectCommand(data) {
1005
+ // Split by newlines
1006
+ const lines = data.split(/\r?\n/);
1007
+ for (const line of lines) {
1008
+ // Remove ANSI escape codes
1009
+ const cleanLine = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
1010
+ // Look for command patterns (prompt followed by command)
1011
+ // Remove the $ anchor to catch partial commands
1012
+ const commandMatch = cleanLine.match(/[$#%>]\s+(.+)/);
1013
+ if (commandMatch && commandMatch[1]) {
1014
+ const potentialCommand = commandMatch[1].trim();
1015
+ // Filter out common non-commands and check if it's different
1016
+ if (potentialCommand &&
1017
+ potentialCommand.length > 1 &&
1018
+ potentialCommand !== this.currentCommand &&
1019
+ !potentialCommand.startsWith('\x1b')) {
1020
+ // Common command prefixes that indicate actual work
1021
+ const commandPrefixes = ['npm', 'yarn', 'node', 'python', 'git', 'vim', 'make', 'cargo', 'go', 'docker', 'kubectl', 'claude'];
1022
+ const isCommand = commandPrefixes.some(cmd => potentialCommand.startsWith(cmd));
1023
+ if (isCommand) {
1024
+ this.currentCommand = potentialCommand;
1025
+ this.sendCommandUpdate(potentialCommand);
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ // Also check for known interactive programs in the output
1031
+ const interactivePrograms = [
1032
+ { pattern: /Claude Code.*session started/i, command: 'claude' },
1033
+ { pattern: /Welcome to Claude/i, command: 'claude' },
1034
+ { pattern: /VIM - Vi IMproved/i, command: 'vim' },
1035
+ { pattern: /GNU nano/i, command: 'nano' },
1036
+ { pattern: /\[\d+\]\+\s+Running\s+(.+)/i, extract: true }, // Background job
1037
+ ];
1038
+ for (const prog of interactivePrograms) {
1039
+ const match = data.match(prog.pattern);
1040
+ if (match) {
1041
+ const command = prog.extract && match[1] ? match[1].trim() : prog.command;
1042
+ if (command && command !== this.currentCommand) {
1043
+ this.currentCommand = command;
1044
+ this.sendCommandUpdate(command);
1045
+ }
1046
+ break;
1047
+ }
1048
+ }
1049
+ }
1050
+ sendCommandUpdate(command) {
1051
+ // Send command update to relay
1052
+ this.sendMessage({
1053
+ type: shared_types_1.MessageType.COMMAND_UPDATE,
1054
+ sessionId: this.options.sessionId,
1055
+ timestamp: Date.now(),
1056
+ command: command
1057
+ });
1058
+ }
677
1059
  disconnect() {
678
1060
  this.cleanup();
679
1061
  if (this.ws) {