diff --git a/package.json b/package.json index 31802c7..8ec2983 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e3de02..3f74033 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/setup/auto.ts b/setup/auto.ts index ea5dec3..958650a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -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 { claudeCliAvailable, resolveTimezoneViaClaude, @@ -78,7 +80,13 @@ async function main(): Promise { 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 3–10 minutes.', + 4, + ), + ); + const res = await runWindowedStep('container', { running: "Preparing your assistant's sandbox…", done: 'Sandbox ready.', failed: "Couldn't prepare the sandbox.", @@ -123,7 +131,7 @@ async function main(): Promise { 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: [ { @@ -265,15 +273,17 @@ async function main(): Promise { 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( @@ -357,9 +367,51 @@ async function main(): Promise { .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 ─────────────────────────────────────────────────── @@ -422,15 +474,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 { + 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); @@ -463,7 +539,7 @@ async function runAuthStep(): Promise { } const method = ensureAnswer( - await p.select({ + await brightSelect({ message: 'How would you like to connect to Claude?', options: [ { @@ -591,31 +667,49 @@ async function runTimezoneStep(): Promise { 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?" - : "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; + : !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({ - message, - options: needsInput - ? [ - { value: 'answer', label: "I'll tell you where I am" }, - { value: 'keep', label: 'Leave it as UTC' }, - ] - : [ - { value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' }, - { value: 'answer', label: "I'm somewhere else" }, - ], - }), - ) as 'keep' | 'answer'; - setupLog.userInput('timezone_choice', choice); + // 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 + ? [ + { value: 'answer', label: "I'll tell you where I am" }, + { value: 'keep', label: 'Leave it as UTC' }, + ] + : [ + { value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' }, + { value: 'answer', label: "I'm somewhere else" }, + ], + }), + ) as 'keep' | 'answer'; + setupLog.userInput('timezone_choice', choice); + } if (choice === 'keep') return; @@ -694,7 +788,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' }, diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index f26dc23..3668686 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -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 { - 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 { 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 { async function askHasBotToken(): Promise { 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 { ); } +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 { + 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 { + // 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 { const answer = ensureAnswer( await p.password({ diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index be29cea..fb4d878 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -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 { while (true) { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: 'How did that go?', options: [ { value: 'done', label: "Done — let's continue" }, diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 29c70e3..f24207a 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -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 { type Block, type StepResult, @@ -148,7 +149,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { async function askAuthMethod(): Promise { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: 'How would you like to authenticate with WhatsApp?', options: [ { diff --git a/setup/container.ts b/setup/container.ts index a2e6433..a15ddb4 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -174,19 +174,31 @@ export async function run(args: string[]): Promise { // .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 + // 3–10 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 diff --git a/setup/lib/bright-select.ts b/setup/lib/bright-select.ts new file mode 100644 index 0000000..94c4838 --- /dev/null +++ b/setup/lib/bright-select.ts @@ -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 { + value: T; + label?: string; + hint?: string; +} + +export interface BrightSelectOptions { + message: string; + options: BrightSelectOption[]; + 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( + opts: BrightSelectOptions, +): Promise { + 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; +} + +export { isCancel }; diff --git a/setup/lib/role-prompt.ts b/setup/lib/role-prompt.ts index c5ac537..7344ac1 100644 --- a/setup/lib/role-prompt.ts +++ b/setup/lib/role-prompt.ts @@ -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 { const choice = ensureAnswer( - await p.select({ + await brightSelect({ message: `How should this ${channelLabel} account be registered?`, initialValue: 'owner', options: [ @@ -39,6 +38,6 @@ export async function askOperatorRole( }, ], }), - ) as OperatorRole; + ); return choice; } diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index d8d3765..1e02d0d 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -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 { 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) => { diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts new file mode 100644 index 0000000..875aba6 --- /dev/null +++ b/setup/lib/windowed-runner.ts @@ -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 (3–10 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 { + 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 { + 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 { + 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 5–10 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(); +}