repterm 0.1.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 (97) hide show
  1. package/dist/api/describe.d.ts +18 -0
  2. package/dist/api/describe.d.ts.map +1 -0
  3. package/dist/api/describe.js +33 -0
  4. package/dist/api/describe.js.map +1 -0
  5. package/dist/api/expect.d.ts +43 -0
  6. package/dist/api/expect.d.ts.map +1 -0
  7. package/dist/api/expect.js +167 -0
  8. package/dist/api/expect.js.map +1 -0
  9. package/dist/api/hooks.d.ts +178 -0
  10. package/dist/api/hooks.d.ts.map +1 -0
  11. package/dist/api/hooks.js +231 -0
  12. package/dist/api/hooks.js.map +1 -0
  13. package/dist/api/steps.d.ts +45 -0
  14. package/dist/api/steps.d.ts.map +1 -0
  15. package/dist/api/steps.js +106 -0
  16. package/dist/api/steps.js.map +1 -0
  17. package/dist/api/test.d.ts +101 -0
  18. package/dist/api/test.d.ts.map +1 -0
  19. package/dist/api/test.js +207 -0
  20. package/dist/api/test.js.map +1 -0
  21. package/dist/cli/index.d.ts +7 -0
  22. package/dist/cli/index.d.ts.map +1 -0
  23. package/dist/cli/index.js +203 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/cli/reporter.d.ts +108 -0
  26. package/dist/cli/reporter.d.ts.map +1 -0
  27. package/dist/cli/reporter.js +368 -0
  28. package/dist/cli/reporter.js.map +1 -0
  29. package/dist/index.d.ts +15 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +24 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/plugin/index.d.ts +47 -0
  34. package/dist/plugin/index.d.ts.map +1 -0
  35. package/dist/plugin/index.js +86 -0
  36. package/dist/plugin/index.js.map +1 -0
  37. package/dist/plugin/withPlugins.d.ts +71 -0
  38. package/dist/plugin/withPlugins.d.ts.map +1 -0
  39. package/dist/plugin/withPlugins.js +101 -0
  40. package/dist/plugin/withPlugins.js.map +1 -0
  41. package/dist/recording/recorder.d.ts +45 -0
  42. package/dist/recording/recorder.d.ts.map +1 -0
  43. package/dist/recording/recorder.js +97 -0
  44. package/dist/recording/recorder.js.map +1 -0
  45. package/dist/runner/artifacts.d.ts +39 -0
  46. package/dist/runner/artifacts.d.ts.map +1 -0
  47. package/dist/runner/artifacts.js +59 -0
  48. package/dist/runner/artifacts.js.map +1 -0
  49. package/dist/runner/config.d.ts +46 -0
  50. package/dist/runner/config.d.ts.map +1 -0
  51. package/dist/runner/config.js +65 -0
  52. package/dist/runner/config.js.map +1 -0
  53. package/dist/runner/filter.d.ts +26 -0
  54. package/dist/runner/filter.d.ts.map +1 -0
  55. package/dist/runner/filter.js +88 -0
  56. package/dist/runner/filter.js.map +1 -0
  57. package/dist/runner/loader.d.ts +32 -0
  58. package/dist/runner/loader.d.ts.map +1 -0
  59. package/dist/runner/loader.js +252 -0
  60. package/dist/runner/loader.js.map +1 -0
  61. package/dist/runner/models.d.ts +261 -0
  62. package/dist/runner/models.d.ts.map +1 -0
  63. package/dist/runner/models.js +5 -0
  64. package/dist/runner/models.js.map +1 -0
  65. package/dist/runner/runner.d.ts +36 -0
  66. package/dist/runner/runner.d.ts.map +1 -0
  67. package/dist/runner/runner.js +216 -0
  68. package/dist/runner/runner.js.map +1 -0
  69. package/dist/runner/scheduler.d.ts +59 -0
  70. package/dist/runner/scheduler.d.ts.map +1 -0
  71. package/dist/runner/scheduler.js +157 -0
  72. package/dist/runner/scheduler.js.map +1 -0
  73. package/dist/runner/worker-runner.d.ts +6 -0
  74. package/dist/runner/worker-runner.d.ts.map +1 -0
  75. package/dist/runner/worker-runner.js +51 -0
  76. package/dist/runner/worker-runner.js.map +1 -0
  77. package/dist/runner/worker.d.ts +54 -0
  78. package/dist/runner/worker.d.ts.map +1 -0
  79. package/dist/runner/worker.js +112 -0
  80. package/dist/runner/worker.js.map +1 -0
  81. package/dist/terminal/session.d.ts +56 -0
  82. package/dist/terminal/session.d.ts.map +1 -0
  83. package/dist/terminal/session.js +126 -0
  84. package/dist/terminal/session.js.map +1 -0
  85. package/dist/terminal/terminal.d.ts +284 -0
  86. package/dist/terminal/terminal.d.ts.map +1 -0
  87. package/dist/terminal/terminal.js +1167 -0
  88. package/dist/terminal/terminal.js.map +1 -0
  89. package/dist/utils/dependencies.d.ts +19 -0
  90. package/dist/utils/dependencies.d.ts.map +1 -0
  91. package/dist/utils/dependencies.js +58 -0
  92. package/dist/utils/dependencies.js.map +1 -0
  93. package/dist/utils/timing.d.ts +55 -0
  94. package/dist/utils/timing.d.ts.map +1 -0
  95. package/dist/utils/timing.js +87 -0
  96. package/dist/utils/timing.js.map +1 -0
  97. package/package.json +43 -0
