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

@@ -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<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.",
@@ -123,7 +131,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: [
{
@@ -265,15 +273,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(
@@ -357,9 +367,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 ───────────────────────────────────────────────────
@@ -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<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);
@@ -463,7 +539,7 @@ async function runAuthStep(): Promise<void> {
}
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<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?"
: "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' },