repterm 0.1.0 → 0.1.1

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.
@@ -2,16 +2,14 @@
2
2
  * Terminal API implementation
3
3
  * Provides high-level terminal interaction (start/send/wait/snapshot)
4
4
  *
5
- * 执行架构:
6
- * - 非录制、非交互模式:使用 Bun.spawn,stdout/stderr 分离,exitCode 精确
7
- * - 录制或交互模式:使用 PTY,支持复杂交互,但 exitCode 不可靠
5
+ * Execution: non-recording/non-interactive uses Bun.spawn (separate stdout/stderr, exact exitCode);
6
+ * recording or interactive uses PTY (rich interaction, exitCode unreliable).
8
7
  */
9
8
  import { TerminalSession } from './session.js';
10
9
  import { EventEmitter } from 'events';
11
10
  import { getCurrentStepOptions, getCurrentStepName, shouldShowStepTitle, markStepTitleShown } from '../api/steps.js';
12
11
  /**
13
- * 计算 tmux 输出捕获的行范围
14
- * 纯函数,可独立单元测试
12
+ * Compute tmux output capture line range. Pure function for unit testing.
15
13
  */
16
14
  export function calculateOutputRange(beforeCursorY, beforeHistorySize, afterCursorY, afterHistorySize, promptLineCount) {
17
15
  const historyGrowth = afterHistorySize - beforeHistorySize;
@@ -20,7 +18,7 @@ export function calculateOutputRange(beforeCursorY, beforeHistorySize, afterCurs
20
18
  return { startLine, endLine };
21
19
  }
22
20
  /**
23
- * CommandResult 实现类,提供 successful getter
21
+ * CommandResult implementation with successful getter
24
22
  */
25
23
  class CommandResultImpl {
26
24
  code;
@@ -47,7 +45,7 @@ class CommandResultImpl {
47
45
  export class Terminal extends EventEmitter {
48
46
  session;
49
47
  recording;
50
- ptyOnly; // PTY-only 模式标志
48
+ ptyOnly; // PTY-only flag
51
49
  recordingPath;
52
50
  closed = false;
53
51
  initialized = false;
@@ -55,20 +53,20 @@ export class Terminal extends EventEmitter {
55
53
  tmuxPaneId;
56
54
  sharedState;
57
55
  paneIndex; // Index of the tmux pane this terminal is bound to
58
- nonInteractiveOutput = ''; // 存储非交互模式下的命令输出
59
- commandLogs = []; // 存储测试期间执行过的命令
60
- pluginFactory; // 插件工厂
61
- plugins; // 插件实例(为新终端设置)
62
- // 发送 Enter 前的状态(用于输出范围捕获)
56
+ nonInteractiveOutput = ''; // Command output in non-interactive mode
57
+ commandLogs = []; // Commands run during test
58
+ pluginFactory; // Plugin factory
59
+ plugins; // Plugin instances (for new terminals)
60
+ // State before sending Enter (for output range capture)
63
61
  beforeEnterHistorySize = 0;
64
62
  beforeEnterCursorY = 0;
65
- // 命令的实际行数(直接从命令内容计算,最可靠)
63
+ // Command line count from command content (most reliable)
66
64
  commandLineCount = 0;
67
- // 检测到的提示符行数(默认 0,由自动检测或用户配置设置)
65
+ // Detected or configured prompt line count (default 0)
68
66
  promptLineCount = 0;
69
- // 是否使用用户配置的值(跳过自动检测)
67
+ // Use user-configured value (skip auto-detect)
70
68
  promptLineCountConfigured = false;
71
- // 检测到的提示符匹配 pattern
69
+ // Detected prompt match pattern
72
70
  detectedPromptPattern;
73
71
  constructor(config = {}) {
74
72
  super();
@@ -80,7 +78,7 @@ export class Terminal extends EventEmitter {
80
78
  this.sharedState = { paneCount: 1, paneOutputs: new Map() }; // Start with 1 pane
81
79
  this.paneIndex = 0; // Main terminal is pane 0
82
80
  this.nonInteractiveOutput = '';
83
- // 如果用户配置了 promptLineCount,直接使用,不进行自动检测
81
+ // Use user promptLineCount and skip auto-detect
84
82
  if (config.promptLineCount !== undefined) {
85
83
  this.promptLineCount = config.promptLineCount;
86
84
  this.promptLineCountConfigured = true;
@@ -156,18 +154,12 @@ export class Terminal extends EventEmitter {
156
154
  this.sharedState.currentActivePane = this.paneIndex;
157
155
  }
158
156
  /**
159
- * 执行命令,返回 PTYProcess
160
- *
161
- * 执行模式:
162
- * - 非录制、非交互(默认):使用 Bun.spawn,stdout/stderr 分离,exitCode 精确
163
- * - 录制或交互:使用 PTY,支持 expect/send,但 exitCode 返回 -1
164
- *
165
- * 用法:
166
- * - 直接 await: 自动调用 wait(),返回 CommandResult
167
- * - 不 await: 返回 PTYProcess 控制器,可调用 expect/send/wait 等方法
168
- *
169
- * @param command - 要执行的命令
170
- * @param options - 可选配置,包括 interactive: true 启用交互模式
157
+ * Run command; returns PTYProcess.
158
+ * Non-recording/non-interactive: Bun.spawn, separate stdout/stderr, exact exitCode.
159
+ * Recording or interactive: PTY, expect/send, exitCode -1.
160
+ * Usage: await for CommandResult, or use controller (expect/send/wait).
161
+ * @param command - Command to run
162
+ * @param options - e.g. interactive: true
171
163
  */
172
164
  run(command, options = {}) {
173
165
  if (this.closed) {
@@ -182,15 +174,14 @@ export class Terminal extends EventEmitter {
182
174
  if (this.initialized)
183
175
  return;
184
176
  if (this.recording && this.recordingPath) {
185
- // 只在用户未配置 promptLineCount 时才自动检测
177
+ // Auto-detect only when user did not set promptLineCount
186
178
  if (!this.promptLineCountConfigured) {
187
179
  await this.detectPromptBeforeRecording();
188
180
  }
189
- // 生成 tmux session 名称
181
+ // Generate tmux session name
190
182
  const sessionName = `repterm-${Date.now().toString(36)}`;
191
183
  this.tmuxSessionName = sessionName;
192
- // Recording mode: 使用 asciinema --command 直接启动 tmux
193
- // 必须显式设置 TERM=xterm-256color,否则 asciinema 会检测父进程的终端类型
184
+ // Recording: asciinema --command starts tmux. Set TERM=xterm-256color explicitly.
194
185
  this.session.start({
195
186
  shell: 'asciinema',
196
187
  args: ['rec', '--command', `tmux new -s ${sessionName}`, this.recordingPath, '--overwrite'],
@@ -198,11 +189,11 @@ export class Terminal extends EventEmitter {
198
189
  TERM: 'xterm-256color',
199
190
  },
200
191
  });
201
- // 等待 tmux 启动就绪
192
+ // Wait for tmux to be ready
202
193
  await this.waitForTmuxReady();
203
194
  }
204
195
  else if (this.ptyOnly) {
205
- // PTY-only 模式:直接启动 shell(不使用 asciinema/tmux
196
+ // PTY-only: start shell directly (no asciinema/tmux)
206
197
  this.session.start();
207
198
  await this.waitForShellReady();
208
199
  }
@@ -227,14 +218,14 @@ export class Terminal extends EventEmitter {
227
218
  const startTime = Date.now();
228
219
  while (Date.now() - startTime < timeout) {
229
220
  const output = this.session.getOutput();
230
- // 检测 shell 准备好的标志(出现命令提示符)
221
+ // Shell ready when prompt appears
231
222
  if (output.includes('$') || output.includes('#') || output.includes('%') || output.includes('>')) {
232
- await this.sleep(100); // 额外等待确保稳定
223
+ await this.sleep(100); // Extra wait for stability
233
224
  return;
234
225
  }
235
226
  await this.sleep(50);
236
227
  }
237
- // 超时不报错,继续执行
228
+ // Timeout: do not throw, continue
238
229
  }
239
230
  /**
240
231
  * Wait for tmux to be ready (detect shell prompt)
@@ -243,14 +234,14 @@ export class Terminal extends EventEmitter {
243
234
  const startTime = Date.now();
244
235
  while (Date.now() - startTime < timeout) {
245
236
  const output = this.session.getOutput();
246
- // 检测 tmux 启动完成的标志(通常是出现命令提示符)
237
+ // Tmux ready when prompt appears
247
238
  if (output.includes('$') || output.includes('#') || output.includes('%')) {
248
- await this.sleep(300); // 额外等待确保稳定
239
+ await this.sleep(300); // Extra wait for stability
249
240
  return;
250
241
  }
251
242
  await this.sleep(100);
252
243
  }
253
- // 超时不报错,继续执行
244
+ // On timeout, continue without throwing
254
245
  }
255
246
  /**
256
247
  * Send text to the terminal
@@ -383,8 +374,7 @@ export class Terminal extends EventEmitter {
383
374
  return this.commandLogs.map((log) => ({ ...log }));
384
375
  }
385
376
  /**
386
- * 设置插件工厂(用于 create() 自动注入插件)
387
- * @internal 由插件系统调用
377
+ * Set plugin factory (create() will inject plugins). @internal
388
378
  */
389
379
  setPluginFactory(factory) {
390
380
  this.pluginFactory = factory;
@@ -398,17 +388,13 @@ export class Terminal extends EventEmitter {
398
388
  async create() {
399
389
  let newTerminal;
400
390
  if (this.recording && this.tmuxSessionName) {
401
- // 录制模式:使用快捷键 Ctrl+B split 窗口
402
- // 分割策略(九宫格效果):
403
- // - 当前1个窗口 -> 第2个窗口:水平分割(上下)使用 "
404
- // - 当前2个窗口 -> 第3个窗口:垂直分割(左右)使用 %
405
- // - 以此类推:奇数个窗口时水平分割,偶数个窗口时垂直分割
391
+ // Recording: Ctrl+B to split. Odd panes: horizontal ("), even: vertical (%).
406
392
  const currentPaneCount = this.sharedState.paneCount;
407
- const splitKey = currentPaneCount % 2 === 1 ? '"' : '%'; // 奇数水平分割,偶数垂直分割
393
+ const splitKey = currentPaneCount % 2 === 1 ? '"' : '%';
408
394
  this.session.write('\x02'); // Ctrl+B
409
395
  await this.sleep(100);
410
396
  this.session.write(splitKey);
411
- await this.sleep(800); // 等待新 pane 初始化
397
+ await this.sleep(800); // Wait for new pane to init
412
398
  const newPaneIndex = this.sharedState.paneCount;
413
399
  this.sharedState.paneCount++;
414
400
  this.sharedState.currentActivePane = newPaneIndex; // New pane is now active after split
@@ -420,10 +406,10 @@ export class Terminal extends EventEmitter {
420
406
  newTerminal.setParentSession(this.session, this.sharedState, newPaneIndex);
421
407
  }
422
408
  else {
423
- // 非录制模式:创建独立终端
409
+ // Non-recording: create independent terminal
424
410
  newTerminal = new Terminal({ recording: false });
425
411
  }
426
- // 如果有插件工厂,为新终端创建插件实例
412
+ // If plugin factory set, create plugin instances for new terminal
427
413
  if (this.pluginFactory) {
428
414
  newTerminal.pluginFactory = this.pluginFactory;
429
415
  newTerminal.plugins = this.pluginFactory(newTerminal);
@@ -435,17 +421,16 @@ export class Terminal extends EventEmitter {
435
421
  */
436
422
  async close() {
437
423
  if (!this.closed) {
438
- const tmuxSessionToClean = this.tmuxSessionName; // 保存 session 名称
424
+ const tmuxSessionToClean = this.tmuxSessionName; // Keep for cleanup
439
425
  if (this.recording && this.tmuxSessionName && this.session.isActive()) {
440
- // 录制结束前等待 2 秒,让用户看到最后的输出
426
+ // Wait 2s before ending so user sees final output
441
427
  await this.sleep(2000);
442
- // 使用快捷键 Ctrl+B d 分离 tmux(detach)
443
- // 这会导致 tmux 退出,从而结束 asciinema 录制
428
+ // Ctrl+B d to detach tmux, which ends asciinema recording
444
429
  await this.sleep(300);
445
430
  this.session.write('\x02'); // Ctrl+B (tmux prefix)
446
431
  await this.sleep(100);
447
432
  this.session.write('d'); // detach
448
- await this.sleep(500); // 等待 asciinema 结束录制
433
+ await this.sleep(500); // Wait for asciinema to finish
449
434
  }
450
435
  else if (this.recording && this.session.isActive()) {
451
436
  // Recording without tmux - send Ctrl+D to end asciinema recording
@@ -456,7 +441,7 @@ export class Terminal extends EventEmitter {
456
441
  this.session.kill('SIGTERM');
457
442
  this.closed = true;
458
443
  this.emit('close');
459
- // 录制结束后,清理 tmux session(在录制外执行)
444
+ // After recording, clean up tmux session (outside recording)
460
445
  if (tmuxSessionToClean) {
461
446
  await this.cleanupTmuxSession(tmuxSessionToClean);
462
447
  }
@@ -489,8 +474,7 @@ export class Terminal extends EventEmitter {
489
474
  return this.recording;
490
475
  }
491
476
  /**
492
- * 是否使用 PTY 模式(录制或 pty-only
493
- * PTY 模式支持交互式命令,但打字效果仅在 recording 模式下启用
477
+ * Whether PTY mode (recording or pty-only). Typing effect only in recording.
494
478
  */
495
479
  isPtyMode() {
496
480
  return this.recording || this.ptyOnly;
@@ -525,13 +509,13 @@ export class Terminal extends EventEmitter {
525
509
  }
526
510
  /**
527
511
  * Record state before sending Enter (internal use)
528
- * 使用单次 tmux 查询原子获取 history_size cursor_y,避免竞态条件
512
+ * Single tmux query for history_size and cursor_y to avoid race.
529
513
  */
530
514
  async recordBeforeEnterState() {
531
515
  if (!this.tmuxSessionName || this.paneIndex === undefined)
532
516
  return;
533
517
  try {
534
- // 原子查询:一次 tmux 调用同时获取 history_size cursor_y
518
+ // Atomic: single tmux call for history_size and cursor_y
535
519
  const proc = Bun.spawn(['tmux', 'display-message', '-t', `${this.tmuxSessionName}:0.${this.paneIndex}`, '-p', '#{history_size}:#{cursor_y}'], {
536
520
  stdout: 'pipe', stderr: 'pipe'
537
521
  });
@@ -547,62 +531,59 @@ export class Terminal extends EventEmitter {
547
531
  }
548
532
  }
549
533
  /**
550
- * 在录制开始前检测提示符相关信息
551
- * - 提示符占用的行数
552
- * - 提示符匹配 pattern
553
- * 通过创建临时 tmux session 来测量,检测过程不会出现在录制中
534
+ * Detect prompt before recording (line count, pattern) via temp tmux session.
554
535
  */
555
536
  async detectPromptBeforeRecording() {
556
537
  const tempSessionName = `repterm-detect-${Date.now()}`;
557
538
  const testMarker = '__REPTERM_PROMPT_TEST__';
558
539
  try {
559
- // 创建临时 tmux session
540
+ // Create temp tmux session
560
541
  await Bun.spawn(['tmux', 'new-session', '-d', '-s', tempSessionName, '-x', '80', '-y', '24']).exited;
561
- await this.sleep(1000); // 等待 shell 启动
562
- // 检测提示符 pattern(在发送命令前捕获干净的提示符)
542
+ await this.sleep(1000); // Wait for shell to start
543
+ // Capture prompt pattern before sending command
563
544
  const promptCaptureProc = Bun.spawn(['tmux', 'capture-pane', '-p', '-t', tempSessionName], {
564
545
  stdout: 'pipe', stderr: 'pipe'
565
546
  });
566
547
  const promptScreen = await new Response(promptCaptureProc.stdout).text();
567
548
  await promptCaptureProc.exited;
568
549
  this.detectedPromptPattern = this.analyzePromptLine(promptScreen);
569
- // 发送测试命令(用于检测提示符行数)
550
+ // Send test command to detect prompt line count
570
551
  await Bun.spawn(['tmux', 'send-keys', '-t', tempSessionName, `echo ${testMarker}`, 'Enter']).exited;
571
- await this.sleep(1000); // 等待命令执行完成
572
- // 获取当前 cursorY
552
+ await this.sleep(1000); // Wait for command to finish
553
+ // Get current cursorY
573
554
  const cursorProc = Bun.spawn(['tmux', 'display-message', '-t', tempSessionName, '-p', '#{cursor_y}'], {
574
555
  stdout: 'pipe', stderr: 'pipe'
575
556
  });
576
557
  const cursorY = parseInt((await new Response(cursorProc.stdout).text()).trim(), 10) || 0;
577
558
  await cursorProc.exited;
578
- // 捕获屏幕内容
559
+ // Capture screen content
579
560
  const captureProc = Bun.spawn(['tmux', 'capture-pane', '-p', '-t', tempSessionName, '-S', '0', '-E', String(cursorY)], {
580
561
  stdout: 'pipe', stderr: 'pipe'
581
562
  });
582
563
  const screenContent = await new Response(captureProc.stdout).text();
583
564
  await captureProc.exited;
584
- // 找到测试字符串的行(找最后一次出现,即 echo 的输出,而非命令回显)
565
+ // Find test string line (last occurrence = echo output)
585
566
  const lines = screenContent.split('\n');
586
567
  let markerLine = -1;
587
568
  for (let i = 0; i < lines.length; i++) {
588
569
  if (lines[i].includes(testMarker)) {
589
- markerLine = i; // break,继续找下一个,最终得到最后一次出现的位置
570
+ markerLine = i; // Keep going to get last occurrence
590
571
  }
591
572
  }
592
573
  if (markerLine >= 0) {
593
- // 提示符行数 = cursorY - markerLine
574
+ // promptLineCount = cursorY - markerLine
594
575
  this.promptLineCount = cursorY - markerLine;
595
576
  if (this.promptLineCount < 1)
596
577
  this.promptLineCount = 1;
597
578
  }
598
- // 关闭临时 session
579
+ // Kill temp session
599
580
  await Bun.spawn(['tmux', 'kill-session', '-t', tempSessionName]).exited;
600
581
  }
601
582
  catch {
602
- // 检测失败时保持默认值
583
+ // On failure keep defaults
603
584
  this.promptLineCount = 0;
604
585
  this.detectedPromptPattern = undefined;
605
- // 尝试清理临时 session
586
+ // Try to kill temp session
606
587
  try {
607
588
  await Bun.spawn(['tmux', 'kill-session', '-t', tempSessionName]).exited;
608
589
  }
@@ -610,7 +591,7 @@ export class Terminal extends EventEmitter {
610
591
  }
611
592
  }
612
593
  /**
613
- * 分析提示符行,生成匹配正则
594
+ * Analyze prompt line and build match regex
614
595
  */
615
596
  analyzePromptLine(screenContent) {
616
597
  const lines = screenContent.trim().split('\n');
@@ -618,9 +599,9 @@ export class Terminal extends EventEmitter {
618
599
  if (!promptLine.trim()) {
619
600
  return undefined;
620
601
  }
621
- // 常见提示符字符
602
+ // Common prompt chars
622
603
  const promptChars = ['$', '#', '%', '>', '❯', '→', 'λ', '»', '❮', '›', '⟩'];
623
- // 查找提示符字符的位置(选最后出现的)
604
+ // Find prompt char position (last occurrence)
624
605
  let foundChar = '';
625
606
  let charIndex = -1;
626
607
  for (const char of promptChars) {
@@ -633,92 +614,92 @@ export class Terminal extends EventEmitter {
633
614
  if (charIndex === -1) {
634
615
  return undefined;
635
616
  }
636
- // 分析提示符后的内容
617
+ // Analyze content after prompt
637
618
  const afterPrompt = promptLine.substring(charIndex + 1);
638
619
  const hasRightContent = afterPrompt.trim().length > 0;
639
- // 转义特殊正则字符
620
+ // Escape regex special chars
640
621
  const escapedChar = foundChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
641
622
  if (hasRightContent) {
642
- // 右侧提示符布局:提示符后有多个空格再有内容
623
+ // Right-side prompt: spaces then content
643
624
  return new RegExp(`${escapedChar}\\s{2,}`);
644
625
  }
645
626
  else {
646
- // 传统布局:提示符在行尾
627
+ // Traditional: prompt at EOL
647
628
  return new RegExp(`${escapedChar}\\s*$`);
648
629
  }
649
630
  }
650
631
  /**
651
- * 获取检测到的提示符行数
632
+ * Get detected prompt line count
652
633
  */
653
634
  getPromptLineCount() {
654
635
  return this.promptLineCount;
655
636
  }
656
637
  /**
657
- * 获取检测到的提示符匹配 pattern
638
+ * Get detected prompt pattern
658
639
  */
659
640
  getDetectedPromptPattern() {
660
641
  return this.detectedPromptPattern;
661
642
  }
662
643
  /**
663
644
  * Type text with human-like delays (for recording mode)
664
- * @param text - 要打字的文本
665
- * @param speed - 每字符延迟 (ms),默认 80ms
666
- * @param variableSpeed - 是否使用变速模式(更自然)
645
+ * @param text - Text to type
646
+ * @param speed - ms per char, default 80
647
+ * @param variableSpeed - Variable speed for natural typing
667
648
  */
668
649
  async typeWithDelay(text, speed = 80, variableSpeed = true) {
669
650
  if (speed === 0) {
670
- // 速度为 0 时直接写入
651
+ // speed 0: write directly
671
652
  this.session.write(text);
672
653
  return;
673
654
  }
674
- let momentum = 0; // 打字动量,模拟熟练度
655
+ let momentum = 0; // Typing momentum
675
656
  for (let i = 0; i < text.length; i++) {
676
657
  const char = text[i];
677
658
  this.session.write(char);
678
659
  let delay;
679
660
  if (variableSpeed) {
680
- // 变速模式:模拟真实打字的加速和减速
661
+ // Variable speed: accelerate/decelerate
681
662
  const baseDelay = Math.max(speed * 0.5, speed - momentum * 5);
682
663
  if (char === ' ' || char === '\n') {
683
- // 单词边界:重置动量,稍长停顿
664
+ // Word boundary: reset momentum, longer pause
684
665
  momentum = 0;
685
666
  delay = baseDelay + speed * 0.3;
686
667
  }
687
668
  else {
688
- // 连续打字:逐渐加速
669
+ // Continuous typing: speed up
689
670
  momentum = Math.min(momentum + 1, 10);
690
671
  delay = baseDelay + (Math.random() - 0.5) * speed * 0.4;
691
672
  }
692
- // 标点符号后额外停顿
673
+ // Extra pause after punctuation
693
674
  if ('.,:;!?'.includes(char)) {
694
675
  delay += speed * 0.5;
695
676
  }
696
677
  }
697
678
  else {
698
- // 原有模式:固定速度 ± 30%
679
+ // Legacy: fixed speed +/- 30%
699
680
  delay = speed + (Math.random() - 0.5) * speed * 0.6;
700
681
  }
701
682
  await this.sleep(delay);
702
- // 引号特殊处理
683
+ // Special handling for quotes
703
684
  if (char === '"' || char === "'") {
704
685
  await this.sleep(50);
705
686
  }
706
687
  }
707
688
  }
708
689
  /**
709
- * 在录制中显示步骤标题
690
+ * Show step title in recording
710
691
  */
711
692
  async displayStepTitle(title) {
712
693
  if (!this.recording)
713
694
  return;
714
695
  const stepOptions = getCurrentStepOptions();
715
696
  const typingSpeed = stepOptions?.typingSpeed;
716
- // 显示注释形式的标题
697
+ // Show title as comment
717
698
  const comment = `# === ${title} ===`;
718
699
  await this.typeWithDelay(comment, typingSpeed ?? 40, false);
719
700
  this.session.write('\r');
720
701
  if (typingSpeed !== 0) {
721
- await this.sleep(500); // 让标题显示片刻
702
+ await this.sleep(500); // Brief display
722
703
  }
723
704
  }
724
705
  /**
@@ -727,9 +708,7 @@ export class Terminal extends EventEmitter {
727
708
  */
728
709
  async waitForOutputStable(timeout = 10000) {
729
710
  const startTime = Date.now();
730
- // 检测行中任意位置的提示符(支持右侧提示符布局)
731
- // 提示符后面通常跟着空格(输入区域)
732
- // 使用检测到的 pattern,或默认 pattern
711
+ // Detect prompt (right-side layout); use detected or default pattern
733
712
  const promptPattern = this.detectedPromptPattern ?? /[\$#%>❯→λ»]\s+/;
734
713
  const checkInterval = 100;
735
714
  while (Date.now() - startTime < timeout) {
@@ -763,16 +742,16 @@ export class Terminal extends EventEmitter {
763
742
  if (!this.session.isActive()) {
764
743
  await this.initializeSession();
765
744
  }
766
- // 命令前暂停
745
+ // Pause before command
767
746
  if (this.recording && options?.pauseBefore) {
768
747
  await this.sleep(options.pauseBefore);
769
748
  }
770
- // 显示步骤标题(每个 step 只显示一次)
749
+ // Show step title (once per step)
771
750
  if (this.recording && shouldShowStepTitle() && options?.stepName) {
772
751
  await this.displayStepTitle(options.stepName);
773
752
  markStepTitleShown();
774
753
  }
775
- // 记录命令行数(直接从命令内容计算,最可靠)
754
+ // Record command line count from content
776
755
  this.commandLineCount = command.split('\n').length;
777
756
  // In recording mode, type with human-like delay
778
757
  if (this.recording) {
@@ -781,21 +760,21 @@ export class Terminal extends EventEmitter {
781
760
  const hasNewline = command.includes('\n');
782
761
  const typingSpeed = options?.typingSpeed ?? 80;
783
762
  if (hasNewline && this.tmuxSessionName) {
784
- // 多行命令:使用 Bracketed Paste Mode 避免续行提示符
763
+ // Multiline: Bracketed Paste to avoid continuation prompt
785
764
  await this.pasteWithTmux(command);
786
765
  }
787
766
  else if (typingSpeed === 0) {
788
- // 禁用打字效果时:快速写入
767
+ // When typing disabled: write fast
789
768
  this.session.write(command);
790
769
  await this.sleep(100);
791
- // 在发送 Enter 之前记录状态
770
+ // Record state before sending Enter
792
771
  await this.recordBeforeEnterState();
793
772
  this.session.write('\r');
794
773
  }
795
774
  else {
796
- // 正常命令:人工打字效果(无论长度)
775
+ // Normal command: human typing
797
776
  await this.typeWithDelay(command, typingSpeed, true);
798
- // 在发送 Enter 之前记录状态
777
+ // Record state before sending Enter
799
778
  await this.recordBeforeEnterState();
800
779
  this.session.write('\r');
801
780
  }
@@ -806,39 +785,28 @@ export class Terminal extends EventEmitter {
806
785
  await this.sleep(50);
807
786
  }
808
787
  /**
809
- * 使用 Bracketed Paste Mode 粘贴多行命令
810
- * 避免 shell 显示续行提示符(如 quote>、pipe heredoc>)
811
- *
812
- * Bracketed Paste Mode 使用转义序列包裹粘贴内容:
813
- * - \x1b[200~ 标记粘贴开始
814
- * - \x1b[201~ 标记粘贴结束
815
- * Shell 会将整个内容作为单个输入块处理,不显示续行提示符
788
+ * Bracketed Paste for multiline: \x1b[200~...\x1b[201~ so shell treats as single input, no continuation prompt.
816
789
  */
817
790
  async pasteWithTmux(command) {
818
791
  const paneTarget = `${this.tmuxSessionName}:0.${this.paneIndex}`;
819
- // Bracketed Paste Mode 转义序列
792
+ // Bracketed Paste escape sequences
820
793
  const PASTE_START = '\x1b[200~'; // ESC [ 200 ~
821
794
  const PASTE_END = '\x1b[201~'; // ESC [ 201 ~
822
- // 包裹命令内容(不包含最后的回车,回车在粘贴结束后单独发送)
795
+ // Wrap command (no final newline; send after paste)
823
796
  const wrappedContent = PASTE_START + command + PASTE_END;
824
- // 使用 tmux send-keys -l 发送字面内容(包含转义序列)
797
+ // tmux send-keys -l for literal content
825
798
  await Bun.spawn(['tmux', 'send-keys', '-l', '-t', paneTarget, wrappedContent]).exited;
826
- // 等待 shell 处理
799
+ // Wait for shell to process
827
800
  await this.sleep(500);
828
- // 在发送 Enter 之前记录状态(用于输出范围捕获)
801
+ // Record state before Enter (for output range)
829
802
  await this.recordBeforeEnterState();
830
- // 发送回车执行命令
803
+ // Send Enter to run command
831
804
  await Bun.spawn(['tmux', 'send-keys', '-t', paneTarget, 'Enter']).exited;
832
805
  await this.sleep(200);
833
806
  }
834
807
  }
835
808
  /**
836
- * PTYProcess 实现类
837
- * 实现 PromiseLike 接口,支持 await 和控制器两种用法
838
- *
839
- * 执行模式:
840
- * - 非录制、非交互:使用 Bun.spawn,stdout/stderr 分离,exitCode 精确
841
- * - 录制或交互:使用 PTY,支持 expect/send,但 exitCode 不可靠(返回 -1)
809
+ * PTYProcess impl. PromiseLike: await or controller. Non-recording: Bun.spawn, exact exitCode; recording/interactive: PTY, exitCode -1.
842
810
  */
843
811
  class PTYProcessImpl {
844
812
  terminal;
@@ -846,10 +814,10 @@ class PTYProcessImpl {
846
814
  command;
847
815
  options;
848
816
  startTime;
849
- // 用于非录制、非交互模式的 Bun.spawn 进程
817
+ // Bun.spawn for non-recording, non-interactive
850
818
  bunProcess;
851
819
  isInteractive;
852
- // 用于非录制交互模式的输出起始位置
820
+ // Output start for non-recording interactive
853
821
  beforeOutputLength = 0;
854
822
  constructor(terminal, command, options = {}) {
855
823
  this.terminal = terminal;
@@ -858,47 +826,42 @@ class PTYProcessImpl {
858
826
  this.startTime = Date.now();
859
827
  this.isInteractive = options.interactive ?? false;
860
828
  }
861
- // ===== PromiseLike 实现 =====
862
- /**
863
- * 实现 PromiseLike.then()
864
- * await proc 时自动调用此方法
829
+ // ===== PromiseLike =====
830
+ /** then(): called when awaiting proc
865
831
  */
866
832
  then(onfulfilled, onrejected) {
867
833
  return this.wait().then(onfulfilled, onrejected);
868
834
  }
869
835
  /**
870
- * 实现 catch 方法(便捷方法)
836
+ * catch (convenience)
871
837
  */
872
838
  catch(onrejected) {
873
839
  return this.wait().catch(onrejected);
874
840
  }
875
841
  /**
876
- * 实现 finally 方法(便捷方法)
842
+ * finally (convenience)
877
843
  */
878
844
  finally(onfinally) {
879
845
  return this.wait().finally(onfinally);
880
846
  }
881
- // ===== 内部方法 =====
882
- /**
883
- * 判断是否使用 PTY 模式(录制、ptyOnly 或交互)
884
- * silent 模式强制使用 Bun.spawn 获取干净输出
847
+ // ===== Internal =====
848
+ /** PTY when recording/ptyOnly/interactive; silent forces Bun.spawn for clean output
885
849
  */
886
850
  usePtyMode() {
887
851
  if (this.options.silent) {
888
852
  return false;
889
853
  }
890
- // 包含 ptyOnly 模式
854
+ // Include ptyOnly
891
855
  return this.terminal.isRecording() || this.terminal.isPtyMode() || this.isInteractive;
892
856
  }
893
857
  /**
894
- * 延时辅助方法
858
+ * Sleep helper
895
859
  */
896
860
  sleep(ms) {
897
861
  return new Promise(resolve => setTimeout(resolve, ms));
898
862
  }
899
863
  /**
900
- * 原子获取 pane 状态(history_size cursor_y)
901
- * 使用单次 tmux 调用避免竞态条件
864
+ * Get pane state atomically (single tmux call)
902
865
  */
903
866
  async getPaneStateAtomic() {
904
867
  if (!this.terminal.isRecording())
@@ -925,9 +888,7 @@ class PTYProcessImpl {
925
888
  }
926
889
  }
927
890
  /**
928
- * 捕获 pane 指定范围的输出
929
- * @param startLine 起始行(负数=历史,正数=可见区域,'-'=历史开头)
930
- * @param endLine 结束行('-'=当前位置)
891
+ * Capture pane output range. startLine/endLine: negative=history, '-'=start/current
931
892
  */
932
893
  async capturePaneRange(startLine, endLine) {
933
894
  const tmuxSession = this.terminal.getTmuxSessionName();
@@ -948,32 +909,29 @@ class PTYProcessImpl {
948
909
  }
949
910
  }
950
911
  /**
951
- * 去除 ANSI 转义序列
912
+ * Strip ANSI escapes
952
913
  */
953
914
  stripAnsi(text) {
954
915
  const ansiRegex = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[=>]|\x1b\[\?[0-9;]*[a-zA-Z]/g;
955
916
  return text.replace(ansiRegex, '');
956
917
  }
957
918
  /**
958
- * 启动命令
959
- * - PTY 模式:通过 terminal.executeInPty 执行
960
- * - Bun.spawn 模式:直接启动子进程
919
+ * Start: PTY via executeInPty or Bun.spawn
961
920
  */
962
921
  async startCommand() {
963
922
  if (this.commandStarted)
964
923
  return;
965
924
  this.commandStarted = true;
966
925
  if (this.usePtyMode()) {
967
- // 录制模式或交互式:使用 PTY
926
+ // Recording or interactive: use PTY
968
927
  if (!this.terminal.isRecording()) {
969
- // 非录制交互模式:记录当前输出长度
928
+ // Non-recording interactive: record output length
970
929
  this.beforeOutputLength = this.terminal.getOutputLength();
971
930
  }
972
- // executeInPty 内部会在发送 Enter 前记录状态
973
- // ★ 从当前 step 上下文获取录制选项
931
+ // executeInPty records state before Enter; get options from step
974
932
  const stepOptions = getCurrentStepOptions();
975
933
  const stepName = getCurrentStepName();
976
- // 合并选项优先级:RunOptions > StepOptions > 默认值
934
+ // Option priority: RunOptions > StepOptions > default
977
935
  const executeOptions = {
978
936
  typingSpeed: this.options.typingSpeed ?? stepOptions?.typingSpeed,
979
937
  pauseBefore: this.options.pauseBefore ?? stepOptions?.pauseBefore,
@@ -983,7 +941,7 @@ class PTYProcessImpl {
983
941
  await this.terminal.executeInPty(this.command, executeOptions);
984
942
  }
985
943
  else {
986
- // 非录制、非交互:使用 Bun.spawn
944
+ // Non-recording, non-interactive: Bun.spawn
987
945
  const env = {};
988
946
  for (const [key, value] of Object.entries(this.options.env ?? process.env)) {
989
947
  if (value !== undefined && typeof value === 'string') {
@@ -998,10 +956,8 @@ class PTYProcessImpl {
998
956
  });
999
957
  }
1000
958
  }
1001
- // ===== 交互式控制方法 =====
1002
- /**
1003
- * Wait for specified text to appear
1004
- * 仅在交互模式或录制模式下可用
959
+ // ===== Interactive =====
960
+ /** Wait for text. Only in interactive or recording.
1005
961
  */
1006
962
  async expect(text, options) {
1007
963
  if (!this.usePtyMode()) {
@@ -1011,8 +967,7 @@ class PTYProcessImpl {
1011
967
  await this.terminal.waitForText(text, options);
1012
968
  }
1013
969
  /**
1014
- * Send input to the process (with newline)
1015
- * 仅在交互模式或录制模式下可用
970
+ * Send input (with newline). Only in interactive or recording.
1016
971
  */
1017
972
  async send(input) {
1018
973
  if (!this.usePtyMode()) {
@@ -1022,8 +977,7 @@ class PTYProcessImpl {
1022
977
  await this.terminal.send(input + '\r');
1023
978
  }
1024
979
  /**
1025
- * Send raw input to the process (without newline)
1026
- * 仅在交互模式或录制模式下可用
980
+ * Send raw input. Only in interactive or recording.
1027
981
  */
1028
982
  async sendRaw(input) {
1029
983
  if (!this.usePtyMode()) {
@@ -1033,8 +987,7 @@ class PTYProcessImpl {
1033
987
  await this.terminal.send(input);
1034
988
  }
1035
989
  /**
1036
- * 启动命令执行,等待输入完成(不等待命令执行完成)
1037
- * 用于 watch 等长时间运行的命令
990
+ * Start command, wait for input only (e.g. watch).
1038
991
  */
1039
992
  async start() {
1040
993
  await this.startCommand();
@@ -1046,24 +999,24 @@ class PTYProcessImpl {
1046
999
  await this.startCommand();
1047
1000
  const timeout = options?.timeout ?? this.options.timeout ?? 300000; // 5 minutes default
1048
1001
  if (this.usePtyMode()) {
1049
- // PTY 模式:等待输出稳定
1002
+ // PTY: wait for output to stabilize
1050
1003
  await this.terminal.waitForOutputStablePublic(timeout);
1051
1004
  let output;
1052
1005
  if (this.terminal.isRecording()) {
1053
- // 录制模式:使用发送 Enter 前记录的状态计算输出范围
1006
+ // Recording: use state before Enter for output range
1054
1007
  const beforeEnterState = this.terminal.getBeforeEnterState();
1055
1008
  const afterState = await this.getPaneStateAtomic();
1056
1009
  const { startLine: outputStartLine, endLine } = calculateOutputRange(beforeEnterState.cursorY, beforeEnterState.historySize, afterState.cursorY, afterState.historySize, this.terminal.getPromptLineCount());
1057
1010
  output = await this.capturePaneRange(String(outputStartLine), String(endLine));
1058
- // 去除尾部空白行
1011
+ // Trim trailing blank lines
1059
1012
  output = output.replace(/\n+$/, '').trim();
1060
1013
  }
1061
1014
  else {
1062
- // 非录制交互模式:使用 session buffer
1015
+ // Non-recording interactive: use session buffer
1063
1016
  const fullOutput = this.terminal.getSessionOutput();
1064
1017
  output = this.stripAnsi(fullOutput.substring(this.beforeOutputLength));
1065
1018
  }
1066
- // 命令后暂停(录制模式)
1019
+ // Pause after command (recording)
1067
1020
  if (this.terminal.isRecording()) {
1068
1021
  const stepOptions = getCurrentStepOptions();
1069
1022
  const pauseAfter = this.options.pauseAfter ?? stepOptions?.pauseAfter;
@@ -1072,7 +1025,7 @@ class PTYProcessImpl {
1072
1025
  }
1073
1026
  }
1074
1027
  const result = new CommandResultImpl({
1075
- code: -1, // PTY 模式无法可靠获取退出码,设为 -1 表示不可用
1028
+ code: -1, // PTY: exitCode unreliable
1076
1029
  stdout: output,
1077
1030
  stderr: '',
1078
1031
  output,
@@ -1090,7 +1043,7 @@ class PTYProcessImpl {
1090
1043
  return result;
1091
1044
  }
1092
1045
  else {
1093
- // Bun.spawn 模式:等待进程结束
1046
+ // Bun.spawn: wait for process exit
1094
1047
  try {
1095
1048
  const proc = this.bunProcess;
1096
1049
  const stdoutStream = proc.stdout;
@@ -1106,7 +1059,7 @@ class PTYProcessImpl {
1106
1059
  reject(new Error(`Command timeout after ${timeout}ms: ${this.command}`));
1107
1060
  }, timeout)),
1108
1061
  ]);
1109
- // 将输出存储到终端,支持 expect(terminal).toContainText() 断言
1062
+ // Store output for expect(terminal).toContainText()
1110
1063
  const combinedOutput = stdout + stderr;
1111
1064
  this.terminal.appendNonInteractiveOutput(combinedOutput);
1112
1065
  const result = new CommandResultImpl({
@@ -1146,8 +1099,7 @@ class PTYProcessImpl {
1146
1099
  const tmuxSession = this.terminal.getTmuxSessionName();
1147
1100
  const paneIndex = this.terminal.getPaneIndex();
1148
1101
  if (this.terminal.isRecording() && tmuxSession && paneIndex !== undefined) {
1149
- // ★ 使用 tmux send-keys 直接发送到指定窗格
1150
- // 不依赖当前活动窗格状态
1102
+ // tmux send-keys to target pane
1151
1103
  await Bun.spawn([
1152
1104
  'tmux', 'send-keys', '-t', `${tmuxSession}:0.${paneIndex}`, 'C-c'
1153
1105
  ]).exited;