@@ -0,0 +1,1167 @@
1
+ /**
2
+ * Terminal API implementation
3
+ * Provides high-level terminal interaction (start/send/wait/snapshot)
4
+ *
5
+ * 执行架构:
6
+ * - 非录制、非交互模式:使用 Bun.spawn,stdout/stderr 分离,exitCode 精确
7
+ * - 录制或交互模式:使用 PTY,支持复杂交互,但 exitCode 不可靠
8
+ */
9
+ import { TerminalSession } from './session.js';
10
+ import { EventEmitter } from 'events';
11
+ import { getCurrentStepOptions, getCurrentStepName, shouldShowStepTitle, markStepTitleShown } from '../api/steps.js';
12
+ /**
13
+ * 计算 tmux 输出捕获的行范围
14
+ * 纯函数,可独立单元测试
15
+ */
16
+ export function calculateOutputRange(beforeCursorY, beforeHistorySize, afterCursorY, afterHistorySize, promptLineCount) {
17
+ const historyGrowth = afterHistorySize - beforeHistorySize;
18
+ const startLine = beforeCursorY + 1 - historyGrowth;
19
+ const endLine = afterCursorY - promptLineCount;
20
+ return { startLine, endLine };
21
+ }
22
+ /**
23
+ * CommandResult 实现类,提供 successful getter
24
+ */
25
+ class CommandResultImpl {
26
+ code;
27
+ stdout;
28
+ stderr;
29
+ output;
30
+ duration;
31
+ command;
32
+ constructor(data) {
33
+ this.code = data.code;
34
+ this.stdout = data.stdout;
35
+ this.stderr = data.stderr;
36
+ this.output = data.output;
37
+ this.duration = data.duration;
38
+ this.command = data.command;
39
+ }
40
+ get successful() {
41
+ return this.code === 0;
42
+ }
43
+ }
44
+ /**
45
+ * High-level Terminal API for test authoring
46
+ */
47
+ export class Terminal extends EventEmitter {
48
+ session;
49
+ recording;
50
+ ptyOnly; // PTY-only 模式标志
51
+ recordingPath;
52
+ closed = false;
53
+ initialized = false;
54
+ tmuxSessionName;
55
+ tmuxPaneId;
56
+ sharedState;
57
+ paneIndex; // Index of the tmux pane this terminal is bound to
58
+ nonInteractiveOutput = ''; // 存储非交互模式下的命令输出
59
+ commandLogs = []; // 存储测试期间执行过的命令
60
+ pluginFactory; // 插件工厂
61
+ plugins; // 插件实例(为新终端设置)
62
+ // 发送 Enter 前的状态(用于输出范围捕获)
63
+ beforeEnterHistorySize = 0;
64
+ beforeEnterCursorY = 0;
65
+ // 命令的实际行数(直接从命令内容计算,最可靠)
66
+ commandLineCount = 0;
67
+ // 检测到的提示符行数(默认 0,由自动检测或用户配置设置)
68
+ promptLineCount = 0;
69
+ // 是否使用用户配置的值(跳过自动检测)
70
+ promptLineCountConfigured = false;
71
+ // 检测到的提示符匹配 pattern
72
+ detectedPromptPattern;
73
+ constructor(config = {}) {
74
+ super();
75
+ this.recording = config.recording ?? false;
76
+ this.ptyOnly = config.ptyOnly ?? false;
77
+ this.recordingPath = config.recordingPath;
78
+ this.tmuxSessionName = config.tmuxSessionName;
79
+ this.tmuxPaneId = config.tmuxPaneId;
80
+ this.sharedState = { paneCount: 1, paneOutputs: new Map() }; // Start with 1 pane
81
+ this.paneIndex = 0; // Main terminal is pane 0
82
+ this.nonInteractiveOutput = '';
83
+ // 如果用户配置了 promptLineCount,直接使用,不进行自动检测
84
+ if (config.promptLineCount !== undefined) {
85
+ this.promptLineCount = config.promptLineCount;
86
+ this.promptLineCountConfigured = true;
87
+ }
88
+ // Create session - in recording mode, spawn asciinema; otherwise spawn shell
89
+ this.session = new TerminalSession({
90
+ cols: config.cols,
91
+ rows: config.rows,
92
+ });
93
+ }
94
+ /**
95
+ * Get tmux session name (for multi-terminal coordination)
96
+ */
97
+ getTmuxSessionName() {
98
+ return this.tmuxSessionName;
99
+ }
100
+ /**
101
+ * Get tmux pane ID (for multi-terminal coordination)
102
+ */
103
+ getTmuxPaneId() {
104
+ return this.tmuxPaneId;
105
+ }
106
+ /**
107
+ * Get terminal session (for direct access)
108
+ */
109
+ getSession() {
110
+ return this.session;
111
+ }
112
+ /**
113
+ * Get shared state (for factory to update pane count)
114
+ */
115
+ getSharedState() {
116
+ return this.sharedState;
117
+ }
118
+ /**
119
+ * Increment pane count (called by factory when creating new panes)
120
+ */
121
+ incrementPaneCount() {
122
+ this.sharedState.paneCount++;
123
+ }
124
+ /**
125
+ * Set parent session (for child terminals that share a session)
126
+ */
127
+ setParentSession(session, sharedState, paneIndex) {
128
+ this.session = session;
129
+ this.sharedState = sharedState;
130
+ this.paneIndex = paneIndex;
131
+ this.initialized = true; // Already initialized via parent
132
+ }
133
+ /**
134
+ * Select the tmux pane that this terminal is bound to
135
+ * Uses arrow key navigation to switch panes without showing pane IDs
136
+ */
137
+ async selectPane() {
138
+ if (this.paneIndex === undefined || !this.recording)
139
+ return;
140
+ // Track current active pane in shared state
141
+ const currentActive = this.sharedState.currentActivePane ?? 0;
142
+ if (currentActive === this.paneIndex) {
143
+ return; // Already on the correct pane
144
+ }
145
+ // Calculate how many panes to navigate
146
+ // First split is horizontal (up/down), second is vertical (left/right), etc.
147
+ // For simplicity, use Ctrl+B o to cycle through panes
148
+ const panesToCycle = (this.paneIndex - currentActive + this.sharedState.paneCount) % this.sharedState.paneCount;
149
+ for (let i = 0; i < panesToCycle; i++) {
150
+ this.session.write('\x02'); // Ctrl+B (tmux prefix)
151
+ await this.sleep(50);
152
+ this.session.write('o'); // Cycle to next pane
153
+ await this.sleep(150);
154
+ }
155
+ // Update current active pane
156
+ this.sharedState.currentActivePane = this.paneIndex;
157
+ }
158
+ /**
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 启用交互模式
171
+ */
172
+ run(command, options = {}) {
173
+ if (this.closed) {
174
+ throw new Error('Terminal is closed');
175
+ }
176
+ return new PTYProcessImpl(this, command, options);
177
+ }
178
+ /**
179
+ * Initialize terminal session (recording or non-recording)
180
+ */
181
+ async initializeSession() {
182
+ if (this.initialized)
183
+ return;
184
+ if (this.recording && this.recordingPath) {
185
+ // 只在用户未配置 promptLineCount 时才自动检测
186
+ if (!this.promptLineCountConfigured) {
187
+ await this.detectPromptBeforeRecording();
188
+ }
189
+ // 生成 tmux session 名称
190
+ const sessionName = `repterm-${Date.now().toString(36)}`;
191
+ this.tmuxSessionName = sessionName;
192
+ // Recording mode: 使用 asciinema --command 直接启动 tmux
193
+ // 必须显式设置 TERM=xterm-256color,否则 asciinema 会检测父进程的终端类型
194
+ this.session.start({
195
+ shell: 'asciinema',
196
+ args: ['rec', '--command', `tmux new -s ${sessionName}`, this.recordingPath, '--overwrite'],
197
+ env: {
198
+ TERM: 'xterm-256color',
199
+ },
200
+ });
201
+ // 等待 tmux 启动就绪
202
+ await this.waitForTmuxReady();
203
+ }
204
+ else if (this.ptyOnly) {
205
+ // PTY-only 模式:直接启动 shell(不使用 asciinema/tmux)
206
+ this.session.start();
207
+ await this.waitForShellReady();
208
+ }
209
+ else if (this.tmuxPaneId) {
210
+ // This is a split pane, don't initialize a new session
211
+ // Commands will be sent through the main terminal
212
+ this.initialized = true;
213
+ return;
214
+ }
215
+ else {
216
+ // Non-recording mode: spawn shell directly
217
+ this.session.start();
218
+ // Wait for shell to initialize and be ready
219
+ await this.waitForShellReady();
220
+ }
221
+ this.initialized = true;
222
+ }
223
+ /**
224
+ * Wait for shell to be ready (detect shell prompt)
225
+ */
226
+ async waitForShellReady(timeout = 5000) {
227
+ const startTime = Date.now();
228
+ while (Date.now() - startTime < timeout) {
229
+ const output = this.session.getOutput();
230
+ // 检测 shell 准备好的标志(出现命令提示符)
231
+ if (output.includes('$') || output.includes('#') || output.includes('%') || output.includes('>')) {
232
+ await this.sleep(100); // 额外等待确保稳定
233
+ return;
234
+ }
235
+ await this.sleep(50);
236
+ }
237
+ // 超时不报错,继续执行
238
+ }
239
+ /**
240
+ * Wait for tmux to be ready (detect shell prompt)
241
+ */
242
+ async waitForTmuxReady(timeout = 5000) {
243
+ const startTime = Date.now();
244
+ while (Date.now() - startTime < timeout) {
245
+ const output = this.session.getOutput();
246
+ // 检测 tmux 启动完成的标志(通常是出现命令提示符)
247
+ if (output.includes('$') || output.includes('#') || output.includes('%')) {
248
+ await this.sleep(300); // 额外等待确保稳定
249
+ return;
250
+ }
251
+ await this.sleep(100);
252
+ }
253
+ // 超时不报错,继续执行
254
+ }
255
+ /**
256
+ * Send text to the terminal
257
+ */
258
+ async send(text) {
259
+ if (this.closed) {
260
+ throw new Error('Terminal is closed');
261
+ }
262
+ if (!this.session.isActive()) {
263
+ throw new Error('Terminal not started');
264
+ }
265
+ // In recording mode, simulate human typing
266
+ if (this.recording) {
267
+ const stepOptions = getCurrentStepOptions();
268
+ const typingSpeed = stepOptions?.typingSpeed ?? 80;
269
+ await this.typeWithDelay(text, typingSpeed);
270
+ }
271
+ else {
272
+ this.session.write(text);
273
+ }
274
+ }
275
+ /**
276
+ * Wait for text to appear in terminal output
277
+ * In recording mode with multi-pane, uses tmux capture-pane for isolation
278
+ */
279
+ async waitForText(text, options = {}) {
280
+ const timeout = options.timeout ?? 5000;
281
+ const shouldStripAnsi = options.stripAnsi ?? true; // Default to true
282
+ const startTime = Date.now();
283
+ while (Date.now() - startTime < timeout) {
284
+ let output;
285
+ if (this.recording && this.paneIndex !== undefined && this.tmuxSessionName) {
286
+ // Recording mode: capture current pane's output via tmux
287
+ const rawOutput = await this.capturePaneOutput();
288
+ // Strip ANSI sequences if enabled (default)
289
+ output = shouldStripAnsi ? this.stripAnsi(rawOutput) : rawOutput;
290
+ }
291
+ else {
292
+ // Non-recording mode: use session buffer
293
+ output = this.getAllOutput();
294
+ }
295
+ if (output.includes(text)) {
296
+ return;
297
+ }
298
+ await this.sleep(100);
299
+ }
300
+ throw new Error(`Timeout waiting for text "${text}" after ${timeout}ms`);
301
+ }
302
+ /**
303
+ * Get snapshot of current terminal output
304
+ * In recording mode, returns current pane's output (stripped of ANSI)
305
+ */
306
+ async snapshot() {
307
+ if (this.closed) {
308
+ throw new Error('Terminal is closed');
309
+ }
310
+ if (this.recording && this.paneIndex !== undefined && this.tmuxSessionName) {
311
+ // Recording mode: capture current pane's output
312
+ const rawOutput = await this.capturePaneOutput();
313
+ return this.stripAnsi(rawOutput);
314
+ }
315
+ return this.getAllOutput();
316
+ }
317
+ /**
318
+ * Get all output (session + non-interactive commands)
319
+ */
320
+ getAllOutput() {
321
+ const sessionOutput = this.session.getOutput();
322
+ return sessionOutput + this.nonInteractiveOutput;
323
+ }
324
+ /**
325
+ * Capture output from current pane using tmux capture-pane
326
+ * Used in recording mode for per-pane output isolation
327
+ */
328
+ async capturePaneOutput() {
329
+ if (!this.tmuxSessionName || this.paneIndex === undefined) {
330
+ return '';
331
+ }
332
+ // Use tmux capture-pane to get current pane's text content
333
+ // -p: output to stdout instead of buffer
334
+ // -t: specify target pane
335
+ // -S -: start from scrollback top
336
+ // -E -: end at current line
337
+ const result = await this.runTmuxCommand(`capture-pane -p -t ${this.tmuxSessionName}:0.${this.paneIndex} -S - -E -`);
338
+ // Update shared pane output buffer
339
+ this.sharedState.paneOutputs.set(this.paneIndex, result);
340
+ return result;
341
+ }
342
+ /**
343
+ * Execute a tmux command and return its output
344
+ */
345
+ async runTmuxCommand(args) {
346
+ try {
347
+ const proc = Bun.spawn(['tmux', ...args.split(' ')], {
348
+ stdout: 'pipe',
349
+ stderr: 'pipe',
350
+ });
351
+ const stdout = await new Response(proc.stdout).text();
352
+ await proc.exited;
353
+ return stdout;
354
+ }
355
+ catch {
356
+ return '';
357
+ }
358
+ }
359
+ /**
360
+ * Strip ANSI escape sequences from text
361
+ */
362
+ stripAnsi(text) {
363
+ // Match common ANSI escape sequences
364
+ const ansiRegex = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[=>]|\x1b\[\?[0-9;]*[a-zA-Z]/g;
365
+ return text.replace(ansiRegex, '');
366
+ }
367
+ /**
368
+ * Append output from non-interactive command (internal use)
369
+ */
370
+ appendNonInteractiveOutput(output) {
371
+ this.nonInteractiveOutput += output;
372
+ }
373
+ /**
374
+ * Record a command execution result for failure diagnostics
375
+ */
376
+ appendCommandLog(log) {
377
+ this.commandLogs.push({ ...log });
378
+ }
379
+ /**
380
+ * Get command logs captured during the current test
381
+ */
382
+ getCommandLogs() {
383
+ return this.commandLogs.map((log) => ({ ...log }));
384
+ }
385
+ /**
386
+ * 设置插件工厂(用于 create() 自动注入插件)
387
+ * @internal 由插件系统调用
388
+ */
389
+ setPluginFactory(factory) {
390
+ this.pluginFactory = factory;
391
+ }
392
+ /**
393
+ * Create a new terminal instance (for multi-terminal tests)
394
+ * - Recording mode: splits tmux window (tmux already started via asciinema --command)
395
+ * - Non-recording mode: creates independent terminal
396
+ * - If pluginFactory is set, new terminal will have plugins property
397
+ */
398
+ async create() {
399
+ let newTerminal;
400
+ if (this.recording && this.tmuxSessionName) {
401
+ // 录制模式:使用快捷键 Ctrl+B split 窗口
402
+ // 分割策略(九宫格效果):
403
+ // - 当前1个窗口 -> 第2个窗口:水平分割(上下)使用 "
404
+ // - 当前2个窗口 -> 第3个窗口:垂直分割(左右)使用 %
405
+ // - 以此类推:奇数个窗口时水平分割,偶数个窗口时垂直分割
406
+ const currentPaneCount = this.sharedState.paneCount;
407
+ const splitKey = currentPaneCount % 2 === 1 ? '"' : '%'; // 奇数水平分割,偶数垂直分割
408
+ this.session.write('\x02'); // Ctrl+B
409
+ await this.sleep(100);
410
+ this.session.write(splitKey);
411
+ await this.sleep(800); // 等待新 pane 初始化
412
+ const newPaneIndex = this.sharedState.paneCount;
413
+ this.sharedState.paneCount++;
414
+ this.sharedState.currentActivePane = newPaneIndex; // New pane is now active after split
415
+ // Create a new Terminal bound to the new pane
416
+ newTerminal = new Terminal({
417
+ recording: true,
418
+ tmuxSessionName: this.tmuxSessionName,
419
+ });
420
+ newTerminal.setParentSession(this.session, this.sharedState, newPaneIndex);
421
+ }
422
+ else {
423
+ // 非录制模式:创建独立终端
424
+ newTerminal = new Terminal({ recording: false });
425
+ }
426
+ // 如果有插件工厂,为新终端创建插件实例
427
+ if (this.pluginFactory) {
428
+ newTerminal.pluginFactory = this.pluginFactory;
429
+ newTerminal.plugins = this.pluginFactory(newTerminal);
430
+ }
431
+ return newTerminal;
432
+ }
433
+ /**
434
+ * Close the terminal
435
+ */
436
+ async close() {
437
+ if (!this.closed) {
438
+ const tmuxSessionToClean = this.tmuxSessionName; // 保存 session 名称
439
+ if (this.recording && this.tmuxSessionName && this.session.isActive()) {
440
+ // 录制结束前等待 2 秒,让用户看到最后的输出
441
+ await this.sleep(2000);
442
+ // 使用快捷键 Ctrl+B d 分离 tmux(detach)
443
+ // 这会导致 tmux 退出,从而结束 asciinema 录制
444
+ await this.sleep(300);
445
+ this.session.write('\x02'); // Ctrl+B (tmux prefix)
446
+ await this.sleep(100);
447
+ this.session.write('d'); // detach
448
+ await this.sleep(500); // 等待 asciinema 结束录制
449
+ }
450
+ else if (this.recording && this.session.isActive()) {
451
+ // Recording without tmux - send Ctrl+D to end asciinema recording
452
+ this.session.write('\x04'); // Ctrl+D
453
+ await this.sleep(500);
454
+ }
455
+ // Use SIGTERM signal to kill the process
456
+ this.session.kill('SIGTERM');
457
+ this.closed = true;
458
+ this.emit('close');
459
+ // 录制结束后,清理 tmux session(在录制外执行)
460
+ if (tmuxSessionToClean) {
461
+ await this.cleanupTmuxSession(tmuxSessionToClean);
462
+ }
463
+ }
464
+ }
465
+ /**
466
+ * Clean up tmux session after recording ends
467
+ */
468
+ async cleanupTmuxSession(sessionName) {
469
+ try {
470
+ const { exec } = await import('child_process');
471
+ const { promisify } = await import('util');
472
+ const execAsync = promisify(exec);
473
+ await execAsync(`tmux kill-session -t ${sessionName} 2>/dev/null || true`);
474
+ }
475
+ catch {
476
+ // Ignore errors - session may already be terminated
477
+ }
478
+ }
479
+ /**
480
+ * Check if terminal is active
481
+ */
482
+ isActive() {
483
+ return this.session.isActive() && !this.closed;
484
+ }
485
+ /**
486
+ * Check if terminal is in recording mode
487
+ */
488
+ isRecording() {
489
+ return this.recording;
490
+ }
491
+ /**
492
+ * 是否使用 PTY 模式(录制或 pty-only)
493
+ * PTY 模式支持交互式命令,但打字效果仅在 recording 模式下启用
494
+ */
495
+ isPtyMode() {
496
+ return this.recording || this.ptyOnly;
497
+ }
498
+ /**
499
+ * Get pane index (for tmux commands)
500
+ */
501
+ getPaneIndex() {
502
+ return this.paneIndex;
503
+ }
504
+ /**
505
+ * Get session output (for non-recording PTY mode)
506
+ */
507
+ getSessionOutput() {
508
+ return this.session.getOutput();
509
+ }
510
+ /**
511
+ * Get output length at current moment (for range capture)
512
+ */
513
+ getOutputLength() {
514
+ return this.session.getOutput().length;
515
+ }
516
+ /**
517
+ * Get the state recorded before sending Enter (for output capture)
518
+ */
519
+ getBeforeEnterState() {
520
+ return {
521
+ historySize: this.beforeEnterHistorySize,
522
+ cursorY: this.beforeEnterCursorY,
523
+ commandLineCount: this.commandLineCount,
524
+ };
525
+ }
526
+ /**
527
+ * Record state before sending Enter (internal use)
528
+ * 使用单次 tmux 查询原子获取 history_size 和 cursor_y,避免竞态条件
529
+ */
530
+ async recordBeforeEnterState() {
531
+ if (!this.tmuxSessionName || this.paneIndex === undefined)
532
+ return;
533
+ try {
534
+ // ★ 原子查询:一次 tmux 调用同时获取 history_size 和 cursor_y
535
+ const proc = Bun.spawn(['tmux', 'display-message', '-t', `${this.tmuxSessionName}:0.${this.paneIndex}`, '-p', '#{history_size}:#{cursor_y}'], {
536
+ stdout: 'pipe', stderr: 'pipe'
537
+ });
538
+ const stdout = await new Response(proc.stdout).text();
539
+ await proc.exited;
540
+ const parts = stdout.trim().split(':');
541
+ this.beforeEnterHistorySize = parseInt(parts[0], 10) || 0;
542
+ this.beforeEnterCursorY = parseInt(parts[1], 10) || 0;
543
+ }
544
+ catch {
545
+ this.beforeEnterHistorySize = 0;
546
+ this.beforeEnterCursorY = 0;
547
+ }
548
+ }
549
+ /**
550
+ * 在录制开始前检测提示符相关信息
551
+ * - 提示符占用的行数
552
+ * - 提示符匹配 pattern
553
+ * 通过创建临时 tmux session 来测量,检测过程不会出现在录制中
554
+ */
555
+ async detectPromptBeforeRecording() {
556
+ const tempSessionName = `repterm-detect-${Date.now()}`;
557
+ const testMarker = '__REPTERM_PROMPT_TEST__';
558
+ try {
559
+ // 创建临时 tmux session
560
+ await Bun.spawn(['tmux', 'new-session', '-d', '-s', tempSessionName, '-x', '80', '-y', '24']).exited;
561
+ await this.sleep(1000); // 等待 shell 启动
562
+ // ★ 检测提示符 pattern(在发送命令前捕获干净的提示符)
563
+ const promptCaptureProc = Bun.spawn(['tmux', 'capture-pane', '-p', '-t', tempSessionName], {
564
+ stdout: 'pipe', stderr: 'pipe'
565
+ });
566
+ const promptScreen = await new Response(promptCaptureProc.stdout).text();
567
+ await promptCaptureProc.exited;
568
+ this.detectedPromptPattern = this.analyzePromptLine(promptScreen);
569
+ // 发送测试命令(用于检测提示符行数)
570
+ await Bun.spawn(['tmux', 'send-keys', '-t', tempSessionName, `echo ${testMarker}`, 'Enter']).exited;
571
+ await this.sleep(1000); // 等待命令执行完成
572
+ // 获取当前 cursorY
573
+ const cursorProc = Bun.spawn(['tmux', 'display-message', '-t', tempSessionName, '-p', '#{cursor_y}'], {
574
+ stdout: 'pipe', stderr: 'pipe'
575
+ });
576
+ const cursorY = parseInt((await new Response(cursorProc.stdout).text()).trim(), 10) || 0;
577
+ await cursorProc.exited;
578
+ // 捕获屏幕内容
579
+ const captureProc = Bun.spawn(['tmux', 'capture-pane', '-p', '-t', tempSessionName, '-S', '0', '-E', String(cursorY)], {
580
+ stdout: 'pipe', stderr: 'pipe'
581
+ });
582
+ const screenContent = await new Response(captureProc.stdout).text();
583
+ await captureProc.exited;
584
+ // 找到测试字符串的行(找最后一次出现,即 echo 的输出,而非命令回显)
585
+ const lines = screenContent.split('\n');
586
+ let markerLine = -1;
587
+ for (let i = 0; i < lines.length; i++) {
588
+ if (lines[i].includes(testMarker)) {
589
+ markerLine = i; // 不 break,继续找下一个,最终得到最后一次出现的位置
590
+ }
591
+ }
592
+ if (markerLine >= 0) {
593
+ // 提示符行数 = cursorY - markerLine
594
+ this.promptLineCount = cursorY - markerLine;
595
+ if (this.promptLineCount < 1)
596
+ this.promptLineCount = 1;
597
+ }
598
+ // 关闭临时 session
599
+ await Bun.spawn(['tmux', 'kill-session', '-t', tempSessionName]).exited;
600
+ }
601
+ catch {
602
+ // 检测失败时保持默认值
603
+ this.promptLineCount = 0;
604
+ this.detectedPromptPattern = undefined;
605
+ // 尝试清理临时 session
606
+ try {
607
+ await Bun.spawn(['tmux', 'kill-session', '-t', tempSessionName]).exited;
608
+ }
609
+ catch { /* ignore */ }
610
+ }
611
+ }
612
+ /**
613
+ * 分析提示符行,生成匹配正则
614
+ */
615
+ analyzePromptLine(screenContent) {
616
+ const lines = screenContent.trim().split('\n');
617
+ const promptLine = lines[lines.length - 1] || '';
618
+ if (!promptLine.trim()) {
619
+ return undefined;
620
+ }
621
+ // 常见提示符字符
622
+ const promptChars = ['$', '#', '%', '>', '❯', '→', 'λ', '»', '❮', '›', '⟩'];
623
+ // 查找提示符字符的位置(选最后出现的)
624
+ let foundChar = '';
625
+ let charIndex = -1;
626
+ for (const char of promptChars) {
627
+ const idx = promptLine.indexOf(char);
628
+ if (idx !== -1 && idx > charIndex) {
629
+ charIndex = idx;
630
+ foundChar = char;
631
+ }
632
+ }
633
+ if (charIndex === -1) {
634
+ return undefined;
635
+ }
636
+ // 分析提示符后的内容
637
+ const afterPrompt = promptLine.substring(charIndex + 1);
638
+ const hasRightContent = afterPrompt.trim().length > 0;
639
+ // 转义特殊正则字符
640
+ const escapedChar = foundChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
641
+ if (hasRightContent) {
642
+ // 右侧提示符布局:提示符后有多个空格再有内容
643
+ return new RegExp(`${escapedChar}\\s{2,}`);
644
+ }
645
+ else {
646
+ // 传统布局:提示符在行尾
647
+ return new RegExp(`${escapedChar}\\s*$`);
648
+ }
649
+ }
650
+ /**
651
+ * 获取检测到的提示符行数
652
+ */
653
+ getPromptLineCount() {
654
+ return this.promptLineCount;
655
+ }
656
+ /**
657
+ * 获取检测到的提示符匹配 pattern
658
+ */
659
+ getDetectedPromptPattern() {
660
+ return this.detectedPromptPattern;
661
+ }
662
+ /**
663
+ * Type text with human-like delays (for recording mode)
664
+ * @param text - 要打字的文本
665
+ * @param speed - 每字符延迟 (ms),默认 80ms
666
+ * @param variableSpeed - 是否使用变速模式(更自然)
667
+ */
668
+ async typeWithDelay(text, speed = 80, variableSpeed = true) {
669
+ if (speed === 0) {
670
+ // 速度为 0 时直接写入
671
+ this.session.write(text);
672
+ return;
673
+ }
674
+ let momentum = 0; // 打字动量,模拟熟练度
675
+ for (let i = 0; i < text.length; i++) {
676
+ const char = text[i];
677
+ this.session.write(char);
678
+ let delay;
679
+ if (variableSpeed) {
680
+ // 变速模式:模拟真实打字的加速和减速
681
+ const baseDelay = Math.max(speed * 0.5, speed - momentum * 5);
682
+ if (char === ' ' || char === '\n') {
683
+ // 单词边界:重置动量,稍长停顿
684
+ momentum = 0;
685
+ delay = baseDelay + speed * 0.3;
686
+ }
687
+ else {
688
+ // 连续打字:逐渐加速
689
+ momentum = Math.min(momentum + 1, 10);
690
+ delay = baseDelay + (Math.random() - 0.5) * speed * 0.4;
691
+ }
692
+ // 标点符号后额外停顿
693
+ if ('.,:;!?'.includes(char)) {
694
+ delay += speed * 0.5;
695
+ }
696
+ }
697
+ else {
698
+ // 原有模式:固定速度 ± 30%
699
+ delay = speed + (Math.random() - 0.5) * speed * 0.6;
700
+ }
701
+ await this.sleep(delay);
702
+ // 引号特殊处理
703
+ if (char === '"' || char === "'") {
704
+ await this.sleep(50);
705
+ }
706
+ }
707
+ }
708
+ /**
709
+ * 在录制中显示步骤标题
710
+ */
711
+ async displayStepTitle(title) {
712
+ if (!this.recording)
713
+ return;
714
+ const stepOptions = getCurrentStepOptions();
715
+ const typingSpeed = stepOptions?.typingSpeed;
716
+ // 显示注释形式的标题
717
+ const comment = `# === ${title} ===`;
718
+ await this.typeWithDelay(comment, typingSpeed ?? 40, false);
719
+ this.session.write('\r');
720
+ if (typingSpeed !== 0) {
721
+ await this.sleep(500); // 让标题显示片刻
722
+ }
723
+ }
724
+ /**
725
+ * Wait for command to complete
726
+ * Uses prompt detection only
727
+ */
728
+ async waitForOutputStable(timeout = 10000) {
729
+ const startTime = Date.now();
730
+ // 检测行中任意位置的提示符(支持右侧提示符布局)
731
+ // 提示符后面通常跟着空格(输入区域)
732
+ // 使用检测到的 pattern,或默认 pattern
733
+ const promptPattern = this.detectedPromptPattern ?? /[\$#%>❯→λ»]\s+/;
734
+ const checkInterval = 100;
735
+ while (Date.now() - startTime < timeout) {
736
+ await this.sleep(checkInterval);
737
+ const output = this.recording
738
+ ? await this.capturePaneOutput()
739
+ : this.session.getOutput();
740
+ const lastLine = output.trim().split('\n').pop() || '';
741
+ if (promptPattern.test(lastLine)) {
742
+ return;
743
+ }
744
+ }
745
+ }
746
+ /**
747
+ * Sleep utility
748
+ */
749
+ sleep(ms) {
750
+ return new Promise((resolve) => setTimeout(resolve, ms));
751
+ }
752
+ /**
753
+ * Wait for output to stabilize (public, for PTYProcessImpl)
754
+ */
755
+ async waitForOutputStablePublic(timeout = 10000) {
756
+ return this.waitForOutputStable(timeout);
757
+ }
758
+ /**
759
+ * Execute command in PTY (internal, for PTYProcessImpl)
760
+ */
761
+ async executeInPty(command, options) {
762
+ // Initialize session on first command
763
+ if (!this.session.isActive()) {
764
+ await this.initializeSession();
765
+ }
766
+ // ★ 命令前暂停
767
+ if (this.recording && options?.pauseBefore) {
768
+ await this.sleep(options.pauseBefore);
769
+ }
770
+ // ★ 显示步骤标题(每个 step 只显示一次)
771
+ if (this.recording && shouldShowStepTitle() && options?.stepName) {
772
+ await this.displayStepTitle(options.stepName);
773
+ markStepTitleShown();
774
+ }
775
+ // ★ 记录命令行数(直接从命令内容计算,最可靠)
776
+ this.commandLineCount = command.split('\n').length;
777
+ // In recording mode, type with human-like delay
778
+ if (this.recording) {
779
+ await this.selectPane();
780
+ await this.sleep(300);
781
+ const hasNewline = command.includes('\n');
782
+ const typingSpeed = options?.typingSpeed ?? 80;
783
+ if (hasNewline && this.tmuxSessionName) {
784
+ // 多行命令:使用 Bracketed Paste Mode 避免续行提示符
785
+ await this.pasteWithTmux(command);
786
+ }
787
+ else if (typingSpeed === 0) {
788
+ // 禁用打字效果时:快速写入
789
+ this.session.write(command);
790
+ await this.sleep(100);
791
+ // ★ 在发送 Enter 之前记录状态
792
+ await this.recordBeforeEnterState();
793
+ this.session.write('\r');
794
+ }
795
+ else {
796
+ // 正常命令:人工打字效果(无论长度)
797
+ await this.typeWithDelay(command, typingSpeed, true);
798
+ // ★ 在发送 Enter 之前记录状态
799
+ await this.recordBeforeEnterState();
800
+ this.session.write('\r');
801
+ }
802
+ }
803
+ else {
804
+ this.session.write(command + '\n');
805
+ }
806
+ await this.sleep(50);
807
+ }
808
+ /**
809
+ * 使用 Bracketed Paste Mode 粘贴多行命令
810
+ * 避免 shell 显示续行提示符(如 quote>、pipe heredoc>)
811
+ *
812
+ * Bracketed Paste Mode 使用转义序列包裹粘贴内容:
813
+ * - \x1b[200~ 标记粘贴开始
814
+ * - \x1b[201~ 标记粘贴结束
815
+ * Shell 会将整个内容作为单个输入块处理,不显示续行提示符
816
+ */
817
+ async pasteWithTmux(command) {
818
+ const paneTarget = `${this.tmuxSessionName}:0.${this.paneIndex}`;
819
+ // Bracketed Paste Mode 转义序列
820
+ const PASTE_START = '\x1b[200~'; // ESC [ 200 ~
821
+ const PASTE_END = '\x1b[201~'; // ESC [ 201 ~
822
+ // 包裹命令内容(不包含最后的回车,回车在粘贴结束后单独发送)
823
+ const wrappedContent = PASTE_START + command + PASTE_END;
824
+ // 使用 tmux send-keys -l 发送字面内容(包含转义序列)
825
+ await Bun.spawn(['tmux', 'send-keys', '-l', '-t', paneTarget, wrappedContent]).exited;
826
+ // 等待 shell 处理
827
+ await this.sleep(500);
828
+ // ★ 在发送 Enter 之前记录状态(用于输出范围捕获)
829
+ await this.recordBeforeEnterState();
830
+ // 发送回车执行命令
831
+ await Bun.spawn(['tmux', 'send-keys', '-t', paneTarget, 'Enter']).exited;
832
+ await this.sleep(200);
833
+ }
834
+ }
835
+ /**
836
+ * PTYProcess 实现类
837
+ * 实现 PromiseLike 接口,支持 await 和控制器两种用法
838
+ *
839
+ * 执行模式:
840
+ * - 非录制、非交互:使用 Bun.spawn,stdout/stderr 分离,exitCode 精确
841
+ * - 录制或交互:使用 PTY,支持 expect/send,但 exitCode 不可靠(返回 -1)
842
+ */
843
+ class PTYProcessImpl {
844
+ terminal;
845
+ commandStarted = false;
846
+ command;
847
+ options;
848
+ startTime;
849
+ // 用于非录制、非交互模式的 Bun.spawn 进程
850
+ bunProcess;
851
+ isInteractive;
852
+ // 用于非录制交互模式的输出起始位置
853
+ beforeOutputLength = 0;
854
+ constructor(terminal, command, options = {}) {
855
+ this.terminal = terminal;
856
+ this.command = command;
857
+ this.options = options;
858
+ this.startTime = Date.now();
859
+ this.isInteractive = options.interactive ?? false;
860
+ }
861
+ // ===== PromiseLike 实现 =====
862
+ /**
863
+ * 实现 PromiseLike.then()
864
+ * await proc 时自动调用此方法
865
+ */
866
+ then(onfulfilled, onrejected) {
867
+ return this.wait().then(onfulfilled, onrejected);
868
+ }
869
+ /**
870
+ * 实现 catch 方法(便捷方法)
871
+ */
872
+ catch(onrejected) {
873
+ return this.wait().catch(onrejected);
874
+ }
875
+ /**
876
+ * 实现 finally 方法(便捷方法)
877
+ */
878
+ finally(onfinally) {
879
+ return this.wait().finally(onfinally);
880
+ }
881
+ // ===== 内部方法 =====
882
+ /**
883
+ * 判断是否使用 PTY 模式(录制、ptyOnly 或交互)
884
+ * silent 模式强制使用 Bun.spawn 获取干净输出
885
+ */
886
+ usePtyMode() {
887
+ if (this.options.silent) {
888
+ return false;
889
+ }
890
+ // 包含 ptyOnly 模式
891
+ return this.terminal.isRecording() || this.terminal.isPtyMode() || this.isInteractive;
892
+ }
893
+ /**
894
+ * 延时辅助方法
895
+ */
896
+ sleep(ms) {
897
+ return new Promise(resolve => setTimeout(resolve, ms));
898
+ }
899
+ /**
900
+ * 原子获取 pane 状态(history_size 和 cursor_y)
901
+ * 使用单次 tmux 调用避免竞态条件
902
+ */
903
+ async getPaneStateAtomic() {
904
+ if (!this.terminal.isRecording())
905
+ return { historySize: 0, cursorY: 0 };
906
+ const tmuxSession = this.terminal.getTmuxSessionName();
907
+ const paneIndex = this.terminal.getPaneIndex();
908
+ if (!tmuxSession || paneIndex === undefined)
909
+ return { historySize: 0, cursorY: 0 };
910
+ try {
911
+ const proc = Bun.spawn(['tmux', 'display-message', '-t', `${tmuxSession}:0.${paneIndex}`, '-p', '#{history_size}:#{cursor_y}'], {
912
+ stdout: 'pipe',
913
+ stderr: 'pipe',
914
+ });
915
+ const stdout = await new Response(proc.stdout).text();
916
+ await proc.exited;
917
+ const parts = stdout.trim().split(':');
918
+ return {
919
+ historySize: parseInt(parts[0], 10) || 0,
920
+ cursorY: parseInt(parts[1], 10) || 0,
921
+ };
922
+ }
923
+ catch {
924
+ return { historySize: 0, cursorY: 0 };
925
+ }
926
+ }
927
+ /**
928
+ * 捕获 pane 指定范围的输出
929
+ * @param startLine 起始行(负数=历史,正数=可见区域,'-'=历史开头)
930
+ * @param endLine 结束行('-'=当前位置)
931
+ */
932
+ async capturePaneRange(startLine, endLine) {
933
+ const tmuxSession = this.terminal.getTmuxSessionName();
934
+ const paneIndex = this.terminal.getPaneIndex();
935
+ if (!tmuxSession || paneIndex === undefined)
936
+ return '';
937
+ try {
938
+ const proc = Bun.spawn(['tmux', 'capture-pane', '-p', '-t', `${tmuxSession}:0.${paneIndex}`, '-S', startLine, '-E', endLine], {
939
+ stdout: 'pipe',
940
+ stderr: 'pipe',
941
+ });
942
+ const stdout = await new Response(proc.stdout).text();
943
+ await proc.exited;
944
+ return this.stripAnsi(stdout);
945
+ }
946
+ catch {
947
+ return '';
948
+ }
949
+ }
950
+ /**
951
+ * 去除 ANSI 转义序列
952
+ */
953
+ stripAnsi(text) {
954
+ const ansiRegex = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[=>]|\x1b\[\?[0-9;]*[a-zA-Z]/g;
955
+ return text.replace(ansiRegex, '');
956
+ }
957
+ /**
958
+ * 启动命令
959
+ * - PTY 模式:通过 terminal.executeInPty 执行
960
+ * - Bun.spawn 模式:直接启动子进程
961
+ */
962
+ async startCommand() {
963
+ if (this.commandStarted)
964
+ return;
965
+ this.commandStarted = true;
966
+ if (this.usePtyMode()) {
967
+ // 录制模式或交互式:使用 PTY
968
+ if (!this.terminal.isRecording()) {
969
+ // 非录制交互模式:记录当前输出长度
970
+ this.beforeOutputLength = this.terminal.getOutputLength();
971
+ }
972
+ // executeInPty 内部会在发送 Enter 前记录状态
973
+ // ★ 从当前 step 上下文获取录制选项
974
+ const stepOptions = getCurrentStepOptions();
975
+ const stepName = getCurrentStepName();
976
+ // 合并选项优先级:RunOptions > StepOptions > 默认值
977
+ const executeOptions = {
978
+ typingSpeed: this.options.typingSpeed ?? stepOptions?.typingSpeed,
979
+ pauseBefore: this.options.pauseBefore ?? stepOptions?.pauseBefore,
980
+ showStepTitle: stepOptions?.showStepTitle,
981
+ stepName: stepName ?? undefined,
982
+ };
983
+ await this.terminal.executeInPty(this.command, executeOptions);
984
+ }
985
+ else {
986
+ // 非录制、非交互:使用 Bun.spawn
987
+ const env = {};
988
+ for (const [key, value] of Object.entries(this.options.env ?? process.env)) {
989
+ if (value !== undefined && typeof value === 'string') {
990
+ env[key] = value;
991
+ }
992
+ }
993
+ this.bunProcess = Bun.spawn(['sh', '-c', this.command], {
994
+ stdout: 'pipe',
995
+ stderr: 'pipe',
996
+ cwd: this.options.cwd ?? process.cwd(),
997
+ env,
998
+ });
999
+ }
1000
+ }
1001
+ // ===== 交互式控制方法 =====
1002
+ /**
1003
+ * Wait for specified text to appear
1004
+ * 仅在交互模式或录制模式下可用
1005
+ */
1006
+ async expect(text, options) {
1007
+ if (!this.usePtyMode()) {
1008
+ throw new Error('expect() requires interactive mode: terminal.run(cmd, { interactive: true })');
1009
+ }
1010
+ await this.startCommand();
1011
+ await this.terminal.waitForText(text, options);
1012
+ }
1013
+ /**
1014
+ * Send input to the process (with newline)
1015
+ * 仅在交互模式或录制模式下可用
1016
+ */
1017
+ async send(input) {
1018
+ if (!this.usePtyMode()) {
1019
+ throw new Error('send() requires interactive mode: terminal.run(cmd, { interactive: true })');
1020
+ }
1021
+ await this.startCommand();
1022
+ await this.terminal.send(input + '\r');
1023
+ }
1024
+ /**
1025
+ * Send raw input to the process (without newline)
1026
+ * 仅在交互模式或录制模式下可用
1027
+ */
1028
+ async sendRaw(input) {
1029
+ if (!this.usePtyMode()) {
1030
+ throw new Error('sendRaw() requires interactive mode: terminal.run(cmd, { interactive: true })');
1031
+ }
1032
+ await this.startCommand();
1033
+ await this.terminal.send(input);
1034
+ }
1035
+ /**
1036
+ * 启动命令执行,等待输入完成(不等待命令执行完成)
1037
+ * 用于 watch 等长时间运行的命令
1038
+ */
1039
+ async start() {
1040
+ await this.startCommand();
1041
+ }
1042
+ /**
1043
+ * Wait for command to complete and return result
1044
+ */
1045
+ async wait(options) {
1046
+ await this.startCommand();
1047
+ const timeout = options?.timeout ?? this.options.timeout ?? 300000; // 5 minutes default
1048
+ if (this.usePtyMode()) {
1049
+ // PTY 模式:等待输出稳定
1050
+ await this.terminal.waitForOutputStablePublic(timeout);
1051
+ let output;
1052
+ if (this.terminal.isRecording()) {
1053
+ // 录制模式:使用发送 Enter 前记录的状态计算输出范围
1054
+ const beforeEnterState = this.terminal.getBeforeEnterState();
1055
+ const afterState = await this.getPaneStateAtomic();
1056
+ const { startLine: outputStartLine, endLine } = calculateOutputRange(beforeEnterState.cursorY, beforeEnterState.historySize, afterState.cursorY, afterState.historySize, this.terminal.getPromptLineCount());
1057
+ output = await this.capturePaneRange(String(outputStartLine), String(endLine));
1058
+ // 去除尾部空白行
1059
+ output = output.replace(/\n+$/, '').trim();
1060
+ }
1061
+ else {
1062
+ // 非录制交互模式:使用 session buffer
1063
+ const fullOutput = this.terminal.getSessionOutput();
1064
+ output = this.stripAnsi(fullOutput.substring(this.beforeOutputLength));
1065
+ }
1066
+ // ★ 命令后暂停(录制模式)
1067
+ if (this.terminal.isRecording()) {
1068
+ const stepOptions = getCurrentStepOptions();
1069
+ const pauseAfter = this.options.pauseAfter ?? stepOptions?.pauseAfter;
1070
+ if (pauseAfter && pauseAfter > 0) {
1071
+ await this.sleep(pauseAfter);
1072
+ }
1073
+ }
1074
+ const result = new CommandResultImpl({
1075
+ code: -1, // PTY 模式无法可靠获取退出码,设为 -1 表示不可用
1076
+ stdout: output,
1077
+ stderr: '',
1078
+ output,
1079
+ duration: Date.now() - this.startTime,
1080
+ command: this.command,
1081
+ });
1082
+ this.terminal.appendCommandLog({
1083
+ command: result.command,
1084
+ code: result.code,
1085
+ stdout: result.stdout,
1086
+ stderr: result.stderr,
1087
+ output: result.output,
1088
+ duration: result.duration,
1089
+ });
1090
+ return result;
1091
+ }
1092
+ else {
1093
+ // Bun.spawn 模式:等待进程结束
1094
+ try {
1095
+ const proc = this.bunProcess;
1096
+ const stdoutStream = proc.stdout;
1097
+ const stderrStream = proc.stderr;
1098
+ const [stdout, stderr, exitCode] = await Promise.race([
1099
+ Promise.all([
1100
+ new Response(stdoutStream).text(),
1101
+ new Response(stderrStream).text(),
1102
+ proc.exited,
1103
+ ]),
1104
+ new Promise((_, reject) => setTimeout(() => {
1105
+ this.bunProcess?.kill();
1106
+ reject(new Error(`Command timeout after ${timeout}ms: ${this.command}`));
1107
+ }, timeout)),
1108
+ ]);
1109
+ // 将输出存储到终端,支持 expect(terminal).toContainText() 断言
1110
+ const combinedOutput = stdout + stderr;
1111
+ this.terminal.appendNonInteractiveOutput(combinedOutput);
1112
+ const result = new CommandResultImpl({
1113
+ code: exitCode ?? -1,
1114
+ stdout,
1115
+ stderr,
1116
+ output: combinedOutput,
1117
+ duration: Date.now() - this.startTime,
1118
+ command: this.command,
1119
+ });
1120
+ this.terminal.appendCommandLog({
1121
+ command: result.command,
1122
+ code: result.code,
1123
+ stdout: result.stdout,
1124
+ stderr: result.stderr,
1125
+ output: result.output,
1126
+ duration: result.duration,
1127
+ });
1128
+ return result;
1129
+ }
1130
+ catch (error) {
1131
+ // Ensure process is killed on error
1132
+ this.bunProcess?.kill();
1133
+ throw error;
1134
+ }
1135
+ }
1136
+ }
1137
+ /**
1138
+ * Send Ctrl+C to interrupt the command
1139
+ * In recording mode with tmux, sends directly to the target pane
1140
+ */
1141
+ async interrupt() {
1142
+ if (this.bunProcess) {
1143
+ this.bunProcess.kill('SIGINT');
1144
+ }
1145
+ else {
1146
+ const tmuxSession = this.terminal.getTmuxSessionName();
1147
+ const paneIndex = this.terminal.getPaneIndex();
1148
+ if (this.terminal.isRecording() && tmuxSession && paneIndex !== undefined) {
1149
+ // ★ 使用 tmux send-keys 直接发送到指定窗格
1150
+ // 不依赖当前活动窗格状态
1151
+ await Bun.spawn([
1152
+ 'tmux', 'send-keys', '-t', `${tmuxSession}:0.${paneIndex}`, 'C-c'
1153
+ ]).exited;
1154
+ }
1155
+ else {
1156
+ await this.terminal.send('\x03'); // Ctrl+C
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+ /**
1162
+ * Create a new Terminal instance
1163
+ */
1164
+ export function createTerminal(config) {
1165
+ return new Terminal(config);
1166
+ }
1167
+ //# sourceMappingURL=terminal.js.map