@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.
Files changed (113) hide show
  1. package/README.md +142 -0
  2. package/dist/driver/StateMachine.d.ts +28 -0
  3. package/dist/driver/StateMachine.d.ts.map +1 -0
  4. package/dist/driver/StateMachine.js +56 -0
  5. package/dist/driver/StateMachine.js.map +1 -0
  6. package/dist/driver/TuiDriver.d.ts +73 -0
  7. package/dist/driver/TuiDriver.d.ts.map +1 -0
  8. package/dist/driver/TuiDriver.js +506 -0
  9. package/dist/driver/TuiDriver.js.map +1 -0
  10. package/dist/driver/TuiProfile.d.ts +59 -0
  11. package/dist/driver/TuiProfile.d.ts.map +1 -0
  12. package/dist/driver/TuiProfile.js +13 -0
  13. package/dist/driver/TuiProfile.js.map +1 -0
  14. package/dist/driver/index.d.ts +5 -0
  15. package/dist/driver/index.d.ts.map +1 -0
  16. package/dist/driver/index.js +13 -0
  17. package/dist/driver/index.js.map +1 -0
  18. package/dist/driver/profiles/claudeCode.profile.d.ts +4 -0
  19. package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -0
  20. package/dist/driver/profiles/claudeCode.profile.js +91 -0
  21. package/dist/driver/profiles/claudeCode.profile.js.map +1 -0
  22. package/dist/driver/profiles/codex.profile.d.ts +4 -0
  23. package/dist/driver/profiles/codex.profile.d.ts.map +1 -0
  24. package/dist/driver/profiles/codex.profile.js +82 -0
  25. package/dist/driver/profiles/codex.profile.js.map +1 -0
  26. package/dist/driver/profiles/index.d.ts +3 -0
  27. package/dist/driver/profiles/index.d.ts.map +1 -0
  28. package/dist/driver/profiles/index.js +8 -0
  29. package/dist/driver/profiles/index.js.map +1 -0
  30. package/dist/example.d.ts +2 -0
  31. package/dist/example.d.ts.map +1 -0
  32. package/dist/example.js +43 -0
  33. package/dist/example.js.map +1 -0
  34. package/dist/expect/ExpectEngine.d.ts +34 -0
  35. package/dist/expect/ExpectEngine.d.ts.map +1 -0
  36. package/dist/expect/ExpectEngine.js +121 -0
  37. package/dist/expect/ExpectEngine.js.map +1 -0
  38. package/dist/expect/Matchers.d.ts +24 -0
  39. package/dist/expect/Matchers.d.ts.map +1 -0
  40. package/dist/expect/Matchers.js +71 -0
  41. package/dist/expect/Matchers.js.map +1 -0
  42. package/dist/expect/index.d.ts +3 -0
  43. package/dist/expect/index.d.ts.map +1 -0
  44. package/dist/expect/index.js +8 -0
  45. package/dist/expect/index.js.map +1 -0
  46. package/dist/extract/Diff.d.ts +10 -0
  47. package/dist/extract/Diff.d.ts.map +1 -0
  48. package/dist/extract/Diff.js +44 -0
  49. package/dist/extract/Diff.js.map +1 -0
  50. package/dist/extract/OutputExtractor.d.ts +16 -0
  51. package/dist/extract/OutputExtractor.d.ts.map +1 -0
  52. package/dist/extract/OutputExtractor.js +71 -0
  53. package/dist/extract/OutputExtractor.js.map +1 -0
  54. package/dist/extract/index.d.ts +3 -0
  55. package/dist/extract/index.d.ts.map +1 -0
  56. package/dist/extract/index.js +11 -0
  57. package/dist/extract/index.js.map +1 -0
  58. package/dist/index.d.ts +11 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +50 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/pty/PtySession.d.ts +38 -0
  63. package/dist/pty/PtySession.d.ts.map +1 -0
  64. package/dist/pty/PtySession.js +231 -0
  65. package/dist/pty/PtySession.js.map +1 -0
  66. package/dist/pty/index.d.ts +2 -0
  67. package/dist/pty/index.d.ts.map +1 -0
  68. package/dist/pty/index.js +6 -0
  69. package/dist/pty/index.js.map +1 -0
  70. package/dist/term/HeadlessScreen.d.ts +29 -0
  71. package/dist/term/HeadlessScreen.d.ts.map +1 -0
  72. package/dist/term/HeadlessScreen.js +126 -0
  73. package/dist/term/HeadlessScreen.js.map +1 -0
  74. package/dist/term/ScreenSnapshot.d.ts +37 -0
  75. package/dist/term/ScreenSnapshot.d.ts.map +1 -0
  76. package/dist/term/ScreenSnapshot.js +68 -0
  77. package/dist/term/ScreenSnapshot.js.map +1 -0
  78. package/dist/term/index.d.ts +3 -0
  79. package/dist/term/index.d.ts.map +1 -0
  80. package/dist/term/index.js +8 -0
  81. package/dist/term/index.js.map +1 -0
  82. package/docs/tui-driver_implementation_plan.md +307 -0
  83. package/package.json +33 -0
  84. package/pnpm-workspace.yaml +1 -0
  85. package/src/driver/StateMachine.ts +90 -0
  86. package/src/driver/TuiDriver.ts +624 -0
  87. package/src/driver/TuiProfile.ts +72 -0
  88. package/src/driver/index.ts +4 -0
  89. package/src/driver/profiles/claudeCode.profile.ts +96 -0
  90. package/src/driver/profiles/codex.profile.ts +87 -0
  91. package/src/driver/profiles/index.ts +2 -0
  92. package/src/example.ts +45 -0
  93. package/src/expect/ExpectEngine.ts +171 -0
  94. package/src/expect/Matchers.ts +92 -0
  95. package/src/expect/index.ts +2 -0
  96. package/src/extract/Diff.ts +51 -0
  97. package/src/extract/OutputExtractor.ts +88 -0
  98. package/src/extract/index.ts +2 -0
  99. package/src/index.ts +67 -0
  100. package/src/pty/PtySession.ts +234 -0
  101. package/src/pty/index.ts +1 -0
  102. package/src/term/HeadlessScreen.ts +151 -0
  103. package/src/term/ScreenSnapshot.ts +89 -0
  104. package/src/term/index.ts +2 -0
  105. package/test/claude-profile.test.ts +11 -0
  106. package/test/codex-profile.test.ts +108 -0
  107. package/test/debug-claude.ts +51 -0
  108. package/test/integration.ts +174 -0
  109. package/test/output-extractor.test.ts +49 -0
  110. package/test/state-diff.test.ts +120 -0
  111. package/test/unit.test.ts +136 -0
  112. package/tsconfig.json +20 -0
  113. package/vitest.config.ts +13 -0
