domsniper 0.1.0

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.
Files changed (63) hide show
  1. package/.env.example +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +2062 -0
  6. package/src/completions.ts +65 -0
  7. package/src/core/db.ts +1313 -0
  8. package/src/core/features/asn-lookup.ts +91 -0
  9. package/src/core/features/backlinks.ts +83 -0
  10. package/src/core/features/blacklist-check.ts +67 -0
  11. package/src/core/features/cert-transparency.ts +87 -0
  12. package/src/core/features/config.ts +81 -0
  13. package/src/core/features/cors-check.ts +90 -0
  14. package/src/core/features/dns-details.ts +27 -0
  15. package/src/core/features/domain-age.ts +33 -0
  16. package/src/core/features/domain-suggest.ts +87 -0
  17. package/src/core/features/drop-catch.ts +159 -0
  18. package/src/core/features/email-security.ts +112 -0
  19. package/src/core/features/expiring-feed.ts +160 -0
  20. package/src/core/features/export.ts +74 -0
  21. package/src/core/features/filter.ts +96 -0
  22. package/src/core/features/http-probe.ts +46 -0
  23. package/src/core/features/marketplace.ts +69 -0
  24. package/src/core/features/path-scanner.ts +123 -0
  25. package/src/core/features/port-scanner.ts +132 -0
  26. package/src/core/features/portfolio-bulk.ts +125 -0
  27. package/src/core/features/portfolio-monitor.ts +214 -0
  28. package/src/core/features/portfolio.ts +98 -0
  29. package/src/core/features/price-compare.ts +39 -0
  30. package/src/core/features/rdap.ts +128 -0
  31. package/src/core/features/reverse-ip.ts +73 -0
  32. package/src/core/features/s3-export.ts +99 -0
  33. package/src/core/features/scoring.ts +121 -0
  34. package/src/core/features/security-headers.ts +162 -0
  35. package/src/core/features/session.ts +74 -0
  36. package/src/core/features/snipe.ts +264 -0
  37. package/src/core/features/social-check.ts +81 -0
  38. package/src/core/features/ssl-check.ts +88 -0
  39. package/src/core/features/subdomain-discovery.ts +53 -0
  40. package/src/core/features/takeover-detect.ts +143 -0
  41. package/src/core/features/tech-stack.ts +135 -0
  42. package/src/core/features/tld-expand.ts +43 -0
  43. package/src/core/features/variations.ts +134 -0
  44. package/src/core/features/version-check.ts +58 -0
  45. package/src/core/features/waf-detect.ts +171 -0
  46. package/src/core/features/watch.ts +120 -0
  47. package/src/core/features/wayback.ts +64 -0
  48. package/src/core/features/webhooks.ts +126 -0
  49. package/src/core/features/whois-history.ts +99 -0
  50. package/src/core/features/zone-transfer.ts +75 -0
  51. package/src/core/index.ts +50 -0
  52. package/src/core/paths.ts +9 -0
  53. package/src/core/registrar.ts +413 -0
  54. package/src/core/theme.ts +140 -0
  55. package/src/core/types.ts +143 -0
  56. package/src/core/validate.ts +58 -0
  57. package/src/core/whois.ts +265 -0
  58. package/src/index.tsx +1888 -0
  59. package/src/market-client.ts +186 -0
  60. package/src/proxy/ca.ts +116 -0
  61. package/src/proxy/db.ts +175 -0
  62. package/src/proxy/server.ts +155 -0
  63. package/tsconfig.json +30 -0
