SLOPSHOPPER

test-progress

Acompanha em segundo plano os testes dos módulos cadastrados no workspace, com contadores observados, estado e logs por módulo.

newpanebandcommandprocesstimer
v0.10.1MITupdated 2026-10-09fabiopbarbieri/claude-test-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · test-progress
│ ┃ claude-test-progress ✕ › fix the failing auth test and add an audit log call │ ┃ 0/0/0 ? │ ┃ Nenhum módulo em .claude/test-progress.json ⏺ Read(src/auth.ts) │ ┃ Coletor retornou resposta inválida (exit 0); ⎿ Read 6 lines │ ┃ requer Node 14+ local. ⏺ Update(src/auth.ts) │ ┃ Registro: O comando test-progress está ⎿ Added 2 lines, removed 1 line │ ┃ indisponível ou pertence a outra origem. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /test-progress │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · claude-test-progress
0/0/0 ? Nenhum módulo em .claude/test-progress.json Coletor retornou resposta inválida (exit 0); requer Node 14+ local. Registro: O comando test-progress está indisponível ou pertence a outra origem.
README

Claude Test Progress

Test Progress

Acompanhe suas suítes de teste no Claude Code enquanto elas rodam em segundo plano. Plugin test-progress, um Claude Code Mod independente da Anthropic. Versão 0.10.1, licença MIT.

Painel do Test Progress com sete módulos de projetos fictícios

  • Um painel para todas as suítes: Maven/JUnit, Karma/Angular, Playwright, pytest/unittest, RSpec, Rails/Minitest ou qualquer comando.
  • Progresso ao vivo: barra, percentual, ✓ passaram, ✗ falharam, ⊘ ignorados e tempo.
  • Ações no próprio painel: ▶ inicia, ■ cancela, ↻ Apenas com erro reexecuta só o que falhou e clicar no nome abre o log com as cores do runner.
  • Resumo acima do prompt: uma linha com o que está rodando e as falhas ainda não vistas.

O topo do painel conta módulos com sucesso / com erro / total. O percentual é testes resolvidos / total conhecido; ~ indica total parcial, que ainda pode crescer. 100% não comprova sucesso: confira estado, falhas e código de saída.

Instalar

Com acesso SSH ao GitHub configurado, rode no Claude Code um comando por vez (colados juntos, as linhas seguintes viram argumentos do primeiro):

/plugin marketplace add git@github.com:fabiopbarbieri/claude-test-progress.git
/plugin install test-progress@test-progress-marketplace
/reload-plugins

Depois, /test-progress help, /test-progress paths e /test-progress list. Instalação por terminal, clone SSH e --plugin-dir: guia de uso.

Configurar

O jeito mais simples é pedir ao Claude: a skill test-progress:configure cadastra as suítes do projeto e explica erros do painel. Depois, quando você pedir para rodar os testes, a skill test-progress:run-tests os inicia pelo coletor, para o run aparecer no painel.

O cadastro fica em .claude/test-progress.json, no diretório em que você abriu a sessão. Exemplo para um app Maven com mvnw na raiz:

{
  "schemaVersion": 1,
  "modules": {
    "api": {
      "label": "API",
      "command": ["./mvnw", "test"],
      "cwd": ".",
      "adapter": "maven",
      "runtime": "inherit"
    }
  }
}

command é argv (um item por argumento, sem shell). Para contar testes em outras suítes, use os adapters pelo caminho absoluto mostrado em /test-progress paths. Exemplos prontos, templates pessoais e todos os campos: configurar seu projeto.

Comandos

ComandoAção
/test-progressAbre ou fecha o painel, sem iniciar testes
/test-progress listLista módulos e diagnósticos
/test-progress start <id> / start allInicia um módulo / todos os habilitados
/test-progress logs <id>Mostra o log do módulo
/test-progress cancel <id> / cancel allCancela a execução
/test-progress help / pathsAjuda / caminhos da instalação

Acrescente --text para a saída em texto. No painel, ⇅ alterna a ordem dos módulos (cadastro, nome, recentes, atenção) e ? mostra a legenda.

Quando todas as execuções terminam e alguma falhou, o painel abre sozinho no log do primeiro módulo com falha, inclusive para execuções iniciadas pelo Claude via CLI. Ele não toma o teclado e, por ser aberto pelo mod, só aparece com o terminal largo o bastante (144 colunas); fechado, não reabre para as mesmas execuções.

No mesmo momento sai uma notificação nativa ("Falharam: API (1 ✗)") pelo canal de notificação configurado no Claude Code, para avisar mesmo longe do terminal ou enquanto o painel espera largura.

Atualizar ou remover

Encerre os jobs antes de trocar a instalação: fechar o painel, recarregar ou desinstalar não para processos em segundo plano.

claude plugin marketplace update test-progress-marketplace
claude plugin update test-progress@test-progress-marketplace --scope user

Para remover:

claude plugin uninstall test-progress@test-progress-marketplace --scope user

Depois de atualizar, confira /test-progress paths e ajuste os caminhos dos adapters. Procedimento completo.

Compatibilidade

Claude Code 2.1.295+ e Node 14+ para o coletor. Linux e WSL são o caminho principal; Windows nativo tem guia próprio. O plugin não instala runners, browsers nem runtimes. Versões testadas por suíte: compatibilidade.

Guias

Uso · Diagnóstico · Compatibilidade · Windows · Angular 9 · Validação · Extensões · Contribuição · CI e governança · Segurança · Licença MIT

