@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.
- package/dist/driver/TuiDriver.d.ts +73 -0
- package/dist/driver/TuiDriver.d.ts.map +1 -1
- package/dist/driver/TuiDriver.js +1122 -42
- package/dist/driver/TuiDriver.js.map +1 -1
- package/dist/driver/TuiProfile.d.ts +2 -0
- package/dist/driver/TuiProfile.d.ts.map +1 -1
- package/dist/driver/TuiProfile.js.map +1 -1
- package/dist/driver/behavior/claude.behavior.d.ts +4 -0
- package/dist/driver/behavior/claude.behavior.d.ts.map +1 -0
- package/dist/driver/behavior/claude.behavior.js +48 -0
- package/dist/driver/behavior/claude.behavior.js.map +1 -0
- package/dist/driver/behavior/copilot.behavior.d.ts +4 -0
- package/dist/driver/behavior/copilot.behavior.d.ts.map +1 -0
- package/dist/driver/behavior/copilot.behavior.js +52 -0
- package/dist/driver/behavior/copilot.behavior.js.map +1 -0
- package/dist/driver/behavior/default.behavior.d.ts +4 -0
- package/dist/driver/behavior/default.behavior.d.ts.map +1 -0
- package/dist/driver/behavior/default.behavior.js +13 -0
- package/dist/driver/behavior/default.behavior.js.map +1 -0
- package/dist/driver/behavior/index.d.ts +5 -0
- package/dist/driver/behavior/index.d.ts.map +1 -0
- package/dist/driver/behavior/index.js +10 -0
- package/dist/driver/behavior/index.js.map +1 -0
- package/dist/driver/behavior/types.d.ts +57 -0
- package/dist/driver/behavior/types.d.ts.map +1 -0
- package/dist/driver/behavior/types.js +3 -0
- package/dist/driver/behavior/types.js.map +1 -0
- package/dist/driver/index.d.ts +4 -1
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js +5 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -1
- package/dist/driver/profiles/claudeCode.profile.js +7 -3
- package/dist/driver/profiles/claudeCode.profile.js.map +1 -1
- package/dist/driver/profiles/copilot.profile.d.ts.map +1 -1
- package/dist/driver/profiles/copilot.profile.js +4 -0
- package/dist/driver/profiles/copilot.profile.js.map +1 -1
- package/dist/extract/OutputExtractor.d.ts +16 -0
- package/dist/extract/OutputExtractor.d.ts.map +1 -1
- package/dist/extract/OutputExtractor.js +113 -5
- package/dist/extract/OutputExtractor.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/pty/PtySession.d.ts +1 -0
- package/dist/pty/PtySession.d.ts.map +1 -1
- package/dist/pty/PtySession.js +9 -0
- package/dist/pty/PtySession.js.map +1 -1
- package/docs/how-to-add-a-new-backend.md +212 -0
- package/package.json +1 -1
- package/src/driver/TuiDriver.ts +1332 -45
- package/src/driver/TuiProfile.ts +3 -0
- package/src/driver/behavior/claude.behavior.ts +54 -0
- package/src/driver/behavior/copilot.behavior.ts +63 -0
- package/src/driver/behavior/default.behavior.ts +12 -0
- package/src/driver/behavior/index.ts +14 -0
- package/src/driver/behavior/types.ts +64 -0
- package/src/driver/index.ts +20 -1
- package/src/driver/profiles/claudeCode.profile.ts +7 -3
- package/src/driver/profiles/copilot.profile.ts +4 -0
- package/src/extract/OutputExtractor.ts +145 -5
- package/src/index.ts +15 -0
- package/src/pty/PtySession.ts +10 -0
- package/test/claude-profile.test.ts +41 -0
- package/test/claude-signals.test.ts +80 -0
- package/test/codex-session-discovery.test.ts +101 -0
- package/test/copilot-profile.test.ts +12 -0
- package/test/copilot-signals.test.ts +70 -0
- package/test/output-extractor.test.ts +79 -0
- package/test/session-file-extraction.test.ts +257 -0
- package/test/stream-detection.test.ts +28 -0
- package/test/timeout-resolution.test.ts +37 -0
package/src/driver/TuiProfile.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/driver/index.ts
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
export {
|
|
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(
|
|
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(
|
|
97
|
+
replyStart: [/^⏺\s(?!.*\b(tool|mcp|running|calling|recalled)\b)/i],
|
|
95
98
|
replyStop: [/^❯\s*/m],
|
|
96
99
|
status: [
|
|
97
|
-
/^⏺\s
|
|
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
|
|
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 (
|
|
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 &&
|
|
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
|
package/src/pty/PtySession.ts
CHANGED
|
@@ -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
|
+
});
|