package/src/app.tsx ADDED
@@ -0,0 +1,2062 @@
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
3
+ import { whoisLookup, verifyAvailability, parseDomainList, type WhoisResult } from "./core/whois.js";
4
+ import {
5
+ checkAvailabilityViaRegistrar, registerDomain, loadConfigFromEnv,
6
+ type RegistrarConfig, type RegistrationResult,
7
+ } from "./core/registrar.js";
8
+ import { readFileSync, existsSync } from "fs";
9
+ import { theme, borders, statusStyle, type DomainStatus } from "./core/theme.js";
10
+ import type { DomainEntry } from "./core/types.js";
11
+ import { createEmptyEntry } from "./core/types.js";
12
+ import { sanitizeDomainList, safePath } from "./core/validate.js";
13
+ import { lookupDns } from "./core/features/dns-details.js";
14
+ import { httpProbe } from "./core/features/http-probe.js";
15
+ import { checkWayback } from "./core/features/wayback.js";
16
+ import { calculateDomainAge, daysUntilExpiry } from "./core/features/domain-age.js";
17
+ import { expandTlds, type TldPreset } from "./core/features/tld-expand.js";
18
+ import { generateVariations } from "./core/features/variations.js";
19
+ import { scoreDomain, scoreGrade } from "./core/features/scoring.js";
20
+ import { exportToCSV, exportToJSON } from "./core/features/export.js";
21
+ import { DomainWatcher, formatInterval } from "./core/features/watch.js";
22
+ import { saveSession, loadSession, listSessions } from "./core/features/session.js";
23
+ import { filterDomains, nextStatus, nextSort, DEFAULT_FILTER, type FilterConfig, type FilterStatus, type SortField } from "./core/features/filter.js";
24
+ import { rdapLookup } from "./core/features/rdap.js";
25
+ import { checkSsl } from "./core/features/ssl-check.js";
26
+ import { discoverSubdomains, getActiveSubdomains } from "./core/features/subdomain-discovery.js";
27
+ import { checkMarketplaces } from "./core/features/marketplace.js";
28
+ import { sendWebhook } from "./core/features/webhooks.js";
29
+ import { loadConfig } from "./core/features/config.js";
30
+ import { generateSuggestions } from "./core/features/domain-suggest.js";
31
+ import { addToPortfolio } from "./core/features/portfolio.js";
32
+ import { snipeDomain, getSnipeTargets, cancelSnipe } from "./core/features/snipe.js";
33
+ import {
34
+ isLoggedIn, getAuthInfo, browseListings, viewListing,
35
+ createListingApi, makeOffer, getMyListings, getMyOffers,
36
+ getMarketStatsApi, getUnreadApi, getServerUrl,
37
+ } from "./market-client.js";
38
+ import { checkSocialMedia, getAvailablePlatforms } from "./core/features/social-check.js";
39
+ import { detectTechStack } from "./core/features/tech-stack.js";
40
+ import { checkBlacklists } from "./core/features/blacklist-check.js";
41
+ import { estimateBacklinks } from "./core/features/backlinks.js";
42
+ import { saveWhoisSnapshot, getLatestDiff, getHistoryCount } from "./core/features/whois-history.js";
43
+ import { createDropCatcher, formatDropCatchStatus, type DropCatchStatus } from "./core/features/drop-catch.js";
44
+ import { scanPorts } from "./core/features/port-scanner.js";
45
+ import { reverseIpLookup } from "./core/features/reverse-ip.js";
46
+ import { lookupAsn } from "./core/features/asn-lookup.js";
47
+ import { checkEmailSecurity } from "./core/features/email-security.js";
48
+ import { checkZoneTransfer } from "./core/features/zone-transfer.js";
49
+ import { queryCertTransparency } from "./core/features/cert-transparency.js";
50
+ import { detectTakeover } from "./core/features/takeover-detect.js";
51
+ import { auditSecurityHeaders } from "./core/features/security-headers.js";
52
+ import { detectWaf } from "./core/features/waf-detect.js";
53
+ import { scanPaths } from "./core/features/path-scanner.js";
54
+ import { checkCors } from "./core/features/cors-check.js";
55
+ import { upsertDomain, saveScan, getCached, setCache, clearCache, getScanHistory, getDomainByName, createSession as dbCreateSession, updateSessionCount, getDbStats, getAllDomains, getPortfolioExpiring } from "./core/db.js";
56
+ import { getPortfolioDashboard, getUnacknowledgedAlerts, acknowledgeAllAlerts, getMonthlyReport, addTransaction, updatePortfolioStatus, getCategories, getSnipeStats, type PortfolioStatus, type TransactionType } from "./core/db.js";
57
+ import { generateRenewalCalendar, estimateAnnualRenewalCost } from "./core/features/portfolio-monitor.js";
58
+
59
+ // ─── Types ────────────────────────────────────────────────
60
+
61
+ type Mode = "idle" | "input" | "scanning" | "done" | "watching";
62
+ type InputMode = "domain" | "file" | "expand" | "variations" | "export" | "load";
63
+
64
+ interface AppProps {
65
+ initialDomains?: string[];
66
+ batchFile?: string;
67
+ autoRegister?: boolean;
68
+ }
69
+
70
+ function pad(s: string, len: number): string {
71
+ return s.length >= len ? s.slice(0, len) : s + " ".repeat(len - s.length);
72
+ }
73
+
74
+ function ts(): string {
75
+ const d = new Date();
76
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
77
+ }
78
+
79
+ // ─── Main App ─────────────────────────────────────────────
80
+
81
+ type IntelTab = "overview" | "dns" | "security" | "recon";
82
+
83
+ export function App({ initialDomains, batchFile, autoRegister = false }: AppProps) {
84
+ const [mode, setMode] = useState<Mode>(initialDomains?.length || batchFile ? "scanning" : "idle");
85
+ const [inputMode, setInputMode] = useState<InputMode>("domain");
86
+ const [domains, setDomains] = useState<DomainEntry[]>([]);
87
+ const [inputValue, setInputValue] = useState("");
88
+ const [selectedIndex, setSelectedIndex] = useState(0);
89
+ const [showHelp, setShowHelp] = useState(false);
90
+ const [showPortfolio, setShowPortfolio] = useState(false);
91
+ const [filter, setFilter] = useState<FilterConfig>({ ...DEFAULT_FILTER });
92
+ const [logs, setLogs] = useState<{ id: number; time: string; msg: string; fg: string }[]>([
93
+ { id: 0, time: ts(), msg: "Domain Sniper v2.0 initialized", fg: theme.textMuted },
94
+ { id: 1, time: ts(), msg: "Press ? for all commands", fg: theme.textMuted },
95
+ ]);
96
+ const [registrarConfig] = useState<RegistrarConfig | null>(loadConfigFromEnv());
97
+ const [reconMode, setReconMode] = useState(false);
98
+ const [watcher, setWatcher] = useState<DomainWatcher | null>(null);
99
+ const [watchCycle, setWatchCycle] = useState(0);
100
+ const [intelTab, setIntelTab] = useState<IntelTab>("overview");
101
+ const [scanProgress, setScanProgress] = useState<{ current: number; total: number } | null>(null);
102
+ const [confirmBulkRegister, setConfirmBulkRegister] = useState(false);
103
+
104
+ // Marketplace state
105
+ const [showMarket, setShowMarket] = useState(false);
106
+ const [marketListings, setMarketListings] = useState<any[]>([]);
107
+ const [marketTotal, setMarketTotal] = useState(0);
108
+ const [marketSelectedIdx, setMarketSelectedIdx] = useState(0);
109
+ const [marketSearch, setMarketSearch] = useState("");
110
+ const [marketLoading, setMarketLoading] = useState(false);
111
+ const [marketView, setMarketView] = useState<"browse" | "my-listings" | "my-offers" | "detail">("browse");
112
+ const [marketDetail, setMarketDetail] = useState<any | null>(null);
113
+ const [marketUnread, setMarketUnread] = useState(0);
114
+ const [marketInputMode, setMarketInputMode] = useState<"none" | "search" | "list-price" | "offer-amount" | "offer-message">("none");
115
+ const [marketInputValue, setMarketInputValue] = useState("");
116
+ const [marketListDomain, setMarketListDomain] = useState("");
117
+
118
+ const { width, height } = useTerminalDimensions();
119
+ const renderer = useRenderer();
120
+ const processingRef = useRef(false);
121
+ const domainsCountRef = useRef(0);
122
+ const logIdRef = useRef(2);
123
+ const appConfig = useRef(loadConfig());
124
+
125
+ useEffect(() => { domainsCountRef.current = domains.length; }, [domains.length]);
126
+
127
+ // ─── Log ────────────────────────────────────────────────
128
+
129
+ const log = useCallback((msg: string, fg: string = theme.textSecondary) => {
130
+ const id = logIdRef.current++;
131
+ setLogs((prev) => [...prev.slice(-80), { id, time: ts(), msg, fg }]);
132
+ }, []);
133
+
134
+ // ─── Domain Processing ──────────────────────────────────
135
+
136
+ const processDomain = useCallback(async (domain: string): Promise<DomainEntry> => {
137
+ // Check cache first (5-minute TTL for regular, skip for recon)
138
+ if (!reconMode) {
139
+ const cached = getCached(domain, "scan");
140
+ if (cached) {
141
+ try {
142
+ const entry = JSON.parse(cached) as DomainEntry;
143
+ log(`↻ CACHED ${domain}`, theme.textDisabled);
144
+ return entry;
145
+ } catch {}
146
+ }
147
+ }
148
+
149
+ const entry: DomainEntry = {
150
+ ...createEmptyEntry(domain),
151
+ status: "checking",
152
+ };
153
+ try {
154
+ const whois = await whoisLookup(domain);
155
+ entry.whois = whois;
156
+ if (whois.error) { entry.status = "error"; entry.error = whois.error; log(`ERR ${domain}: ${whois.error}`, theme.error); return entry; }
157
+
158
+ const verification = await verifyAvailability(domain);
159
+ entry.verification = verification;
160
+
161
+ if (registrarConfig?.apiKey) {
162
+ try {
163
+ const regCheck = await checkAvailabilityViaRegistrar(domain, registrarConfig);
164
+ entry.registrarCheck = { available: regCheck.available, price: regCheck.price, currency: regCheck.currency };
165
+ } catch (err: unknown) {
166
+ log(`REG check failed: ${err instanceof Error ? err.message : "unknown"}`, theme.warning);
167
+ }
168
+ }
169
+
170
+ if (whois.available && verification.confidence === "high") { entry.status = "available"; log(`● AVAIL ${domain} [${verification.confidence}]`, theme.primary); }
171
+ else if (whois.expired) { entry.status = "expired"; log(`◈ EXPRD ${domain}`, theme.pending); }
172
+ else if (whois.available) { entry.status = "available"; log(`● AVAIL ${domain} [${verification.confidence}]`, theme.primary); }
173
+ else { entry.status = "taken"; log(`✕ TAKEN ${domain}`, theme.textDisabled); }
174
+
175
+ // New features — run in parallel, don't block on failures
176
+ const [dns, probe, wayback, rdap, ssl, subdomains, marketplace, social, techStack, blacklist, backlinks] = await Promise.all([
177
+ lookupDns(domain).catch(() => null),
178
+ httpProbe(domain).catch(() => null),
179
+ checkWayback(domain).catch(() => null),
180
+ rdapLookup(domain).catch(() => null),
181
+ checkSsl(domain).catch(() => null),
182
+ discoverSubdomains(domain).catch(() => null),
183
+ checkMarketplaces(domain).catch(() => null),
184
+ checkSocialMedia(domain).catch(() => null),
185
+ detectTechStack(domain).catch(() => null),
186
+ checkBlacklists(domain).catch(() => null),
187
+ estimateBacklinks(domain).catch(() => null),
188
+ ]);
189
+ entry.dns = dns;
190
+ entry.httpProbe = probe;
191
+ entry.wayback = wayback;
192
+ entry.rdap = rdap;
193
+ entry.ssl = ssl;
194
+ entry.subdomains = subdomains;
195
+ entry.marketplace = marketplace;
196
+ entry.socialMedia = social;
197
+ entry.techStack = techStack;
198
+ entry.blacklist = blacklist;
199
+ entry.backlinks = backlinks;
200
+ entry.domainAge = calculateDomainAge(entry.whois?.createdDate ?? null);
201
+
202
+ // Recon features — only run in recon mode
203
+ if (reconMode) {
204
+ const [ports, reverseIp, asn, emailSec, zoneXfer, certs, takeover, secHeaders, waf, paths, cors] = await Promise.all([
205
+ scanPorts(domain).catch(() => null),
206
+ reverseIpLookup(domain).catch(() => null),
207
+ lookupAsn(domain).catch(() => null),
208
+ checkEmailSecurity(domain).catch(() => null),
209
+ checkZoneTransfer(domain).catch(() => null),
210
+ queryCertTransparency(domain).catch(() => null),
211
+ detectTakeover(domain).catch(() => null),
212
+ auditSecurityHeaders(domain).catch(() => null),
213
+ detectWaf(domain).catch(() => null),
214
+ scanPaths(domain).catch(() => null),
215
+ checkCors(domain).catch(() => null),
216
+ ]);
217
+ entry.portScan = ports;
218
+ entry.reverseIp = reverseIp;
219
+ entry.asn = asn;
220
+ entry.emailSecurity = emailSec;
221
+ entry.zoneTransfer = zoneXfer;
222
+ entry.certTransparency = certs;
223
+ entry.takeover = takeover;
224
+ entry.securityHeaders = secHeaders;
225
+ entry.waf = waf;
226
+ entry.pathScan = paths;
227
+ entry.cors = cors;
228
+ }
229
+
230
+ // Save WHOIS snapshot for history tracking
231
+ if (entry.whois && !entry.whois.error) {
232
+ try { saveWhoisSnapshot(entry.whois); } catch {}
233
+ }
234
+
235
+ // Send webhook notification if configured
236
+ if ((entry.status === "available" || entry.status === "expired")) {
237
+ const cfg = appConfig.current;
238
+ if (cfg.notifications.webhookUrl) {
239
+ void sendWebhook(cfg.notifications.webhookUrl, {
240
+ domain: entry.domain,
241
+ status: entry.status,
242
+ timestamp: new Date().toISOString(),
243
+ }).catch(() => {});
244
+ }
245
+ }
246
+
247
+ // Save to cache (5 min TTL) and database
248
+ setCache(domain, "scan", JSON.stringify(entry), 5);
249
+ try {
250
+ const domainId = upsertDomain(domain);
251
+ const score = scoreDomain(domain);
252
+ saveScan(domainId, entry.status, entry, undefined, score.total);
253
+ } catch {}
254
+
255
+ return entry;
256
+ } catch (err: unknown) {
257
+ const message = err instanceof Error ? err.message : "Unknown error";
258
+ entry.status = "error"; entry.error = message;
259
+ log(`ERR ${domain}: ${message}`, theme.error);
260
+ return entry;
261
+ }
262
+ }, [registrarConfig, reconMode, log]);
263
+
264
+ const processAllDomains = useCallback(async (domainList: string[], append = false) => {
265
+ if (processingRef.current) return;
266
+ processingRef.current = true;
267
+ setMode("scanning");
268
+
269
+ let sessionId: number | undefined;
270
+ try { sessionId = dbCreateSession(); } catch {}
271
+
272
+ const entries: DomainEntry[] = domainList.map((d) => createEmptyEntry(d));
273
+
274
+ if (append) {
275
+ setDomains((prev: DomainEntry[]) => [...prev, ...entries]);
276
+ } else {
277
+ setDomains(entries);
278
+ }
279
+
280
+ const startIdx = append ? domainsCountRef.current : 0;
281
+ log(`━━━ Scanning ${domainList.length} domain${domainList.length > 1 ? "s" : ""} ━━━`, theme.info);
282
+ setScanProgress({ current: 0, total: domainList.length });
283
+
284
+ // Concurrent pool
285
+ const CONCURRENCY = 5;
286
+ let nextIdx = 0;
287
+
288
+ async function worker() {
289
+ while (nextIdx < entries.length) {
290
+ const i = nextIdx++;
291
+ const globalIdx = startIdx + i;
292
+ setDomains((prev: DomainEntry[]) => {
293
+ const u = [...prev];
294
+ if (u[globalIdx]) u[globalIdx] = { ...u[globalIdx]!, status: "checking" };
295
+ return u;
296
+ });
297
+
298
+ const result = await processDomain(domainList[i]!);
299
+ setDomains((prev: DomainEntry[]) => { const u = [...prev]; u[globalIdx] = result; return u; });
300
+ setScanProgress((prev) => prev ? { ...prev, current: prev.current + 1 } : null);
301
+
302
+ if (autoRegister && registrarConfig?.apiKey && (result.status === "available" || result.status === "expired")) {
303
+ setDomains((prev: DomainEntry[]) => {
304
+ const u = [...prev];
305
+ if (u[globalIdx]) u[globalIdx] = { ...u[globalIdx]!, status: "registering" };
306
+ return u;
307
+ });
308
+ try {
309
+ const regResult = await registerDomain(domainList[i]!, registrarConfig);
310
+ setDomains((prev: DomainEntry[]) => {
311
+ const u = [...prev];
312
+ if (u[globalIdx]) u[globalIdx] = { ...u[globalIdx]!, status: regResult.success ? "registered" : u[globalIdx]!.status, registration: regResult };
313
+ return u;
314
+ });
315
+ if (regResult.success) log(`★ REG'd ${domainList[i]}`, theme.secondary);
316
+ } catch (err: unknown) {
317
+ log(`REG failed: ${err instanceof Error ? err.message : "unknown"}`, theme.error);
318
+ }
319
+ }
320
+
321
+ // Rate limit per worker
322
+ await new Promise((r) => setTimeout(r, 500));
323
+ }
324
+ }
325
+
326
+ const workers = Array.from(
327
+ { length: Math.min(CONCURRENCY, domainList.length) },
328
+ () => worker()
329
+ );
330
+ await Promise.all(workers);
331
+
332
+ try { if (sessionId) updateSessionCount(sessionId, domainList.length); } catch {}
333
+
334
+ processingRef.current = false;
335
+ setScanProgress(null);
336
+ setMode("done");
337
+ log("━━━ Scan complete ━━━", theme.info);
338
+ }, [autoRegister, registrarConfig, processDomain, log]);
339
+
340
+ // ─── Marketplace loaders ────────────────────────────────
341
+
342
+ const loadMarketListings = useCallback(async (search?: string) => {
343
+ setMarketLoading(true);
344
+ try {
345
+ const result = await browseListings({ search, limit: 50, sort: "newest" });
346
+ if (result.ok) {
347
+ setMarketListings(result.data.listings);
348
+ setMarketTotal(result.data.total);
349
+ }
350
+ } catch {}
351
+ setMarketLoading(false);
352
+ }, []);
353
+
354
+ const loadMyListings = useCallback(async () => {
355
+ setMarketLoading(true);
356
+ try {
357
+ const result = await getMyListings();
358
+ if (result.ok) { setMarketListings(result.data); setMarketTotal(result.data.length); }
359
+ } catch {}
360
+ setMarketLoading(false);
361
+ }, []);
362
+
363
+ const loadMyOffers = useCallback(async () => {
364
+ setMarketLoading(true);
365
+ try {
366
+ const result = await getMyOffers("buyer");
367
+ const result2 = await getMyOffers("seller");
368
+ if (result.ok && result2.ok) {
369
+ const all = [...result.data, ...result2.data];
370
+ setMarketListings(all);
371
+ setMarketTotal(all.length);
372
+ }
373
+ } catch {}
374
+ setMarketLoading(false);
375
+ }, []);
376
+
377
+ // Check unread count periodically
378
+ useEffect(() => {
379
+ if (!isLoggedIn()) return;
380
+ const checkUnread = async () => {
381
+ try {
382
+ const r = await getUnreadApi();
383
+ if (r.ok) setMarketUnread(r.data.count);
384
+ } catch {}
385
+ };
386
+ checkUnread();
387
+ const interval = setInterval(checkUnread, 60000);
388
+ return () => clearInterval(interval);
389
+ }, []);
390
+
391
+ // ─── Init ───────────────────────────────────────────────
392
+
393
+ useEffect(() => {
394
+ if (batchFile && existsSync(batchFile)) {
395
+ const content = readFileSync(batchFile, "utf-8");
396
+ const list = parseDomainList(content);
397
+ if (list.length > 0) processAllDomains(list);
398
+ } else if (initialDomains?.length) {
399
+ processAllDomains(initialDomains);
400
+ }
401
+ }, []);
402
+
403
+ // ─── Filtered domains ──────────────────────────────────
404
+
405
+ const filteredDomains = useMemo(() => filterDomains(domains, filter), [domains, filter]);
406
+ const selected = filteredDomains[selectedIndex] || null;
407
+
408
+ // ─── Keyboard ───────────────────────────────────────────
409
+
410
+ useKeyboard((e) => {
411
+ const key = e.name;
412
+ const ctrl = e.ctrl;
413
+
414
+ if (ctrl && key === "c") { watcher?.stop(); renderer.destroy(); process.exit(0); }
415
+ if (key === "q" && mode !== "input") { watcher?.stop(); renderer.destroy(); process.exit(0); }
416
+
417
+ // Toggle help
418
+ if (key === "?" && mode !== "input") { setShowHelp((v) => !v); return; }
419
+ if (showHelp && key === "escape") { setShowHelp(false); return; }
420
+ if (showHelp) return; // Consume all keys while help is shown
421
+
422
+ // Toggle portfolio dashboard
423
+ if (key === "P" && mode !== "input") { setShowPortfolio((v) => !v); return; }
424
+ if (showPortfolio && key === "escape") { setShowPortfolio(false); return; }
425
+
426
+ // ── Marketplace toggle ──
427
+ if (key === "M" && mode !== "input" && !showPortfolio) {
428
+ if (!showMarket) {
429
+ setShowMarket(true);
430
+ setMarketView("browse");
431
+ setMarketSelectedIdx(0);
432
+ void loadMarketListings();
433
+ } else {
434
+ setShowMarket(false);
435
+ setMarketInputMode("none");
436
+ }
437
+ return;
438
+ }
439
+
440
+ // ── Marketplace keyboard controls (when market is open) ──
441
+ if (showMarket) {
442
+ // Close marketplace
443
+ if (key === "escape") {
444
+ if (marketInputMode !== "none") {
445
+ setMarketInputMode("none");
446
+ setMode(domains.length > 0 ? "done" : "idle");
447
+ } else if (marketView === "detail") {
448
+ setMarketView("browse");
449
+ setMarketDetail(null);
450
+ } else {
451
+ setShowMarket(false);
452
+ }
453
+ return;
454
+ }
455
+
456
+ // Marketplace input mode
457
+ if (marketInputMode !== "none") {
458
+ return; // Let the input component handle it
459
+ }
460
+
461
+ // Navigation
462
+ if (key === "up" || key === "k") { setMarketSelectedIdx((i) => Math.max(0, i - 1)); return; }
463
+ if (key === "down" || key === "j") { setMarketSelectedIdx((i) => Math.min(marketListings.length - 1, i + 1)); return; }
464
+
465
+ // View listing detail
466
+ if (key === "return" && marketListings[marketSelectedIdx]) {
467
+ const item = marketListings[marketSelectedIdx];
468
+ if (marketView === "browse" && item.id) {
469
+ setMarketLoading(true);
470
+ viewListing(item.id).then((r) => {
471
+ if (r.ok) { setMarketDetail(r.data); setMarketView("detail"); }
472
+ setMarketLoading(false);
473
+ });
474
+ }
475
+ return;
476
+ }
477
+
478
+ // Search
479
+ if (key === "/" && marketView === "browse") {
480
+ setMarketInputMode("search");
481
+ setMarketInputValue(marketSearch);
482
+ setMode("input");
483
+ setInputValue("");
484
+ return;
485
+ }
486
+
487
+ // Switch views
488
+ if (key === "1") { setMarketView("browse"); setMarketSelectedIdx(0); void loadMarketListings(marketSearch); return; }
489
+ if (key === "2" && isLoggedIn()) { setMarketView("my-listings"); setMarketSelectedIdx(0); void loadMyListings(); return; }
490
+ if (key === "3" && isLoggedIn()) { setMarketView("my-offers"); setMarketSelectedIdx(0); void loadMyOffers(); return; }
491
+
492
+ // List selected domain for sale (from scan results)
493
+ if (key === "l" && selected && isLoggedIn() && marketView !== "detail") {
494
+ setMarketListDomain(selected.domain);
495
+ setMarketInputMode("list-price");
496
+ setMode("input");
497
+ setInputValue("");
498
+ log(`Enter asking price for ${selected.domain}`, theme.info);
499
+ return;
500
+ }
501
+
502
+ // Make offer on selected listing
503
+ if (key === "o" && marketView === "detail" && marketDetail && isLoggedIn()) {
504
+ setMarketInputMode("offer-amount");
505
+ setMode("input");
506
+ setInputValue("");
507
+ return;
508
+ }
509
+
510
+ // Back from detail
511
+ if (key === "backspace" && marketView === "detail") {
512
+ setMarketView("browse");
513
+ setMarketDetail(null);
514
+ return;
515
+ }
516
+
517
+ // Refresh
518
+ if (key === "r") {
519
+ if (marketView === "browse") void loadMarketListings(marketSearch);
520
+ else if (marketView === "my-listings") void loadMyListings();
521
+ else if (marketView === "my-offers") void loadMyOffers();
522
+ return;
523
+ }
524
+
525
+ return; // Consume all other keys while market is open
526
+ }
527
+
528
+ // ── Tab switching for INTEL panel (Issue 1) ──
529
+ if (key === "tab" && mode !== "input") {
530
+ const tabs: IntelTab[] = ["overview", "dns", "security", "recon"];
531
+ setIntelTab((current) => {
532
+ const idx = tabs.indexOf(current);
533
+ return tabs[(idx + 1) % tabs.length]!;
534
+ });
535
+ return;
536
+ }
537
+ if ((key === "backtick" || (e.shift && key === "tab")) && mode !== "input") {
538
+ const tabs: IntelTab[] = ["overview", "dns", "security", "recon"];
539
+ setIntelTab((current) => {
540
+ const idx = tabs.indexOf(current);
541
+ return tabs[(idx - 1 + tabs.length) % tabs.length]!;
542
+ });
543
+ return;
544
+ }
545
+
546
+ // Cancel bulk register on any other key
547
+ if (confirmBulkRegister && key !== "R") {
548
+ setConfirmBulkRegister(false);
549
+ }
550
+
551
+ // ── Input triggers ──
552
+ if (mode !== "input" && mode !== "scanning") {
553
+ if (key === "/" || key === "i") { setInputMode("domain"); setMode("input"); setInputValue(""); return; }
554
+ if (key === "f") { setInputMode("file"); setMode("input"); setInputValue(""); log("Enter file path...", theme.textMuted); return; }
555
+ if (key === "e") { setInputMode("expand"); setMode("input"); setInputValue(""); log("Enter base name for TLD expansion...", theme.textMuted); return; }
556
+ if (key === "v" && selected) {
557
+ // Generate variations for selected domain
558
+ const vars = generateVariations(selected.domain);
559
+ log(`Generated ${vars.length} variations of ${selected.domain}`, theme.info);
560
+ if (vars.length > 0) processAllDomains(vars, true);
561
+ return;
562
+ }
563
+ if (key === "x") { setInputMode("export"); setMode("input"); setInputValue(""); log("Enter export path (.csv or .json)...", theme.textMuted); return; }
564
+ }
565
+
566
+ // ── Input mode ──
567
+ if (mode === "input") {
568
+ if (key === "escape") { setMode(domains.length > 0 ? "done" : "idle"); return; }
569
+ return;
570
+ }
571
+
572
+ // ── Navigation ──
573
+ if (mode === "scanning" || mode === "done" || mode === "watching") {
574
+ if (key === "up" || key === "k" || (ctrl && key === "p")) setSelectedIndex((i: number) => Math.max(0, i - 1));
575
+ else if (key === "down" || key === "j" || (ctrl && key === "n")) setSelectedIndex((i: number) => Math.min(filteredDomains.length - 1, i + 1));
576
+ else if (key === "pageup") setSelectedIndex((i: number) => Math.max(0, i - 10));
577
+ else if (key === "pagedown") setSelectedIndex((i: number) => Math.min(filteredDomains.length - 1, i + 10));
578
+ else if (key === "home" || key === "g") setSelectedIndex(0);
579
+ else if (key === "end") setSelectedIndex(Math.max(0, filteredDomains.length - 1));
580
+
581
+ // Register (Issue 5: feedback on all statuses)
582
+ else if (key === "r" && selected) {
583
+ if ((selected.status === "available" || selected.status === "expired") && registrarConfig?.apiKey) {
584
+ void handleRegister(domains.indexOf(selected));
585
+ } else if (!registrarConfig?.apiKey) {
586
+ log("No registrar configured. Set REGISTRAR_PROVIDER and REGISTRAR_API_KEY in .env", theme.warning);
587
+ } else {
588
+ log(`Cannot register ${selected.domain} (status: ${selected.status})`, theme.warning);
589
+ }
590
+ }
591
+
592
+ // Bulk register tagged (Issue 7: two-step confirmation)
593
+ else if (key === "R") {
594
+ const tagged = domains.filter((d) => d.tagged && (d.status === "available" || d.status === "expired"));
595
+ if (!registrarConfig?.apiKey) {
596
+ log("No registrar configured", theme.warning);
597
+ } else if (tagged.length === 0) {
598
+ log("Tag domains with SPACE first, then R to bulk register", theme.warning);
599
+ } else if (!confirmBulkRegister) {
600
+ setConfirmBulkRegister(true);
601
+ log(`⚠ CONFIRM: Press R again to register ${tagged.length} domain(s) via ${registrarConfig.provider}`, theme.warning);
602
+ setTimeout(() => setConfirmBulkRegister(false), 5000);
603
+ } else {
604
+ setConfirmBulkRegister(false);
605
+ log(`Bulk registering ${tagged.length} domains...`, theme.info);
606
+ const registerPromises = tagged.map((d) => handleRegister(domains.indexOf(d)));
607
+ void Promise.allSettled(registerPromises);
608
+ }
609
+ }
610
+
611
+ // Tag/untag
612
+ else if (key === "space" && selected) {
613
+ setDomains((prev: DomainEntry[]) => {
614
+ const u = [...prev];
615
+ const idx = u.indexOf(selected);
616
+ if (idx >= 0) u[idx] = { ...u[idx]!, tagged: !u[idx]!.tagged };
617
+ return u;
618
+ });
619
+ setSelectedIndex((i: number) => Math.min(filteredDomains.length - 1, i + 1));
620
+ }
621
+
622
+ // Filter: cycle status
623
+ else if (key === "s") {
624
+ setFilter((f: FilterConfig) => ({ ...f, status: nextStatus(f.status) }));
625
+ setSelectedIndex(0);
626
+ }
627
+
628
+ // Sort: cycle field
629
+ else if (key === "o") {
630
+ setFilter((f: FilterConfig) => ({ ...f, sort: nextSort(f.sort) }));
631
+ setSelectedIndex(0);
632
+ }
633
+
634
+ // Sort: toggle order
635
+ else if (key === "O") {
636
+ setFilter((f: FilterConfig) => ({ ...f, order: f.order === "asc" ? "desc" : "asc" }));
637
+ }
638
+
639
+ // Watch mode
640
+ else if (key === "w") {
641
+ if (watcher?.running) {
642
+ watcher.stop();
643
+ setMode("done");
644
+ log("Watch stopped", theme.warning);
645
+ } else {
646
+ const watchDomains = domains.filter((d) => d.tagged).map((d) => d.domain);
647
+ if (watchDomains.length === 0) {
648
+ log("Tag domains with SPACE first, then w to watch", theme.warning);
649
+ } else {
650
+ const w = new DomainWatcher({
651
+ domains: watchDomains,
652
+ intervalMs: 3600000,
653
+ notify: true,
654
+ onCheck: (domain, status) => log(`[watch] ${domain}: ${status}`, status === "available" ? theme.primary : theme.textMuted),
655
+ onCycle: (cycle) => { setWatchCycle(cycle); log(`━━━ Watch cycle #${cycle} ━━━`, theme.info); },
656
+ onAvailable: (domain) => log(`🔔 ${domain} is AVAILABLE!`, theme.primary),
657
+ });
658
+ setWatcher(w);
659
+ w.start();
660
+ setMode("watching");
661
+ log(`Watching ${watchDomains.length} domains (1h interval)`, theme.info);
662
+ }
663
+ }
664
+ }
665
+
666
+ // Domain suggestions (Issue 6: deduplicate)
667
+ else if (key === "d" && selected) {
668
+ const name = selected.domain.split(".")[0] || "";
669
+ const suggestions = generateSuggestions(name);
670
+ const existingDomains = new Set(domains.map((d) => d.domain));
671
+ const newSuggestions = suggestions.filter((s) => !existingDomains.has(s.domain)).slice(0, 15);
672
+ if (newSuggestions.length > 0) {
673
+ log(`Generated ${newSuggestions.length} new suggestions from "${name}"`, theme.info);
674
+ processAllDomains(newSuggestions.map((s) => s.domain), true);
675
+ } else {
676
+ log(`All suggestions for "${name}" already in list`, theme.textMuted);
677
+ }
678
+ }
679
+
680
+ // Drop catch mode
681
+ else if (key === "D" && selected && registrarConfig?.apiKey) {
682
+ if (selected.status === "expired" || selected.whois?.expired) {
683
+ const catcher = createDropCatcher({
684
+ domain: selected.domain,
685
+ registrarConfig: registrarConfig!,
686
+ pollIntervalMs: 30000,
687
+ maxAttempts: 2880,
688
+ onStatus: (status: DropCatchStatus) => log(formatDropCatchStatus(status), status.phase === "success" ? theme.primary : status.phase === "failed" ? theme.error : theme.info),
689
+ onSuccess: (d: string) => log(`DROP CAUGHT: ${d}!`, theme.primary),
690
+ onFailed: (d: string, err: string) => log(`Drop catch failed for ${d}: ${err}`, theme.error),
691
+ });
692
+ catcher.start();
693
+ log(`Drop catch started for ${selected.domain} (polling every 30s)`, theme.info);
694
+ } else {
695
+ log("Drop catch requires an expired domain", theme.warning);
696
+ }
697
+ }
698
+
699
+ // Toggle recon mode (Issue 4: fix inverted message)
700
+ else if (key === "n") {
701
+ setReconMode((v) => {
702
+ const newVal = !v;
703
+ log(newVal ? "Recon mode ON (full pentest — rescan to apply)" : "Recon mode OFF (fast scan)", newVal ? theme.warning : theme.textMuted);
704
+ return newVal;
705
+ });
706
+ }
707
+
708
+ // Add to portfolio
709
+ else if (key === "p" && selected) {
710
+ try {
711
+ addToPortfolio(selected.domain, {
712
+ registrar: selected.whois?.registrar || selected.rdap?.registrar || "unknown",
713
+ expiryDate: selected.whois?.expiryDate || selected.rdap?.expiryDate || "",
714
+ purchaseDate: new Date().toISOString().split("T")[0],
715
+ });
716
+ log(`Added ${selected.domain} to portfolio`, theme.info);
717
+ } catch (err: unknown) {
718
+ log(`Portfolio: ${err instanceof Error ? err.message : "failed"}`, theme.error);
719
+ }
720
+ }
721
+
722
+ // Snipe selected domain
723
+ else if (key === "S" && selected) {
724
+ if (selected.status === "taken" || selected.status === "expired") {
725
+ snipeDomain(selected.domain, {
726
+ expiryDate: selected.whois?.expiryDate || undefined,
727
+ });
728
+ const phase = selected.status === "expired" ? "frequent" : "hourly";
729
+ log(`Sniping ${selected.domain} — ${selected.status === "expired" ? "expired, checking every 5 min" : "watching for expiry"}`, theme.warning);
730
+ log(`Run 'domain-sniper snipe run' to start the engine`, theme.textDisabled);
731
+ } else if (selected.status === "available") {
732
+ log(`${selected.domain} is already available — press r to register now`, theme.primary);
733
+ } else {
734
+ log(`Cannot snipe ${selected.domain} (status: ${selected.status})`, theme.textMuted);
735
+ }
736
+ }
737
+
738
+ // Clear cache for selected domain
739
+ else if (key === "c" && selected) {
740
+ const count = clearCache(selected.domain);
741
+ log(`Cleared cache for ${selected.domain} (${count} entries)`, theme.info);
742
+ }
743
+
744
+ // Show scan history for selected domain
745
+ else if (key === "h" && selected) {
746
+ const history = getScanHistory(selected.domain, 5);
747
+ if (history.length > 0) {
748
+ log(`─── History for ${selected.domain} ───`, theme.secondary);
749
+ for (const h of history) {
750
+ log(` ${h.scanned_at} — ${h.status}${h.score ? ` (${h.score})` : ""}`, theme.textSecondary);
751
+ }
752
+ } else {
753
+ log(`No scan history for ${selected.domain}`, theme.textMuted);
754
+ }
755
+ }
756
+
757
+ // Save session
758
+ else if (ctrl && key === "s") {
759
+ const path = saveSession(domains);
760
+ log(`Session saved: ${path}`, theme.info);
761
+ }
762
+
763
+ // Load session
764
+ else if (ctrl && key === "l") {
765
+ setInputMode("load");
766
+ setMode("input");
767
+ setInputValue("");
768
+ const sessions = listSessions();
769
+ if (sessions.length > 0) {
770
+ log(`${sessions.length} saved session(s). Enter ID or path.`, theme.info);
771
+ sessions.slice(0, 3).forEach((s) => log(` ${s.id} (${s.count} domains)`, theme.textMuted));
772
+ } else {
773
+ log("No saved sessions found", theme.warning);
774
+ }
775
+ }
776
+ }
777
+ });
778
+
779
+ // ─── Register ───────────────────────────────────────────
780
+
781
+ const handleRegister = async (index: number) => {
782
+ if (!registrarConfig?.apiKey || index < 0) return;
783
+ const d = domains[index]!;
784
+ log(`Registering ${d.domain}...`, theme.info);
785
+ setDomains((prev: DomainEntry[]) => { const u = [...prev]; u[index] = { ...u[index]!, status: "registering" }; return u; });
786
+ const result = await registerDomain(d.domain, registrarConfig);
787
+ setDomains((prev: DomainEntry[]) => {
788
+ const u = [...prev]; u[index] = { ...u[index]!, status: result.success ? "registered" : "available", registration: result }; return u;
789
+ });
790
+ log(result.success ? `★ REG'd ${d.domain}` : `FAIL ${result.error}`, result.success ? theme.secondary : theme.error);
791
+ };
792
+
793
+ // ─── Submit handler ─────────────────────────────────────
794
+
795
+ const handleSubmit = (value: string) => {
796
+ const v = value.trim();
797
+ if (!v) return;
798
+
799
+ // Marketplace inputs
800
+ if (marketInputMode === "search") {
801
+ setMarketSearch(v);
802
+ setMarketInputMode("none");
803
+ setMarketSelectedIdx(0);
804
+ void loadMarketListings(v);
805
+ setMode(domains.length > 0 ? "done" : "idle");
806
+ return;
807
+ }
808
+ if (marketInputMode === "list-price") {
809
+ const price = parseFloat(v);
810
+ if (isNaN(price) || price <= 0) { log("Invalid price", theme.error); setMarketInputMode("none"); setMode(domains.length > 0 ? "done" : "idle"); return; }
811
+ setMarketInputMode("none");
812
+ setMode(domains.length > 0 ? "done" : "idle");
813
+ createListingApi(marketListDomain, price, { title: marketListDomain }).then((r) => {
814
+ if (r.ok) {
815
+ log(`Listed ${marketListDomain} for $${price} — verify ownership to activate`, theme.primary);
816
+ if (r.data.verification) {
817
+ log(`DNS: Add TXT record domain-sniper-verify=${r.data.verification.token}`, theme.textMuted);
818
+ }
819
+ } else {
820
+ log(`Failed to list: ${r.data?.error || "unknown error"}`, theme.error);
821
+ }
822
+ });
823
+ return;
824
+ }
825
+ if (marketInputMode === "offer-amount") {
826
+ const amount = parseFloat(v);
827
+ if (isNaN(amount) || amount <= 0) { log("Invalid amount", theme.error); setMarketInputMode("none"); setMode(domains.length > 0 ? "done" : "idle"); return; }
828
+ setMarketInputMode("none");
829
+ setMode(domains.length > 0 ? "done" : "idle");
830
+ if (marketDetail?.listing) {
831
+ makeOffer(marketDetail.listing.id, amount).then((r) => {
832
+ if (r.ok) {
833
+ log(`Offer of $${amount} submitted on ${marketDetail.listing.domain}`, theme.primary);
834
+ } else {
835
+ log(`Offer failed: ${r.data?.error || "unknown"}`, theme.error);
836
+ }
837
+ });
838
+ }
839
+ return;
840
+ }
841
+
842
+ if (inputMode === "file") {
843
+ try {
844
+ const filePath = safePath(v, [process.cwd()]);
845
+ if (existsSync(filePath)) {
846
+ const content = readFileSync(filePath, "utf-8");
847
+ const list = parseDomainList(content);
848
+ if (list.length > 0) { log(`Loaded ${list.length} domains from ${filePath}`, theme.info); processAllDomains(list); }
849
+ else log("No valid domains in file", theme.warning);
850
+ } else { log("File not found: " + filePath, theme.error); setMode(domains.length > 0 ? "done" : "idle"); }
851
+ } catch (err: unknown) {
852
+ log(`Path error: ${err instanceof Error ? err.message : "invalid path"}`, theme.error);
853
+ setMode(domains.length > 0 ? "done" : "idle");
854
+ }
855
+ } else if (inputMode === "expand") {
856
+ const expanded = expandTlds(v, "popular");
857
+ log(`Expanded "${v}" into ${expanded.length} TLD variants`, theme.info);
858
+ if (expanded.length > 0) processAllDomains(expanded, domains.length > 0);
859
+ else { setMode(domains.length > 0 ? "done" : "idle"); }
860
+ } else if (inputMode === "export") {
861
+ try {
862
+ const path = v.endsWith(".json") ? exportToJSON(domains, v) : exportToCSV(domains, v);
863
+ log(`Exported ${domains.length} domains to ${path}`, theme.primary);
864
+ } catch (err: unknown) { log(`Export failed: ${err instanceof Error ? err.message : "unknown"}`, theme.error); }
865
+ setMode("done");
866
+ } else if (inputMode === "load") {
867
+ const session = loadSession(v);
868
+ if (session) {
869
+ setDomains(session.domains.map((d: any) => ({ ...d, tagged: false })));
870
+ setMode("done");
871
+ log(`Loaded session: ${session.id} (${session.domains.length} domains)`, theme.info);
872
+ } else { log("Session not found", theme.error); setMode(domains.length > 0 ? "done" : "idle"); }
873
+ } else if (inputMode === "domain") {
874
+ if (existsSync(v)) {
875
+ const content = readFileSync(v, "utf-8");
876
+ const list = parseDomainList(content);
877
+ if (list.length > 0) { log(`Loaded ${list.length} from ${v}`, theme.info); processAllDomains(list, domains.length > 0); }
878
+ } else {
879
+ const list = v.split(/[,\s]+/).map((d: string) => d.trim().toLowerCase()).filter(Boolean);
880
+ const validated = sanitizeDomainList(list);
881
+ if (validated.length > 0) processAllDomains(validated, domains.length > 0);
882
+ else log("No valid domains entered", theme.warning);
883
+ }
884
+ }
885
+ };
886
+
887
+ // ─── Stats ──────────────────────────────────────────────
888
+
889
+ const stats = useMemo(() => {
890
+ const s = { total: domains.length, available: 0, expired: 0, taken: 0, checking: 0, registered: 0, errors: 0, tagged: 0 };
891
+ for (const d of domains) {
892
+ if (d.status === "available") s.available++;
893
+ else if (d.status === "expired") s.expired++;
894
+ else if (d.status === "taken") s.taken++;
895
+ else if (d.status === "checking" || d.status === "pending" || d.status === "registering") s.checking++;
896
+ else if (d.status === "registered") s.registered++;
897
+ else if (d.status === "error") s.errors++;
898
+ if (d.tagged) s.tagged++;
899
+ }
900
+ return s;
901
+ }, [domains]);
902
+
903
+ // Memoize scores (Issue 12: avoid recalculating per render)
904
+ const domainScores = useMemo(() => {
905
+ const map = new Map<string, { score: ReturnType<typeof scoreDomain>; grade: ReturnType<typeof scoreGrade> }>();
906
+ for (const d of domains) {
907
+ const s = scoreDomain(d.domain);
908
+ map.set(d.domain, { score: s, grade: scoreGrade(s.total) });
909
+ }
910
+ return map;
911
+ }, [domains]);
912
+
913
+ const selectedScoreData = selected ? domainScores.get(selected.domain) : null;
914
+ const score = selectedScoreData?.score ?? null;
915
+ const grade = selectedScoreData?.grade ?? null;
916
+
917
+ // ─── Layout ─────────────────────────────────────────────
918
+
919
+ const sidebarW = Math.max(32, Math.min(48, Math.floor(width * 0.42)));
920
+ const hLine = (w: number) => "─".repeat(Math.max(1, w - 2));
921
+ const dLine = (w: number) => "═".repeat(Math.max(1, w - 2));
922
+ const logPanelH = Math.max(4, Math.floor((height - 5) * 0.28));
923
+
924
+ const inputLabel = marketInputMode === "search" ? "SEARCH" : marketInputMode === "list-price" ? "PRICE" : marketInputMode === "offer-amount" ? "OFFER" : inputMode === "file" ? "FILE" : inputMode === "expand" ? "EXPAND" : inputMode === "export" ? "EXPORT" : inputMode === "load" ? "LOAD" : "SCAN";
925
+ const inputPlaceholder = marketInputMode === "search" ? "Search domains..." : marketInputMode === "list-price" ? `Asking price for ${marketListDomain}` : marketInputMode === "offer-amount" ? "Your offer amount" : inputMode === "file" ? "/path/to/domains.txt" : inputMode === "expand" ? "coolstartup" : inputMode === "export" ? "results.csv or results.json" : inputMode === "load" ? "session-id or path" : "domains or /path/to/file";
926
+
927
+ // ─── Render ─────────────────────────────────────────────
928
+
929
+ return (
930
+ <box width={width} height={height} backgroundColor={theme.background} flexDirection="column">
931
+
932
+ {/* ═══ HEADER ═══ */}
933
+ <box flexShrink={0} flexDirection="column">
934
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1}>
935
+ <box flexDirection="row" gap={1}>
936
+ <box backgroundColor={theme.primary}><text content=" ◆ DOMAIN SNIPER " fg={theme.background} /></box>
937
+ <box backgroundColor={
938
+ mode === "scanning" ? theme.warning : mode === "watching" ? theme.accent : mode === "done" ? theme.primary : mode === "input" ? theme.info : theme.textDisabled
939
+ }><text content={
940
+ mode === "scanning" ? (scanProgress ? ` ${scanProgress.current}/${scanProgress.total} ` : " SCANNING ") : mode === "watching" ? ` WATCH #${watchCycle} ` : mode === "done" ? " READY " : mode === "input" ? ` ${inputLabel} ` : " IDLE "
941
+ } fg={theme.background} /></box>
942
+ {filter.status !== "all" && (
943
+ <box backgroundColor={theme.secondaryDim}><text content={` ${filter.status.toUpperCase()} `} fg={theme.secondary} /></box>
944
+ )}
945
+ {filter.sort !== "domain" && (
946
+ <box backgroundColor={theme.accentDim}><text content={` ↕${filter.sort} `} fg={theme.accent} /></box>
947
+ )}
948
+ {reconMode && (
949
+ <box backgroundColor={theme.errorDim}><text content=" RECON " fg={theme.error} /></box>
950
+ )}
951
+ </box>
952
+ <box flexDirection="row" gap={2}>
953
+ {stats.total > 0 && (
954
+ <box flexDirection="row" gap={1}>
955
+ <text content={`${stats.available}`} fg={theme.primary} />
956
+ <text content="avl" fg={theme.textDisabled} />
957
+ <text content={`${stats.expired}`} fg={theme.pending} />
958
+ <text content="exp" fg={theme.textDisabled} />
959
+ <text content={`${stats.taken}`} fg={theme.error} />
960
+ <text content="tkn" fg={theme.textDisabled} />
961
+ {stats.tagged > 0 && (<><text content={`${stats.tagged}`} fg={theme.info} /><text content="tag" fg={theme.textDisabled} /></>)}
962
+ </box>
963
+ )}
964
+ {registrarConfig?.apiKey ? (
965
+ <text content={`● ${registrarConfig.provider}`} fg={theme.secondary} />
966
+ ) : (
967
+ <text content="○ whois" fg={theme.textDisabled} />
968
+ )}
969
+ {isLoggedIn() && (
970
+ <box flexDirection="row" gap={1}>
971
+ <text content={`◆ market`} fg={theme.secondary} />
972
+ {marketUnread > 0 && <text content={`✉${marketUnread}`} fg={theme.warning} />}
973
+ </box>
974
+ )}
975
+ {(() => {
976
+ try {
977
+ const snipeStats = getSnipeStats();
978
+ return snipeStats.total > 0 ? (
979
+ <text content={`⊕ ${snipeStats.total} sniping`} fg={theme.warning} />
980
+ ) : null;
981
+ } catch { return null; }
982
+ })()}
983
+ </box>
984
+ </box>
985
+ <text content={dLine(width)} fg={theme.border} paddingLeft={1} />
986
+ </box>
987
+
988
+ {/* ═══ BODY ═══ */}
989
+ <box flexGrow={1} flexDirection="row" minHeight={0}>
990
+ {showMarket ? (
991
+ <box flexGrow={1} flexDirection="column" paddingLeft={1} paddingRight={1} minHeight={0}>
992
+ {/* Marketplace header */}
993
+ <box flexDirection="row" gap={2} flexShrink={0} paddingLeft={1}>
994
+ <box backgroundColor={theme.secondary}><text content=" MARKETPLACE " fg={theme.background} /></box>
995
+ {(["browse", "my-listings", "my-offers"] as const).map((v, i) => (
996
+ <box key={v} backgroundColor={marketView === v ? theme.secondaryDim : "transparent"}>
997
+ <text content={` ${i + 1}:${v === "browse" ? "Browse" : v === "my-listings" ? "My Listings" : "My Offers"} `} fg={marketView === v ? theme.secondary : theme.textDisabled} />
998
+ </box>
999
+ ))}
1000
+ {isLoggedIn() ? (
1001
+ <text content={`● ${getAuthInfo()?.name || "user"}`} fg={theme.primary} />
1002
+ ) : (
1003
+ <text content="○ not signed in (use CLI: market login)" fg={theme.textDisabled} />
1004
+ )}
1005
+ {marketUnread > 0 && <text content={`✉ ${marketUnread}`} fg={theme.warning} />}
1006
+ <box flexGrow={1} />
1007
+ <text content="(M to close)" fg={theme.textDisabled} />
1008
+ </box>
1009
+ <text content={hLine(width)} fg={theme.borderSubtle} paddingLeft={1} />
1010
+
1011
+ {marketView === "detail" && marketDetail ? (
1012
+ /* Detail view */
1013
+ <scrollbox flexGrow={1} paddingLeft={2} minHeight={0} scrollbarOptions={{ visible: true }}>
1014
+ <box flexDirection="column" gap={0} paddingTop={1}>
1015
+ <box flexDirection="row" gap={2}>
1016
+ <text content={marketDetail.listing.domain} fg={theme.text} />
1017
+ <text content={`$${marketDetail.listing.asking_price}`} fg={theme.warning} />
1018
+ <text content={marketDetail.listing.verified ? "✓ verified" : "unverified"} fg={marketDetail.listing.verified ? theme.primary : theme.textDisabled} />
1019
+ <text content={marketDetail.listing.status} fg={theme.textMuted} />
1020
+ </box>
1021
+ <text content="" />
1022
+ {marketDetail.listing.title && <text content={marketDetail.listing.title} fg={theme.textSecondary} />}
1023
+ {marketDetail.listing.description && <text content={marketDetail.listing.description} fg={theme.textMuted} />}
1024
+ <text content="" />
1025
+ <box flexDirection="row" gap={2}>
1026
+ <text content={`Category: ${marketDetail.listing.category}`} fg={theme.textDisabled} />
1027
+ <text content={`Views: ${marketDetail.listing.views}`} fg={theme.textDisabled} />
1028
+ <text content={`Offers: ${marketDetail.offerCount}`} fg={theme.textDisabled} />
1029
+ </box>
1030
+ {marketDetail.listing.min_offer > 0 && <text content={`Min offer: $${marketDetail.listing.min_offer}`} fg={theme.textMuted} />}
1031
+ {marketDetail.listing.buy_now ? <text content="Buy Now enabled" fg={theme.primary} /> : null}
1032
+ <text content="" />
1033
+ <text content={`Listed: ${marketDetail.listing.created_at}`} fg={theme.textDisabled} />
1034
+ <text content="" />
1035
+ {isLoggedIn() && <text content="Press 'o' to make an offer, Backspace to go back" fg={theme.textMuted} />}
1036
+ {!isLoggedIn() && <text content="Sign in via CLI to make offers: domain-sniper market login" fg={theme.textMuted} />}
1037
+ </box>
1038
+ </scrollbox>
1039
+ ) : (
1040
+ /* List view */
1041
+ <box flexGrow={1} flexDirection="row" minHeight={0}>
1042
+ {/* Listing list */}
1043
+ <box flexGrow={1} flexDirection="column" minHeight={0}>
1044
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1} flexShrink={0}>
1045
+ <text content={`${marketView === "browse" ? "All Listings" : marketView === "my-listings" ? "My Listings" : "My Offers"} (${marketTotal})`} fg={theme.secondary} />
1046
+ {marketLoading && <text content="loading..." fg={theme.warning} />}
1047
+ {marketSearch && marketView === "browse" && <text content={`search: "${marketSearch}"`} fg={theme.textMuted} />}
1048
+ </box>
1049
+ <text content={hLine(width)} fg={theme.borderSubtle} paddingLeft={1} />
1050
+
1051
+ {marketListings.length > 0 ? (
1052
+ <scrollbox flexGrow={1} paddingLeft={1} minHeight={0} scrollbarOptions={{ visible: true }}>
1053
+ {marketListings.map((item: any, i: number) => {
1054
+ const active = marketSelectedIdx === i;
1055
+ const domain = item.domain || "—";
1056
+ const price = item.asking_price ?? item.amount ?? 0;
1057
+ const status = item.status || "—";
1058
+ const verified = item.verified ? "✓" : " ";
1059
+ return (
1060
+ <box key={item.id || i} flexDirection="row" backgroundColor={active ? theme.secondaryDim : "transparent"} paddingLeft={1} gap={1}>
1061
+ <text content={verified} fg={theme.primary} />
1062
+ <text content={pad(domain, 28)} fg={active ? theme.text : theme.textSecondary} />
1063
+ <text content={`$${price}`} fg={theme.warning} />
1064
+ <box flexGrow={1} />
1065
+ <text content={status} fg={status === "active" ? theme.primary : status === "pending" ? theme.warning : theme.textDisabled} />
1066
+ </box>
1067
+ );
1068
+ })}
1069
+ </scrollbox>
1070
+ ) : (
1071
+ <box flexGrow={1} alignItems="center" justifyContent="center" minHeight={0}>
1072
+ <text content={marketLoading ? "Loading..." : "No listings found"} fg={theme.textDisabled} />
1073
+ </box>
1074
+ )}
1075
+ </box>
1076
+ </box>
1077
+ )}
1078
+
1079
+ {/* Marketplace footer hints */}
1080
+ <text content={dLine(width)} fg={theme.border} paddingLeft={1} />
1081
+ <box flexDirection="row" paddingLeft={1} paddingRight={1} flexShrink={0}>
1082
+ <box flexDirection="row" gap={1}>
1083
+ <box backgroundColor={theme.textDisabled}><text content=" 1 " fg={theme.background} /></box><text content="browse" fg={theme.textMuted} />
1084
+ {isLoggedIn() && (<><box backgroundColor={theme.textDisabled}><text content=" 2 " fg={theme.background} /></box><text content="mine" fg={theme.textMuted} /></>)}
1085
+ {isLoggedIn() && (<><box backgroundColor={theme.textDisabled}><text content=" 3 " fg={theme.background} /></box><text content="offers" fg={theme.textMuted} /></>)}
1086
+ <box backgroundColor={theme.textDisabled}><text content=" / " fg={theme.background} /></box><text content="search" fg={theme.textMuted} />
1087
+ <box backgroundColor={theme.textDisabled}><text content=" ⏎ " fg={theme.background} /></box><text content="view" fg={theme.textMuted} />
1088
+ {isLoggedIn() && selected && (<><box backgroundColor={theme.textDisabled}><text content=" l " fg={theme.background} /></box><text content="list domain" fg={theme.textMuted} /></>)}
1089
+ <box backgroundColor={theme.textDisabled}><text content=" r " fg={theme.background} /></box><text content="refresh" fg={theme.textMuted} />
1090
+ <box backgroundColor={theme.textDisabled}><text content=" M " fg={theme.background} /></box><text content="close" fg={theme.textMuted} />
1091
+ </box>
1092
+ </box>
1093
+ </box>
1094
+ ) : showPortfolio ? (
1095
+ <box flexGrow={1} flexDirection="column" paddingLeft={2} paddingRight={2} minHeight={0}>
1096
+ <scrollbox flexGrow={1} minHeight={0} scrollbarOptions={{ visible: true }}>
1097
+ {(() => {
1098
+ const dash = getPortfolioDashboard();
1099
+ const calendar = generateRenewalCalendar(6);
1100
+ const annualCost = estimateAnnualRenewalCost();
1101
+ const alerts = getUnacknowledgedAlerts();
1102
+ const monthly = getMonthlyReport(6);
1103
+
1104
+ return (
1105
+ <box flexDirection="column" gap={0}>
1106
+ {/* Header */}
1107
+ <box flexDirection="row" gap={2} paddingTop={1}>
1108
+ <box backgroundColor={theme.primary}><text content=" PORTFOLIO DASHBOARD " fg={theme.background} /></box>
1109
+ <text content={`${dash.totalDomains} domains`} fg={theme.text} />
1110
+ <text content={`$${dash.totalValue.toFixed(0)} value`} fg={theme.primary} />
1111
+ <text content="(Press P to close)" fg={theme.textDisabled} />
1112
+ </box>
1113
+
1114
+ <text content="" />
1115
+
1116
+ {/* Summary cards row */}
1117
+ <box flexDirection="row" gap={3}>
1118
+ <box flexDirection="column">
1119
+ <text content="FINANCIALS" fg={theme.secondary} />
1120
+ <text content={` Costs: $${dash.totalCosts.toFixed(2)}`} fg={theme.error} />
1121
+ <text content={` Revenue: $${dash.totalRevenue.toFixed(2)}`} fg={theme.primary} />
1122
+ <text content={` Profit: $${dash.totalProfit.toFixed(2)}`} fg={dash.totalProfit >= 0 ? theme.primary : theme.error} />
1123
+ <text content={` Annual renewals: ~$${annualCost.toFixed(0)}`} fg={theme.textMuted} />
1124
+ </box>
1125
+ <box flexDirection="column">
1126
+ <text content="STATUS" fg={theme.secondary} />
1127
+ {Object.entries(dash.byStatus).map(([status, count]) => (
1128
+ <text key={status} content={` ${status}: ${count}`} fg={theme.textSecondary} />
1129
+ ))}
1130
+ </box>
1131
+ <box flexDirection="column">
1132
+ <text content="CATEGORIES" fg={theme.secondary} />
1133
+ {Object.entries(dash.byCategory).map(([cat, count]) => (
1134
+ <text key={cat} content={` ${cat}: ${count}`} fg={theme.textSecondary} />
1135
+ ))}
1136
+ </box>
1137
+ </box>
1138
+
1139
+ <text content="" />
1140
+
1141
+ {/* Alerts */}
1142
+ {alerts.length > 0 && (
1143
+ <box flexDirection="column">
1144
+ <text content={`ALERTS (${alerts.length})`} fg={theme.warning} />
1145
+ {alerts.slice(0, 5).map((a) => (
1146
+ <text key={a.id} content={` ${a.severity === "critical" ? "!!" : a.severity === "warning" ? "! " : "· "} ${a.domain}: ${a.message}`} fg={a.severity === "critical" ? theme.error : a.severity === "warning" ? theme.warning : theme.textSecondary} />
1147
+ ))}
1148
+ {alerts.length > 5 && <text content={` +${alerts.length - 5} more`} fg={theme.textDisabled} />}
1149
+ </box>
1150
+ )}
1151
+
1152
+ <text content="" />
1153
+
1154
+ {/* Renewal Calendar */}
1155
+ {calendar.length > 0 && (
1156
+ <box flexDirection="column">
1157
+ <text content="UPCOMING RENEWALS" fg={theme.secondary} />
1158
+ {calendar.slice(0, 8).map((r) => (
1159
+ <box key={r.domain} flexDirection="row" gap={2}>
1160
+ <text content={` ${pad(r.domain, 30)}`} fg={r.daysLeft <= 7 ? theme.error : r.daysLeft <= 30 ? theme.warning : theme.text} />
1161
+ <text content={`${r.daysLeft}d`} fg={r.daysLeft <= 7 ? theme.error : r.daysLeft <= 30 ? theme.warning : theme.textMuted} />
1162
+ <text content={`$${r.renewalPrice}`} fg={theme.textDisabled} />
1163
+ {r.autoRenew && <text content="auto" fg={theme.primary} />}
1164
+ </box>
1165
+ ))}
1166
+ </box>
1167
+ )}
1168
+
1169
+ <text content="" />
1170
+
1171
+ {/* Top valued domains */}
1172
+ {dash.topValueDomains.length > 0 && (
1173
+ <box flexDirection="column">
1174
+ <text content="TOP VALUED DOMAINS" fg={theme.secondary} />
1175
+ {dash.topValueDomains.map((d) => (
1176
+ <text key={d.domain} content={` ${pad(d.domain, 30)} $${d.estimated_value.toFixed(0)}`} fg={theme.text} />
1177
+ ))}
1178
+ </box>
1179
+ )}
1180
+
1181
+ <text content="" />
1182
+
1183
+ {/* Monthly P&L */}
1184
+ {monthly.length > 0 && (
1185
+ <box flexDirection="column">
1186
+ <text content="MONTHLY P&L" fg={theme.secondary} />
1187
+ {monthly.map((m) => (
1188
+ <box key={m.month} flexDirection="row" gap={2}>
1189
+ <text content={` ${m.month}`} fg={theme.textMuted} />
1190
+ <text content={`-$${m.costs.toFixed(0)}`} fg={theme.error} />
1191
+ <text content={`+$${m.revenue.toFixed(0)}`} fg={theme.primary} />
1192
+ <text content={`= $${m.profit.toFixed(0)}`} fg={m.profit >= 0 ? theme.primary : theme.error} />
1193
+ </box>
1194
+ ))}
1195
+ </box>
1196
+ )}
1197
+
1198
+ <text content="" />
1199
+
1200
+ {/* Recent transactions */}
1201
+ {dash.recentTransactions.length > 0 && (
1202
+ <box flexDirection="column">
1203
+ <text content="RECENT TRANSACTIONS" fg={theme.secondary} />
1204
+ {dash.recentTransactions.map((t, i) => (
1205
+ <text key={i} content={` ${t.date} ${pad(t.type, 18)} ${t.amount >= 0 ? "+" : ""}$${t.amount.toFixed(2)} ${t.domain}`} fg={theme.textSecondary} />
1206
+ ))}
1207
+ </box>
1208
+ )}
1209
+
1210
+ {/* Pipeline count */}
1211
+ {dash.pipelineCount > 0 && (
1212
+ <>
1213
+ <text content="" />
1214
+ <text content={`PIPELINE: ${dash.pipelineCount} domain(s) being tracked`} fg={theme.info} />
1215
+ </>
1216
+ )}
1217
+ </box>
1218
+ );
1219
+ })()}
1220
+ </scrollbox>
1221
+ </box>
1222
+ ) : (
1223
+ <>
1224
+ {/* ─── LEFT PANEL ─── */}
1225
+ <box width={sidebarW} flexDirection="column" minHeight={0}>
1226
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1} flexShrink={0}>
1227
+ <text content={` TARGETS ${filteredDomains.length !== stats.total ? `(${filteredDomains.length}/${stats.total})` : stats.total > 0 ? `(${stats.total})` : ""}`} fg={theme.primary} />
1228
+ {stats.checking > 0 && <text content={`◆ ${stats.checking}`} fg={theme.warning} />}
1229
+ </box>
1230
+ <text content={hLine(sidebarW)} fg={theme.borderSubtle} paddingLeft={1} />
1231
+
1232
+ {filteredDomains.length > 0 ? (
1233
+ <scrollbox flexGrow={1} paddingLeft={1} minHeight={0} scrollbarOptions={{ visible: true }}>
1234
+ {filteredDomains.map((entry: DomainEntry, i: number) => {
1235
+ const active = selectedIndex === i;
1236
+ const ss = statusStyle(entry.status);
1237
+ const cached = domainScores.get(entry.domain);
1238
+ const gr = cached?.grade ?? scoreGrade(0);
1239
+ return (
1240
+ <box key={entry.domain} flexDirection="row" backgroundColor={active ? theme.primaryDim : "transparent"} paddingLeft={1} gap={1}>
1241
+ <text content={entry.tagged ? "◉" : " "} fg={entry.tagged ? theme.info : "transparent"} />
1242
+ <text content={ss.icon} fg={ss.fg} />
1243
+ <text content={entry.domain} fg={active ? theme.text : theme.textSecondary} />
1244
+ <box flexGrow={1} />
1245
+ <text content={gr.grade} fg={gr.color} />
1246
+ {entry.status === "checking" && <text content="···" fg={theme.warning} />}
1247
+ </box>
1248
+ );
1249
+ })}
1250
+ </scrollbox>
1251
+ ) : (
1252
+ <box flexGrow={1} alignItems="center" justifyContent="center" minHeight={0}>
1253
+ <text content={domains.length > 0 ? "No matches" : "No targets"} fg={theme.textDisabled} />
1254
+ </box>
1255
+ )}
1256
+
1257
+ {/* LOG */}
1258
+ <text content={hLine(sidebarW)} fg={theme.borderSubtle} paddingLeft={1} />
1259
+ <box flexDirection="row" paddingLeft={1} flexShrink={0}>
1260
+ <text content=" LOG" fg={theme.textMuted} />
1261
+ </box>
1262
+ <scrollbox height={logPanelH} paddingLeft={1} minHeight={0} scrollbarOptions={{ visible: false }}>
1263
+ {logs.map((l) => (
1264
+ <box key={l.id} flexDirection="row" gap={1}>
1265
+ <text content={l.time} fg={theme.textDisabled} />
1266
+ <text content={l.msg} fg={l.fg} />
1267
+ </box>
1268
+ ))}
1269
+ </scrollbox>
1270
+ </box>
1271
+
1272
+ {/* ─── DIVIDER ─── */}
1273
+ <box width={1}><text content={"┃\n".repeat(Math.max(1, height - 5))} fg={theme.border} /></box>
1274
+
1275
+ {/* ─── RIGHT PANEL: INTEL ─── */}
1276
+ <box flexGrow={1} flexDirection="column" minHeight={0}>
1277
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1} flexShrink={0}>
1278
+ <text content=" INTEL" fg={theme.primary} />
1279
+ {selected && <text content={selected.domain} fg={theme.text} />}
1280
+ </box>
1281
+ {/* Tab bar (Issue 1) */}
1282
+ {selected && !showHelp && (
1283
+ <box flexDirection="row" paddingLeft={1} gap={1} flexShrink={0}>
1284
+ {(["overview", "dns", "security", "recon"] as IntelTab[]).map((tab) => (
1285
+ <box key={tab} backgroundColor={intelTab === tab ? theme.primaryDim : "transparent"}>
1286
+ <text content={` ${tab.toUpperCase()} `} fg={intelTab === tab ? theme.primary : theme.textDisabled} />
1287
+ </box>
1288
+ ))}
1289
+ </box>
1290
+ )}
1291
+ <text content={hLine(width - sidebarW)} fg={theme.borderSubtle} paddingLeft={1} />
1292
+
1293
+ {/* ─── Help overlay ─── */}
1294
+ {showHelp ? (
1295
+ <scrollbox flexGrow={1} paddingLeft={2} paddingRight={2} minHeight={0} scrollbarOptions={{ visible: false }}>
1296
+ <box flexDirection="column" gap={0} paddingTop={1}>
1297
+ <text content="COMMANDS" fg={theme.primary} />
1298
+ <text content="" />
1299
+ <text content="Navigation" fg={theme.secondary} />
1300
+ <text content=" ↑/k ↓/j Move selection" fg={theme.textSecondary} />
1301
+ <text content=" PgUp PgDn Jump 10" fg={theme.textSecondary} />
1302
+ <text content=" g / Home First" fg={theme.textSecondary} />
1303
+ <text content=" End Last" fg={theme.textSecondary} />
1304
+ <text content=" Tab / ` Cycle INTEL tabs" fg={theme.textSecondary} />
1305
+ <text content="" />
1306
+ <text content="Scanning" fg={theme.secondary} />
1307
+ <text content=" / i Enter domains" fg={theme.textSecondary} />
1308
+ <text content=" f Load from file" fg={theme.textSecondary} />
1309
+ <text content=" e TLD expansion" fg={theme.textSecondary} />
1310
+ <text content=" v Variations of selected" fg={theme.textSecondary} />
1311
+ <text content="" />
1312
+ <text content="Actions" fg={theme.secondary} />
1313
+ <text content=" SPACE Tag / untag domain" fg={theme.textSecondary} />
1314
+ <text content=" r Register selected" fg={theme.textSecondary} />
1315
+ <text content=" R Bulk register tagged (2x)" fg={theme.textSecondary} />
1316
+ <text content=" d Suggest similar domains" fg={theme.textSecondary} />
1317
+ <text content=" p Add to portfolio" fg={theme.textSecondary} />
1318
+ <text content=" S Snipe domain (auto-register when it drops)" fg={theme.textSecondary} />
1319
+ <text content=" D Drop catch (expired only)" fg={theme.textSecondary} />
1320
+ <text content=" c Clear cache for selected" fg={theme.textSecondary} />
1321
+ <text content=" h Show scan history" fg={theme.textSecondary} />
1322
+ <text content=" w Watch tagged (1h)" fg={theme.textSecondary} />
1323
+ <text content="" />
1324
+ <text content="Filter & Sort" fg={theme.secondary} />
1325
+ <text content=" s Cycle status filter" fg={theme.textSecondary} />
1326
+ <text content=" o Cycle sort field" fg={theme.textSecondary} />
1327
+ <text content=" O Toggle sort order" fg={theme.textSecondary} />
1328
+ <text content="" />
1329
+ <text content="Recon" fg={theme.secondary} />
1330
+ <text content=" n Toggle recon mode" fg={theme.textSecondary} />
1331
+ <text content=" Enables port scan, WAF, headers," fg={theme.textDisabled} />
1332
+ <text content=" CORS, zone transfer, takeover detect" fg={theme.textDisabled} />
1333
+ <text content=" (rescan required after toggling)" fg={theme.textDisabled} />
1334
+ <text content="" />
1335
+ <text content="Marketplace" fg={theme.secondary} />
1336
+ <text content=" M Open/close marketplace" fg={theme.textSecondary} />
1337
+ <text content=" / Search listings (in marketplace)" fg={theme.textSecondary} />
1338
+ <text content=" 1 2 3 Switch: Browse / My Listings / My Offers" fg={theme.textSecondary} />
1339
+ <text content=" Enter View listing details" fg={theme.textSecondary} />
1340
+ <text content=" l List selected domain for sale" fg={theme.textSecondary} />
1341
+ <text content=" o Make offer (in detail view)" fg={theme.textSecondary} />
1342
+ <text content=" r Refresh listings" fg={theme.textSecondary} />
1343
+ <text content="" />
1344
+ <text content="Portfolio" fg={theme.secondary} />
1345
+ <text content=" P Portfolio dashboard" fg={theme.textSecondary} />
1346
+ <text content=" p Add selected to portfolio" fg={theme.textSecondary} />
1347
+ <text content="" />
1348
+ <text content="Session" fg={theme.secondary} />
1349
+ <text content=" Ctrl+S Save session" fg={theme.textSecondary} />
1350
+ <text content=" Ctrl+L Load session" fg={theme.textSecondary} />
1351
+ <text content=" x Export CSV/JSON" fg={theme.textSecondary} />
1352
+ <text content="" />
1353
+ <text content=" ? Toggle this help" fg={theme.textSecondary} />
1354
+ <text content=" q / Ctrl+C Quit" fg={theme.textSecondary} />
1355
+ </box>
1356
+ </scrollbox>
1357
+ ) : selected ? (
1358
+ <scrollbox flexGrow={1} paddingLeft={1} paddingRight={1} minHeight={0} scrollbarOptions={{ visible: true }}>
1359
+ <box flexDirection="column" gap={0} paddingRight={1}>
1360
+
1361
+ {/* ══ OVERVIEW TAB ══ */}
1362
+ {intelTab === "overview" && (
1363
+ <>
1364
+ {/* Status + Score banner */}
1365
+ <box flexDirection="row" gap={2} paddingLeft={1} paddingTop={1}>
1366
+ {(() => { const ss = statusStyle(selected.status); return (<box flexDirection="row" gap={1}><text content={ss.icon} fg={ss.fg} /><text content={ss.label} fg={ss.fg} /></box>); })()}
1367
+ {score && grade && (
1368
+ <box flexDirection="row" gap={1}>
1369
+ <text content={`${grade.grade}`} fg={grade.color} />
1370
+ <text content={`${score.total}/100`} fg={theme.textMuted} />
1371
+ </box>
1372
+ )}
1373
+ {selected.verification && (
1374
+ <text content={`conf: ${selected.verification.confidence}`} fg={selected.verification.confidence === "high" ? theme.primary : theme.warning} />
1375
+ )}
1376
+ {selected.tagged && <text content="TAGGED" fg={theme.info} />}
1377
+ {selected.domainAge && (
1378
+ <text content={`age: ${selected.domainAge}`} fg={theme.textMuted} />
1379
+ )}
1380
+ {(() => {
1381
+ try {
1382
+ const dbDomain = getDomainByName(selected.domain);
1383
+ return dbDomain && dbDomain.scan_count > 1 ? <text content={`scanned ${dbDomain.scan_count}x`} fg={theme.textDisabled} /> : null;
1384
+ } catch { return null; }
1385
+ })()}
1386
+ </box>
1387
+
1388
+ {/* Score breakdown */}
1389
+ {score && (
1390
+ <box flexDirection="row" paddingLeft={2} paddingTop={1} gap={1}>
1391
+ <text content={`len:${score.length}`} fg={theme.textDisabled} />
1392
+ <text content={`tld:${score.tld}`} fg={theme.textDisabled} />
1393
+ <text content={`read:${score.readability}`} fg={theme.textDisabled} />
1394
+ <text content={`brand:${score.brandable}`} fg={theme.textDisabled} />
1395
+ <text content={`seo:${score.seo}`} fg={theme.textDisabled} />
1396
+ </box>
1397
+ )}
1398
+
1399
+ {/* Contextual action hint */}
1400
+ {selected && selected.status !== "checking" && selected.status !== "pending" && (
1401
+ <box paddingLeft={2} paddingTop={0} flexShrink={0}>
1402
+ <text content={
1403
+ selected.status === "available" && registrarConfig?.apiKey
1404
+ ? "\u2192 Press r to register | p to add to portfolio | M then l to list for sale"
1405
+ : selected.status === "available"
1406
+ ? "\u2192 Press p to add to portfolio | M then l to list for sale"
1407
+ : selected.status === "expired"
1408
+ ? "\u2192 Press D for drop catch | w to watch for availability"
1409
+ : selected.status === "taken"
1410
+ ? "\u2192 Press d for alternatives | v for variations | Tab for more intel"
1411
+ : selected.status === "registered"
1412
+ ? "\u2192 Press p to add to portfolio | M then l to list for sale"
1413
+ : selected.status === "error"
1414
+ ? "\u2192 Press c to clear cache and rescan"
1415
+ : ""
1416
+ } fg={theme.textDisabled} />
1417
+ </box>
1418
+ )}
1419
+
1420
+ {/* One-line summary */}
1421
+ {selected && selected.status !== "checking" && selected.status !== "pending" && (
1422
+ <box paddingLeft={1} paddingTop={1} paddingBottom={1} flexShrink={0} flexDirection="row" gap={1} flexWrap="wrap">
1423
+ {selected.status === "available" && <text content="AVAILABLE" fg={theme.primary} />}
1424
+ {selected.status === "taken" && <text content="TAKEN" fg={theme.error} />}
1425
+ {selected.status === "expired" && <text content="EXPIRED" fg={theme.warning} />}
1426
+ {selected.httpProbe?.parked && <text content="| Parked" fg={theme.warning} />}
1427
+ {selected.httpProbe?.reachable && !selected.httpProbe?.parked && <text content="| Live" fg={theme.primary} />}
1428
+ {selected.ssl && !selected.ssl.error && selected.ssl.valid && <text content="| SSL OK" fg={theme.primary} />}
1429
+ {selected.ssl && !selected.ssl.error && !selected.ssl.valid && <text content="| SSL Bad" fg={theme.error} />}
1430
+ {selected.blacklist?.listed && <text content="| BLACKLISTED" fg={theme.error} />}
1431
+ {selected.blacklist && !selected.blacklist.listed && <text content="| Clean" fg={theme.textDisabled} />}
1432
+ {selected.waf?.detected && <text content={`| WAF: ${selected.waf.waf}`} fg={theme.textDisabled} />}
1433
+ {selected.techStack?.cms && <text content={`| ${selected.techStack.cms}`} fg={theme.textDisabled} />}
1434
+ {selected.domainAge && <text content={`| ${selected.domainAge} old`} fg={theme.textDisabled} />}
1435
+ </box>
1436
+ )}
1437
+
1438
+ {/* WHOIS */}
1439
+ {selected.whois && !selected.whois.available && (
1440
+ <box flexDirection="column" paddingLeft={1}>
1441
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.secondary} /><text content="WHOIS" fg={theme.secondary} /></box>
1442
+ {selected.whois.registrar && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Registrar", 12)} fg={theme.textMuted} /><text content={selected.whois.registrar} fg={theme.text} /></box>)}
1443
+ {selected.whois.createdDate && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Created", 12)} fg={theme.textMuted} /><text content={selected.whois.createdDate} fg={theme.text} /></box>)}
1444
+ {selected.whois.expiryDate && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Expires", 12)} fg={theme.textMuted} /><text content={selected.whois.expiryDate} fg={selected.whois.expired ? theme.error : theme.text} /></box>)}
1445
+ {selected.whois.expiryDate && (() => {
1446
+ const daysLeft = daysUntilExpiry(selected.whois!.expiryDate);
1447
+ return daysLeft !== null ? (
1448
+ <box flexDirection="row" gap={1}>
1449
+ <text content="┃" fg={theme.borderSubtle} />
1450
+ <text content={pad("Expires in", 12)} fg={theme.textMuted} />
1451
+ <text content={`${daysLeft}d`} fg={daysLeft < 30 ? theme.error : daysLeft < 90 ? theme.warning : theme.textSecondary} />
1452
+ </box>
1453
+ ) : null;
1454
+ })()}
1455
+ {selected.whois.nameServers.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("NS", 12)} fg={theme.textMuted} /><text content={selected.whois.nameServers.slice(0, 2).join(", ")} fg={theme.textSecondary} /></box>)}
1456
+ <text content="" />
1457
+ </box>
1458
+ )}
1459
+
1460
+ {/* Verification */}
1461
+ {selected.verification && (
1462
+ <box flexDirection="column" paddingLeft={1}>
1463
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.primary} /><text content="VERIFICATION" fg={theme.primary} /></box>
1464
+ {selected.verification.checks.map((c: string, i: number) => (
1465
+ <box key={i} flexDirection="row" gap={1}>
1466
+ <text content="┃" fg={theme.borderSubtle} />
1467
+ <text content={c} fg={c.startsWith("✓") ? theme.primary : c.startsWith("✗") ? theme.error : theme.warning} />
1468
+ </box>
1469
+ ))}
1470
+ <text content="" />
1471
+ </box>
1472
+ )}
1473
+
1474
+ {/* Registrar */}
1475
+ {selected.registrarCheck && (
1476
+ <box flexDirection="column" paddingLeft={1}>
1477
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.accent} /><text content="REGISTRAR" fg={theme.accent} /></box>
1478
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Available", 12)} fg={theme.textMuted} /><text content={selected.registrarCheck.available ? "Yes" : "No"} fg={selected.registrarCheck.available ? theme.primary : theme.error} /></box>
1479
+ {selected.registrarCheck.price && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Price", 12)} fg={theme.textMuted} /><text content={`$${selected.registrarCheck.price}`} fg={theme.warning} /></box>)}
1480
+ <text content="" />
1481
+ </box>
1482
+ )}
1483
+
1484
+ {/* Marketplace */}
1485
+ {selected.marketplace && selected.marketplace.length > 0 && (
1486
+ <box flexDirection="column" paddingLeft={1}>
1487
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.warning} /><text content="MARKETPLACE" fg={theme.warning} /></box>
1488
+ {selected.marketplace.map((m) => (
1489
+ <box key={m.source} flexDirection="row" gap={1}>
1490
+ <text content="┃" fg={theme.borderSubtle} />
1491
+ <text content={pad(m.source, 12)} fg={theme.textMuted} />
1492
+ <text content={m.price ? `$${m.price}` : m.listed ? "Listed" : "—"} fg={m.price ? theme.warning : theme.textDisabled} />
1493
+ </box>
1494
+ ))}
1495
+ <text content="" />
1496
+ </box>
1497
+ )}
1498
+
1499
+ {/* Social Media */}
1500
+ {selected.socialMedia && selected.socialMedia.length > 0 && (() => {
1501
+ const avail = selected.socialMedia!.filter((s) => s.available && !s.error);
1502
+ const taken = selected.socialMedia!.filter((s) => !s.available && !s.error);
1503
+ return (
1504
+ <box flexDirection="column" paddingLeft={1}>
1505
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.info} /><text content={`SOCIAL MEDIA (${avail.length} avail / ${taken.length} taken)`} fg={theme.info} /></box>
1506
+ {avail.slice(0, 5).map((s) => (
1507
+ <box key={s.platform} flexDirection="row" gap={1}>
1508
+ <text content="┃" fg={theme.borderSubtle} />
1509
+ <text content="●" fg={theme.primary} />
1510
+ <text content={s.platform} fg={theme.primary} />
1511
+ </box>
1512
+ ))}
1513
+ {taken.slice(0, 4).map((s) => (
1514
+ <box key={s.platform} flexDirection="row" gap={1}>
1515
+ <text content="┃" fg={theme.borderSubtle} />
1516
+ <text content="✕" fg={theme.textDisabled} />
1517
+ <text content={s.platform} fg={theme.textDisabled} />
1518
+ </box>
1519
+ ))}
1520
+ <text content="" />
1521
+ </box>
1522
+ );
1523
+ })()}
1524
+
1525
+ {/* WHOIS History */}
1526
+ {(() => {
1527
+ const histCount = getHistoryCount(selected.domain);
1528
+ return histCount > 1 ? <text content={`${histCount} snapshots`} fg={theme.textDisabled} /> : null;
1529
+ })()}
1530
+
1531
+ {/* Registration */}
1532
+ {selected.registration && (
1533
+ <box flexDirection="column" paddingLeft={1}>
1534
+ <box flexDirection="row" gap={1}><text content="┃" fg={selected.registration.success ? theme.secondary : theme.error} /><text content="REGISTRATION" fg={selected.registration.success ? theme.secondary : theme.error} /></box>
1535
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={selected.registration.message} fg={theme.text} /></box>
1536
+ </box>
1537
+ )}
1538
+
1539
+ {/* Score details */}
1540
+ {score && score.breakdown.length > 0 && (
1541
+ <box flexDirection="column" paddingLeft={1}>
1542
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.warning} /><text content="SCORE FACTORS" fg={theme.warning} /></box>
1543
+ {score.breakdown.map((b: string, i: number) => (
1544
+ <box key={i} flexDirection="row" gap={1}>
1545
+ <text content="┃" fg={theme.borderSubtle} />
1546
+ <text content={`· ${b}`} fg={theme.textSecondary} />
1547
+ </box>
1548
+ ))}
1549
+ </box>
1550
+ )}
1551
+
1552
+ {selected.error && (
1553
+ <box flexDirection="column" paddingLeft={1}>
1554
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.error} /><text content="ERROR" fg={theme.error} /></box>
1555
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={selected.error} fg={theme.error} /></box>
1556
+ </box>
1557
+ )}
1558
+ </>
1559
+ )}
1560
+
1561
+ {/* ══ DNS TAB ══ */}
1562
+ {intelTab === "dns" && (
1563
+ <>
1564
+ {/* DNS Details */}
1565
+ {selected.dns && (selected.dns.a.length > 0 || selected.dns.mx.length > 0 || selected.dns.txt.length > 0 || selected.dns.cname.length > 0) && (
1566
+ <box flexDirection="column" paddingLeft={1}>
1567
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.info} /><text content="DNS RECORDS" fg={theme.info} /></box>
1568
+ {selected.dns.a.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("A", 12)} fg={theme.textMuted} /><text content={selected.dns.a.join(", ")} fg={theme.text} /></box>)}
1569
+ {selected.dns.aaaa.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("AAAA", 12)} fg={theme.textMuted} /><text content={selected.dns.aaaa.join(", ")} fg={theme.text} /></box>)}
1570
+ {selected.dns.mx.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("MX", 12)} fg={theme.textMuted} /><text content={selected.dns.mx.slice(0, 3).join(", ")} fg={theme.text} /></box>)}
1571
+ {selected.dns.txt.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("TXT", 12)} fg={theme.textMuted} /><text content={selected.dns.txt.slice(0, 2).join(", ")} fg={theme.textSecondary} /></box>)}
1572
+ {selected.dns.cname.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("CNAME", 12)} fg={theme.textMuted} /><text content={selected.dns.cname.join(", ")} fg={theme.text} /></box>)}
1573
+ <text content="" />
1574
+ </box>
1575
+ )}
1576
+
1577
+ {/* RDAP */}
1578
+ {selected.rdap && !selected.rdap.error && (
1579
+ <box flexDirection="column" paddingLeft={1}>
1580
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.info} /><text content="RDAP" fg={theme.info} /></box>
1581
+ {selected.rdap.registrar && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Registrar", 12)} fg={theme.textMuted} /><text content={selected.rdap.registrar} fg={theme.text} /></box>)}
1582
+ {selected.rdap.status.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Status", 12)} fg={theme.textMuted} /><text content={selected.rdap.status.slice(0, 3).join(", ")} fg={theme.textSecondary} /></box>)}
1583
+ <text content="" />
1584
+ </box>
1585
+ )}
1586
+
1587
+ {/* Subdomains */}
1588
+ {selected.subdomains && (() => {
1589
+ const active = selected.subdomains!.filter((s) => s.resolved);
1590
+ return active.length > 0 ? (
1591
+ <box flexDirection="column" paddingLeft={1}>
1592
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.secondary} /><text content={`SUBDOMAINS (${active.length} found)`} fg={theme.secondary} /></box>
1593
+ {active.slice(0, 8).map((s) => (
1594
+ <box key={s.subdomain} flexDirection="row" gap={1}>
1595
+ <text content="┃" fg={theme.borderSubtle} />
1596
+ <text content={pad(s.subdomain, 12)} fg={theme.textMuted} />
1597
+ <text content={s.ip || ""} fg={theme.textSecondary} />
1598
+ </box>
1599
+ ))}
1600
+ {active.length > 8 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={` +${active.length - 8} more`} fg={theme.textDisabled} /></box>)}
1601
+ <text content="" />
1602
+ </box>
1603
+ ) : null;
1604
+ })()}
1605
+
1606
+ {/* Cert Transparency (recon) */}
1607
+ {selected.certTransparency && selected.certTransparency.subdomains.length > 0 && (
1608
+ <box flexDirection="column" paddingLeft={1}>
1609
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.secondary} /><text content={`CERT TRANSPARENCY (${selected.certTransparency.subdomains.length} subdomains, ${selected.certTransparency.totalCerts} certs)`} fg={theme.secondary} /></box>
1610
+ {selected.certTransparency.subdomains.slice(0, 8).map((s) => (
1611
+ <box key={s} flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={s} fg={theme.textSecondary} /></box>
1612
+ ))}
1613
+ {selected.certTransparency.subdomains.length > 8 && <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={` +${selected.certTransparency.subdomains.length - 8} more`} fg={theme.textDisabled} /></box>}
1614
+ <text content="" />
1615
+ </box>
1616
+ )}
1617
+
1618
+ {/* Email Security (recon) */}
1619
+ {selected.emailSecurity && (
1620
+ <box flexDirection="column" paddingLeft={1}>
1621
+ <box flexDirection="row" gap={1}><text content="┃" fg={selected.emailSecurity.grade <= "B" ? theme.primary : theme.error} /><text content={`EMAIL SECURITY (${selected.emailSecurity.grade})`} fg={selected.emailSecurity.grade <= "B" ? theme.primary : theme.error} /></box>
1622
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("SPF", 12)} fg={theme.textMuted} /><text content={selected.emailSecurity.spf.found ? "Found" : "Missing"} fg={selected.emailSecurity.spf.found ? theme.primary : theme.error} /></box>
1623
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("DKIM", 12)} fg={theme.textMuted} /><text content={selected.emailSecurity.dkim.found ? `Found (${selected.emailSecurity.dkim.selector})` : "Missing"} fg={selected.emailSecurity.dkim.found ? theme.primary : theme.error} /></box>
1624
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("DMARC", 12)} fg={theme.textMuted} /><text content={selected.emailSecurity.dmarc.found ? `p=${selected.emailSecurity.dmarc.policy || "?"}` : "Missing"} fg={selected.emailSecurity.dmarc.found ? theme.primary : theme.error} /></box>
1625
+ {selected.emailSecurity.issues.slice(0, 3).map((issue, i) => (
1626
+ <box key={i} flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={`! ${issue}`} fg={theme.warning} /></box>
1627
+ ))}
1628
+ <text content="" />
1629
+ </box>
1630
+ )}
1631
+
1632
+ {!selected.dns && !selected.rdap && !selected.subdomains && !selected.certTransparency && !selected.emailSecurity && (
1633
+ <box paddingLeft={2} paddingTop={2}><text content="No DNS data available" fg={theme.textDisabled} /></box>
1634
+ )}
1635
+ </>
1636
+ )}
1637
+
1638
+ {/* ══ SECURITY TAB ══ */}
1639
+ {intelTab === "security" && (
1640
+ <>
1641
+ {/* SSL Certificate */}
1642
+ {selected.ssl && !selected.ssl.error && (
1643
+ <box flexDirection="column" paddingLeft={1}>
1644
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.primary} /><text content="SSL CERTIFICATE" fg={theme.primary} /></box>
1645
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Valid", 12)} fg={theme.textMuted} /><text content={selected.ssl.valid ? "Yes" : "No"} fg={selected.ssl.valid ? theme.primary : theme.error} /></box>
1646
+ {selected.ssl.issuer && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Issuer", 12)} fg={theme.textMuted} /><text content={selected.ssl.issuer} fg={theme.text} /></box>)}
1647
+ {selected.ssl.daysUntilExpiry !== null && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Cert Expiry", 12)} fg={theme.textMuted} /><text content={`${selected.ssl.daysUntilExpiry}d`} fg={selected.ssl.daysUntilExpiry < 30 ? theme.error : theme.textSecondary} /></box>)}
1648
+ {selected.ssl.protocol && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Protocol", 12)} fg={theme.textMuted} /><text content={selected.ssl.protocol} fg={theme.textSecondary} /></box>)}
1649
+ {selected.ssl.sans.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("SANs", 12)} fg={theme.textMuted} /><text content={selected.ssl.sans.slice(0, 3).join(", ")} fg={theme.textSecondary} /></box>)}
1650
+ <text content="" />
1651
+ </box>
1652
+ )}
1653
+
1654
+ {/* Blacklist */}
1655
+ {selected.blacklist && (
1656
+ <box flexDirection="column" paddingLeft={1}>
1657
+ <box flexDirection="row" gap={1}>
1658
+ <text content="┃" fg={selected.blacklist.listed ? theme.error : theme.primary} />
1659
+ <text content={selected.blacklist.listed ? `BLACKLISTED (${selected.blacklist.listedCount})` : `REPUTATION (${selected.blacklist.cleanCount}/${selected.blacklist.lists.length} clean)`} fg={selected.blacklist.listed ? theme.error : theme.primary} />
1660
+ </box>
1661
+ {selected.blacklist.listed && selected.blacklist.lists.filter((l) => l.listed).map((l) => (
1662
+ <box key={l.name} flexDirection="row" gap={1}>
1663
+ <text content="┃" fg={theme.borderSubtle} />
1664
+ <text content="⚠" fg={theme.error} />
1665
+ <text content={l.name} fg={theme.error} />
1666
+ {l.detail && <text content={l.detail} fg={theme.textDisabled} />}
1667
+ </box>
1668
+ ))}
1669
+ <text content="" />
1670
+ </box>
1671
+ )}
1672
+
1673
+ {/* Tech Stack */}
1674
+ {selected.techStack && selected.techStack.technologies.length > 0 && (
1675
+ <box flexDirection="column" paddingLeft={1}>
1676
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.accent} /><text content={`TECH STACK (${selected.techStack.technologies.length})`} fg={theme.accent} /></box>
1677
+ {selected.techStack.cms && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("CMS", 12)} fg={theme.textMuted} /><text content={selected.techStack.cms} fg={theme.text} /></box>)}
1678
+ {selected.techStack.framework && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Framework", 12)} fg={theme.textMuted} /><text content={selected.techStack.framework} fg={theme.text} /></box>)}
1679
+ {selected.techStack.cdn && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("CDN", 12)} fg={theme.textMuted} /><text content={selected.techStack.cdn} fg={theme.text} /></box>)}
1680
+ {selected.techStack.analytics.length > 0 && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Analytics", 12)} fg={theme.textMuted} /><text content={selected.techStack.analytics.join(", ")} fg={theme.textSecondary} /></box>)}
1681
+ {selected.techStack.technologies.filter((t) => !["CMS", "Framework", "CDN", "Analytics"].includes(t.category)).slice(0, 4).map((t) => (
1682
+ <box key={t.name} flexDirection="row" gap={1}>
1683
+ <text content="┃" fg={theme.borderSubtle} />
1684
+ <text content={pad(t.category, 12)} fg={theme.textMuted} />
1685
+ <text content={t.name} fg={theme.textSecondary} />
1686
+ </box>
1687
+ ))}
1688
+ <text content="" />
1689
+ </box>
1690
+ )}
1691
+
1692
+ {/* Backlinks / Authority */}
1693
+ {selected.backlinks && (selected.backlinks.pageRank !== null || selected.backlinks.commonCrawlPages !== null) && (
1694
+ <box flexDirection="column" paddingLeft={1}>
1695
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.secondary} /><text content="AUTHORITY" fg={theme.secondary} /></box>
1696
+ {selected.backlinks.pageRank !== null && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("PageRank", 12)} fg={theme.textMuted} /><text content={`${selected.backlinks.pageRank}`} fg={selected.backlinks.pageRank >= 5 ? theme.primary : theme.text} /></box>)}
1697
+ {selected.backlinks.commonCrawlPages !== null && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("CC Pages", 12)} fg={theme.textMuted} /><text content={`~${selected.backlinks.commonCrawlPages}`} fg={theme.textSecondary} /></box>)}
1698
+ <text content="" />
1699
+ </box>
1700
+ )}
1701
+
1702
+ {/* Security Headers (recon) */}
1703
+ {selected.securityHeaders && !selected.securityHeaders.error && (
1704
+ <box flexDirection="column" paddingLeft={1}>
1705
+ <box flexDirection="row" gap={1}><text content="┃" fg={selected.securityHeaders.grade <= "B" ? theme.primary : theme.error} /><text content={`SECURITY HEADERS (${selected.securityHeaders.grade} — ${selected.securityHeaders.score}/100)`} fg={selected.securityHeaders.grade <= "B" ? theme.primary : theme.error} /></box>
1706
+ {selected.securityHeaders.missing.slice(0, 4).map((h) => (
1707
+ <box key={h} flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={`x ${h}`} fg={theme.error} /></box>
1708
+ ))}
1709
+ {selected.securityHeaders.headers.filter((h) => h.status === "good").slice(0, 3).map((h) => (
1710
+ <box key={h.name} flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={`+ ${h.name}`} fg={theme.primary} /></box>
1711
+ ))}
1712
+ <text content="" />
1713
+ </box>
1714
+ )}
1715
+
1716
+ {/* WAF (recon) */}
1717
+ {selected.waf && (
1718
+ <box flexDirection="column" paddingLeft={1}>
1719
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.accent} /><text content={selected.waf.detected ? `WAF: ${selected.waf.waf} (${selected.waf.confidence})` : "WAF: None detected"} fg={selected.waf.detected ? theme.accent : theme.textDisabled} /></box>
1720
+ <text content="" />
1721
+ </box>
1722
+ )}
1723
+
1724
+ {/* CORS (recon) */}
1725
+ {selected.cors && selected.cors.vulnerable && (
1726
+ <box flexDirection="column" paddingLeft={1}>
1727
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.error} /><text content="!! CORS MISCONFIGURATION" fg={theme.error} /></box>
1728
+ {selected.cors.findings.filter((f) => f.allowed).slice(0, 3).map((f, i) => (
1729
+ <box key={i} flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={f.detail} fg={theme.error} /></box>
1730
+ ))}
1731
+ <text content="" />
1732
+ </box>
1733
+ )}
1734
+
1735
+ {/* Zone Transfer (recon) */}
1736
+ {selected.zoneTransfer && selected.zoneTransfer.vulnerable && (
1737
+ <box flexDirection="column" paddingLeft={1}>
1738
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.error} /><text content="!! ZONE TRANSFER VULNERABLE" fg={theme.error} /></box>
1739
+ {selected.zoneTransfer.vulnerableNs.map((ns) => (
1740
+ <box key={ns} flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={ns} fg={theme.error} /></box>
1741
+ ))}
1742
+ <text content="" />
1743
+ </box>
1744
+ )}
1745
+
1746
+ {/* Takeover Detection (recon) */}
1747
+ {selected.takeover && selected.takeover.vulnerable && (
1748
+ <box flexDirection="column" paddingLeft={1}>
1749
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.error} /><text content="!! SUBDOMAIN TAKEOVER" fg={theme.error} /></box>
1750
+ {selected.takeover.findings.filter((f) => f.status === "vulnerable").map((f) => (
1751
+ <box key={f.subdomain} flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={`${f.subdomain} -> ${f.service}`} fg={theme.error} /></box>
1752
+ ))}
1753
+ <text content="" />
1754
+ </box>
1755
+ )}
1756
+
1757
+ {/* Path Scanner (recon) */}
1758
+ {selected.pathScan && selected.pathScan.findings.length > 0 && (
1759
+ <box flexDirection="column" paddingLeft={1}>
1760
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.error} /><text content={`EXPOSED PATHS (${selected.pathScan.findings.length})`} fg={theme.error} /></box>
1761
+ {selected.pathScan.findings.slice(0, 8).map((f) => (
1762
+ <box key={f.path} flexDirection="row" gap={1}>
1763
+ <text content="┃" fg={theme.borderSubtle} />
1764
+ <text content={f.severity === "critical" ? "!!" : f.severity === "high" ? "! " : ". "} fg={f.severity === "critical" ? theme.error : f.severity === "high" ? theme.warning : theme.textMuted} />
1765
+ <text content={f.path} fg={f.severity === "critical" ? theme.error : theme.text} />
1766
+ <text content={`${f.status}`} fg={theme.textDisabled} />
1767
+ </box>
1768
+ ))}
1769
+ <text content="" />
1770
+ </box>
1771
+ )}
1772
+
1773
+ {!selected.ssl && !selected.blacklist && !selected.techStack && !selected.backlinks && !selected.securityHeaders && !selected.waf && !selected.cors && !selected.zoneTransfer && !selected.takeover && !selected.pathScan && (
1774
+ <box paddingLeft={2} paddingTop={2}><text content="No security data available" fg={theme.textDisabled} /></box>
1775
+ )}
1776
+ </>
1777
+ )}
1778
+
1779
+ {/* ══ RECON TAB ══ */}
1780
+ {intelTab === "recon" && (
1781
+ <>
1782
+ {/* Port Scan */}
1783
+ {selected.portScan && selected.portScan.openPorts.length > 0 && (
1784
+ <box flexDirection="column" paddingLeft={1}>
1785
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.error} /><text content={`OPEN PORTS (${selected.portScan.openPorts.length})`} fg={theme.error} /></box>
1786
+ {selected.portScan.openPorts.slice(0, 10).map((p) => (
1787
+ <box key={p.port} flexDirection="row" gap={1}>
1788
+ <text content="┃" fg={theme.borderSubtle} />
1789
+ <text content={pad(String(p.port), 6)} fg={theme.warning} />
1790
+ <text content={pad(p.service, 12)} fg={theme.text} />
1791
+ {p.banner && <text content={p.banner.slice(0, 40)} fg={theme.textDisabled} />}
1792
+ </box>
1793
+ ))}
1794
+ {selected.portScan.ip && <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={`IP: ${selected.portScan.ip} (${selected.portScan.scanTime}ms)`} fg={theme.textDisabled} /></box>}
1795
+ <text content="" />
1796
+ </box>
1797
+ )}
1798
+
1799
+ {/* ASN / Network */}
1800
+ {selected.asn && !selected.asn.error && (
1801
+ <box flexDirection="column" paddingLeft={1}>
1802
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.info} /><text content="NETWORK" fg={theme.info} /></box>
1803
+ {selected.asn.asn && <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("ASN", 12)} fg={theme.textMuted} /><text content={`${selected.asn.asn}${selected.asn.asnName ? ` (${selected.asn.asnName})` : ""}`} fg={theme.text} /></box>}
1804
+ {selected.asn.org && <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Org", 12)} fg={theme.textMuted} /><text content={selected.asn.org} fg={theme.text} /></box>}
1805
+ {selected.asn.country && <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Location", 12)} fg={theme.textMuted} /><text content={`${selected.asn.city || ""}${selected.asn.city && selected.asn.country ? ", " : ""}${selected.asn.country}`} fg={theme.textSecondary} /></box>}
1806
+ <text content="" />
1807
+ </box>
1808
+ )}
1809
+
1810
+ {/* Reverse IP */}
1811
+ {selected.reverseIp && selected.reverseIp.sharedDomains.length > 0 && (
1812
+ <box flexDirection="column" paddingLeft={1}>
1813
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.warning} /><text content={`SHARED HOSTING (${selected.reverseIp.sharedDomains.length} domains on ${selected.reverseIp.ip})`} fg={theme.warning} /></box>
1814
+ {selected.reverseIp.sharedDomains.slice(0, 6).map((d) => (
1815
+ <box key={d} flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={d} fg={theme.textSecondary} /></box>
1816
+ ))}
1817
+ {selected.reverseIp.sharedDomains.length > 6 && <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={` +${selected.reverseIp.sharedDomains.length - 6} more`} fg={theme.textDisabled} /></box>}
1818
+ <text content="" />
1819
+ </box>
1820
+ )}
1821
+
1822
+ {/* HTTP Probe */}
1823
+ {selected.httpProbe && (
1824
+ <box flexDirection="column" paddingLeft={1}>
1825
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.pending} /><text content="HTTP PROBE" fg={theme.pending} /></box>
1826
+ {selected.httpProbe.reachable ? (
1827
+ <>
1828
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Status", 12)} fg={theme.textMuted} /><text content={`${selected.httpProbe.status}`} fg={selected.httpProbe.status === 200 ? theme.primary : theme.warning} /></box>
1829
+ {selected.httpProbe.server && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Server", 12)} fg={theme.textMuted} /><text content={selected.httpProbe.server} fg={theme.textSecondary} /></box>)}
1830
+ {selected.httpProbe.redirectUrl && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Redirect", 12)} fg={theme.textMuted} /><text content={selected.httpProbe.redirectUrl} fg={theme.warning} /></box>)}
1831
+ {selected.httpProbe.parked && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content="⚠ PARKED DOMAIN" fg={theme.error} /></box>)}
1832
+ </>
1833
+ ) : (
1834
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content="Unreachable" fg={theme.textDisabled} /></box>
1835
+ )}
1836
+ <text content="" />
1837
+ </box>
1838
+ )}
1839
+
1840
+ {/* Wayback Machine */}
1841
+ {selected.wayback && selected.wayback.hasHistory && (
1842
+ <box flexDirection="column" paddingLeft={1}>
1843
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.accent} /><text content="WAYBACK MACHINE" fg={theme.accent} /></box>
1844
+ <box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Snapshots", 12)} fg={theme.textMuted} /><text content={`~${selected.wayback.snapshots} pages`} fg={theme.text} /></box>
1845
+ {selected.wayback.firstArchived && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("First", 12)} fg={theme.textMuted} /><text content={selected.wayback.firstArchived} fg={theme.textSecondary} /></box>)}
1846
+ {selected.wayback.lastArchived && (<box flexDirection="row" gap={1}><text content="┃" fg={theme.borderSubtle} /><text content={pad("Last", 12)} fg={theme.textMuted} /><text content={selected.wayback.lastArchived} fg={theme.textSecondary} /></box>)}
1847
+ <text content="" />
1848
+ </box>
1849
+ )}
1850
+
1851
+ {!selected.portScan && !selected.asn && !selected.reverseIp && !selected.httpProbe && !selected.wayback && (
1852
+ <box paddingLeft={2} paddingTop={2}><text content={reconMode ? "No recon data yet — rescan to collect" : "Enable recon mode (n) and rescan"} fg={theme.textDisabled} /></box>
1853
+ )}
1854
+ </>
1855
+ )}
1856
+
1857
+ {(selected.status === "pending") && <box paddingLeft={2} paddingTop={2}><text content="Queued..." fg={theme.textDisabled} /></box>}
1858
+ {(selected.status === "checking") && <box paddingLeft={2} paddingTop={2}><text content="Scanning..." fg={theme.warning} /></box>}
1859
+ </box>
1860
+ </scrollbox>
1861
+ ) : (
1862
+ <box flexGrow={1} alignItems="center" justifyContent="center" minHeight={0}>
1863
+ {(() => {
1864
+ const dbStats = (() => { try { return getDbStats(); } catch { return null; } })();
1865
+ const portfolio = (() => { try { return getPortfolioDashboard(); } catch { return null; } })();
1866
+ const expiring = (() => { try { return getPortfolioExpiring(30); } catch { return []; } })();
1867
+ const recentDomains = (() => { try { return getAllDomains(5, 0); } catch { return []; } })();
1868
+ const alerts = (() => { try { return getUnacknowledgedAlerts(); } catch { return []; } })();
1869
+ const hasData = dbStats && dbStats.totalScans > 0;
1870
+ const cw = Math.min(56, width - 10);
1871
+ const sep = ` ${" ".repeat(2)}${"─".repeat(cw)}`;
1872
+
1873
+ return (
1874
+ <box flexDirection="column" alignItems="center">
1875
+ {/* Logo */}
1876
+ <text content="" />
1877
+ <box flexDirection="row" gap={1} justifyContent="center">
1878
+ <text content="◆" fg={theme.primary} />
1879
+ <text content="DOMAIN SNIPER" fg={theme.primary} />
1880
+ </box>
1881
+ <box justifyContent="center">
1882
+ <text content="Domain Intelligence & Security Recon" fg={theme.textDisabled} />
1883
+ </box>
1884
+ <text content="" />
1885
+
1886
+ {/* Stats */}
1887
+ {hasData && (
1888
+ <>
1889
+ <text content={sep} fg={theme.borderSubtle} />
1890
+ <text content="" />
1891
+ <box flexDirection="row" justifyContent="center" gap={4}>
1892
+ <box flexDirection="column" alignItems="center">
1893
+ <text content={`${dbStats.totalDomains}`} fg={theme.primary} />
1894
+ <text content="domains" fg={theme.textDisabled} />
1895
+ </box>
1896
+ <box flexDirection="column" alignItems="center">
1897
+ <text content={`${dbStats.totalScans}`} fg={theme.primary} />
1898
+ <text content="scans" fg={theme.textDisabled} />
1899
+ </box>
1900
+ {portfolio && portfolio.totalDomains > 0 ? (
1901
+ <box flexDirection="column" alignItems="center">
1902
+ <text content={`$${portfolio.totalValue.toFixed(0)}`} fg={theme.warning} />
1903
+ <text content="portfolio" fg={theme.textDisabled} />
1904
+ </box>
1905
+ ) : (
1906
+ <box flexDirection="column" alignItems="center">
1907
+ <text content={`${dbStats.totalSessions}`} fg={theme.info} />
1908
+ <text content="sessions" fg={theme.textDisabled} />
1909
+ </box>
1910
+ )}
1911
+ </box>
1912
+ <text content="" />
1913
+ </>
1914
+ )}
1915
+
1916
+ {/* Alerts / Expiring */}
1917
+ {alerts.length > 0 && (
1918
+ <>
1919
+ {alerts.slice(0, 2).map((a: any) => (
1920
+ <box key={a.id} justifyContent="center">
1921
+ <text content={`${a.severity === "critical" ? "!!" : " !"} ${a.domain}: ${a.message}`} fg={a.severity === "critical" ? theme.error : theme.warning} />
1922
+ </box>
1923
+ ))}
1924
+ <text content="" />
1925
+ </>
1926
+ )}
1927
+ {expiring.length > 0 && !alerts.length && (
1928
+ <>
1929
+ <box justifyContent="center">
1930
+ <text content={`⚠ ${expiring.length} domain${expiring.length > 1 ? "s" : ""} expiring within 30 days`} fg={theme.warning} />
1931
+ </box>
1932
+ <text content="" />
1933
+ </>
1934
+ )}
1935
+
1936
+ {/* Recent scans */}
1937
+ {recentDomains.length > 0 && (
1938
+ <>
1939
+ <text content={sep} fg={theme.borderSubtle} />
1940
+ <text content="" />
1941
+ <box justifyContent="center">
1942
+ <text content="RECENT" fg={theme.textMuted} />
1943
+ </box>
1944
+ {recentDomains.slice(0, 3).map((d: any) => (
1945
+ <box key={d.domain} justifyContent="center" flexDirection="row" gap={1}>
1946
+ <text content={d.domain} fg={theme.textSecondary} />
1947
+ {d.scan_count > 0 && <text content={`${d.scan_count}x`} fg={theme.textDisabled} />}
1948
+ </box>
1949
+ ))}
1950
+ <text content="" />
1951
+ </>
1952
+ )}
1953
+
1954
+ {/* First run */}
1955
+ {!hasData && (
1956
+ <>
1957
+ <text content={sep} fg={theme.borderSubtle} />
1958
+ <text content="" />
1959
+ <box justifyContent="center">
1960
+ <text content="Press / to scan your first domain" fg={theme.textSecondary} />
1961
+ </box>
1962
+ <text content="" />
1963
+ </>
1964
+ )}
1965
+
1966
+ {/* Shortcuts */}
1967
+ <text content={sep} fg={theme.borderSubtle} />
1968
+ <text content="" />
1969
+ <box flexDirection="row" justifyContent="center" gap={3}>
1970
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.primaryDim}><text content=" / " fg={theme.primary} /></box><text content="scan" fg={theme.textSecondary} /></box>
1971
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.primaryDim}><text content=" e " fg={theme.primary} /></box><text content="expand" fg={theme.textSecondary} /></box>
1972
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.primaryDim}><text content=" f " fg={theme.primary} /></box><text content="file" fg={theme.textSecondary} /></box>
1973
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.primaryDim}><text content=" ? " fg={theme.primary} /></box><text content="help" fg={theme.textSecondary} /></box>
1974
+ </box>
1975
+ <box flexDirection="row" justifyContent="center" gap={3}>
1976
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.secondaryDim}><text content=" M " fg={theme.secondary} /></box><text content="market" fg={theme.textSecondary} /></box>
1977
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.secondaryDim}><text content=" P " fg={theme.secondary} /></box><text content="portfolio" fg={theme.textSecondary} /></box>
1978
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.accentDim}><text content=" n " fg={theme.accent} /></box><text content="recon" fg={theme.textSecondary} /></box>
1979
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.errorDim}><text content=" q " fg={theme.error} /></box><text content="quit" fg={theme.textSecondary} /></box>
1980
+ </box>
1981
+ <text content="" />
1982
+
1983
+ {/* Mode indicators */}
1984
+ <box flexDirection="row" justifyContent="center" gap={2}>
1985
+ <text content={reconMode ? "● recon" : "○ recon"} fg={reconMode ? theme.warning : theme.textDisabled} />
1986
+ <text content={registrarConfig?.apiKey ? `● ${registrarConfig.provider}` : "○ registrar"} fg={registrarConfig?.apiKey ? theme.secondary : theme.textDisabled} />
1987
+ <text content={isLoggedIn() ? `● ${getAuthInfo()?.name}` : "○ market"} fg={isLoggedIn() ? theme.primary : theme.textDisabled} />
1988
+ </box>
1989
+ </box>
1990
+ );
1991
+ })()}
1992
+ </box>
1993
+ )}
1994
+ </box>
1995
+ </>
1996
+ )}
1997
+ </box>
1998
+
1999
+ {/* ═══ INPUT BAR ═══ */}
2000
+ {(mode === "input" || marketInputMode !== "none") && (
2001
+ <box flexShrink={0} flexDirection="row" paddingLeft={1} paddingRight={1} backgroundColor={theme.backgroundPanel} gap={1}>
2002
+ <box backgroundColor={theme.info}><text content={` ${inputLabel} `} fg={theme.background} /></box>
2003
+ <input
2004
+ focused value={inputValue}
2005
+ placeholder={inputPlaceholder}
2006
+ placeholderColor={theme.textPlaceholder} cursorColor={theme.primary}
2007
+ focusedTextColor={theme.text} focusedBackgroundColor={theme.backgroundPanel}
2008
+ width={width - inputLabel.length - 6}
2009
+ onChange={(v: string) => setInputValue(v)}
2010
+ // opentui input onSubmit type workaround
2011
+ onSubmit={((v: any) => handleSubmit(String(v))) as any}
2012
+ />
2013
+ </box>
2014
+ )}
2015
+
2016
+ {/* ═══ FOOTER ═══ */}
2017
+ <box flexShrink={0} flexDirection="column">
2018
+ <text content={dLine(width)} fg={theme.border} paddingLeft={1} />
2019
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1}>
2020
+ <box flexDirection="row" gap={1}>
2021
+ {mode !== "input" ? (
2022
+ (() => {
2023
+ const footerHints: { key: string; label: string; priority: number }[] = [
2024
+ { key: "/", label: "scan", priority: 1 },
2025
+ { key: "␣", label: "tag", priority: 2 },
2026
+ { key: "?", label: "help", priority: 3 },
2027
+ { key: "n", label: reconMode ? "recon:ON" : "recon", priority: 4 },
2028
+ { key: "M", label: "market", priority: 5 },
2029
+ { key: "S", label: "snipe", priority: 6 },
2030
+ { key: "e", label: "expand", priority: 7 },
2031
+ { key: "Tab", label: "tabs", priority: 8 },
2032
+ ...(registrarConfig?.apiKey ? [{ key: "r", label: "reg", priority: 9 }] : []),
2033
+ { key: "d", label: "suggest", priority: 10 },
2034
+ { key: "P", label: showPortfolio ? "close" : "dash", priority: 11 },
2035
+ { key: "p", label: "portfolio", priority: 12 },
2036
+ ];
2037
+ const maxHints = Math.floor((width - 20) / 10);
2038
+ const visibleHints = footerHints.slice(0, maxHints);
2039
+ return (
2040
+ <>
2041
+ {visibleHints.map((h) => (
2042
+ <box key={h.key} flexDirection="row" gap={0}>
2043
+ <box backgroundColor={theme.textDisabled}><text content={` ${h.key} `} fg={theme.background} /></box>
2044
+ <text content={h.label} fg={h.key === "n" && reconMode ? theme.error : h.key === "P" && showPortfolio ? theme.primary : h.key === "M" && showMarket ? theme.secondary : theme.textMuted} />
2045
+ </box>
2046
+ ))}
2047
+ </>
2048
+ );
2049
+ })()
2050
+ ) : (
2051
+ <>
2052
+ <box backgroundColor={theme.textDisabled}><text content=" ⏎ " fg={theme.background} /></box><text content="submit" fg={theme.textMuted} />
2053
+ <box backgroundColor={theme.textDisabled}><text content=" esc " fg={theme.background} /></box><text content="cancel" fg={theme.textMuted} />
2054
+ </>
2055
+ )}
2056
+ </box>
2057
+ <text content={stats.total > 0 ? `${stats.available + stats.expired}/${stats.total} actionable` : "v2.0"} fg={theme.textDisabled} />
2058
+ </box>
2059
+ </box>
2060
+ </box>
2061
+ );
2062
+ }