diff --git a/setup/auto.ts b/setup/auto.ts index 392bc133f..94ffe20c5 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -53,7 +53,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 { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; +import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -579,18 +579,16 @@ async function confirmAssistantResponds(): Promise { const s = p.spinner(); const start = Date.now(); const label = 'Waking your assistant…'; - s.start(fitToWidth(label, ' (999s)')); + s.start(fitToWidth(label, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); }, 1000); const result = await pingCliAgent(); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result === 'ok') { s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); } else { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3638e4eec..c25f2decc 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -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 { accentGreen, brandBody, note } from '../lib/theme.js'; +import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -289,9 +289,8 @@ async function validateDiscordToken(token: string): Promise { username?: string; message?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (res.ok && data.username) { - s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('discord-validate', 'success', Date.now() - start, { BOT_USERNAME: data.username, BOT_ID: data.id ?? '', @@ -309,8 +308,7 @@ async function validateDiscordToken(token: string): Promise { 'Copy the token again from the Developer Portal and retry setup.', ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-validate', 'failed', Date.now() - start, { ERROR: message, @@ -338,7 +336,6 @@ async function fetchApplicationInfo(token: string): Promise { team?: unknown; message?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (!res.ok || !data.id || !data.verify_key) { const reason = data.message ?? `HTTP ${res.status}`; s.stop(`Couldn't read application info: ${reason}`, 1); @@ -351,7 +348,7 @@ async function fetchApplicationInfo(token: string): Promise { 'Re-run setup. If it keeps failing, check the bot token has the right scopes.', ); } - s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); // owner is populated for solo applications; team-owned apps return a // team object instead and we'll fall back to a manual user-id prompt. const owner = @@ -369,8 +366,7 @@ async function fetchApplicationInfo(token: string): Promise { owner, }; } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-app-info', 'failed', Date.now() - start, { ERROR: message, @@ -479,7 +475,6 @@ async function openDmChannel(token: string, userId: string): Promise { body: JSON.stringify({ recipient_id: userId }), }); const data = (await res.json()) as { id?: string; message?: string }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (!res.ok || !data.id) { const reason = data.message ?? `HTTP ${res.status}`; s.stop(`Couldn't open a DM channel: ${reason}`, 1); @@ -492,14 +487,13 @@ async function openDmChannel(token: string, userId: string): Promise { 'Make sure the bot is in a server you\'re also in, then retry setup.', ); } - s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('discord-open-dm', 'success', Date.now() - start, { DM_CHANNEL_ID: data.id, }); return data.id; } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-open-dm', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 0c5718ec7..8462a568e 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,7 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { accentGreen, note } from '../lib/theme.js'; +import { accentGreen, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -324,8 +324,7 @@ async function restartService(): Promise { // Give the adapter a moment to connect to signal-cli before // init-first-agent's welcome DM hits the delivery path. await new Promise((r) => setTimeout(r, 5000)); - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('signal-restart', 'success', Date.now() - start, { PLATFORM: platform, }); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 167fa726a..340eabc09 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 { accentGreen, note, wrapForGutter } from '../lib/theme.js'; +import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -241,10 +241,9 @@ async function validateSlackToken(token: string): Promise { user_id?: string; error?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.team && data.user) { s.stop( - `Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`, + `Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, ); const info: WorkspaceInfo = { teamName: data.team, @@ -273,8 +272,7 @@ async function validateSlackToken(token: string): Promise { : `Slack said "${reason}". Check the token scopes and workspace install, then retry.`, ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('slack-validate', 'failed', Date.now() - start, { ERROR: message, @@ -334,9 +332,8 @@ async function openDmChannel(token: string, userId: string): Promise { channel?: { id?: string }; error?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.channel?.id) { - s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('slack-open-dm', 'success', Date.now() - start, { DM_CHANNEL_ID: data.channel.id, }); @@ -360,8 +357,7 @@ async function openDmChannel(token: string, userId: string): Promise { `Slack said "${reason}". Check the member ID and app permissions, then retry.`, ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('slack-open-dm', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 1aa7cb541..ad749eb98 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js'; +import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -191,10 +191,9 @@ async function validateTelegramToken(token: string): Promise { result?: { username?: string; id?: number }; description?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.result?.username) { const username = data.result.username; - s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('telegram-validate', 'success', Date.now() - start, { BOT_USERNAME: username, BOT_ID: data.result.id ?? '', @@ -212,8 +211,7 @@ async function validateTelegramToken(token: string): Promise { 'Copy the token again from @BotFather and try setup once more.', ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 96d23d53c..922c98521 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 { accentGreen, brandBody, brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -379,8 +379,7 @@ async function restartService(): Promise { // Give the adapter a moment to reconnect before init-first-agent's // welcome DM hits the delivery path. await new Promise((r) => setTimeout(r, 5000)); - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('whatsapp-restart', 'success', Date.now() - start, { PLATFORM: platform, }); diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index e76a4fcfd..187377e02 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -28,7 +28,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { brandBody, fitToWidth, note } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -295,9 +295,8 @@ async function queryClaudeUnderSpinner( // Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window). 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 suffix = ` (${fmtDuration(Date.now() - start)})`; const header = fitToWidth('Asking Claude to diagnose…', suffix); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); @@ -355,8 +354,7 @@ async function queryClaudeUnderSpinner( clearBlock(); out.write(SHOW_CURSOR); process.off('exit', restoreCursorOnExit); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (kind === 'ok') { p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`); resolve(payload); diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index cf7a86da8..6ffffed80 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -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 { brandBody, fitToWidth } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration } from './theme.js'; export type Fields = Record; export type Block = { type: string; fields: Fields }; @@ -307,18 +307,16 @@ async function runUnderSpinner< ): Promise { const s = p.spinner(); const start = Date.now(); - s.start(fitToWidth(labels.running, ' (999s)')); + s.start(fitToWidth(labels.running, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`); }, 1000); const result = await work(); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 0dfa53f74..2c80c8a55 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -51,6 +51,22 @@ export function accentGreen(s: string): string { return k.green(s); } +/** + * Format an elapsed-time duration (in milliseconds) for the spinner + * suffixes setup writes everywhere. Sub-minute durations stay in plain + * seconds (`47s`); once the timer crosses 60 seconds we switch to the + * `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or + * similar. The format is consistent above 60s — `4m 0s` over `4m` — + * so live spinner output doesn't change shape at every whole minute. + */ +export function fmtDuration(ms: number): string { + const totalSec = Math.round(ms / 1000); + if (totalSec < 60) return `${totalSec}s`; + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}m ${s}s`; +} + /** * Brand body color for setup-flow prose. Used for card bodies (via the * `note()` formatter) and `p.log.*` body arguments — anywhere the diff --git a/setup/lib/tz-from-claude.ts b/setup/lib/tz-from-claude.ts index 5486fbb20..f861f6491 100644 --- a/setup/lib/tz-from-claude.ts +++ b/setup/lib/tz-from-claude.ts @@ -17,7 +17,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { isValidTimezone } from '../../src/timezone.js'; -import { fitToWidth } from './theme.js'; +import { fitToWidth, fmtDuration } from './theme.js'; export function claudeCliAvailable(): boolean { try { @@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude( const s = p.spinner(); const start = Date.now(); const label = 'Looking up that timezone…'; - s.start(fitToWidth(label, ' (999s)')); + s.start(fitToWidth(label, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); }, 1000); const reply = await queryClaude(prompt); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; const resolved = reply ? extractTimezone(reply) : null; if (resolved) {