Source 5 files
hooks/register.tsx 757 lines
1// Claude Code Mods 2.1.295+. No host Node APIs run inside the Mod sandbox.
2import { atom, read } from 'claude-code';
3import type { EngineInterface, Register, Timer } from 'claude-code';
4import type { TestProgressDiagnostic, TestProgressPanel } from '../types';
5import { ACTIVE, labels, validateEnvelope, parseCommand, visibleModuleIds, moduleTitle, countSummary, diagnosticText,
6  sanitizeText, sanitizeTail, plainTail, ansiSpans, statusGlyph, progressBar, outcomeText, summaryCounts, finishedWithFailure, compactPercent, compactCounts, clock,
7  configStatus, SORTS, sortLabels, nextSort, sortModuleIds } from '../runner/module-presentation.mjs';
8
9type $ = EngineInterface;
10type Action = 'list' | 'start' | 'status' | 'logs' | 'cancel' | 'help' | 'paths';
11const PANE = 'claude-test-progress';
12const NARROW_COLUMNS = 60;
13// Columns the band leaves free at the right, where the host draws its collapse control.
14const BAND_RESERVE = 6;
15const IDLE_POLL_TICKS = 10;
16const LOG_ROWS = 12;
17const empty = (): TestProgressPanel => ({ identity: '', generation: 0, sessionOwner: '', modules: {}, jobs: {},
18  stateDiagnostics: {}, workspace: null, busy: false, lastError: '', registrationError: '', selectedLogs: null,
19  logTail: [], logTop: null, chooseLogs: false, showHelp: false, sort: 'order', seenRuns: [], collector: null });
20// The host holds what the pane draws, so a hot reload keeps the selection, the seen runs and the collector Node.
21const panel = atom({ plugin: 'test-progress', key: 'panel' } as const, empty());
22// Working copy: hooks change it, then publish it; drawings read the host's value and redraw when it changes.
23let p = empty(), hydrated = false, published = '';
24// Collector calls run one at a time; commands and buttons wait instead of being dropped.
25let queue: Promise<unknown> = Promise.resolve(), queued = 0, idleTicks = 0, timer: Timer | null = null;
26const errorText = (error: unknown) => String(error instanceof Error ? error.message : error);
27async function hydrate($: $) {
28  if (hydrated) return;
29  const held = await read($, panel);
30  if (!hydrated) {
31    p = { ...empty(), ...held, busy: false };
32    if (!SORTS.includes(p.sort)) p.sort = 'order';
33    hydrated = true; published = JSON.stringify(p);
34  }
35}
36async function publish($: $) {
37  hydrated = true;
38  const next = JSON.stringify(p);
39  if (next === published) return;
40  published = next;
41  await $.state.set({ plugin: 'test-progress', key: 'panel' }, JSON.parse(next) as TestProgressPanel);
42}
43const visible = () => visibleModuleIds(p.modules, p.jobs, p.stateDiagnostics);
44const enabled = () => visible().filter(id => p.modules[id]?.enabled);
45const catalogue = () => JSON.stringify({ modules: p.modules, moduleConfig: p.workspace?.moduleConfig,
46  stateBlocked: p.workspace?.stateBlocked, stateDiagnostics: p.stateDiagnostics });
47const actionProjection = () => JSON.stringify({ catalogue: catalogue(), jobs: Object.keys(p.jobs).sort().map(id => {
48  const job = p.jobs[id]!;
49  return [id, job.runId, job.status, !!job.recoveryRequired, !!job.cancellable];
50}) });
51// Labels alone read best; a label shared by two visible modules gets its ID.
52function displayNames() {
53  const ids = visible(), count: Record<string, number> = {};
54  for (const id of ids) { const label = p.modules[id]?.label ?? id; count[label] = (count[label] ?? 0) + 1; }
55  return Object.fromEntries(ids.map(id => {
56    const label = p.modules[id]?.label ?? id;
57    return [id, (count[label] ?? 0) > 1 && label !== id ? `${label} (${id})` : label];
58  }));
59}
60// The log shows a window of LOG_ROWS lines; with no top of its own it follows the end.
61function logWindow() {
62  const max = Math.max(0, p.logTail.length - LOG_ROWS);
63  return { top: p.logTop === null ? max : Math.min(Math.max(0, p.logTop), max), max };
64}
65const closeLogs = () => { p.selectedLogs = null; p.logTail = []; p.logTop = null; };
66const diagnostics = (id: string): TestProgressDiagnostic[] => [...(p.modules[id]?.diagnostics ?? []), ...(p.stateDiagnostics[id] ?? [])];
67function serialized<T>(task: () => Promise<T>): Promise<T> {
68  queued += 1;
69  const run = queue.then(task).finally(() => { queued -= 1; });
70  queue = run.catch(() => {});
71  return run;
72}
73const liveJobs = () => Object.values(p.jobs).some(job => ACTIVE.has(job.status) || job.recoveryRequired);
74// Idle, a query starts a Node only when something it read changed. Each answer names its sources
75// (the state directory and the configuration files and module folders); every IDLE_CHECK_TICKS
76// seconds the Mod compares them through the host's file system, with a query at least every
77// IDLE_REFRESH_TICKS seconds (ten after a failed one). Without sources, or if the file system
78// refuses, the panel polls every IDLE_POLL_TICKS seconds instead.
79const IDLE_CHECK_TICKS = 2;
80const IDLE_REFRESH_TICKS = 300;
81const absolutePath = (value: unknown): value is string => typeof value === 'string' && !value.includes('\0') &&
82  (value.startsWith('/') || /^[a-z]:\\/i.test(value));
83let sources: { identity: string; state: string; paths: string[] } | null = null;
84let baseline: string | null = null, refreshTicks = 0;
85function rememberSources(value: { state?: unknown; paths?: unknown } | undefined) {
86  const paths = Array.isArray(value?.paths) ? value.paths : null;
87  sources = absolutePath(value?.state) && paths && paths.length <= 256 && paths.every(absolutePath)
88    ? { identity: p.identity, state: value.state, paths } : null;
89}
90async function fingerprint($: $) {
91  const current = sources;
92  if (!current || current.identity !== p.identity) return null;
93  try {
94    const rows = (await $.fs.list(current.state)).map(entry => `${entry.name}|${entry.kind}|${entry.size}|${entry.mtimeMs}`).sort();
95    for (const path of current.paths) {
96      const info = await $.fs.stat(path).catch(() => null);
97      // A folder counts by presence: its own time moves with every file a test writes in it.
98      rows.push(`${path}|${!info ? 'absent' : info.kind === 'dir' ? 'dir' : `${info.kind}|${info.size}|${info.mtimeMs}`}`);
99    }
100    return rows.join('\n');
101  } catch { return null; }
102}
103// Poll every second only while something can change without us: an unseen identity or a live job.
104async function pollDue($: $) {
105  if (!p.workspace || liveJobs()) { idleTicks = 0; return true; }
106  const ticks = ++idleTicks, refresh = --refreshTicks <= 0;
107  if (!refresh && ticks % IDLE_CHECK_TICKS) return false;
108  const print = refresh ? null : await fingerprint($);
109  const due = refresh || (print === null ? ticks >= IDLE_POLL_TICKS : print !== baseline);
110  if (due) idleTicks = 0;
111  return due;
112}
113// While a job is live, one collector started once streams each status change (`watch`),
114// instead of a Node started every second. A watcher that stops answering, or one the host
115// cannot start, falls back to the per-second query.
116const WATCH_STALE_MS = 5000;
117const WATCH_RETRY_MS = 60000;
118type Watcher = { identity: string; logs: string; seenAt: number; stop: () => void };
119let watcher: Watcher | null = null, watchBlockedUntil = 0;
120function stopWatcher() {
121  const current = watcher;
122  watcher = null;
123  current?.stop();
124}
125function applyEnvelope(data: ReturnType<typeof validateEnvelope>) {
126  p.modules = data.modules; p.jobs = data.jobs; p.workspace = data.workspace; p.stateDiagnostics = data.stateDiagnostics;
127  rememberSources(data.sources);
128  const selected = p.selectedLogs && p.jobs[p.selectedLogs.id];
129  if (p.selectedLogs && selected?.runId === p.selectedLogs.runId && Array.isArray(selected.logTail)) p.logTail = sanitizeTail(selected.logTail);
130}
131async function applyWatched($: $, self: Watcher, line: string) {
132  if (watcher !== self) return;
133  await synchronizeIdentity($);
134  if (p.identity !== self.identity) { stopWatcher(); return; }
135  let data;
136  try { data = validateEnvelope(JSON.parse(line)); } catch { return; }
137  applyEnvelope(data);
138  p.lastError = data.ok ? '' : data.error ?? 'O coletor encerrou a observação.';
139  if (await paneShown($)) acknowledge();
140  else await autoOpen($);
141  await publish($);
142}
143// True while a watcher for this identity and log selection runs or was just started.
144function watch($: $, logs: string) {
145  const direct = p.collector?.identity === p.identity ? p.collector : null;
146  if (!direct || Date.now() < watchBlockedUntil) return false;
147  if (watcher?.identity === p.identity && watcher.logs === logs) return true;
148  stopWatcher();
149  const [cwd = '', owner = ''] = p.identity.split('\n');
150  let stream: ReturnType<$['process']['spawn']>;
151  try {
152    // A 1 MB young generation keeps this long-lived Node small (LONG_LIVED_NODE_FLAGS).
153    stream = $.process.spawn({ argv: [direct.path, '--max-semi-space-size=1', `${$.plugin.root}/runner/cli.mjs`, 'watch',
154      '--cwd', cwd, '--owner', owner, '--module', logs], env: { TEST_PROGRESS_NODE_SOURCE: direct.source } });
155  } catch { watchBlockedUntil = Date.now() + WATCH_RETRY_MS; return false; }
156  const self: Watcher = { identity: p.identity, logs, seenAt: Date.now(),
157    stop: () => { void stream.return({ code: null, signal: null }).catch(() => {}); } };
158  watcher = self;
159  void (async () => {
160    let buffer = '', lines = 0;
161    try {
162      for await (const chunk of stream) {
163        if (watcher !== self) break;
164        if (chunk.stream !== 'stdout') continue;
165        buffer += chunk.text;
166        for (let end = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
167          const line = buffer.slice(0, end);
168          buffer = buffer.slice(end + 1);
169          if (!line.trim()) continue;
170          lines++; self.seenAt = Date.now();
171          await serialized(() => applyWatched($, self, line));
172        }
173      }
174    } catch { /* The per-second query takes over. */ }
175    finally {
176      if (watcher === self) watcher = null;
177      if (!lines) watchBlockedUntil = Date.now() + WATCH_RETRY_MS;
178    }
179  })();
180  return true;
181}
182const validCollector = (value: { path?: unknown; source?: unknown } | undefined): value is { path: string; source: string } =>
183  typeof value?.path === 'string' && !value.path.includes('\0') && (value.path.startsWith('/') || /^[a-z]:\\/i.test(value.path)) &&
184  typeof value.source === 'string' && /^[\w-]{1,32}$/.test(value.source);
185// The bootstrap's Node is kept between sessions for the same folder, PATH and override, so a new
186// session queries it directly instead of starting PowerShell or bash first. A Node that no longer
187// answers is forgotten, and that query goes through the bootstrap.
188const COLLECTORS = 'collectors';
189async function collectorKey($: $, cwd: string) {
190  try {
191    const [path, override] = await Promise.all([$.env.get('PATH').then(value => value ?? $.env.get('Path')), $.env.get('TEST_PROGRESS_NODE')]);
192    return JSON.stringify([cwd, path ?? null, override ?? null]);
193  } catch { return null; }
194}
195async function storedCollectors($: $) {
196  const value = await $.store.get(COLLECTORS).catch(() => undefined);
197  return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, { path?: unknown; source?: unknown }> : {};
198}
199async function rememberedCollector($: $, key: string | null) {
200  if (!key) return null;
201  const value = (await storedCollectors($))[key];
202  if (!validCollector(value)) return null;
203  p.collector = { identity: p.identity, path: value.path, source: value.source };
204  return p.collector;
205}
206async function keepCollector($: $, key: string | null, value: { path: string; source: string }) {
207  if (!key) return;
208  // The newest 32 folders stay; each entry is a path and a source label.
209  const kept = Object.entries(await storedCollectors($)).filter(([entry]) => entry !== key).slice(-31);
210  await $.store.set(COLLECTORS, Object.fromEntries([...kept, [key, { path: value.path, source: value.source }]])).catch(() => {});
211}
212async function forgetCollector($: $, key: string | null) {
213  if (!key) return;
214  const all = await storedCollectors($);
215  if (!(key in all)) return;
216  delete all[key];
217  await $.store.set(COLLECTORS, all).catch(() => {});
218}
219const duration = (ms: number | undefined) => typeof ms === 'number' ? `${Math.floor(ms / 1000)}s` : 'desconhecida';
220function startAllowed(id: string) {
221  const job = p.jobs[id];
222  return !p.busy && p.workspace?.moduleConfig?.status === 'valid' && !p.workspace?.stateBlocked &&
223    !p.stateDiagnostics['*']?.length && p.modules[id]?.enabled === true && p.modules[id]?.directoryPresent === true &&
224    !diagnostics(id).length && !(job && ACTIVE.has(job.status)) && !job?.recoveryRequired;
225}
226const allAllowed = () => enabled().length > 0 && enabled().every(startAllowed);
227const failedIds = () => enabled().filter(id => finishedWithFailure(p.jobs[id]));
228const failedAllowed = () => failedIds().length > 0 && failedIds().every(startAllowed);
229// Opening or using the pane acknowledges the runs it shows, so the band can step aside.
230function acknowledge() {
231  const seen = new Set(p.seenRuns);
232  for (const job of Object.values(p.jobs)) seen.add(job.runId);
233  if (seen.size !== p.seenRuns.length) p.seenRuns = [...seen].slice(-200);
234}
235function shortSummary(id: string) {
236  const job = p.jobs[id];
237  if (!job) return `${moduleTitle(id, p.modules[id])}: sem execução`;
238  const counts = compactCounts(job).map(count => count.text).join(' ');
239  return `${moduleTitle(id, p.modules[id])}: ${statusGlyph(job).glyph} ${compactPercent(job)} · ${labels[job.status] ?? job.status}${counts ? ` · ${counts}` : ''}`;
240}
241function textSummary() {
242  const rows = ['Test Progress', ...(p.sessionOwner ? [`owner=${p.sessionOwner}`] : [])];
243  for (const id of visible()) {
244    const job = p.jobs[id];
245    rows.push(shortSummary(id), `  linguagem=${p.modules[id]?.language ?? 'não informada'}`);
246    if (!p.modules[id]?.enabled) rows.push('  Módulo removido/desativado.');
247    for (const item of diagnostics(id)) rows.push(`  Diagnóstico: ${diagnosticText(item)}`);
248    if (!job) continue;
249    rows.push(`  ${countSummary(job)}; passed=${job.passed ?? 0}, failed=${job.failed ?? 0}, skipped=${job.skipped ?? 0}`,
250      `  origem=${job.source}; fase=${job.phase}; exitCode=${job.exitCode ?? 'ainda desconhecido'}`,
251      `  duração=${duration(job.elapsedMs)}; último sinal do executor=${job.heartbeatAt ?? 'ainda não observado'}`,
252      `  última saída=${job.lastOutputAt ?? 'não observada'}; último progresso reconhecido=${job.lastProgressAt ?? 'não observado'}`,
253      `  runId=${job.runId}; log=${job.logPath ?? 'não disponível'}`);
254    if (job.collectorRuntime) rows.push(`  Node coletor=${job.collectorRuntime.version} (${job.collectorRuntime.source})`);
255    if (job.nodeRuntime) rows.push(`  Node da suíte=${job.nodeRuntime.version} (${job.nodeRuntime.source}); .nvmrc=${job.nodeRuntime.nvmrc ?? 'ausente'}`);
256    if (job.error) rows.push(`  ${job.error}`);
257  }
258  if (!visible().length) rows.push('Nenhum módulo ativado neste workspace.', 'Configure .claude/test-progress.json; consulte /test-progress help.');
259  rows.push(`Cadastro: ${configStatus[p.workspace?.moduleConfig?.status ?? ''] ?? 'não consultado'} · .claude/test-progress.json`);
260  if (p.workspace?.error) rows.push(`Configuração: ${p.workspace.error}`);
261  for (const item of p.workspace?.moduleConfig?.diagnostics ?? []) rows.push(`Configuração: ${diagnosticText(item)}`);
262  for (const item of p.stateDiagnostics['*'] ?? []) rows.push(`Estado: ${diagnosticText(item)}`);
263  if (p.lastError) rows.push(`Erro: ${p.lastError}`);
264  if (p.registrationError) rows.push(`Registro: ${p.registrationError}`);
265  return rows.join('\n');
266}
267async function synchronizeIdentity($: $) {
268  await hydrate($);
269  const cwd = await $.session.cwd(), owner = await $.session.id();
270  const key = `${cwd}\n${owner}`;
271  if (p.identity !== key) {
272    // A new identity starts clean, but the collector Node it bootstrapped, the registration outcome and the ordering stay.
273    p = { ...empty(), identity: key, generation: p.generation + 1, sessionOwner: owner,
274      registrationError: p.registrationError, collector: p.collector, busy: p.busy, sort: p.sort };
275  }
276  return { cwd, owner, generation: p.generation };
277}
278async function collect($: $, action: Action = 'status', moduleId = 'all'): Promise<unknown> {
279  const context = await synchronizeIdentity($);
280  const windows = /^[a-z]:[\\/]|^\\\\/i.test(context.cwd);
281  // After one bootstrap, call the Node it selected directly: no shell, no PATH/nvm probing.
282  let key: string | null | undefined;
283  const keyOf = async () => key === undefined ? (key = await collectorKey($, context.cwd)) : key;
284  const direct = p.collector?.identity === p.identity ? p.collector : await rememberedCollector($, await keyOf());
285  // What the files looked like before this query; an answer makes it the idle baseline.
286  const print = await fingerprint($);
287  const settle = (ok: boolean) => { baseline = print; refreshTicks = ok ? IDLE_REFRESH_TICKS : IDLE_POLL_TICKS; };
288  let argv: string[];
289  if (direct) argv = [direct.path, `${$.plugin.root}/runner/cli.mjs`, action,
290    '--cwd', context.cwd, '--owner', context.owner, '--module', moduleId];
291  else if (windows) {
292    const override = await $.env.get('TEST_PROGRESS_POWERSHELL'), systemRoot = await $.env.get('SystemRoot');
293    argv = [override || (systemRoot ? `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : 'powershell.exe'),
294      '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${$.plugin.root}/scripts/run-collector.ps1`,
295      '-Action', action, '-Cwd', context.cwd, '-Owner', context.owner, '-Module', moduleId];
296  } else argv = ['bash', `${$.plugin.root}/scripts/run-collector.sh`, action,
297    '--cwd', context.cwd, '--owner', context.owner, '--module', moduleId];
298  const init = { timeoutMs: action === 'start' ? 60000 : windows ? 15000 : 5000,
299    ...(direct ? { env: { TEST_PROGRESS_NODE_SOURCE: direct.source } } : {}) };
300  // A stale cached Node falls back to the bootstrap once; a start is never repeated.
301  const fallback = async () => { p.collector = null; await forgetCollector($, await keyOf()); return !!direct && action !== 'start'; };
302  const changed = () => new Error('A sessão mudou durante a consulta. Atualize para ver os jobs desta sessão.');
303  let response;
304  try { response = await $.process.run(argv, init); }
305  catch (error) {
306    await synchronizeIdentity($);
307    if (context.generation !== p.generation) throw changed();
308    if (direct && await fallback()) return collect($, action, moduleId);
309    settle(false);
310    throw error;
311  }
312  await synchronizeIdentity($);
313  if (context.generation !== p.generation) throw changed();
314  let raw: unknown;
315  try { raw = JSON.parse(response.stdout.trim()); }
316  catch {
317    if (direct && await fallback()) return collect($, action, moduleId);
318    settle(false);
319    throw new Error(sanitizeText(response.stderr).trim().slice(0, 1024) || `Coletor retornou resposta inválida (exit ${response.exitCode}); requer Node 14+ local.`);
320  }
321  let data;
322  try { data = validateEnvelope(raw); } catch (error) { settle(false); throw error; }
323  if (!direct && validCollector(data.collector)) {
324    p.collector = { identity: p.identity, path: data.collector.path, source: data.collector.source };
325    await keepCollector($, await keyOf(), p.collector);
326  }
327  // Error envelopes still carry the valid catalogue and persistent jobs.
328  applyEnvelope(data);
329  settle(data.ok && response.exitCode === 0);
330  if (!data.ok || response.exitCode !== 0) {
331    const actionErrors = Object.entries(data.actionResults ?? {}).filter(([, result]) => !result.ok)
332      .map(([id, result]) => `${id}: ${result.error ?? 'ação recusada'}`).join('\n');
333    throw new Error(data.error ?? (actionErrors || `Coletor encerrou com código ${response.exitCode}.`));
334  }
335  p.lastError = '';
336  return data;
337}
338type Expected = { generation: number; projection: string };
339function perform($: $, action: Action, moduleId = 'all', expected: Expected | null = null) {
340  return serialized(() => performNow($, action, moduleId, expected));
341}
342// The collector starts one ID or all; a rerun of the failed modules starts each in turn after one check.
343function startFailed($: $, expected: Expected) {
344  return serialized(async () => {
345    await hydrate($);
346    p.busy = true; await publish($);
347    try {
348      await synchronizeIdentity($);
349      await collect($, 'status');
350      if (p.generation !== expected.generation || actionProjection() !== expected.projection) throw new Error('O cadastro ou a execução mudou. Revise os módulos antes de agir.');
351      p.busy = false;
352      const ids = failedIds(), allowed = failedAllowed();
353      p.busy = true;
354      if (!allowed) throw new Error('Início indisponível. Confira os diagnósticos e jobs ativos.');
355      for (const id of ids) await collect($, 'start', id);
356    } catch (error) { p.lastError = errorText(error); }
357    finally { p.busy = false; await publish($); }
358  });
359}
360async function performNow($: $, action: Action, moduleId: string, expected: Expected | null) {
361  await hydrate($);
362  p.busy = true; await publish($);
363  try {
364    await synchronizeIdentity($);
365    if (expected && (p.generation !== expected.generation || actionProjection() !== expected.projection)) throw new Error('A seleção mudou. Atualize o painel antes de agir.');
366    if (expected && ['start', 'cancel', 'logs'].includes(action)) {
367      // Refresh before rendered actions; never act on a renamed module or a replaced run silently.
368      await collect($, 'status');
369      if (p.generation !== expected.generation || actionProjection() !== expected.projection) throw new Error('O cadastro ou a execução mudou. Revise o módulo antes de agir.');
370      if (action === 'start') {
371        p.busy = false;
372        const allowed = moduleId === 'all' ? allAllowed() : startAllowed(moduleId);
373        p.busy = true;
374        if (!allowed) throw new Error('Início indisponível. Confira os diagnósticos e jobs ativos.');
375      }
376    }
377    await collect($, action, moduleId);
378    if (action === 'logs') {
379      const job = p.jobs[moduleId];
380      if (moduleId === 'all') { closeLogs(); p.chooseLogs = true; }
381      else if (job) { p.selectedLogs = { id: moduleId, runId: job.runId }; p.logTail = sanitizeTail(job.logTail); p.logTop = null; p.chooseLogs = false; }
382    }
383  } catch (error) { p.lastError = errorText(error); }
384  finally { p.busy = false; await publish($); }
385}
386const HELP = [
387  '/test-progress — abre ou fecha o painel; não inicia testes.',
388  '/test-progress list — lista módulos ativados, IDs, linguagens e diagnósticos.',
389  '/test-progress start <id|all> — inicia explicitamente os módulos ativados.',
390  '/test-progress status [id|all] — consulta o estado.',
391  '/test-progress logs [id|all] — consulta os logs; escolha um ID no painel.',
392  '/test-progress cancel [id|all] — cancela jobs ativos/recuperáveis desta sessão.',
393  '/test-progress help | paths — ajuda e caminhos instalados.',
394  'Acrescente --text para resposta textual, inclusive no modo headless.',
395  'Cadastro: <diretório da sessão>/.claude/test-progress.json.',
396  'Windows: PowerShell 5.1/7; cancelamento da árvore por Job Object.',
397  'Percentual = testes resolvidos / testes conhecidos. Total parcial pode crescer.',
398  'Cobertura de código e estimativa de tempo não são calculadas.',
399].join('\n');
400const LEGEND = ['● rodando  ✓ ok  ✗ falhou  ■ cancelado  ! erro ou órfão  ○ sem execução',
401  '▶ iniciar  ↻ reinicia os com erro  ■ cancelar  nome abre o log  × fecha o log  ~ total parcial',
402  '⇅ ordena: cadastro → nome → recentes → atenção (falhas primeiro)',
403  'A roda do mouse rola o log aberto; ↓ volta ao fim.',
404  'S/E/T: módulos com sucesso / com erro / total',
405  '✓ passaram  ✗ falharam  ⊘ ignorados',
406  '/test-progress help lista os comandos.'];
407function textLogs(moduleId: string) {
408  const ids = moduleId === 'all' ? Object.keys(p.jobs).sort() : [moduleId];
409  return ids.flatMap(id => { const job = p.jobs[id]; return job ? [`LOGS · ${id} · ${job.runId}\n${plainTail(job.logTail).join('\n')}`] : []; }).join('\n\n');
410}
411async function paneShown($: $) {
412  try { return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown); } catch { return false; }
413}
414// Runs whose failure already opened the pane once; closing it keeps it closed for those runs.
415const autoOpened = new Set<string>();
416// Once nothing is running, unseen failures open the pane on the first failed module's log, without
417// taking the keyboard. Passing runs never open it. Runs only by the tick, inside its serialized task.
418// The host may hold the pane until the terminal is wide enough; the band still reports meanwhile.
419async function autoOpen($: $) {
420  if (Object.values(p.jobs).some(job => ACTIVE.has(job.status))) return;
421  const failed = visible().filter(id => { const job = p.jobs[id]; return !!job && finishedWithFailure(job) &&
422    !p.seenRuns.includes(job.runId) && !autoOpened.has(job.runId); });
423  if (!failed.length) return;
424  for (const id of failed) autoOpened.add(p.jobs[id]!.runId);
425  // A native notification reaches the person away from the terminal, or where the pane waits for width.
426  // Their own channel and Notification hooks decide; a refusal or a missing channel changes nothing here.
427  const names = displayNames();
428  const notice = failed.map(id => { const count = p.jobs[id]!.failed ?? 0; return `${names[id] ?? id} (${count ? `${count} ✗` : 'erro'})`; });
429  void Promise.resolve().then(() => $.ui.notify(`Falharam: ${notice.join(', ')}`, { title: 'Test Progress' })).catch(() => {});
430  try {
431    if ((await $.ui.panes()).some(pane => pane.id === PANE)) return;
432    const first = failed[0]!;
433    await collect($, 'logs', first);
434    const job = p.jobs[first];
435    if (job) { p.selectedLogs = { id: first, runId: job.runId }; p.logTail = sanitizeTail(job.logTail); p.logTop = null; p.chooseLogs = false; }
436    await $.ui.open({ id: PANE, title: 'Test Progress' });
437  } catch { /* No pane in this surface: the band and the command remain. */ }
438}
439export const register: Register = on => {
440  on('session.start', async ($, e, next) => {
441    await hydrate($);
442    timer?.cancel();
443    timer = $.clock.every(1000, () => {
444      // A waiting command already refreshes state; never stack ticks behind it.
445      if (queued) return;
446      void serialized(async () => {
447        try {
448          await synchronizeIdentity($);
449          if (watcher && watcher.identity !== p.identity) stopWatcher();
450          if (await pollDue($)) {
451            // Every query returns the whole catalogue and all jobs; only a live selected run re-reads its log.
452            const selected = p.selectedLogs, job = selected && p.jobs[selected.id];
453            const live = !!selected && !!job && ACTIVE.has(job.status);
454            const streaming = liveJobs() && watch($, live ? selected.id : 'all');
455            if (streaming && watcher && Date.now() - watcher.seenAt > WATCH_STALE_MS) stopWatcher();
456            if (!streaming || !watcher) await collect($, live ? 'logs' : 'status', live ? selected.id : 'all');
457          }
458        } catch (error) { p.lastError = errorText(error); }
459        if (await paneShown($)) acknowledge();
460        else await autoOpen($);
461        await publish($);
462      });
463    });
464    await perform($, 'status');
465    try {
466      // Keep one generic hint; no catalogue updates overwrite another command's registration.
467      const existing = (await $.command.list()).find(command => command.name === 'test-progress');
468      if (existing && (existing.source !== 'plugin' || existing.plugin !== 'test-progress')) {
469        throw new Error('O comando test-progress já existe; origem não pertence a este plugin.');
470      }
471      await $.command.register({ name: 'test-progress', description: 'Painel de execução de testes em segundo plano',
472        argumentHint: '[list|start|status|logs|cancel|help|paths] [id|all] [--text]', immediate: true });
473      p.registrationError = '';
474    } catch (error) { p.registrationError = errorText(error); }
475    await publish($);
476    return next(e);
477  });
478  on('session.end', async ($, e, next) => {
479    // Poll survives clear/resume/branch; workers and timer aren't cancelled here.
480    stopWatcher();
481    await hydrate($);
482    p = { ...empty(), generation: p.generation + 1, registrationError: p.registrationError, collector: p.collector, sort: p.sort };
483    await publish($);
484    return next(e);
485  });
486  on('command.run', { command: 'test-progress' }, async ($, e, next) => {
487    await hydrate($);
488    // A name match alone isn't ownership: register() can have failed, or another
489    // command can have replaced our name since startup. Preserve its full result.
490    if (p.registrationError) return next(e);
491    try {
492      const current = (await $.command.list()).find(command => command.name === 'test-progress');
493      if (!current || current.source !== 'plugin' || current.plugin !== 'test-progress') {
494        p.registrationError = 'O comando test-progress está indisponível ou pertence a outra origem.';
495        await publish($);
496        return next(e);
497      }
498    } catch (error) {
499      p.registrationError = `Não foi possível confirmar a origem do comando: ${errorText(error)}`;
500      await publish($);
501      return next(e);
502    }
503    let command;
504    try { command = parseCommand(e.args); } catch (error) { return { text: errorText(error) }; }
505    if (command.action === 'help') return { text: HELP };
506    if (command.action === 'paths') return { text: [`Plugin: ${$.plugin.root}`,
507      `Rails: ${$.plugin.root}/adapters/rails/run.rb`, `Python: ${$.plugin.root}/adapters/python/run.py`,
508      `Karma: ${$.plugin.root}/adapters/karma/reporter.cjs`, `JUnit: ${$.plugin.root}/adapters/junit/pom.xml`,
509      `Ruby / RSpec: ${$.plugin.root}/adapters/ruby/run.rb`,
510      `Playwright: ${$.plugin.root}/adapters/playwright/reporter.cjs`, `Exemplos: ${$.plugin.root}/config.example.json`].join('\n') };
511    // A bare /test-progress toggles: an open pane closes, a closed one refreshes and opens.
512    if (!String(e.args ?? '').trim()) {
513      try {
514        if ((await $.ui.panes()).some(pane => pane.id === PANE)) { await $.ui.close({ id: PANE }); return {}; }
515      } catch { /* No pane record: open as usual. */ }
516    }
517    await perform($, command.action, command.moduleId);
518    if (command.text || command.action === 'list') return { text: textSummary() + (command.action === 'logs' ? `\n${textLogs(command.moduleId)}` : '') };
519    try {
520      const placement = await $.ui.open({ id: PANE, title: 'Test Progress', focus: true, closeOnEscape: true });
521      if (!placement.isPlaced) return { text: `${textSummary()}\nPainel aguardando espaço: ${placement.reason}` };
522      acknowledge(); await publish($);
523    } catch (error) { return { text: `${textSummary()}\nPainel indisponível: ${errorText(error)}` }; }
524    return {};
525  });
526  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
527    // Reading the host's value subscribes this drawing; each publish redraws it.
528    await read($, panel);
529    await hydrate($);
530    const { Box, Text, Button } = $.ui.resolve(e);
531    const expected = { generation: p.generation, projection: actionProjection() };
532    const guarded = (fn: () => Promise<void> | void) => async () => {
533      await synchronizeIdentity($);
534      if (p.generation !== expected.generation || actionProjection() !== expected.projection) {
535        p.lastError = 'A seleção mudou. Tente de novo.'; await publish($); return;
536      }
537      acknowledge();
538      await fn();
539      await publish($);
540    };
541    const bodyColumns = typeof e.props?.bodyColumns === 'number' ? e.props.bodyColumns : 80;
542    const narrow = bodyColumns < NARROW_COLUMNS;
543    // Button has no disabled prop in the native API. Dim and guard instead of inventing HTML attributes.
544    const button = (key: string, label: string, action: Action, id = 'all', allowed = true) =>
545      <Button key={key} label={label} plain dimColor={p.busy || !allowed}
546        onPress={guarded(async () => { if (allowed) await perform($, action, id, expected); })} />;
547    const local = (key: string, label: string, fn: () => void) => <Button key={key} label={label} plain onPress={guarded(fn)} />;
548    const names = displayNames();
549    const toggleLogs = (id: string) => guarded(async () => {
550      if (p.selectedLogs?.id === id && !p.chooseLogs) { closeLogs(); return; }
551      await perform($, 'logs', id, expected);
552    });
553    // ▶/■ leads the row, in a fixed cell so names stay aligned when a module has neither.
554    const runControl = (id: string) => {
555      const module = p.modules[id], job = p.jobs[id];
556      return <Box key="run" width={1}>{job && (ACTIVE.has(job.status) || job.recoveryRequired && job.cancellable) ? button(`cancel-${id}`, '■', 'cancel', id) :
557        module?.enabled ? button(`start-${id}`, '▶', 'start', id, startAllowed(id)) : null}</Box>;
558    };
559    const progress = (id: string, cells: number) => {
560      const job = p.jobs[id];
561      if (!job) return [<Text key="none" color="inactive">—</Text>];
562      if (job.recoveryRequired) return [<Text key="orphan" color="error">órfão: processo ainda vivo</Text>];
563      const counts = compactCounts(job).map(count => <Text key={count.text} color={count.color}>{`${count.text} `}</Text>);
564      if (!ACTIVE.has(job.status)) {
565        const outcome = outcomeText(job);
566        return [...counts, <Text key="outcome" color={outcome.color}>{outcome.text}</Text>];
567      }
568      const stopping = job.phase === 'cancellation-requested';
569      const bar = progressBar(job, cells);
570      return [
571        <Box key="bar" flexDirection="row" width={cells + 1}>
572          {bar.done ? <Text key="done" color={stopping ? 'inactive' : 'suggestion'}>{bar.done}</Text> : null}
573          <Text key="rest" color="subtle">{bar.rest}</Text>
574        </Box>,
575        <Box key="pct" width={7}><Text dimColor={stopping}>{compactPercent(job)}</Text></Box>,
576        ...(stopping ? [<Text key="stopping" color="inactive">parando…</Text>] : counts),
577      ];
578    };
579    // The runner's own colors win; a line without any keeps the dim tail and the red error guess.
580    const logLine = (line: string, key: string) => {
581      const spans = ansiSpans(line);
582      const text = spans.map(span => span.text).join('');
583      if (!spans.some(span => span.style)) return /\b(ERROR|FAIL(ED|URE)?)\b/.test(text) ?
584        <Text key={key} wrap="truncate-end" color="error">{`│ ${text}`}</Text> :
585        <Text key={key} wrap="truncate-end" dimColor>{`│ ${text}`}</Text>;
586      return <Text key={key} wrap="truncate-end">
587        <Text dimColor>{'│ '}</Text>
588        {spans.map((span, j) => span.style ? <Text key={`s${j}`} {...span.style}>{span.text}</Text> : <Text key={`s${j}`} dimColor>{span.text}</Text>)}
589      </Text>;
590    };
591    const logBlock = (id: string) => {
592      const selected = p.selectedLogs;
593      if (selected?.id !== id) return [];
594      const job = p.jobs[id];
595      const changed = job?.runId !== selected.runId;
596      const { top, max } = logWindow(), total = p.logTail.length;
597      const lines = p.logTail.slice(top, top + LOG_ROWS);
598      const range = total <= LOG_ROWS ? `${total} ${total === 1 ? 'linha' : 'linhas'}` :
599        top === max ? `últimas ${lines.length} de ${total} linhas` : `linhas ${top + 1}–${top + lines.length} de ${total}`;
600      const header = `log · run ${selected.runId.slice(0, 8)} · ${range}`;
601      return [
602        <Box key={`log-${id}`} flexDirection="column" paddingLeft={4}>
603          <Box key="log-header" flexDirection="row" justifyContent="space-between">
604            <Text key="log-title" color="inactive" wrap="truncate-end">{`│ ${header}`}</Text>
605            <Box key="log-actions" flexDirection="row" flexShrink={0} gap={1}>
606              {top < max ? local(`end-logs-${id}`, '↓', () => { p.logTop = null; }) : null}
607              {local(`close-logs-${id}`, '×', closeLogs)}
608            </Box>
609          </Box>
610          {changed && job ? <Box key="changed" flexDirection="row" gap={1}>
611            <Text color="inactive">│ nova execução</Text>
612            {button('select-current-logs', '↻', 'logs', id)}
613          </Box> : null}
614          {lines.length ? lines.map((line, i) => logLine(line, `log-${selected.runId}-${top + i}`)) :
615            <Text key="empty" color="inactive">│ sem saída ainda</Text>}
616        </Box>,
617      ];
618    };
619    // Errors are the line the person most needs whole: they wrap, then point at the next step.
620    const problemBlock = (id: string) => {
621      const job = p.jobs[id];
622      const problems = [...diagnostics(id).map(diagnosticText), ...(job?.error ? [job.error] : [])];
623      if (!problems.length) return [];
624      const hint = job && p.selectedLogs?.id !== id ? '→ clique no nome para abrir o log' + (job.phase === 'no-progress-observed' || /eventos de progresso/.test(job.error ?? '') ?
625        ' · confira o "adapter" do módulo' : '') : '';
626      return [
627        <Box key="problems" flexDirection="column" paddingLeft={4}>
628          {problems.slice(0, 2).map((item, i) => <Text key={`problem-${i}`} color="error" wrap="wrap">{item}</Text>)}
629          {hint ? <Text key="hint" dimColor wrap="wrap">{hint}</Text> : null}
630        </Box>,
631      ];
632    };
633    const row = (id: string) => {
634      const job = p.jobs[id], module = p.modules[id];
635      const { glyph, color } = statusGlyph(job);
636      const time = job ? clock(job.elapsedMs) : '';
637      const live = !!job && ACTIVE.has(job.status);
638      const head = [
639        <Box key="glyph" width={1}><Text color={color} bold>{glyph}</Text></Box>,
640        runControl(id),
641        // With a run, the name opens and closes its log.
642        <Box key="name" flexGrow={1} flexShrink={1} overflow="hidden">{job ?
643          <Button key={`logs-${id}`} label={names[id] ?? id} plain dimColor={!module?.enabled || p.busy && p.selectedLogs?.id !== id} onPress={toggleLogs(id)} /> :
644          <Text wrap="truncate" bold={!!module?.enabled} dimColor={!module?.enabled}>{names[id] ?? id}</Text>}</Box>,
645      ];
646      const tail = <Box key="tail" flexDirection="row" flexShrink={0} gap={1}>
647        {time ? <Text key="time" dimColor>{time}</Text> : null}
648      </Box>;
649      // The name takes the free space, so results and actions stay right-aligned at any width.
650      // Narrow: a finished result fits the name line; only a live bar takes a second one.
651      const middle = <Box key="middle" flexDirection="row" flexShrink={0}>{progress(id, narrow ? 10 : 12)}</Box>;
652      return <Box key={`module-${id}`} flexDirection="column">
653        <Box key="line" flexDirection="row" gap={1}>{head}{narrow && live ? null : middle}{tail}</Box>
654        {narrow && live ? <Box key="progress" flexDirection="row" paddingLeft={4}>{progress(id, 10)}</Box> : null}
655        {problemBlock(id)}
656        {logBlock(id)}
657      </Box>;
658    };
659    const ids = sortModuleIds(visible(), p.modules, p.jobs, p.sort);
660    const configured = ['absent', 'valid'].includes(p.workspace?.moduleConfig?.status ?? 'absent');
661    const counts = summaryCounts(ids, p.jobs);
662    return <Box key="module-list" flexDirection="column">
663      <Box key="toolbar" flexDirection="row" justifyContent="space-between">
664        <Box key="summary" flexDirection="row" gap={2}>
665          <Box key="counts" flexDirection="row">
666            <Text key="passed" color="success">{`${counts.passed}`}</Text>
667            <Text key="sep-1" dimColor>/</Text>
668            <Text key="failed" color="error">{`${counts.failed}`}</Text>
669            <Text key="sep-2" dimColor>/</Text>
670            <Text key="total" dimColor>{`${counts.total}`}</Text>
671          </Box>
672          {enabled().length >= 2 ? button('start-all', '▶ Todos', 'start', 'all', allAllowed()) : null}
673          {/* Button takes no color at rest; the whole label is the target and turns red under the pointer. Hidden while nothing failed. */}
674          {failedIds().length ? <Box key="failed-action"><Button key="start-failed" label="↻ Apenas com erro" plain hover={{ color: 'error' }}
675            dimColor={p.busy || !failedAllowed()} onPress={guarded(async () => { if (failedAllowed()) await startFailed($, expected); })} /></Box> : null}
676          {p.busy ? <Text key="busy" dimColor>…</Text> : null}
677        </Box>
678        <Box key="toolbar-actions" flexDirection="row" gap={1}>
679          {ids.length >= 2 ? local('sort', p.sort === 'order' ? '⇅' : `⇅ ${sortLabels[p.sort]}`, () => { p.sort = nextSort(p.sort); }) : null}
680          {local('help', '?', () => { p.showHelp = !p.showHelp; })}
681        </Box>
682      </Box>
683      {ids.map(row)}
684      {!ids.length && configured && !p.workspace?.error ? <Text key="empty" dimColor>Nenhum módulo em .claude/test-progress.json</Text> : null}
685      {p.chooseLogs ? <Text key="choose-logs" dimColor>Clique no nome de um módulo para ver o log.</Text> : null}
686      {p.selectedLogs && !ids.includes(p.selectedLogs.id) ? logBlock(p.selectedLogs.id) : null}
687      {p.workspace?.error ? <Text key="config-error" color="error" wrap="wrap">{`Configuração: ${p.workspace.error}`}</Text> : null}
688      {(p.workspace?.moduleConfig?.diagnostics ?? []).map((item, i) => <Text key={`config-${i}`} color="error" wrap="wrap">{`Configuração: ${diagnosticText(item)}`}</Text>)}
689      {(p.stateDiagnostics['*'] ?? []).map((item, i) => <Text key={`state-${i}`} color="error" wrap="wrap">{`Estado: ${diagnosticText(item)}`}</Text>)}
690      {p.lastError ? <Text key="last-error" color="error" wrap="wrap">{p.lastError}</Text> : null}
691      {p.registrationError ? <Text key="registration" color="error" wrap="wrap">{`Registro: ${p.registrationError}`}</Text> : null}
692      {p.showHelp ? LEGEND.map((line, i) => <Text key={`legend-${i}`} dimColor wrap="wrap">{line}</Text>) : null}
693    </Box>;
694  });
695  // With a log open, the wheel moves the log's own window; at its edges, and for the keys, the pane scrolls.
696  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
697    await hydrate($);
698    if (!p.selectedLogs || e.origin.kind !== 'person' || !e.pointer) return next(e);
699    const { top, max } = logWindow(), to = Math.min(max, Math.max(0, top + e.by));
700    if (to === top) return next(e);
701    p.logTop = to === max ? null : to;
702    await publish($);
703    return {};
704  });
705  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
706    const existing = await next(e);
707    if (e.props?.hasSurvey) return existing;
708    await read($, panel);
709    await hydrate($);
710    const seen = new Set(p.seenRuns);
711    const live = (id: string) => { const job = p.jobs[id]!; return ACTIVE.has(job.status) || !!job.recoveryRequired; };
712    const failed = (id: string) => { const job = p.jobs[id]!; return ['failed', 'error'].includes(job.status) || (job.failed ?? 0) > 0; };
713    const ids = visible().filter(id => p.jobs[id]);
714    const active = ids.filter(live), unseenFailures = ids.filter(id => !live(id) && failed(id) && !seen.has(p.jobs[id]!.runId));
715    // Nothing running and nothing new to report: the band steps aside.
716    if (!active.length && !unseenFailures.length) return existing;
717    const passed = ids.filter(id => !live(id) && !failed(id) && p.jobs[id]!.status === 'completed');
718    const shown = [...active, ...unseenFailures, ...passed];
719    const { Box, Text } = $.ui.resolve(e);
720    const names = displayNames();
721    const parts = (id: string) => {
722      const job = p.jobs[id]!, { glyph, color } = statusGlyph(job), pct = compactPercent(job);
723      return { glyph, color, name: names[id] ?? id, orphan: !!job.recoveryRequired,
724        progress: job.recoveryRequired || !live(id) ? '' : pct === '—' ? ` ${job.resolved ?? 0}` : ` ${pct}`, dim: pct === '—',
725        failed: !job.recoveryRequired && (job.failed ?? 0) > 0 ? ` ✗${job.failed}` : '' };
726    };
727    const item = (id: string) => {
728      const { glyph, color, name, orphan, progress, dim, failed } = parts(id);
729      return <Box key={`summary-${id}`} flexDirection="row">
730        <Text key="glyph" color={color}>{`${glyph} `}</Text>
731        <Text key="name">{name}</Text>
732        {orphan ? <Text key="state" color="error">{' órfão'}</Text> :
733          progress ? (dim ? <Text key="resolved" dimColor>{progress}</Text> : <Text key="pct">{progress}</Text>) : null}
734        {failed ? <Text key="failed" color="error">{failed}</Text> : null}
735      </Box>;
736    };
737    // As many items as the line holds, leaving room for the host's own corner control and the "+N" tail.
738    const SEP = '  ·  ', width = (s: string) => [...s].length;
739    const itemWidth = (id: string) => { const x = parts(id); return 2 + width(x.name) + (x.orphan ? 6 : width(x.progress)) + width(x.failed); };
740    const room = (typeof e.props?.bodyColumns === 'number' ? e.props.bodyColumns : 80) - BAND_RESERVE;
741    let fit = 0, used = 0;
742    for (const id of shown) {
743      const end = used + (fit ? SEP.length : 0) + itemWidth(id), rest = shown.length - fit - 1;
744      if (fit && end + (rest ? width(`${SEP}+${rest}`) : 0) > room) break;
745      fit++; used = end;
746    }
747    const children = shown.slice(0, fit).flatMap((id, i) => [...(i ? [<Text key={`sep-${i}`} dimColor>{SEP}</Text>] : []), item(id)]);
748    return <Box flexDirection="column">
749      {existing ?? null}
750      <Box key="band" flexDirection="row" overflow="hidden">
751        {children}
752        {shown.length > fit ? <Text key="more" dimColor>{`${SEP}+${shown.length - fit}`}</Text> : null}
753      </Box>
754    </Box>;
755  });
756};
757
runner/module-presentation.mjs 257 lines
1// Pure presentation rules shared by native surfaces and text. No host APIs.
2import { validModuleId } from './module-id.mjs';
3import { SCHEMA_VERSION } from './schema.mjs';
4export const ACTIVE = new Set(['preparing', 'running']);
5export const labels = { preparing: 'Preparando', running: 'Em execução', completed: 'Encerrado',
6  failed: 'Encerrado com falha', cancelled: 'Cancelado', error: 'Erro' };
7const record = value => value && typeof value === 'object' && !Array.isArray(value);
8export function validateEnvelope(data) {
9  const config = data?.workspace?.moduleConfig;
10  if (data?.schemaVersion !== SCHEMA_VERSION || typeof data.ok !== 'boolean' || !record(data.modules) ||
11      !record(data.jobs) || !record(data.stateDiagnostics) || !record(config) ||
12      !['absent', 'valid', 'invalid'].includes(config.status) ||
13      ![SCHEMA_VERSION, null].includes(config.schemaVersion) || !Array.isArray(config.enabledIds)) {
14    throw new Error(`Versão de resposta do coletor incompatível; requer schemaVersion ${SCHEMA_VERSION}.`);
15  }
16  for (const [id, module] of Object.entries(data.modules)) {
17    if (!validModuleId(id) || module?.id !== id || typeof module.label !== 'string' ||
18        !(module.language === null || typeof module.language === 'string') ||
19        !Number.isFinite(module.order) || typeof module.enabled !== 'boolean' ||
20        typeof module.directoryPresent !== 'boolean' || typeof module.origin !== 'string' || !Array.isArray(module.diagnostics)) {
21      throw new Error('Catálogo de módulos inválido na resposta do coletor.');
22    }
23  }
24  for (const [id, job] of Object.entries(data.jobs)) {
25    if (!validModuleId(id) || job?.schemaVersion !== SCHEMA_VERSION || job.moduleId !== id ||
26        typeof job.runId !== 'string' || !job.runId || job.source !== 'config' || !Object.prototype.hasOwnProperty.call(labels, job.status) ||
27        typeof job.phase !== 'string' || job.command !== undefined && !Array.isArray(job.command)) {
28      throw new Error('Snapshot de job incompatível na resposta do coletor.');
29    }
30  }
31  for (const [id, items] of Object.entries(data.stateDiagnostics)) {
32    if (id !== '*' && !validModuleId(id) || !Array.isArray(items)) throw new Error('Diagnósticos de estado inválidos na resposta do coletor.');
33  }
34  if (config.enabledIds.some(id => !validModuleId(id))) throw new Error('IDs de módulos inválidos na resposta do coletor.');
35  return data;
36}
37export function parseCommand(raw) {
38  const tokens = String(raw ?? '').trim().split(/\s+/).filter(Boolean);
39  const text = tokens.includes('--text');
40  const args = tokens.filter(token => token !== '--text');
41  const action = args[0] ?? 'status';
42  if (!['list', 'start', 'status', 'logs', 'cancel', 'help', 'paths'].includes(action) || args.length > 2 ||
43      (['list', 'help', 'paths'].includes(action) && args.length > 1) ||
44      (action === 'start' && args.length !== 2)) throw new Error('Use /test-progress help para consultar os comandos.');
45  const moduleId = args[1] ?? 'all';
46  if (moduleId !== 'all' && !validModuleId(moduleId)) throw new Error('ID de módulo inválido. Use list para ver os IDs.');
47  return { action, moduleId, text };
48}
49export function visibleModuleIds(modules, jobs, diagnostics) {
50  const enabled = Object.keys(modules).filter(id => modules[id].enabled)
51    .sort((a, b) => modules[a].order - modules[b].order || a.localeCompare(b));
52  const retained = new Set(Object.keys(jobs).filter(id => ACTIVE.has(jobs[id].status) || jobs[id].recoveryRequired));
53  for (const id of Object.keys(diagnostics)) if (id !== '*' && validModuleId(id) && diagnostics[id]?.length) retained.add(id);
54  return [...enabled, ...[...retained].filter(id => !enabled.includes(id)).sort()];
55}
56// Panel orderings, cycled by one toolbar button. `order` keeps the catalogue as visibleModuleIds returns it.
57export const SORTS = ['order', 'name', 'recent', 'attention'];
58export const sortLabels = { order: 'cadastro', name: 'nome', recent: 'recentes', attention: 'atenção' };
59export const nextSort = sort => SORTS[(SORTS.indexOf(sort) + 1) % SORTS.length];
60const startedAt = job => { const at = Date.parse(job?.startedAt ?? ''); return Number.isFinite(at) ? at : -Infinity; };
61// What needs a look first: failures and orphans, live runs, cancellations, passes, then modules never run.
62function attention(job) {
63  if (!job) return 4;
64  if (job.recoveryRequired || job.status === 'error') return 0;
65  if (ACTIVE.has(job.status)) return 1;
66  if (job.status === 'failed' || (job.failed ?? 0) > 0) return 0;
67  return job.status === 'cancelled' ? 2 : 3;
68}
69export function sortModuleIds(ids, modules, jobs, sort) {
70  const name = id => modules[id]?.label ?? id;
71  const by = {
72    name: (a, b) => name(a).localeCompare(name(b), 'pt-BR', { sensitivity: 'base', numeric: true }),
73    // Most recent run first; modules without a run keep catalogue order at the end.
74    recent: (a, b) => startedAt(jobs[b]) - startedAt(jobs[a]),
75    attention: (a, b) => attention(jobs[a]) - attention(jobs[b]) || startedAt(jobs[b]) - startedAt(jobs[a]),
76  }[sort];
77  if (!by) return ids;
78  const position = new Map(ids.map((id, i) => [id, i]));
79  return [...ids].sort((a, b) => by(a, b) || position.get(a) - position.get(b));
80}
81export const moduleTitle =(id, module) => `${module?.label ?? id} · ${id}`;
82export function percentage(job) {
83  return typeof job.percent === 'number' && Number.isFinite(job.percent) ? `${job.percent.toFixed(job.percent % 1 ? 1 : 0)}%` : '—';
84}
85export function progressText(job) {
86  if (job.total === 0) return '— [ sem testes ]';
87  if (job.total === null || job.total === undefined || typeof job.percent !== 'number' || !Number.isFinite(job.percent)) return '— [ total desconhecido ]';
88  const filled = Math.max(0, Math.min(16, Math.round(job.percent / 100 * 16)));
89  return `${percentage(job)} [${'■'.repeat(filled)}${'·'.repeat(16 - filled)}]`;
90}
91export function countSummary(job) {
92  const denominator = job.total == null ? 'total desconhecido' : `${job.total} no total${job.totalStable ? '' : ' · total parcial'}`;
93  return `${job.resolved ?? 0} resolvidos / ${denominator}`;
94}
95export const diagnosticText = item => typeof item === 'string' ? item : item?.message ?? item?.code ?? 'Diagnóstico indisponível.';
96export function sanitizeText(value) {
97  return String(value ?? '').replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
98    .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\x1b[@-_]/g, '')
99    .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, '    ');
100}
101// Log lines keep their SGR color sequences for the panel; every other escape and control goes.
102const SGR = /\x1b\[[\d;:]*m/g;
103export function sanitizeLogText(value) {
104  const text = String(value ?? '').replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '');
105  let result = '', last = 0;
106  for (const match of text.matchAll(SGR)) {
107    result += sanitizeText(text.slice(last, match.index)) + match[0];
108    last = match.index + match[0].length;
109  }
110  return result + sanitizeText(text.slice(last));
111}
112export const sanitizeTail = tail => Array.isArray(tail) ? tail.slice(-200).map(sanitizeLogText) : [];
113export const plainTail = tail => Array.isArray(tail) ? tail.slice(-200).map(sanitizeText) : [];
114// Named colors follow the person's terminal palette; the 256-color cube and truecolor become hex.
115const NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
116const named = index => (index < 8 ? NAMES[index] : `${NAMES[index - 8]}Bright`);
117const hex = (r, g, b) => `#${[r, g, b].map(part => Math.max(0, Math.min(255, part | 0)).toString(16).padStart(2, '0')).join('')}`;
118function palette(index) {
119  if (!(index >= 0 && index <= 255)) return undefined;
120  if (index < 16) return named(index);
121  if (index >= 232) { const level = 8 + (index - 232) * 10; return hex(level, level, level); }
122  const cube = index - 16, level = part => (part ? 55 + part * 40 : 0);
123  return hex(level(Math.floor(cube / 36)), level(Math.floor(cube / 6) % 6), level(cube % 6));
124}
125// Reads the extended color that starts at params[i] (38/48), returning it and how many params it used.
126function extended(params, i) {
127  if (params[i].length > 1) {
128    const parts = params[i];
129    if (parts[1] === 5) return [palette(parts[2]), 1];
130    if (parts[1] === 2) { const rgb = parts.slice(-3); return [rgb.length === 3 ? hex(...rgb) : undefined, 1]; }
131    return [undefined, 1];
132  }
133  const mode = params[i + 1]?.[0];
134  if (mode === 5) return [palette(params[i + 2]?.[0]), 3];
135  if (mode === 2) return [hex(params[i + 2]?.[0], params[i + 3]?.[0], params[i + 4]?.[0]), 5];
136  return [undefined, 1];
137}
138function applySgr(style, body) {
139  const params = (body === '' ? '0' : body).split(';').map(param => param.split(':').map(part => (part === '' ? 0 : Number(part))));
140  for (let i = 0; i < params.length;) {
141    const code = params[i][0];
142    if (code === 0) { for (const name of Object.keys(style)) delete style[name]; i++; continue; }
143    if (code === 38 || code === 48) {
144      const [color, used] = extended(params, i);
145      if (color) style[code === 38 ? 'color' : 'backgroundColor'] = color;
146      i += used; continue;
147    }
148    if (code === 1) style.bold = true;
149    else if (code === 2) style.dimColor = true;
150    else if (code === 3) style.italic = true;
151    else if (code === 4) style.underline = true;
152    else if (code === 7) style.inverse = true;
153    else if (code === 9) style.strikethrough = true;
154    else if (code === 22) { delete style.bold; delete style.dimColor; }
155    else if (code === 23) delete style.italic;
156    else if (code === 24) delete style.underline;
157    else if (code === 27) delete style.inverse;
158    else if (code === 29) delete style.strikethrough;
159    else if (code >= 30 && code <= 37) style.color = named(code - 30);
160    else if (code === 39) delete style.color;
161    else if (code >= 40 && code <= 47) style.backgroundColor = named(code - 40);
162    else if (code === 49) delete style.backgroundColor;
163    else if (code >= 90 && code <= 97) style.color = named(code - 90 + 8);
164    else if (code >= 100 && code <= 107) style.backgroundColor = named(code - 100 + 8);
165    i++;
166  }
167}
168// Splits a sanitized log line into runs of text and the SGR style each one carries (null when plain).
169export function ansiSpans(line) {
170  const text = String(line ?? '');
171  const spans = [], style = {};
172  let last = 0;
173  const push = value => {
174    if (!value) return;
175    const current = Object.keys(style).length ? { ...style } : null;
176    const previous = spans[spans.length - 1];
177    if (previous && JSON.stringify(previous.style) === JSON.stringify(current)) previous.text += value;
178    else spans.push({ text: value, style: current });
179  };
180  for (const match of text.matchAll(SGR)) {
181    push(text.slice(last, match.index));
182    applySgr(style, match[0].slice(2, -1));
183    last = match.index + match[0].length;
184  }
185  push(text.slice(last));
186  return spans;
187}
188// Compact panel and band vocabulary: one-column glyphs so terminal columns stay aligned.
189export function statusGlyph(job) {
190  if (!job) return { glyph: '○', color: 'inactive' };
191  if (job.recoveryRequired || job.status === 'error') return { glyph: '!', color: 'error' };
192  if (ACTIVE.has(job.status)) return job.phase === 'cancellation-requested' ?
193    { glyph: '◌', color: 'inactive' } : { glyph: '●', color: 'warning' };
194  if (job.status === 'cancelled') return { glyph: '■', color: 'inactive' };
195  if (job.status === 'failed' || (job.failed ?? 0) > 0) return { glyph: '✗', color: 'error' };
196  return { glyph: '✓', color: 'success' };
197}
198const knownPercent = job => job.total != null && job.total > 0 && typeof job.percent === 'number' && Number.isFinite(job.percent);
199// A thin bar while a run is live: done cells in the accent, the rest subtle. Unknown totals draw dashes.
200export function progressBar(job, cells = 12) {
201  if (!knownPercent(job)) return { done: '', rest: '╌'.repeat(cells) };
202  const filled = Math.max(0, Math.min(cells, Math.round(job.percent / 100 * cells)));
203  return { done: '━'.repeat(filled), rest: '━'.repeat(cells - filled) };
204}
205// A finished run reads as its result, not as a full bar.
206export function outcomeText(job) {
207  if (job.status === 'error') return { text: 'erro', color: 'error' };
208  if (job.status === 'cancelled') return { text: 'cancelado', color: 'inactive' };
209  if (job.total === 0) return { text: '-', color: 'inactive' };
210  // Under `exit` without events the exit code is the whole result.
211  if (job.total == null && job.adapter === 'exit' && Number.isInteger(job.exitCode)) return { text: `exit ${job.exitCode}`, color: 'inactive' };
212  if (job.total == null) return { text: 'total desconhecido', color: 'inactive' };
213  return { text: `· ${job.total}`, color: 'inactive' };
214}
215// A finished run that failed: a failed or errored status, or any failed test.
216export function finishedWithFailure(job) {
217  return !!job && !ACTIVE.has(job.status) && !job.recoveryRequired &&
218    (['failed', 'error'].includes(job.status) || (job.failed ?? 0) > 0);
219}
220// Toolbar summary S/E/T: modules that passed, modules that failed, all modules.
221export function summaryCounts(ids, jobs) {
222  const failed = ids.filter(id => finishedWithFailure(jobs[id])).length;
223  const passed = ids.filter(id => jobs[id]?.status === 'completed' && !finishedWithFailure(jobs[id])).length;
224  return { passed, failed, total: ids.length };
225}
226// Protocol lines are the collector's input, not the person's log; runs of blank lines collapse to one.
227// A line holding only color sequences is as blank as an empty one.
228const blank = line => !line.replace(SGR, '').trim();
229export function readableTail(lines, prefix) {
230  const kept = [];
231  for (const line of lines) {
232    if (line.includes(prefix)) continue;
233    if (blank(line) && (!kept.length || blank(kept[kept.length - 1]))) continue;
234    kept.push(line);
235  }
236  while (kept.length && blank(kept[kept.length - 1])) kept.pop();
237  return kept;
238}
239export function compactPercent(job) {
240  if (job.total === 0) return 'sem testes';
241  if (!knownPercent(job)) return '—';
242  // A finished run's total is final, whatever the stream last claimed.
243  return `${ACTIVE.has(job.status) && !job.totalStable ? '~' : ''}${percentage(job)}`;
244}
245// ⊘ renders flush against the next digit in common terminal fonts, so it keeps a space.
246export function compactCounts(job) {
247  return [['passed', '✓', 'success'], ['failed', '✗', 'error'], ['skipped', '⊘ ', 'inactive']]
248    .filter(([key]) => (job?.[key] ?? 0) > 0).map(([key, glyph, color]) => ({ text: `${glyph}${job[key]}`, color }));
249}
250export function clock(ms) {
251  if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '';
252  const total = Math.floor(ms / 1000), h = Math.floor(total / 3600), m = Math.floor(total % 3600 / 60), s = total % 60;
253  const pad = value => String(value).padStart(2, '0');
254  return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
255}
256export const configStatus = { valid: 'válido', absent: 'ausente', invalid: 'inválido' };
257
runner/module-id.mjs 14 lines
1const reserved = new Set(['all', 'constructor', 'prototype', 'con', 'prn', 'aux', 'nul']);
2
3export function validModuleId(id) {
4  return typeof id === 'string' && /^[a-z][a-z0-9-]{0,47}$/.test(id) &&
5    !reserved.has(id) && !/^(?:com|lpt)[1-9]$/.test(id);
6}
7
8export function requireModuleId(id) {
9  if (validModuleId(id)) return id;
10  const error = new Error('ID de módulo inválido ou reservado.');
11  error.code = 'INVALID_MODULE_ID';
12  throw error;
13}
14
runner/schema.mjs 3 lines
1// Version of every persisted record (configuration, state, batches) and of the collector response.
2export const SCHEMA_VERSION = 1;
3
types/index.d.ts 94 lines
1// Test Progress state contract: what the pane and the band draw, held by the host so a hot reload keeps it.
2
3/** One module of the workspace catalogue, as the collector reports it. */
4export type TestProgressModule = {
5  id: string
6  label: string
7  language: string | null
8  order: number
9  enabled: boolean
10  directoryPresent: boolean
11  origin: string
12  diagnostics: TestProgressDiagnostic[]
13}
14
15/** A diagnostic is either plain text or a coded message. */
16export type TestProgressDiagnostic = string | { code?: string; message?: string; blocking?: boolean }
17
18/** One run's snapshot. Counts are observed, never estimated; `total: null` is unknown, not zero. */
19export type TestProgressJob = {
20  schemaVersion: number
21  moduleId: string
22  runId: string
23  source: 'config'
24  status: 'preparing' | 'running' | 'completed' | 'failed' | 'cancelled' | 'error'
25  phase: string
26  adapter?: string
27  total?: number | null
28  totalStable?: boolean
29  resolved?: number
30  passed?: number
31  failed?: number
32  skipped?: number
33  percent?: number | null
34  exitCode?: number | null
35  elapsedMs?: number
36  startedAt?: string | null
37  endedAt?: string | null
38  error?: string
39  recoveryRequired?: boolean
40  cancellable?: boolean
41  logPath?: string
42  logTail?: string[]
43  heartbeatAt?: string | null
44  lastOutputAt?: string | null
45  lastProgressAt?: string | null
46  collectorRuntime?: { version: string; source: string }
47  nodeRuntime?: { version: string; source: string; nvmrc?: string | null }
48}
49
50export type TestProgressWorkspace = {
51  moduleConfig?: {
52    status: 'absent' | 'valid' | 'invalid'
53    schemaVersion: number | null
54    enabledIds: string[]
55    diagnostics?: TestProgressDiagnostic[]
56  }
57  stateBlocked?: boolean
58  error?: string
59}
60
61/** The Node the collector bootstrapped, reused for later queries of the same identity. */
62export type TestProgressCollector = { identity: string; path: string; source: string }
63
64/** Everything the pane and band draw, plus the session identity it belongs to. */
65export type TestProgressPanel = {
66  identity: string
67  generation: number
68  sessionOwner: string
69  modules: Record<string, TestProgressModule>
70  jobs: Record<string, TestProgressJob>
71  stateDiagnostics: Record<string, TestProgressDiagnostic[]>
72  workspace: TestProgressWorkspace | null
73  busy: boolean
74  lastError: string
75  registrationError: string
76  selectedLogs: { id: string; runId: string } | null
77  logTail: string[]
78  /** First log line shown; null follows the end as the run writes. */
79  logTop: number | null
80  chooseLogs: boolean
81  showHelp: boolean
82  /** How the pane orders modules; kept across identities so a new session reads the same way. */
83  sort: 'order' | 'name' | 'recent' | 'attention'
84  /** Runs the person has seen in the pane; the band keeps only unseen failures. */
85  seenRuns: string[]
86  collector: TestProgressCollector | null
87}
88
89declare module 'claude-code' {
90  interface PluginState {
91    'test-progress': { panel: TestProgressPanel }
92  }
93}
94