@@ -0,0 +1,88 @@
1
+ import { ScreenSnapshot } from "../term/ScreenSnapshot.js";
2
+ import { TuiExtraction } from "../driver/TuiProfile.js";
3
+ import { computeLineDiff, getAddedText } from "./Diff.js";
4
+
5
+ export interface ExtractionResult {
6
+ text: string;
7
+ rawText: string;
8
+ linesAdded: number;
9
+ }
10
+
11
+ export class OutputExtractor {
12
+ constructor(private extraction: TuiExtraction) {}
13
+
14
+ extract(before: ScreenSnapshot, after: ScreenSnapshot): ExtractionResult {
15
+ let rawText: string;
16
+
17
+ if (this.extraction.mode === "diff-viewport") {
18
+ rawText = this.extractFromViewport(before, after);
19
+ } else {
20
+ rawText = this.extractFromScrollback(before, after);
21
+ }
22
+
23
+ const text = this.cleanText(rawText);
24
+
25
+ return {
26
+ text,
27
+ rawText,
28
+ linesAdded: text.split("\n").length,
29
+ };
30
+ }
31
+
32
+ private extractFromViewport(before: ScreenSnapshot, after: ScreenSnapshot): string {
33
+ const bottomLines = this.extraction.bottomUiLines ?? 3;
34
+ const afterText = after.getViewportWithoutBottom(bottomLines);
35
+ const beforeText = before.getViewportWithoutBottom(bottomLines);
36
+
37
+ return getAddedText(beforeText, afterText);
38
+ }
39
+
40
+ private extractFromScrollback(before: ScreenSnapshot, after: ScreenSnapshot): string {
41
+ const { added } = computeLineDiff(before.scrollbackText, after.scrollbackText);
42
+ return added.join("\n");
43
+ }
44
+
45
+ private cleanText(text: string): string {
46
+ let result = text;
47
+ const includeLinePatterns = this.extraction.includeLinePatterns;
48
+ const stopLinePatterns = this.extraction.stopLinePatterns ?? [];
49
+
50
+ if (includeLinePatterns && includeLinePatterns.length > 0) {
51
+ const lines = result.split("\n");
52
+ const kept: string[] = [];
53
+ let including = false;
54
+
55
+ for (const line of lines) {
56
+ if (includeLinePatterns.some(pattern => pattern.test(line))) {
57
+ including = true;
58
+ kept.push(line);
59
+ continue;
60
+ }
61
+
62
+ if (including && stopLinePatterns.some(pattern => pattern.test(line))) {
63
+ including = false;
64
+ continue;
65
+ }
66
+
67
+ if (including) {
68
+ kept.push(line);
69
+ }
70
+ }
71
+
72
+ result = kept.join("\n");
73
+ }
74
+
75
+ for (const pattern of this.extraction.stripPatterns) {
76
+ result = result.replace(pattern, "");
77
+ }
78
+
79
+ result = result
80
+ .split("\n")
81
+ .map(line => line.trimEnd())
82
+ .join("\n");
83
+
84
+ result = result.replace(/\n{3,}/g, "\n\n");
85
+
86
+ return result.trim();
87
+ }
88
+ }
@@ -0,0 +1,2 @@
1
+ export { OutputExtractor, ExtractionResult } from "./OutputExtractor.js";
2
+ export { computeLineDiff, computeCharDiff, getAddedText, getRemovedText, DiffResult } from "./Diff.js";
package/src/index.ts ADDED
@@ -0,0 +1,67 @@
1
+ // PTY Session
2
+ export { PtySession, PtySessionOptions } from "./pty/index.js";
3
+
4
+ // Terminal / Screen
5
+ export { HeadlessScreen, HeadlessScreenOptions } from "./term/index.js";
6
+ export { ScreenSnapshot, ScreenSnapshotData } from "./term/index.js";
7
+
8
+ // Expect Engine
9
+ export { ExpectEngine, UntilOptions, IdleOptions, ExpectResult } from "./expect/index.js";
10
+ export { Matchers, MatcherFn } from "./expect/index.js";
11
+
12
+ // Driver
13
+ export {
14
+ TuiDriver,
15
+ TuiDriverOptions,
16
+ AskResult,
17
+ TuiProfile,
18
+ TuiProfileName,
19
+ TuiAnchors,
20
+ TuiKeys,
21
+ TuiExtraction,
22
+ createProfile,
23
+ StateMachine,
24
+ TuiState,
25
+ StateTransition,
26
+ claudeCodeProfile,
27
+ codexProfile,
28
+ TuiSignals,
29
+ TuiScreenSignals,
30
+ } from "./driver/index.js";
31
+
32
+ // Extract
33
+ export {
34
+ OutputExtractor,
35
+ ExtractionResult,
36
+ computeLineDiff,
37
+ computeCharDiff,
38
+ getAddedText,
39
+ getRemovedText,
40
+ DiffResult,
41
+ } from "./extract/index.js";
42
+
43
+ // Convenience factory function
44
+ import { TuiDriver, TuiDriverOptions } from "./driver/index.js";
45
+ import { claudeCodeProfile, codexProfile } from "./driver/index.js";
46
+ import { TuiProfileName, TuiProfile } from "./driver/index.js";
47
+
48
+ export function createDriver(
49
+ profileName: TuiProfileName,
50
+ options?: Partial<Omit<TuiDriverOptions, "profile">>
51
+ ): TuiDriver {
52
+ const baseProfile = profileName === "claude-code" ? claudeCodeProfile : codexProfile;
53
+
54
+ // Merge current process.env with profile env
55
+ const profile: TuiProfile = {
56
+ ...baseProfile,
57
+ env: {
58
+ ...process.env as Record<string, string>,
59
+ ...baseProfile.env,
60
+ },
61
+ };
62
+
63
+ return new TuiDriver({
64
+ profile,
65
+ ...options,
66
+ });
67
+ }
@@ -0,0 +1,234 @@
1
+ import * as pty from "node-pty";
2
+ import { EventEmitter } from "events";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export interface PtySessionOptions {
7
+ cols?: number;
8
+ rows?: number;
9
+ env?: Record<string, string>;
10
+ cwd?: string;
11
+ }
12
+
13
+ export interface PtySessionEvents {
14
+ data: (chunk: string) => void;
15
+ exit: (code: number, signal?: number) => void;
16
+ error: (err: Error) => void;
17
+ }
18
+
19
+ const DEFAULT_COLS = 120;
20
+ const DEFAULT_ROWS = 40;
21
+
22
+ let helperPermissionChecked = false;
23
+
24
+ function locateSpawnHelperPath(): string | null {
25
+ if (process.platform === "win32") {
26
+ return null;
27
+ }
28
+
29
+ try {
30
+ const unixTerminalPath = require.resolve("node-pty/lib/unixTerminal.js");
31
+ const libDir = path.dirname(unixTerminalPath);
32
+ const platformDir = `${process.platform}-${process.arch}`;
33
+ const candidates = [
34
+ path.resolve(libDir, "../build/Release/spawn-helper"),
35
+ path.resolve(libDir, "../build/Debug/spawn-helper"),
36
+ path.resolve(libDir, `../prebuilds/${platformDir}/spawn-helper`),
37
+ path.resolve(libDir, "./build/Release/spawn-helper"),
38
+ path.resolve(libDir, "./build/Debug/spawn-helper"),
39
+ path.resolve(libDir, `./prebuilds/${platformDir}/spawn-helper`),
40
+ ];
41
+
42
+ for (const candidate of candidates) {
43
+ if (fs.existsSync(candidate)) {
44
+ return candidate;
45
+ }
46
+ }
47
+ } catch {
48
+ // best effort
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ function ensureSpawnHelperExecutable(): void {
55
+ if (helperPermissionChecked) {
56
+ return;
57
+ }
58
+ helperPermissionChecked = true;
59
+
60
+ const helperPath = locateSpawnHelperPath();
61
+ if (!helperPath) {
62
+ return;
63
+ }
64
+
65
+ try {
66
+ fs.accessSync(helperPath, fs.constants.X_OK);
67
+ } catch {
68
+ try {
69
+ const stat = fs.statSync(helperPath);
70
+ fs.chmodSync(helperPath, stat.mode | 0o111);
71
+ } catch {
72
+ // best effort
73
+ }
74
+ }
75
+ }
76
+
77
+ export class PtySession extends EventEmitter {
78
+ private ptyProcess: pty.IPty | null = null;
79
+ private _cols: number;
80
+ private _rows: number;
81
+ private _isRunning = false;
82
+
83
+ constructor(
84
+ private command: string,
85
+ private args: string[] = [],
86
+ private options: PtySessionOptions = {}
87
+ ) {
88
+ super();
89
+ this._cols = options.cols ?? DEFAULT_COLS;
90
+ this._rows = options.rows ?? DEFAULT_ROWS;
91
+ }
92
+
93
+ get cols(): number {
94
+ return this._cols;
95
+ }
96
+
97
+ get rows(): number {
98
+ return this._rows;
99
+ }
100
+
101
+ get isRunning(): boolean {
102
+ return this._isRunning;
103
+ }
104
+
105
+ get pid(): number | undefined {
106
+ return this.ptyProcess?.pid;
107
+ }
108
+
109
+ spawn(): void {
110
+ if (this.ptyProcess) {
111
+ throw new Error("PTY session already spawned");
112
+ }
113
+
114
+ ensureSpawnHelperExecutable();
115
+
116
+ const env: Record<string, string> = {
117
+ ...process.env as Record<string, string>,
118
+ TERM: "xterm-256color",
119
+ LANG: "en_US.UTF-8",
120
+ LC_ALL: "en_US.UTF-8",
121
+ ...this.options.env,
122
+ };
123
+
124
+ this.ptyProcess = pty.spawn(this.command, this.args, {
125
+ name: "xterm-256color",
126
+ cols: this._cols,
127
+ rows: this._rows,
128
+ cwd: this.options.cwd ?? process.cwd(),
129
+ env,
130
+ });
131
+
132
+ this._isRunning = true;
133
+
134
+ this.ptyProcess.onData((data: string) => {
135
+ this.emit("data", data);
136
+ });
137
+
138
+ this.ptyProcess.onExit(({ exitCode, signal }) => {
139
+ this._isRunning = false;
140
+ this.emit("exit", exitCode, signal);
141
+ });
142
+ }
143
+
144
+ write(data: string): void {
145
+ if (!this.ptyProcess) {
146
+ throw new Error("PTY session not spawned");
147
+ }
148
+ this.ptyProcess.write(data);
149
+ }
150
+
151
+ writeResponse(data: string): void {
152
+ this.write(data);
153
+ }
154
+
155
+ async writeChunked(
156
+ data: string,
157
+ chunkSize = 300,
158
+ delayMs = 20
159
+ ): Promise<void> {
160
+ for (let i = 0; i < data.length; i += chunkSize) {
161
+ const chunk = data.slice(i, i + chunkSize);
162
+ this.write(chunk);
163
+ if (i + chunkSize < data.length) {
164
+ await this.sleep(delayMs);
165
+ }
166
+ }
167
+ }
168
+
169
+ resize(cols: number, rows: number): void {
170
+ if (!this.ptyProcess) {
171
+ throw new Error("PTY session not spawned");
172
+ }
173
+ this._cols = cols;
174
+ this._rows = rows;
175
+ this.ptyProcess.resize(cols, rows);
176
+ }
177
+
178
+ kill(signal: string = "SIGTERM"): void {
179
+ if (this.ptyProcess) {
180
+ this.ptyProcess.kill(signal);
181
+ this.ptyProcess = null;
182
+ this._isRunning = false;
183
+ }
184
+ }
185
+
186
+ sendKey(key: string): void {
187
+ const keyMap: Record<string, string> = {
188
+ ENTER: "\r",
189
+ CTRL_C: "\x03",
190
+ CTRL_D: "\x04",
191
+ CTRL_J: "\n",
192
+ CTRL_L: "\x0c",
193
+ CTRL_Z: "\x1a",
194
+ ESC: "\x1b",
195
+ TAB: "\t",
196
+ BACKSPACE: "\x7f",
197
+ UP: "\x1b[A",
198
+ DOWN: "\x1b[B",
199
+ RIGHT: "\x1b[C",
200
+ LEFT: "\x1b[D",
201
+ };
202
+
203
+ const mapped = keyMap[key.toUpperCase()];
204
+ if (mapped) {
205
+ this.write(mapped);
206
+ } else {
207
+ this.write(key);
208
+ }
209
+ }
210
+
211
+ sendKeys(keys: string[], delayMs = 50): Promise<void> {
212
+ return new Promise(async (resolve) => {
213
+ for (let i = 0; i < keys.length; i++) {
214
+ this.sendKey(keys[i]);
215
+ if (i < keys.length - 1) {
216
+ await this.sleep(delayMs);
217
+ }
218
+ }
219
+ resolve();
220
+ });
221
+ }
222
+
223
+ private sleep(ms: number): Promise<void> {
224
+ return new Promise((resolve) => setTimeout(resolve, ms));
225
+ }
226
+
227
+ onData(callback: (chunk: string) => void): void {
228
+ this.on("data", callback);
229
+ }
230
+
231
+ onExit(callback: (code: number, signal?: number) => void): void {
232
+ this.on("exit", callback);
233
+ }
234
+ }
@@ -0,0 +1 @@
1
+ export { PtySession, PtySessionOptions, PtySessionEvents } from "./PtySession.js";
@@ -0,0 +1,151 @@
1
+ import { Terminal } from "@xterm/headless";
2
+ import { SerializeAddon } from "@xterm/addon-serialize";
3
+ import { ScreenSnapshot } from "./ScreenSnapshot.js";
4
+
5
+ export interface HeadlessScreenOptions {
6
+ cols?: number;
7
+ rows?: number;
8
+ scrollback?: number;
9
+ onTerminalReply?: (data: string) => void;
10
+ }
11
+
12
+ const DEFAULT_COLS = 120;
13
+ const DEFAULT_ROWS = 40;
14
+ const DEFAULT_SCROLLBACK = 5000;
15
+
16
+ export class HeadlessScreen {
17
+ private terminal: Terminal;
18
+ private serializeAddon: SerializeAddon;
19
+ private _cols: number;
20
+ private _rows: number;
21
+ private onTerminalReply?: (data: string) => void;
22
+
23
+ constructor(options: HeadlessScreenOptions = {}) {
24
+ this._cols = options.cols ?? DEFAULT_COLS;
25
+ this._rows = options.rows ?? DEFAULT_ROWS;
26
+ this.onTerminalReply = options.onTerminalReply;
27
+
28
+ this.terminal = new Terminal({
29
+ cols: this._cols,
30
+ rows: this._rows,
31
+ scrollback: options.scrollback ?? DEFAULT_SCROLLBACK,
32
+ allowProposedApi: true,
33
+ });
34
+
35
+ this.serializeAddon = new SerializeAddon();
36
+ this.terminal.loadAddon(this.serializeAddon);
37
+ }
38
+
39
+ get cols(): number {
40
+ return this._cols;
41
+ }
42
+
43
+ get rows(): number {
44
+ return this._rows;
45
+ }
46
+
47
+ write(data: string): void {
48
+ const replies = this.getDsrReplies(data);
49
+ this.terminal.write(data);
50
+ if (this.onTerminalReply && replies.length > 0) {
51
+ for (const reply of replies) {
52
+ this.onTerminalReply(reply);
53
+ }
54
+ }
55
+ }
56
+
57
+ resize(cols: number, rows: number): void {
58
+ this._cols = cols;
59
+ this._rows = rows;
60
+ this.terminal.resize(cols, rows);
61
+ }
62
+
63
+ clear(): void {
64
+ this.terminal.clear();
65
+ }
66
+
67
+ reset(): void {
68
+ this.terminal.reset();
69
+ }
70
+
71
+ snapshot(): ScreenSnapshot {
72
+ const viewportText = this.getViewportText();
73
+ const scrollbackText = this.getScrollbackText();
74
+ const cursor = this.getCursor();
75
+ const hash = ScreenSnapshot.computeHash(viewportText);
76
+
77
+ return new ScreenSnapshot({
78
+ viewportText,
79
+ scrollbackText,
80
+ cursor,
81
+ hash,
82
+ timestamp: Date.now(),
83
+ cols: this._cols,
84
+ rows: this._rows,
85
+ });
86
+ }
87
+
88
+ private getViewportText(): string {
89
+ const buffer = this.terminal.buffer.active;
90
+ const lines: string[] = [];
91
+
92
+ for (let i = 0; i < this._rows; i++) {
93
+ const line = buffer.getLine(buffer.baseY + i);
94
+ if (line) {
95
+ lines.push(line.translateToString(true));
96
+ } else {
97
+ lines.push("");
98
+ }
99
+ }
100
+
101
+ return lines.join("\n");
102
+ }
103
+
104
+ private getScrollbackText(): string {
105
+ const buffer = this.terminal.buffer.active;
106
+ const lines: string[] = [];
107
+ const totalLines = buffer.baseY + this._rows;
108
+
109
+ for (let i = 0; i < totalLines; i++) {
110
+ const line = buffer.getLine(i);
111
+ if (line) {
112
+ lines.push(line.translateToString(true));
113
+ } else {
114
+ lines.push("");
115
+ }
116
+ }
117
+
118
+ return lines.join("\n");
119
+ }
120
+
121
+ private getCursor(): { x: number; y: number } {
122
+ const buffer = this.terminal.buffer.active;
123
+ return {
124
+ x: buffer.cursorX,
125
+ y: buffer.cursorY,
126
+ };
127
+ }
128
+
129
+ private getDsrReplies(data: string): string[] {
130
+ const dsrRegex = /\x1b\[(\?)?6n/g;
131
+ const matches = data.match(dsrRegex);
132
+ if (!matches || matches.length === 0) {
133
+ return [];
134
+ }
135
+
136
+ const cursor = this.getCursor();
137
+ const row = cursor.y + 1;
138
+ const col = cursor.x + 1;
139
+ const reply = `\x1b[${row};${col}R`;
140
+
141
+ return Array.from({ length: matches.length }, () => reply);
142
+ }
143
+
144
+ serialize(): string {
145
+ return this.serializeAddon.serialize();
146
+ }
147
+
148
+ dispose(): void {
149
+ this.terminal.dispose();
150
+ }
151
+ }
@@ -0,0 +1,89 @@
1
+ import { createHash } from "crypto";
2
+
3
+ export interface ScreenSnapshotData {
4
+ viewportText: string;
5
+ scrollbackText: string;
6
+ cursor: { x: number; y: number };
7
+ hash: string;
8
+ timestamp: number;
9
+ cols: number;
10
+ rows: number;
11
+ }
12
+
13
+ export class ScreenSnapshot implements ScreenSnapshotData {
14
+ readonly viewportText: string;
15
+ readonly scrollbackText: string;
16
+ readonly cursor: { x: number; y: number };
17
+ readonly hash: string;
18
+ readonly timestamp: number;
19
+ readonly cols: number;
20
+ readonly rows: number;
21
+
22
+ constructor(data: ScreenSnapshotData) {
23
+ this.viewportText = data.viewportText;
24
+ this.scrollbackText = data.scrollbackText;
25
+ this.cursor = data.cursor;
26
+ this.hash = data.hash;
27
+ this.timestamp = data.timestamp;
28
+ this.cols = data.cols;
29
+ this.rows = data.rows;
30
+ }
31
+
32
+ static computeHash(text: string): string {
33
+ return createHash("md5").update(text).digest("hex").slice(0, 16);
34
+ }
35
+
36
+ getViewportLines(): string[] {
37
+ return this.viewportText.split("\n");
38
+ }
39
+
40
+ getScrollbackLines(): string[] {
41
+ return this.scrollbackText.split("\n");
42
+ }
43
+
44
+ getViewportWithoutBottom(bottomLines: number): string {
45
+ const lines = this.getViewportLines();
46
+ if (bottomLines >= lines.length) {
47
+ return "";
48
+ }
49
+ return lines.slice(0, lines.length - bottomLines).join("\n");
50
+ }
51
+
52
+ matchesPattern(pattern: RegExp, region: "viewport" | "scrollback" = "viewport"): boolean {
53
+ const text = region === "viewport" ? this.viewportText : this.scrollbackText;
54
+ return pattern.test(text);
55
+ }
56
+
57
+ findPattern(pattern: RegExp, region: "viewport" | "scrollback" = "viewport"): RegExpMatchArray | null {
58
+ const text = region === "viewport" ? this.viewportText : this.scrollbackText;
59
+ return text.match(pattern);
60
+ }
61
+
62
+ diff(other: ScreenSnapshot): {
63
+ added: string[];
64
+ removed: string[];
65
+ } {
66
+ const thisLines = this.scrollbackText.split("\n");
67
+ const otherLines = other.scrollbackText.split("\n");
68
+
69
+ const thisSet = new Set(thisLines);
70
+ const otherSet = new Set(otherLines);
71
+
72
+ const added = otherLines.filter(line => !thisSet.has(line));
73
+ const removed = thisLines.filter(line => !otherSet.has(line));
74
+
75
+ return { added, removed };
76
+ }
77
+
78
+ toJSON(): ScreenSnapshotData {
79
+ return {
80
+ viewportText: this.viewportText,
81
+ scrollbackText: this.scrollbackText,
82
+ cursor: this.cursor,
83
+ hash: this.hash,
84
+ timestamp: this.timestamp,
85
+ cols: this.cols,
86
+ rows: this.rows,
87
+ };
88
+ }
89
+ }
@@ -0,0 +1,2 @@
1
+ export { HeadlessScreen, HeadlessScreenOptions } from "./HeadlessScreen.js";
2
+ export { ScreenSnapshot, ScreenSnapshotData } from "./ScreenSnapshot.js";
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { claudeCodeProfile } from "../src/driver/profiles/claudeCode.profile.js";
3
+
4
+ describe("claude profile timeout policy", () => {
5
+ it("disables hard timeouts for long-running turns", () => {
6
+ expect(claudeCodeProfile.timeouts?.boot).toBe(0);
7
+ expect(claudeCodeProfile.timeouts?.ready).toBe(0);
8
+ expect(claudeCodeProfile.timeouts?.streamStart).toBe(0);
9
+ expect(claudeCodeProfile.timeouts?.streamEnd).toBe(0);
10
+ });
11
+ });