@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.
Files changed (38) hide show
  1. package/README.md +19 -12
  2. package/lib/cli/agents/providers/claude.mjs +1 -1
  3. package/lib/cli/agents/providers/codex.mjs +1 -1
  4. package/lib/cli/assistant/prompt-builder.mjs +78 -0
  5. package/lib/cli/assistant/protocol.mjs +67 -0
  6. package/lib/cli/assistant/session.mjs +92 -0
  7. package/lib/cli/assistant/slash-commands.mjs +160 -0
  8. package/lib/cli/assistant/state.mjs +279 -0
  9. package/lib/cli/assistant/tool-registry.mjs +236 -0
  10. package/lib/cli/assistant/tool-run-reporter.mjs +80 -0
  11. package/lib/cli/command-helpers.mjs +40 -24
  12. package/lib/cli/commands/assistant.mjs +84 -0
  13. package/lib/cli/entrypoint.mjs +37 -11
  14. package/lib/cli/presentation/tree-reporter.mjs +34 -28
  15. package/lib/cli/tui/assistant-app.mjs +131 -0
  16. package/lib/cli/tui/detail-pane.mjs +161 -0
  17. package/lib/cli/tui/filter-bar.mjs +12 -0
  18. package/lib/cli/tui/fuzzy-match.mjs +106 -0
  19. package/lib/cli/tui/inspect-app.mjs +306 -0
  20. package/lib/cli/tui/inspect-artifact-adapter.mjs +3 -0
  21. package/lib/cli/tui/inspect-live-adapter.mjs +15 -0
  22. package/lib/cli/tui/inspect-model.mjs +817 -0
  23. package/lib/cli/tui/inspect-state.mjs +321 -0
  24. package/node_modules/@elench/next-analysis/package.json +1 -1
  25. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  26. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  27. package/node_modules/@elench/ts-analysis/package.json +1 -1
  28. package/package.json +6 -6
  29. package/lib/cli/commands/artifacts.mjs +0 -45
  30. package/lib/cli/commands/investigate.mjs +0 -87
  31. package/lib/cli/commands/logs.mjs +0 -47
  32. package/lib/cli/commands/show.mjs +0 -47
  33. package/lib/cli/commands/watch.mjs +0 -23
  34. package/lib/cli/tui/run-app.mjs +0 -1
  35. package/lib/cli/tui/run-session-app.mjs +0 -432
  36. package/lib/cli/tui/run-session-state.mjs +0 -505
  37. package/lib/cli/tui/run-tree-state.mjs +0 -1
  38. package/lib/cli/tui/watch-app.mjs +0 -220
