@promptai.credit/cli 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,729 @@
1
+ /**
2
+ * PromptAI native rewarded-ad card for Claude Code (Mods / function hooks).
3
+ *
4
+ * Lifecycle:
5
+ * 1. session.start — load ~/.promptai config; heartbeat so CLI skips /watch
6
+ * 2. prompt.submit — fire-and-forget POST /ads/session (never await before next)
7
+ * 3. ui.render AbovePrompt — compact sponsored card + countdown
8
+ * 4. clock tick — when minWatchMs elapsed, POST /ads/complete
9
+ * 5. CTA press — beacon click, open destination in OS browser
10
+ * 6. Jev ticker — GET /stocks/ticker (top picks + Robinhood Chain quotes),
11
+ * drawn on the ad card and on the prompt hint while working
12
+ *
13
+ * Stop / transcript pricing / credit redeem stay on the command-hook CLI path.
14
+ *
15
+ * Requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 until Mods are generally available.
16
+ */
17
+ import type { EngineInterface as Engine, Register, RenderChildren, Timer } from "claude-code";
18
+ import { WORK_PX, bitmapToCells, decodeBase64, hashKey, parseBmp, type LogoCells } from "./logo.ts";
19
+
20
+ const SOURCE = "claude-mods";
21
+ /** Logo cell grid: half-blocks, so 12 columns × 6 rows is a square of 12×12 pixels. */
22
+ const LOGO_COLS = 12;
23
+ const LOGO_ROWS = 6;
24
+ /** Stock logos in the Jev tiles: 6×3 cells, one tile tall. */
25
+ const STOCK_LOGO_COLS = 6;
26
+ const STOCK_LOGO_ROWS = 3;
27
+ const HEARTBEAT_FILE = "native-plugin.json";
28
+ /** How long the CLI treats a heartbeat as "plugin is live" before falling back to /watch. */
29
+ const HEARTBEAT_FRESH_MS = 2 * 60 * 60 * 1000;
30
+ /** Refetch the Jev ticker at most this often (the server caches it for 60s). */
31
+ const TICKER_STALE_MS = 5 * 60 * 1000;
32
+
33
+ interface AdCreative {
34
+ id: string;
35
+ brand: string;
36
+ tag: string;
37
+ tagline: string;
38
+ lines: Array<{ cmd: boolean; text: string }>;
39
+ ctaLabel: string;
40
+ ctaUrl: string;
41
+ destinationUrl?: string;
42
+ logoUrl?: string;
43
+ minWatchMs: number;
44
+ }
45
+
46
+ interface PromptaiConfig {
47
+ deviceId: string;
48
+ serverUrl: string;
49
+ adsOptIn: boolean;
50
+ }
51
+
52
+ interface TickerName {
53
+ ticker: string;
54
+ name: string;
55
+ logoUrl?: string;
56
+ price: number | null;
57
+ change24h: number | null;
58
+ weight: number | null;
59
+ }
60
+
61
+ interface JevTicker {
62
+ enabled: boolean;
63
+ rewardUsd: number;
64
+ picks: TickerName[];
65
+ names: TickerName[];
66
+ }
67
+
68
+ interface GrantedStock {
69
+ ticker: string;
70
+ notionalUsd: number;
71
+ }
72
+
73
+ type AdPhase = "idle" | "loading" | "showing" | "verified" | "failed";
74
+
75
+ interface ActiveAd {
76
+ phase: AdPhase;
77
+ sessionId?: string;
78
+ ad?: AdCreative;
79
+ shownAt?: number;
80
+ error?: string;
81
+ tick?: Timer;
82
+ logo?: LogoCells;
83
+ /** Paper stock slices granted on this verified watch (Jev inside promptai). */
84
+ stocks?: GrantedStock[];
85
+ }
86
+
87
+ const active: ActiveAd = { phase: "idle" };
88
+ let config: PromptaiConfig | null = null;
89
+ let homeDir = "";
90
+ let startingSession = false;
91
+ let jev: JevTicker | null = null;
92
+ let jevFetchedAt = 0;
93
+ let jevFetching = false;
94
+ /** Stock logo cells by ticker; null once a load failed so it is not retried. */
95
+ const stockLogos = new Map<string, LogoCells | null>();
96
+
97
+ function configPath(): string {
98
+ return `${homeDir}/.promptai/config.json`;
99
+ }
100
+
101
+ function statePath(): string {
102
+ return `${homeDir}/.promptai/state.json`;
103
+ }
104
+
105
+ function heartbeatPath(): string {
106
+ return `${homeDir}/.promptai/${HEARTBEAT_FILE}`;
107
+ }
108
+
109
+ async function resolveHome($: Engine): Promise<string> {
110
+ try {
111
+ return ((await $.env.get("HOME")) ?? "").trim();
112
+ } catch {
113
+ return "";
114
+ }
115
+ }
116
+
117
+ async function readJson<T>($: Engine, path: string, fallback: T): Promise<T> {
118
+ try {
119
+ if (!(await $.fs.exists(path))) return fallback;
120
+ const raw = await $.fs.read(path);
121
+ return { ...fallback, ...(JSON.parse(raw) as T) };
122
+ } catch {
123
+ return fallback;
124
+ }
125
+ }
126
+
127
+ async function writeJson($: Engine, path: string, value: unknown): Promise<void> {
128
+ try {
129
+ await $.fs.write(path, `${JSON.stringify(value, null, 2)}\n`);
130
+ } catch {
131
+ // never break the session for local state
132
+ }
133
+ }
134
+
135
+ async function loadConfig($: Engine): Promise<PromptaiConfig | null> {
136
+ if (!homeDir) return null;
137
+ const cfg = await readJson<PromptaiConfig>($, configPath(), {
138
+ deviceId: "",
139
+ serverUrl: "https://api.promptai.credit",
140
+ adsOptIn: true,
141
+ });
142
+ if (!cfg.deviceId) return null;
143
+ if (!cfg.serverUrl) cfg.serverUrl = "https://api.promptai.credit";
144
+ return cfg;
145
+ }
146
+
147
+ async function bumpLastAdOpenedAt($: Engine, at: number): Promise<void> {
148
+ const state = await readJson<Record<string, unknown>>($, statePath(), {});
149
+ state.lastAdOpenedAt = at;
150
+ await writeJson($, statePath(), state);
151
+ }
152
+
153
+ async function writeHeartbeat($: Engine): Promise<void> {
154
+ await writeJson($, heartbeatPath(), {
155
+ active: true,
156
+ at: Date.now(),
157
+ source: SOURCE,
158
+ freshMs: HEARTBEAT_FRESH_MS,
159
+ });
160
+ }
161
+
162
+ // Same palette as the /watch page so the terminal card reads as one brand.
163
+ const C = {
164
+ fg: "#fafafa",
165
+ soft: "#b9b9b9",
166
+ dim: "#8f8f8f",
167
+ faint: "#525252",
168
+ green: "#4ade80",
169
+ red: "#f87171",
170
+ amber: "#fbbf24",
171
+ };
172
+
173
+ function progressBar(elapsed: number, total: number, width = 20): { filled: string; empty: string } {
174
+ const ratio = Math.min(1, Math.max(0, elapsed / Math.max(1, total)));
175
+ const n = Math.round(ratio * width);
176
+ return { filled: "█".repeat(n), empty: "░".repeat(width - n) };
177
+ }
178
+
179
+ function clearTick(): void {
180
+ const tick = active.tick;
181
+ active.tick = undefined;
182
+ try {
183
+ tick?.cancel();
184
+ } catch {
185
+ // ignore
186
+ }
187
+ }
188
+
189
+ function resetAd(phase: AdPhase = "idle"): void {
190
+ clearTick();
191
+ active.phase = phase;
192
+ active.sessionId = undefined;
193
+ active.ad = undefined;
194
+ active.shownAt = undefined;
195
+ active.error = undefined;
196
+ active.logo = undefined;
197
+ active.stocks = undefined;
198
+ }
199
+
200
+ function parseBody<T>(text: string): T {
201
+ try {
202
+ return JSON.parse(text) as T;
203
+ } catch {
204
+ return {} as T;
205
+ }
206
+ }
207
+
208
+ /** True while /ads/complete is in flight, so a slow response is never asked twice. */
209
+ let completing = false;
210
+
211
+ async function completeAd($: Engine): Promise<void> {
212
+ if (completing || !config || !active.sessionId || active.phase !== "showing") return;
213
+ completing = true;
214
+ // Stop the countdown first: with the request in flight the phase is still
215
+ // "showing", and another tick would send a second complete ("already verified").
216
+ clearTick();
217
+ const sessionId = active.sessionId;
218
+ try {
219
+ const res = await $.http.fetch(`${config.serverUrl}/ads/complete`, {
220
+ method: "POST",
221
+ headers: { "Content-Type": "application/json" },
222
+ body: JSON.stringify({ sessionId, deviceId: config.deviceId }),
223
+ });
224
+ if (!res.ok) {
225
+ const body = parseBody<{ error?: string }>(res.text);
226
+ active.phase = "failed";
227
+ active.error = body.error ?? `complete ${res.status}`;
228
+ clearTick();
229
+ $.ui.invalidate("ui.render");
230
+ return;
231
+ }
232
+ const body = parseBody<{
233
+ verified?: boolean;
234
+ stocks?: Array<{ ticker?: string; notionalUsd?: number | string }>;
235
+ }>(res.text);
236
+ active.stocks = (body.stocks ?? [])
237
+ .filter((s) => typeof s.ticker === "string" && s.ticker)
238
+ .map((s) => ({ ticker: s.ticker as string, notionalUsd: Number(s.notionalUsd) || 0 }))
239
+ .slice(0, 5);
240
+ active.phase = "verified";
241
+ clearTick();
242
+ // The card stays open once banked; Hide or the next prompt's ad replaces it.
243
+ $.ui.invalidate("ui.render");
244
+ } catch (err) {
245
+ active.phase = "failed";
246
+ active.error = String(err);
247
+ clearTick();
248
+ $.ui.invalidate("ui.render");
249
+ } finally {
250
+ completing = false;
251
+ }
252
+ }
253
+
254
+ function startCountdown($: Engine): void {
255
+ clearTick();
256
+ active.tick = $.clock.every(1_000, () => {
257
+ if (active.phase !== "showing" || !active.ad || active.shownAt == null) return;
258
+ const elapsed = Date.now() - active.shownAt;
259
+ if (elapsed >= active.ad.minWatchMs) {
260
+ void completeAd($);
261
+ return;
262
+ }
263
+ $.ui.invalidate("ui.render");
264
+ });
265
+ }
266
+
267
+ async function openDestination($: Engine, url: string): Promise<void> {
268
+ const candidates: string[][] = [
269
+ ["xdg-open", url],
270
+ ["open", url],
271
+ ["cmd", "/c", "start", "", url],
272
+ ];
273
+ for (const argv of candidates) {
274
+ try {
275
+ const result = await $.process.run(argv, { timeoutMs: 5_000 });
276
+ if (result.exitCode === 0) return;
277
+ } catch {
278
+ // try next opener
279
+ }
280
+ }
281
+ $.ui.toast(`Open in browser: ${url}`);
282
+ }
283
+
284
+ async function onCtaPress($: Engine): Promise<void> {
285
+ if (!config || !active.sessionId || !active.ad) return;
286
+ const ad = active.ad;
287
+ const clickUrl = ad.ctaUrl.includes("/ads/click/")
288
+ ? ad.ctaUrl
289
+ : `${config.serverUrl}/ads/click/${active.sessionId}`;
290
+ try {
291
+ void $.http.fetch(clickUrl, { method: "GET" });
292
+ } catch {
293
+ // click is best-effort
294
+ }
295
+ const target =
296
+ ad.destinationUrl ||
297
+ (ad.ctaUrl.includes("/ads/click/") ? undefined : ad.ctaUrl);
298
+ if (target) await openDestination($, target);
299
+ }
300
+
301
+ // --- logo host pipeline (curl → svg→png → bmp; pure image code is in logo.ts) ---
302
+
303
+ async function tryRun($: Engine, argv: string[], timeoutMs = 8_000): Promise<{ ok: boolean; stdout: string }> {
304
+ try {
305
+ const res = await $.process.run(argv, { timeoutMs });
306
+ return { ok: res.exitCode === 0, stdout: res.stdout };
307
+ } catch {
308
+ return { ok: false, stdout: "" };
309
+ }
310
+ }
311
+
312
+ async function exists($: Engine, path: string): Promise<boolean> {
313
+ try {
314
+ return await $.fs.exists(path);
315
+ } catch {
316
+ return false;
317
+ }
318
+ }
319
+
320
+ async function svgToPng($: Engine, svg: string, dir: string, png: string): Promise<boolean> {
321
+ // macOS Quick Look thumbnailer: writes <dir>/<file>.png
322
+ const ql = await tryRun($, ["qlmanage", "-t", "-s", String(WORK_PX * 2), "-o", dir, svg]);
323
+ if (ql.ok && (await exists($, `${svg}.png`))) {
324
+ await tryRun($, ["mv", "-f", `${svg}.png`, png]);
325
+ if (await exists($, png)) return true;
326
+ }
327
+ const rsvg = await tryRun($, ["rsvg-convert", "-w", String(WORK_PX), "-h", String(WORK_PX), "-f", "png", "-o", png, svg]);
328
+ return rsvg.ok && (await exists($, png));
329
+ }
330
+
331
+ async function toBmp($: Engine, input: string, bmp: string): Promise<boolean> {
332
+ const attempts: string[][] = [
333
+ ["sips", "-s", "format", "bmp", "-Z", String(WORK_PX), input, "--out", bmp],
334
+ ["magick", input, "-background", "none", "-resize", `${WORK_PX}x${WORK_PX}`, `BMP3:${bmp}`],
335
+ ["convert", input, "-background", "none", "-resize", `${WORK_PX}x${WORK_PX}`, `BMP3:${bmp}`],
336
+ ];
337
+ for (const argv of attempts) {
338
+ const res = await tryRun($, argv);
339
+ if (res.ok && (await exists($, bmp))) return true;
340
+ }
341
+ return false;
342
+ }
343
+
344
+ const failedLogos = new Set<string>();
345
+
346
+ /**
347
+ * Load `url` as terminal cells, caching the result under
348
+ * `<home>/.promptai/logos/`. Resolves null when anything is unavailable.
349
+ */
350
+ async function loadLogoCells(
351
+ $: Engine,
352
+ opts: { url: string; home: string; columns: number; rows: number },
353
+ ): Promise<LogoCells | null> {
354
+ const { url, home, columns, rows } = opts;
355
+ if (!home || !/^https?:\/\//i.test(url)) return null;
356
+
357
+ const key = `${hashKey(url)}-${columns}x${rows}`;
358
+ if (failedLogos.has(key)) return null;
359
+
360
+ const dir = `${home}/.promptai/logos`;
361
+ const cacheFile = `${dir}/${key}.cells.json`;
362
+ try {
363
+ if (await exists($, cacheFile)) {
364
+ const cached = JSON.parse(await $.fs.read(cacheFile)) as LogoCells;
365
+ if (cached.cells && cached.columns === columns && cached.rows === rows) return cached;
366
+ }
367
+ } catch {
368
+ // rebuild below
369
+ }
370
+
371
+ const src = `${dir}/${key}.src`;
372
+ const png = `${dir}/${key}.png`;
373
+ const bmp = `${dir}/${key}.bmp`;
374
+ try {
375
+ const dl = await tryRun($, [
376
+ "curl", "-sSL", "--max-time", "5", "--max-filesize", "2000000", "--create-dirs",
377
+ "-o", src, "-w", "%{content_type}", url,
378
+ ]);
379
+ if (!dl.ok || !(await exists($, src))) throw new Error("download");
380
+
381
+ const isSvg = /svg/i.test(dl.stdout) || /\.svg(\?|$)/i.test(url);
382
+ let input = src;
383
+ if (isSvg) {
384
+ const svgFile = `${src}.svg`;
385
+ await tryRun($, ["mv", "-f", src, svgFile]);
386
+ if (!(await svgToPng($, svgFile, dir, png))) throw new Error("svg");
387
+ input = png;
388
+ }
389
+ if (!(await toBmp($, input, bmp))) throw new Error("bmp");
390
+
391
+ const { base64 } = await $.fs.read(bmp, { as: "bytes" });
392
+ const bitmap = parseBmp(decodeBase64(base64));
393
+ if (!bitmap) throw new Error("parse");
394
+
395
+ const logo = bitmapToCells(bitmap, columns, rows);
396
+ await $.fs.write(cacheFile, JSON.stringify(logo));
397
+ return logo;
398
+ } catch {
399
+ failedLogos.add(key);
400
+ return null;
401
+ } finally {
402
+ await tryRun($, ["rm", "-f", src, `${src}.svg`, png, bmp]);
403
+ }
404
+ }
405
+
406
+ /** Fire-and-forget: the card draws at once, the logo joins it when ready. */
407
+ async function loadLogo($: Engine, ad: AdCreative): Promise<void> {
408
+ if (!ad.logoUrl) return;
409
+ const logo = await loadLogoCells($, { url: ad.logoUrl, home: homeDir, columns: LOGO_COLS, rows: LOGO_ROWS });
410
+ if (logo && active.ad?.id === ad.id) {
411
+ active.logo = logo;
412
+ $.ui.invalidate("ui.render");
413
+ }
414
+ }
415
+
416
+ async function beginAdSession($: Engine): Promise<void> {
417
+ if (!config || !config.adsOptIn || startingSession) return;
418
+ if (active.phase === "loading" || active.phase === "showing") return;
419
+
420
+ startingSession = true;
421
+ // A banked card stays open until now; drop its logo and credited stocks.
422
+ resetAd("loading");
423
+ $.ui.invalidate("ui.render");
424
+
425
+ try {
426
+ const res = await $.http.fetch(`${config.serverUrl}/ads/session`, {
427
+ method: "POST",
428
+ headers: { "Content-Type": "application/json" },
429
+ body: JSON.stringify({ deviceId: config.deviceId, source: SOURCE }),
430
+ });
431
+ const body = parseBody<{
432
+ sessionId?: string;
433
+ ad?: AdCreative;
434
+ error?: string;
435
+ }>(res.text);
436
+ if (!res.ok || !body.sessionId || !body.ad) {
437
+ active.phase = "failed";
438
+ active.error = body.error ?? `session ${res.status}`;
439
+ $.ui.invalidate("ui.render");
440
+ $.clock.after(5_000, () => {
441
+ if (active.phase === "failed") {
442
+ resetAd("idle");
443
+ $.ui.invalidate("ui.render");
444
+ }
445
+ });
446
+ return;
447
+ }
448
+
449
+ const now = Date.now();
450
+ active.phase = "showing";
451
+ active.sessionId = body.sessionId;
452
+ active.ad = body.ad;
453
+ active.shownAt = now;
454
+ active.error = undefined;
455
+ await bumpLastAdOpenedAt($, now);
456
+ startCountdown($);
457
+ $.ui.invalidate("ui.render");
458
+ void loadLogo($, body.ad);
459
+ } catch (err) {
460
+ active.phase = "failed";
461
+ active.error = String(err);
462
+ $.ui.invalidate("ui.render");
463
+ $.clock.after(5_000, () => {
464
+ if (active.phase === "failed") {
465
+ resetAd("idle");
466
+ $.ui.invalidate("ui.render");
467
+ }
468
+ });
469
+ } finally {
470
+ startingSession = false;
471
+ }
472
+ }
473
+
474
+ // --- Jev ticker ---
475
+
476
+ /** Fire-and-forget; keeps the last good ticker when a refresh fails. */
477
+ async function refreshTicker($: Engine, force = false): Promise<void> {
478
+ if (!config || jevFetching) return;
479
+ if (!force && Date.now() - jevFetchedAt < TICKER_STALE_MS) return;
480
+ jevFetching = true;
481
+ try {
482
+ const res = await $.http.fetch(`${config.serverUrl}/stocks/ticker`, { method: "GET" });
483
+ if (!res.ok) return;
484
+ const body = parseBody<Partial<JevTicker>>(res.text);
485
+ if (!Array.isArray(body.names)) return;
486
+ jev = {
487
+ enabled: Boolean(body.enabled),
488
+ rewardUsd: Number(body.rewardUsd) || 0,
489
+ picks: Array.isArray(body.picks) ? body.picks : [],
490
+ names: body.names,
491
+ };
492
+ jevFetchedAt = Date.now();
493
+ $.ui.invalidate("ui.render");
494
+ for (const name of jev.names) void loadStockLogo($, name);
495
+ } catch {
496
+ // ticker is decoration; never surface a fetch error
497
+ } finally {
498
+ jevFetching = false;
499
+ }
500
+ }
501
+
502
+ /** Fire-and-forget: tiles draw a text badge until the logo lands. */
503
+ async function loadStockLogo($: Engine, name: TickerName): Promise<void> {
504
+ if (!name.logoUrl || stockLogos.has(name.ticker)) return;
505
+ stockLogos.set(name.ticker, null);
506
+ const logo = await loadLogoCells($, {
507
+ url: name.logoUrl,
508
+ home: homeDir,
509
+ columns: STOCK_LOGO_COLS,
510
+ rows: STOCK_LOGO_ROWS,
511
+ });
512
+ if (logo) {
513
+ stockLogos.set(name.ticker, logo);
514
+ $.ui.invalidate("ui.render");
515
+ }
516
+ }
517
+
518
+ function tickerQuote(ticker: string): TickerName | undefined {
519
+ return jev?.names.find((n) => n.ticker === ticker);
520
+ }
521
+
522
+ function fmtPrice(price: number | null): string {
523
+ if (price == null) return "";
524
+ return price >= 1000 ? price.toFixed(0) : price.toFixed(2);
525
+ }
526
+
527
+ function fmtChange(change: number | null): { text: string; color: string } | null {
528
+ if (change == null) return null;
529
+ const up = change >= 0;
530
+ return { text: `${up ? "▲" : "▼"}${Math.abs(change).toFixed(1)}%`, color: up ? C.green : C.red };
531
+ }
532
+
533
+ /** Plain-text ticker for the one-string prompt hint: `NVDA 182.40 ▲1.2% · TSLA …`. */
534
+ function tickerLine(): string {
535
+ const names = jev?.picks.length ? jev.picks : [];
536
+ return names
537
+ .map((n) => [n.ticker, fmtPrice(n.price), fmtChange(n.change24h)?.text ?? ""].filter(Boolean).join(" "))
538
+ .join(" · ");
539
+ }
540
+
541
+ function remainingSeconds($: Engine): number {
542
+ if (!active.ad || active.shownAt == null) return 0;
543
+ const left = active.ad.minWatchMs - (Date.now() - active.shownAt);
544
+ return Math.max(0, Math.ceil(left / 1000));
545
+ }
546
+
547
+ export const register: Register = (on) => {
548
+ on("session.start", async ($, e, next) => {
549
+ homeDir = await resolveHome($);
550
+ config = await loadConfig($);
551
+ if (config?.adsOptIn) {
552
+ await writeHeartbeat($);
553
+ void refreshTicker($, true);
554
+ }
555
+ return next(e);
556
+ });
557
+
558
+ // Fire-and-forget: never await the session fetch before next(e).
559
+ on("prompt.submit", ($, e, next) => {
560
+ if (config?.adsOptIn) {
561
+ void writeHeartbeat($);
562
+ void beginAdSession($);
563
+ void refreshTicker($);
564
+ }
565
+ return next(e);
566
+ });
567
+
568
+ // While the agent works and no card is up, ride Jev's picks on the hint line.
569
+ on("ui.render", { component: "PromptHint" }, ($, e, next) => {
570
+ if (!config?.adsOptIn || active.phase !== "idle" || !e.props.isWorking || e.props.isDraft) {
571
+ return next(e);
572
+ }
573
+ const line = tickerLine();
574
+ if (!line) return next(e);
575
+ const hint = e.props.hint ? `${e.props.hint} · ` : "";
576
+ return next({ ...e, props: { ...e.props, hint: `${hint}Jev ${line}` } });
577
+ });
578
+
579
+ on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
580
+ if (!config?.adsOptIn || active.phase === "idle") {
581
+ return next(e);
582
+ }
583
+
584
+ const t = await $.ui.resolve(e);
585
+ const Raster = "Raster" in t ? t.Raster : null;
586
+
587
+ // One Jev stock tile: logo, then ticker / quote / detail stacked beside it.
588
+ const stockTile = (ticker: string, detail: string, detailColor: string) => {
589
+ const quote = tickerQuote(ticker);
590
+ const change = fmtChange(quote?.change24h ?? null);
591
+ const logo = stockLogos.get(ticker);
592
+ return (
593
+ <t.Box key={`tile-${ticker}`} gap={1}>
594
+ {Raster && logo ? (
595
+ <Raster key={`logo-${ticker}`} columns={logo.columns} rows={logo.rows} cells={logo.cells} />
596
+ ) : (
597
+ <t.Box width={STOCK_LOGO_COLS} height={STOCK_LOGO_ROWS} justifyContent="center" alignItems="center">
598
+ <t.Text color={C.dim} bold>{ticker.slice(0, 2)}</t.Text>
599
+ </t.Box>
600
+ )}
601
+ <t.Box flexDirection="column">
602
+ <t.Text color={C.fg} bold>{ticker}</t.Text>
603
+ <t.Box gap={1}>
604
+ {quote?.price != null ? <t.Text color={C.soft}>{fmtPrice(quote.price)}</t.Text> : null}
605
+ {change ? <t.Text color={change.color}>{change.text}</t.Text> : null}
606
+ </t.Box>
607
+ <t.Text color={detailColor}>{detail}</t.Text>
608
+ </t.Box>
609
+ </t.Box>
610
+ );
611
+ };
612
+
613
+ const jevSection = (title: string, aside: string, tiles: RenderChildren[]) => (
614
+ <t.Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1} marginTop={1}>
615
+ <t.Box justifyContent="space-between">
616
+ <t.Text color={C.amber} bold>{`◇ ${title}`}</t.Text>
617
+ <t.Text color={C.dim}>{aside}</t.Text>
618
+ </t.Box>
619
+ <t.Box gap={4} flexWrap="wrap" marginTop={1}>
620
+ {tiles}
621
+ </t.Box>
622
+ </t.Box>
623
+ );
624
+
625
+ if (active.phase === "loading") {
626
+ return (
627
+ <t.Box borderStyle="round" borderColor={C.faint} paddingX={1} gap={1}>
628
+ <t.Text color={C.amber}>●</t.Text>
629
+ <t.Text color={C.dim}>promptai · loading sponsored card…</t.Text>
630
+ </t.Box>
631
+ );
632
+ }
633
+
634
+ if (active.phase === "failed") {
635
+ return (
636
+ <t.Box borderStyle="round" borderColor={C.red} paddingX={1} gap={1}>
637
+ <t.Text color={C.red}>✕</t.Text>
638
+ <t.Text color={C.dim}>{`promptai · ad unavailable${active.error ? ` (${active.error})` : ""}`}</t.Text>
639
+ </t.Box>
640
+ );
641
+ }
642
+
643
+ const verified = active.phase === "verified";
644
+ if ((active.phase !== "showing" && !verified) || !active.ad || active.shownAt == null) {
645
+ return next(e);
646
+ }
647
+
648
+ const ad = active.ad;
649
+ const elapsed = Date.now() - active.shownAt;
650
+ const bar = progressBar(elapsed, ad.minWatchMs);
651
+ const secs = remainingSeconds($);
652
+ const shownLines = (ad.lines ?? []).slice(0, 4);
653
+
654
+ const logo = active.logo;
655
+ const granted = verified ? (active.stocks ?? []) : [];
656
+
657
+ const details = (
658
+ <t.Box flexDirection="column" flexGrow={1}>
659
+ <t.Box gap={1}>
660
+ <t.Text color={C.green}>◆</t.Text>
661
+ <t.Text color={C.fg} bold>{ad.brand}</t.Text>
662
+ {ad.tag ? <t.Text color={C.dim}>{`· ${ad.tag}`}</t.Text> : null}
663
+ <t.Text color={C.faint}>· sponsored</t.Text>
664
+ </t.Box>
665
+ <t.Text color={C.soft} wrap="truncate-end">{ad.tagline}</t.Text>
666
+ {shownLines.map((line, i) => (
667
+ <t.Box key={`line-${i}`} gap={1}>
668
+ {line.cmd ? <t.Text color={C.green}>$</t.Text> : <t.Text color={C.faint}>│</t.Text>}
669
+ <t.Text color={line.cmd ? C.fg : C.dim} wrap="truncate-end">{line.text}</t.Text>
670
+ </t.Box>
671
+ ))}
672
+ </t.Box>
673
+ );
674
+
675
+ return (
676
+ <t.Box flexDirection="column" borderStyle="round" borderColor={verified ? C.green : C.faint} paddingX={1}>
677
+ {Raster && logo ? (
678
+ <t.Box gap={2}>
679
+ <Raster key="logo" columns={logo.columns} rows={logo.rows} cells={logo.cells} />
680
+ {details}
681
+ </t.Box>
682
+ ) : (
683
+ details
684
+ )}
685
+ {granted.length
686
+ ? jevSection(
687
+ "Jev credited",
688
+ `$${granted.reduce((sum, g) => sum + g.notionalUsd, 0).toFixed(2)} paper`,
689
+ granted.map((g) => stockTile(g.ticker, `+$${g.notionalUsd.toFixed(4)}`, C.green)),
690
+ )
691
+ : jev?.enabled && jev.picks.length && !verified
692
+ ? jevSection(
693
+ "Jev top picks",
694
+ `watch → $${jev.rewardUsd.toFixed(2)} slice`,
695
+ jev.picks.map((p) =>
696
+ stockTile(p.ticker, p.weight != null ? `${Math.round(p.weight * 100)}% of book` : "", C.faint),
697
+ ),
698
+ )
699
+ : null}
700
+ {verified ? (
701
+ <t.Box gap={1} marginTop={1}>
702
+ <t.Text color={C.green} bold>✓ verified</t.Text>
703
+ <t.Text color={C.soft}>credit banked</t.Text>
704
+ <t.Text color={C.faint}>·</t.Text>
705
+ <t.Button
706
+ key="hide"
707
+ label="Hide"
708
+ plain
709
+ onPress={() => {
710
+ resetAd("idle");
711
+ $.ui.invalidate("ui.render");
712
+ }}
713
+ />
714
+ </t.Box>
715
+ ) : (
716
+ <t.Box gap={1} marginTop={1}>
717
+ <t.Box>
718
+ <t.Text color={C.green}>{bar.filled}</t.Text>
719
+ <t.Text color={C.faint}>{bar.empty}</t.Text>
720
+ </t.Box>
721
+ <t.Text color={C.fg} bold>{`${secs}s`}</t.Text>
722
+ <t.Text color={C.faint}>opt-in rewarded ad</t.Text>
723
+ </t.Box>
724
+ )}
725
+ <t.Button key="cta" label={`${ad.ctaLabel || "Learn more"} →`} onPress={() => void onCtaPress($)} />
726
+ </t.Box>
727
+ );
728
+ });
729
+ };