feat(setup): paint card and log bodies in brand cyan
Adds a `brandBody` helper in setup/lib/theme.ts that wraps prose in brand cyan (#2BB7CE), with the same TTY/NO_COLOR/truecolor gating used by `brand`/`brandBold`/`brandChip`. The helper splits multi-line input and colors each line independently so the SGR sequence doesn't bleed across clack's gutter prefix. Routing: - `note()` (the un-dim card wrapper from #2095) now passes `brandBody` as its `format` callback, so card bodies render cyan line-by-line. - Every prose `p.log.{message,info,success,step,warn}` call in the setup flow wraps its body argument in `brandBody`. Calls whose body is explicitly `k.dim(...)` (failure transcript tails, log paths, claude-assist response previews) are left alone — those are the "preview/debug" cases the dim-policy comment in theme.ts already carves out. - Spinner-finish lines in windowed-runner / claude-assist color only the message portion; the `(5s)` elapsed suffix stays dim. Brand cyan accents (chips, wordmark, inline emphasis) are unchanged. This PR only adds the body color. A follow-up will add OSC 11 dark/light detection so light-mode terminals get a brand blue (#2b6fdc) variant — opt-in upgrade with no regression for the dark-mode default.
This commit is contained in:
@@ -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, note, wrapForGutter } from './lib/theme.js';
|
||||
import { brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
@@ -122,11 +122,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
|
||||
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
||||
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,
|
||||
brandBody(
|
||||
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', {
|
||||
@@ -161,9 +163,11 @@ async function main(): Promise<void> {
|
||||
|
||||
if (!skip.has('onecli')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -287,9 +291,11 @@ async function main(): Promise<void> {
|
||||
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
|
||||
}
|
||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
|
||||
p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
|
||||
p.log.message(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
brandBody(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -320,9 +326,11 @@ async function main(): Promise<void> {
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
const ping = await confirmAssistantResponds();
|
||||
@@ -387,9 +395,11 @@ async function main(): Promise<void> {
|
||||
await runIMessageChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -629,7 +639,7 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
|
||||
async function runAuthStep(): Promise<void> {
|
||||
if (anthropicSecretExists()) {
|
||||
p.log.success('Your Claude account is already connected.');
|
||||
p.log.success(brandBody('Your Claude account is already connected.'));
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
|
||||
return;
|
||||
}
|
||||
@@ -677,7 +687,7 @@ async function runAuthStep(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runSubscriptionAuth(): Promise<void> {
|
||||
p.log.step('Opening the Claude sign-in flow…');
|
||||
p.log.step(brandBody('Opening the Claude sign-in flow…'));
|
||||
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
|
||||
console.log();
|
||||
const start = Date.now();
|
||||
@@ -696,7 +706,7 @@ async function runSubscriptionAuth(): Promise<void> {
|
||||
);
|
||||
}
|
||||
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
|
||||
p.log.success('Claude account connected.');
|
||||
p.log.success(brandBody('Claude account connected.'));
|
||||
}
|
||||
|
||||
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
@@ -919,9 +929,11 @@ async function runTimezoneStep(): Promise<void> {
|
||||
tz = await resolveTimezoneViaClaude(raw);
|
||||
} else {
|
||||
p.log.warn(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1086,7 +1098,7 @@ function maybeReexecUnderSg(): void {
|
||||
if (!/permission denied/i.test(err)) return;
|
||||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
||||
|
||||
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
|
||||
p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
|
||||
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
||||
|
||||
@@ -31,7 +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';
|
||||
import { brandBody, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
@@ -386,7 +386,7 @@ async function resolveOwnerUserId(
|
||||
}
|
||||
} else {
|
||||
p.log.info(
|
||||
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
|
||||
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
|
||||
);
|
||||
}
|
||||
return await promptForUserIdWithDevMode();
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { brandBold, note } from '../lib/theme.js';
|
||||
import { brandBody, brandBold, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||
@@ -267,7 +267,7 @@ async function runWhatsAppAuth(
|
||||
if (spinnerActive) {
|
||||
stopSpinner('WhatsApp linked.');
|
||||
} else {
|
||||
p.log.success('WhatsApp linked.');
|
||||
p.log.success(brandBody('WhatsApp linked.'));
|
||||
}
|
||||
} else if (status === 'failed') {
|
||||
if (qrLinesPrinted > 0) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { fitToWidth, note } from './theme.js';
|
||||
import { brandBody, fitToWidth, note } from './theme.js';
|
||||
|
||||
export interface AssistContext {
|
||||
stepName: string;
|
||||
@@ -106,7 +106,7 @@ export async function offerClaudeAssist(
|
||||
|
||||
const parsed = parseResponse(response);
|
||||
if (!parsed) {
|
||||
p.log.warn("Claude responded but I couldn't parse a command out of it.");
|
||||
p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
|
||||
p.log.message(k.dim(response.trim().slice(0, 500)));
|
||||
return false;
|
||||
}
|
||||
@@ -268,7 +268,7 @@ async function queryClaudeUnderSpinner(
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (kind === 'ok') {
|
||||
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
|
||||
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
|
||||
resolve(payload);
|
||||
} else {
|
||||
p.log.error(
|
||||
|
||||
@@ -27,7 +27,7 @@ import { execSync, spawn } from 'child_process';
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { note } from './theme.js';
|
||||
import { brandBody, note } from './theme.js';
|
||||
|
||||
export interface HandoffContext {
|
||||
/** Channel this handoff is happening in (e.g., 'teams'). */
|
||||
@@ -64,7 +64,7 @@ export interface HandoffContext {
|
||||
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
|
||||
if (!isClaudeUsable()) {
|
||||
p.log.warn(
|
||||
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
|
||||
brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
p.log.success("Back from Claude. Let's continue.");
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
child.on('error', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import k from 'kleur';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { offerClaudeAssist } from './claude-assist.js';
|
||||
import { emit as phEmit } from './diagnostics.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
import { brandBody, fitToWidth } from './theme.js';
|
||||
|
||||
export type Fields = Record<string, string>;
|
||||
export type Block = { type: string; fields: Fields };
|
||||
@@ -390,7 +390,7 @@ export async function fail(
|
||||
const skipList = [
|
||||
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
|
||||
].join(',');
|
||||
p.log.step(`Retrying from ${stepName}…`);
|
||||
p.log.step(brandBody(`Retrying from ${stepName}…`));
|
||||
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_SKIP: skipList },
|
||||
|
||||
@@ -39,6 +39,29 @@ export function brandChip(s: string): string {
|
||||
return k.bgCyan(k.black(k.bold(s)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand body color for setup-flow prose. Used for card bodies (via the
|
||||
* `note()` formatter) and `p.log.*` body arguments — anywhere the
|
||||
* previous "dim" treatment was making prose hard to read or washing
|
||||
* out embedded brand emphasis.
|
||||
*
|
||||
* Multi-line input is colored line-by-line so embedded line breaks
|
||||
* don't bleed the SGR sequence across clack's gutter prefix.
|
||||
*/
|
||||
export function brandBody(s: string): string {
|
||||
if (!USE_ANSI) return s;
|
||||
if (TRUECOLOR) {
|
||||
return s
|
||||
.split('\n')
|
||||
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
|
||||
.join('\n');
|
||||
}
|
||||
return s
|
||||
.split('\n')
|
||||
.map((line) => (line.length > 0 ? k.cyan(line) : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text so it fits inside clack's gutter without the terminal's soft
|
||||
* wrap breaking the `│ …` bar on long lines. Works on a single string with
|
||||
@@ -70,16 +93,13 @@ export function dimWrap(text: string, gutter: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Wrap clack's `p.note` so card bodies render in the brand body color
|
||||
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
|
||||
* on each line individually, so `brandBody` colors each line cleanly
|
||||
* without bleeding across the gutter prefix.
|
||||
*/
|
||||
const passthroughFormat = (s: string): string => s;
|
||||
export function note(message: string, title?: string): void {
|
||||
p.note(message, title, { format: passthroughFormat });
|
||||
p.note(message, title, { format: brandBody });
|
||||
}
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
@@ -23,7 +23,7 @@ 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';
|
||||
import { brandBody, fitToWidth } from './theme.js';
|
||||
|
||||
const WINDOW_SIZE = 3;
|
||||
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
|
||||
@@ -169,7 +169,7 @@ async function runUnderWindow(
|
||||
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)}`);
|
||||
p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
|
||||
@@ -185,7 +185,7 @@ async function handleStall(
|
||||
): Promise<void> {
|
||||
render.pauseRender();
|
||||
p.log.warn(
|
||||
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
|
||||
brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`),
|
||||
);
|
||||
phEmit('step_stalled', { step: stepName });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user