diff --git a/cli/dashboard.ts b/cli/dashboard.ts index b964db6..3eb392a 100644 --- a/cli/dashboard.ts +++ b/cli/dashboard.ts @@ -8,6 +8,7 @@ // Built on @profullstack/hqtui, the same library behind `coinpay finances`. import type { Container, RenderArgs, Theme } from "@profullstack/hqtui"; +import { markdownText } from "@profullstack/hqtui"; import { collectDashboard, type CoinPayAuth, type DashboardSnapshot, type SiteStats, type FeedName, type FeedProgress } from "../lib/dashboard/collect"; import { AD_TARGET_CTR, AD_TARGET_IMPRESSIONS, adTargets } from "../lib/dashboard/roi"; @@ -1010,6 +1011,39 @@ function feedBusy(feed: FeedProgress): boolean { return feed.status === "loading" || feed.status === "retrying"; } +/** Context travels with every copied pane, including partial/stale source warnings. */ +export function dashboardMarkdownContext(state: State): string { + const snapshot = state.snapshot; + const sourceErrors = [ + ...Object.entries(snapshot?.errors ?? {}).map(([source, error]) => `${source}: ${error}`), + ...Object.entries(snapshot?.finance?.errors ?? {}).map(([source, error]) => `CoinPay ${source}: ${error}`), + ]; + return [ + `CrawlProof · ${state.domain ?? "Fleet"} · ${TABS[state.tab]}`, + `Displayed range: ${snapshot?.window.range ?? state.range} · ${snapshot?.window.who ?? state.who}`, + ...(snapshot && (snapshot.window.range !== state.range || snapshot.window.who !== state.who) ? [`Requested range: ${state.range} · ${state.who} (refresh pending)`] : []), + `CoinPay window: ${snapshot?.window.financeDays ?? FINANCE_DAYS[state.range] ?? 30} days`, + `Snapshot: ${snapshot?.generatedAt ?? "not loaded"}`, + `Status: ${state.loading ? "refreshing" : state.paused ? "paused" : "live"}`, + ...(state.error ? [`Refresh failed: ${state.error}`] : []), + ...(snapshot?.adsStale ? [`Ads are stale: last successful read ${snapshot.adsUpdatedAt ?? snapshot.generatedAt}`] : []), + ...sourceErrors, + ...(snapshot?.roi.caveats ?? []).map((caveat) => `Note: ${caveat}`), + ].join("\n"); +} + +export function dashboardStatusMarkdown(state: State): string { + return [ + "## CrawlProof status", + markdownText(dashboardMarkdownContext(state)), + ...(["traffic", "ads", "finance"] as const).map((name) => { + const feed = state.feeds[name]; + return `- **${name === "finance" ? "CoinPay" : name === "ads" ? "Ads" : "Traffic"}:** ${markdownText(feed.status)} — ${markdownText(feed.detail)}`; + }), + ...(state.refreshMessage ? [markdownText(state.refreshMessage)] : []), + ].join("\n\n") + "\n"; +} + /** Each request has a visible lifecycle on every screen, including the first load. */ export function renderFetchStatus(ui: Container, state: State, theme: Theme): void { ui.row({ size: 1, gap: 2 }, (row) => { @@ -1023,6 +1057,7 @@ export function renderFetchStatus(ui: Container, state: State, theme: Theme): vo }); }); } + row.copyButton({ markdown: () => dashboardStatusMarkdown(state), width: 6 }); }); if (state.loading) { const elapsed = state.refreshStartedAt ? Math.floor((Date.now() - state.refreshStartedAt.getTime()) / 1000) : 0; @@ -1329,6 +1364,8 @@ export function createRefreshController( export async function runDashboard(opts: DashboardOptions): Promise { const hqtui = await loadHqtui(); const app = await hqtui.createApp({ + copyMarkdown: true, + markdownContext: () => dashboardMarkdownContext(state), fps: 30, theme: (opts.theme as never) || "dark", quitKeys: ["ctrl+c", "q"], @@ -1409,7 +1446,8 @@ export async function runDashboard(opts: DashboardOptions): Promise { width: 76, height: 28, message: - "1-5, Tab, ←/→ switch screens.\n" + + "1-5, ←/→ switch screens.\n" + + "⧉ MD copies a summary; Tab focuses, Enter copies.\n" + `r refreshes now; it also refreshes every ${interval}s.\n` + "w cycles the window: 1h → 4h → 1d → 1w → 1m.\n" + "b cycles who counts: humans → all → bots.\n" + @@ -1429,7 +1467,8 @@ export async function runDashboard(opts: DashboardOptions): Promise { " are the same account on both sides of our own network, so\n" + " they are reported under Internal and counted as neither.\n\n" + "Press any key to close.", - buttons: [{ label: "Close", focused: true }], + buttons: [{ label: "Close", onPress: () => { state.showHelp = false; } }], + onDismiss: () => { state.showHelp = false; }, }); } }); diff --git a/package-lock.json b/package-lock.json index 8fd556d..def4e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/coinpay": "^0.9.0", - "@profullstack/hqtui": "^0.6.2", + "@profullstack/hqtui": "^0.6.3", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/x402-client": "^0.2.0", @@ -2185,9 +2185,9 @@ "license": "MIT" }, "node_modules/@profullstack/hqtui": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@profullstack/hqtui/-/hqtui-0.6.2.tgz", - "integrity": "sha512-r8w5PtAclzhxvjXrnVOIDD6pqTN812flNPN6ujH9cnJYIIfsiHZAu4b6dFuUI4Quv9dWCFS1RU/xxK7ORHr9Xg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@profullstack/hqtui/-/hqtui-0.6.3.tgz", + "integrity": "sha512-RQmULiyNt6Qcz+M0x7Hn3sGN+2C766STHA8AZv4olcYpoMeQFzUW0NnOSXtbqgaUTZCeJLZPU4NHx6NQffVvkw==", "license": "MIT", "bin": { "hqtui": "bin/hqtui.mjs" diff --git a/package.json b/package.json index d5f8ce5..ab00a71 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/coinpay": "^0.9.0", - "@profullstack/hqtui": "^0.6.2", + "@profullstack/hqtui": "^0.6.3", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/x402-client": "^0.2.0", diff --git a/packages/cli/README.md b/packages/cli/README.md index bc9e1d6..351bee7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -19,7 +19,7 @@ and CoinPay for what the bank actually did. | Money | Earnings, bank position, invoices, income vs spending by month | | Spend | Who we pay, largest first, and burn by category | -`1`–`5` or Tab switches screens, `w` cycles the window, `b` cycles humans / +`1`–`5` or the left/right arrows switch screens, `w` cycles the window, `b` cycles humans / all / bots, `r` refreshes, `?` explains the arithmetic, `q` quits. Traffic, ads and CoinPay each show an animated spinner while fetching, with @@ -136,3 +136,12 @@ neither. ## License MIT + +### Copy summaries as Markdown + +Click **⧉ MD** on a summary pane or the feed-status strip. Tab / Shift+Tab +focus controls; Enter / Space copies the focused summary. Copied Markdown includes +the screen/domain, displayed range, CoinPay window, snapshot time, stale-source +warnings and source errors. The status export names failures such as CoinPay +payouts instead of just reporting the unavailable-source count. Data rows are +excluded. Clipboard delivery uses the terminal's OSC 52 support, including over SSH. diff --git a/packages/cli/package.json b/packages/cli/package.json index b340c41..f27afe8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/crawlproof", - "version": "0.2.0", + "version": "0.2.1", "description": "What the fleet costs and what it returns: a live terminal dashboard over CrawlProof traffic, ad delivery and CoinPay banking.", "license": "MIT", "type": "module", @@ -21,7 +21,7 @@ }, "dependencies": { "@profullstack/coinpay": "^0.9.0", - "@profullstack/hqtui": "^0.6.2" + "@profullstack/hqtui": "^0.6.3" }, "devDependencies": { "esbuild": "^0.25.0" diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f434a9f..e2c9090 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -15,7 +15,7 @@ import { collectDashboard } from "../../../lib/dashboard/collect"; import { renderStats } from "../../../lib/dashboard/stats-text"; import { FINANCE_DAYS, runDashboard } from "../../../cli/dashboard"; -export const VERSION = "0.2.0"; +export const VERSION = "0.2.1"; type Args = { command: string; diff --git a/tests/dashboard-markdown.test.ts b/tests/dashboard-markdown.test.ts new file mode 100644 index 0000000..e2e4e11 --- /dev/null +++ b/tests/dashboard-markdown.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { renderToScreen } from "@profullstack/hqtui/testing"; +import { dashboardMarkdownContext, initialState, renderBody, type State } from "@/cli/dashboard"; +import { buildRoi } from "@/lib/dashboard/roi"; + +function state(): State { + const finance = { errors: { payouts: "Failed to fetch payouts" }, windowDays: 30 }; + return initialState({ range: "1m", snapshot: { + generatedAt: "2026-09-13T12:00:00Z", + window: { range: "1m", who: "humans", financeDays: 30 }, + sites: [], fleet: { sources: [], pages: [], referrers: [] }, + ads: null, finance, errors: { finance: "1 CoinPay sources unavailable" }, + adsStale: true, adsUpdatedAt: "2026-09-12T12:00:00Z", + roi: buildRoi({ traffic: { range: "1m", who: "humans", sites: [] }, ads: null, finance }), + } }); +} + +const draw = (s: State) => renderToScreen(({ ui, theme }) => renderBody(ui, s, theme), { + width: 160, height: 44, copyMarkdown: true, markdownContext: dashboardMarkdownContext(s), +}); + +describe("dashboard Markdown copy", () => { + it("copies exact feed errors, displayed/requested ranges, timestamps and stale warnings", () => { + const s = state(); + s.range = "1w"; + s.who = "bots"; + s.loading = true; + s.feeds.finance = { status: "error", detail: "1 CoinPay sources unavailable" }; + const screen = draw(s); + const icon = screen.find("⧉ MD")!; + expect(icon).not.toBeNull(); + screen.click(icon.x, icon.y); + const text = screen.copied[0]; + expect(text).toContain("## CrawlProof status"); + expect(text).toContain("Displayed range: 1m · humans"); + expect(text).toContain("Requested range: 1w · bots (refresh pending)"); + expect(text).toContain("CoinPay window: 30 days"); + expect(text).toContain("CoinPay payouts: Failed to fetch payouts"); + expect(text).toContain("Ads are stale: last successful read 2026-09-12T12:00:00Z"); + }); + + it("makes ROI summaries independently copyable with caveats", () => { + const screen = draw(state()); + for (const r of screen.regions) screen.click(r.rect.x, r.rect.y); + const summary = screen.copied.find((text) => text.startsWith("## The number")); + expect(summary).toBeDefined(); + expect(summary).toContain("CrawlProof · Fleet · ROI"); + expect(summary).toContain("Snapshot: 2026-09-13T12:00:00Z"); + expect(summary).toContain("CoinPay payouts: Failed to fetch payouts"); + expect(screen.copied.some((text) => text.startsWith("## Read this before quoting a number"))).toBe(true); + }); + + it("lets startup and failed refresh status be copied before a snapshot arrives", () => { + const s = initialState({ error: "Not connected " }); + const screen = draw(s); + const icon = screen.find("⧉ MD")!; + screen.click(icon.x, icon.y); + expect(screen.copied[0]).toContain("Snapshot: not loaded"); + expect(screen.copied[0]).toContain("Not connected \\"); + }); +});