@@ -0,0 +1,106 @@
1
+ const FIELD_WEIGHTS = {
2
+ path: 140,
3
+ label: 120,
4
+ suite: 90,
5
+ service: 70,
6
+ type: 60,
7
+ status: 30,
8
+ error: 20,
9
+ };
10
+
11
+ export function fuzzyMatch(query, candidate) {
12
+ const normalizedQuery = normalizeValue(query);
13
+ const normalizedCandidate = normalizeValue(candidate);
14
+ if (!normalizedQuery) {
15
+ return { matched: true, score: 0, positions: [] };
16
+ }
17
+ if (!normalizedCandidate) {
18
+ return { matched: false, score: Number.NEGATIVE_INFINITY, positions: [] };
19
+ }
20
+
21
+ const positions = [];
22
+ let queryIndex = 0;
23
+ let lastPosition = -1;
24
+ let score = 0;
25
+
26
+ for (let candidateIndex = 0; candidateIndex < normalizedCandidate.length; candidateIndex += 1) {
27
+ if (normalizedCandidate[candidateIndex] !== normalizedQuery[queryIndex]) continue;
28
+ positions.push(candidateIndex);
29
+ score += 1;
30
+ if (candidateIndex === 0) score += 3;
31
+ if (isBoundary(candidate, candidateIndex)) score += 10;
32
+ if (lastPosition >= 0) {
33
+ const gap = candidateIndex - lastPosition - 1;
34
+ score -= gap;
35
+ if (gap === 0) score += 5;
36
+ }
37
+ lastPosition = candidateIndex;
38
+ queryIndex += 1;
39
+ if (queryIndex >= normalizedQuery.length) {
40
+ return { matched: true, score, positions };
41
+ }
42
+ }
43
+
44
+ return { matched: false, score: Number.NEGATIVE_INFINITY, positions: [] };
45
+ }
46
+
47
+ export function matchInspectEntry(query, entry) {
48
+ const fields = buildEntryMatchFields(entry);
49
+ let best = null;
50
+
51
+ for (const field of fields) {
52
+ if (!field.value) continue;
53
+ const result = fuzzyMatch(query, field.value);
54
+ if (!result.matched) continue;
55
+ const weightedScore = result.score + (FIELD_WEIGHTS[field.name] || 0);
56
+ if (!best || weightedScore > best.score) {
57
+ best = {
58
+ matched: true,
59
+ score: weightedScore,
60
+ field: field.name,
61
+ positions: result.positions,
62
+ };
63
+ }
64
+ }
65
+
66
+ if (!best) {
67
+ return { matched: false, score: Number.NEGATIVE_INFINITY, field: null, positions: [] };
68
+ }
69
+
70
+ return best;
71
+ }
72
+
73
+ export function applyHighlight(text, positions, highlightFn) {
74
+ const source = String(text || "");
75
+ if (!Array.isArray(positions) || positions.length === 0) return source;
76
+ const positionSet = new Set(positions);
77
+ let output = "";
78
+ for (let index = 0; index < source.length; index += 1) {
79
+ output += positionSet.has(index) ? highlightFn(source[index]) : source[index];
80
+ }
81
+ return output;
82
+ }
83
+
84
+ function buildEntryMatchFields(entry) {
85
+ return [
86
+ { name: "label", value: entry.label || "" },
87
+ { name: "path", value: entry.filePath || "" },
88
+ { name: "suite", value: entry.suiteName || "" },
89
+ { name: "service", value: entry.serviceName || "" },
90
+ { name: "type", value: entry.type || "" },
91
+ { name: "status", value: entry.status || "" },
92
+ { name: "error", value: entry.error || "" },
93
+ ];
94
+ }
95
+
96
+ function normalizeValue(value) {
97
+ return String(value || "").toLowerCase();
98
+ }
99
+
100
+ function isBoundary(source, index) {
101
+ if (index <= 0) return true;
102
+ const current = source[index];
103
+ const previous = source[index - 1];
104
+ if (/[/_.\-\s]/.test(previous)) return true;
105
+ return previous.toLowerCase() === previous && current.toUpperCase() === current && current.toLowerCase() !== current;
106
+ }
@@ -0,0 +1,306 @@
1
+ import React, { createElement, useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useAnimation, useApp, useInput } from "ink";
3
+ import figures from "figures";
4
+ import { formatDuration } from "../../runner/formatting.mjs";
5
+ import {
6
+ bold,
7
+ colorService,
8
+ colorTypeBadge,
9
+ dim,
10
+ green,
11
+ red,
12
+ yellow,
13
+ } from "../presentation/colors.mjs";
14
+ import { renderSummaryBox } from "../presentation/summary-box.mjs";
15
+ import { getTerminalWidth } from "../presentation/terminal-layout.mjs";
16
+ import { defaultInvestigationMessage } from "../agents/prompt-builder.mjs";
17
+ import { applyHighlight } from "./fuzzy-match.mjs";
18
+ import { buildInspectPaneContent } from "./detail-pane.mjs";
19
+ import { FilterBar } from "./filter-bar.mjs";
20
+
21
+ const SPINNER_FRAMES = ["|", "/", "-", "\\"];
22
+
23
+ export function InspectApp({
24
+ inspectState,
25
+ stdout,
26
+ productDir,
27
+ onInvestigate,
28
+ onRequestClose,
29
+ onCancelInvestigation,
30
+ } = {}) {
31
+ const { exit } = useApp();
32
+ const [snapshot, setSnapshot] = useState(() => inspectState.getSnapshot());
33
+ const { frame } = useAnimation({ interval: 80, isActive: !snapshot.finished });
34
+ const spinnerFrame = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
35
+
36
+ useEffect(() => {
37
+ const unsubscribe = inspectState.subscribe(() => {
38
+ setSnapshot(inspectState.getSnapshot());
39
+ });
40
+ return unsubscribe;
41
+ }, [inspectState]);
42
+
43
+ useInput((input, key) => {
44
+ if (snapshot.mode === "investigating") {
45
+ if (input === "q") {
46
+ (onRequestClose || exit)();
47
+ return;
48
+ }
49
+ if (input === "t") {
50
+ inspectState.toggleInvestigationViewMode();
51
+ return;
52
+ }
53
+ if (input === "x") {
54
+ onCancelInvestigation?.();
55
+ return;
56
+ }
57
+ if (input === "b" && snapshot.agentSession?.status !== "running" && snapshot.agentSession?.status !== "starting") {
58
+ inspectState.returnToSummary();
59
+ }
60
+ return;
61
+ }
62
+
63
+ if (!snapshot.finished) {
64
+ if (input === "q") {
65
+ inspectState.setNotice("Run is still in progress. Wait for completion before closing.");
66
+ }
67
+ return;
68
+ }
69
+
70
+ if (snapshot.filter.active) {
71
+ if (key.escape) {
72
+ inspectState.deactivateFilter();
73
+ return;
74
+ }
75
+ if (key.return) return;
76
+ if (key.downArrow || input === "j") {
77
+ inspectState.moveCursorDown();
78
+ return;
79
+ }
80
+ if (key.upArrow || input === "k") {
81
+ inspectState.moveCursorUp();
82
+ return;
83
+ }
84
+ if (key.backspace || key.delete) {
85
+ inspectState.updateFilterQuery(snapshot.filter.query.slice(0, -1));
86
+ return;
87
+ }
88
+ if (isPrintableInput(input, key)) {
89
+ inspectState.updateFilterQuery(`${snapshot.filter.query}${input}`);
90
+ }
91
+ return;
92
+ }
93
+
94
+ if (input === "q") {
95
+ (onRequestClose || exit)();
96
+ return;
97
+ }
98
+ if (input === "/") {
99
+ inspectState.activateFilter();
100
+ return;
101
+ }
102
+ if (key.downArrow || input === "j") {
103
+ inspectState.moveCursorDown();
104
+ return;
105
+ }
106
+ if (key.upArrow || input === "k") {
107
+ inspectState.moveCursorUp();
108
+ return;
109
+ }
110
+ if (key.return) {
111
+ inspectState.toggleExpand();
112
+ return;
113
+ }
114
+ if (key.tab) {
115
+ inspectState.cyclePaneMode();
116
+ return;
117
+ }
118
+ if ((input === "y" || input === "i") && snapshot.selectedFailure) {
119
+ onInvestigate?.({
120
+ provider: "auto",
121
+ userMessage: defaultInvestigationMessage(),
122
+ });
123
+ return;
124
+ }
125
+ if (input === "c" && snapshot.selectedFailure) {
126
+ onInvestigate?.({ provider: "claude", userMessage: defaultInvestigationMessage() });
127
+ return;
128
+ }
129
+ if (input === "o" && snapshot.selectedFailure) {
130
+ onInvestigate?.({ provider: "codex", userMessage: defaultInvestigationMessage() });
131
+ }
132
+ });
133
+
134
+ const terminalWidth = getTerminalWidth(stdout, 100);
135
+ const leftWidth = Math.max(42, Math.floor(terminalWidth * 0.52));
136
+ const rightWidth = Math.max(28, terminalWidth - leftWidth - 1);
137
+ const visibleTreeEntries = useMemo(
138
+ () => buildTreeViewport(snapshot.visibleEntries, snapshot.selectedEntryId, 17),
139
+ [snapshot.visibleEntries, snapshot.selectedEntryId]
140
+ );
141
+ const paneContent = useMemo(
142
+ () =>
143
+ snapshot.mode === "investigating"
144
+ ? { title: "Investigation", lines: buildInvestigationLines(snapshot) }
145
+ : buildInspectPaneContent({
146
+ productDir,
147
+ snapshot,
148
+ paneMode: snapshot.paneMode,
149
+ }),
150
+ [productDir, snapshot]
151
+ );
152
+ const summaryLines = snapshot.finished && snapshot.summaryData
153
+ ? renderSummaryBox(snapshot.summaryData.rows, { stdout })
154
+ : [];
155
+
156
+ return createElement(
157
+ Box,
158
+ { flexDirection: "column" },
159
+ createElement(Text, { key: "header" }, dim(buildHeaderText(snapshot))),
160
+ snapshot.notice ? createElement(Text, { key: "notice" }, yellow(snapshot.notice)) : null,
161
+ createElement(
162
+ Box,
163
+ { key: "main", marginTop: 1, flexDirection: "row" },
164
+ createElement(Box, { width: leftWidth, flexDirection: "column", paddingRight: 1 }, ...visibleTreeEntries.map(renderTreeLine.bind(null, snapshot, spinnerFrame))),
165
+ createElement(
166
+ Box,
167
+ { width: rightWidth, flexDirection: "column", paddingLeft: 1 },
168
+ createElement(Text, { key: "pane-title" }, bold(paneContent.title)),
169
+ createElement(Text, { key: "pane-gap" }, ""),
170
+ ...paneContent.lines.slice(0, 34).map((line, index) => createElement(Text, { key: `pane-${index}` }, line))
171
+ )
172
+ ),
173
+ snapshot.filter.active ? createElement(Text, { key: "filter-gap" }, "") : null,
174
+ snapshot.filter.active ? createElement(FilterBar, { key: "filter-bar", filter: snapshot.filter }) : null,
175
+ summaryLines.length > 0 ? createElement(Text, { key: "summary-gap" }, "") : null,
176
+ ...summaryLines.map((line, index) => createElement(Text, { key: `summary-${index}` }, line)),
177
+ createElement(Text, { key: "footer-gap" }, ""),
178
+ createElement(Text, { key: "footer" }, dim(buildFooterText(snapshot)))
179
+ );
180
+ }
181
+
182
+ export function buildHeaderText(snapshot) {
183
+ const progressText = snapshot.totalCount > 0 ? `[${snapshot.completedCount}/${snapshot.totalCount}]` : "[0/0]";
184
+ const phaseText = snapshot.phase || (snapshot.finished ? "run complete" : "preparing");
185
+ const sourceText = snapshot.dataSource === "artifact" ? "artifact inspect" : snapshot.finished ? "live summary" : "live run";
186
+ const filterText = snapshot.filter.active ? `filter ${snapshot.filter.count}` : null;
187
+ return [progressText, phaseText, sourceText, filterText].filter(Boolean).join(" · ");
188
+ }
189
+
190
+ export function buildFooterText(snapshot) {
191
+ if (snapshot.mode === "investigating") {
192
+ if (snapshot.agentSession?.status === "running" || snapshot.agentSession?.status === "starting") {
193
+ return "t transcript · x cancel investigation · q quit";
194
+ }
195
+ return "t transcript · b back · q quit";
196
+ }
197
+ if (!snapshot.finished) return "Run in progress";
198
+ if (snapshot.filter.active) {
199
+ return "type to filter · ↑/↓ move · Esc clear filter · q quit";
200
+ }
201
+ const inspectKeys = "↑/↓ move · Enter collapse/expand · Tab cycle pane · / filter";
202
+ if (!snapshot.selectedFailure) return `${inspectKeys} · q quit`;
203
+ return `${inspectKeys} · y investigate · c Claude · o Codex · q quit`;
204
+ }
205
+
206
+ export function formatAgentEntry(entry) {
207
+ if (!entry) return "";
208
+ if (entry.kind === "status") return `[status] ${entry.text}`;
209
+ if (entry.kind === "tool") return `[tool] ${entry.text}`;
210
+ if (entry.kind === "error") return `[error] ${entry.text}`;
211
+ return entry.text;
212
+ }
213
+
214
+ export function formatTimelineEntry(entry) {
215
+ if (!entry) return "";
216
+ if (entry.kind === "result") return entry.summary || "";
217
+ if (entry.kind === "notice") return `${entry.severity || "info"}: ${entry.message}`;
218
+ return entry.message || entry.summary || "";
219
+ }
220
+
221
+ function buildInvestigationLines(snapshot) {
222
+ const transcriptEntries = snapshot.agentSession?.transcriptEntries || [];
223
+ const timeline = snapshot.agentSession?.timeline || [];
224
+ const viewMode = snapshot.agentSession?.viewMode || "summary";
225
+ const status = snapshot.agentSession?.status || "idle";
226
+ const lines = [
227
+ `Status: ${status}`,
228
+ `Phase: ${snapshot.agentSession?.activePhase || "planning"}`,
229
+ "",
230
+ ];
231
+ if (viewMode === "transcript") {
232
+ for (const entry of transcriptEntries.slice(-28)) lines.push(formatAgentEntry(entry));
233
+ return lines;
234
+ }
235
+ for (const entry of timeline.slice(-16)) lines.push(formatTimelineEntry(entry));
236
+ if (timeline.length === 0 && snapshot.agentSession?.finalText) {
237
+ lines.push(snapshot.agentSession.finalText);
238
+ }
239
+ return lines;
240
+ }
241
+
242
+ function buildTreeViewport(entries, selectedEntryId, radius) {
243
+ if (entries.length <= radius * 2 + 1) return entries;
244
+ const selectedIndex = Math.max(0, entries.findIndex((entry) => entry.id === selectedEntryId));
245
+ const start = Math.max(0, selectedIndex - radius);
246
+ const end = Math.min(entries.length, selectedIndex + radius + 1);
247
+ return entries.slice(start, end);
248
+ }
249
+
250
+ function renderTreeLine(snapshot, spinnerFrame, entry) {
251
+ const selected = entry.id === snapshot.selectedEntryId;
252
+ const pointer = selected ? `${bold(">")} ` : " ";
253
+ const indent = " ".repeat(entry.depth);
254
+ const rawLabel = entry.label;
255
+ const match = entry.match;
256
+ const highlightedLabel = match?.field === "label"
257
+ ? applyHighlight(rawLabel, match.positions, bold)
258
+ : rawLabel;
259
+ const renderedLabel = decorateEntryLabel(entry, highlightedLabel, match);
260
+ const icon = entryIcon(entry, spinnerFrame);
261
+ const line = `${pointer}${indent}${icon ? `${icon} ` : ""}${renderedLabel}${entrySuffix(entry)}`;
262
+ return createElement(Text, { key: entry.id }, line);
263
+ }
264
+
265
+ function entryIcon(entry, spinnerFrame) {
266
+ if (entry.kind === "service" || entry.kind === "type") return "";
267
+ if (entry.kind === "suite") return entry.collapsed ? yellow("+") : yellow("-");
268
+ if (entry.status === "failed") return red(figures.cross);
269
+ if (entry.status === "passed") return green(figures.tick);
270
+ if (entry.status === "skipped") return yellow(figures.arrowDown);
271
+ if (entry.status === "running") return green(spinnerFrame);
272
+ if (entry.status === "not_run") return yellow("!");
273
+ return dim("·");
274
+ }
275
+
276
+ function decorateEntryLabel(entry, label, match) {
277
+ let rendered = label;
278
+ if (entry.kind === "service") rendered = colorService(label);
279
+ else if (entry.kind === "type") rendered = colorTypeBadge(label.toUpperCase());
280
+ else if (entry.kind === "suite") rendered = bold(label);
281
+
282
+ if (match?.field === "path" && entry.filePath) {
283
+ rendered += ` ${dim(`(${applyHighlight(entry.filePath, match.positions, bold)})`)}`;
284
+ }
285
+ return rendered;
286
+ }
287
+
288
+ function entrySuffix(entry) {
289
+ if (entry.kind === "file") {
290
+ const duration = entry.durationMs != null ? ` ${dim(formatDuration(entry.durationMs))}` : "";
291
+ return duration;
292
+ }
293
+ if (entry.summary?.total > 0) {
294
+ return dim(` (${entry.summary.total} files)`);
295
+ }
296
+ if (entry.skipReason) {
297
+ return ` ${dim(entry.skipReason)}`;
298
+ }
299
+ return "";
300
+ }
301
+
302
+ function isPrintableInput(input, key) {
303
+ if (!input) return false;
304
+ if (key.ctrl || key.meta || key.escape || key.return || key.tab) return false;
305
+ return input >= " ";
306
+ }
@@ -0,0 +1,3 @@
1
+ export function hydrateInspectStateFromArtifact(inspectState, artifact) {
2
+ inspectState.hydrateFromArtifact(artifact);
3
+ }
@@ -0,0 +1,15 @@
1
+ export function applyReporterPlans(inspectState, plans) {
2
+ inspectState.initFromPlans(plans);
3
+ }
4
+
5
+ export function applyReporterTaskStarted(inspectState, task, suiteKey) {
6
+ inspectState.markFileRunning(task.serviceName, suiteKey, task.file);
7
+ }
8
+
9
+ export function applyReporterTaskFinished(inspectState, task, outcome) {
10
+ inspectState.markFileFinished(task, outcome);
11
+ }
12
+
13
+ export function applyReporterRunSummary(inspectState, results, durationMs, regressionReport) {
14
+ inspectState.finish(results, durationMs, regressionReport);
15
+ }