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.
- package/dist/api/describe.d.ts +2 -2
- package/dist/api/expect.d.ts +1 -1
- package/dist/api/expect.d.ts.map +1 -1
- package/dist/api/hooks.d.ts +16 -17
- package/dist/api/hooks.d.ts.map +1 -1
- package/dist/api/hooks.js +9 -9
- package/dist/api/hooks.js.map +1 -1
- package/dist/api/steps.d.ts +10 -10
- package/dist/api/steps.d.ts.map +1 -1
- package/dist/api/steps.js +12 -12
- package/dist/api/steps.js.map +1 -1
- package/dist/api/test.d.ts +10 -12
- package/dist/api/test.d.ts.map +1 -1
- package/dist/api/test.js +29 -30
- package/dist/api/test.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/plugin/index.d.ts +2 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -2
- package/dist/plugin/index.js.map +1 -1
- package/dist/runner/config.js +1 -1
- package/dist/runner/config.js.map +1 -1
- package/dist/runner/filter.d.ts +9 -9
- package/dist/runner/filter.js +18 -18
- package/dist/runner/filter.js.map +1 -1
- package/dist/runner/loader.d.ts.map +1 -1
- package/dist/runner/loader.js +11 -13
- package/dist/runner/loader.js.map +1 -1
- package/dist/runner/models.d.ts +58 -58
- package/dist/runner/models.d.ts.map +1 -1
- package/dist/runner/runner.js +9 -9
- package/dist/runner/runner.js.map +1 -1
- package/dist/terminal/terminal.d.ts +21 -40
- package/dist/terminal/terminal.d.ts.map +1 -1
- package/dist/terminal/terminal.js +133 -181
- package/dist/terminal/terminal.js.map +1 -1
- package/package.json +41 -41
|
@@ -2,16 +2,14 @@
|
|
|
2
2
|
* Terminal API implementation
|
|
3
3
|
* Provides high-level terminal interaction (start/send/wait/snapshot)
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
-
//
|
|
177
|
+
// Auto-detect only when user did not set promptLineCount
|
|
186
178
|
if (!this.promptLineCountConfigured) {
|
|
187
179
|
await this.detectPromptBeforeRecording();
|
|
188
180
|
}
|
|
189
|
-
//
|
|
181
|
+
// Generate tmux session name
|
|
190
182
|
const sessionName = `repterm-${Date.now().toString(36)}`;
|
|
191
183
|
this.tmuxSessionName = sessionName;
|
|
192
|
-
// Recording
|
|
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
|
-
//
|
|
192
|
+
// Wait for tmux to be ready
|
|
202
193
|
await this.waitForTmuxReady();
|
|
203
194
|
}
|
|
204
195
|
else if (this.ptyOnly) {
|
|
205
|
-
// PTY-only
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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); //
|
|
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; //
|
|
424
|
+
const tmuxSessionToClean = this.tmuxSessionName; // Keep for cleanup
|
|
439
425
|
if (this.recording && this.tmuxSessionName && this.session.isActive()) {
|
|
440
|
-
//
|
|
426
|
+
// Wait 2s before ending so user sees final output
|
|
441
427
|
await this.sleep(2000);
|
|
442
|
-
//
|
|
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); //
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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); //
|
|
562
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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; //
|
|
570
|
+
markerLine = i; // Keep going to get last occurrence
|
|
590
571
|
}
|
|
591
572
|
}
|
|
592
573
|
if (markerLine >= 0) {
|
|
593
|
-
//
|
|
574
|
+
// promptLineCount = cursorY - markerLine
|
|
594
575
|
this.promptLineCount = cursorY - markerLine;
|
|
595
576
|
if (this.promptLineCount < 1)
|
|
596
577
|
this.promptLineCount = 1;
|
|
597
578
|
}
|
|
598
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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 -
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
797
|
+
// tmux send-keys -l for literal content
|
|
825
798
|
await Bun.spawn(['tmux', 'send-keys', '-l', '-t', paneTarget, wrappedContent]).exited;
|
|
826
|
-
//
|
|
799
|
+
// Wait for shell to process
|
|
827
800
|
await this.sleep(500);
|
|
828
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
836
|
+
* catch (convenience)
|
|
871
837
|
*/
|
|
872
838
|
catch(onrejected) {
|
|
873
839
|
return this.wait().catch(onrejected);
|
|
874
840
|
}
|
|
875
841
|
/**
|
|
876
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
973
|
-
// ★ 从当前 step 上下文获取录制选项
|
|
931
|
+
// executeInPty records state before Enter; get options from step
|
|
974
932
|
const stepOptions = getCurrentStepOptions();
|
|
975
933
|
const stepName = getCurrentStepName();
|
|
976
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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;
|