feat(setup): clarify setup flow from user-feedback session

- Container step: duration hint + 3-line rolling output window with
  60s stall detector that offers "keep waiting" vs "ask Claude"
- First chat: reframed as a try-out with sandbox-model explainer
  (wakes on message, sleeps when idle, context persists)
- Timezone: auto-detected non-UTC zones now get an explicit
  confirm from the user instead of silent persist
- Outro: added always-on warning + prominent "check your DM" banner
  when a channel was configured; directive last line
- Discord: always show token-location reminder even when user says
  they have one; new "do you have a server?" branch walks through
  server creation if not
- All select prompts: custom brightSelect renderer keeps inactive
  option labels at full brightness (was dim gray); adds @clack/core
  as a direct dep

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-23 10:35:12 +03:00
parent 4f6d62a65e
commit 56ef5b4461
11 changed files with 611 additions and 51 deletions

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();
}