@elench/testkit 0.1.86 → 0.1.88
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 +19 -12
- package/lib/cli/agents/providers/claude.mjs +1 -1
- package/lib/cli/agents/providers/codex.mjs +1 -1
- package/lib/cli/assistant/prompt-builder.mjs +78 -0
- package/lib/cli/assistant/protocol.mjs +67 -0
- package/lib/cli/assistant/session.mjs +92 -0
- package/lib/cli/assistant/slash-commands.mjs +160 -0
- package/lib/cli/assistant/state.mjs +279 -0
- package/lib/cli/assistant/tool-registry.mjs +236 -0
- package/lib/cli/assistant/tool-run-reporter.mjs +80 -0
- package/lib/cli/command-helpers.mjs +40 -24
- package/lib/cli/commands/assistant.mjs +84 -0
- package/lib/cli/entrypoint.mjs +37 -11
- package/lib/cli/presentation/tree-reporter.mjs +34 -28
- package/lib/cli/tui/assistant-app.mjs +131 -0
- package/lib/cli/tui/detail-pane.mjs +161 -0
- package/lib/cli/tui/filter-bar.mjs +12 -0
- package/lib/cli/tui/fuzzy-match.mjs +106 -0
- package/lib/cli/tui/inspect-app.mjs +306 -0
- package/lib/cli/tui/inspect-artifact-adapter.mjs +3 -0
- package/lib/cli/tui/inspect-live-adapter.mjs +15 -0
- package/lib/cli/tui/inspect-model.mjs +817 -0
- package/lib/cli/tui/inspect-state.mjs +321 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +6 -6
- package/lib/cli/commands/artifacts.mjs +0 -45
- package/lib/cli/commands/investigate.mjs +0 -87
- package/lib/cli/commands/logs.mjs +0 -47
- package/lib/cli/commands/show.mjs +0 -47
- package/lib/cli/commands/watch.mjs +0 -23
- package/lib/cli/tui/run-app.mjs +0 -1
- package/lib/cli/tui/run-session-app.mjs +0 -432
- package/lib/cli/tui/run-session-state.mjs +0 -505
- package/lib/cli/tui/run-tree-state.mjs +0 -1
- package/lib/cli/tui/watch-app.mjs +0 -220
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { createElement } from "react";
|
|
2
|
+
import { Command, Flags } from "@oclif/core";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { sharedFlags, resolveConfigsForCommand } from "../command-helpers.mjs";
|
|
5
|
+
import { createAssistantState } from "../assistant/state.mjs";
|
|
6
|
+
import { loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
7
|
+
|
|
8
|
+
export default class AssistantCommand extends Command {
|
|
9
|
+
static summary = "Launch the interactive testkit assistant";
|
|
10
|
+
|
|
11
|
+
static enableJsonFlag = true;
|
|
12
|
+
|
|
13
|
+
static flags = {
|
|
14
|
+
...sharedFlags,
|
|
15
|
+
provider: Flags.string({
|
|
16
|
+
description: "Assistant provider",
|
|
17
|
+
options: ["auto", "claude", "codex"],
|
|
18
|
+
default: "auto",
|
|
19
|
+
}),
|
|
20
|
+
pane: Flags.string({
|
|
21
|
+
description: "Initial workbench pane",
|
|
22
|
+
options: ["detail", "artifacts", "logs", "setup"],
|
|
23
|
+
default: "detail",
|
|
24
|
+
}),
|
|
25
|
+
file: Flags.string({
|
|
26
|
+
description: "Initial file selection",
|
|
27
|
+
}),
|
|
28
|
+
message: Flags.string({
|
|
29
|
+
description: "Run one assistant turn non-interactively",
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async run() {
|
|
34
|
+
const { flags } = await this.parse(AssistantCommand);
|
|
35
|
+
const { allConfigs } = await resolveConfigsForCommand(flags);
|
|
36
|
+
const productDir = allConfigs[0]?.productDir || process.cwd();
|
|
37
|
+
const assistantState = createAssistantState({
|
|
38
|
+
productDir,
|
|
39
|
+
provider: flags.provider,
|
|
40
|
+
initialPane: flags.pane,
|
|
41
|
+
configs: allConfigs,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await assistantState.loadLatestArtifact();
|
|
45
|
+
if (flags.file) {
|
|
46
|
+
try {
|
|
47
|
+
const artifact = loadLatestRunArtifact(productDir);
|
|
48
|
+
const subject = resolveFileSubject(artifact, flags.file, flags.service || null);
|
|
49
|
+
assistantState.revealFile(subject.service.name, subject.file.path);
|
|
50
|
+
} catch {
|
|
51
|
+
// Ignore missing initial selection.
|
|
52
|
+
}
|
|
53
|
+
} else if (flags.service) {
|
|
54
|
+
assistantState.revealService(flags.service);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const interactive = process.stdout.isTTY && !this.jsonEnabled() && !flags.message;
|
|
58
|
+
if (!interactive) {
|
|
59
|
+
if (flags.message) {
|
|
60
|
+
await assistantState.submitInput(flags.message);
|
|
61
|
+
}
|
|
62
|
+
const snapshot = assistantState.getSnapshot();
|
|
63
|
+
if (!this.jsonEnabled()) {
|
|
64
|
+
for (const message of snapshot.messages) {
|
|
65
|
+
this.log(`${message.role}: ${message.text}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return snapshot;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { AssistantApp } = await import("../tui/assistant-app.mjs");
|
|
72
|
+
const app = render(
|
|
73
|
+
createElement(AssistantApp, {
|
|
74
|
+
assistantState,
|
|
75
|
+
stdout: process.stdout,
|
|
76
|
+
productDir,
|
|
77
|
+
}),
|
|
78
|
+
{ stdout: process.stdout, exitOnCtrlC: false }
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await app.waitUntilExit();
|
|
82
|
+
return assistantState.getSnapshot();
|
|
83
|
+
}
|
|
84
|
+
}
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
export function normalizeCliArgs(argv) {
|
|
2
2
|
const topLevelCommands = new Set([
|
|
3
|
+
"assistant",
|
|
3
4
|
"run",
|
|
4
5
|
"status",
|
|
5
6
|
"destroy",
|
|
6
7
|
"cleanup",
|
|
7
|
-
"show",
|
|
8
|
-
"logs",
|
|
9
|
-
"artifacts",
|
|
10
|
-
"watch",
|
|
11
8
|
"discover",
|
|
12
9
|
"typecheck",
|
|
13
10
|
"doctor",
|
|
14
|
-
"investigate",
|
|
15
11
|
"browser",
|
|
16
12
|
"db",
|
|
17
13
|
"help",
|
|
@@ -40,22 +36,52 @@ export function normalizeCliArgs(argv) {
|
|
|
40
36
|
"--log-tail",
|
|
41
37
|
"--provider",
|
|
42
38
|
"--message",
|
|
39
|
+
"--pane",
|
|
43
40
|
]);
|
|
44
41
|
const positionals = findPositionals(argv, valueFlags);
|
|
45
42
|
const firstPositional = positionals[0] || null;
|
|
43
|
+
const interactiveTty = process.stdout.isTTY;
|
|
44
|
+
const runFlagPresent = argv.some((value) =>
|
|
45
|
+
[
|
|
46
|
+
"--type",
|
|
47
|
+
"-t",
|
|
48
|
+
"--suite",
|
|
49
|
+
"-s",
|
|
50
|
+
"--file",
|
|
51
|
+
"-f",
|
|
52
|
+
"--workers",
|
|
53
|
+
"--file-timeout-seconds",
|
|
54
|
+
"--shard",
|
|
55
|
+
"--seed",
|
|
56
|
+
"--write-status",
|
|
57
|
+
"--allow-partial-status",
|
|
58
|
+
"--ignore-skip-rules",
|
|
59
|
+
"--output-mode",
|
|
60
|
+
"--debug",
|
|
61
|
+
].includes(value)
|
|
62
|
+
);
|
|
46
63
|
const shouldPrefixRun =
|
|
47
|
-
!firstPositional ||
|
|
48
|
-
runTypeShortcuts.has(firstPositional
|
|
49
|
-
|
|
64
|
+
(!firstPositional && !interactiveTty) ||
|
|
65
|
+
runTypeShortcuts.has(firstPositional?.value) ||
|
|
66
|
+
runFlagPresent ||
|
|
67
|
+
!topLevelCommands.has(firstPositional?.value);
|
|
50
68
|
|
|
51
|
-
if (
|
|
52
|
-
return ["
|
|
69
|
+
if (!firstPositional && interactiveTty && !runFlagPresent) {
|
|
70
|
+
return ["assistant", ...argv];
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
if (topLevelCommands.has(firstPositional
|
|
73
|
+
if (!shouldPrefixRun && topLevelCommands.has(firstPositional?.value) && argv[0] !== firstPositional.value) {
|
|
56
74
|
return reorderCommandArgs(argv, positionals);
|
|
57
75
|
}
|
|
58
76
|
|
|
77
|
+
if (!topLevelCommands.has(firstPositional?.value) && interactiveTty && !runFlagPresent) {
|
|
78
|
+
return ["assistant", "--message", argv.join(" ")];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (shouldPrefixRun) {
|
|
82
|
+
return ["run", ...argv];
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
return argv;
|
|
60
86
|
}
|
|
61
87
|
|
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import React, { createElement } from "react";
|
|
2
2
|
import { render } from "ink";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { createInspectState } from "../tui/inspect-state.mjs";
|
|
4
|
+
import { InspectApp } from "../tui/inspect-app.mjs";
|
|
5
|
+
import {
|
|
6
|
+
applyReporterPlans,
|
|
7
|
+
applyReporterRunSummary,
|
|
8
|
+
applyReporterTaskFinished,
|
|
9
|
+
applyReporterTaskStarted,
|
|
10
|
+
} from "../tui/inspect-live-adapter.mjs";
|
|
5
11
|
import { suiteSelectionType } from "../../runner/suite-selection.mjs";
|
|
6
12
|
import { startHostedInvestigation } from "../agents/investigate.mjs";
|
|
7
13
|
import { createInvestigationInterpreter } from "../agents/investigation-interpreter.mjs";
|
|
8
14
|
import { writeInvestigationLog } from "../agents/investigation-log.mjs";
|
|
9
15
|
|
|
10
16
|
export function createTreeReporter({ stdout = process.stdout, stderr = process.stderr, productDir } = {}) {
|
|
11
|
-
const
|
|
17
|
+
const inspectState = createInspectState({ dataSource: "live" });
|
|
12
18
|
let activeAgentSession = null;
|
|
13
19
|
let activeInterpreter = null;
|
|
14
20
|
let investigationToken = 0;
|
|
15
21
|
|
|
16
22
|
const app = render(
|
|
17
|
-
createElement(
|
|
18
|
-
|
|
23
|
+
createElement(InspectApp, {
|
|
24
|
+
inspectState,
|
|
19
25
|
stdout,
|
|
20
26
|
productDir,
|
|
21
27
|
onInvestigate: startInvestigation,
|
|
@@ -31,36 +37,36 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
31
37
|
outputMode: "compact",
|
|
32
38
|
|
|
33
39
|
setServicePlans(plans) {
|
|
34
|
-
|
|
40
|
+
applyReporterPlans(inspectState, plans);
|
|
35
41
|
},
|
|
36
42
|
|
|
37
43
|
setTotalFileCount(count) {
|
|
38
|
-
|
|
44
|
+
inspectState.setTotalFileCount(count);
|
|
39
45
|
},
|
|
40
46
|
|
|
41
47
|
setRegressionCatalog(document) {
|
|
42
|
-
|
|
48
|
+
inspectState.setRegressionCatalog(document);
|
|
43
49
|
},
|
|
44
50
|
|
|
45
51
|
serviceSkipped(config, reason) {
|
|
46
|
-
|
|
52
|
+
inspectState.markServiceSkipped(config.name, reason);
|
|
47
53
|
},
|
|
48
54
|
|
|
49
55
|
plannedSkip(entry) {
|
|
50
|
-
|
|
56
|
+
inspectState.markPlannedSkip(entry);
|
|
51
57
|
},
|
|
52
58
|
|
|
53
59
|
taskStarted(task, _config) {
|
|
54
60
|
const suiteKey = `${task.displayType || suiteSelectionType(task.type, task.framework)}:${task.suiteName}`;
|
|
55
|
-
|
|
61
|
+
applyReporterTaskStarted(inspectState, task, suiteKey);
|
|
56
62
|
},
|
|
57
63
|
|
|
58
64
|
taskFinished(task, outcome) {
|
|
59
|
-
|
|
65
|
+
applyReporterTaskFinished(inspectState, task, outcome);
|
|
60
66
|
},
|
|
61
67
|
|
|
62
68
|
runtimeError(task, message) {
|
|
63
|
-
|
|
69
|
+
inspectState.markRuntimeError(task, message);
|
|
64
70
|
},
|
|
65
71
|
|
|
66
72
|
setupOperationFinished(_operation) {
|
|
@@ -68,7 +74,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
68
74
|
},
|
|
69
75
|
|
|
70
76
|
phaseStarted(label) {
|
|
71
|
-
|
|
77
|
+
inspectState.setPhase(label);
|
|
72
78
|
},
|
|
73
79
|
|
|
74
80
|
toolchainResolved() {},
|
|
@@ -78,7 +84,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
78
84
|
telemetry() {},
|
|
79
85
|
|
|
80
86
|
runSummary(results, durationMs, regressionReport) {
|
|
81
|
-
|
|
87
|
+
applyReporterRunSummary(inspectState, results, durationMs, regressionReport);
|
|
82
88
|
},
|
|
83
89
|
|
|
84
90
|
error(message) {
|
|
@@ -93,19 +99,19 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
93
99
|
};
|
|
94
100
|
|
|
95
101
|
async function startInvestigation({ provider = "auto", userMessage } = {}) {
|
|
96
|
-
const snapshot =
|
|
102
|
+
const snapshot = inspectState.getSnapshot();
|
|
97
103
|
if (!snapshot.selectedFailure) {
|
|
98
|
-
|
|
104
|
+
inspectState.setNotice("No failed file is selected for investigation.");
|
|
99
105
|
return;
|
|
100
106
|
}
|
|
101
107
|
if (activeAgentSession) {
|
|
102
|
-
|
|
108
|
+
inspectState.setNotice("An investigation is already running.");
|
|
103
109
|
return;
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
const token = ++investigationToken;
|
|
107
113
|
let finalDelivered = false;
|
|
108
|
-
|
|
114
|
+
inspectState.beginInvestigation({ provider, userMessage });
|
|
109
115
|
|
|
110
116
|
try {
|
|
111
117
|
activeInterpreter = createInvestigationInterpreter();
|
|
@@ -119,7 +125,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
119
125
|
if (token !== investigationToken) return;
|
|
120
126
|
if (event.type === "final") finalDelivered = true;
|
|
121
127
|
const presentation = activeInterpreter?.consumeProviderEvent(event) || null;
|
|
122
|
-
|
|
128
|
+
inspectState.recordInvestigationProgress(event, presentation);
|
|
123
129
|
},
|
|
124
130
|
});
|
|
125
131
|
const result = await activeAgentSession.completion;
|
|
@@ -128,21 +134,21 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
128
134
|
if (result.finalText && !finalDelivered) {
|
|
129
135
|
const finalEvent = { type: "final", text: result.finalText };
|
|
130
136
|
const presentation = activeInterpreter?.consumeProviderEvent(finalEvent) || null;
|
|
131
|
-
|
|
137
|
+
inspectState.recordInvestigationProgress(finalEvent, presentation);
|
|
132
138
|
}
|
|
133
139
|
if (result.cancelled) {
|
|
134
|
-
|
|
140
|
+
inspectState.cancelAgentSession("Cancelled investigation.");
|
|
135
141
|
persistInvestigationLog();
|
|
136
142
|
activeInterpreter = null;
|
|
137
143
|
return;
|
|
138
144
|
}
|
|
139
145
|
if (result.exitCode !== 0 && !result.finalText) {
|
|
140
|
-
|
|
146
|
+
inspectState.failAgentSession(result.stderr || `Agent exited with code ${result.exitCode}`);
|
|
141
147
|
persistInvestigationLog();
|
|
142
148
|
activeInterpreter = null;
|
|
143
149
|
return;
|
|
144
150
|
}
|
|
145
|
-
|
|
151
|
+
inspectState.completeAgentSession({
|
|
146
152
|
finalText: result.finalText,
|
|
147
153
|
exitCode: result.exitCode,
|
|
148
154
|
});
|
|
@@ -151,7 +157,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
151
157
|
} catch (error) {
|
|
152
158
|
if (token !== investigationToken) return;
|
|
153
159
|
activeAgentSession = null;
|
|
154
|
-
|
|
160
|
+
inspectState.failAgentSession(error);
|
|
155
161
|
persistInvestigationLog();
|
|
156
162
|
activeInterpreter = null;
|
|
157
163
|
}
|
|
@@ -159,13 +165,13 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
159
165
|
|
|
160
166
|
function cancelInvestigation() {
|
|
161
167
|
if (!activeAgentSession) {
|
|
162
|
-
|
|
168
|
+
inspectState.returnToSummary();
|
|
163
169
|
return;
|
|
164
170
|
}
|
|
165
171
|
investigationToken += 1;
|
|
166
172
|
activeAgentSession.cancel();
|
|
167
173
|
activeAgentSession = null;
|
|
168
|
-
|
|
174
|
+
inspectState.cancelAgentSession("Cancelled investigation.");
|
|
169
175
|
persistInvestigationLog();
|
|
170
176
|
activeInterpreter = null;
|
|
171
177
|
}
|
|
@@ -180,7 +186,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
180
186
|
}
|
|
181
187
|
|
|
182
188
|
function persistInvestigationLog() {
|
|
183
|
-
const snapshot =
|
|
189
|
+
const snapshot = inspectState.getSnapshot();
|
|
184
190
|
if (!snapshot.selectedFailure || !snapshot.agentSession) return;
|
|
185
191
|
writeInvestigationLog({
|
|
186
192
|
productDir,
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { createElement, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Box, Text, useAnimation, useApp, useInput } from "ink";
|
|
3
|
+
import { bold, dim, yellow } from "../presentation/colors.mjs";
|
|
4
|
+
import { getTerminalWidth } from "../presentation/terminal-layout.mjs";
|
|
5
|
+
import { buildInspectPaneContent } from "./detail-pane.mjs";
|
|
6
|
+
|
|
7
|
+
const SPINNER_FRAMES = ["|", "/", "-", "\\"];
|
|
8
|
+
|
|
9
|
+
export function AssistantApp({ assistantState, stdout, productDir } = {}) {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [snapshot, setSnapshot] = useState(() => assistantState.getSnapshot());
|
|
12
|
+
const { frame } = useAnimation({ interval: 80, isActive: snapshot.busy });
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const unsubscribe = assistantState.subscribe(() => {
|
|
16
|
+
setSnapshot(assistantState.getSnapshot());
|
|
17
|
+
});
|
|
18
|
+
return unsubscribe;
|
|
19
|
+
}, [assistantState]);
|
|
20
|
+
|
|
21
|
+
useInput((input, key) => {
|
|
22
|
+
if (key.ctrl && input === "c") {
|
|
23
|
+
exit();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (input === "q" && !snapshot.composer) {
|
|
27
|
+
exit();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (key.return) {
|
|
31
|
+
void assistantState.submitCurrentComposer();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (key.backspace || key.delete) {
|
|
35
|
+
assistantState.backspaceComposer();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (key.tab) {
|
|
39
|
+
const panes = ["detail", "artifacts", "logs", "setup"];
|
|
40
|
+
const index = panes.indexOf(snapshot.workbench.paneMode || "detail");
|
|
41
|
+
assistantState.setPaneMode(panes[(index + 1) % panes.length]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (isPrintableInput(input, key)) {
|
|
45
|
+
assistantState.appendComposer(input);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const terminalWidth = getTerminalWidth(stdout, 100);
|
|
50
|
+
const transcriptWidth = Math.max(54, Math.floor(terminalWidth * 0.58));
|
|
51
|
+
const contextWidth = Math.max(26, terminalWidth - transcriptWidth - 1);
|
|
52
|
+
const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
53
|
+
const contextPane = useMemo(
|
|
54
|
+
() =>
|
|
55
|
+
buildInspectPaneContent({
|
|
56
|
+
productDir,
|
|
57
|
+
snapshot: snapshot.workbench,
|
|
58
|
+
paneMode: snapshot.workbench.paneMode,
|
|
59
|
+
logTail: 12,
|
|
60
|
+
}),
|
|
61
|
+
[productDir, snapshot.workbench]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return createElement(
|
|
65
|
+
Box,
|
|
66
|
+
{ flexDirection: "column" },
|
|
67
|
+
createElement(Text, null, dim(buildHeader(snapshot, spinner))),
|
|
68
|
+
snapshot.notice ? createElement(Text, null, yellow(snapshot.notice)) : null,
|
|
69
|
+
createElement(
|
|
70
|
+
Box,
|
|
71
|
+
{ flexDirection: "row", marginTop: 1 },
|
|
72
|
+
createElement(
|
|
73
|
+
Box,
|
|
74
|
+
{ width: transcriptWidth, flexDirection: "column", paddingRight: 1 },
|
|
75
|
+
...buildTranscriptLines(snapshot).map((line, index) => createElement(Text, { key: `line-${index}` }, line))
|
|
76
|
+
),
|
|
77
|
+
createElement(
|
|
78
|
+
Box,
|
|
79
|
+
{ width: contextWidth, flexDirection: "column", paddingLeft: 1 },
|
|
80
|
+
createElement(Text, null, bold(contextPane.title)),
|
|
81
|
+
createElement(Text, null, ""),
|
|
82
|
+
...contextPane.lines.slice(0, 34).map((line, index) => createElement(Text, { key: `pane-${index}` }, line))
|
|
83
|
+
)
|
|
84
|
+
),
|
|
85
|
+
createElement(Text, null, ""),
|
|
86
|
+
createElement(Text, null, dim("Enter send · Tab cycle pane · q quit")),
|
|
87
|
+
createElement(Text, null, bold("> "), snapshot.composer || dim("Ask testkit or use /run, /file, /discover, /status"))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildHeader(snapshot, spinner) {
|
|
92
|
+
const workbench = snapshot.workbench;
|
|
93
|
+
const status = snapshot.busy ? `${spinner} ${snapshot.activeStatus || "working"}` : "ready";
|
|
94
|
+
const selection = workbench.selectedEntry?.label || workbench.selectedEntry?.filePath || "no selection";
|
|
95
|
+
return [`testkit assistant`, `provider ${snapshot.provider}`, status, selection].join(" · ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildTranscriptLines(snapshot) {
|
|
99
|
+
const lines = [];
|
|
100
|
+
for (const message of snapshot.messages.slice(-28)) {
|
|
101
|
+
const prefix = rolePrefix(message.role, message.toolName);
|
|
102
|
+
const messageLines = String(message.text || "").split(/\r?\n/);
|
|
103
|
+
for (const [index, line] of messageLines.entries()) {
|
|
104
|
+
lines.push(index === 0 ? `${prefix}${line}` : ` ${line}`);
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
if (snapshot.busy && snapshot.messages.length === 0) {
|
|
109
|
+
lines.push("assistant is starting...");
|
|
110
|
+
}
|
|
111
|
+
return lines.slice(-40);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rolePrefix(role, toolName) {
|
|
115
|
+
if (role === "user") return "you> ";
|
|
116
|
+
if (role === "assistant") return "tk> ";
|
|
117
|
+
if (role === "tool") return `${toolName || "tool"}> `;
|
|
118
|
+
return "sys> ";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isPrintableInput(input, key) {
|
|
122
|
+
return Boolean(
|
|
123
|
+
input &&
|
|
124
|
+
input.length === 1 &&
|
|
125
|
+
!key.ctrl &&
|
|
126
|
+
!key.meta &&
|
|
127
|
+
!key.return &&
|
|
128
|
+
!key.escape &&
|
|
129
|
+
!key.tab
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import {
|
|
3
|
+
collectArtifactEntries,
|
|
4
|
+
formatArtifactPreview,
|
|
5
|
+
formatFileDetail,
|
|
6
|
+
getServiceLogRefs,
|
|
7
|
+
getSetupOperationsForService,
|
|
8
|
+
loadCurrentRunArtifact,
|
|
9
|
+
resolveFileSubject,
|
|
10
|
+
} from "../viewer.mjs";
|
|
11
|
+
import { formatDuration } from "../../runner/formatting.mjs";
|
|
12
|
+
import { readLogTail } from "../../runner/logs.mjs";
|
|
13
|
+
|
|
14
|
+
export function buildInspectPaneContent({ productDir, snapshot, paneMode = "detail", logTail = 12 } = {}) {
|
|
15
|
+
const selectedEntry = snapshot.selectedEntry || null;
|
|
16
|
+
if (!selectedEntry) {
|
|
17
|
+
return { title: "Selection", lines: ["No entry selected."], data: null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const runArtifact = resolveArtifact(productDir, snapshot);
|
|
21
|
+
const subject = resolveSubject(runArtifact, selectedEntry);
|
|
22
|
+
|
|
23
|
+
if (paneMode === "artifacts") {
|
|
24
|
+
return buildArtifactsPane(productDir, runArtifact, selectedEntry, subject);
|
|
25
|
+
}
|
|
26
|
+
if (paneMode === "logs") {
|
|
27
|
+
return buildLogsPane(productDir, runArtifact, selectedEntry, logTail);
|
|
28
|
+
}
|
|
29
|
+
if (paneMode === "setup") {
|
|
30
|
+
return buildSetupPane(productDir, runArtifact, selectedEntry);
|
|
31
|
+
}
|
|
32
|
+
return buildDetailPane(productDir, runArtifact, selectedEntry, subject, logTail);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildDetailPane(productDir, runArtifact, entry, subject, logTail) {
|
|
36
|
+
if (subject && runArtifact) {
|
|
37
|
+
return {
|
|
38
|
+
title: "Detail",
|
|
39
|
+
lines: formatFileDetail(productDir, runArtifact, subject, { logTail }),
|
|
40
|
+
data: subject,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
title: "Detail",
|
|
46
|
+
lines: formatAggregateDetail(entry),
|
|
47
|
+
data: entry,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildArtifactsPane(productDir, runArtifact, entry, subject) {
|
|
52
|
+
if (!runArtifact || !subject) {
|
|
53
|
+
return {
|
|
54
|
+
title: "Artifacts",
|
|
55
|
+
lines: ["Artifacts are available for file selections from a persisted run artifact."],
|
|
56
|
+
data: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const entries = collectArtifactEntries(productDir, runArtifact, subject.file.path, subject.service.name).map((item) => ({
|
|
61
|
+
service: item.service.name,
|
|
62
|
+
suite: `${item.suite.type}:${item.suite.name}`,
|
|
63
|
+
file: item.file.path,
|
|
64
|
+
name: item.artifactRef.name,
|
|
65
|
+
kind: item.artifactRef.kind,
|
|
66
|
+
summary: item.artifactRef.summary,
|
|
67
|
+
path: item.artifactRef.path,
|
|
68
|
+
preview: formatArtifactPreview(item.payload, 6),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
if (entries.length === 0) {
|
|
72
|
+
return { title: "Artifacts", lines: ["No artifacts were persisted for the selected file."], data: [] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lines = [];
|
|
76
|
+
for (const item of entries) {
|
|
77
|
+
lines.push(`${item.name}${item.kind ? ` [${item.kind}]` : ""}`);
|
|
78
|
+
if (item.summary) lines.push(` ${item.summary}`);
|
|
79
|
+
for (const line of item.preview) lines.push(` ${line}`);
|
|
80
|
+
lines.push(` ${item.path}`);
|
|
81
|
+
}
|
|
82
|
+
return { title: "Artifacts", lines, data: entries };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildLogsPane(productDir, runArtifact, entry, tail) {
|
|
86
|
+
if (!runArtifact) {
|
|
87
|
+
return { title: "Logs", lines: ["Backend logs are available only from persisted run artifacts."], data: [] };
|
|
88
|
+
}
|
|
89
|
+
const serviceName = entry.serviceName;
|
|
90
|
+
const logs = getServiceLogRefs(runArtifact, serviceName).map((item) => ({
|
|
91
|
+
...item,
|
|
92
|
+
lines: readLogTail(path.join(productDir, item.path), tail),
|
|
93
|
+
}));
|
|
94
|
+
if (logs.length === 0) {
|
|
95
|
+
return { title: "Logs", lines: ["No backend logs were recorded for the selected service."], data: [] };
|
|
96
|
+
}
|
|
97
|
+
const lines = [];
|
|
98
|
+
for (const item of logs) {
|
|
99
|
+
lines.push(item.runtimeLabel);
|
|
100
|
+
lines.push(` ${item.path}`);
|
|
101
|
+
for (const line of item.lines) lines.push(` ${line}`);
|
|
102
|
+
}
|
|
103
|
+
return { title: "Logs", lines, data: logs };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildSetupPane(productDir, runArtifact, entry) {
|
|
107
|
+
if (!runArtifact) {
|
|
108
|
+
return { title: "Setup", lines: ["Setup operations are available only from persisted run artifacts."], data: [] };
|
|
109
|
+
}
|
|
110
|
+
const operations = getSetupOperationsForService(runArtifact, entry.serviceName);
|
|
111
|
+
if (operations.length === 0) {
|
|
112
|
+
return { title: "Setup", lines: ["No setup operations were recorded for the selected service."], data: [] };
|
|
113
|
+
}
|
|
114
|
+
const lines = [];
|
|
115
|
+
for (const operation of operations) {
|
|
116
|
+
const duration = operation.durationMs == null ? "" : ` ${formatDuration(operation.durationMs)}`;
|
|
117
|
+
const summary = operation.summary ? ` ${operation.summary}` : "";
|
|
118
|
+
lines.push(`${operation.status} ${operation.stage}${duration}${summary}`);
|
|
119
|
+
if (operation.error) lines.push(` ${operation.error}`);
|
|
120
|
+
if (operation.logRef?.path) lines.push(` ${operation.logRef.path}`);
|
|
121
|
+
}
|
|
122
|
+
return { title: "Setup", lines, data: operations };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveArtifact(productDir, snapshot) {
|
|
126
|
+
if (snapshot.runArtifact) return snapshot.runArtifact;
|
|
127
|
+
try {
|
|
128
|
+
return loadCurrentRunArtifact(productDir);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveSubject(runArtifact, entry) {
|
|
135
|
+
if (!runArtifact || !entry || entry.kind !== "file") return null;
|
|
136
|
+
try {
|
|
137
|
+
return resolveFileSubject(runArtifact, entry.filePath, entry.serviceName);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatAggregateDetail(entry) {
|
|
144
|
+
const lines = [`Kind: ${entry.kind}`];
|
|
145
|
+
if (entry.serviceName) lines.push(`Service: ${entry.serviceName}`);
|
|
146
|
+
if (entry.type) lines.push(`Type: ${entry.type}`);
|
|
147
|
+
if (entry.suiteName) lines.push(`Suite: ${entry.suiteName}`);
|
|
148
|
+
if (entry.filePath) lines.push(`File: ${entry.filePath}`);
|
|
149
|
+
if (entry.status) lines.push(`Status: ${entry.status}`);
|
|
150
|
+
if (entry.summary) {
|
|
151
|
+
lines.push(
|
|
152
|
+
`Files: ${entry.summary.total} total · ${entry.summary.passed} passed · ${entry.summary.failed} failed · ${entry.summary.skipped} skipped · ${entry.summary.notRun} not run`
|
|
153
|
+
);
|
|
154
|
+
if (entry.summary.durationMs > 0) {
|
|
155
|
+
lines.push(`Duration: ${formatDuration(entry.summary.durationMs)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (entry.skipReason) lines.push(`Skip reason: ${entry.skipReason}`);
|
|
159
|
+
if (entry.error) lines.push(`Error: ${entry.error}`);
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React, { createElement } from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { bold, dim } from "../presentation/colors.mjs";
|
|
4
|
+
|
|
5
|
+
export function FilterBar({ filter } = {}) {
|
|
6
|
+
if (!filter?.active) return null;
|
|
7
|
+
return createElement(
|
|
8
|
+
Text,
|
|
9
|
+
null,
|
|
10
|
+
`${bold("/")}${filter.query}${dim(` ${filter.count} ${filter.count === 1 ? "match" : "matches"}`)}`
|
|
11
|
+
);
|
|
12
|
+
}
|