diff --git a/setup/auto.ts b/setup/auto.ts index 4dee7c8a0..ee5c3694c 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -52,7 +52,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js'; +import { brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -435,7 +435,7 @@ async function main(): Promise { ); } if (notes.length > 0) { - p.note(notes.join('\n'), "What's left"); + note(notes.join('\n'), "What's left"); } // "What's left" is a soft failure — we don't abort like fail(), but the // user is still stuck and a fix is exactly what claude-assist is for. @@ -467,11 +467,11 @@ async function main(): Promise { ]; const labelWidth = Math.max(...rows.map(([l]) => l.length)); const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n'); - p.note(nextSteps, 'Try these'); + 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( + 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, @@ -488,7 +488,7 @@ async function main(): Promise { // 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'); + 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`.")); @@ -567,7 +567,7 @@ function renderPingFailureNote(result: PingResult): void { 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', 6, ); - p.note(body, 'Skipping the first chat'); + note(body, 'Skipping the first chat'); } /** @@ -582,7 +582,7 @@ function renderPingFailureNote(result: PingResult): void { * clearly optional. */ async function runFirstChat(): Promise { - p.note( + note( wrapForGutter( [ 'Your assistant runs in a sandbox on this machine.', diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 366868692..671d9202c 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,6 +31,7 @@ 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'; +import { note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -155,7 +156,7 @@ async function askHasBotToken(): Promise { async function walkThroughBotCreation(): Promise { const url = 'https://discord.com/developers/applications'; - p.note( + note( [ "You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.", '', @@ -184,7 +185,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void { // 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( + note( [ "Where to find your bot token:", '', @@ -216,7 +217,7 @@ async function walkThroughServerCreation(): Promise { // 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( + note( [ "A Discord server is just a private space for you and the bot. Free and takes 30 seconds.", '', @@ -392,7 +393,7 @@ async function resolveOwnerUserId( } async function promptForUserIdWithDevMode(): Promise { - p.note( + note( [ "To get your Discord user ID:", '', @@ -430,7 +431,7 @@ async function promptInviteBot( `&scope=bot` + `&permissions=${INVITE_PERMISSIONS}`; - p.note( + note( [ `@${botUsername} needs to share a server with you before it can DM you.`, '', diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129fa8..387f6b268 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -36,7 +36,7 @@ import * as setupLog from '../logs.js'; import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { wrapForGutter } from '../lib/theme.js'; +import { note, wrapForGutter } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -189,7 +189,7 @@ async function walkThroughFullDiskAccess(): Promise { } const nodeDir = path.dirname(nodePath); - p.note( + note( wrapForGutter( [ `iMessage needs Full Disk Access granted to the Node binary:`, @@ -222,7 +222,7 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { - p.note( + note( [ "Photon is a separate service that owns an iMessage account and", "exposes it over HTTP. NanoClaw will talk to it via its API.", @@ -264,7 +264,7 @@ async function collectRemoteCreds(): Promise { } async function askOperatorHandle(): Promise { - p.note( + note( [ "What phone number or email do you iMessage with?", "That's where your assistant will send its welcome message.", diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 9e54cb971..4e1cbfb6e 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,6 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; +import { note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -139,7 +140,7 @@ async function ensureSignalCli(): Promise { if (!probe.error && probe.status === 0) return; if (process.platform === 'darwin') { - p.note( + note( [ "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", '', @@ -152,7 +153,7 @@ async function ensureSignalCli(): Promise { 'signal-cli not found', ); } else { - p.note( + note( [ "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", '', diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 6d1ff5628..4ee59734f 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -28,7 +28,7 @@ import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { wrapForGutter } from '../lib/theme.js'; +import { note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -121,7 +121,7 @@ export async function runSlackChannel(displayName: string): Promise { } async function walkThroughAppCreation(): Promise { - p.note( + note( [ "You'll create a Slack app that the assistant talks through.", "Free and stays inside the workspaces you pick.", @@ -262,7 +262,7 @@ async function validateSlackToken(token: string): Promise { } async function collectSlackUserId(): Promise { - p.note( + note( [ "To get your Slack member ID:", '', @@ -367,7 +367,7 @@ async function resolveAgentName(): Promise { } function showPostInstallChecklist(info: WorkspaceInfo): void { - p.note( + note( wrapForGutter( [ `Your agent is wired to Slack and a welcome DM is on its way.`, diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878de..e4120867b 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -40,6 +40,7 @@ import { } from '../lib/claude-handoff.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; +import { note } from '../lib/theme.js'; import * as setupLog from '../logs.js'; const CHANNEL = 'teams'; @@ -79,7 +80,7 @@ export async function runTeamsChannel(_displayName: string): Promise { // ─── step: intro / prereqs ────────────────────────────────────────────── function printIntro(): void { - p.note( + note( [ 'Setting up Teams is more involved than the other channels — about', '7 steps across the Azure portal and Teams admin.', @@ -93,7 +94,7 @@ function printIntro(): void { } async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ 'Before we start, confirm you have:', '', @@ -119,7 +120,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[] // ─── step: public URL ────────────────────────────────────────────────── async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ "Azure Bot Service delivers messages to an HTTPS endpoint you", "control. The endpoint needs to reach this machine's webhook", @@ -175,7 +176,7 @@ async function stepAppRegistration(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, '2. Name it (e.g. "NanoClaw")', @@ -259,7 +260,7 @@ async function stepClientSecret(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ `1. In your app registration, open "Certificates & secrets"`, '2. Click "New client secret"', @@ -328,7 +329,7 @@ async function stepAzureBot(args: { ` --appid ${args.collected.appId} \\\n` + ` ${tenantFlag}--endpoint "${endpoint}"`; - p.note( + note( [ `In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`, '', @@ -365,7 +366,7 @@ async function stepEnableTeamsChannel(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ '1. Open your Azure Bot resource → Channels', '2. Click Microsoft Teams → Accept terms → Apply', @@ -435,7 +436,7 @@ async function stepSideload(args: { completed: string[]; zipPath: string; }): Promise { - p.note( + note( [ '1. Open Microsoft Teams', '2. Go to Apps → Manage your apps → Upload an app', @@ -501,7 +502,7 @@ async function finishWithHandoff( collected: Collected, completed: string[], ): Promise { - p.note( + note( [ 'The Teams adapter is live and the service is running.', '', @@ -530,7 +531,7 @@ async function finishWithHandoff( ); if (choice === 'self') { - p.note( + note( [ ' 1. Find your bot in Teams (search by name, or via the sideloaded', ' app) and send it a message ("hi" is fine)', diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcff1..3a86a5f67 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { brandBold } from '../lib/theme.js'; +import { brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -47,7 +47,7 @@ export async function runTelegramChannel(displayName: string): Promise { // installed, or the bot's web profile if not. tg://resolve?domain= is // more direct but silently fails when the scheme isn't registered. const botUrl = `https://t.me/${botUsername}`; - p.note( + note( [ `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, '', @@ -132,7 +132,7 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { - p.note( + note( [ "Your assistant talks to you through a Telegram bot you create.", "Here's how:", @@ -240,7 +240,7 @@ async function runPairTelegram(): Promise< } else { stopSpinner("Old code expired. Here's a fresh one."); } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); + note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); s.start('Waiting for you to send the code from Telegram…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 85c98663a..eb487cbfe 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { brandBold } from '../lib/theme.js'; +import { brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -171,7 +171,7 @@ async function askAuthMethod(): Promise { } async function askPhoneNumber(): Promise { - p.note( + note( [ "Enter your phone number the way WhatsApp expects it:", '', @@ -249,7 +249,7 @@ async function runWhatsAppAuth( } else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') { const code = block.fields.CODE ?? '????'; stopSpinner('Your pairing code is ready.'); - p.note(formatPairingCard(code), 'Pairing code'); + note(formatPairingCard(code), 'Pairing code'); s.start('Waiting for you to enter the code…'); spinnerActive = true; } else if (block.type === 'WHATSAPP_AUTH') { @@ -395,7 +395,7 @@ async function restartService(): Promise { } async function askChatPhone(authedPhone: string): Promise { - p.note( + note( [ `Authenticated with ${k.cyan('+' + authedPhone)}.`, '', diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 1651a9c12..48c760e29 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -24,7 +24,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { fitToWidth } from './theme.js'; +import { fitToWidth, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -111,7 +111,7 @@ export async function offerClaudeAssist( return false; } - p.note( + note( `${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`, "Claude's suggestion", ); diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 9c931f25a..3a0c2194c 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -27,6 +27,8 @@ import { execSync, spawn } from 'child_process'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { note } from './theme.js'; + export interface HandoffContext { /** Channel this handoff is happening in (e.g., 'teams'). */ channel: string; @@ -69,7 +71,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise const systemPrompt = buildSystemPrompt(ctx); - p.note( + note( [ "I'm handing you off to Claude in interactive mode.", "It has the context of where you are in setup.", diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 35b5ca343..f30ebe663 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -11,6 +11,7 @@ * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) * - Otherwise → kleur's 16-color cyan (closest fallback) */ +import * as p from '@clack/prompts'; import k from 'kleur'; const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; @@ -68,6 +69,19 @@ export function dimWrap(text: string, gutter: number): string { return wrapForGutter(text, gutter); } +/** + * Wrap clack's `p.note` with the dim formatter disabled. By default + * clack renders note bodies through `styleText("dim", …)`, which the + * project's prose-readability stance (see `dimWrap` above) explicitly + * rejects. Pass-through formatter keeps body text at the terminal's + * regular weight; pre-styled segments (chips, bold, brand color) come + * through unfaded. + */ +const passthroughFormat = (s: string): string => s; +export function note(message: string, title?: string): void { + p.note(message, title, { format: passthroughFormat }); +} + const ANSI_RE = /\x1b\[[0-9;]*m/g; function visibleLength(s: string): number {