@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,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
+ }
@@ -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.value) ||
49
- !topLevelCommands.has(firstPositional.value);
64
+ (!firstPositional && !interactiveTty) ||
65
+ runTypeShortcuts.has(firstPositional?.value) ||
66
+ runFlagPresent ||
67
+ !topLevelCommands.has(firstPositional?.value);
50
68
 
51
- if (shouldPrefixRun) {
52
- return ["run", ...argv];
69
+ if (!firstPositional && interactiveTty && !runFlagPresent) {
70
+ return ["assistant", ...argv];
53
71
  }
54
72
 
55
- if (topLevelCommands.has(firstPositional.value) && argv[0] !== firstPositional.value) {
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 { createRunSessionState } from "../tui/run-session-state.mjs";
4
- import { RunSessionApp } from "../tui/run-session-app.mjs";
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 sessionState = createRunSessionState();
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(RunSessionApp, {
18
- sessionState,
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
- sessionState.initFromPlans(plans);
40
+ applyReporterPlans(inspectState, plans);
35
41
  },
36
42
 
37
43
  setTotalFileCount(count) {
38
- sessionState.setTotalFileCount(count);
44
+ inspectState.setTotalFileCount(count);
39
45
  },
40
46
 
41
47
  setRegressionCatalog(document) {
42
- sessionState.setRegressionCatalog(document);
48
+ inspectState.setRegressionCatalog(document);
43
49
  },
44
50
 
45
51
  serviceSkipped(config, reason) {
46
- sessionState.markServiceSkipped(config.name, reason);
52
+ inspectState.markServiceSkipped(config.name, reason);
47
53
  },
48
54
 
49
55
  plannedSkip(entry) {
50
- sessionState.markPlannedSkip(entry);
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
- sessionState.markFileRunning(task.serviceName, suiteKey, task.file);
61
+ applyReporterTaskStarted(inspectState, task, suiteKey);
56
62
  },
57
63
 
58
64
  taskFinished(task, outcome) {
59
- sessionState.markFileFinished(task, outcome);
65
+ applyReporterTaskFinished(inspectState, task, outcome);
60
66
  },
61
67
 
62
68
  runtimeError(task, message) {
63
- sessionState.markRuntimeError(task, message);
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
- sessionState.setPhase(label);
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
- sessionState.finish(results, durationMs, regressionReport);
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 = sessionState.getSnapshot();
102
+ const snapshot = inspectState.getSnapshot();
97
103
  if (!snapshot.selectedFailure) {
98
- sessionState.setNotice("No failed file is selected for investigation.");
104
+ inspectState.setNotice("No failed file is selected for investigation.");
99
105
  return;
100
106
  }
101
107
  if (activeAgentSession) {
102
- sessionState.setNotice("An investigation is already running.");
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
- sessionState.beginInvestigation({ provider, userMessage });
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
- sessionState.recordInvestigationProgress(event, presentation);
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
- sessionState.recordInvestigationProgress(finalEvent, presentation);
137
+ inspectState.recordInvestigationProgress(finalEvent, presentation);
132
138
  }
133
139
  if (result.cancelled) {
134
- sessionState.cancelAgentSession("Cancelled investigation.");
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
- sessionState.failAgentSession(result.stderr || `Agent exited with code ${result.exitCode}`);
146
+ inspectState.failAgentSession(result.stderr || `Agent exited with code ${result.exitCode}`);
141
147
  persistInvestigationLog();
142
148
  activeInterpreter = null;
143
149
  return;
144
150
  }
145
- sessionState.completeAgentSession({
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
- sessionState.failAgentSession(error);
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
- sessionState.returnToSummary();
168
+ inspectState.returnToSummary();
163
169
  return;
164
170
  }
165
171
  investigationToken += 1;
166
172
  activeAgentSession.cancel();
167
173
  activeAgentSession = null;
168
- sessionState.cancelAgentSession("Cancelled investigation.");
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 = sessionState.getSnapshot();
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
+ }