@love-moon/tui-driver 0.2.11 → 0.2.13

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 (73) hide show
  1. package/dist/driver/TuiDriver.d.ts +73 -0
  2. package/dist/driver/TuiDriver.d.ts.map +1 -1
  3. package/dist/driver/TuiDriver.js +1122 -42
  4. package/dist/driver/TuiDriver.js.map +1 -1
  5. package/dist/driver/TuiProfile.d.ts +2 -0
  6. package/dist/driver/TuiProfile.d.ts.map +1 -1
  7. package/dist/driver/TuiProfile.js.map +1 -1
  8. package/dist/driver/behavior/claude.behavior.d.ts +4 -0
  9. package/dist/driver/behavior/claude.behavior.d.ts.map +1 -0
  10. package/dist/driver/behavior/claude.behavior.js +48 -0
  11. package/dist/driver/behavior/claude.behavior.js.map +1 -0
  12. package/dist/driver/behavior/copilot.behavior.d.ts +4 -0
  13. package/dist/driver/behavior/copilot.behavior.d.ts.map +1 -0
  14. package/dist/driver/behavior/copilot.behavior.js +52 -0
  15. package/dist/driver/behavior/copilot.behavior.js.map +1 -0
  16. package/dist/driver/behavior/default.behavior.d.ts +4 -0
  17. package/dist/driver/behavior/default.behavior.d.ts.map +1 -0
  18. package/dist/driver/behavior/default.behavior.js +13 -0
  19. package/dist/driver/behavior/default.behavior.js.map +1 -0
  20. package/dist/driver/behavior/index.d.ts +5 -0
  21. package/dist/driver/behavior/index.d.ts.map +1 -0
  22. package/dist/driver/behavior/index.js +10 -0
  23. package/dist/driver/behavior/index.js.map +1 -0
  24. package/dist/driver/behavior/types.d.ts +57 -0
  25. package/dist/driver/behavior/types.d.ts.map +1 -0
  26. package/dist/driver/behavior/types.js +3 -0
  27. package/dist/driver/behavior/types.js.map +1 -0
  28. package/dist/driver/index.d.ts +4 -1
  29. package/dist/driver/index.d.ts.map +1 -1
  30. package/dist/driver/index.js +5 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -1
  33. package/dist/driver/profiles/claudeCode.profile.js +7 -3
  34. package/dist/driver/profiles/claudeCode.profile.js.map +1 -1
  35. package/dist/driver/profiles/copilot.profile.d.ts.map +1 -1
  36. package/dist/driver/profiles/copilot.profile.js +4 -0
  37. package/dist/driver/profiles/copilot.profile.js.map +1 -1
  38. package/dist/extract/OutputExtractor.d.ts +16 -0
  39. package/dist/extract/OutputExtractor.d.ts.map +1 -1
  40. package/dist/extract/OutputExtractor.js +113 -5
  41. package/dist/extract/OutputExtractor.js.map +1 -1
  42. package/dist/index.d.ts +2 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/pty/PtySession.d.ts +1 -0
  47. package/dist/pty/PtySession.d.ts.map +1 -1
  48. package/dist/pty/PtySession.js +9 -0
  49. package/dist/pty/PtySession.js.map +1 -1
  50. package/docs/how-to-add-a-new-backend.md +212 -0
  51. package/package.json +1 -1
  52. package/src/driver/TuiDriver.ts +1332 -45
  53. package/src/driver/TuiProfile.ts +3 -0
  54. package/src/driver/behavior/claude.behavior.ts +54 -0
  55. package/src/driver/behavior/copilot.behavior.ts +63 -0
  56. package/src/driver/behavior/default.behavior.ts +12 -0
  57. package/src/driver/behavior/index.ts +14 -0
  58. package/src/driver/behavior/types.ts +64 -0
  59. package/src/driver/index.ts +20 -1
  60. package/src/driver/profiles/claudeCode.profile.ts +7 -3
  61. package/src/driver/profiles/copilot.profile.ts +4 -0
  62. package/src/extract/OutputExtractor.ts +145 -5
  63. package/src/index.ts +15 -0
  64. package/src/pty/PtySession.ts +10 -0
  65. package/test/claude-profile.test.ts +41 -0
  66. package/test/claude-signals.test.ts +80 -0
  67. package/test/codex-session-discovery.test.ts +101 -0
  68. package/test/copilot-profile.test.ts +12 -0
  69. package/test/copilot-signals.test.ts +70 -0
  70. package/test/output-extractor.test.ts +79 -0
  71. package/test/session-file-extraction.test.ts +257 -0
  72. package/test/stream-detection.test.ts +28 -0
  73. package/test/timeout-resolution.test.ts +37 -0
