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:
119
setup/lib/bright-select.ts
Normal file
119
setup/lib/bright-select.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
229
setup/lib/windowed-runner.ts
Normal file
229
setup/lib/windowed-runner.ts
Normal 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 (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<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 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();
|
||||
}
|
||||
Reference in New Issue
Block a user