@elench/testkit 0.1.89 → 0.1.90
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 +16 -7
- package/lib/cli/agents/index.mjs +48 -9
- package/lib/cli/agents/providers/claude.mjs +3 -3
- package/lib/cli/agents/providers/codex.mjs +3 -3
- package/lib/cli/assistant/bootstrap.mjs +248 -0
- package/lib/cli/assistant/interactive.mjs +52 -0
- package/lib/cli/assistant/prompt-builder.mjs +3 -7
- package/lib/cli/assistant/session.mjs +3 -1
- package/lib/cli/assistant/state.mjs +4 -2
- package/lib/cli/assistant/tool-registry.mjs +17 -17
- package/lib/cli/commands/assistant.mjs +44 -34
- package/lib/cli/{tui/detail-pane.mjs → context-resources.mjs} +81 -21
- package/lib/cli/entrypoint.mjs +12 -4
- package/lib/cli/presentation/tree-reporter.mjs +0 -101
- package/lib/cli/tui/inspect-app.mjs +7 -88
- package/lib/cli/tui/inspect-state.mjs +0 -117
- 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 +5 -5
- package/lib/cli/agents/investigate.mjs +0 -75
- package/lib/cli/agents/investigation-context.mjs +0 -102
- package/lib/cli/agents/investigation-interpreter.mjs +0 -320
- package/lib/cli/agents/investigation-log.mjs +0 -37
- package/lib/cli/agents/prompt-builder.mjs +0 -25
- package/lib/cli/assistant/content.mjs +0 -60
- package/lib/cli/tui/assistant-app.mjs +0 -82
- package/lib/cli/tui/assistant-render.mjs +0 -99
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
const INSPECT_COMMANDS = new Set([
|
|
2
|
-
"cat",
|
|
3
|
-
"find",
|
|
4
|
-
"grep",
|
|
5
|
-
"head",
|
|
6
|
-
"ls",
|
|
7
|
-
"open",
|
|
8
|
-
"read",
|
|
9
|
-
"rg",
|
|
10
|
-
"sed",
|
|
11
|
-
"tail",
|
|
12
|
-
]);
|
|
13
|
-
|
|
14
|
-
const EDIT_COMMANDS = new Set([
|
|
15
|
-
"apply_patch",
|
|
16
|
-
"cp",
|
|
17
|
-
"ed",
|
|
18
|
-
"mv",
|
|
19
|
-
"perl",
|
|
20
|
-
"python",
|
|
21
|
-
"python3",
|
|
22
|
-
"ruby",
|
|
23
|
-
"tee",
|
|
24
|
-
"write",
|
|
25
|
-
]);
|
|
26
|
-
|
|
27
|
-
const RERUN_COMMANDS = new Set([
|
|
28
|
-
"npm",
|
|
29
|
-
"npx",
|
|
30
|
-
"pnpm",
|
|
31
|
-
"pytest",
|
|
32
|
-
"testkit",
|
|
33
|
-
"vitest",
|
|
34
|
-
"yarn",
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
const PHASE_LABELS = {
|
|
38
|
-
planning: "Planning investigation",
|
|
39
|
-
inspecting: "Inspecting relevant files",
|
|
40
|
-
editing: "Editing candidate fix",
|
|
41
|
-
rerunning: "Rerunning validation",
|
|
42
|
-
summarizing: "Summarizing outcome",
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export function createInvestigationInterpreter() {
|
|
46
|
-
let phase = "planning";
|
|
47
|
-
let timeline = [];
|
|
48
|
-
let activeStepId = null;
|
|
49
|
-
let nextId = 1;
|
|
50
|
-
let inspectedCount = 0;
|
|
51
|
-
let rerunCount = 0;
|
|
52
|
-
const editedFiles = new Set();
|
|
53
|
-
|
|
54
|
-
pushTimeline({
|
|
55
|
-
kind: "phase",
|
|
56
|
-
phase,
|
|
57
|
-
label: PHASE_LABELS[phase],
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
consumeProviderEvent(event) {
|
|
62
|
-
if (!event || !event.type) return getSnapshot();
|
|
63
|
-
|
|
64
|
-
if (event.type === "start") {
|
|
65
|
-
setPhase("planning");
|
|
66
|
-
activateStep("planning", "Preparing investigation", "active");
|
|
67
|
-
return getSnapshot();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (event.type === "tool") {
|
|
71
|
-
handleToolEvent(event);
|
|
72
|
-
return getSnapshot();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (event.type === "status") {
|
|
76
|
-
handleStatusEvent(event);
|
|
77
|
-
return getSnapshot();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (event.type === "error") {
|
|
81
|
-
failActiveStep();
|
|
82
|
-
pushTimeline({
|
|
83
|
-
kind: "notice",
|
|
84
|
-
severity: "error",
|
|
85
|
-
message: event.message || "Agent error",
|
|
86
|
-
});
|
|
87
|
-
return getSnapshot();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (event.type === "final") {
|
|
91
|
-
finalizeWithText(event.text || "");
|
|
92
|
-
return getSnapshot();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (event.type === "exit" && Number(event.code) !== 0) {
|
|
96
|
-
failActiveStep();
|
|
97
|
-
pushTimeline({
|
|
98
|
-
kind: "notice",
|
|
99
|
-
severity: "error",
|
|
100
|
-
message: `Agent exited with code ${event.code}`,
|
|
101
|
-
});
|
|
102
|
-
return getSnapshot();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return getSnapshot();
|
|
106
|
-
},
|
|
107
|
-
|
|
108
|
-
getSnapshot,
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
function getSnapshot() {
|
|
112
|
-
return {
|
|
113
|
-
phase,
|
|
114
|
-
activeStep: activeStepId ? findTimeline(activeStepId) : null,
|
|
115
|
-
timeline: timeline.map((entry) => ({
|
|
116
|
-
...entry,
|
|
117
|
-
files: Array.isArray(entry.files) ? [...entry.files] : entry.files,
|
|
118
|
-
})),
|
|
119
|
-
summary: {
|
|
120
|
-
inspectedCount,
|
|
121
|
-
rerunCount,
|
|
122
|
-
editedFiles: [...editedFiles].sort(),
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function handleToolEvent(event) {
|
|
128
|
-
const commandText = String(event.name || "").trim();
|
|
129
|
-
const detailText = String(event.detail || "").trim();
|
|
130
|
-
const command = firstToken(commandText);
|
|
131
|
-
|
|
132
|
-
if (RERUN_COMMANDS.has(command) || /(?:^|\s)(testkit|vitest|pytest|npm|pnpm|yarn)(?:\s|$)/i.test(commandText)) {
|
|
133
|
-
setPhase("rerunning");
|
|
134
|
-
rerunCount += 1;
|
|
135
|
-
activateStep("rerunning", "Rerunning validation", "active", compactDetail(commandText, detailText));
|
|
136
|
-
pushTimeline({
|
|
137
|
-
kind: "rerun",
|
|
138
|
-
summary: compactDetail(commandText, detailText) || commandText,
|
|
139
|
-
command: commandText || detailText || null,
|
|
140
|
-
});
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (EDIT_COMMANDS.has(command) || /apply_patch|write|edit/i.test(commandText)) {
|
|
145
|
-
setPhase("editing");
|
|
146
|
-
const files = extractFileHints(`${commandText} ${detailText}`);
|
|
147
|
-
for (const file of files) editedFiles.add(file);
|
|
148
|
-
activateStep(
|
|
149
|
-
"editing",
|
|
150
|
-
"Editing candidate fix",
|
|
151
|
-
"active",
|
|
152
|
-
files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} touched` : compactDetail(commandText, detailText)
|
|
153
|
-
);
|
|
154
|
-
if (files.length > 0) {
|
|
155
|
-
upsertTimeline(
|
|
156
|
-
"latest-edit",
|
|
157
|
-
{
|
|
158
|
-
kind: "edit",
|
|
159
|
-
summary: `Edited ${files.length} file${files.length === 1 ? "" : "s"}`,
|
|
160
|
-
files,
|
|
161
|
-
},
|
|
162
|
-
true
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (INSPECT_COMMANDS.has(command) || /git|diff|read|search|inspect|open/i.test(commandText)) {
|
|
169
|
-
setPhase("inspecting");
|
|
170
|
-
inspectedCount += 1;
|
|
171
|
-
activateStep(
|
|
172
|
-
"inspecting",
|
|
173
|
-
"Inspecting relevant files",
|
|
174
|
-
"active",
|
|
175
|
-
`${inspectedCount} check${inspectedCount === 1 ? "" : "s"}`
|
|
176
|
-
);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
setPhase("inspecting");
|
|
181
|
-
activateStep("inspecting", "Inspecting relevant files", "active", compactDetail(commandText, detailText));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function handleStatusEvent(event) {
|
|
185
|
-
const message = String(event.message || "").trim();
|
|
186
|
-
if (!message) return;
|
|
187
|
-
if (/error|failed|exception/i.test(message)) {
|
|
188
|
-
pushTimeline({
|
|
189
|
-
kind: "notice",
|
|
190
|
-
severity: "warning",
|
|
191
|
-
message,
|
|
192
|
-
});
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
if (/plan|thinking|analy/i.test(message) && phase === "planning") {
|
|
196
|
-
activateStep("planning", "Planning investigation", "active", "Gathering context");
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function finalizeWithText(text) {
|
|
201
|
-
setPhase("summarizing");
|
|
202
|
-
completeActiveStep();
|
|
203
|
-
const summary = summarizeFinalText(text);
|
|
204
|
-
if (summary) {
|
|
205
|
-
pushTimeline({
|
|
206
|
-
kind: "result",
|
|
207
|
-
summary,
|
|
208
|
-
finalText: text,
|
|
209
|
-
});
|
|
210
|
-
if (/root cause|likely|caused by|problem/i.test(summary)) {
|
|
211
|
-
pushTimeline({
|
|
212
|
-
kind: "finding",
|
|
213
|
-
severity: "info",
|
|
214
|
-
summary,
|
|
215
|
-
confidence: inferConfidence(summary),
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function setPhase(nextPhase) {
|
|
222
|
-
if (!nextPhase || nextPhase === phase) return;
|
|
223
|
-
completeActiveStep();
|
|
224
|
-
phase = nextPhase;
|
|
225
|
-
pushTimeline({
|
|
226
|
-
kind: "phase",
|
|
227
|
-
phase,
|
|
228
|
-
label: PHASE_LABELS[phase] || nextPhase,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function activateStep(key, label, status, detail = null) {
|
|
233
|
-
const stepId = `step:${key}`;
|
|
234
|
-
upsertTimeline(
|
|
235
|
-
stepId,
|
|
236
|
-
{
|
|
237
|
-
kind: "step",
|
|
238
|
-
label,
|
|
239
|
-
status,
|
|
240
|
-
detail,
|
|
241
|
-
},
|
|
242
|
-
false
|
|
243
|
-
);
|
|
244
|
-
activeStepId = stepId;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function completeActiveStep() {
|
|
248
|
-
if (!activeStepId) return;
|
|
249
|
-
const entry = findTimeline(activeStepId);
|
|
250
|
-
if (entry && entry.status === "active") entry.status = "done";
|
|
251
|
-
activeStepId = null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function failActiveStep() {
|
|
255
|
-
if (!activeStepId) return;
|
|
256
|
-
const entry = findTimeline(activeStepId);
|
|
257
|
-
if (entry) entry.status = "failed";
|
|
258
|
-
activeStepId = null;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function findTimeline(id) {
|
|
262
|
-
return timeline.find((entry) => entry.id === id) || null;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function pushTimeline(entry) {
|
|
266
|
-
timeline.push({
|
|
267
|
-
id: `timeline:${nextId += 1}`,
|
|
268
|
-
at: Date.now(),
|
|
269
|
-
...entry,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function upsertTimeline(id, entry, replaceFiles = false) {
|
|
274
|
-
const existing = findTimeline(id);
|
|
275
|
-
if (existing) {
|
|
276
|
-
Object.assign(existing, entry);
|
|
277
|
-
if (replaceFiles && Array.isArray(entry.files)) existing.files = [...entry.files];
|
|
278
|
-
return existing;
|
|
279
|
-
}
|
|
280
|
-
const nextEntry = {
|
|
281
|
-
id,
|
|
282
|
-
at: Date.now(),
|
|
283
|
-
...entry,
|
|
284
|
-
};
|
|
285
|
-
timeline.push(nextEntry);
|
|
286
|
-
return nextEntry;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function firstToken(commandText) {
|
|
291
|
-
const normalized = String(commandText || "").trim();
|
|
292
|
-
if (!normalized) return "";
|
|
293
|
-
return normalized.split(/\s+/)[0].toLowerCase();
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function compactDetail(commandText, detailText) {
|
|
297
|
-
if (commandText && detailText && detailText !== commandText) {
|
|
298
|
-
return `${commandText} (${detailText})`;
|
|
299
|
-
}
|
|
300
|
-
return commandText || detailText || null;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function summarizeFinalText(text) {
|
|
304
|
-
const normalized = String(text || "").trim();
|
|
305
|
-
if (!normalized) return "";
|
|
306
|
-
const [firstParagraph] = normalized.split(/\n\s*\n/);
|
|
307
|
-
const [firstSentence] = firstParagraph.split(/(?<=[.!?])\s+/);
|
|
308
|
-
return firstSentence.trim();
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function inferConfidence(summary) {
|
|
312
|
-
if (/definitely|confirmed|root cause/i.test(summary)) return "high";
|
|
313
|
-
if (/likely|probably|appears/i.test(summary)) return "medium";
|
|
314
|
-
return "low";
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function extractFileHints(text) {
|
|
318
|
-
const matches = String(text || "").match(/[A-Za-z0-9_./-]+\.(?:mjs|js|cjs|ts|tsx|jsx|json|md)/g) || [];
|
|
319
|
-
return [...new Set(matches)];
|
|
320
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
|
|
4
|
-
export function writeInvestigationLog({ productDir, selectedFailure, agentSession } = {}) {
|
|
5
|
-
if (!productDir || !selectedFailure || !agentSession) return null;
|
|
6
|
-
|
|
7
|
-
try {
|
|
8
|
-
const rootDir = path.join(productDir, ".testkit", "agent-sessions");
|
|
9
|
-
fs.mkdirSync(rootDir, { recursive: true });
|
|
10
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
11
|
-
const fileName = `${stamp}-${sanitize(selectedFailure.serviceName)}-${sanitize(path.basename(selectedFailure.filePath))}.json`;
|
|
12
|
-
const filePath = path.join(rootDir, fileName);
|
|
13
|
-
const payload = {
|
|
14
|
-
serviceName: selectedFailure.serviceName,
|
|
15
|
-
filePath: selectedFailure.filePath,
|
|
16
|
-
provider: agentSession.provider,
|
|
17
|
-
status: agentSession.status,
|
|
18
|
-
startedAt: agentSession.startedAt || null,
|
|
19
|
-
endedAt: agentSession.endedAt || null,
|
|
20
|
-
finalText: agentSession.finalText || "",
|
|
21
|
-
error: agentSession.error || null,
|
|
22
|
-
exitCode: agentSession.exitCode ?? null,
|
|
23
|
-
transcriptEntries: agentSession.transcriptEntries || [],
|
|
24
|
-
timeline: agentSession.timeline || [],
|
|
25
|
-
summary: agentSession.summary || null,
|
|
26
|
-
activePhase: agentSession.activePhase || null,
|
|
27
|
-
};
|
|
28
|
-
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
29
|
-
return filePath;
|
|
30
|
-
} catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function sanitize(value) {
|
|
36
|
-
return String(value || "unknown").replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
37
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { formatInvestigationTranscriptContext } from "./investigation-context.mjs";
|
|
2
|
-
|
|
3
|
-
const DEFAULT_INVESTIGATION_MESSAGE =
|
|
4
|
-
"Investigate the selected testkit failure. Explain the most likely root cause, cite concrete evidence from the provided context, and suggest the next debugging steps or code changes.";
|
|
5
|
-
|
|
6
|
-
export function defaultInvestigationMessage() {
|
|
7
|
-
return DEFAULT_INVESTIGATION_MESSAGE;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function buildInvestigationPrompt({ context, userMessage } = {}) {
|
|
11
|
-
const message = userMessage || DEFAULT_INVESTIGATION_MESSAGE;
|
|
12
|
-
const transcriptContext = formatInvestigationTranscriptContext(context);
|
|
13
|
-
|
|
14
|
-
return [
|
|
15
|
-
"You are investigating a failed testkit run.",
|
|
16
|
-
"Use the provided run context as the primary evidence.",
|
|
17
|
-
"If the evidence is incomplete, say what additional command or file you would inspect next.",
|
|
18
|
-
"Keep the response actionable and focused on root cause, evidence, and next steps.",
|
|
19
|
-
"",
|
|
20
|
-
`User request: ${message}`,
|
|
21
|
-
"",
|
|
22
|
-
"Run context:",
|
|
23
|
-
transcriptContext,
|
|
24
|
-
].join("\n");
|
|
25
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { buildInspectPaneContent } from "../tui/detail-pane.mjs";
|
|
2
|
-
|
|
3
|
-
export function readAssistantContent({
|
|
4
|
-
productDir,
|
|
5
|
-
inspectState,
|
|
6
|
-
mode = "detail",
|
|
7
|
-
logTail = 12,
|
|
8
|
-
} = {}) {
|
|
9
|
-
const snapshot = inspectState.getSnapshot();
|
|
10
|
-
const content = buildInspectPaneContent({
|
|
11
|
-
productDir,
|
|
12
|
-
snapshot,
|
|
13
|
-
paneMode: normalizeMode(mode),
|
|
14
|
-
logTail,
|
|
15
|
-
});
|
|
16
|
-
return {
|
|
17
|
-
mode: normalizeMode(mode),
|
|
18
|
-
title: content.title,
|
|
19
|
-
lines: content.lines || [],
|
|
20
|
-
data: content.data ?? null,
|
|
21
|
-
selection: snapshot.selectedEntry || null,
|
|
22
|
-
summaryRows: snapshot.summaryData?.rows || [],
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function buildAssistantContext(snapshot) {
|
|
27
|
-
const selectedEntry = snapshot?.selectedEntry || null;
|
|
28
|
-
const summaryRows = snapshot?.summaryData?.rows || [];
|
|
29
|
-
return {
|
|
30
|
-
selection: selectedEntry
|
|
31
|
-
? {
|
|
32
|
-
kind: selectedEntry.kind,
|
|
33
|
-
label: selectedEntry.label || selectedEntry.filePath || selectedEntry.serviceName || "selection",
|
|
34
|
-
serviceName: selectedEntry.serviceName || null,
|
|
35
|
-
type: selectedEntry.type || null,
|
|
36
|
-
suiteName: selectedEntry.suiteName || null,
|
|
37
|
-
filePath: selectedEntry.filePath || null,
|
|
38
|
-
status: selectedEntry.status || null,
|
|
39
|
-
}
|
|
40
|
-
: null,
|
|
41
|
-
summaryRows,
|
|
42
|
-
phase: snapshot?.phase || null,
|
|
43
|
-
hasArtifact: Boolean(snapshot?.runArtifact),
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function formatAssistantToolText(title, lines) {
|
|
48
|
-
const normalizedTitle = String(title || "").trim();
|
|
49
|
-
const normalizedLines = Array.isArray(lines) ? lines.filter((line) => String(line).length > 0) : [];
|
|
50
|
-
if (normalizedTitle && normalizedLines.length > 0) {
|
|
51
|
-
return [normalizedTitle, ...normalizedLines].join("\n");
|
|
52
|
-
}
|
|
53
|
-
if (normalizedTitle) return normalizedTitle;
|
|
54
|
-
return normalizedLines.join("\n");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function normalizeMode(mode) {
|
|
58
|
-
if (mode === "logs" || mode === "artifacts" || mode === "setup") return mode;
|
|
59
|
-
return "detail";
|
|
60
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import React, { createElement, useEffect, useState } from "react";
|
|
2
|
-
import { Box, Text, useAnimation, useApp, useInput } from "ink";
|
|
3
|
-
import { dim, yellow } from "../presentation/colors.mjs";
|
|
4
|
-
import {
|
|
5
|
-
buildAssistantComposerLines,
|
|
6
|
-
buildAssistantHeader,
|
|
7
|
-
buildAssistantTranscriptLines,
|
|
8
|
-
getAssistantLayout,
|
|
9
|
-
} from "./assistant-render.mjs";
|
|
10
|
-
|
|
11
|
-
const SPINNER_FRAMES = ["|", "/", "-", "\\"];
|
|
12
|
-
|
|
13
|
-
export function AssistantApp({ assistantState, stdout } = {}) {
|
|
14
|
-
const { exit } = useApp();
|
|
15
|
-
const [snapshot, setSnapshot] = useState(() => assistantState.getSnapshot());
|
|
16
|
-
const { frame } = useAnimation({ interval: 80, isActive: snapshot.busy });
|
|
17
|
-
|
|
18
|
-
useEffect(() => {
|
|
19
|
-
const unsubscribe = assistantState.subscribe(() => {
|
|
20
|
-
setSnapshot(assistantState.getSnapshot());
|
|
21
|
-
});
|
|
22
|
-
return unsubscribe;
|
|
23
|
-
}, [assistantState]);
|
|
24
|
-
|
|
25
|
-
useInput((input, key) => {
|
|
26
|
-
if (key.ctrl && input === "c") {
|
|
27
|
-
exit();
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
if (input === "q" && !snapshot.composer) {
|
|
31
|
-
exit();
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
if (key.return) {
|
|
35
|
-
void assistantState.submitCurrentComposer();
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
if (key.backspace || key.delete) {
|
|
39
|
-
assistantState.backspaceComposer();
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (key.escape) {
|
|
43
|
-
assistantState.setComposer("");
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
if (isPrintableInput(input, key)) {
|
|
47
|
-
assistantState.appendComposer(input);
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
52
|
-
const layout = getAssistantLayout(stdout);
|
|
53
|
-
const transcriptLines = buildAssistantTranscriptLines(snapshot, {
|
|
54
|
-
width: layout.width,
|
|
55
|
-
maxLines: layout.transcriptLines,
|
|
56
|
-
});
|
|
57
|
-
const composerLines = buildAssistantComposerLines(snapshot, { width: layout.width });
|
|
58
|
-
|
|
59
|
-
return createElement(
|
|
60
|
-
Box,
|
|
61
|
-
{ flexDirection: "column" },
|
|
62
|
-
createElement(Text, null, dim(buildAssistantHeader(snapshot, spinner))),
|
|
63
|
-
snapshot.notice ? createElement(Text, null, yellow(snapshot.notice)) : null,
|
|
64
|
-
createElement(Text, null, ""),
|
|
65
|
-
...transcriptLines.map((line, index) => createElement(Text, { key: `line-${index}` }, line)),
|
|
66
|
-
createElement(Text, null, ""),
|
|
67
|
-
...composerLines.map((line, index) => createElement(Text, { key: `composer-${index}` }, line)),
|
|
68
|
-
createElement(Text, null, dim("Enter send · Esc clear draft · q quit"))
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function isPrintableInput(input, key) {
|
|
73
|
-
return Boolean(
|
|
74
|
-
input &&
|
|
75
|
-
input.length === 1 &&
|
|
76
|
-
!key.ctrl &&
|
|
77
|
-
!key.meta &&
|
|
78
|
-
!key.return &&
|
|
79
|
-
!key.escape &&
|
|
80
|
-
!key.tab
|
|
81
|
-
);
|
|
82
|
-
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { bold, cyan, dim, yellow } from "../presentation/colors.mjs";
|
|
2
|
-
import {
|
|
3
|
-
getTerminalWidth,
|
|
4
|
-
measureWidth,
|
|
5
|
-
padEndVisible,
|
|
6
|
-
wrapText,
|
|
7
|
-
} from "../presentation/terminal-layout.mjs";
|
|
8
|
-
|
|
9
|
-
export function buildAssistantHeader(snapshot, spinner = "|") {
|
|
10
|
-
const status = snapshot.busy ? `${spinner} ${snapshot.activeStatus || "working"}` : "ready";
|
|
11
|
-
const focus = snapshot.context.selection?.filePath
|
|
12
|
-
|| snapshot.context.selection?.label
|
|
13
|
-
|| "no focus";
|
|
14
|
-
return [`testkit assistant`, `provider ${snapshot.provider}`, status, focus].join(" · ");
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function buildAssistantTranscriptLines(snapshot, { width = 100, maxLines = 40 } = {}) {
|
|
18
|
-
const blocks = [];
|
|
19
|
-
for (const message of snapshot.messages) {
|
|
20
|
-
blocks.push(...formatAssistantMessageBlock(message, width));
|
|
21
|
-
blocks.push("");
|
|
22
|
-
}
|
|
23
|
-
if (snapshot.busy) {
|
|
24
|
-
blocks.push(dim(`${snapshot.activeStatus || "Working"}...`));
|
|
25
|
-
}
|
|
26
|
-
return trimTranscript(blocks, maxLines);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function buildAssistantComposerLines(snapshot, { width = 100 } = {}) {
|
|
30
|
-
const boxWidth = Math.max(24, width);
|
|
31
|
-
const innerWidth = Math.max(18, boxWidth - 4);
|
|
32
|
-
const draft = snapshot.composer || "";
|
|
33
|
-
const label = "Message";
|
|
34
|
-
const promptPrefix = "> ";
|
|
35
|
-
const contentLines = draft
|
|
36
|
-
? wrapText(draft, Math.max(8, innerWidth - measureWidth(promptPrefix)))
|
|
37
|
-
: [dim("Ask testkit about failures, runs, logs, or artifacts")];
|
|
38
|
-
const top = `┌${"─".repeat(Math.max(0, boxWidth - 2))}┐`;
|
|
39
|
-
const labelLine = `│ ${padEndVisible(bold(label), innerWidth)} │`;
|
|
40
|
-
const draftLines = contentLines.map((line, index) =>
|
|
41
|
-
`│ ${padEndVisible(index === 0 ? `${promptPrefix}${line}` : ` ${line}`, innerWidth)} │`
|
|
42
|
-
);
|
|
43
|
-
const bottom = `└${"─".repeat(Math.max(0, boxWidth - 2))}┘`;
|
|
44
|
-
return [top, labelLine, ...draftLines, bottom];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function getAssistantLayout(stdout, { transcriptReserve = 9 } = {}) {
|
|
48
|
-
const width = getTerminalWidth(stdout, 100);
|
|
49
|
-
const rows = Number(stdout?.rows);
|
|
50
|
-
const height = Number.isFinite(rows) && rows > 0 ? rows : 40;
|
|
51
|
-
const transcriptLines = Math.max(10, height - transcriptReserve);
|
|
52
|
-
return {
|
|
53
|
-
width,
|
|
54
|
-
transcriptLines,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function formatAssistantMessageBlock(message, width = 100) {
|
|
59
|
-
const title = formatRoleTitle(message);
|
|
60
|
-
const bodyWidth = Math.max(12, width - 2);
|
|
61
|
-
const bodyLines = String(message.text || "")
|
|
62
|
-
.split(/\r?\n/)
|
|
63
|
-
.flatMap((line) => wrapText(line, bodyWidth));
|
|
64
|
-
const rendered = [title];
|
|
65
|
-
for (const line of bodyLines) {
|
|
66
|
-
rendered.push(line ? ` ${line}` : "");
|
|
67
|
-
}
|
|
68
|
-
if (message.role === "tool" && message.toolName) {
|
|
69
|
-
rendered[0] = cyan(title);
|
|
70
|
-
} else if (message.role === "assistant") {
|
|
71
|
-
rendered[0] = bold(title);
|
|
72
|
-
} else if (message.role === "system") {
|
|
73
|
-
rendered[0] = yellow(title);
|
|
74
|
-
}
|
|
75
|
-
return rendered;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function formatRoleTitle(message) {
|
|
79
|
-
if (message.role === "user") return "You";
|
|
80
|
-
if (message.role === "assistant") return "Testkit";
|
|
81
|
-
if (message.role === "tool") return message.title || formatToolName(message.toolName);
|
|
82
|
-
return "System";
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function formatToolName(toolName) {
|
|
86
|
-
const raw = String(toolName || "Tool").trim();
|
|
87
|
-
if (!raw) return "Tool";
|
|
88
|
-
return raw
|
|
89
|
-
.split(/[_-]+/)
|
|
90
|
-
.filter(Boolean)
|
|
91
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
92
|
-
.join(" ");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function trimTranscript(lines, maxLines) {
|
|
96
|
-
if (lines.length <= maxLines) return lines;
|
|
97
|
-
const hidden = lines.length - maxLines;
|
|
98
|
-
return [dim(`… ${hidden} earlier line${hidden === 1 ? "" : "s"} …`), ...lines.slice(-maxLines + 1)];
|
|
99
|
-
}
|