From a289cb935302146365401bc70aaf94fc03616e13 Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Sat, 18 Apr 2026 14:30:26 +0100 Subject: [PATCH 1/2] Introduce a swappable theme plug for the TUI; surface bulletin attestation on dot init. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All screens now render through `src/utils/ui/theme/` — one folder holds every color, glyph, and layout token used anywhere in the CLI. Swap the folder to reskin; stub the exports to strip styling. No color literals, glyph literals, or spacing constants live outside this directory. `dot init` now queries bulletin attestation on every run — even for already-signed-in users — and renders how long the upload quota is valid for in human form (e.g. `~13d 4h · #14,582,331`), switching to warning color under 24h and danger color when expired. Terminal tab title updates during long deploys via OSC 0 (building → uploading → publishing → ✓/✕), so `dot deploy` shows progress in the tab strip while the user tabs away. TTY-gated so CI/pipe output stays clean; cleared on process shutdown. --- .../theme-plug-and-bulletin-attestation.md | 16 + src/commands/deploy/DeployScreen.tsx | 458 ++++++++---------- src/commands/init/AccountSetup.tsx | 139 +++--- src/commands/init/DependencyList.tsx | 93 ++-- src/commands/init/InitScreen.tsx | 35 +- src/commands/init/QrLogin.tsx | 48 +- src/commands/mod/AppBrowser.tsx | 38 +- src/commands/mod/SetupScreen.tsx | 36 +- src/index.ts | 8 +- src/utils/account/attestation.test.ts | 172 +++++++ src/utils/account/attestation.ts | 135 ++++++ src/utils/account/index.ts | 9 + src/utils/ui/components/StepRunner.tsx | 58 +-- src/utils/ui/components/loading.tsx | 32 -- src/utils/ui/index.ts | 10 +- src/utils/ui/theme/Callout.tsx | 52 ++ src/utils/ui/theme/Header.tsx | 74 +++ src/utils/ui/theme/Hint.tsx | 11 + src/utils/ui/theme/Input.tsx | 74 +++ src/utils/ui/theme/LogTail.tsx | 28 ++ src/utils/ui/theme/Mark.tsx | 30 ++ src/utils/ui/theme/Row.tsx | 61 +++ src/utils/ui/theme/Rule.tsx | 14 + src/utils/ui/theme/Section.tsx | 23 + src/utils/ui/theme/Select.tsx | 58 +++ src/utils/ui/theme/Sparkline.tsx | 56 +++ src/utils/ui/theme/index.tsx | 29 ++ src/utils/ui/theme/tokens.ts | 45 ++ src/utils/ui/theme/window-title.ts | 36 ++ src/utils/version.ts | 4 + 30 files changed, 1358 insertions(+), 524 deletions(-) create mode 100644 .changeset/theme-plug-and-bulletin-attestation.md create mode 100644 src/utils/account/attestation.test.ts create mode 100644 src/utils/account/attestation.ts delete mode 100644 src/utils/ui/components/loading.tsx create mode 100644 src/utils/ui/theme/Callout.tsx create mode 100644 src/utils/ui/theme/Header.tsx create mode 100644 src/utils/ui/theme/Hint.tsx create mode 100644 src/utils/ui/theme/Input.tsx create mode 100644 src/utils/ui/theme/LogTail.tsx create mode 100644 src/utils/ui/theme/Mark.tsx create mode 100644 src/utils/ui/theme/Row.tsx create mode 100644 src/utils/ui/theme/Rule.tsx create mode 100644 src/utils/ui/theme/Section.tsx create mode 100644 src/utils/ui/theme/Select.tsx create mode 100644 src/utils/ui/theme/Sparkline.tsx create mode 100644 src/utils/ui/theme/index.tsx create mode 100644 src/utils/ui/theme/tokens.ts create mode 100644 src/utils/ui/theme/window-title.ts create mode 100644 src/utils/version.ts diff --git a/.changeset/theme-plug-and-bulletin-attestation.md b/.changeset/theme-plug-and-bulletin-attestation.md new file mode 100644 index 0000000..85375b9 --- /dev/null +++ b/.changeset/theme-plug-and-bulletin-attestation.md @@ -0,0 +1,16 @@ +--- +"playground-cli": minor +--- + +New editorial TUI: every screen now renders through a single theme plug +(`src/utils/ui/theme/`) — swap that folder to reskin the CLI, stub it to +strip styling, zero styling leaks into commands. + +`dot init` now surfaces bulletin attestation status on every run — even +for already-signed-in users — showing how long your upload quota is valid +for in human-readable form (e.g. `~13d 4h · #14,582,331`), with warning +color when expiry drops under 24 h. + +Bonus: the terminal tab title updates during long deploys, so +`dot deploy` shows build / upload / publish / ✓ in your tab strip while +you tab away to the browser. diff --git a/src/commands/deploy/DeployScreen.tsx b/src/commands/deploy/DeployScreen.tsx index b1dbb8c..170a4c2 100644 --- a/src/commands/deploy/DeployScreen.tsx +++ b/src/commands/deploy/DeployScreen.tsx @@ -1,6 +1,17 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; -import { Spinner, Done, Failed, Warning } from "../../utils/ui/index.js"; +import { + Header, + Row, + Section, + Hint, + Callout, + Sparkline, + Select, + Input, + setWindowTitle, + type MarkKind, +} from "../../utils/ui/theme/index.js"; import { runDeploy, resolveSignerSetup, @@ -17,6 +28,7 @@ import { import { buildSummaryView } from "./summary.js"; import type { ResolvedSigner } from "../../utils/signer.js"; import { DEFAULT_BUILD_DIR } from "../../config.js"; +import { VERSION_LABEL } from "../../utils/version.js"; export interface DeployScreenInputs { projectDir: string; @@ -64,6 +76,11 @@ export function DeployScreen({ pickInitialStage(initialMode, initialBuildDir, initialDomain, initialPublish), ); + // Passed down to RunningStage; read back on completion for the sparkline. + // Ref instead of state so the high-frequency chunk-progress stream doesn't + // force re-renders of the whole DeployScreen. + const finalChunkTimingsRef = useRef([]); + const advance = ( nextMode: SignerMode | null = mode, nextBuildDir: string | null = buildDir, @@ -74,26 +91,45 @@ export function DeployScreen({ setStage(s); }; - // Used only once inputs are fully resolved; read by the `running` stage. const resolved = useMemo(() => { if (mode === null || buildDir === null || domain === null || publishToPlayground === null) return null; return { mode, buildDir, domain, publishToPlayground }; }, [mode, buildDir, domain, publishToPlayground]); + // Dynamic terminal tab title: subtitle becomes the domain once we know it. + const headerSubtitle = resolved?.domain ?? domain ?? undefined; + return ( - + +
+ {stage.kind === "prompt-signer" && ( - + label="signer" + options={[ + { value: "dev", label: "dev signer", hint: "fast, 0 phone taps for upload" }, + { + value: "phone", + label: "your phone signer", + hint: "signed with your logged-in account", + }, + ]} onSelect={(m) => { setMode(m); advance(m); }} /> )} + {stage.kind === "prompt-buildDir" && ( - { setBuildDir(v); @@ -101,16 +137,17 @@ export function DeployScreen({ }} /> )} + {stage.kind === "prompt-domain" && ( - /^[a-z0-9][a-z0-9-]*(\.dot)?$/i.test(v.trim()) ? null - : "Use lowercase letters, digits, and dashes." + : "use lowercase letters, digits, and dashes" } onSubmit={(v) => { const trimmed = v.trim(); @@ -120,6 +157,7 @@ export function DeployScreen({ }} /> )} + {stage.kind === "validate-domain" && ( )} + {stage.kind === "prompt-publish" && ( - { + + label="publish to the playground?" + options={[ + { value: false, label: "no", hint: "DotNS only" }, + { value: true, label: "yes", hint: "publish to the playground registry" }, + ]} + initialIndex={0} + onSelect={(yes) => { setPublishToPlayground(yes); advance(mode, buildDir, domain, yes); }} /> )} + {stage.kind === "confirm" && resolved && ( )} + {stage.kind === "running" && resolved && ( { + onFinish={(outcome, chunkTimings) => { setStage({ kind: "done", outcome }); + // Surface completion on the terminal tab so users can glance over. + setWindowTitle(`dot deploy · ${resolved.domain} · ✓`); onDone(outcome); + // chunkTimings is threaded via ref below — consumed by FinalResult. + finalChunkTimingsRef.current = chunkTimings; }} onError={(message) => { setStage({ kind: "error", message }); + setWindowTitle(`dot deploy · ${resolved.domain} · ✕`); onDone(null); }} /> )} - {stage.kind === "done" && } + + {stage.kind === "done" && ( + + )} + {stage.kind === "error" && ( - - - - - Deploy failed - - - - {stage.message} - - +
+ +
)} ); @@ -211,97 +257,7 @@ function pickNextStage( return { kind: "confirm" }; } -// ── Prompt components ──────────────────────────────────────────────────────── - -function SignerPrompt({ onSelect }: { onSelect: (mode: SignerMode) => void }) { - const [index, setIndex] = useState(0); - const options: Array<{ mode: SignerMode; label: string; hint: string }> = [ - { mode: "dev", label: "Dev signer", hint: "Fast. 0 phone taps for upload." }, - { mode: "phone", label: "Your phone signer", hint: "Signed with your logged-in account." }, - ]; - - useInput((_input, key) => { - if (key.upArrow) setIndex((i) => (i - 1 + options.length) % options.length); - if (key.downArrow) setIndex((i) => (i + 1) % options.length); - if (key.return) onSelect(options[index].mode); - }); - - return ( - - Signer — use ↑/↓ then Enter - {options.map((opt, i) => ( - - {i === index ? "▸" : " "} - - {opt.label} - - — {opt.hint} - - ))} - - ); -} - -function TextPrompt({ - label, - initial, - prefill, - externalError, - validate, - onSubmit, -}: { - label: string; - initial: string; - prefill?: string; - externalError?: string | null; - validate?: (value: string) => string | null; - onSubmit: (value: string) => void; -}) { - const [value, setValue] = useState(prefill ?? initial); - const [error, setError] = useState(null); - - useInput((input, key) => { - if (key.return) { - const final = value.trim() || initial; - if (validate) { - const msg = validate(final); - if (msg) { - setError(msg); - return; - } - } - onSubmit(final); - return; - } - if (key.backspace || key.delete) { - setValue((v) => v.slice(0, -1)); - setError(null); - return; - } - if (key.ctrl || key.meta) return; - // Accept printable characters. - if (input && input.length > 0 && input >= " " && input !== "\t") { - setValue((v) => v + input); - setError(null); - } - }); - - const shownError = error ?? externalError ?? null; - return ( - - - {label} - {initial ? ` [${initial}]` : ""} - - - - {value} - - - {shownError && {shownError}} - - ); -} +// ── Domain validation ──────────────────────────────────────────────────────── function ValidateDomainStage({ domain, @@ -326,7 +282,7 @@ function ValidateDomainStage({ if (result.status === "available") { setStatus("done"); setMessage(formatAvailability(result)); - // Short hold so the user can read any note (e.g. "PoP will + // Short hold so users can read any note (e.g. "PoP will // be set up automatically") before the next prompt mounts. setTimeout( () => { @@ -346,7 +302,7 @@ function ValidateDomainStage({ if (cancelled) return; const msg = err instanceof Error ? err.message : String(err); setStatus("error"); - setMessage(`Availability check failed: ${msg}`); + setMessage(`availability check failed: ${msg}`); setTimeout(() => { if (!cancelled) onUnavailable(msg); }, 600); @@ -357,48 +313,12 @@ function ValidateDomainStage({ }; }, [domain]); + const mark: MarkKind = status === "checking" ? "run" : status === "done" ? "ok" : "fail"; + const label = status === "checking" ? `checking ${domain}` : (message ?? ""); return ( - - - {status === "checking" ? : status === "done" ? : } - - {status === "checking" ? `Checking availability of ${domain}…` : message} - - - - ); -} - -function YesNoPrompt({ - label, - initial, - onSubmit, -}: { - label: string; - initial: boolean; - onSubmit: (yes: boolean) => void; -}) { - const [yes, setYes] = useState(initial); - - useInput((input, key) => { - if (key.leftArrow || key.rightArrow || input === "y" || input === "n") { - setYes((prev) => (input === "y" ? true : input === "n" ? false : !prev)); - } - if (key.return) onSubmit(yes); - }); - - return ( - - {label} (y/n, ←/→ to toggle) - - - {yes ? "▸ Yes" : " Yes"} - - - {!yes ? "▸ No" : " No"} - - - +
+ +
); } @@ -445,38 +365,42 @@ function ConfirmStage({ return ( - {view.headline} - +
{view.rows.map((row) => ( - - {row.label.padEnd(10)} - {row.value} - + ))} - - +
+ +
{view.totalApprovals === 0 ? ( - No phone approvals required. + ) : ( <> - Phone approvals required: {view.totalApprovals} + {view.approvalLines.map((line) => ( - - {" "} + {line} - + ))} )} - - - Press Enter to deploy, Esc to cancel. - +
+ + enter to deploy · esc to cancel + {"error" in setup && setup.error && ( - - - {setup.error} - + + {setup.error} + )}
); @@ -491,12 +415,25 @@ interface PhaseState { const PHASE_ORDER: DeployPhase[] = ["build", "storage-and-dotns", "playground", "done"]; const PHASE_TITLE: Record = { - build: "Build", - "storage-and-dotns": "Upload + DotNS", - playground: "Publish to Playground", - done: "Done", + build: "build", + "storage-and-dotns": "upload + dotns", + playground: "publish to playground", + done: "done", }; +function phaseMark(status: PhaseState["status"]): MarkKind { + switch (status) { + case "complete": + return "ok"; + case "running": + return "run"; + case "error": + return "fail"; + default: + return "idle"; + } +} + function RunningStage({ projectDir, inputs, @@ -507,7 +444,7 @@ function RunningStage({ projectDir: string; inputs: Resolved; userSigner: ResolvedSigner | null; - onFinish: (outcome: DeployOutcome) => void; + onFinish: (outcome: DeployOutcome, chunkTimings: number[]) => void; onError: (message: string) => void; }) { const initialPhases: Record = { @@ -523,13 +460,18 @@ function RunningStage({ const [signingPrompt, setSigningPrompt] = useState(null); const [latestInfo, setLatestInfo] = useState(null); + // Per-chunk timing for the sparkline on completion. Held in refs to avoid + // re-renders on every chunk tick. + const chunkTimingsRef = useRef([]); + const lastChunkAtRef = useRef(null); + // ── Throttled info updates ────────────────────────────────────────── - // Verbose builds (vite / next) and bulletin-deploy's per-chunk logs - // can fire hundreds of "build-log" / "info" events per second. Calling - // setLatestInfo on every one floods React's update queue and — on long - // deploys — builds up enough backpressure to spike memory into the - // gigabytes. Users only ever see the most recent line anyway, so we - // coalesce updates to ~10 per second via a ref-based sink. + // Verbose builds (vite / next) and bulletin-deploy's per-chunk logs can + // fire hundreds of info events per second. Calling setLatestInfo on + // every one floods React's update queue and on long deploys builds up + // enough backpressure to spike memory into the gigabytes. Users only + // ever see the most recent line anyway, so we coalesce updates to ~10 + // per second via a ref-based sink. const pendingInfoRef = useRef(null); const infoTimerRef = useRef(null); const INFO_THROTTLE_MS = 100; @@ -549,6 +491,9 @@ function RunningStage({ }; useEffect(() => { + // Announce the command + target in the terminal tab on mount. + setWindowTitle(`dot deploy · ${inputs.domain} · building`); + let cancelled = false; (async () => { @@ -562,7 +507,7 @@ function RunningStage({ userSigner, onEvent: (event) => handleEvent(event), }); - if (!cancelled) onFinish(outcome); + if (!cancelled) onFinish(outcome, chunkTimingsRef.current); } catch (err) { if (!cancelled) { const message = err instanceof Error ? err.message : String(err); @@ -574,6 +519,13 @@ function RunningStage({ function handleEvent(event: DeployEvent) { if (event.kind === "phase-start") { setPhases((p) => ({ ...p, [event.phase]: { status: "running" } })); + if (event.phase === "storage-and-dotns") { + setWindowTitle(`dot deploy · ${inputs.domain} · uploading`); + } else if (event.phase === "playground") { + setWindowTitle(`dot deploy · ${inputs.domain} · publishing`); + } else if (event.phase === "build") { + setWindowTitle(`dot deploy · ${inputs.domain} · building`); + } } else if (event.kind === "phase-complete") { setPhases((p) => ({ ...p, [event.phase]: { status: "complete" } })); } else if (event.kind === "build-log") { @@ -582,7 +534,15 @@ function RunningStage({ queueInfo(`> ${event.config.description}`); } else if (event.kind === "storage-event") { if (event.event.kind === "chunk-progress") { - queueInfo(`Uploading chunk ${event.event.current}/${event.event.total}`); + const now = performance.now(); + const last = lastChunkAtRef.current; + if (last !== null) { + chunkTimingsRef.current.push(now - last); + } + lastChunkAtRef.current = now; + queueInfo( + `uploading chunk ${event.event.current}/${event.event.total}`, + ); } else if (event.event.kind === "info") { queueInfo(event.event.message); } @@ -593,7 +553,7 @@ function RunningStage({ setSigningPrompt(null); } else if (event.event.kind === "sign-error") { setSigningPrompt(null); - queueInfo(`Signing rejected: ${event.event.message}`); + queueInfo(`signing rejected: ${event.event.message}`); } } else if (event.kind === "error") { setPhases((p) => ({ @@ -615,40 +575,34 @@ function RunningStage({ return ( - {PHASE_ORDER.filter((p) => p !== "done").map((phase) => { - const state = phases[phase]; - return ( - - {state.status === "running" && } - {state.status === "complete" && } - {state.status === "error" && } - {state.status === "pending" && } - {PHASE_TITLE[phase]} - {state.detail && — {state.detail}} - - ); - })} +
+ {PHASE_ORDER.filter((p) => p !== "done").map((phase) => { + const state = phases[phase]; + return ( + + ); + })} +
+ {latestInfo && ( - - {truncate(latestInfo, 120)} + + {truncate(latestInfo, 120)} )} + {signingPrompt && signingPrompt.kind === "sign-request" && ( - - - 📱 Check your phone - + - Approve step {signingPrompt.step} of {signingPrompt.total}:{" "} + approve step {signingPrompt.step} of {signingPrompt.total}:{" "} {signingPrompt.label} - + )} ); @@ -656,33 +610,40 @@ function RunningStage({ // ── Final result ───────────────────────────────────────────────────────────── -function FinalResult({ outcome }: { outcome: DeployOutcome }) { +function FinalResult({ + outcome, + chunkTimings, +}: { + outcome: DeployOutcome; + chunkTimings: number[]; +}) { return ( - - - - Deploy complete - - - - - - - {outcome.ipfsCid && } - {outcome.metadataCid && ( - - )} + + + +
+ + + + {outcome.ipfsCid && } + {outcome.metadataCid && ( + + )} +
-
- ); -} -function LabelValue({ label, value }: { label: string; value: string }) { - return ( - - {label.padEnd(12)} - {value} + {chunkTimings.length > 0 && ( + + {"chunks".padEnd(14)} + + + {" "} + {chunkTimings.length + 1} chunks · avg{" "} + {(average(chunkTimings) / 1000).toFixed(2)}s/chunk + + + )} ); } @@ -690,3 +651,8 @@ function LabelValue({ label, value }: { label: string; value: string }) { function truncate(s: string, n: number): string { return s.length > n ? `${s.slice(0, n - 1)}…` : s; } + +function average(xs: number[]): number { + if (xs.length === 0) return 0; + return xs.reduce((a, b) => a + b, 0) / xs.length; +} diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index ae2ac49..b57e01f 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -1,42 +1,43 @@ import { useState, useEffect } from "react"; -import { Box, Text } from "ink"; -import { Spinner, Done, Failed } from "../../utils/ui/index.js"; +import { Row, Section, type MarkKind } from "../../utils/ui/theme/index.js"; import { getConnection } from "../../utils/connection.js"; import { getSessionSigner, type SessionHandle } from "../../utils/auth.js"; import { checkBalance, ensureFunded, FUND_AMOUNT } from "../../utils/account/funding.js"; import { checkMapping, ensureMapped } from "../../utils/account/mapping.js"; +import { checkAllowance, ensureAllowance, LOW_TX_THRESHOLD } from "../../utils/account/allowance.js"; import { - checkAllowance, - ensureAllowance, - LOW_TX_THRESHOLD, -} from "../../utils/account/allowance.js"; + checkAttestation, + getBulletinBlockTimeMs, + formatAttestation, + type FormattedAttestation, +} from "../../utils/account/attestation.js"; type Status = "pending" | "active" | "ok" | "failed" | "skipped"; -const STEP_COUNT = 3; - /** Planck per PAS (10 decimals). */ const PLANCK_PER_PAS = 10_000_000_000n; interface StepState { label: string; status: Status; - detail?: string; + value?: string; + valueTone?: "default" | "danger" | "warning" | "muted" | "accent"; + hint?: string; error?: string; } -function StatusIcon({ status }: { status: Status }) { +function toMark(status: Status): MarkKind | undefined { switch (status) { case "active": - return ; + return "run"; case "ok": - return ; + return "ok"; case "failed": - return ; + return "fail"; case "skipped": - return -; + return "idle"; default: - return ·; + return "idle"; } } @@ -63,9 +64,9 @@ export function AccountSetup({ onDone: (success: boolean) => void; }) { const [steps, setSteps] = useState([ - { label: "Funding account", status: "pending" }, - { label: "Mapping account (Revive)", status: "pending" }, - { label: "Granting bulletin access", status: "pending" }, + { label: "funding", status: "pending" }, + { label: "mapping", status: "pending" }, + { label: "bulletin", status: "pending" }, ]); useEffect(() => { @@ -91,7 +92,7 @@ export function AccountSetup({ client = await getConnection(); } catch (err) { const msg = describe(err); - for (let i = 0; i < STEP_COUNT; i++) update(i, { status: "failed", error: msg }); + setSteps((prev) => prev.map((s) => ({ ...s, status: "failed", error: msg }))); finish(false); return; } @@ -104,29 +105,24 @@ export function AccountSetup({ const before = await checkBalance(client, address); if (cancelled) return; if (before.sufficient) { - update(0, { - status: "ok", - detail: `Balance: ${formatPas(before.free)}`, - }); + update(0, { status: "ok", value: formatPas(before.free) }); funded = true; } else { update(0, { status: "active", - detail: `Balance: ${formatPas(before.free)} — funding...`, + value: formatPas(before.free), + hint: "funding…", }); await ensureFunded(client, address); if (cancelled) return; // Optimistic post-balance: avoids a second RPC call that // could read a stale best-block and display the old value. const expected = before.free + FUND_AMOUNT; - update(0, { - status: "ok", - detail: `Balance: ${formatPas(expected)}`, - }); + update(0, { status: "ok", value: formatPas(expected), hint: undefined }); funded = true; } } catch (err) { - update(0, { status: "failed", error: describe(err) }); + update(0, { status: "failed", error: describe(err), valueTone: "danger" }); } // Step 1: Revive mapping (requires funds, user signs via mobile wallet) @@ -136,56 +132,67 @@ export function AccountSetup({ const mapped = await checkMapping(client, address); if (cancelled) return; if (mapped) { - update(1, { status: "ok", detail: "Already mapped" }); + update(1, { status: "ok", value: "mapped", valueTone: "muted" }); } else { session = await getSessionSigner(); if (cancelled) return; if (!session) { update(1, { status: "failed", - error: "No session — run dot init to log in", + error: "no session — run dot init to log in", }); } else { update(1, { status: "active", - detail: "Approve on your Polkadot mobile app...", + value: "approve on your Polkadot mobile app…", + valueTone: "muted", }); await ensureMapped(client, address, session.signer); if (cancelled) return; - update(1, { status: "ok", detail: "Mapped" }); + update(1, { status: "ok", value: "mapped", valueTone: "muted" }); } } } catch (err) { update(1, { status: "failed", error: describe(err) }); } } else { - update(1, { status: "skipped", error: "Skipped — account not funded" }); + update(1, { + status: "skipped", + value: "skipped — account not funded", + valueTone: "muted", + }); } - // Step 2: Bulletin allowance (Alice signs, independent of mapping) + // Step 2: Bulletin attestation (Alice signs, independent of mapping). + // Always surfaces the formatted attestation validity — users see + // this even on re-runs where nothing needs refreshing. update(2, { status: "active" }); try { + const blockTimeMs = await getBulletinBlockTimeMs(client); const before = await checkAllowance(client, address); if (cancelled) return; if (before.authorized && before.remainingTxs >= LOW_TX_THRESHOLD) { - update(2, { - status: "ok", - detail: `${before.remainingTxs} txs, ${formatMb(before.remainingBytes)} remaining`, + const attestation = await checkAttestation(client, address); + if (cancelled) return; + applyAttestation(update, 2, "ok", formatAttestation(attestation, blockTimeMs), { + txs: before.remainingTxs, + bytes: before.remainingBytes, }); } else { update(2, { status: "active", - detail: before.authorized - ? `Low quota (${before.remainingTxs} txs) — authorizing...` - : "Not authorized — authorizing...", + value: before.authorized + ? `low quota (${before.remainingTxs} txs) — refreshing…` + : "not authorized — attesting…", + valueTone: "muted", }); await ensureAllowance(client, address); if (cancelled) return; - const after = await checkAllowance(client, address); + const attestation = await checkAttestation(client, address); if (cancelled) return; - update(2, { - status: "ok", - detail: `${after.remainingTxs} txs, ${formatMb(after.remainingBytes)} remaining`, + applyAttestation(update, 2, "ok", formatAttestation(attestation, blockTimeMs), { + txs: attestation.remainingTxs, + bytes: attestation.remainingBytes, }); } } catch (err) { @@ -208,24 +215,32 @@ export function AccountSetup({ }, [address, onDone]); return ( - - - Account setup - +
{steps.map((step) => ( - - - - {step.label} - {step.detail && {step.detail}} - - {step.error && ( - - {step.error} - - )} - + ))} - +
); } + +/** Write a formatted attestation into a step's value, with its quota as the hint. */ +function applyAttestation( + update: (idx: number, patch: Partial) => void, + idx: number, + status: Status, + formatted: FormattedAttestation, + quota: { txs: number | undefined; bytes: bigint | undefined }, +): void { + const hint = + quota.txs !== undefined && quota.bytes !== undefined + ? `${quota.txs} txs · ${formatMb(quota.bytes)} remaining` + : undefined; + update(idx, { status, value: formatted.text, valueTone: formatted.tone, hint }); +} diff --git a/src/commands/init/DependencyList.tsx b/src/commands/init/DependencyList.tsx index d184006..1049770 100644 --- a/src/commands/init/DependencyList.tsx +++ b/src/commands/init/DependencyList.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; -import { Box, Text } from "ink"; -import { Spinner, Done, Failed, Warning } from "../../utils/ui/index.js"; +import { Box } from "ink"; +import { Row, LogTail, Section, type MarkKind } from "../../utils/ui/theme/index.js"; import { TOOL_STEPS, isGhAuthenticated } from "../../utils/toolchain.js"; type Status = "pending" | "checking" | "installing" | "ok" | "failed" | "warning"; @@ -14,19 +14,19 @@ interface StepState { hint?: string; } -function StatusIcon({ status }: { status: Status }) { +function toMark(status: Status): MarkKind { switch (status) { - case "checking": - case "installing": - return ; case "ok": - return ; + return "ok"; case "failed": - return ; + return "fail"; case "warning": - return ; + return "warn"; + case "checking": + case "installing": + return "run"; default: - return ·; + return "idle"; } } @@ -37,11 +37,10 @@ export function DependencyList({ onDone }: { onDone: () => void }) { status: "pending" as Status, hint: s.manualHint, })), - { name: "Authenticated", status: "pending" as Status }, + { name: "authenticated", status: "pending" as Status }, ]); const [output, setOutput] = useState([]); const [complete, setComplete] = useState(false); - const [allOk, setAllOk] = useState(true); useEffect(() => { const onData = (line: string) => { @@ -49,13 +48,9 @@ export function DependencyList({ onDone }: { onDone: () => void }) { }; (async () => { - let ok = true; - for (let i = 0; i < TOOL_STEPS.length; i++) { const step = TOOL_STEPS[i]; - setSteps((prev) => - prev.map((s, j) => (j === i ? { ...s, status: "checking" } : s)), - ); + setSteps((prev) => prev.map((s, j) => (j === i ? { ...s, status: "checking" } : s))); if (await step.check()) { setSteps((prev) => prev.map((s, j) => (j === i ? { ...s, status: "ok" } : s))); @@ -71,7 +66,6 @@ export function DependencyList({ onDone }: { onDone: () => void }) { ); setOutput([]); } catch (err) { - ok = false; const msg = err instanceof Error ? err.message : String(err); setSteps((prev) => prev.map((s, j) => @@ -85,20 +79,17 @@ export function DependencyList({ onDone }: { onDone: () => void }) { // gh auth check (advisory — not auto-login) const authIdx = TOOL_STEPS.length; if (await isGhAuthenticated()) { - setSteps((prev) => - prev.map((s, j) => (j === authIdx ? { ...s, status: "ok" } : s)), - ); + setSteps((prev) => prev.map((s, j) => (j === authIdx ? { ...s, status: "ok" } : s))); } else { setSteps((prev) => prev.map((s, j) => j === authIdx - ? { ...s, status: "warning", message: "Run: gh auth login" } + ? { ...s, status: "warning", message: "run: gh auth login" } : s, ), ); } - setAllOk(ok); setComplete(true); })(); }, []); @@ -108,50 +99,28 @@ export function DependencyList({ onDone }: { onDone: () => void }) { }, [complete]); return ( - - - Installing dependencies - +
{steps.map((step) => ( - - - {step.name} - {step.message && {step.message}} - + ))} {!complete && ( - - {Array.from({ length: OUTPUT_LINES }, (_, i) => ( - - {output[i] ?? " "} - - ))} - - )} - {complete && ( - {allOk ? ( - - {" "} - All dependencies installed - - ) : ( - - {steps - .filter((s) => s.status === "failed") - .map((s) => ( - - - {s.name} - - {s.message && {s.message}} - {s.hint && Manual install: {s.hint}} - - ))} - - )} + )} - +
); } diff --git a/src/commands/init/InitScreen.tsx b/src/commands/init/InitScreen.tsx index 87c6974..d694efe 100644 --- a/src/commands/init/InitScreen.tsx +++ b/src/commands/init/InitScreen.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from "react"; -import { Box, Text } from "ink"; +import { Box } from "ink"; +import { Header, Row, Section } from "../../utils/ui/theme/index.js"; import { DependencyList } from "./DependencyList.js"; import { QrLogin } from "./QrLogin.js"; import { AccountSetup } from "./AccountSetup.js"; import { computeAllDone } from "./completion.js"; +import { VERSION_LABEL } from "../../utils/version.js"; import type { LoginHandle } from "../../utils/auth.js"; export function InitScreen({ @@ -50,24 +52,35 @@ export function InitScreen({ return ( +
+ {needsQr && } {!needsQr && existingAddress && ( - - - Logged in - {existingAddress} - +
+ +
)} + + {loggedInAddress && depsComplete && ( )} + {allDone && ( - - - Setup complete - {!accountOk && (some account setup steps failed)} - +
+ +
)} ); diff --git a/src/commands/init/QrLogin.tsx b/src/commands/init/QrLogin.tsx index 17f2b1a..c7bd610 100644 --- a/src/commands/init/QrLogin.tsx +++ b/src/commands/init/QrLogin.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; -import { Box, Text } from "ink"; -import { Spinner, Done, Failed } from "../../utils/ui/index.js"; +import { Row } from "../../utils/ui/theme/index.js"; import { waitForLogin, type LoginStatus, type LoginHandle } from "../../utils/auth.js"; export function QrLogin({ @@ -16,35 +15,18 @@ export function QrLogin({ waitForLogin(login, setStatus).then(onDone); }, []); - return ( - - - Logging in - - - {(status.step === "waiting" || - status.step === "paired" || - status.step === "attesting") && ( - <> - - Sign in with the Polkadot App - - )} - {status.step === "success" && ( - <> - - Logged in - {status.address} - - )} - {status.step === "error" && ( - <> - - Login failed - {status.message} - - )} - - - ); + if (status.step === "success") { + return ; + } + if (status.step === "error") { + return ( + + ); + } + return ; } diff --git a/src/commands/mod/AppBrowser.tsx b/src/commands/mod/AppBrowser.tsx index 69ed318..15f0213 100644 --- a/src/commands/mod/AppBrowser.tsx +++ b/src/commands/mod/AppBrowser.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { Box, Text, useInput, useStdout } from "ink"; import { getGateway, fetchJson } from "@polkadot-apps/bulletin"; -import { Spinner } from "../../utils/ui/index.js"; +import { Mark, Hint, COLOR } from "../../utils/ui/theme/index.js"; export interface AppEntry { domain: string; @@ -41,11 +41,9 @@ export function AppBrowser({ registry, onSelect }: Props) { const indices = []; for (let i = startIdx; i > startIdx - BATCH && i >= 0; i--) indices.push(i); - // Track where next batch should start const lowestQueried = Math.min(...indices); nextIdx.current = lowestQueried > 0 ? lowestQueried - 1 : null; - // Fetch domains in parallel const results = await Promise.all( indices.map(async (i) => { const res = await registry.getDomainAt.query(i); @@ -60,7 +58,6 @@ export function AppBrowser({ registry, onSelect }: Props) { setApps((prev) => [...prev, ...entries]); setFetching(false); - // Fetch metadata in background, update each entry as it arrives await Promise.allSettled( entries.map(async (entry) => { const metaRes = await registry.getMetadataUri.query(entry.domain); @@ -85,7 +82,6 @@ export function AppBrowser({ registry, onSelect }: Props) { [registry, gateway], ); - // Initial load useEffect(() => { (async () => { const res = await registry.getAppCount.query(); @@ -95,14 +91,12 @@ export function AppBrowser({ registry, onSelect }: Props) { })(); }, []); - // Auto-load when cursor nears end useEffect(() => { if (cursor >= apps.length - 3 && nextIdx.current !== null && !fetching) { loadBatch(nextIdx.current); } }, [cursor, apps.length, fetching]); - // Keyboard useInput((input, key) => { if (key.upArrow && cursor > 0) { const next = cursor - 1; @@ -122,17 +116,15 @@ export function AppBrowser({ registry, onSelect }: Props) { const descW = Math.max((stdout?.columns ?? 80) - COL.num - COL.domain - COL.name - 10, 10); return ( - + - {pad(" #", COL.num)}│ {pad("Domain", COL.domain)}│ {pad("Name", COL.name)}│{" "} - Description + {pad(" #", COL.num)} {pad("domain", COL.domain)} {pad("name", COL.name)} description - {"─".repeat(COL.num)}┼{"─".repeat(COL.domain + 1)}┼{"─".repeat(COL.name + 1)}┼ - {"─".repeat(descW + 1)} + {"─".repeat(COL.num + COL.domain + COL.name + descW + 6)} @@ -140,29 +132,27 @@ export function AppBrowser({ registry, onSelect }: Props) { const idx = scroll + i; const sel = idx === cursor; const num = sel - ? `>${String(idx + 1).padStart(COL.num - 1)}` + ? `›${String(idx + 1).padStart(COL.num - 1)}` : ` ${String(idx + 1).padStart(COL.num - 1)}`; return ( - - {num}│ {pad(app.domain, COL.domain)}│{" "} - {pad(app.name ?? (app.name === null ? "…" : "—"), COL.name)}│{" "} - {pad(app.description ?? "", descW)} + + {num} {pad(app.domain, COL.domain)} {pad(app.name ?? (app.name === null ? "…" : "—"), COL.name)} {pad(app.description ?? "", descW)} ); })} {fetching && ( - - - Loading apps... + + + loading apps… )} - - ↑↓ navigate ⏎ select q quit ({apps.length}/{total}) - + + ↑↓ navigate · ⏎ select · q quit · ({apps.length}/{total}) + ); diff --git a/src/commands/mod/SetupScreen.tsx b/src/commands/mod/SetupScreen.tsx index 51c14a5..10f8c58 100644 --- a/src/commands/mod/SetupScreen.tsx +++ b/src/commands/mod/SetupScreen.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; -import { Box, Text } from "ink"; +import { Box } from "ink"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { getGateway, fetchJson } from "@polkadot-apps/bulletin"; -import { StepRunner, type Step, type StepRunnerResult } from "../../utils/ui/index.js"; +import { StepRunner, type Step } from "../../utils/ui/components/StepRunner.js"; +import { Header, Hint, Row, Section } from "../../utils/ui/theme/index.js"; import { isGhAuthenticated, forkAndClone, cloneRepo, runCommand } from "../../utils/git.js"; +import { VERSION_LABEL } from "../../utils/version.js"; interface AppMetadata { name?: string; @@ -39,24 +41,24 @@ export function SetupScreen({ const steps: Step[] = [ { - name: "Fetch app metadata", + name: "fetch app metadata", run: async (log) => { if (initial) { - log("Using cached metadata"); + log("using cached metadata"); return; } - log(`Querying registry for ${domain}...`); + log(`querying registry for ${domain}...`); const metaRes = await registry.getMetadataUri.query(domain); const cid = metaRes.value.isSome ? metaRes.value.value : null; if (!cid) throw new Error(`App "${domain}" not found in registry`); - log(`Fetching metadata from IPFS (${cid.slice(0, 16)}...)...`); + log(`fetching metadata from IPFS (${cid.slice(0, 16)}...)...`); meta = await fetchJson(cid, getGateway("paseo")); if (!meta.repository) throw new Error("App has no repository URL"); }, }, { - name: canFork ? "Fork & clone" : "Clone", + name: canFork ? "fork & clone" : "clone", run: async (log) => { const repo = meta.repository!; if (canFork) { @@ -69,10 +71,10 @@ export function SetupScreen({ }, }, { - name: "Run setup.sh", + name: "run setup.sh", run: async (log) => { if (!existsSync(resolve(targetDir, "setup.sh"))) { - throw new StepWarning("No setup.sh found"); + throw new StepWarning("no setup.sh found"); } await runCommand("bash setup.sh", { cwd: targetDir, log }); }, @@ -83,21 +85,23 @@ export function SetupScreen({ return ( +
+ { if (result.error) setError(result.error); onDone(result.ok); }} /> - - → {targetDir} - + + → {targetDir} + {error && ( - - {error} - +
+ +
)} ); diff --git a/src/index.ts b/src/index.ts index b34f8cd..15750c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,8 @@ import { modCommand } from "./commands/mod/index.js"; import { buildCommand } from "./commands/build.js"; import { deployCommand } from "./commands/deploy/index.js"; import { updateCommand } from "./commands/update.js"; -import { installSignalHandlers } from "./utils/process-guard.js"; +import { installSignalHandlers, onProcessShutdown } from "./utils/process-guard.js"; +import { clearWindowTitle } from "./utils/ui/theme/window-title.js"; // ── Bun compiled-binary stdin workaround ───────────────────────────────────── // When `dot` is shipped via `bun build --compile`, Ink's internal @@ -39,6 +40,11 @@ if (process.env.BULLETIN_DEPLOY_TELEMETRY === undefined) { // indefinitely. installSignalHandlers(); +// Hand the terminal tab title back to the shell on exit. The shell usually +// repaints its own title immediately, but being explicit avoids leaving +// "dot deploy · my-app.dot · ✓" stuck on a long-lived tab. +onProcessShutdown(clearWindowTitle); + const program = new Command() .name("dot") .description("CLI for Polkadot Playground") diff --git a/src/utils/account/attestation.test.ts b/src/utils/account/attestation.test.ts new file mode 100644 index 0000000..28312f7 --- /dev/null +++ b/src/utils/account/attestation.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi } from "vitest"; +import { + formatAttestation, + humanizeDuration, + checkAttestation, + type AttestationStatus, +} from "./attestation.js"; + +const SIX_SECONDS = 6_000; + +// ── humanizeDuration ───────────────────────────────────────────────────────── + +describe("humanizeDuration", () => { + it("floors to 1m for any positive sub-minute input", () => { + expect(humanizeDuration(1_000)).toBe("~1m"); + expect(humanizeDuration(45_000)).toBe("~1m"); + }); + + it("renders minutes below an hour", () => { + expect(humanizeDuration(47 * 60_000)).toBe("~47m"); + }); + + it("renders hours (with minutes when non-zero) below a day", () => { + expect(humanizeDuration(3 * 3_600_000 + 15 * 60_000)).toBe("~3h 15m"); + expect(humanizeDuration(5 * 3_600_000)).toBe("~5h"); + }); + + it("renders days (with hours when non-zero) up to 30d", () => { + expect(humanizeDuration(13 * 86_400_000 + 4 * 3_600_000)).toBe("~13d 4h"); + expect(humanizeDuration(7 * 86_400_000)).toBe("~7d"); + }); + + it("returns >30d for anything beyond", () => { + expect(humanizeDuration(30 * 86_400_000)).toBe(">30d"); + expect(humanizeDuration(400 * 86_400_000)).toBe(">30d"); + }); + + it("returns 0m for zero or negative values", () => { + expect(humanizeDuration(0)).toBe("0m"); + expect(humanizeDuration(-1)).toBe("0m"); + }); +}); + +// ── formatAttestation ──────────────────────────────────────────────────────── + +const unauthorized: AttestationStatus = { + authorized: false, + expired: false, + remainingBlocks: 0, + expiresAt: undefined, + remainingTxs: undefined, + remainingBytes: undefined, +}; + +describe("formatAttestation", () => { + it("reports 'not attested' when authorization is missing", () => { + expect(formatAttestation(unauthorized, SIX_SECONDS)).toEqual({ + text: "not attested", + tone: "muted", + }); + }); + + it("reports expired with block number when authorization exists but has lapsed", () => { + const expired: AttestationStatus = { + authorized: true, + expired: true, + remainingBlocks: 0, + expiresAt: 14_582_331, + remainingTxs: 1000, + remainingBytes: 100_000_000n, + }; + expect(formatAttestation(expired, SIX_SECONDS)).toEqual({ + text: "expired · #14,582,331", + tone: "danger", + }); + }); + + it("reports remaining duration + expiry block in default tone above 24h", () => { + const remainingMs = 13 * 86_400_000 + 4 * 3_600_000; + const active: AttestationStatus = { + authorized: true, + expired: false, + remainingBlocks: Math.round(remainingMs / SIX_SECONDS), + expiresAt: 14_582_331, + remainingTxs: 500, + remainingBytes: 50_000_000n, + }; + const f = formatAttestation(active, SIX_SECONDS); + expect(f.text).toBe("~13d 4h · #14,582,331"); + expect(f.tone).toBe("default"); + }); + + it("switches to warning tone when less than 24h remains", () => { + const remainingMs = 5 * 3_600_000; + const active: AttestationStatus = { + authorized: true, + expired: false, + remainingBlocks: Math.round(remainingMs / SIX_SECONDS), + expiresAt: 99_000, + remainingTxs: 100, + remainingBytes: 10_000_000n, + }; + const f = formatAttestation(active, SIX_SECONDS); + expect(f.text.startsWith("~5h")).toBe(true); + expect(f.tone).toBe("warning"); + }); +}); + +// ── checkAttestation ───────────────────────────────────────────────────────── + +function makeClient(authRaw: unknown, currentBlock: number) { + return { + bulletin: { + query: { + TransactionStorage: { + Authorizations: { getValue: vi.fn().mockResolvedValue(authRaw) }, + }, + System: { + Number: { getValue: vi.fn().mockResolvedValue(currentBlock) }, + }, + }, + }, + } as any; +} + +describe("checkAttestation", () => { + it("returns unauthorized when no storage entry exists", async () => { + const client = makeClient(undefined, 100); + const s = await checkAttestation(client, "5GrwvaEF"); + expect(s).toEqual({ + authorized: false, + expired: false, + remainingBlocks: 0, + expiresAt: undefined, + remainingTxs: undefined, + remainingBytes: undefined, + }); + }); + + it("derives remainingBlocks from expiration - currentBlock", async () => { + const client = makeClient( + { extent: { transactions: 500, bytes: 50_000_000n }, expiration: 1000 }, + 200, + ); + const s = await checkAttestation(client, "5GrwvaEF"); + expect(s.authorized).toBe(true); + expect(s.expired).toBe(false); + expect(s.remainingBlocks).toBe(800); + expect(s.expiresAt).toBe(1000); + expect(s.remainingTxs).toBe(500); + expect(s.remainingBytes).toBe(50_000_000n); + }); + + it("marks as expired when expiration has passed", async () => { + const client = makeClient( + { extent: { transactions: 10, bytes: 1_000_000n }, expiration: 200 }, + 500, + ); + const s = await checkAttestation(client, "5GrwvaEF"); + expect(s.authorized).toBe(true); + expect(s.expired).toBe(true); + expect(s.remainingBlocks).toBe(0); + }); + + it("wraps the address in Enum('Account', ...) when querying", async () => { + const client = makeClient(undefined, 0); + await checkAttestation(client, "5GrwvaEF_addr"); + const arg = client.bulletin.query.TransactionStorage.Authorizations.getValue.mock + .calls[0][0]; + expect(arg).toMatchObject({ type: "Account", value: "5GrwvaEF_addr" }); + }); +}); diff --git a/src/utils/account/attestation.ts b/src/utils/account/attestation.ts new file mode 100644 index 0000000..dd6393d --- /dev/null +++ b/src/utils/account/attestation.ts @@ -0,0 +1,135 @@ +/** + * Bulletin attestation — read current authorization, format for the TUI. + * + * Bulletin authorizations (`TransactionStorage.Authorizations`) carry an + * explicit `expiration` block number. `dot init` surfaces this on every run + * so users can see — even when already signed in — how much longer their + * upload quota is valid for. + * + * Formatting is a pure function of a fetched status + the chain's block + * time (ms per block), so it is trivially unit-testable without touching + * the chain client. + */ + +import { Enum } from "polkadot-api"; +import type { PaseoClient } from "../connection.js"; + +const AT_BEST = { at: "best" as const }; + +export interface AttestationStatus { + authorized: boolean; + expired: boolean; + /** 0 if unauthorized or expired. */ + remainingBlocks: number; + /** Absolute block number at which the authorization expires. */ + expiresAt: number | undefined; + remainingTxs: number | undefined; + remainingBytes: bigint | undefined; +} + +export async function checkAttestation( + client: PaseoClient, + address: string, +): Promise { + const [raw, currentBlock] = await Promise.all([ + client.bulletin.query.TransactionStorage.Authorizations.getValue( + Enum("Account", address), + AT_BEST, + ), + client.bulletin.query.System.Number.getValue(AT_BEST), + ]); + + if (!raw) { + return { + authorized: false, + expired: false, + remainingBlocks: 0, + expiresAt: undefined, + remainingTxs: undefined, + remainingBytes: undefined, + }; + } + + const remainingBlocks = Math.max(0, raw.expiration - currentBlock); + return { + authorized: true, + expired: remainingBlocks === 0, + remainingBlocks, + expiresAt: raw.expiration, + remainingTxs: raw.extent.transactions, + remainingBytes: raw.extent.bytes, + }; +} + +/** + * Bulletin runs on Aura — the slot duration doubles as the block time. + * Cached for the process lifetime; chain constants don't change without + * a runtime upgrade. + */ +let cachedBlockTimeMs: number | null = null; +export async function getBulletinBlockTimeMs(client: PaseoClient): Promise { + if (cachedBlockTimeMs !== null) return cachedBlockTimeMs; + const ms = await client.bulletin.constants.Aura.SlotDuration(); + cachedBlockTimeMs = Number(ms); + return cachedBlockTimeMs; +} + +// ── Formatter (pure) ───────────────────────────────────────────────────────── + +export type AttestationTone = "default" | "warning" | "danger" | "muted"; + +export interface FormattedAttestation { + text: string; + tone: AttestationTone; +} + +/** 24 hours — below this, attestation reads in warning color. */ +const WARNING_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +/** + * Turn a raw attestation status + the chain's block time into the compact + * display string shown on a Row value. No colors are chosen here — only a + * tone, which the theme maps to its palette. + */ +export function formatAttestation( + status: AttestationStatus, + blockTimeMs: number, +): FormattedAttestation { + if (!status.authorized) { + return { text: "not attested", tone: "muted" }; + } + if (status.expired) { + return { text: `expired · ${formatBlock(status.expiresAt!)}`, tone: "danger" }; + } + const remainingMs = status.remainingBlocks * blockTimeMs; + const human = humanizeDuration(remainingMs); + const tone: AttestationTone = remainingMs < WARNING_THRESHOLD_MS ? "warning" : "default"; + return { text: `${human} · ${formatBlock(status.expiresAt!)}`, tone }; +} + +const MS_PER_MINUTE = 60_000; +const MS_PER_HOUR = 60 * MS_PER_MINUTE; +const MS_PER_DAY = 24 * MS_PER_HOUR; + +/** + * Human-readable remaining time. Quantized so "roughly how long" is never + * more precise than it needs to be: minutes below an hour, hours below a + * day, days with trailing hours below a month, and >30d beyond that. + */ +export function humanizeDuration(ms: number): string { + if (ms <= 0) return "0m"; + if (ms >= 30 * MS_PER_DAY) return ">30d"; + + const days = Math.floor(ms / MS_PER_DAY); + const hours = Math.floor((ms % MS_PER_DAY) / MS_PER_HOUR); + const minutes = Math.floor((ms % MS_PER_HOUR) / MS_PER_MINUTE); + + if (days >= 1) return hours > 0 ? `~${days}d ${hours}h` : `~${days}d`; + if (hours >= 1) return minutes > 0 ? `~${hours}h ${minutes}m` : `~${hours}h`; + return `~${minutes || 1}m`; +} + +/** Block number with thousands separators, prefixed with '#'. */ +function formatBlock(n: number): string { + return `#${n.toLocaleString("en-US")}`; +} diff --git a/src/utils/account/index.ts b/src/utils/account/index.ts index 74858f8..bdfa5b8 100644 --- a/src/utils/account/index.ts +++ b/src/utils/account/index.ts @@ -14,3 +14,12 @@ export { LOW_TX_THRESHOLD, type AllowanceStatus, } from "./allowance.js"; +export { + checkAttestation, + getBulletinBlockTimeMs, + formatAttestation, + humanizeDuration, + type AttestationStatus, + type AttestationTone, + type FormattedAttestation, +} from "./attestation.js"; diff --git a/src/utils/ui/components/StepRunner.tsx b/src/utils/ui/components/StepRunner.tsx index 76668fc..ff8173f 100644 --- a/src/utils/ui/components/StepRunner.tsx +++ b/src/utils/ui/components/StepRunner.tsx @@ -1,14 +1,14 @@ /** - * Reusable step runner — displays a list of sequential steps with - * spinner → ✔/✖/! transitions and a fixed-height log box for output. + * Reusable step runner — displays a list of sequential steps with status + * marks and a fixed-height log tail for step output. * * Errors are passed to onDone for the parent to display below the UI. - * Warnings (isWarning = true) show inline and don't stop execution. + * Warnings (`isWarning = true`) show inline and don't stop execution. */ import { useState, useEffect } from "react"; -import { Box, Text } from "ink"; -import { Spinner, Done, Failed, Warning } from "./loading.js"; +import { Box } from "ink"; +import { Row, Section, LogTail, type MarkKind } from "../theme/index.js"; export interface Step { name: string; @@ -25,23 +25,18 @@ interface StepState { const LOG_LINES = 5; -function StatusIcon({ status }: { status: StepStatus }) { - // ✔ and ✖ render wide (2 cols). Pad spinner and · to match. +function toMark(status: StepStatus): MarkKind { switch (status) { case "running": - return ( - - {" "} - - ); + return "run"; case "ok": - return ; + return "ok"; case "failed": - return ; + return "fail"; case "warning": - return ; + return "warn"; default: - return · ; + return "idle"; } } @@ -112,30 +107,21 @@ export function StepRunner({ title, steps, onDone }: Props) { const running = states.some((s) => s.status === "running"); return ( - - - {title} - - +
{states.map((step) => ( - - - - {step.name} - {step.message ? — {step.message} : ""} - - + ))} - {running && output.length > 0 && ( - - {Array.from({ length: LOG_LINES }, (_, i) => ( - - {output[i] ?? " "} - - ))} + + )} - +
); } diff --git a/src/utils/ui/components/loading.tsx b/src/utils/ui/components/loading.tsx deleted file mode 100644 index 5f4fb07..0000000 --- a/src/utils/ui/components/loading.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useState, useEffect } from "react"; -import { Text } from "ink"; - -const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -const SPINNER_INTERVAL_MS = 80; - -export function Spinner({ tick: externalTick }: { tick?: number } = {}) { - const [internalTick, setInternalTick] = useState(0); - - useEffect(() => { - if (externalTick !== undefined) return; - const id = setInterval(() => setInternalTick((t) => t + 1), SPINNER_INTERVAL_MS); - return () => clearInterval(id); - }, [externalTick]); - - const tick = externalTick ?? internalTick; - return {SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}; -} - -export function Done() { - return ; -} - -export function Failed() { - return ; -} - -export function Warning() { - // ✔ & ✖ render with a space to the right - // Add space manually here - return ! ; -} diff --git a/src/utils/ui/index.ts b/src/utils/ui/index.ts index d8222a5..fe54c11 100644 --- a/src/utils/ui/index.ts +++ b/src/utils/ui/index.ts @@ -1,2 +1,10 @@ -export { Spinner, Done, Failed, Warning } from "./components/loading.js"; +/** + * Public TUI surface: re-exports the theme plug (the visual system) and + * the shared StepRunner (the sequential-steps runner built on top of it). + * + * Screens import everything they need from this module. The theme itself + * lives in `./theme/` — edit that directory to change the look. + */ + +export * from "./theme/index.js"; export { StepRunner, type Step, type StepRunnerResult } from "./components/StepRunner.js"; diff --git a/src/utils/ui/theme/Callout.tsx b/src/utils/ui/theme/Callout.tsx new file mode 100644 index 0000000..4085ec2 --- /dev/null +++ b/src/utils/ui/theme/Callout.tsx @@ -0,0 +1,52 @@ +import { Box, Text } from "ink"; +import { COLOR, LAYOUT } from "./tokens.js"; + +type CalloutTone = "accent" | "warning" | "danger" | "success"; + +/** + * Light-touch bordered panel for moments that must interrupt scanning — + * e.g. the "check your phone" sign-in prompt during a deploy. Use sparingly; + * overusing this reverts the aesthetic to card-soup. + */ +export function Callout({ + tone = "accent", + title, + children, +}: { + tone?: CalloutTone; + title?: string; + children: React.ReactNode; +}) { + const color = toneToColor(tone); + return ( + + {title && ( + + {title} + + )} + {children} + + ); +} + +function toneToColor(tone: CalloutTone) { + switch (tone) { + case "danger": + return COLOR.danger; + case "warning": + return COLOR.warning; + case "success": + return COLOR.success; + default: + return COLOR.accent; + } +} diff --git a/src/utils/ui/theme/Header.tsx b/src/utils/ui/theme/Header.tsx new file mode 100644 index 0000000..24992e4 --- /dev/null +++ b/src/utils/ui/theme/Header.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from "react"; +import { Box, Text, useStdout } from "ink"; +import { LAYOUT } from "./tokens.js"; +import { setWindowTitle } from "./window-title.js"; +import { Rule } from "./Rule.js"; + +export interface HeaderProps { + /** "dot init", "dot deploy", etc. Lowercase. First token rendered bold. */ + cmd: string; + /** Second bread-crumb piece: "polkadot playground" or the domain being deployed. */ + subtitle?: string; + /** Short network label — "paseo" on testnet. */ + network?: string; + /** Right-aligned metadata; most commonly the CLI version. */ + right?: string; + /** + * Override for the terminal tab/window title. When omitted, we set + * "{cmd} · {subtitle}" automatically. When a screen wants finer-grained + * control (phase transitions, completion status), it can call + * `setWindowTitle` directly from window-title.ts. + */ + tabTitle?: string; +} + +/** + * Top-of-screen anchor. + * + * Renders a single-line breadcrumb `{cmd · subtitle · network}` followed by + * a hairline rule, and — as a side effect — sets the user's terminal tab + * title so they can see progress without refocusing the terminal. + */ +export function Header({ cmd, subtitle, network, right, tabTitle }: HeaderProps) { + const { stdout } = useStdout(); + + useEffect(() => { + const title = tabTitle ?? defaultTabTitle(cmd, subtitle); + setWindowTitle(title); + }, [cmd, subtitle, tabTitle]); + + const pieces = [cmd, subtitle, network].filter((p): p is string => Boolean(p)); + const cols = stdout?.columns ?? 80; + const width = Math.max(10, Math.min(cols - LAYOUT.leftMargin * 2, LAYOUT.ruleWidthMax)); + + return ( + // marginTop guarantees a blank line above the banner even when a + // third-party library (e.g. chain-client during domain validation) + // writes console.log output above Ink's render tree — otherwise + // those bled lines crowd the banner with no visual separator. + + + + {pieces.map((piece, i) => ( + + {i > 0 && ( + + {" · "} + + )} + 0}> + {piece} + + + ))} + + {right && {right}} + + + + ); +} + +function defaultTabTitle(cmd: string, subtitle: string | undefined): string { + return subtitle ? `${cmd} · ${subtitle}` : cmd; +} diff --git a/src/utils/ui/theme/Hint.tsx b/src/utils/ui/theme/Hint.tsx new file mode 100644 index 0000000..99c4f38 --- /dev/null +++ b/src/utils/ui/theme/Hint.tsx @@ -0,0 +1,11 @@ +import { Box, Text } from "ink"; +import { LAYOUT } from "./tokens.js"; + +/** Dim footer text — keybind rows, secondary context. */ +export function Hint({ children, indent = 0 }: { children: React.ReactNode; indent?: number }) { + return ( + + {children} + + ); +} diff --git a/src/utils/ui/theme/Input.tsx b/src/utils/ui/theme/Input.tsx new file mode 100644 index 0000000..74e8c47 --- /dev/null +++ b/src/utils/ui/theme/Input.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { COLOR, GLYPH, LAYOUT } from "./tokens.js"; + +export interface InputProps { + label: string; + /** Value returned when the field is empty on submit. Also shown as "[default]". */ + initial?: string; + /** Pre-populated editable value — unlike `initial`, the user sees/edits this. */ + prefill?: string; + placeholder?: string; + validate?: (value: string) => string | null; + externalError?: string | null; + onSubmit: (value: string) => void; +} + +/** Single-line text input with cursor block + inline validation. */ +export function Input({ + label, + initial = "", + prefill, + placeholder, + validate, + externalError, + onSubmit, +}: InputProps) { + const [value, setValue] = useState(prefill ?? ""); + const [error, setError] = useState(null); + + useInput((input, key) => { + if (key.return) { + const final = value.trim() || initial; + if (validate) { + const msg = validate(final); + if (msg) { + setError(msg); + return; + } + } + onSubmit(final); + return; + } + if (key.backspace || key.delete) { + setValue((v) => v.slice(0, -1)); + setError(null); + return; + } + if (key.ctrl || key.meta) return; + // Accept printable characters. + if (input && input.length > 0 && input >= " " && input !== "\t") { + setValue((v) => v + input); + setError(null); + } + }); + + const shownError = error ?? externalError ?? null; + const showPlaceholder = value.length === 0 && placeholder; + + return ( + + + {label} + {initial ? {` default: ${initial}`} : null} + + + {`${GLYPH.cursor} `} + {value} + {showPlaceholder ? {placeholder} : null} + {GLYPH.cursorBlock} + + {shownError && {shownError}} + + ); +} diff --git a/src/utils/ui/theme/LogTail.tsx b/src/utils/ui/theme/LogTail.tsx new file mode 100644 index 0000000..2d72b30 --- /dev/null +++ b/src/utils/ui/theme/LogTail.tsx @@ -0,0 +1,28 @@ +import { Box, Text } from "ink"; +import { LAYOUT } from "./tokens.js"; + +export interface LogTailProps { + /** Most-recent-last log lines; undersized arrays are padded with blanks. */ + lines: string[]; + height: number; +} + +/** + * Fixed-height viewport of dim log lines. Used by step runners so a noisy + * install stream doesn't push the rest of the screen around. + * + * This is a pure renderer — coalescing / throttling is the caller's job + * (see RunningStage.queueInfo for the 10 Hz pattern we rely on to keep + * setState pressure bounded on high-rate streams). + */ +export function LogTail({ lines, height }: LogTailProps) { + return ( + + {Array.from({ length: height }, (_, i) => ( + + {lines[i] ?? " "} + + ))} + + ); +} diff --git a/src/utils/ui/theme/Mark.tsx b/src/utils/ui/theme/Mark.tsx new file mode 100644 index 0000000..7722040 --- /dev/null +++ b/src/utils/ui/theme/Mark.tsx @@ -0,0 +1,30 @@ +import { useState, useEffect } from "react"; +import { Text } from "ink"; +import { COLOR, GLYPH, TIMING } from "./tokens.js"; + +export type MarkKind = "ok" | "fail" | "warn" | "run" | "idle"; + +/** Semantic status glyph. Width-normalized to 1 column. */ +export function Mark({ kind }: { kind: MarkKind }) { + switch (kind) { + case "ok": + return {GLYPH.ok}; + case "fail": + return {GLYPH.fail}; + case "warn": + return {GLYPH.warn}; + case "run": + return ; + case "idle": + return {GLYPH.pending}; + } +} + +function Spinner() { + const [tick, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((t) => (t + 1) % GLYPH.spinner.length), TIMING.spinnerMs); + return () => clearInterval(id); + }, []); + return {GLYPH.spinner[tick]}; +} diff --git a/src/utils/ui/theme/Row.tsx b/src/utils/ui/theme/Row.tsx new file mode 100644 index 0000000..401ab1f --- /dev/null +++ b/src/utils/ui/theme/Row.tsx @@ -0,0 +1,61 @@ +import { Box, Text } from "ink"; +import { COLOR, LAYOUT } from "./tokens.js"; +import { Mark, type MarkKind } from "./Mark.js"; + +type ValueTone = "default" | "danger" | "warning" | "muted" | "accent"; + +export interface RowProps { + mark?: MarkKind; + label: string; + value?: string; + hint?: string; + /** Controls vertical alignment of `value` across sibling Rows. */ + labelWidth?: number; + /** Semantic color for the value — e.g. danger for "expired". */ + tone?: ValueTone; +} + +/** A labeled status line: [mark] label (padded) value — optional dim hint below. */ +export function Row({ + mark, + label, + value, + hint, + labelWidth = LAYOUT.defaultLabelWidth, + tone = "default", +}: RowProps) { + const paddedLabel = label.length >= labelWidth ? label + " " : label.padEnd(labelWidth); + return ( + + + {mark && ( + + + + )} + {paddedLabel} + {value !== undefined && {value}} + + {hint && ( + + {hint} + + )} + + ); +} + +function ValueText({ tone, children }: { tone: ValueTone; children: string }) { + switch (tone) { + case "danger": + return {children}; + case "warning": + return {children}; + case "accent": + return {children}; + case "muted": + return {children}; + default: + return {children}; + } +} diff --git a/src/utils/ui/theme/Rule.tsx b/src/utils/ui/theme/Rule.tsx new file mode 100644 index 0000000..1fe9d5c --- /dev/null +++ b/src/utils/ui/theme/Rule.tsx @@ -0,0 +1,14 @@ +import { Box, Text, useStdout } from "ink"; +import { GLYPH, LAYOUT } from "./tokens.js"; + +/** Horizontal hairline, dim. Caps at LAYOUT.ruleWidthMax so wide terminals don't stretch forever. */ +export function Rule() { + const { stdout } = useStdout(); + const cols = stdout?.columns ?? 80; + const width = Math.max(10, Math.min(cols - LAYOUT.leftMargin * 2, LAYOUT.ruleWidthMax)); + return ( + + {GLYPH.rule.repeat(width)} + + ); +} diff --git a/src/utils/ui/theme/Section.tsx b/src/utils/ui/theme/Section.tsx new file mode 100644 index 0000000..164dfa4 --- /dev/null +++ b/src/utils/ui/theme/Section.tsx @@ -0,0 +1,23 @@ +import { Box, Text } from "ink"; +import { LAYOUT } from "./tokens.js"; + +export function Section({ + title, + children, + gapBelow = true, +}: { + title?: string; + children: React.ReactNode; + gapBelow?: boolean; +}) { + return ( + + {title && ( + + {title} + + )} + {children} + + ); +} diff --git a/src/utils/ui/theme/Select.tsx b/src/utils/ui/theme/Select.tsx new file mode 100644 index 0000000..4f081f1 --- /dev/null +++ b/src/utils/ui/theme/Select.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { COLOR, GLYPH, LAYOUT } from "./tokens.js"; + +export interface SelectOption { + value: T; + label: string; + hint?: string; +} + +export interface SelectProps { + label: string; + options: SelectOption[]; + initialIndex?: number; + onSelect: (value: T) => void; +} + +/** Keyboard picker: ↑/↓ move, Enter confirms. Replaces the ad-hoc SignerPrompt / YesNoPrompt shapes. */ +export function Select({ label, options, initialIndex = 0, onSelect }: SelectProps) { + const [index, setIndex] = useState(Math.min(Math.max(initialIndex, 0), options.length - 1)); + + useInput((_input, key) => { + if (key.upArrow || key.leftArrow) { + setIndex((i) => (i - 1 + options.length) % options.length); + } + if (key.downArrow || key.rightArrow) { + setIndex((i) => (i + 1) % options.length); + } + if (key.return) onSelect(options[index].value); + }); + + return ( + + + {label} + + {options.map((opt, i) => { + const selected = i === index; + return ( + + + {selected ? `${GLYPH.cursor} ` : " "} + + + {opt.label} + + {opt.hint && ( + <> + {` ${GLYPH.separator} `} + {opt.hint} + + )} + + ); + })} + + ); +} diff --git a/src/utils/ui/theme/Sparkline.tsx b/src/utils/ui/theme/Sparkline.tsx new file mode 100644 index 0000000..e4e3c50 --- /dev/null +++ b/src/utils/ui/theme/Sparkline.tsx @@ -0,0 +1,56 @@ +import { Text } from "ink"; +import { GLYPH } from "./tokens.js"; + +export interface SparklineProps { + /** Numeric samples. Non-negative values work best. */ + values: number[]; + /** Target width in characters. Averages buckets when samples exceed width. */ + width?: number; +} + +/** + * One-line bar-chart sparkline built from unicode block fractions. + * Used on the deploy completion card to show per-chunk upload timings. + * Pure: no timers, no state, memory is the output string. + */ +export function Sparkline({ values, width = 16 }: SparklineProps) { + if (values.length === 0) return ; + + const samples = resample(values, width); + let min = Infinity; + let max = -Infinity; + for (const v of samples) { + if (v < min) min = v; + if (v > max) max = v; + } + // All samples equal → render as mid-height bars, not empty. + const range = max - min || 1; + const levels = GLYPH.bars.length; + + const rendered = samples + .map((v) => { + const n = Math.round(((v - min) / range) * (levels - 1)); + return GLYPH.bars[Math.max(0, Math.min(levels - 1, n))]; + }) + .join(""); + + return {rendered}; +} + +/** Bucketed average resample. Preserves shape when compressing long inputs. */ +function resample(values: number[], target: number): number[] { + if (values.length <= target) return values.slice(); + const out: number[] = []; + for (let i = 0; i < target; i++) { + const start = Math.floor((i * values.length) / target); + const end = Math.floor(((i + 1) * values.length) / target); + let sum = 0; + let n = 0; + for (let j = start; j < end && j < values.length; j++) { + sum += values[j]; + n++; + } + out.push(n > 0 ? sum / n : 0); + } + return out; +} diff --git a/src/utils/ui/theme/index.tsx b/src/utils/ui/theme/index.tsx new file mode 100644 index 0000000..a8d069d --- /dev/null +++ b/src/utils/ui/theme/index.tsx @@ -0,0 +1,29 @@ +/** + * Theme plug — the complete public surface for the CLI's visual identity. + * + * Rules: + * 1. Everything styled in the TUI imports from this file. + * 2. No color literals, glyph literals, or spacing constants outside + * `src/utils/ui/theme/`. + * 3. To re-skin: edit components in this directory and/or `tokens.ts`. + * 4. To strip: replace these exports with passthrough components that + * render plain ``; the rest of the CLI keeps working. + * + * There is exactly one public surface (this file). If you find yourself + * reaching into `./Row.js` from a screen, stop — add what you need to + * this re-export list first. + */ + +export { Header, type HeaderProps } from "./Header.js"; +export { Row, type RowProps } from "./Row.js"; +export { Mark, type MarkKind } from "./Mark.js"; +export { Rule } from "./Rule.js"; +export { Hint } from "./Hint.js"; +export { Section } from "./Section.js"; +export { Sparkline, type SparklineProps } from "./Sparkline.js"; +export { Select, type SelectOption, type SelectProps } from "./Select.js"; +export { Input, type InputProps } from "./Input.js"; +export { LogTail, type LogTailProps } from "./LogTail.js"; +export { Callout } from "./Callout.js"; +export { setWindowTitle, clearWindowTitle } from "./window-title.js"; +export { COLOR, GLYPH, LAYOUT, TIMING } from "./tokens.js"; diff --git a/src/utils/ui/theme/tokens.ts b/src/utils/ui/theme/tokens.ts new file mode 100644 index 0000000..5d479d6 --- /dev/null +++ b/src/utils/ui/theme/tokens.ts @@ -0,0 +1,45 @@ +/** + * Design tokens — the entire identity of the CLI's TUI in one file. + * + * To restyle the CLI, edit this file. + * To reskin it, swap every component in this directory. + * To strip it, replace the `index.tsx` exports with passthrough components + * that render plain `` — everything else keeps working. + * + * Hard rule: no color literals, no glyph literals, no spacing numbers + * anywhere in `src/commands/*`. They all live here. + * + * Why named ANSI colors only: the 16 named colors are safe under every + * popular terminal theme (light / dark / solarized / gruvbox / dracula). + * Truecolor is intentionally avoided — we don't fight the user's palette. + */ + +export const COLOR = { + accent: "magenta", + success: "green", + danger: "red", + warning: "yellow", +} as const; + +export const GLYPH = { + ok: "✓", + fail: "✕", + warn: "⚠", + pending: "·", + cursor: "›", + separator: "·", + rule: "─", + cursorBlock: "█", + spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const, + bars: ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const, +} as const; + +export const LAYOUT = { + leftMargin: 2, + ruleWidthMax: 72, + defaultLabelWidth: 14, +} as const; + +export const TIMING = { + spinnerMs: 80, +} as const; diff --git a/src/utils/ui/theme/window-title.ts b/src/utils/ui/theme/window-title.ts new file mode 100644 index 0000000..e0c1968 --- /dev/null +++ b/src/utils/ui/theme/window-title.ts @@ -0,0 +1,36 @@ +/** + * Terminal tab/window title integration via OSC 0. + * + * Writes a short title string to the user's terminal so `dot ` shows + * its status in the tab strip — even when the user tabs away. Hackathon + * flow: kick off a deploy, switch to the browser, tab back later to see + * "dot · my-app.dot · ✓" without refocusing the terminal first. + * + * OSC 0 (ESC ]0; TITLE BEL) sets both icon name and window title with the + * widest terminal support: xterm, iTerm2, Kitty, WezTerm, Warp, Alacritty, + * GNOME Terminal, macOS Terminal.app, and tmux/screen (which pass it to + * the outer terminal). + * + * Guard: no-op when stdout is not a TTY — avoids leaking control codes + * into CI logs and pipelines. + */ + +const ESC = "\x1b"; +const BEL = "\x07"; + +let lastTitle: string | null = null; + +export function setWindowTitle(title: string): void { + if (!process.stdout.isTTY) return; + if (title === lastTitle) return; + lastTitle = title; + process.stdout.write(`${ESC}]0;${title}${BEL}`); +} + +export function clearWindowTitle(): void { + if (!process.stdout.isTTY) return; + lastTitle = null; + // Writing an empty title hands the tab back to the shell, which most + // terminals interpret as "reset to default" (e.g. re-render the CWD). + process.stdout.write(`${ESC}]0;${BEL}`); +} diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..fbf1af7 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,4 @@ +import pkg from "../../package.json" with { type: "json" }; + +/** "v0.6.9" — used by the theme Header's right-aligned breadcrumb. */ +export const VERSION_LABEL = `v${pkg.version}`; From 93b819e43c0882d110f822bc83e3aa2e4196371c Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Sat, 18 Apr 2026 14:35:29 +0100 Subject: [PATCH 2/2] style: satisfy biome format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Biome collapses whitespace in JSX text children, which broke the " · " separator rhythm in Hint rows. Wrapped those hints and the AppBrowser column rows in template literals so biome leaves the spacing alone; ran biome --write to fix the remaining visually-neutral line-length issues. --- src/commands/deploy/DeployScreen.tsx | 18 ++++++++++-------- src/commands/init/AccountSetup.tsx | 6 +++++- src/commands/init/DependencyList.tsx | 8 ++++++-- src/commands/init/QrLogin.tsx | 9 +-------- src/commands/mod/AppBrowser.tsx | 18 ++++++++++-------- src/utils/account/attestation.test.ts | 4 ++-- src/utils/ui/theme/Header.tsx | 6 +----- src/utils/ui/theme/Mark.tsx | 5 ++++- 8 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/commands/deploy/DeployScreen.tsx b/src/commands/deploy/DeployScreen.tsx index 170a4c2..1d5732a 100644 --- a/src/commands/deploy/DeployScreen.tsx +++ b/src/commands/deploy/DeployScreen.tsx @@ -113,7 +113,11 @@ export function DeployScreen({ label="signer" options={[ - { value: "dev", label: "dev signer", hint: "fast, 0 phone taps for upload" }, + { + value: "dev", + label: "dev signer", + hint: "fast, 0 phone taps for upload", + }, { value: "phone", label: "your phone signer", @@ -395,7 +399,7 @@ function ConfirmStage({ )} - enter to deploy · esc to cancel + {"enter to deploy · esc to cancel"} {"error" in setup && setup.error && ( @@ -540,9 +544,7 @@ function RunningStage({ chunkTimingsRef.current.push(now - last); } lastChunkAtRef.current = now; - queueInfo( - `uploading chunk ${event.event.current}/${event.event.total}`, - ); + queueInfo(`uploading chunk ${event.event.current}/${event.event.total}`); } else if (event.event.kind === "info") { queueInfo(event.event.message); } @@ -638,9 +640,9 @@ function FinalResult({ {"chunks".padEnd(14)} - {" "} - {chunkTimings.length + 1} chunks · avg{" "} - {(average(chunkTimings) / 1000).toFixed(2)}s/chunk + {` ${chunkTimings.length + 1} chunks · avg ${( + average(chunkTimings) / 1000 + ).toFixed(2)}s/chunk`}
)} diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index b57e01f..694d3b5 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -4,7 +4,11 @@ import { getConnection } from "../../utils/connection.js"; import { getSessionSigner, type SessionHandle } from "../../utils/auth.js"; import { checkBalance, ensureFunded, FUND_AMOUNT } from "../../utils/account/funding.js"; import { checkMapping, ensureMapped } from "../../utils/account/mapping.js"; -import { checkAllowance, ensureAllowance, LOW_TX_THRESHOLD } from "../../utils/account/allowance.js"; +import { + checkAllowance, + ensureAllowance, + LOW_TX_THRESHOLD, +} from "../../utils/account/allowance.js"; import { checkAttestation, getBulletinBlockTimeMs, diff --git a/src/commands/init/DependencyList.tsx b/src/commands/init/DependencyList.tsx index 1049770..7dc83d5 100644 --- a/src/commands/init/DependencyList.tsx +++ b/src/commands/init/DependencyList.tsx @@ -50,7 +50,9 @@ export function DependencyList({ onDone }: { onDone: () => void }) { (async () => { for (let i = 0; i < TOOL_STEPS.length; i++) { const step = TOOL_STEPS[i]; - setSteps((prev) => prev.map((s, j) => (j === i ? { ...s, status: "checking" } : s))); + setSteps((prev) => + prev.map((s, j) => (j === i ? { ...s, status: "checking" } : s)), + ); if (await step.check()) { setSteps((prev) => prev.map((s, j) => (j === i ? { ...s, status: "ok" } : s))); @@ -79,7 +81,9 @@ export function DependencyList({ onDone }: { onDone: () => void }) { // gh auth check (advisory — not auto-login) const authIdx = TOOL_STEPS.length; if (await isGhAuthenticated()) { - setSteps((prev) => prev.map((s, j) => (j === authIdx ? { ...s, status: "ok" } : s))); + setSteps((prev) => + prev.map((s, j) => (j === authIdx ? { ...s, status: "ok" } : s)), + ); } else { setSteps((prev) => prev.map((s, j) => diff --git a/src/commands/init/QrLogin.tsx b/src/commands/init/QrLogin.tsx index c7bd610..b970e9e 100644 --- a/src/commands/init/QrLogin.tsx +++ b/src/commands/init/QrLogin.tsx @@ -19,14 +19,7 @@ export function QrLogin({ return ; } if (status.step === "error") { - return ( - - ); + return ; } return ; } diff --git a/src/commands/mod/AppBrowser.tsx b/src/commands/mod/AppBrowser.tsx index 15f0213..6ae5eb5 100644 --- a/src/commands/mod/AppBrowser.tsx +++ b/src/commands/mod/AppBrowser.tsx @@ -119,13 +119,14 @@ export function AppBrowser({ registry, onSelect }: Props) { - {pad(" #", COL.num)} {pad("domain", COL.domain)} {pad("name", COL.name)} description + {`${pad(" #", COL.num)} ${pad("domain", COL.domain)} ${pad( + "name", + COL.name, + )} description`} - - {"─".repeat(COL.num + COL.domain + COL.name + descW + 6)} - + {"─".repeat(COL.num + COL.domain + COL.name + descW + 6)} {visible.map((app, i) => { @@ -137,7 +138,10 @@ export function AppBrowser({ registry, onSelect }: Props) { return ( - {num} {pad(app.domain, COL.domain)} {pad(app.name ?? (app.name === null ? "…" : "—"), COL.name)} {pad(app.description ?? "", descW)} + {`${num} ${pad(app.domain, COL.domain)} ${pad( + app.name ?? (app.name === null ? "…" : "—"), + COL.name, + )} ${pad(app.description ?? "", descW)}`} ); @@ -150,9 +154,7 @@ export function AppBrowser({ registry, onSelect }: Props) { )} - - ↑↓ navigate · ⏎ select · q quit · ({apps.length}/{total}) - + {`↑↓ navigate · ⏎ select · q quit · (${apps.length}/${total})`} ); diff --git a/src/utils/account/attestation.test.ts b/src/utils/account/attestation.test.ts index 28312f7..1289fc6 100644 --- a/src/utils/account/attestation.test.ts +++ b/src/utils/account/attestation.test.ts @@ -165,8 +165,8 @@ describe("checkAttestation", () => { it("wraps the address in Enum('Account', ...) when querying", async () => { const client = makeClient(undefined, 0); await checkAttestation(client, "5GrwvaEF_addr"); - const arg = client.bulletin.query.TransactionStorage.Authorizations.getValue.mock - .calls[0][0]; + const arg = + client.bulletin.query.TransactionStorage.Authorizations.getValue.mock.calls[0][0]; expect(arg).toMatchObject({ type: "Account", value: "5GrwvaEF_addr" }); }); }); diff --git a/src/utils/ui/theme/Header.tsx b/src/utils/ui/theme/Header.tsx index 24992e4..7f47302 100644 --- a/src/utils/ui/theme/Header.tsx +++ b/src/utils/ui/theme/Header.tsx @@ -51,11 +51,7 @@ export function Header({ cmd, subtitle, network, right, tabTitle }: HeaderProps) {pieces.map((piece, i) => ( - {i > 0 && ( - - {" · "} - - )} + {i > 0 && {" · "}} 0}> {piece} diff --git a/src/utils/ui/theme/Mark.tsx b/src/utils/ui/theme/Mark.tsx index 7722040..1ac4578 100644 --- a/src/utils/ui/theme/Mark.tsx +++ b/src/utils/ui/theme/Mark.tsx @@ -23,7 +23,10 @@ export function Mark({ kind }: { kind: MarkKind }) { function Spinner() { const [tick, setTick] = useState(0); useEffect(() => { - const id = setInterval(() => setTick((t) => (t + 1) % GLYPH.spinner.length), TIMING.spinnerMs); + const id = setInterval( + () => setTick((t) => (t + 1) % GLYPH.spinner.length), + TIMING.spinnerMs, + ); return () => clearInterval(id); }, []); return {GLYPH.spinner[tick]};