Merge pull request #1927 from qwibitai/setup-feedback-fixes

Clarify setup flow from user-feedback session
This commit is contained in:
gavrielc
2026-04-23 10:43:27 +03:00
committed by GitHub
11 changed files with 611 additions and 51 deletions

View File

@@ -24,6 +24,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@clack/core": "^1.2.0",
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "^0.3.1",
"better-sqlite3": "11.10.0",

3
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@clack/core':
specifier: ^1.2.0
version: 1.2.0
'@clack/prompts':
specifier: ^1.2.0
version: 1.2.0

View File

@@ -31,7 +31,9 @@ import { runTeamsChannel } from './channels/teams.js';
import { runTelegramChannel } from './channels/telegram.js';
import { runWhatsAppChannel } from './channels/whatsapp.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { brightSelect } from './lib/bright-select.js';
import { offerClaudeAssist } from './lib/claude-assist.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
claudeCliAvailable,
@@ -79,7 +81,13 @@ async function main(): Promise<void> {
4,
),
);
const res = await runQuietStep('container', {
p.log.message(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
),
);
const res = await runWindowedStep('container', {
running: "Preparing your assistant's sandbox…",
done: 'Sandbox ready.',
failed: "Couldn't prepare the sandbox.",
@@ -124,7 +132,7 @@ async function main(): Promise<void> {
let reuse = false;
if (existing) {
const choice = ensureAnswer(
await p.select({
await brightSelect({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{
@@ -266,15 +274,17 @@ async function main(): Promise<void> {
await runTimezoneStep();
}
let channelChoice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' =
'skip';
if (!skip.has('channel')) {
const choice = await askChannelChoice();
if (choice === 'telegram') {
channelChoice = await askChannelChoice();
if (channelChoice === 'telegram') {
await runTelegramChannel(displayName!);
} else if (choice === 'discord') {
} else if (channelChoice === 'discord') {
await runDiscordChannel(displayName!);
} else if (choice === 'whatsapp') {
} else if (channelChoice === 'whatsapp') {
await runWhatsAppChannel(displayName!);
} else if (choice === 'teams') {
} else if (channelChoice === 'teams') {
await runTeamsChannel(displayName!);
} else {
p.log.info(
@@ -359,9 +369,51 @@ async function main(): Promise<void> {
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
.join('\n');
p.note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the
// caveat doesn't land after the user's already looked away at their phone.
p.note(
wrapForGutter(
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
6,
),
'Heads up',
);
setupLog.complete(Date.now() - RUN_START);
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
p.outro(k.green("You're ready! Enjoy NanoClaw."));
const dmTarget = channelDmLabel(channelChoice);
if (dmTarget) {
// Bright framed banner (not dim) — the whole point of the feedback was
// that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it
// as the last thing before outro.
p.note(
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
'Go say hi',
);
p.outro(k.green("You're set."));
} else {
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
}
}
function channelDmLabel(
choice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip',
): string | null {
switch (choice) {
case 'telegram':
return 'Telegram';
case 'discord':
return 'Discord DMs';
case 'whatsapp':
return 'WhatsApp';
case 'teams':
return 'Teams';
default:
return null;
}
}
// ─── first-chat step ───────────────────────────────────────────────────
@@ -424,15 +476,39 @@ function renderPingFailureNote(result: PingResult): void {
* Chat loop. Each message is piped through `pnpm run chat`, which uses
* the same Unix-socket path the ping just exercised, so output streams
* back inline as the agent replies. An empty input ends the loop.
*
* The intro note teaches the sandbox mental model — users reported being
* confused about what the terminal chat *is* (vs the phone channel they'd
* set up next) and what happens to the agent when they walk away. We
* explain once, then offer "message or Enter to continue" so the chat is
* clearly optional.
*/
async function runFirstChat(): Promise<void> {
p.note(
wrapForGutter(
[
'Your assistant runs in a sandbox on this machine.',
'It wakes up when you send a message and goes back to sleep when',
"you're not talking — so it isn't burning resources in the background.",
'Its memory and environment persist between conversations.',
].join(' '),
6,
),
'How this works',
);
let first = true;
while (true) {
const answer = ensureAnswer(
await p.text({
message: 'Say something to your assistant',
placeholder: 'press Enter with nothing to continue',
message: first
? 'Try a quick hello — or press Enter to continue setup'
: 'Another message? Press Enter to continue setup',
placeholder: first
? 'e.g. "hi, what can you do?"'
: 'press Enter to continue',
}),
);
first = false;
const text = ((answer as string | undefined) ?? '').trim();
if (!text) return;
await sendChatMessage(text);
@@ -465,7 +541,7 @@ async function runAuthStep(): Promise<void> {
}
const method = ensureAnswer(
await p.select({
await brightSelect({
message: 'How would you like to connect to Claude?',
options: [
{
@@ -593,18 +669,35 @@ async function runTimezoneStep(): Promise<void> {
resolvedTz === 'Etc/UTC' ||
resolvedTz === 'Universal';
// Three branches:
// - no TZ detected: ask where they are (or leave as UTC)
// - detected UTC: confirm (likely VPS, but worth checking)
// - detected specific zone: confirm explicitly rather than silently
// persisting — users shouldn't be surprised the agent "already knew"
// their timezone from system settings they didn't think about.
if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') {
return;
const confirmed = ensureAnswer(
await p.confirm({
message: `I detected ${resolvedTz} from your computer settings. Is that right?`,
initialValue: true,
}),
);
setupLog.userInput('timezone_confirm_detected', String(confirmed));
if (confirmed) return;
}
// Either autodetect failed outright, or it landed on UTC and we should
// check that's really what the user wants before leaving it there.
const message = needsInput
? "Your system didn't expose a timezone. Which one are you in?"
: !isUtc
? "Where are you, then?"
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
const choice = ensureAnswer(
await p.select({
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
// straight to the free-text prompt — the user already said "not that".
let choice: 'keep' | 'answer' = 'answer';
if (needsInput || isUtc) {
choice = ensureAnswer(
await brightSelect({
message,
options: needsInput
? [
@@ -618,6 +711,7 @@ async function runTimezoneStep(): Promise<void> {
}),
) as 'keep' | 'answer';
setupLog.userInput('timezone_choice', choice);
}
if (choice === 'keep') return;
@@ -696,7 +790,7 @@ async function askChannelChoice(): Promise<
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
> {
const choice = ensureAnswer(
await p.select({
await brightSelect({
message: 'Want to chat with your assistant from your phone?',
options: [
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },

View File

@@ -27,6 +27,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
@@ -46,9 +47,14 @@ interface AppInfo {
}
export async function runDiscordChannel(displayName: string): Promise<void> {
if (!(await askHasBotToken())) {
const hasBot = await askHasBotToken();
if (!hasBot) {
await walkThroughBotCreation();
}
// Even users who said "yes" often can't find the token on demand — the
// Dev Portal resets it if you don't store it, and people forget which
// app it belongs to. A quick reminder before the paste prompt is cheap.
showTokenLocationReminder(hasBot);
const token = await collectDiscordToken();
const botUsername = await validateDiscordToken(token);
@@ -56,6 +62,13 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
const ownerUserId = await resolveOwnerUserId(app.owner);
// Before inviting: do they have a server to invite into? Walkthrough if
// not — a fresh Discord account without a server makes the invite page a
// dead end.
if (!(await askHasDiscordServer())) {
await walkThroughServerCreation();
}
await promptInviteBot(app.applicationId, botUsername);
const install = await runQuietChild(
@@ -129,7 +142,7 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
async function askHasBotToken(): Promise<boolean> {
const answer = ensureAnswer(
await p.select({
await brightSelect({
message: 'Do you already have a Discord bot?',
options: [
{ value: 'yes', label: 'Yes, I have a bot token ready' },
@@ -165,6 +178,66 @@ async function walkThroughBotCreation(): Promise<void> {
);
}
function showTokenLocationReminder(hasExistingBot: boolean): void {
// If we just walked them through creating a bot, they're staring at the
// token. If they came in with an existing one, they may still need a nudge
// to find it — tokens in the Dev Portal aren't visible after first reveal,
// and "Reset Token" issues a new one.
if (hasExistingBot) {
p.note(
[
"Where to find your bot token:",
'',
' 1. discord.com/developers/applications → pick your app',
' 2. "Bot" tab → "Reset Token" (the old one stops working)',
' 3. Copy the new token',
].join('\n'),
'Reminder',
);
}
}
async function askHasDiscordServer(): Promise<boolean> {
const answer = ensureAnswer(
await brightSelect({
message: 'Do you have a Discord server you can add the bot to?',
options: [
{ value: 'yes', label: 'Yes, I have a server' },
{ value: 'no', label: "No, walk me through creating one" },
],
}),
);
setupLog.userInput('discord_has_server', String(answer));
return answer === 'yes';
}
async function walkThroughServerCreation(): Promise<void> {
// Discord doesn't have a stable deep-link for "create server" so we open
// the web client and rely on the + button being visible. The steps below
// are the same whether they're in the desktop app or the browser.
const url = 'https://discord.com/channels/@me';
p.note(
[
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
'',
' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")',
'',
k.dim(url),
].join('\n'),
'Create a Discord server',
);
await confirmThenOpen(url, 'Press Enter to open Discord');
ensureAnswer(
await p.confirm({
message: "Server created?",
initialValue: true,
}),
);
}
async function collectDiscordToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({

View File

@@ -30,6 +30,7 @@ import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import {
isHelpEscape,
@@ -223,7 +224,7 @@ async function askAppType(args: {
}): Promise<'SingleTenant' | 'MultiTenant'> {
while (true) {
const choice = ensureAnswer(
await p.select({
await brightSelect({
message: 'Which account type did you pick?',
options: [
{
@@ -515,7 +516,7 @@ async function finishWithHandoff(
);
const choice = ensureAnswer(
await p.select({
await brightSelect({
message: 'Ready to finish?',
options: [
{
@@ -571,7 +572,7 @@ async function stepGate(args: {
}): Promise<void> {
while (true) {
const choice = ensureAnswer(
await p.select({
await brightSelect({
message: 'How did that go?',
options: [
{ value: 'done', label: "Done — let's continue" },

View File

@@ -33,6 +33,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import {
type Block,
@@ -149,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
async function askAuthMethod(): Promise<AuthMethod> {
const choice = ensureAnswer(
await p.select({
await brightSelect({
message: 'How would you like to authenticate with WhatsApp?',
options: [
{

View File

@@ -175,19 +175,31 @@ export async function run(args: string[]): Promise<void> {
// .env is optional; absence is normal on a fresh checkout
}
// Build
// Build — stdio inherit so the parent setup runner can tail docker's
// per-step output and render it in a rolling window. Previously we used
// execSync which buffered everything; users couldn't tell whether a
// 310 minute build was making progress or hung.
let buildOk = false;
log.info('Building container', { runtime, buildArgs });
try {
const argsStr = buildArgs.length > 0 ? ' ' + buildArgs.join(' ') : '';
execSync(`${buildCmd}${argsStr} -t ${image} .`, {
const buildRes = spawnSync(
buildCmd.split(' ')[0],
[
...buildCmd.split(' ').slice(1),
...buildArgs.flatMap((a) => a.split(' ')),
'-t',
image,
'.',
],
{
cwd: path.join(projectRoot, 'container'),
stdio: ['ignore', 'pipe', 'pipe'],
});
stdio: 'inherit',
},
);
if (buildRes.status === 0) {
buildOk = true;
log.info('Container build succeeded');
} catch (err) {
log.error('Container build failed', { err });
} else {
log.error('Container build failed', { exitCode: buildRes.status });
}
// Test

119
setup/lib/bright-select.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* A drop-in alternative to `@clack/prompts`' `p.select` that renders
* unselected option labels at full brightness instead of dim gray.
*
* Why this exists: clack styles inactive options with `styleText("dim", …)`
* inline in its render function. There is no configuration hook to override
* it, and the feedback was clear — non-selected options in the setup flow
* were "too light, need stronger font weight". So we write our own render
* against `@clack/core`'s `SelectPrompt`, keeping the visual shell of clack
* (diamond header, `│` gutter, cyan in-progress / green on submit) but
* leaving the label un-dimmed. Only the bullet and hint stay dim, which
* gives enough contrast for the cursor to read as "active".
*
* Not a full clack-feature clone: no search, no maxItems paging, no custom
* bar characters. Just the bits the NanoClaw setup menus actually use.
*/
import { SelectPrompt } from '@clack/core';
import { isCancel } from '@clack/prompts';
import { styleText } from 'node:util';
const BULLET_ACTIVE = '●';
const BULLET_INACTIVE = '○';
const BAR = '│';
const CAP_BOT = '└';
const DIAMOND = '◆';
const DIAMOND_CANCEL = '■';
const DIAMOND_SUBMIT = '◇';
type PromptState = 'initial' | 'active' | 'error' | 'cancel' | 'submit';
function stateColor(state: PromptState): 'cyan' | 'green' | 'red' | 'yellow' {
switch (state) {
case 'submit':
return 'green';
case 'cancel':
return 'red';
case 'error':
return 'yellow';
default:
return 'cyan';
}
}
function headerIcon(state: PromptState): string {
switch (state) {
case 'submit':
return styleText('green', DIAMOND_SUBMIT);
case 'cancel':
return styleText('red', DIAMOND_CANCEL);
default:
return styleText('cyan', DIAMOND);
}
}
export interface BrightSelectOption<T> {
value: T;
label?: string;
hint?: string;
}
export interface BrightSelectOptions<T> {
message: string;
options: BrightSelectOption<T>[];
initialValue?: T;
}
/**
* Matches the return shape of `p.select` — resolves to the selected value
* on submit, or to clack's cancel symbol on Ctrl-C / Esc. Callers pass
* the result through `ensureAnswer(...)` the same way they do for
* `p.select`.
*/
export function brightSelect<T>(
opts: BrightSelectOptions<T>,
): Promise<T | symbol> {
const { message, options, initialValue } = opts;
return new SelectPrompt({
options: options as Array<{ value: T; label?: string; hint?: string }>,
initialValue,
render() {
const st = this.state as PromptState;
const color = stateColor(st);
const bar = styleText(color, BAR);
const grayBar = styleText('gray', BAR);
const lines: string[] = [];
lines.push(grayBar);
lines.push(`${headerIcon(st)} ${message}`);
if (st === 'submit' || st === 'cancel') {
const selected =
options.find((o) => o.value === this.value)?.label ??
String(this.value ?? '');
const shown =
st === 'cancel'
? styleText(['strikethrough', 'dim'], selected)
: styleText('dim', selected);
lines.push(`${grayBar} ${shown}`);
return lines.join('\n');
}
const cursor = (this as unknown as { cursor: number }).cursor;
options.forEach((opt, idx) => {
const label = opt.label ?? String(opt.value);
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
const marker =
idx === cursor
? styleText('green', BULLET_ACTIVE)
: styleText('dim', BULLET_INACTIVE);
lines.push(`${bar} ${marker} ${label}${hint}`);
});
lines.push(styleText(color, CAP_BOT));
return lines.join('\n');
},
}).prompt() as Promise<T | symbol>;
}
export { isCancel };

View File

@@ -8,8 +8,7 @@
* surfaces admin/member for the edge cases (shared instance, collaborators
* with limited access), but hitting Enter assigns owner.
*/
import * as p from '@clack/prompts';
import { brightSelect } from './bright-select.js';
import { ensureAnswer } from './runner.js';
export type OperatorRole = 'owner' | 'admin' | 'member';
@@ -18,7 +17,7 @@ export async function askOperatorRole(
channelLabel: string,
): Promise<OperatorRole> {
const choice = ensureAnswer(
await p.select({
await brightSelect<OperatorRole>({
message: `How should this ${channelLabel} account be registered?`,
initialValue: 'owner',
options: [
@@ -39,6 +38,6 @@ export async function askOperatorRole(
},
],
}),
) as OperatorRole;
);
return choice;
}

View File

@@ -102,12 +102,19 @@ export class StatusStream {
* raw log file (level 3) and parsed for status blocks (level 2 summary).
* The onBlock callback fires per status block as they close so the UI can
* react mid-stream.
*
* `onLine`, if provided, fires for every line from stdout + stderr (minus
* status-block control lines) so callers can render a rolling tail. Status
* block lines are still parsed by the `StatusStream` — they're just
* excluded from the line feed so they don't fill the user-facing window
* with `=== NANOCLAW SETUP: …` noise.
*/
export function spawnStep(
stepName: string,
extra: string[],
onBlock: (block: Block) => void,
rawLogPath: string,
onLine?: (line: string) => void,
): Promise<StepResult> {
return new Promise((resolve) => {
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
@@ -118,13 +125,34 @@ export function spawnStep(
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
raw.write(`# ${stepName}${new Date().toISOString()}\n\n`);
// Per-line forwarder for the optional onLine callback. We keep our own
// buffer (separate from StatusStream's) so the parser still gets raw
// chunks and isn't forced through a line-by-line path it doesn't need.
let lineBuf = '';
const pushLines = (chunk: string): void => {
if (!onLine) return;
lineBuf += chunk;
let idx: number;
while ((idx = lineBuf.indexOf('\n')) !== -1) {
const line = lineBuf.slice(0, idx).replace(/\r/g, '');
lineBuf = lineBuf.slice(idx + 1);
if (line.startsWith('=== NANOCLAW SETUP:')) continue;
if (line.startsWith('=== END ===')) continue;
if (line.trim()) onLine(line);
}
};
child.stdout.on('data', (chunk: Buffer) => {
stream.write(chunk.toString('utf-8'));
const s = chunk.toString('utf-8');
stream.write(s);
raw.write(chunk);
pushLines(s);
});
child.stderr.on('data', (chunk: Buffer) => {
stream.transcript += chunk.toString('utf-8');
const s = chunk.toString('utf-8');
stream.transcript += s;
raw.write(chunk);
pushLines(s);
});
child.on('close', (code) => {

View File

@@ -0,0 +1,229 @@
/**
* Windowed step runner: shows a fixed-height rolling tail of a long step's
* output so the user can see it's making progress, plus a stall detector
* that interrupts with a "keep waiting or ask for help?" prompt when the
* output stream goes silent for too long.
*
* Used for the container build (310 minutes on a fresh machine, no user
* feedback with a plain spinner). Models the UI on claude-assist.ts's
* 3-line action window — a single-line spinner header sitting above three
* gutter-prefixed lines of the most recent output, redrawn in place via
* ANSI cursor controls.
*
* Stall detection: a silence timer resets on every new line. When it hits
* STALL_THRESHOLD_MS we pause the render, show `offerClaudeAssist` with
* the step's raw log, and either resume (user said "keep waiting") or
* let the step run its course while giving them the exit path.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import type { StepResult, SpinnerLabels } from './runner.js';
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
import * as setupLog from '../logs.js';
import { fitToWidth } from './theme.js';
const WINDOW_SIZE = 3;
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
const HIDE_CURSOR = '\x1b[?25l';
const SHOW_CURSOR = '\x1b[?25h';
const STALL_THRESHOLD_MS = 60_000;
/**
* Run a step with a 3-line rolling tail + stall detector. Same signature
* shape as `runQuietStep` (so auto.ts can swap them), but tails the
* child's stdout/stderr into a fixed-height window.
*/
export async function runWindowedStep(
stepName: string,
labels: SpinnerLabels,
extra: string[] = [],
): Promise<StepResult & { rawLog: string; durationMs: number }> {
const rawLog = setupLog.stepRawLog(stepName);
const start = Date.now();
phEmit('step_started', { step: stepName });
const result = await runUnderWindow(stepName, labels, extra, rawLog);
const durationMs = Date.now() - start;
writeStepEntry(stepName, result, durationMs, rawLog);
phEmit('step_completed', {
step: stepName,
status: outcomeStatus(result),
duration_ms: durationMs,
});
return { ...result, rawLog, durationMs };
}
function outcomeStatus(result: StepResult): 'success' | 'skipped' | 'failed' {
const rawStatus = result.terminal?.fields.STATUS;
if (!result.ok) return 'failed';
return rawStatus === 'skipped' ? 'skipped' : 'success';
}
/**
* The core render + spawn loop. Kept separate from `runWindowedStep` so
* the logging bookkeeping (writeStepEntry, phEmit) lives with the
* public-facing wrapper and this function stays focused on terminal IO.
*/
async function runUnderWindow(
stepName: string,
labels: SpinnerLabels,
extra: string[],
rawLog: string,
): Promise<StepResult> {
const out = process.stdout;
const start = Date.now();
const actions: string[] = [];
let frameIdx = 0;
let lastLineAt = Date.now();
let stallPromptActive = false;
let handledStall = false;
const redraw = (): void => {
if (stallPromptActive) return;
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
const elapsed = Math.round((Date.now() - start) / 1000);
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
const suffix = ` (${elapsed}s)`;
const header = fitToWidth(labels.running, suffix);
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
for (let i = 0; i < WINDOW_SIZE; i++) {
const idx = actions.length - WINDOW_SIZE + i;
const action = idx >= 0 ? actions[idx] : '';
out.write('\x1b[2K');
if (action) {
out.write(`${k.gray('│')} ${k.dim(fitToWidth(action, ''))}`);
} else {
out.write(k.gray('│'));
}
out.write('\n');
}
};
const clearBlock = (): void => {
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
for (let i = 0; i < WINDOW_SIZE + 1; i++) {
out.write('\x1b[2K\n');
}
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
};
out.write(HIDE_CURSOR);
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
redraw();
const restoreCursorOnExit = (): void => {
out.write(SHOW_CURSOR);
};
process.once('exit', restoreCursorOnExit);
const frameTick = setInterval(() => {
frameIdx++;
redraw();
}, 250);
const stallCheck = setInterval(() => {
if (handledStall || stallPromptActive) return;
if (Date.now() - lastLineAt < STALL_THRESHOLD_MS) return;
handledStall = true;
void handleStall(stepName, rawLog, {
pauseRender: () => {
stallPromptActive = true;
clearBlock();
out.write(SHOW_CURSOR);
},
resumeRender: () => {
out.write(HIDE_CURSOR);
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
stallPromptActive = false;
lastLineAt = Date.now();
redraw();
},
});
}, 5_000);
const onLine = (line: string): void => {
lastLineAt = Date.now();
// Strip ANSI escape sequences — Docker Buildx writes color codes that
// mangle the rolling window layout when replayed in a narrow cell.
// eslint-disable-next-line no-control-regex
const clean = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim();
if (clean) actions.push(clean);
redraw();
};
const result = await spawnStep(stepName, extra, () => {}, rawLog, onLine);
clearInterval(frameTick);
clearInterval(stallCheck);
clearBlock();
out.write(SHOW_CURSOR);
process.off('exit', restoreCursorOnExit);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
dumpTranscriptOnFailure(result.transcript);
}
return result;
}
async function handleStall(
stepName: string,
rawLog: string,
render: { pauseRender: () => void; resumeRender: () => void },
): Promise<void> {
render.pauseRender();
p.log.warn(
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
);
phEmit('step_stalled', { step: stepName });
const { ensureAnswer } = await import('./runner.js');
const { brightSelect } = await import('./bright-select.js');
const choice = ensureAnswer(
await brightSelect<'wait' | 'help'>({
message: "What now?",
options: [
{
value: 'wait',
label: "Keep waiting",
hint: "large images can take 510 minutes",
},
{
value: 'help',
label: 'Ask Claude to take a look',
hint: 'reads the raw build log and suggests a fix',
},
],
}),
);
if (choice === 'help') {
// offerClaudeAssist runs its own spinner and may propose a fix command.
// We don't attempt to restart the stalled build from here — if Claude
// proposes a command the user accepts, they can retry setup afterwards.
await offerClaudeAssist({
stepName,
msg: `The ${stepName} step has produced no output for 60 seconds.`,
hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',
rawLogPath: rawLog,
});
// Keep the spinner going — the underlying process is still running,
// and cancelling it here would race with Claude's investigation. The
// user can Ctrl-C if they want to bail.
}
render.resumeRender();
}