@love-moon/tui-driver 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -0
- package/dist/driver/StateMachine.d.ts +28 -0
- package/dist/driver/StateMachine.d.ts.map +1 -0
- package/dist/driver/StateMachine.js +56 -0
- package/dist/driver/StateMachine.js.map +1 -0
- package/dist/driver/TuiDriver.d.ts +73 -0
- package/dist/driver/TuiDriver.d.ts.map +1 -0
- package/dist/driver/TuiDriver.js +506 -0
- package/dist/driver/TuiDriver.js.map +1 -0
- package/dist/driver/TuiProfile.d.ts +59 -0
- package/dist/driver/TuiProfile.d.ts.map +1 -0
- package/dist/driver/TuiProfile.js +13 -0
- package/dist/driver/TuiProfile.js.map +1 -0
- package/dist/driver/index.d.ts +5 -0
- package/dist/driver/index.d.ts.map +1 -0
- package/dist/driver/index.js +13 -0
- package/dist/driver/index.js.map +1 -0
- package/dist/driver/profiles/claudeCode.profile.d.ts +4 -0
- package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -0
- package/dist/driver/profiles/claudeCode.profile.js +91 -0
- package/dist/driver/profiles/claudeCode.profile.js.map +1 -0
- package/dist/driver/profiles/codex.profile.d.ts +4 -0
- package/dist/driver/profiles/codex.profile.d.ts.map +1 -0
- package/dist/driver/profiles/codex.profile.js +82 -0
- package/dist/driver/profiles/codex.profile.js.map +1 -0
- package/dist/driver/profiles/index.d.ts +3 -0
- package/dist/driver/profiles/index.d.ts.map +1 -0
- package/dist/driver/profiles/index.js +8 -0
- package/dist/driver/profiles/index.js.map +1 -0
- package/dist/example.d.ts +2 -0
- package/dist/example.d.ts.map +1 -0
- package/dist/example.js +43 -0
- package/dist/example.js.map +1 -0
- package/dist/expect/ExpectEngine.d.ts +34 -0
- package/dist/expect/ExpectEngine.d.ts.map +1 -0
- package/dist/expect/ExpectEngine.js +121 -0
- package/dist/expect/ExpectEngine.js.map +1 -0
- package/dist/expect/Matchers.d.ts +24 -0
- package/dist/expect/Matchers.d.ts.map +1 -0
- package/dist/expect/Matchers.js +71 -0
- package/dist/expect/Matchers.js.map +1 -0
- package/dist/expect/index.d.ts +3 -0
- package/dist/expect/index.d.ts.map +1 -0
- package/dist/expect/index.js +8 -0
- package/dist/expect/index.js.map +1 -0
- package/dist/extract/Diff.d.ts +10 -0
- package/dist/extract/Diff.d.ts.map +1 -0
- package/dist/extract/Diff.js +44 -0
- package/dist/extract/Diff.js.map +1 -0
- package/dist/extract/OutputExtractor.d.ts +16 -0
- package/dist/extract/OutputExtractor.d.ts.map +1 -0
- package/dist/extract/OutputExtractor.js +71 -0
- package/dist/extract/OutputExtractor.js.map +1 -0
- package/dist/extract/index.d.ts +3 -0
- package/dist/extract/index.d.ts.map +1 -0
- package/dist/extract/index.js +11 -0
- package/dist/extract/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/pty/PtySession.d.ts +38 -0
- package/dist/pty/PtySession.d.ts.map +1 -0
- package/dist/pty/PtySession.js +231 -0
- package/dist/pty/PtySession.js.map +1 -0
- package/dist/pty/index.d.ts +2 -0
- package/dist/pty/index.d.ts.map +1 -0
- package/dist/pty/index.js +6 -0
- package/dist/pty/index.js.map +1 -0
- package/dist/term/HeadlessScreen.d.ts +29 -0
- package/dist/term/HeadlessScreen.d.ts.map +1 -0
- package/dist/term/HeadlessScreen.js +126 -0
- package/dist/term/HeadlessScreen.js.map +1 -0
- package/dist/term/ScreenSnapshot.d.ts +37 -0
- package/dist/term/ScreenSnapshot.d.ts.map +1 -0
- package/dist/term/ScreenSnapshot.js +68 -0
- package/dist/term/ScreenSnapshot.js.map +1 -0
- package/dist/term/index.d.ts +3 -0
- package/dist/term/index.d.ts.map +1 -0
- package/dist/term/index.js +8 -0
- package/dist/term/index.js.map +1 -0
- package/docs/tui-driver_implementation_plan.md +307 -0
- package/package.json +33 -0
- package/pnpm-workspace.yaml +1 -0
- package/src/driver/StateMachine.ts +90 -0
- package/src/driver/TuiDriver.ts +624 -0
- package/src/driver/TuiProfile.ts +72 -0
- package/src/driver/index.ts +4 -0
- package/src/driver/profiles/claudeCode.profile.ts +96 -0
- package/src/driver/profiles/codex.profile.ts +87 -0
- package/src/driver/profiles/index.ts +2 -0
- package/src/example.ts +45 -0
- package/src/expect/ExpectEngine.ts +171 -0
- package/src/expect/Matchers.ts +92 -0
- package/src/expect/index.ts +2 -0
- package/src/extract/Diff.ts +51 -0
- package/src/extract/OutputExtractor.ts +88 -0
- package/src/extract/index.ts +2 -0
- package/src/index.ts +67 -0
- package/src/pty/PtySession.ts +234 -0
- package/src/pty/index.ts +1 -0
- package/src/term/HeadlessScreen.ts +151 -0
- package/src/term/ScreenSnapshot.ts +89 -0
- package/src/term/index.ts +2 -0
- package/test/claude-profile.test.ts +11 -0
- package/test/codex-profile.test.ts +108 -0
- package/test/debug-claude.ts +51 -0
- package/test/integration.ts +174 -0
- package/test/output-extractor.test.ts +49 -0
- package/test/state-diff.test.ts +120 -0
- package/test/unit.test.ts +136 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type TuiProfileName = "claude-code" | "codex";
|
|
2
|
+
|
|
3
|
+
export interface TuiAnchors {
|
|
4
|
+
ready: RegExp[];
|
|
5
|
+
busy?: RegExp[];
|
|
6
|
+
error?: RegExp[];
|
|
7
|
+
trust?: RegExp[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TuiKeys {
|
|
11
|
+
submit: string[];
|
|
12
|
+
newChat?: string[];
|
|
13
|
+
cancel?: string[];
|
|
14
|
+
trustConfirm?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TuiExtraction {
|
|
18
|
+
mode: "diff-viewport" | "diff-scrollback";
|
|
19
|
+
stripPatterns: RegExp[];
|
|
20
|
+
stripEcho?: boolean;
|
|
21
|
+
bottomUiLines?: number;
|
|
22
|
+
includeLinePatterns?: RegExp[];
|
|
23
|
+
stopLinePatterns?: RegExp[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TuiSignals {
|
|
27
|
+
prompt?: RegExp[];
|
|
28
|
+
promptHint?: RegExp[];
|
|
29
|
+
replyStart?: RegExp[];
|
|
30
|
+
replyStop?: RegExp[];
|
|
31
|
+
status?: RegExp[];
|
|
32
|
+
statusDone?: RegExp[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TuiProfile {
|
|
36
|
+
name: TuiProfileName;
|
|
37
|
+
command: string;
|
|
38
|
+
args: string[];
|
|
39
|
+
env?: Record<string, string>;
|
|
40
|
+
anchors: TuiAnchors;
|
|
41
|
+
keys: TuiKeys;
|
|
42
|
+
preflightKeys?: string[];
|
|
43
|
+
signals?: TuiSignals;
|
|
44
|
+
requireReplyStart?: boolean;
|
|
45
|
+
extraction: TuiExtraction;
|
|
46
|
+
cols?: number;
|
|
47
|
+
rows?: number;
|
|
48
|
+
scrollback?: number;
|
|
49
|
+
timeouts?: {
|
|
50
|
+
boot?: number;
|
|
51
|
+
ready?: number;
|
|
52
|
+
streamStart?: number;
|
|
53
|
+
streamEnd?: number;
|
|
54
|
+
idle?: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createProfile(partial: Partial<TuiProfile> & {
|
|
59
|
+
name: TuiProfileName;
|
|
60
|
+
command: string;
|
|
61
|
+
anchors: TuiAnchors;
|
|
62
|
+
keys: TuiKeys;
|
|
63
|
+
extraction: TuiExtraction;
|
|
64
|
+
}): TuiProfile {
|
|
65
|
+
return {
|
|
66
|
+
args: [],
|
|
67
|
+
cols: 120,
|
|
68
|
+
rows: 40,
|
|
69
|
+
scrollback: 5000,
|
|
70
|
+
...partial,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { TuiDriver, TuiDriverOptions, AskResult, TuiScreenSignals } from "./TuiDriver.js";
|
|
2
|
+
export { TuiProfile, TuiProfileName, TuiAnchors, TuiKeys, TuiExtraction, TuiSignals, createProfile } from "./TuiProfile.js";
|
|
3
|
+
export { StateMachine, TuiState, StateTransition } from "./StateMachine.js";
|
|
4
|
+
export { claudeCodeProfile, codexProfile } from "./profiles/index.js";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { TuiProfile, createProfile } from "../TuiProfile.js";
|
|
2
|
+
|
|
3
|
+
export const claudeCodeProfile: TuiProfile = createProfile({
|
|
4
|
+
name: "claude-code",
|
|
5
|
+
command: "claude",
|
|
6
|
+
args: [],
|
|
7
|
+
env: {
|
|
8
|
+
TERM: "xterm-256color",
|
|
9
|
+
LANG: "en_US.UTF-8",
|
|
10
|
+
LC_ALL: "en_US.UTF-8",
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
anchors: {
|
|
14
|
+
ready: [
|
|
15
|
+
/❯\s*$/m, // Empty prompt (ready for input)
|
|
16
|
+
/❯\s*(?!\d+\.)/m, // Prompt line (avoid trust menu options)
|
|
17
|
+
],
|
|
18
|
+
trust: [
|
|
19
|
+
/Accessing workspace:/i,
|
|
20
|
+
/Quick safety check/i,
|
|
21
|
+
/Yes, I trust this folder/i,
|
|
22
|
+
],
|
|
23
|
+
busy: [
|
|
24
|
+
/⏺/, // Claude's streaming indicator
|
|
25
|
+
/Thinking/i,
|
|
26
|
+
/Working/i,
|
|
27
|
+
],
|
|
28
|
+
error: [
|
|
29
|
+
/Error:/i,
|
|
30
|
+
/Failed to/i,
|
|
31
|
+
/Connection refused/i,
|
|
32
|
+
/Authentication failed/i,
|
|
33
|
+
/Rate limit/i,
|
|
34
|
+
/API.*error/i,
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
keys: {
|
|
39
|
+
submit: ["ENTER"],
|
|
40
|
+
newChat: ["CTRL_L"],
|
|
41
|
+
cancel: ["CTRL_C"],
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
extraction: {
|
|
45
|
+
mode: "diff-scrollback",
|
|
46
|
+
includeLinePatterns: [
|
|
47
|
+
/^⏺\s(?!.*(tool|mcp|running|calling|call))/i,
|
|
48
|
+
],
|
|
49
|
+
stopLinePatterns: [
|
|
50
|
+
/^❯\s*/m,
|
|
51
|
+
],
|
|
52
|
+
stripPatterns: [
|
|
53
|
+
/^❯\s*/gm, // Prompt indicator
|
|
54
|
+
/^⏺\s*/gm, // Claude reply/tool indicator
|
|
55
|
+
/^\s*\d+\s*│/gm, // Line numbers
|
|
56
|
+
/Press .* to/gi,
|
|
57
|
+
/\? for shortcuts/gi, // Help hint
|
|
58
|
+
/────+/g, // Horizontal lines
|
|
59
|
+
/╭.*╮/g, // Box top
|
|
60
|
+
/╰.*╯/g, // Box bottom
|
|
61
|
+
/│.*│/g, // Box sides (be careful with this)
|
|
62
|
+
],
|
|
63
|
+
stripEcho: true,
|
|
64
|
+
bottomUiLines: 4,
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
signals: {
|
|
68
|
+
prompt: [/^❯\s*/m],
|
|
69
|
+
promptHint: [/^❯\s+(?!\d+\.)\S+/m],
|
|
70
|
+
replyStart: [/^⏺\s(?!.*(tool|mcp|running|calling|call))/i],
|
|
71
|
+
replyStop: [/^❯\s*/m],
|
|
72
|
+
status: [
|
|
73
|
+
/^⏺\s.*(tool|mcp|running|calling|call)/i,
|
|
74
|
+
/^✻\s.*\(\d+s\s*•/i, // Working status with updating time: "✻ ... (5s • esc to interrupt)"
|
|
75
|
+
],
|
|
76
|
+
statusDone: [
|
|
77
|
+
/^✻\s.*for\s+\d+(\.\d+)?s/i, // Final status with fixed time: "✻ Cooked for 33s"
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
requireReplyStart: true,
|
|
82
|
+
|
|
83
|
+
cols: 120,
|
|
84
|
+
rows: 40,
|
|
85
|
+
scrollback: 5000,
|
|
86
|
+
|
|
87
|
+
timeouts: {
|
|
88
|
+
boot: 0,
|
|
89
|
+
ready: 0,
|
|
90
|
+
streamStart: 0,
|
|
91
|
+
streamEnd: 0,
|
|
92
|
+
idle: 1000,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export default claudeCodeProfile;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { TuiProfile, createProfile } from "../TuiProfile.js";
|
|
2
|
+
|
|
3
|
+
export const codexProfile: TuiProfile = createProfile({
|
|
4
|
+
name: "codex",
|
|
5
|
+
command: "codex",
|
|
6
|
+
args: [],
|
|
7
|
+
env: {
|
|
8
|
+
TERM: "xterm-256color",
|
|
9
|
+
LANG: "en_US.UTF-8",
|
|
10
|
+
LC_ALL: "en_US.UTF-8",
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
anchors: {
|
|
14
|
+
ready: [
|
|
15
|
+
/[\s\S]*model:(?!\s*loading)[\s\S]*^›\s*/mi,
|
|
16
|
+
/^(?![\s\S]*model:\s*loading)(?![\s\S]*Press enter to continue)(?=[\s\S]*(?:^|\n)›\s*(?!\d+\.)[^\n]*)[\s\S]*$/i,
|
|
17
|
+
],
|
|
18
|
+
trust: [
|
|
19
|
+
/You are running Codex in /i,
|
|
20
|
+
/Since this folder is not version controlled/i,
|
|
21
|
+
/Allow Codex to work in this folder without asking for approval/i,
|
|
22
|
+
/Require approval of edits and commands/i,
|
|
23
|
+
/Press enter to continue/i,
|
|
24
|
+
],
|
|
25
|
+
busy: [
|
|
26
|
+
/^\s*•\s*(Working|Thinking)\b.*$/m,
|
|
27
|
+
/\b(Working|Thinking)\s*\(\d+s\s*•\s*esc to interrupt\)/i,
|
|
28
|
+
],
|
|
29
|
+
error: [
|
|
30
|
+
/Error:/i,
|
|
31
|
+
/Failed/i,
|
|
32
|
+
/API.*error/i,
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
keys: {
|
|
37
|
+
submit: ["ENTER"],
|
|
38
|
+
newChat: [],
|
|
39
|
+
cancel: ["CTRL_C"],
|
|
40
|
+
trustConfirm: ["UP", "ENTER"],
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
extraction: {
|
|
44
|
+
mode: "diff-scrollback",
|
|
45
|
+
includeLinePatterns: [
|
|
46
|
+
/^\s*•\s*(?!Working|Thinking).+/m,
|
|
47
|
+
],
|
|
48
|
+
stopLinePatterns: [
|
|
49
|
+
/^\s*›\s*/m,
|
|
50
|
+
],
|
|
51
|
+
stripPatterns: [
|
|
52
|
+
/^>\s*/gm,
|
|
53
|
+
/^\s*›\s*/gm,
|
|
54
|
+
/^\s*•\s*/gm,
|
|
55
|
+
/codex>\s*/gi,
|
|
56
|
+
],
|
|
57
|
+
stripEcho: true,
|
|
58
|
+
bottomUiLines: 2,
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
signals: {
|
|
62
|
+
prompt: [/^\s*›\s*/m],
|
|
63
|
+
replyStart: [/^\s*•\s*(?!Working|Thinking).+/m],
|
|
64
|
+
replyStop: [/^\s*›\s*/m],
|
|
65
|
+
status: [
|
|
66
|
+
/^\s*•\s*(Working|Thinking)\b.*$/m,
|
|
67
|
+
/\b(Working|Thinking)\s*\(\d+s\s*•\s*esc to interrupt\)/i,
|
|
68
|
+
],
|
|
69
|
+
statusDone: [/^\s*─\s*Worked for .*─+/m, /\bWorked for\b.*$/i],
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
requireReplyStart: true,
|
|
73
|
+
|
|
74
|
+
cols: 120,
|
|
75
|
+
rows: 40,
|
|
76
|
+
scrollback: 5000,
|
|
77
|
+
|
|
78
|
+
timeouts: {
|
|
79
|
+
boot: 0,
|
|
80
|
+
ready: 0,
|
|
81
|
+
streamStart: 0,
|
|
82
|
+
streamEnd: 0,
|
|
83
|
+
idle: 1200,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export default codexProfile;
|
package/src/example.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createDriver, TuiDriver, claudeCodeProfile } from "./index.js";
|
|
2
|
+
|
|
3
|
+
async function main() {
|
|
4
|
+
// Method 1: Using the convenience factory
|
|
5
|
+
const driver1 = createDriver("claude-code", { debug: true });
|
|
6
|
+
|
|
7
|
+
// Method 2: Using TuiDriver directly with a profile
|
|
8
|
+
const driver2 = new TuiDriver({
|
|
9
|
+
profile: claudeCodeProfile,
|
|
10
|
+
debug: true,
|
|
11
|
+
onSnapshot: (snapshot, state) => {
|
|
12
|
+
console.log(`[${state}] Screen hash: ${snapshot.hash}`);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// Boot the TUI
|
|
18
|
+
await driver2.boot();
|
|
19
|
+
console.log("TUI booted successfully");
|
|
20
|
+
|
|
21
|
+
// Ask a question
|
|
22
|
+
const result = await driver2.ask("What is 2 + 2?");
|
|
23
|
+
|
|
24
|
+
if (result.success) {
|
|
25
|
+
console.log("Answer:", result.answer);
|
|
26
|
+
console.log("Elapsed:", result.elapsedMs, "ms");
|
|
27
|
+
} else {
|
|
28
|
+
console.error("Failed:", result.error?.message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Ask another question
|
|
32
|
+
const result2 = await driver2.ask("Explain briefly what TypeScript is.");
|
|
33
|
+
|
|
34
|
+
if (result2.success) {
|
|
35
|
+
console.log("Answer:", result2.answer);
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error("Error:", error);
|
|
39
|
+
} finally {
|
|
40
|
+
// Clean up
|
|
41
|
+
driver2.kill();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { HeadlessScreen } from "../term/HeadlessScreen.js";
|
|
2
|
+
import { ScreenSnapshot } from "../term/ScreenSnapshot.js";
|
|
3
|
+
import { MatcherFn } from "./Matchers.js";
|
|
4
|
+
|
|
5
|
+
export interface UntilOptions {
|
|
6
|
+
name: string;
|
|
7
|
+
match: MatcherFn;
|
|
8
|
+
stableMs?: number;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
pollIntervalMs?: number;
|
|
11
|
+
onTimeout?: () => void | Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IdleOptions {
|
|
15
|
+
name: string;
|
|
16
|
+
idleMs: number;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
pollIntervalMs?: number;
|
|
19
|
+
onTimeout?: () => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExpectResult {
|
|
23
|
+
success: boolean;
|
|
24
|
+
snapshot: ScreenSnapshot;
|
|
25
|
+
elapsedMs: number;
|
|
26
|
+
timedOut: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
30
|
+
const DEFAULT_POLL_INTERVAL_MS = 100;
|
|
31
|
+
const DEFAULT_STABLE_MS = 300;
|
|
32
|
+
|
|
33
|
+
export class ExpectEngine {
|
|
34
|
+
constructor(private screen: HeadlessScreen) {}
|
|
35
|
+
|
|
36
|
+
async until(options: UntilOptions): Promise<ExpectResult> {
|
|
37
|
+
const {
|
|
38
|
+
match,
|
|
39
|
+
stableMs = DEFAULT_STABLE_MS,
|
|
40
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
41
|
+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
42
|
+
onTimeout,
|
|
43
|
+
} = options;
|
|
44
|
+
|
|
45
|
+
const startTime = Date.now();
|
|
46
|
+
let matchStartTime: number | null = null;
|
|
47
|
+
let lastSnapshot: ScreenSnapshot = this.screen.snapshot();
|
|
48
|
+
|
|
49
|
+
while (true) {
|
|
50
|
+
const elapsed = Date.now() - startTime;
|
|
51
|
+
|
|
52
|
+
if (elapsed >= timeoutMs) {
|
|
53
|
+
if (onTimeout) {
|
|
54
|
+
await onTimeout();
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
snapshot: lastSnapshot,
|
|
59
|
+
elapsedMs: elapsed,
|
|
60
|
+
timedOut: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lastSnapshot = this.screen.snapshot();
|
|
65
|
+
const matched = match(lastSnapshot);
|
|
66
|
+
|
|
67
|
+
if (matched) {
|
|
68
|
+
if (matchStartTime === null) {
|
|
69
|
+
matchStartTime = Date.now();
|
|
70
|
+
} else if (Date.now() - matchStartTime >= stableMs) {
|
|
71
|
+
return {
|
|
72
|
+
success: true,
|
|
73
|
+
snapshot: lastSnapshot,
|
|
74
|
+
elapsedMs: Date.now() - startTime,
|
|
75
|
+
timedOut: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
matchStartTime = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await this.sleep(pollIntervalMs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async untilIdle(options: IdleOptions): Promise<ExpectResult> {
|
|
87
|
+
const {
|
|
88
|
+
idleMs,
|
|
89
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
90
|
+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
91
|
+
onTimeout,
|
|
92
|
+
} = options;
|
|
93
|
+
|
|
94
|
+
const startTime = Date.now();
|
|
95
|
+
let lastHash = this.screen.snapshot().hash;
|
|
96
|
+
let lastChangeTime = Date.now();
|
|
97
|
+
let lastSnapshot: ScreenSnapshot = this.screen.snapshot();
|
|
98
|
+
|
|
99
|
+
while (true) {
|
|
100
|
+
const elapsed = Date.now() - startTime;
|
|
101
|
+
|
|
102
|
+
if (elapsed >= timeoutMs) {
|
|
103
|
+
if (onTimeout) {
|
|
104
|
+
await onTimeout();
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
success: false,
|
|
108
|
+
snapshot: lastSnapshot,
|
|
109
|
+
elapsedMs: elapsed,
|
|
110
|
+
timedOut: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lastSnapshot = this.screen.snapshot();
|
|
115
|
+
const currentHash = lastSnapshot.hash;
|
|
116
|
+
|
|
117
|
+
if (currentHash !== lastHash) {
|
|
118
|
+
lastHash = currentHash;
|
|
119
|
+
lastChangeTime = Date.now();
|
|
120
|
+
} else if (Date.now() - lastChangeTime >= idleMs) {
|
|
121
|
+
return {
|
|
122
|
+
success: true,
|
|
123
|
+
snapshot: lastSnapshot,
|
|
124
|
+
elapsedMs: Date.now() - startTime,
|
|
125
|
+
timedOut: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await this.sleep(pollIntervalMs);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async waitForChange(timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<ExpectResult> {
|
|
134
|
+
const startTime = Date.now();
|
|
135
|
+
const initialHash = this.screen.snapshot().hash;
|
|
136
|
+
let lastSnapshot: ScreenSnapshot = this.screen.snapshot();
|
|
137
|
+
|
|
138
|
+
while (true) {
|
|
139
|
+
const elapsed = Date.now() - startTime;
|
|
140
|
+
|
|
141
|
+
if (elapsed >= timeoutMs) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
snapshot: lastSnapshot,
|
|
145
|
+
elapsedMs: elapsed,
|
|
146
|
+
timedOut: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lastSnapshot = this.screen.snapshot();
|
|
151
|
+
if (lastSnapshot.hash !== initialHash) {
|
|
152
|
+
return {
|
|
153
|
+
success: true,
|
|
154
|
+
snapshot: lastSnapshot,
|
|
155
|
+
elapsedMs: Date.now() - startTime,
|
|
156
|
+
timedOut: false,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await this.sleep(DEFAULT_POLL_INTERVAL_MS);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
snapshot(): ScreenSnapshot {
|
|
165
|
+
return this.screen.snapshot();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private sleep(ms: number): Promise<void> {
|
|
169
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ScreenSnapshot } from "../term/ScreenSnapshot.js";
|
|
2
|
+
|
|
3
|
+
export type MatcherFn = (snapshot: ScreenSnapshot) => boolean;
|
|
4
|
+
|
|
5
|
+
export interface MatcherResult {
|
|
6
|
+
matched: boolean;
|
|
7
|
+
matchedPattern?: RegExp;
|
|
8
|
+
matchedText?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Matchers {
|
|
12
|
+
static regex(pattern: RegExp, region: "viewport" | "scrollback" = "viewport"): MatcherFn {
|
|
13
|
+
return (snapshot: ScreenSnapshot) => snapshot.matchesPattern(pattern, region);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static anyOf(patterns: RegExp[], region: "viewport" | "scrollback" = "viewport"): MatcherFn {
|
|
17
|
+
return (snapshot: ScreenSnapshot) => {
|
|
18
|
+
const text = region === "viewport" ? snapshot.viewportText : snapshot.scrollbackText;
|
|
19
|
+
return patterns.some(p => p.test(text));
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static allOf(patterns: RegExp[], region: "viewport" | "scrollback" = "viewport"): MatcherFn {
|
|
24
|
+
return (snapshot: ScreenSnapshot) => {
|
|
25
|
+
const text = region === "viewport" ? snapshot.viewportText : snapshot.scrollbackText;
|
|
26
|
+
return patterns.every(p => p.test(text));
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static noneOf(patterns: RegExp[], region: "viewport" | "scrollback" = "viewport"): MatcherFn {
|
|
31
|
+
return (snapshot: ScreenSnapshot) => {
|
|
32
|
+
const text = region === "viewport" ? snapshot.viewportText : snapshot.scrollbackText;
|
|
33
|
+
return !patterns.some(p => p.test(text));
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static cursorAt(x: number, y: number): MatcherFn {
|
|
38
|
+
return (snapshot: ScreenSnapshot) => {
|
|
39
|
+
return snapshot.cursor.x === x && snapshot.cursor.y === y;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static cursorInRegion(
|
|
44
|
+
minX: number, maxX: number,
|
|
45
|
+
minY: number, maxY: number
|
|
46
|
+
): MatcherFn {
|
|
47
|
+
return (snapshot: ScreenSnapshot) => {
|
|
48
|
+
const { x, y } = snapshot.cursor;
|
|
49
|
+
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static bottomRegion(pattern: RegExp, bottomLines: number = 5): MatcherFn {
|
|
54
|
+
return (snapshot: ScreenSnapshot) => {
|
|
55
|
+
const lines = snapshot.getViewportLines();
|
|
56
|
+
const bottom = lines.slice(-bottomLines).join("\n");
|
|
57
|
+
return pattern.test(bottom);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static topRegion(pattern: RegExp, topLines: number = 5): MatcherFn {
|
|
62
|
+
return (snapshot: ScreenSnapshot) => {
|
|
63
|
+
const lines = snapshot.getViewportLines();
|
|
64
|
+
const top = lines.slice(0, topLines).join("\n");
|
|
65
|
+
return pattern.test(top);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static hashChanged(previousHash: string): MatcherFn {
|
|
70
|
+
return (snapshot: ScreenSnapshot) => snapshot.hash !== previousHash;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static hashUnchanged(previousHash: string): MatcherFn {
|
|
74
|
+
return (snapshot: ScreenSnapshot) => snapshot.hash === previousHash;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static and(...matchers: MatcherFn[]): MatcherFn {
|
|
78
|
+
return (snapshot: ScreenSnapshot) => matchers.every(m => m(snapshot));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static or(...matchers: MatcherFn[]): MatcherFn {
|
|
82
|
+
return (snapshot: ScreenSnapshot) => matchers.some(m => m(snapshot));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static not(matcher: MatcherFn): MatcherFn {
|
|
86
|
+
return (snapshot: ScreenSnapshot) => !matcher(snapshot);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static custom(fn: (snapshot: ScreenSnapshot) => boolean): MatcherFn {
|
|
90
|
+
return fn;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import diff from "fast-diff";
|
|
2
|
+
|
|
3
|
+
export interface DiffResult {
|
|
4
|
+
added: string[];
|
|
5
|
+
removed: string[];
|
|
6
|
+
unchanged: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function computeLineDiff(before: string, after: string): DiffResult {
|
|
10
|
+
const beforeLines = before.split("\n");
|
|
11
|
+
const afterLines = after.split("\n");
|
|
12
|
+
|
|
13
|
+
const beforeSet = new Set(beforeLines);
|
|
14
|
+
const afterSet = new Set(afterLines);
|
|
15
|
+
|
|
16
|
+
const added = afterLines.filter(line => !beforeSet.has(line));
|
|
17
|
+
const removed = beforeLines.filter(line => !afterSet.has(line));
|
|
18
|
+
const unchanged = afterLines.filter(line => beforeSet.has(line));
|
|
19
|
+
|
|
20
|
+
return { added, removed, unchanged };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function computeCharDiff(before: string, after: string): Array<[number, string]> {
|
|
24
|
+
return diff(before, after);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getAddedText(before: string, after: string): string {
|
|
28
|
+
const result = diff(before, after);
|
|
29
|
+
const added: string[] = [];
|
|
30
|
+
|
|
31
|
+
for (const [type, text] of result) {
|
|
32
|
+
if (type === 1) {
|
|
33
|
+
added.push(text);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return added.join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getRemovedText(before: string, after: string): string {
|
|
41
|
+
const result = diff(before, after);
|
|
42
|
+
const removed: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const [type, text] of result) {
|
|
45
|
+
if (type === -1) {
|
|
46
|
+
removed.push(text);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return removed.join("");
|
|
51
|
+
}
|