Skip to content

Commit b14da29

Browse files
Justin Helmerclaude
andcommitted
feat(map): tidy terminal UX — suppress raw agent output, heartbeat, clean finish
`polylane map` inherited the agent's stdout, so a run dumped the agent's raw markdown (literal **bold**, tool-call noise) into the terminal with no framing, and showed nothing at all until the agent's final buffered response landed — which reads as hung during a multi-minute map. Now the agent's output is captured, not streamed: we print a short intro, a heartbeat with elapsed time while it runs, and a clean completion line pointing at the workspace. The captured output is surfaced only if the run fails, so a failure is never silent. The rendered result (topology, issues, first-run thread) lives in the workspace, not the terminal. Note: mapping submits its topology in a single shot at the end, so there is no per-node streaming to show here; a live phase indicator in the console is a separate follow-up. Part of unified onboarding (coreplanelabs/nominal#1165). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 55ac92f commit b14da29

1 file changed

Lines changed: 72 additions & 8 deletions

File tree

‎src/commands/map.ts‎

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Command } from '../command';
66
import type { Config } from '../config/schema';
77
import type { GlobalFlags } from '../types/flags';
88
import { AGENTS, AGENT_IDS, MAPPING_PROMPT, agentById, type AgentSetup, type HeadlessRun } from '../agents/registry';
9+
import { consoleBaseUrl } from '../auth/oauth';
910
import { isInteractive } from '../utils/env';
1011
import { promptSelect } from '../utils/prompt';
1112
import { CLIError } from '../errors/base';
@@ -47,17 +48,66 @@ export function resolveRunnable(
4748
return { viaSibling: false };
4849
}
4950

50-
function runChild(bin: string, argv: string[], env?: Record<string, string>): Promise<number> {
51+
function formatElapsed(ms: number): string {
52+
const total = Math.floor(ms / 1000);
53+
const m = Math.floor(total / 60);
54+
const s = total % 60;
55+
return m > 0 ? `${m}m ${s}s` : `${s}s`;
56+
}
57+
58+
/**
59+
* Run the agent headlessly, capturing its output instead of streaming it. A
60+
* coding agent's raw output is markdown and mostly tool calls, so dumping it
61+
* into the terminal is a wall of noise; the rendered result lives in the
62+
* workspace. We show a heartbeat while it runs and surface the captured log
63+
* only if it fails. Returns the exit code and everything the agent printed.
64+
*/
65+
function runMapping(
66+
bin: string,
67+
argv: string[],
68+
env: Record<string, string> | undefined,
69+
onStart: () => void
70+
): Promise<{ code: number; output: string }> {
5171
return new Promise((resolve, reject) => {
5272
const child = spawn(bin, argv, {
53-
stdio: 'inherit',
73+
stdio: ['ignore', 'pipe', 'pipe'],
5474
cwd: process.cwd(),
5575
env: { ...process.env, ...(env ?? {}) },
5676
});
57-
child.on('error', (err) =>
58-
reject(new CLIError(`Failed to launch ${bin}: ${(err as Error).message}`, ExitCode.GENERAL))
59-
);
60-
child.on('close', (code) => resolve(code ?? 0));
77+
78+
// Keep at most the tail of the output so a long run can't grow unbounded;
79+
// only the end matters when we surface it after a failure.
80+
const MAX_CAPTURE = 64 * 1024;
81+
let output = '';
82+
const capture = (chunk: Buffer): void => {
83+
output += chunk.toString();
84+
if (output.length > MAX_CAPTURE) output = output.slice(output.length - MAX_CAPTURE);
85+
};
86+
child.stdout?.on('data', capture);
87+
child.stderr?.on('data', capture);
88+
89+
onStart();
90+
const started = Date.now();
91+
const tty = Boolean(process.stderr.isTTY);
92+
const heartbeat = setInterval(() => {
93+
const elapsed = formatElapsed(Date.now() - started);
94+
if (tty) process.stderr.write(`\r\x1b[2mMapping… ${elapsed} elapsed\x1b[0m\x1b[K`);
95+
else process.stderr.write(`Mapping… ${elapsed} elapsed\n`);
96+
}, 5000);
97+
98+
const finish = (): void => {
99+
clearInterval(heartbeat);
100+
if (tty) process.stderr.write('\r\x1b[K');
101+
};
102+
103+
child.on('error', (err) => {
104+
finish();
105+
reject(new CLIError(`Failed to launch ${bin}: ${(err as Error).message}`, ExitCode.GENERAL));
106+
});
107+
child.on('close', (code) => {
108+
finish();
109+
resolve({ code: code ?? 0, output });
110+
});
61111
});
62112
}
63113

@@ -129,14 +179,28 @@ export const mapCommand: Command = {
129179
return;
130180
}
131181

132-
process.stderr.write(`Mapping this repository with ${agent.name}...\n`);
133-
const code = await runChild(run.bin, argv, run.env);
182+
const workspaceUrl = consoleBaseUrl(config);
183+
const { code, output } = await runMapping(run.bin, argv, run.env, () => {
184+
process.stderr.write(
185+
`Mapping this repository with ${agent.name}. Your agent reads the repo, runs checks, and\n` +
186+
`assembles the map locally; this usually takes a few minutes.\n`
187+
);
188+
});
189+
134190
if (code !== 0) {
191+
// Surface the tail of the captured output so a failure isn't silent.
192+
const tail = output.trim().split('\n').slice(-20).join('\n');
193+
if (tail) process.stderr.write(`\n${tail}\n`);
135194
throw new CLIError(
136195
`${agent.name} exited with code ${code} before finishing the map`,
137196
ExitCode.GENERAL,
138197
`Run it yourself: open ${agent.name} and ask "${MAPPING_PROMPT}"`
139198
);
140199
}
200+
201+
process.stderr.write(
202+
`\n✓ Mapped this repository. Your topology, issues, and first-run thread are in your workspace:\n` +
203+
` ${workspaceUrl}\n`
204+
);
141205
},
142206
};

0 commit comments

Comments
 (0)