@@ -1,3 +1,5 @@
1
+ import type { TuiDriverBehavior } from "./behavior/types.js";
2
+
1
3
  export type TuiProfileName = "claude-code" | "codex" | "copilot";
2
4
 
3
5
  export interface TuiAnchors {
@@ -39,6 +41,7 @@ export interface TuiProfile {
39
41
  command: string;
40
42
  args: string[];
41
43
  env?: Record<string, string>;
44
+ behavior?: TuiDriverBehavior;
42
45
  anchors: TuiAnchors;
43
46
  keys: TuiKeys;
44
47
  preflightKeys?: string[];
@@ -0,0 +1,54 @@
1
+ import type { MatcherFn } from "../../expect/Matchers.js";
2
+ import type { TuiDriverBehavior } from "./types.js";
3
+
4
+ function findLastClaudeUserPromptIndex(lines: string[]): number {
5
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
6
+ if (/^\s*❯\s+(?!\d+\.)\S+/.test(lines[i])) {
7
+ return i;
8
+ }
9
+ }
10
+ return -1;
11
+ }
12
+
13
+ function findClaudeContinuationStatusLine(lines: string[]): string | null {
14
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
15
+ if (/^\s*⎿\s+\S+/.test(lines[i])) {
16
+ return lines[i];
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export const claudeBehavior: TuiDriverBehavior = {
23
+ getSignalScopeLines: ({ lines }) => {
24
+ const startIndex = findLastClaudeUserPromptIndex(lines);
25
+ if (startIndex < 0) {
26
+ return lines;
27
+ }
28
+ return lines.slice(startIndex);
29
+ },
30
+
31
+ resolveStatusLine: ({ lines, defaultStatusLine }) => {
32
+ if (defaultStatusLine) {
33
+ return defaultStatusLine;
34
+ }
35
+ return findClaudeContinuationStatusLine(lines);
36
+ },
37
+
38
+ matchStreamStartFallback: ({ previousSnapshot, snapshot }) => {
39
+ return snapshot.hash !== previousSnapshot.hash;
40
+ },
41
+
42
+ buildStreamEndCompleteMatcher: ({ readyMatcher }) => readyMatcher,
43
+
44
+ buildStreamEndStatusClearMatcher: ({ getSignals }): MatcherFn => {
45
+ return (snapshot) => {
46
+ const currentSignals = getSignals(snapshot);
47
+ return currentSignals.statusDoneLine
48
+ ? true
49
+ : !currentSignals.statusLine;
50
+ };
51
+ },
52
+ };
53
+
54
+ export default claudeBehavior;
@@ -0,0 +1,63 @@
1
+ import type { TuiDriverBehavior } from "./types.js";
2
+
3
+ function findLastCopilotUserPromptIndex(lines: string[]): number {
4
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
5
+ const current = lines[i];
6
+ if (/^\s*You said:\s*\S+/i.test(current)) {
7
+ return i;
8
+ }
9
+ if (/^\s*❯\s+(?!Type @ to mention files,\s*\/ for commands,\s*or \? for shortcuts\b)(?!\d+\.)\S+/i.test(current)) {
10
+ return i;
11
+ }
12
+ }
13
+ return -1;
14
+ }
15
+
16
+ export const copilotBehavior: TuiDriverBehavior = {
17
+ getSignalScopeLines: ({ lines }) => {
18
+ const startIndex = findLastCopilotUserPromptIndex(lines);
19
+ if (startIndex < 0) {
20
+ return lines;
21
+ }
22
+ return lines.slice(startIndex);
23
+ },
24
+
25
+ matchStreamStartFallback: ({ previousSnapshot, snapshot }) => {
26
+ return snapshot.hash !== previousSnapshot.hash;
27
+ },
28
+
29
+ matchStreamEndReplyFallback: ({
30
+ snapshot,
31
+ turnStartHash,
32
+ readyMatcher,
33
+ busyMatcher,
34
+ promptMatcher,
35
+ sawBusyDuringWait,
36
+ waitStartedAt,
37
+ hasAnyNewScrollbackLine,
38
+ }) => {
39
+ const busyNow = busyMatcher ? busyMatcher(snapshot) : false;
40
+ if (busyNow) {
41
+ return false;
42
+ }
43
+ if (turnStartHash && snapshot.hash === turnStartHash) {
44
+ return false;
45
+ }
46
+ const hasPrompt = promptMatcher ? promptMatcher(snapshot) : readyMatcher(snapshot);
47
+ if (!hasPrompt) {
48
+ return false;
49
+ }
50
+ if (sawBusyDuringWait) {
51
+ return true;
52
+ }
53
+ if (!hasAnyNewScrollbackLine(snapshot)) {
54
+ return false;
55
+ }
56
+ if (Date.now() - waitStartedAt < 1000) {
57
+ return false;
58
+ }
59
+ return true;
60
+ },
61
+ };
62
+
63
+ export default copilotBehavior;
@@ -0,0 +1,12 @@
1
+ import type { TuiDriverBehavior } from "./types.js";
2
+
3
+ export const defaultTuiDriverBehavior: TuiDriverBehavior = {
4
+ getSignalScopeLines: ({ lines }) => lines,
5
+ resolveStatusLine: ({ defaultStatusLine }) => defaultStatusLine,
6
+ matchStreamStartFallback: () => false,
7
+ matchStreamEndReplyFallback: () => false,
8
+ buildStreamEndCompleteMatcher: ({ defaultMatcher }) => defaultMatcher,
9
+ buildStreamEndStatusClearMatcher: ({ defaultMatcher }) => defaultMatcher,
10
+ };
11
+
12
+ export default defaultTuiDriverBehavior;
@@ -0,0 +1,14 @@
1
+ export type {
2
+ SignalHelpers,
3
+ SignalScopeContext,
4
+ ResolveStatusLineContext,
5
+ StreamStartFallbackContext,
6
+ StreamEndReplyFallbackContext,
7
+ StreamEndCompleteMatcherContext,
8
+ StreamEndStatusClearMatcherContext,
9
+ TuiDriverBehavior,
10
+ } from "./types.js";
11
+
12
+ export { defaultTuiDriverBehavior } from "./default.behavior.js";
13
+ export { claudeBehavior } from "./claude.behavior.js";
14
+ export { copilotBehavior } from "./copilot.behavior.js";
@@ -0,0 +1,64 @@
1
+ import type { MatcherFn } from "../../expect/Matchers.js";
2
+ import type { ScreenSnapshot } from "../../term/ScreenSnapshot.js";
3
+ import type { TuiSignals } from "../TuiProfile.js";
4
+
5
+ export interface SignalHelpers {
6
+ findLastMatch: (lines: string[], patterns?: RegExp[]) => string | null;
7
+ }
8
+
9
+ export interface SignalScopeContext {
10
+ lines: string[];
11
+ signals: TuiSignals;
12
+ helpers: SignalHelpers;
13
+ }
14
+
15
+ export interface ResolveStatusLineContext {
16
+ lines: string[];
17
+ signals: TuiSignals;
18
+ defaultStatusLine: string | null;
19
+ helpers: SignalHelpers;
20
+ }
21
+
22
+ export interface StreamStartFallbackContext {
23
+ previousSnapshot: ScreenSnapshot;
24
+ snapshot: ScreenSnapshot;
25
+ previousScrollback: string;
26
+ addedLines: string[];
27
+ hasNewReplyStart: boolean;
28
+ }
29
+
30
+ export interface StreamEndReplyFallbackContext {
31
+ snapshot: ScreenSnapshot;
32
+ turnStartSnapshot?: ScreenSnapshot;
33
+ turnStartScrollback: string;
34
+ turnStartHash: string;
35
+ readyMatcher: MatcherFn;
36
+ busyMatcher?: MatcherFn | null;
37
+ promptMatcher?: MatcherFn | null;
38
+ sawBusyDuringWait: boolean;
39
+ waitStartedAt: number;
40
+ hasAnyNewScrollbackLine: (snapshot: ScreenSnapshot) => boolean;
41
+ }
42
+
43
+ export interface StreamEndCompleteMatcherContext {
44
+ readyMatcher: MatcherFn;
45
+ busyMatcher?: MatcherFn | null;
46
+ defaultMatcher: MatcherFn;
47
+ }
48
+
49
+ export interface StreamEndStatusClearMatcherContext {
50
+ defaultMatcher: MatcherFn;
51
+ getSignals: (snapshot: ScreenSnapshot) => {
52
+ statusLine?: string;
53
+ statusDoneLine?: string;
54
+ };
55
+ }
56
+
57
+ export interface TuiDriverBehavior {
58
+ getSignalScopeLines?: (context: SignalScopeContext) => string[];
59
+ resolveStatusLine?: (context: ResolveStatusLineContext) => string | null;
60
+ matchStreamStartFallback?: (context: StreamStartFallbackContext) => boolean;
61
+ matchStreamEndReplyFallback?: (context: StreamEndReplyFallbackContext) => boolean;
62
+ buildStreamEndCompleteMatcher?: (context: StreamEndCompleteMatcherContext) => MatcherFn;
63
+ buildStreamEndStatusClearMatcher?: (context: StreamEndStatusClearMatcherContext) => MatcherFn;
64
+ }
@@ -1,4 +1,23 @@
1
- export { TuiDriver, TuiDriverOptions, AskResult, TuiScreenSignals, HealthStatus, HealthReason } from "./TuiDriver.js";
1
+ export {
2
+ TuiDriver,
3
+ TuiDriverOptions,
4
+ AskResult,
5
+ TuiScreenSignals,
6
+ HealthStatus,
7
+ HealthReason,
8
+ } from "./TuiDriver.js";
9
+ export type { TuiSessionInfo, TuiSessionUsageSummary } from "./TuiDriver.js";
2
10
  export { TuiProfile, TuiProfileName, TuiAnchors, TuiKeys, TuiExtraction, TuiSignals, createProfile } from "./TuiProfile.js";
3
11
  export { StateMachine, TuiState, StateTransition } from "./StateMachine.js";
4
12
  export { claudeCodeProfile, codexProfile, copilotProfile } from "./profiles/index.js";
13
+ export { defaultTuiDriverBehavior, claudeBehavior, copilotBehavior } from "./behavior/index.js";
14
+ export type {
15
+ TuiDriverBehavior,
16
+ SignalHelpers,
17
+ SignalScopeContext,
18
+ ResolveStatusLineContext,
19
+ StreamStartFallbackContext,
20
+ StreamEndReplyFallbackContext,
21
+ StreamEndCompleteMatcherContext,
22
+ StreamEndStatusClearMatcherContext,
23
+ } from "./behavior/index.js";
@@ -1,4 +1,5 @@
1
1
  import { TuiProfile, createProfile } from "../TuiProfile.js";
2
+ import { claudeBehavior } from "../behavior/claude.behavior.js";
2
3
 
3
4
  export const claudeCodeProfile: TuiProfile = createProfile({
4
5
  name: "claude-code",
@@ -9,6 +10,7 @@ export const claudeCodeProfile: TuiProfile = createProfile({
9
10
  LANG: "en_US.UTF-8",
10
11
  LC_ALL: "en_US.UTF-8",
11
12
  },
13
+ behavior: claudeBehavior,
12
14
 
13
15
  anchors: {
14
16
  ready: [
@@ -24,6 +26,7 @@ export const claudeCodeProfile: TuiProfile = createProfile({
24
26
  /⏺/, // Claude's streaming indicator
25
27
  /Thinking/i,
26
28
  /Working/i,
29
+ /^\s*⎿\s*(?:Retrying\b.*\battempt\s+\d+\/\d+\b|.*\bAPI_TIMEOUT_MS=\d+ms\b).*$/im,
27
30
  ],
28
31
  error: [
29
32
  // 只匹配 CLI 系统错误,不匹配代码中的文本
@@ -68,7 +71,7 @@ export const claudeCodeProfile: TuiProfile = createProfile({
68
71
  extraction: {
69
72
  mode: "diff-scrollback",
70
73
  includeLinePatterns: [
71
- /^⏺\s(?!.*(tool|mcp|running|calling|call))/i,
74
+ /^⏺\s(?!.*\b(tool|mcp|running|calling|recalled)\b)/i,
72
75
  ],
73
76
  stopLinePatterns: [
74
77
  /^❯\s*/m,
@@ -91,11 +94,12 @@ export const claudeCodeProfile: TuiProfile = createProfile({
91
94
  signals: {
92
95
  prompt: [/^❯\s*/m],
93
96
  promptHint: [/^❯\s+(?!\d+\.)\S+/m],
94
- replyStart: [/^⏺\s(?!.*(tool|mcp|running|calling|call))/i],
97
+ replyStart: [/^⏺\s(?!.*\b(tool|mcp|running|calling|recalled)\b)/i],
95
98
  replyStop: [/^❯\s*/m],
96
99
  status: [
97
- /^⏺\s.*(tool|mcp|running|calling|call)/i,
100
+ /^⏺\s.*\b(tool|mcp|running|calling)\b/i,
98
101
  /^✻\s.*\(\d+s\s*•/i, // Working status with updating time: "✻ ... (5s • esc to interrupt)"
102
+ /^\s*⎿\s*(?:Retrying\b.*\battempt\s+\d+\/\d+\b|.*\bAPI_TIMEOUT_MS=\d+ms\b).*$/im,
99
103
  ],
100
104
  statusDone: [
101
105
  /^✻\s.*for\s+\d+(\.\d+)?s/i, // Final status with fixed time: "✻ Cooked for 33s"
@@ -1,4 +1,5 @@
1
1
  import { TuiProfile, createProfile } from "../TuiProfile.js";
2
+ import { copilotBehavior } from "../behavior/copilot.behavior.js";
2
3
 
3
4
  export const copilotProfile: TuiProfile = createProfile({
4
5
  name: "copilot",
@@ -14,11 +15,14 @@ export const copilotProfile: TuiProfile = createProfile({
14
15
  LANG: "en_US.UTF-8",
15
16
  LC_ALL: "en_US.UTF-8",
16
17
  },
18
+ behavior: copilotBehavior,
17
19
 
18
20
  anchors: {
19
21
  ready: [
20
22
  /^(?![\s\S]*Thinking \(Esc to cancel)(?=[\s\S]*(?:^|\n)❯\s+)(?=[\s\S]*Remaining reqs\.:)[\s\S]*$/,
21
23
  /^(?![\s\S]*Thinking \(Esc to cancel)(?=[\s\S]*(?:^|\n)Start of Prompt Indicator\b)(?=[\s\S]*Remaining reqs\.:)[\s\S]*$/i,
24
+ /^(?![\s\S]*Thinking \(Esc to cancel)(?=[\s\S]*(?:^|\n)❯\s+)[\s\S]*$/,
25
+ /^(?![\s\S]*Thinking \(Esc to cancel)(?=[\s\S]*(?:^|\n)Start of Prompt Indicator\b)[\s\S]*$/i,
22
26
  ],
23
27
  trust: [
24
28
  /Remember screen reader mode/i,
@@ -20,12 +20,13 @@ export class OutputExtractor {
20
20
  rawText = this.extractFromScrollback(before, after);
21
21
  }
22
22
 
23
- const text = this.cleanText(rawText);
23
+ const boundedRawText = this.extractLatestReplyBlockFromSnapshot(before, after) ?? rawText;
24
+ const text = this.cleanText(boundedRawText);
24
25
 
25
26
  return {
26
27
  text,
27
- rawText,
28
- linesAdded: text.split("\n").length,
28
+ rawText: boundedRawText,
29
+ linesAdded: text ? text.split("\n").length : 0,
29
30
  };
30
31
  }
31
32
 
@@ -42,24 +43,144 @@ export class OutputExtractor {
42
43
  return added.join("\n");
43
44
  }
44
45
 
46
+ /**
47
+ * Extract the latest reply block by TUI markers:
48
+ * reply starts from includeLinePatterns (e.g. "• ...")
49
+ * and ends at the latest stopLinePatterns match (e.g. "› ...").
50
+ * Content after the latest prompt line is ignored.
51
+ * Returns:
52
+ * - null: rule not applicable (missing markers/config)
53
+ * - "": prompt found but no valid new reply block before it
54
+ * - string: bounded raw reply block
55
+ */
56
+ private extractLatestReplyBlockFromSnapshot(
57
+ before: ScreenSnapshot,
58
+ after: ScreenSnapshot
59
+ ): string | null {
60
+ const includeLinePatterns = this.extraction.includeLinePatterns ?? [];
61
+ const stopLinePatterns = this.extraction.stopLinePatterns ?? [];
62
+ if (this.extraction.mode !== "diff-scrollback") {
63
+ return null;
64
+ }
65
+ if (includeLinePatterns.length === 0 || stopLinePatterns.length === 0) {
66
+ return null;
67
+ }
68
+
69
+ const beforeLines = before.scrollbackText.split("\n");
70
+ const lines = after.scrollbackText.split("\n");
71
+ const stopIndex = this.findLastMatchingLineIndex(lines, stopLinePatterns);
72
+ if (stopIndex < 0) {
73
+ return null;
74
+ }
75
+ if (stopIndex === 0) {
76
+ return "";
77
+ }
78
+
79
+ const firstChangedIndex = this.findFirstChangedLineIndex(beforeLines, lines);
80
+ if (firstChangedIndex < 0) {
81
+ return "";
82
+ }
83
+
84
+ // Prefer reply markers that appeared in this turn's changed range.
85
+ const startIndex = this.findLastMatchingLineIndexInRange(
86
+ lines,
87
+ firstChangedIndex,
88
+ stopIndex,
89
+ includeLinePatterns
90
+ );
91
+ if (startIndex < 0) {
92
+ return "";
93
+ }
94
+
95
+ if (!this.rangeHasChangedContentBeforeStop({
96
+ lines,
97
+ startIndex,
98
+ stopIndex,
99
+ firstChangedIndex,
100
+ stopLinePatterns,
101
+ })) {
102
+ return "";
103
+ }
104
+
105
+ const block = lines.slice(startIndex, stopIndex + 1);
106
+ return block.join("\n");
107
+ }
108
+
109
+ private findFirstChangedLineIndex(beforeLines: string[], afterLines: string[]): number {
110
+ const minLength = Math.min(beforeLines.length, afterLines.length);
111
+ let prefixLength = 0;
112
+ while (prefixLength < minLength && beforeLines[prefixLength] === afterLines[prefixLength]) {
113
+ prefixLength += 1;
114
+ }
115
+ return prefixLength < afterLines.length ? prefixLength : -1;
116
+ }
117
+
118
+ private findLastMatchingLineIndexInRange(
119
+ lines: string[],
120
+ startIndex: number,
121
+ endExclusive: number,
122
+ patterns: RegExp[]
123
+ ): number {
124
+ const safeStart = Math.max(0, startIndex);
125
+ const safeEnd = Math.min(lines.length, Math.max(safeStart, endExclusive));
126
+ for (let index = safeEnd - 1; index >= safeStart; index -= 1) {
127
+ if (this.matchesAny(lines[index], patterns)) {
128
+ return index;
129
+ }
130
+ }
131
+ return -1;
132
+ }
133
+
134
+ private rangeHasChangedContentBeforeStop(options: {
135
+ lines: string[];
136
+ startIndex: number;
137
+ stopIndex: number;
138
+ firstChangedIndex: number;
139
+ stopLinePatterns: RegExp[];
140
+ }): boolean {
141
+ const { lines, startIndex, stopIndex, firstChangedIndex, stopLinePatterns } = options;
142
+ const from = Math.max(startIndex, firstChangedIndex);
143
+ const to = Math.min(stopIndex, lines.length);
144
+
145
+ for (let index = from; index < to; index += 1) {
146
+ const line = lines[index];
147
+ if (!line || line.trim().length === 0) {
148
+ continue;
149
+ }
150
+ if (this.matchesAny(line, stopLinePatterns)) {
151
+ continue;
152
+ }
153
+ return true;
154
+ }
155
+ return false;
156
+ }
157
+
45
158
  private cleanText(text: string): string {
46
159
  let result = text;
47
160
  const includeLinePatterns = this.extraction.includeLinePatterns;
48
161
  const stopLinePatterns = this.extraction.stopLinePatterns ?? [];
49
162
 
163
+ if (stopLinePatterns.length > 0) {
164
+ const boundedLines = result.split("\n");
165
+ const lastStopIndex = this.findLastMatchingLineIndex(boundedLines, stopLinePatterns);
166
+ if (lastStopIndex >= 0) {
167
+ result = boundedLines.slice(0, lastStopIndex + 1).join("\n");
168
+ }
169
+ }
170
+
50
171
  if (includeLinePatterns && includeLinePatterns.length > 0) {
51
172
  const lines = result.split("\n");
52
173
  const kept: string[] = [];
53
174
  let including = false;
54
175
 
55
176
  for (const line of lines) {
56
- if (includeLinePatterns.some(pattern => pattern.test(line))) {
177
+ if (this.matchesAny(line, includeLinePatterns)) {
57
178
  including = true;
58
179
  kept.push(line);
59
180
  continue;
60
181
  }
61
182
 
62
- if (including && stopLinePatterns.some(pattern => pattern.test(line))) {
183
+ if (including && this.matchesAny(line, stopLinePatterns)) {
63
184
  including = false;
64
185
  continue;
65
186
  }
@@ -85,4 +206,23 @@ export class OutputExtractor {
85
206
 
86
207
  return result.trim();
87
208
  }
209
+
210
+ private findLastMatchingLineIndex(lines: string[], patterns: RegExp[]): number {
211
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
212
+ if (this.matchesAny(lines[index], patterns)) {
213
+ return index;
214
+ }
215
+ }
216
+ return -1;
217
+ }
218
+
219
+ private matchesAny(line: string, patterns: RegExp[]): boolean {
220
+ for (const pattern of patterns) {
221
+ pattern.lastIndex = 0;
222
+ if (pattern.test(line)) {
223
+ return true;
224
+ }
225
+ }
226
+ return false;
227
+ }
88
228
  }
package/src/index.ts CHANGED
@@ -30,6 +30,21 @@ export {
30
30
  TuiScreenSignals,
31
31
  HealthStatus,
32
32
  HealthReason,
33
+ defaultTuiDriverBehavior,
34
+ claudeBehavior,
35
+ copilotBehavior,
36
+ } from "./driver/index.js";
37
+ export type {
38
+ TuiSessionInfo,
39
+ TuiSessionUsageSummary,
40
+ TuiDriverBehavior,
41
+ SignalHelpers,
42
+ SignalScopeContext,
43
+ ResolveStatusLineContext,
44
+ StreamStartFallbackContext,
45
+ StreamEndReplyFallbackContext,
46
+ StreamEndCompleteMatcherContext,
47
+ StreamEndStatusClearMatcherContext,
33
48
  } from "./driver/index.js";
34
49
 
35
50
  // Extract
@@ -137,10 +137,20 @@ export class PtySession extends EventEmitter {
137
137
 
138
138
  this.ptyProcess.onExit(({ exitCode, signal }) => {
139
139
  this._isRunning = false;
140
+ // Clear spawn state on natural exit so follow-up restarts can spawn safely.
141
+ this.ptyProcess = null;
140
142
  this.emit("exit", exitCode, signal);
141
143
  });
142
144
  }
143
145
 
146
+ setCommandArgs(command: string, args: string[] = []): void {
147
+ if (this.ptyProcess) {
148
+ throw new Error("Cannot update PTY command while session is running");
149
+ }
150
+ this.command = command;
151
+ this.args = Array.isArray(args) ? [...args] : [];
152
+ }
153
+
144
154
  write(data: string): void {
145
155
  if (!this.ptyProcess) {
146
156
  throw new Error("PTY session not spawned");
@@ -1,5 +1,46 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { Matchers } from "../src/expect/Matchers.js";
2
3
  import { claudeCodeProfile } from "../src/driver/profiles/claudeCode.profile.js";
4
+ import { ScreenSnapshot } from "../src/term/ScreenSnapshot.js";
5
+
6
+ function createSnapshot(text: string): ScreenSnapshot {
7
+ return new ScreenSnapshot({
8
+ viewportText: text,
9
+ scrollbackText: text,
10
+ cursor: { x: 0, y: 0 },
11
+ hash: ScreenSnapshot.computeHash(text),
12
+ timestamp: Date.now(),
13
+ cols: 120,
14
+ rows: 40,
15
+ });
16
+ }
17
+
18
+ describe("claude profile busy and status signals", () => {
19
+ it("matches retry status line with attempt and timeout hint", () => {
20
+ const snapshot = createSnapshot([
21
+ "❯ hi, 1+1=",
22
+ " ⎿ Retrying in 14 seconds… (attempt 9/10) · API_TIMEOUT_MS=3000000ms, try increasing it",
23
+ ].join("\n"));
24
+
25
+ const busyMatcher = Matchers.anyOf(claudeCodeProfile.anchors.busy ?? []);
26
+ const statusMatcher = Matchers.anyOf(claudeCodeProfile.signals?.status ?? []);
27
+ const replyStartMatcher = Matchers.anyOf(claudeCodeProfile.signals?.replyStart ?? [], "scrollback");
28
+
29
+ expect(busyMatcher(snapshot)).toBe(true);
30
+ expect(statusMatcher(snapshot)).toBe(true);
31
+ expect(replyStartMatcher(snapshot)).toBe(false);
32
+ });
33
+
34
+ it("does not classify recalled memory summary as reply or tool status", () => {
35
+ const snapshot = createSnapshot("⏺ Recalled 1 memory, wrote 1 memory (ctrl+o to expand)");
36
+
37
+ const statusMatcher = Matchers.anyOf(claudeCodeProfile.signals?.status ?? []);
38
+ const replyStartMatcher = Matchers.anyOf(claudeCodeProfile.signals?.replyStart ?? [], "scrollback");
39
+
40
+ expect(statusMatcher(snapshot)).toBe(false);
41
+ expect(replyStartMatcher(snapshot)).toBe(false);
42
+ });
43
+ });
3
44
 
4
45
  describe("claude profile timeout policy", () => {
5
46
  it("disables hard timeouts for long-running turns", () => {
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { TuiDriver } from "../src/driver/TuiDriver.js";
3
+ import { claudeCodeProfile } from "../src/driver/profiles/claudeCode.profile.js";
4
+ import { ScreenSnapshot } from "../src/term/ScreenSnapshot.js";
5
+
6
+ function createSnapshot(text: string): ScreenSnapshot {
7
+ return new ScreenSnapshot({
8
+ viewportText: text,
9
+ scrollbackText: text,
10
+ cursor: { x: 0, y: 0 },
11
+ hash: ScreenSnapshot.computeHash(text),
12
+ timestamp: Date.now(),
13
+ cols: 120,
14
+ rows: 40,
15
+ });
16
+ }
17
+
18
+ describe("claude getSignals continuation status", () => {
19
+ it("ignores history status lines before latest user prompt", () => {
20
+ const driver = new TuiDriver({ profile: claudeCodeProfile });
21
+ const snapshot = createSnapshot([
22
+ "❯ old question",
23
+ " ⎿ API Error: 500",
24
+ "❯ new question",
25
+ "⏺ Recalled 1 memory, wrote 1 memory (ctrl+o to expand)",
26
+ "❯ ",
27
+ ].join("\n"));
28
+
29
+ const signals = driver.getSignals(snapshot);
30
+ expect(signals.statusLine).toBeUndefined();
31
+
32
+ driver.kill();
33
+ });
34
+
35
+ it("captures not-logged-in continuation line after latest user prompt", () => {
36
+ const driver = new TuiDriver({ profile: claudeCodeProfile });
37
+ const snapshot = createSnapshot([
38
+ "❯ old question",
39
+ " ⎿ API Error: 500",
40
+ "⏺ Recalled 1 memory, wrote 1 memory (ctrl+o to expand)",
41
+ "❯ new question",
42
+ " ⎿ Not logged in · Please run /login",
43
+ "❯ ",
44
+ ].join("\n"));
45
+
46
+ const signals = driver.getSignals(snapshot);
47
+ expect(signals.statusLine).toBe(" ⎿ Not logged in · Please run /login");
48
+
49
+ driver.kill();
50
+ });
51
+
52
+ it("captures api error continuation line after user prompt", () => {
53
+ const driver = new TuiDriver({ profile: claudeCodeProfile });
54
+ const snapshot = createSnapshot([
55
+ "❯ please continue",
56
+ " ⎿ API Error: 401",
57
+ "❯ ",
58
+ ].join("\n"));
59
+
60
+ const signals = driver.getSignals(snapshot);
61
+ expect(signals.statusLine).toBe(" ⎿ API Error: 401");
62
+
63
+ driver.kill();
64
+ });
65
+
66
+ it("uses the latest continuation line within current turn", () => {
67
+ const driver = new TuiDriver({ profile: claudeCodeProfile });
68
+ const snapshot = createSnapshot([
69
+ "❯ retry this",
70
+ " ⎿ Not logged in · Please run /login",
71
+ " ⎿ API Error: 401",
72
+ "❯ ",
73
+ ].join("\n"));
74
+
75
+ const signals = driver.getSignals(snapshot);
76
+ expect(signals.statusLine).toBe(" ⎿ API Error: 401");
77
+
78
+ driver.kill();
79
+ });
80
+ });