domsniper 0.1.2 → 0.2.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.
package/src/app.tsx CHANGED
@@ -9,7 +9,7 @@ import { readFileSync, existsSync } from "fs";
9
9
  import { theme, borders, statusStyle, type DomainStatus } from "./core/theme.js";
10
10
  import type { DomainEntry } from "./core/types.js";
11
11
  import { createEmptyEntry } from "./core/types.js";
12
- import { sanitizeDomainList, safePath } from "./core/validate.js";
12
+ import { sanitizeDomainList, safePath, detectTldTypo } from "./core/validate.js";
13
13
  import { lookupDns } from "./core/features/dns-details.js";
14
14
  import { httpProbe } from "./core/features/http-probe.js";
15
15
  import { checkWayback } from "./core/features/wayback.js";
@@ -20,14 +20,14 @@ import { scoreDomain, scoreGrade } from "./core/features/scoring.js";
20
20
  import { exportToCSV, exportToJSON } from "./core/features/export.js";
21
21
  import { DomainWatcher, formatInterval } from "./core/features/watch.js";
22
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";
23
+ import { filterDomains, nextStatus, DEFAULT_FILTER, type FilterConfig, type FilterStatus, type SortField } from "./core/features/filter.js";
24
24
  import { rdapLookup } from "./core/features/rdap.js";
25
25
  import { checkSsl } from "./core/features/ssl-check.js";
26
26
  import { discoverSubdomains, getActiveSubdomains } from "./core/features/subdomain-discovery.js";
27
27
  import { checkMarketplaces } from "./core/features/marketplace.js";
28
28
  import { sendWebhook } from "./core/features/webhooks.js";
29
29
  import { loadConfig } from "./core/features/config.js";
30
- import { generateSuggestions } from "./core/features/domain-suggest.js";
30
+ import { generateSuggestions, generateScoredSuggestions } from "./core/features/domain-suggest.js";
31
31
  import { addToPortfolio } from "./core/features/portfolio.js";
32
32
  import { snipeDomain, getSnipeTargets, cancelSnipe } from "./core/features/snipe.js";
33
33
  import {
@@ -52,14 +52,16 @@ import { auditSecurityHeaders } from "./core/features/security-headers.js";
52
52
  import { detectWaf } from "./core/features/waf-detect.js";
53
53
  import { scanPaths } from "./core/features/path-scanner.js";
54
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";
55
+ import { groupDomains, shouldShowGroups, type DomainGroup } from "./core/features/grouping.js";
56
+ import { getTimeline, loadHistoryScan, type HistoryTimeline } from "./core/features/history.js";
57
+ import { upsertDomain, saveScan, getCached, setCache, clearCache, getScanHistory, getLatestScan, getDomainByName, createSession as dbCreateSession, updateSessionCount, getDbStats, getAllDomains, getPortfolioExpiring } from "./core/db.js";
56
58
  import { getPortfolioDashboard, getUnacknowledgedAlerts, acknowledgeAllAlerts, getMonthlyReport, addTransaction, updatePortfolioStatus, getCategories, getSnipeStats, type PortfolioStatus, type TransactionType } from "./core/db.js";
57
59
  import { generateRenewalCalendar, estimateAnnualRenewalCost } from "./core/features/portfolio-monitor.js";
58
60
 
59
61
  // ─── Types ────────────────────────────────────────────────
60
62
 
61
63
  type Mode = "idle" | "input" | "scanning" | "done" | "watching";
62
- type InputMode = "domain" | "file" | "expand" | "variations" | "export" | "load";
64
+ type InputMode = "domain" | "file" | "expand" | "variations" | "export" | "load" | "suggest";
63
65
 
64
66
  interface AppProps {
65
67
  initialDomains?: string[];
@@ -90,16 +92,20 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
90
92
  const [showPortfolio, setShowPortfolio] = useState(false);
91
93
  const [filter, setFilter] = useState<FilterConfig>({ ...DEFAULT_FILTER });
92
94
  const [logs, setLogs] = useState<{ id: number; time: string; msg: string; fg: string }[]>([
93
- { id: 0, time: ts(), msg: "Domain Sniper v0.1.2 initialized", fg: theme.textMuted },
95
+ { id: 0, time: ts(), msg: "domsniper v0.2.0 — recon mode on", fg: theme.textMuted },
94
96
  { id: 1, time: ts(), msg: "Press ? for all commands", fg: theme.textMuted },
95
97
  ]);
96
98
  const [registrarConfig] = useState<RegistrarConfig | null>(loadConfigFromEnv());
97
- const [reconMode, setReconMode] = useState(false);
99
+ const [reconMode, setReconMode] = useState(true);
100
+ const [showActionMenu, setShowActionMenu] = useState(false);
98
101
  const [watcher, setWatcher] = useState<DomainWatcher | null>(null);
99
102
  const [watchCycle, setWatchCycle] = useState(0);
100
103
  const [intelTab, setIntelTab] = useState<IntelTab>("overview");
101
104
  const [scanProgress, setScanProgress] = useState<{ current: number; total: number } | null>(null);
102
105
  const [confirmBulkRegister, setConfirmBulkRegister] = useState(false);
106
+ const [showGroups, setShowGroups] = useState(false);
107
+ const [showHistory, setShowHistory] = useState(false);
108
+ const [historyIndex, setHistoryIndex] = useState(0);
103
109
 
104
110
  // Marketplace state
105
111
  const [showMarket, setShowMarket] = useState(false);
@@ -263,13 +269,30 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
263
269
 
264
270
  const processAllDomains = useCallback(async (domainList: string[], append = false) => {
265
271
  if (processingRef.current) return;
272
+
273
+ // Deduplicate
274
+ const uniqueList = [...new Set(domainList)];
275
+
276
+ // If appending, also remove domains already in the list
277
+ const existingDomains = append ? new Set(domains.map((d) => d.domain)) : new Set<string>();
278
+ const newDomains = uniqueList.filter((d) => !existingDomains.has(d));
279
+
280
+ if (newDomains.length === 0) {
281
+ log("All domains already in list", theme.textMuted);
282
+ return;
283
+ }
284
+
285
+ if (newDomains.length < domainList.length) {
286
+ log(`Skipped ${domainList.length - newDomains.length} duplicate(s)`, theme.textDisabled);
287
+ }
288
+
266
289
  processingRef.current = true;
267
290
  setMode("scanning");
268
291
 
269
292
  let sessionId: number | undefined;
270
293
  try { sessionId = dbCreateSession(); } catch {}
271
294
 
272
- const entries: DomainEntry[] = domainList.map((d) => createEmptyEntry(d));
295
+ const entries: DomainEntry[] = newDomains.map((d) => createEmptyEntry(d));
273
296
 
274
297
  if (append) {
275
298
  setDomains((prev: DomainEntry[]) => [...prev, ...entries]);
@@ -278,8 +301,8 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
278
301
  }
279
302
 
280
303
  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 });
304
+ log(`━━━ Scanning ${newDomains.length} domain${newDomains.length > 1 ? "s" : ""} ━━━`, theme.info);
305
+ setScanProgress({ current: 0, total: newDomains.length });
283
306
 
284
307
  // Concurrent pool
285
308
  const CONCURRENCY = 5;
@@ -295,7 +318,7 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
295
318
  return u;
296
319
  });
297
320
 
298
- const result = await processDomain(domainList[i]!);
321
+ const result = await processDomain(newDomains[i]!);
299
322
  setDomains((prev: DomainEntry[]) => { const u = [...prev]; u[globalIdx] = result; return u; });
300
323
  setScanProgress((prev) => prev ? { ...prev, current: prev.current + 1 } : null);
301
324
 
@@ -306,13 +329,13 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
306
329
  return u;
307
330
  });
308
331
  try {
309
- const regResult = await registerDomain(domainList[i]!, registrarConfig);
332
+ const regResult = await registerDomain(newDomains[i]!, registrarConfig);
310
333
  setDomains((prev: DomainEntry[]) => {
311
334
  const u = [...prev];
312
335
  if (u[globalIdx]) u[globalIdx] = { ...u[globalIdx]!, status: regResult.success ? "registered" : u[globalIdx]!.status, registration: regResult };
313
336
  return u;
314
337
  });
315
- if (regResult.success) log(`★ REG'd ${domainList[i]}`, theme.secondary);
338
+ if (regResult.success) log(`★ REG'd ${newDomains[i]}`, theme.secondary);
316
339
  } catch (err: unknown) {
317
340
  log(`REG failed: ${err instanceof Error ? err.message : "unknown"}`, theme.error);
318
341
  }
@@ -324,18 +347,18 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
324
347
  }
325
348
 
326
349
  const workers = Array.from(
327
- { length: Math.min(CONCURRENCY, domainList.length) },
350
+ { length: Math.min(CONCURRENCY, newDomains.length) },
328
351
  () => worker()
329
352
  );
330
353
  await Promise.all(workers);
331
354
 
332
- try { if (sessionId) updateSessionCount(sessionId, domainList.length); } catch {}
355
+ try { if (sessionId) updateSessionCount(sessionId, newDomains.length); } catch {}
333
356
 
334
357
  processingRef.current = false;
335
358
  setScanProgress(null);
336
359
  setMode("done");
337
360
  log("━━━ Scan complete ━━━", theme.info);
338
- }, [autoRegister, registrarConfig, processDomain, log]);
361
+ }, [autoRegister, registrarConfig, processDomain, log, domains]);
339
362
 
340
363
  // ─── Marketplace loaders ────────────────────────────────
341
364
 
@@ -403,6 +426,10 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
403
426
  // ─── Filtered domains ──────────────────────────────────
404
427
 
405
428
  const filteredDomains = useMemo(() => filterDomains(domains, filter), [domains, filter]);
429
+ const domainGroups = useMemo(() => {
430
+ if (!showGroups || filteredDomains.length < 4) return null;
431
+ return groupDomains(filteredDomains);
432
+ }, [filteredDomains, showGroups]);
406
433
  const selected = filteredDomains[selectedIndex] || null;
407
434
 
408
435
  // ─── Keyboard ───────────────────────────────────────────
@@ -419,12 +446,186 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
419
446
  if (showHelp && key === "escape") { setShowHelp(false); return; }
420
447
  if (showHelp) return; // Consume all keys while help is shown
421
448
 
449
+ // ── History panel ──
450
+ if (showHistory && selected) {
451
+ if (key === "escape") { setShowHistory(false); return; }
452
+ if (key === "up" || key === "k") { setHistoryIndex((i) => Math.max(0, i - 1)); return; }
453
+ if (key === "down" || key === "j") { setHistoryIndex((i) => i + 1); return; }
454
+
455
+ // Load selected historical scan
456
+ if (key === "return") {
457
+ const timeline = getTimeline(selected.domain, 20);
458
+ const entry = timeline.entries[historyIndex];
459
+ if (entry) {
460
+ const scan = loadHistoryScan(entry.id);
461
+ if (scan) {
462
+ const idx = domains.indexOf(selected);
463
+ if (idx >= 0) {
464
+ setDomains((prev: DomainEntry[]) => {
465
+ const u = [...prev];
466
+ u[idx] = { ...scan, tagged: prev[idx]!.tagged };
467
+ return u;
468
+ });
469
+ log(`Loaded scan from ${entry.scannedAt}`, theme.info);
470
+ }
471
+ }
472
+ }
473
+ setShowHistory(false);
474
+ return;
475
+ }
476
+
477
+ return; // Consume all keys while history is open
478
+ }
479
+
480
+ // ── Action menu mode ──
481
+ if (showActionMenu && selected) {
482
+ const idx = domains.indexOf(selected);
483
+
484
+ if (key === "escape") { setShowActionMenu(false); return; }
485
+
486
+ // Rescan
487
+ if (key === "return") {
488
+ setShowActionMenu(false);
489
+ clearCache(selected.domain);
490
+ if (idx >= 0) {
491
+ setDomains((prev: DomainEntry[]) => { const u = [...prev]; u[idx] = { ...createEmptyEntry(selected.domain), status: "checking" }; return u; });
492
+ processDomain(selected.domain).then((result) => { setDomains((prev: DomainEntry[]) => { const u = [...prev]; u[idx] = result; return u; }); });
493
+ }
494
+ log(`Rescanning ${selected.domain}...`, theme.info);
495
+ return;
496
+ }
497
+
498
+ // Register
499
+ if (key === "r" && (selected.status === "available" || selected.status === "expired") && registrarConfig?.apiKey) {
500
+ setShowActionMenu(false);
501
+ void handleRegister(idx);
502
+ return;
503
+ }
504
+
505
+ // Snipe
506
+ if (key === "z") {
507
+ setShowActionMenu(false);
508
+ if (selected.status === "taken" || selected.status === "expired") {
509
+ snipeDomain(selected.domain, { expiryDate: selected.whois?.expiryDate || undefined });
510
+ log(`Sniping ${selected.domain}`, theme.warning);
511
+ } else if (selected.status === "available") {
512
+ log(`Already available — press r to register`, theme.primary);
513
+ }
514
+ return;
515
+ }
516
+
517
+ // Add to portfolio
518
+ if (key === "p") {
519
+ setShowActionMenu(false);
520
+ try {
521
+ addToPortfolio(selected.domain, {
522
+ registrar: selected.whois?.registrar || selected.rdap?.registrar || "unknown",
523
+ expiryDate: selected.whois?.expiryDate || selected.rdap?.expiryDate || "",
524
+ });
525
+ log(`Added ${selected.domain} to portfolio`, theme.info);
526
+ } catch (err: unknown) {
527
+ log(`Portfolio: ${err instanceof Error ? err.message : "failed"}`, theme.error);
528
+ }
529
+ return;
530
+ }
531
+
532
+ // Suggest alternatives
533
+ if (key === "d") {
534
+ setShowActionMenu(false);
535
+ const name = selected.domain.split(".")[0] || "";
536
+ const existingDomains = new Set(domains.map((d) => d.domain));
537
+ const suggestions = generateScoredSuggestions(name, ["com", "io", "dev", "app", "co"], 20);
538
+ const newSuggestions = suggestions.filter((s) => !existingDomains.has(s.domain));
539
+ if (newSuggestions.length > 0) {
540
+ log(`Generated ${newSuggestions.length} suggestions from "${name}" (top: ${newSuggestions[0]!.domain} ${newSuggestions[0]!.grade})`, theme.info);
541
+ processAllDomains(newSuggestions.map((s) => s.domain), true);
542
+ setShowGroups(true);
543
+ } else {
544
+ log(`All suggestions for "${name}" already in list`, theme.textMuted);
545
+ }
546
+ return;
547
+ }
548
+
549
+ // Variations
550
+ if (key === "v") {
551
+ setShowActionMenu(false);
552
+ const vars = generateVariations(selected.domain);
553
+ log(`Generated ${vars.length} variations of ${selected.domain}`, theme.info);
554
+ if (vars.length > 0) { processAllDomains(vars, true); setShowGroups(true); }
555
+ return;
556
+ }
557
+
558
+ // TLD expansion
559
+ if (key === "e") {
560
+ setShowActionMenu(false);
561
+ const name = selected.domain.split(".")[0] || "";
562
+ const expanded = expandTlds(name, "popular");
563
+ log(`Expanded "${name}" into ${expanded.length} TLD variants`, theme.info);
564
+ if (expanded.length > 0) { processAllDomains(expanded, domains.length > 0); setShowGroups(true); }
565
+ return;
566
+ }
567
+
568
+ // Export
569
+ if (key === "x") {
570
+ setShowActionMenu(false);
571
+ setInputMode("export");
572
+ setMode("input");
573
+ setInputValue("");
574
+ return;
575
+ }
576
+
577
+ // Watch
578
+ if (key === "w") {
579
+ setShowActionMenu(false);
580
+ setDomains((prev: DomainEntry[]) => {
581
+ const u = [...prev];
582
+ if (idx >= 0) u[idx] = { ...u[idx]!, tagged: true };
583
+ return u;
584
+ });
585
+ log(`Tagged ${selected.domain} for watching`, theme.info);
586
+ return;
587
+ }
588
+
589
+ // Clear cache
590
+ if (key === "c") {
591
+ setShowActionMenu(false);
592
+ const count = clearCache(selected.domain);
593
+ log(`Cleared cache for ${selected.domain} (${count} entries)`, theme.info);
594
+ return;
595
+ }
596
+
597
+ // History panel
598
+ if (key === "h") {
599
+ setShowActionMenu(false);
600
+ setShowHistory(true);
601
+ setHistoryIndex(0);
602
+ return;
603
+ }
604
+
605
+ // Load previous
606
+ if (key === "l") {
607
+ setShowActionMenu(false);
608
+ try {
609
+ const prevScan = getLatestScan(selected.domain);
610
+ if (prevScan && idx >= 0) {
611
+ setDomains((prev: DomainEntry[]) => { const u = [...prev]; u[idx] = { ...prevScan, tagged: prev[idx]!.tagged }; return u; });
612
+ log(`Loaded last saved scan for ${selected.domain}`, theme.info);
613
+ } else {
614
+ log(`No saved scan for ${selected.domain}`, theme.textMuted);
615
+ }
616
+ } catch { log("Could not load scan", theme.error); }
617
+ return;
618
+ }
619
+
620
+ return; // Consume all other keys while menu is open
621
+ }
622
+
422
623
  // Toggle portfolio dashboard
423
- if (key === "P" && mode !== "input") { setShowPortfolio((v) => !v); return; }
624
+ if (key === "b" && mode !== "input") { setShowPortfolio((v) => !v); return; }
424
625
  if (showPortfolio && key === "escape") { setShowPortfolio(false); return; }
425
626
 
426
627
  // ── Marketplace toggle ──
427
- if (key === "M" && mode !== "input") {
628
+ if (key === "m" && mode !== "input") {
428
629
  if (!showMarket) {
429
630
  setShowMarket(true);
430
631
  setMarketView("browse");
@@ -544,7 +745,7 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
544
745
  }
545
746
 
546
747
  // Cancel bulk register on any other key
547
- if (confirmBulkRegister && key !== "R") {
748
+ if (confirmBulkRegister && key !== "a") {
548
749
  setConfirmBulkRegister(false);
549
750
  }
550
751
 
@@ -557,10 +758,11 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
557
758
  // Generate variations for selected domain
558
759
  const vars = generateVariations(selected.domain);
559
760
  log(`Generated ${vars.length} variations of ${selected.domain}`, theme.info);
560
- if (vars.length > 0) processAllDomains(vars, true);
761
+ if (vars.length > 0) { processAllDomains(vars, true); setShowGroups(true); }
561
762
  return;
562
763
  }
563
764
  if (key === "x") { setInputMode("export"); setMode("input"); setInputValue(""); log("Enter export path (.csv or .json)...", theme.textMuted); return; }
765
+ if (key === "d" && !selected && !showActionMenu && !showMarket && !showPortfolio) { setInputMode("suggest"); setMode("input"); setInputValue(""); log("Enter keyword for domain suggestions...", theme.textMuted); return; }
564
766
  }
565
767
 
566
768
  // ── Input mode ──
@@ -575,20 +777,44 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
575
777
  if (key === "n" && !ctrl) {
576
778
  setReconMode((v) => {
577
779
  const newVal = !v;
578
- log(newVal ? "Recon mode ON (full pentest — rescan to apply)" : "Recon mode OFF (fast scan)", newVal ? theme.warning : theme.textMuted);
780
+ if (domains.length > 0) {
781
+ log(newVal ? "Recon mode ON — press Enter on a domain to rescan with recon" : "Recon mode OFF (fast scan)", newVal ? theme.warning : theme.textMuted);
782
+ } else {
783
+ log(newVal ? "Recon mode ON — next scan will include security checks" : "Recon mode OFF (fast scan)", newVal ? theme.warning : theme.textMuted);
784
+ }
579
785
  return newVal;
580
786
  });
581
787
  return;
582
788
  }
583
789
 
584
- // Snipe selected domain (already past input mode guard)
585
- if (key === "S" && selected) {
586
- if (selected.status === "taken" || selected.status === "expired") {
790
+ // Snipe selected domain (merged with drop catch for expired domains)
791
+ if (key === "z" && selected) {
792
+ if (selected.status === "expired" || selected.whois?.expired) {
793
+ // Expired domain — start snipe + drop catch
794
+ snipeDomain(selected.domain, {
795
+ expiryDate: selected.whois?.expiryDate || undefined,
796
+ });
797
+ log(`Sniping ${selected.domain} — expired, will auto-register when it drops`, theme.warning);
798
+ log(`Run 'domain-sniper snipe run' to start the engine`, theme.textDisabled);
799
+ // Also start drop catch if registrar is configured
800
+ if (registrarConfig?.apiKey) {
801
+ const catcher = createDropCatcher({
802
+ domain: selected.domain,
803
+ registrarConfig: registrarConfig!,
804
+ pollIntervalMs: 30000,
805
+ maxAttempts: 2880,
806
+ onStatus: (status: DropCatchStatus) => log(formatDropCatchStatus(status), status.phase === "success" ? theme.primary : status.phase === "failed" ? theme.error : theme.info),
807
+ onSuccess: (d: string) => log(`DROP CAUGHT: ${d}!`, theme.primary),
808
+ onFailed: (d: string, err: string) => log(`Drop catch failed for ${d}: ${err}`, theme.error),
809
+ });
810
+ catcher.start();
811
+ log(`Drop catch started for ${selected.domain} (polling every 30s)`, theme.info);
812
+ }
813
+ } else if (selected.status === "taken") {
587
814
  snipeDomain(selected.domain, {
588
815
  expiryDate: selected.whois?.expiryDate || undefined,
589
816
  });
590
- const phase = selected.status === "expired" ? "frequent" : "hourly";
591
- log(`Sniping ${selected.domain} — ${selected.status === "expired" ? "expired, checking every 5 min" : "watching for expiry"}`, theme.warning);
817
+ log(`Sniping ${selected.domain} watching for expiry`, theme.info);
592
818
  log(`Run 'domain-sniper snipe run' to start the engine`, theme.textDisabled);
593
819
  } else if (selected.status === "available") {
594
820
  log(`${selected.domain} is already available — press r to register now`, theme.primary);
@@ -604,9 +830,23 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
604
830
  else if (key === "down" || key === "j" || (ctrl && key === "n")) setSelectedIndex((i: number) => Math.min(filteredDomains.length - 1, i + 1));
605
831
  else if (key === "pageup") setSelectedIndex((i: number) => Math.max(0, i - 10));
606
832
  else if (key === "pagedown") setSelectedIndex((i: number) => Math.min(filteredDomains.length - 1, i + 10));
607
- else if (key === "home" || key === "g") setSelectedIndex(0);
833
+ else if (key === "home") setSelectedIndex(0);
834
+ else if (key === "g") {
835
+ if (shouldShowGroups(filteredDomains)) {
836
+ setShowGroups((v) => !v);
837
+ log(showGroups ? "Flat list view" : "Grouped by base name", theme.textMuted);
838
+ } else {
839
+ log("Not enough domains to group", theme.textMuted);
840
+ }
841
+ }
608
842
  else if (key === "end") setSelectedIndex(Math.max(0, filteredDomains.length - 1));
609
843
 
844
+ // History panel (global shortcut)
845
+ else if (key === "h" && selected && !showActionMenu && !showMarket && !showPortfolio && !showHistory) {
846
+ setShowHistory(true);
847
+ setHistoryIndex(0);
848
+ }
849
+
610
850
  // Register (Issue 5: feedback on all statuses)
611
851
  else if (key === "r" && selected) {
612
852
  if ((selected.status === "available" || selected.status === "expired") && registrarConfig?.apiKey) {
@@ -618,16 +858,16 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
618
858
  }
619
859
  }
620
860
 
621
- // Bulk register tagged (Issue 7: two-step confirmation)
622
- else if (key === "R") {
861
+ // Bulk register tagged (two-step confirmation)
862
+ else if (key === "a") {
623
863
  const tagged = domains.filter((d) => d.tagged && (d.status === "available" || d.status === "expired"));
624
864
  if (!registrarConfig?.apiKey) {
625
865
  log("No registrar configured", theme.warning);
626
866
  } else if (tagged.length === 0) {
627
- log("Tag domains with SPACE first, then R to bulk register", theme.warning);
867
+ log("Tag domains with SPACE first, then a to bulk register", theme.warning);
628
868
  } else if (!confirmBulkRegister) {
629
869
  setConfirmBulkRegister(true);
630
- log(`⚠ CONFIRM: Press R again to register ${tagged.length} domain(s) via ${registrarConfig.provider}`, theme.warning);
870
+ log(`⚠ CONFIRM: Press a again to register ${tagged.length} domain(s) via ${registrarConfig.provider}`, theme.warning);
631
871
  setTimeout(() => setConfirmBulkRegister(false), 5000);
632
872
  } else {
633
873
  setConfirmBulkRegister(false);
@@ -654,17 +894,23 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
654
894
  setSelectedIndex(0);
655
895
  }
656
896
 
657
- // Sort: cycle field
897
+ // Sort: cycle field + order (o cycles: field asc -> field desc -> next field asc -> ...)
658
898
  else if (key === "o") {
659
- setFilter((f: FilterConfig) => ({ ...f, sort: nextSort(f.sort) }));
899
+ setFilter((f: FilterConfig) => {
900
+ const fields: SortField[] = ["domain", "status", "score", "expiry", "price"];
901
+ const currentIdx = fields.indexOf(f.sort);
902
+ if (f.order === "asc") {
903
+ // Switch to desc of same field
904
+ return { ...f, order: "desc" as const };
905
+ } else {
906
+ // Move to next field, reset to asc
907
+ const nextField = fields[(currentIdx + 1) % fields.length]!;
908
+ return { ...f, sort: nextField, order: "asc" as const };
909
+ }
910
+ });
660
911
  setSelectedIndex(0);
661
912
  }
662
913
 
663
- // Sort: toggle order
664
- else if (key === "O") {
665
- setFilter((f: FilterConfig) => ({ ...f, order: f.order === "asc" ? "desc" : "asc" }));
666
- }
667
-
668
914
  // Watch mode
669
915
  else if (key === "w") {
670
916
  if (watcher?.running) {
@@ -692,38 +938,21 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
692
938
  }
693
939
  }
694
940
 
695
- // Domain suggestions (Issue 6: deduplicate)
941
+ // Domain suggestions scored, multi-TLD
696
942
  else if (key === "d" && selected) {
697
943
  const name = selected.domain.split(".")[0] || "";
698
- const suggestions = generateSuggestions(name);
699
944
  const existingDomains = new Set(domains.map((d) => d.domain));
700
- const newSuggestions = suggestions.filter((s) => !existingDomains.has(s.domain)).slice(0, 15);
945
+ const suggestions = generateScoredSuggestions(name, ["com", "io", "dev", "app", "co"], 20);
946
+ const newSuggestions = suggestions.filter((s) => !existingDomains.has(s.domain));
701
947
  if (newSuggestions.length > 0) {
702
- log(`Generated ${newSuggestions.length} new suggestions from "${name}"`, theme.info);
948
+ log(`Generated ${newSuggestions.length} suggestions from "${name}" (top: ${newSuggestions[0]!.domain} ${newSuggestions[0]!.grade})`, theme.info);
703
949
  processAllDomains(newSuggestions.map((s) => s.domain), true);
950
+ setShowGroups(true);
704
951
  } else {
705
952
  log(`All suggestions for "${name}" already in list`, theme.textMuted);
706
953
  }
707
954
  }
708
955
 
709
- // Drop catch mode
710
- else if (key === "D" && selected && registrarConfig?.apiKey) {
711
- if (selected.status === "expired" || selected.whois?.expired) {
712
- const catcher = createDropCatcher({
713
- domain: selected.domain,
714
- registrarConfig: registrarConfig!,
715
- pollIntervalMs: 30000,
716
- maxAttempts: 2880,
717
- onStatus: (status: DropCatchStatus) => log(formatDropCatchStatus(status), status.phase === "success" ? theme.primary : status.phase === "failed" ? theme.error : theme.info),
718
- onSuccess: (d: string) => log(`DROP CAUGHT: ${d}!`, theme.primary),
719
- onFailed: (d: string, err: string) => log(`Drop catch failed for ${d}: ${err}`, theme.error),
720
- });
721
- catcher.start();
722
- log(`Drop catch started for ${selected.domain} (polling every 30s)`, theme.info);
723
- } else {
724
- log("Drop catch requires an expired domain", theme.warning);
725
- }
726
- }
727
956
 
728
957
  // Add to portfolio
729
958
  else if (key === "p" && selected) {
@@ -745,6 +974,15 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
745
974
  log(`Cleared cache for ${selected.domain} (${count} entries)`, theme.info);
746
975
  }
747
976
 
977
+ // Context menu
978
+ else if (key === "return" && selected && mode !== "scanning" && !showMarket && !showPortfolio) {
979
+ if (showActionMenu) {
980
+ setShowActionMenu(false);
981
+ } else {
982
+ setShowActionMenu(true);
983
+ }
984
+ }
985
+
748
986
  // Show scan history for selected domain
749
987
  else if (key === "h" && selected) {
750
988
  const history = getScanHistory(selected.domain, 5);
@@ -758,6 +996,29 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
758
996
  }
759
997
  }
760
998
 
999
+ // Load previous scan result
1000
+ else if (key === "l" && selected) {
1001
+ try {
1002
+ const prevScan = getLatestScan(selected.domain);
1003
+ if (prevScan) {
1004
+ // Replace the current entry with the historical scan
1005
+ const idx = domains.indexOf(selected);
1006
+ if (idx >= 0) {
1007
+ setDomains((prev: DomainEntry[]) => {
1008
+ const u = [...prev];
1009
+ u[idx] = { ...prevScan, tagged: prev[idx]!.tagged };
1010
+ return u;
1011
+ });
1012
+ log(`Loaded last saved scan for ${selected.domain}`, theme.info);
1013
+ }
1014
+ } else {
1015
+ log(`No saved scan history for ${selected.domain}`, theme.textMuted);
1016
+ }
1017
+ } catch {
1018
+ log("Could not load scan history", theme.error);
1019
+ }
1020
+ }
1021
+
761
1022
  // Save session
762
1023
  else if (ctrl && key === "s") {
763
1024
  const path = saveSession(domains);
@@ -859,7 +1120,7 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
859
1120
  } else if (inputMode === "expand") {
860
1121
  const expanded = expandTlds(v, "popular");
861
1122
  log(`Expanded "${v}" into ${expanded.length} TLD variants`, theme.info);
862
- if (expanded.length > 0) processAllDomains(expanded, domains.length > 0);
1123
+ if (expanded.length > 0) { processAllDomains(expanded, domains.length > 0); setShowGroups(true); }
863
1124
  else { setMode(domains.length > 0 ? "done" : "idle"); }
864
1125
  } else if (inputMode === "export") {
865
1126
  try {
@@ -874,6 +1135,23 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
874
1135
  setMode("done");
875
1136
  log(`Loaded session: ${session.id} (${session.domains.length} domains)`, theme.info);
876
1137
  } else { log("Session not found", theme.error); setMode(domains.length > 0 ? "done" : "idle"); }
1138
+ } else if (inputMode === "suggest") {
1139
+ const suggestions = generateScoredSuggestions(v, ["com", "io", "dev", "app", "co"], 20);
1140
+ if (suggestions.length > 0) {
1141
+ const existingDomains = new Set(domains.map((d) => d.domain));
1142
+ const newSuggestions = suggestions.filter((s) => !existingDomains.has(s.domain));
1143
+ if (newSuggestions.length > 0) {
1144
+ log(`Generated ${newSuggestions.length} suggestions for "${v}" (top: ${newSuggestions[0]!.domain} ${newSuggestions[0]!.grade})`, theme.info);
1145
+ processAllDomains(newSuggestions.map((s) => s.domain), domains.length > 0);
1146
+ setShowGroups(true);
1147
+ } else {
1148
+ log(`All suggestions for "${v}" already in list`, theme.textMuted);
1149
+ setMode(domains.length > 0 ? "done" : "idle");
1150
+ }
1151
+ } else {
1152
+ log("No suggestions generated", theme.warning);
1153
+ setMode(domains.length > 0 ? "done" : "idle");
1154
+ }
877
1155
  } else if (inputMode === "domain") {
878
1156
  if (existsSync(v)) {
879
1157
  const content = readFileSync(v, "utf-8");
@@ -881,8 +1159,22 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
881
1159
  if (list.length > 0) { log(`Loaded ${list.length} from ${v}`, theme.info); processAllDomains(list, domains.length > 0); }
882
1160
  } else {
883
1161
  const list = v.split(/[,\s]+/).map((d: string) => d.trim().toLowerCase()).filter(Boolean);
884
- const validated = sanitizeDomainList(list);
885
- if (validated.length > 0) processAllDomains(validated, domains.length > 0);
1162
+
1163
+ // Auto-detect and warn about TLD typos
1164
+ const corrected: string[] = [];
1165
+ for (const d of list) {
1166
+ const suggestion = detectTldTypo(d);
1167
+ if (suggestion) {
1168
+ log(`Auto-corrected: "${d}" → "${suggestion}"`, theme.warning);
1169
+ corrected.push(suggestion);
1170
+ } else {
1171
+ corrected.push(d);
1172
+ }
1173
+ }
1174
+
1175
+ const validated = sanitizeDomainList(corrected);
1176
+ const unique = [...new Set(validated)];
1177
+ if (unique.length > 0) processAllDomains(unique, domains.length > 0);
886
1178
  else log("No valid domains entered", theme.warning);
887
1179
  }
888
1180
  }
@@ -925,8 +1217,8 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
925
1217
  const dLine = (w: number) => "═".repeat(Math.max(1, w - 2));
926
1218
  const logPanelH = Math.max(4, Math.floor((height - 5) * 0.28));
927
1219
 
928
- 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";
929
- 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";
1220
+ const inputLabel = marketInputMode === "search" ? "SEARCH" : marketInputMode === "list-price" ? "PRICE" : marketInputMode === "offer-amount" ? "OFFER" : inputMode === "suggest" ? "SUGGEST" : inputMode === "file" ? "FILE" : inputMode === "expand" ? "EXPAND" : inputMode === "export" ? "EXPORT" : inputMode === "load" ? "LOAD" : "SCAN";
1221
+ const inputPlaceholder = marketInputMode === "search" ? "Search domains..." : marketInputMode === "list-price" ? `Asking price for ${marketListDomain}` : marketInputMode === "offer-amount" ? "Your offer amount" : inputMode === "suggest" ? "Enter a keyword (e.g., startup, cloud)" : 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";
930
1222
 
931
1223
  // ─── Render ─────────────────────────────────────────────
932
1224
 
@@ -1008,7 +1300,7 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
1008
1300
  )}
1009
1301
  {marketUnread > 0 && <text content={`✉ ${marketUnread}`} fg={theme.warning} />}
1010
1302
  <box flexGrow={1} />
1011
- <text content="(M to close)" fg={theme.textDisabled} />
1303
+ <text content="(m to close)" fg={theme.textDisabled} />
1012
1304
  </box>
1013
1305
  <text content={hLine(width)} fg={theme.borderSubtle} paddingLeft={1} />
1014
1306
 
@@ -1091,7 +1383,7 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
1091
1383
  <box backgroundColor={theme.textDisabled}><text content=" ⏎ " fg={theme.background} /></box><text content="view" fg={theme.textMuted} />
1092
1384
  {isLoggedIn() && selected && (<><box backgroundColor={theme.textDisabled}><text content=" l " fg={theme.background} /></box><text content="list domain" fg={theme.textMuted} /></>)}
1093
1385
  <box backgroundColor={theme.textDisabled}><text content=" r " fg={theme.background} /></box><text content="refresh" fg={theme.textMuted} />
1094
- <box backgroundColor={theme.textDisabled}><text content=" M " fg={theme.background} /></box><text content="close" fg={theme.textMuted} />
1386
+ <box backgroundColor={theme.textDisabled}><text content=" m " fg={theme.background} /></box><text content="close" fg={theme.textMuted} />
1095
1387
  </box>
1096
1388
  </box>
1097
1389
  </box>
@@ -1112,7 +1404,7 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
1112
1404
  <box backgroundColor={theme.primary}><text content=" PORTFOLIO DASHBOARD " fg={theme.background} /></box>
1113
1405
  <text content={`${dash.totalDomains} domains`} fg={theme.text} />
1114
1406
  <text content={`$${dash.totalValue.toFixed(0)} value`} fg={theme.primary} />
1115
- <text content="(Press P to close)" fg={theme.textDisabled} />
1407
+ <text content="(Press b to close)" fg={theme.textDisabled} />
1116
1408
  </box>
1117
1409
 
1118
1410
  <text content="" />
@@ -1228,29 +1520,68 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
1228
1520
  {/* ─── LEFT PANEL ─── */}
1229
1521
  <box width={sidebarW} flexDirection="column" minHeight={0}>
1230
1522
  <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1} flexShrink={0}>
1231
- <text content={` TARGETS ${filteredDomains.length !== stats.total ? `(${filteredDomains.length}/${stats.total})` : stats.total > 0 ? `(${stats.total})` : ""}`} fg={theme.primary} />
1523
+ <box flexDirection="row" gap={1}>
1524
+ <text content={` TARGETS ${filteredDomains.length !== stats.total ? `(${filteredDomains.length}/${stats.total})` : stats.total > 0 ? `(${stats.total})` : ""}`} fg={theme.primary} />
1525
+ {showGroups && <text content="grouped" fg={theme.textDisabled} />}
1526
+ </box>
1232
1527
  {stats.checking > 0 && <text content={`◆ ${stats.checking}`} fg={theme.warning} />}
1233
1528
  </box>
1234
1529
  <text content={hLine(sidebarW)} fg={theme.borderSubtle} paddingLeft={1} />
1235
1530
 
1236
1531
  {filteredDomains.length > 0 ? (
1237
1532
  <scrollbox flexGrow={1} paddingLeft={1} minHeight={0} scrollbarOptions={{ visible: true }}>
1238
- {filteredDomains.map((entry: DomainEntry, i: number) => {
1239
- const active = selectedIndex === i;
1240
- const ss = statusStyle(entry.status);
1241
- const cached = domainScores.get(entry.domain);
1242
- const gr = cached?.grade ?? scoreGrade(0);
1243
- return (
1244
- <box key={entry.domain} flexDirection="row" backgroundColor={active ? theme.primaryDim : "transparent"} paddingLeft={1} gap={1} onMouseDown={() => setSelectedIndex(i)}>
1245
- <text content={entry.tagged ? "" : " "} fg={entry.tagged ? theme.info : "transparent"} />
1246
- <text content={ss.icon} fg={ss.fg} />
1247
- <text content={entry.domain} fg={active ? theme.text : theme.textSecondary} />
1248
- <box flexGrow={1} />
1249
- <text content={gr.grade} fg={gr.color} />
1250
- {entry.status === "checking" && <text content="···" fg={theme.warning} />}
1251
- </box>
1252
- );
1253
- })}
1533
+ {showGroups && domainGroups ? (
1534
+ <>
1535
+ {domainGroups.map((group) => (
1536
+ <box key={group.baseName} flexDirection="column">
1537
+ {group.total >= 2 && (
1538
+ <box flexDirection="row" gap={1} paddingTop={1}>
1539
+ <text content={`▸ ${group.baseName}`} fg={theme.secondary} />
1540
+ <text content={`(${group.available > 0 ? `${group.available} avail` : ""}${group.expired > 0 ? ` ${group.expired} exp` : ""}${group.taken > 0 ? ` ${group.taken} tkn` : ""})`} fg={theme.textDisabled} />
1541
+ </box>
1542
+ )}
1543
+ {group.domains.map((domainName) => {
1544
+ const entry = filteredDomains.find((d) => d.domain === domainName);
1545
+ if (!entry) return null;
1546
+ const i = filteredDomains.indexOf(entry);
1547
+ const active = selectedIndex === i;
1548
+ const ss = statusStyle(entry.status);
1549
+ const cached = domainScores.get(entry.domain);
1550
+ return (
1551
+ <box key={entry.domain} flexDirection="row" backgroundColor={active ? theme.primaryDim : "transparent"} paddingLeft={group.total >= 2 ? 2 : 1} gap={1}
1552
+ onMouseDown={() => setSelectedIndex(i)}>
1553
+ <text content={entry.tagged ? "◉" : " "} fg={entry.tagged ? theme.info : "transparent"} />
1554
+ <text content={ss.icon} fg={ss.fg} />
1555
+ <text content={entry.domain} fg={active ? theme.text : theme.textSecondary} />
1556
+ <box flexGrow={1} />
1557
+ {cached && <text content={cached.grade.grade} fg={cached.grade.color} />}
1558
+ {entry.status === "checking" && <text content="···" fg={theme.warning} />}
1559
+ </box>
1560
+ );
1561
+ })}
1562
+ </box>
1563
+ ))}
1564
+ </>
1565
+ ) : (
1566
+ <>
1567
+ {filteredDomains.map((entry: DomainEntry, i: number) => {
1568
+ const active = selectedIndex === i;
1569
+ const ss = statusStyle(entry.status);
1570
+ const cached = domainScores.get(entry.domain);
1571
+ const gr = cached?.grade ?? scoreGrade(0);
1572
+ return (
1573
+ <box key={entry.domain} flexDirection="row" backgroundColor={active ? theme.primaryDim : "transparent"} paddingLeft={1} gap={1} onMouseDown={() => setSelectedIndex(i)}>
1574
+ <text content={entry.tagged ? "◉" : " "} fg={entry.tagged ? theme.info : "transparent"} />
1575
+ <text content={ss.icon} fg={ss.fg} />
1576
+ <text content={entry.domain} fg={active ? theme.text : theme.textSecondary} />
1577
+ <box flexGrow={1} />
1578
+ <text content={gr.grade} fg={gr.color} />
1579
+ {entry.status === "checking" && <text content="···" fg={theme.warning} />}
1580
+ </box>
1581
+ );
1582
+ })}
1583
+ </>
1584
+ )}
1254
1585
  </scrollbox>
1255
1586
  ) : (
1256
1587
  <box flexGrow={1} alignItems="center" justifyContent="center" minHeight={0}>
@@ -1298,66 +1629,221 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
1298
1629
  {showHelp ? (
1299
1630
  <scrollbox flexGrow={1} paddingLeft={2} paddingRight={2} minHeight={0} scrollbarOptions={{ visible: false }}>
1300
1631
  <box flexDirection="column" gap={0} paddingTop={1}>
1301
- <text content="COMMANDS" fg={theme.primary} />
1302
- <text content="" />
1303
- <text content="Navigation" fg={theme.secondary} />
1304
- <text content=" ↑/k ↓/j Move selection" fg={theme.textSecondary} />
1305
- <text content=" PgUp PgDn Jump 10" fg={theme.textSecondary} />
1306
- <text content=" g / Home First" fg={theme.textSecondary} />
1307
- <text content=" End Last" fg={theme.textSecondary} />
1308
- <text content=" Tab / ` Cycle INTEL tabs" fg={theme.textSecondary} />
1632
+ <text content="GLOBAL SHORTCUTS" fg={theme.primary} />
1309
1633
  <text content="" />
1310
- <text content="Scanning" fg={theme.secondary} />
1311
- <text content=" / i Enter domains" fg={theme.textSecondary} />
1634
+ <text content=" / i Scan domains" fg={theme.textSecondary} />
1312
1635
  <text content=" f Load from file" fg={theme.textSecondary} />
1313
1636
  <text content=" e TLD expansion" fg={theme.textSecondary} />
1314
- <text content=" v Variations of selected" fg={theme.textSecondary} />
1315
- <text content="" />
1316
- <text content="Actions" fg={theme.secondary} />
1317
- <text content=" SPACE Tag / untag domain" fg={theme.textSecondary} />
1318
- <text content=" r Register selected" fg={theme.textSecondary} />
1319
- <text content=" R Bulk register tagged (2x)" fg={theme.textSecondary} />
1320
- <text content=" d Suggest similar domains" fg={theme.textSecondary} />
1321
- <text content=" p Add to portfolio" fg={theme.textSecondary} />
1322
- <text content=" S Snipe domain (auto-register when it drops)" fg={theme.textSecondary} />
1323
- <text content=" D Drop catch (expired only)" fg={theme.textSecondary} />
1324
- <text content=" c Clear cache for selected" fg={theme.textSecondary} />
1325
- <text content=" h Show scan history" fg={theme.textSecondary} />
1326
- <text content=" w Watch tagged (1h)" fg={theme.textSecondary} />
1327
- <text content="" />
1328
- <text content="Filter & Sort" fg={theme.secondary} />
1637
+ <text content=" d Suggest domains from keyword" fg={theme.textSecondary} />
1638
+ <text content=" Space Tag / untag" fg={theme.textSecondary} />
1639
+ <text content=" Tab Cycle intel tabs" fg={theme.textSecondary} />
1640
+ <text content=" n Toggle recon mode" fg={theme.textSecondary} />
1641
+ <text content=" b Portfolio dashboard" fg={theme.textSecondary} />
1642
+ <text content=" m Marketplace" fg={theme.textSecondary} />
1329
1643
  <text content=" s Cycle status filter" fg={theme.textSecondary} />
1330
- <text content=" o Cycle sort field" fg={theme.textSecondary} />
1331
- <text content=" O Toggle sort order" fg={theme.textSecondary} />
1644
+ <text content=" o Cycle sort" fg={theme.textSecondary} />
1645
+ <text content=" g Toggle group view" fg={theme.textSecondary} />
1646
+ <text content=" q Quit" fg={theme.textSecondary} />
1647
+ <text content=" ? This help" fg={theme.textSecondary} />
1332
1648
  <text content="" />
1333
- <text content="Recon" fg={theme.secondary} />
1334
- <text content=" n Toggle recon mode" fg={theme.textSecondary} />
1335
- <text content=" Enables port scan, WAF, headers," fg={theme.textDisabled} />
1336
- <text content=" CORS, zone transfer, takeover detect" fg={theme.textDisabled} />
1337
- <text content=" (rescan required after toggling)" fg={theme.textDisabled} />
1649
+ <text content="DOMAIN ACTIONS (Enter to open menu)" fg={theme.primary} />
1338
1650
  <text content="" />
1339
- <text content="Marketplace" fg={theme.secondary} />
1340
- <text content=" M Open/close marketplace" fg={theme.textSecondary} />
1341
- <text content=" / Search listings (in marketplace)" fg={theme.textSecondary} />
1342
- <text content=" 1 2 3 Switch: Browse / My Listings / My Offers" fg={theme.textSecondary} />
1343
- <text content=" Enter View listing details" fg={theme.textSecondary} />
1344
- <text content=" l List selected domain for sale" fg={theme.textSecondary} />
1345
- <text content=" o Make offer (in detail view)" fg={theme.textSecondary} />
1346
- <text content=" r Refresh listings" fg={theme.textSecondary} />
1651
+ <text content=" r Register" fg={theme.textSecondary} />
1652
+ <text content=" z Snipe" fg={theme.textSecondary} />
1653
+ <text content=" p Add to portfolio" fg={theme.textSecondary} />
1654
+ <text content=" d Suggest similar" fg={theme.textSecondary} />
1655
+ <text content=" v Variations" fg={theme.textSecondary} />
1656
+ <text content=" w Tag for watch" fg={theme.textSecondary} />
1657
+ <text content=" h History timeline (with deltas)" fg={theme.textSecondary} />
1658
+ <text content=" c Clear cache" fg={theme.textSecondary} />
1659
+ <text content=" x Export" fg={theme.textSecondary} />
1660
+ <text content=" Enter Rescan" fg={theme.textSecondary} />
1347
1661
  <text content="" />
1348
- <text content="Portfolio" fg={theme.secondary} />
1349
- <text content=" P Portfolio dashboard" fg={theme.textSecondary} />
1350
- <text content=" p Add selected to portfolio" fg={theme.textSecondary} />
1662
+ <text content="Navigation" fg={theme.secondary} />
1663
+ <text content=" ↑/k ↓/j Move selection" fg={theme.textSecondary} />
1664
+ <text content=" PgUp PgDn Jump 10" fg={theme.textSecondary} />
1665
+ <text content=" Home First End Last" fg={theme.textSecondary} />
1351
1666
  <text content="" />
1352
1667
  <text content="Session" fg={theme.secondary} />
1353
1668
  <text content=" Ctrl+S Save session" fg={theme.textSecondary} />
1354
1669
  <text content=" Ctrl+L Load session" fg={theme.textSecondary} />
1355
- <text content=" x Export CSV/JSON" fg={theme.textSecondary} />
1670
+ <text content=" a Bulk register tagged (2x confirm)" fg={theme.textSecondary} />
1356
1671
  <text content="" />
1357
- <text content=" ? Toggle this help" fg={theme.textSecondary} />
1358
- <text content=" q / Ctrl+C Quit" fg={theme.textSecondary} />
1672
+ <text content="Marketplace (when open)" fg={theme.secondary} />
1673
+ <text content=" / 1 2 3 Search / Browse / Mine / Offers" fg={theme.textSecondary} />
1674
+ <text content=" l List domain for sale" fg={theme.textSecondary} />
1675
+ <text content=" o Make offer (detail view)" fg={theme.textSecondary} />
1359
1676
  </box>
1360
1677
  </scrollbox>
1678
+ ) : showHistory && selected ? (
1679
+ <scrollbox flexGrow={1} paddingLeft={1} paddingRight={1} minHeight={0} scrollbarOptions={{ visible: true }}>
1680
+ {(() => {
1681
+ const timeline = getTimeline(selected.domain, 20);
1682
+ const maxIdx = Math.max(0, timeline.entries.length - 1);
1683
+ const safeIdx = Math.min(historyIndex, maxIdx);
1684
+
1685
+ return (
1686
+ <box flexDirection="column" gap={0} paddingTop={1}>
1687
+ {/* Header */}
1688
+ <box flexDirection="row" gap={2} paddingLeft={1}>
1689
+ <text content="HISTORY" fg={theme.secondary} />
1690
+ <text content={selected.domain} fg={theme.text} />
1691
+ <text content={`(${timeline.totalScans} scans)`} fg={theme.textDisabled} />
1692
+ </box>
1693
+
1694
+ {timeline.firstSeen && (
1695
+ <box flexDirection="row" gap={2} paddingLeft={1}>
1696
+ <text content={`First: ${timeline.firstSeen}`} fg={theme.textDisabled} />
1697
+ <text content={`Latest: ${timeline.lastSeen}`} fg={theme.textDisabled} />
1698
+ </box>
1699
+ )}
1700
+
1701
+ <text content="" />
1702
+
1703
+ {/* Status changes summary */}
1704
+ {timeline.statusChanges.length > 0 && (
1705
+ <box flexDirection="column" paddingLeft={1}>
1706
+ <text content="STATUS CHANGES" fg={theme.warning} />
1707
+ {timeline.statusChanges.map((sc, i) => (
1708
+ <box key={i} flexDirection="row" gap={1} paddingLeft={1}>
1709
+ <text content={sc.from} fg={theme.error} />
1710
+ <text content={"\u2192"} fg={theme.textDisabled} />
1711
+ <text content={sc.to} fg={theme.primary} />
1712
+ <text content={sc.at} fg={theme.textDisabled} />
1713
+ </box>
1714
+ ))}
1715
+ <text content="" />
1716
+ </box>
1717
+ )}
1718
+
1719
+ {/* Timeline entries */}
1720
+ <text content=" SCANS (Enter to load, Esc to close)" fg={theme.textMuted} paddingLeft={1} />
1721
+ <text content="" />
1722
+
1723
+ {timeline.entries.length === 0 ? (
1724
+ <box paddingLeft={2} paddingTop={1}>
1725
+ <text content="No scan history for this domain" fg={theme.textDisabled} />
1726
+ <text content="Scan this domain first to build history" fg={theme.textDisabled} />
1727
+ </box>
1728
+ ) : (
1729
+ timeline.entries.map((entry, i) => {
1730
+ const active = safeIdx === i;
1731
+ const statusColor = entry.status === "available" ? theme.primary : entry.status === "expired" ? theme.warning : entry.status === "error" ? theme.error : theme.textSecondary;
1732
+ const deltas = i < timeline.deltas.length ? timeline.deltas[i]! : [];
1733
+
1734
+ return (
1735
+ <box key={entry.id} flexDirection="column">
1736
+ {/* Scan entry row */}
1737
+ <box flexDirection="row" gap={1} paddingLeft={1} backgroundColor={active ? theme.primaryDim : "transparent"}>
1738
+ <text content={active ? "\u25b8" : " "} fg={theme.primary} />
1739
+ <text content={entry.scannedAt} fg={active ? theme.text : theme.textDisabled} />
1740
+ <text content={entry.status} fg={statusColor} />
1741
+ {entry.score !== null && <text content={`(${entry.score})`} fg={theme.textDisabled} />}
1742
+ </box>
1743
+
1744
+ {/* Deltas (changes from this scan to the next) */}
1745
+ {deltas.length > 0 && (
1746
+ <box flexDirection="column" paddingLeft={3}>
1747
+ {deltas.map((d, j) => (
1748
+ <box key={j} flexDirection="row" gap={1}>
1749
+ <text content={d.severity === "critical" ? "!!" : d.severity === "warning" ? " !" : " "} fg={d.severity === "critical" ? theme.error : d.severity === "warning" ? theme.warning : theme.textDisabled} />
1750
+ <text content={d.field} fg={theme.textMuted} />
1751
+ <text content={d.from} fg={theme.error} />
1752
+ <text content={"\u2192"} fg={theme.textDisabled} />
1753
+ <text content={d.to} fg={theme.primary} />
1754
+ </box>
1755
+ ))}
1756
+ </box>
1757
+ )}
1758
+ </box>
1759
+ );
1760
+ })
1761
+ )}
1762
+ </box>
1763
+ );
1764
+ })()}
1765
+ </scrollbox>
1766
+ ) : showActionMenu && selected ? (
1767
+ <box flexGrow={1} paddingLeft={2} paddingTop={1} minHeight={0} flexDirection="column">
1768
+ {/* Menu header */}
1769
+ <box flexDirection="row" gap={1}>
1770
+ <text content={selected.domain} fg={theme.text} />
1771
+ <text content={`(${statusStyle(selected.status).label})`} fg={statusStyle(selected.status).fg} />
1772
+ </box>
1773
+ <text content="" />
1774
+
1775
+ {/* Available actions — context-dependent */}
1776
+ {(selected.status === "available" || selected.status === "expired") && registrarConfig?.apiKey && (
1777
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1778
+ <box backgroundColor={theme.primaryDim}><text content=" r " fg={theme.primary} /></box>
1779
+ <text content="Register this domain" fg={theme.textSecondary} />
1780
+ </box>
1781
+ )}
1782
+
1783
+ {(selected.status === "taken" || selected.status === "expired") && (
1784
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1785
+ <box backgroundColor={theme.warningDim}><text content=" z " fg={theme.warning} /></box>
1786
+ <text content="Snipe (auto-register when it drops)" fg={theme.textSecondary} />
1787
+ </box>
1788
+ )}
1789
+
1790
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1791
+ <box backgroundColor={theme.secondaryDim}><text content=" p " fg={theme.secondary} /></box>
1792
+ <text content="Add to portfolio" fg={theme.textSecondary} />
1793
+ </box>
1794
+
1795
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1796
+ <box backgroundColor={theme.infoDim}><text content=" d " fg={theme.info} /></box>
1797
+ <text content="Suggest similar domains" fg={theme.textSecondary} />
1798
+ </box>
1799
+
1800
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1801
+ <box backgroundColor={theme.infoDim}><text content=" v " fg={theme.info} /></box>
1802
+ <text content="Generate variations" fg={theme.textSecondary} />
1803
+ </box>
1804
+
1805
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1806
+ <box backgroundColor={theme.infoDim}><text content=" e " fg={theme.info} /></box>
1807
+ <text content="Check other TLDs" fg={theme.textSecondary} />
1808
+ </box>
1809
+
1810
+ <text content="" />
1811
+
1812
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1813
+ <box backgroundColor={theme.textDisabled}><text content=" w " fg={theme.text} /></box>
1814
+ <text content="Tag for watching" fg={theme.textSecondary} />
1815
+ </box>
1816
+
1817
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1818
+ <box backgroundColor={theme.textDisabled}><text content=" h " fg={theme.text} /></box>
1819
+ <text content="History timeline (with deltas)" fg={theme.textSecondary} />
1820
+ </box>
1821
+
1822
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1823
+ <box backgroundColor={theme.textDisabled}><text content=" l " fg={theme.text} /></box>
1824
+ <text content="Load previous scan" fg={theme.textSecondary} />
1825
+ </box>
1826
+
1827
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1828
+ <box backgroundColor={theme.textDisabled}><text content=" c " fg={theme.text} /></box>
1829
+ <text content="Clear cache" fg={theme.textSecondary} />
1830
+ </box>
1831
+
1832
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1833
+ <box backgroundColor={theme.textDisabled}><text content=" x " fg={theme.text} /></box>
1834
+ <text content="Export results" fg={theme.textSecondary} />
1835
+ </box>
1836
+
1837
+ <text content="" />
1838
+
1839
+ <box flexDirection="row" gap={1} paddingLeft={1}>
1840
+ <box backgroundColor={theme.primaryDim}><text content=" ⏎ " fg={theme.primary} /></box>
1841
+ <text content="Rescan domain" fg={theme.textSecondary} />
1842
+ </box>
1843
+
1844
+ <text content="" />
1845
+ <text content=" Press a key to act, Esc to cancel" fg={theme.textDisabled} />
1846
+ </box>
1361
1847
  ) : selected ? (
1362
1848
  <scrollbox flexGrow={1} paddingLeft={1} paddingRight={1} minHeight={0} scrollbarOptions={{ visible: true }}>
1363
1849
  <box flexDirection="column" gap={0} paddingRight={1}>
@@ -1405,15 +1891,15 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
1405
1891
  <box paddingLeft={2} paddingTop={0} flexShrink={0}>
1406
1892
  <text content={
1407
1893
  selected.status === "available" && registrarConfig?.apiKey
1408
- ? "\u2192 Press r to register | p to add to portfolio | M then l to list for sale"
1894
+ ? "\u2192 Press r to register | p to add to portfolio | m then l to list for sale"
1409
1895
  : selected.status === "available"
1410
- ? "\u2192 Press p to add to portfolio | M then l to list for sale"
1896
+ ? "\u2192 Press p to add to portfolio | m then l to list for sale"
1411
1897
  : selected.status === "expired"
1412
- ? "\u2192 Press D for drop catch | w to watch for availability"
1898
+ ? "\u2192 Press z to snipe (auto drop catch) | w to watch for availability"
1413
1899
  : selected.status === "taken"
1414
1900
  ? "\u2192 Press d for alternatives | v for variations | Tab for more intel"
1415
1901
  : selected.status === "registered"
1416
- ? "\u2192 Press p to add to portfolio | M then l to list for sale"
1902
+ ? "\u2192 Press p to add to portfolio | m then l to list for sale"
1417
1903
  : selected.status === "error"
1418
1904
  ? "\u2192 Press c to clear cache and rescan"
1419
1905
  : ""
@@ -1880,7 +2366,7 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
1880
2366
  <text content="" />
1881
2367
  <box flexDirection="row" gap={1} justifyContent="center">
1882
2368
  <text content="◆" fg={theme.primary} />
1883
- <text content="DOMAIN SNIPER v0.1.2" fg={theme.primary} />
2369
+ <text content="DOMAIN SNIPER v0.2.0" fg={theme.primary} />
1884
2370
  </box>
1885
2371
  <box justifyContent="center">
1886
2372
  <text content="Domain Intelligence & Security Recon" fg={theme.textDisabled} />
@@ -1977,8 +2463,8 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
1977
2463
  <box flexDirection="row" gap={1}><box backgroundColor={theme.primaryDim}><text content=" ? " fg={theme.primary} /></box><text content="help" fg={theme.textSecondary} /></box>
1978
2464
  </box>
1979
2465
  <box flexDirection="row" justifyContent="center" gap={3}>
1980
- <box flexDirection="row" gap={1}><box backgroundColor={theme.secondaryDim}><text content=" M " fg={theme.secondary} /></box><text content="market" fg={theme.textSecondary} /></box>
1981
- <box flexDirection="row" gap={1}><box backgroundColor={theme.secondaryDim}><text content=" P " fg={theme.secondary} /></box><text content="portfolio" fg={theme.textSecondary} /></box>
2466
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.secondaryDim}><text content=" m " fg={theme.secondary} /></box><text content="market" fg={theme.textSecondary} /></box>
2467
+ <box flexDirection="row" gap={1}><box backgroundColor={theme.secondaryDim}><text content=" b " fg={theme.secondary} /></box><text content="portfolio" fg={theme.textSecondary} /></box>
1982
2468
  <box flexDirection="row" gap={1}><box backgroundColor={theme.accentDim}><text content=" n " fg={theme.accent} /></box><text content="recon" fg={theme.textSecondary} /></box>
1983
2469
  <box flexDirection="row" gap={1}><box backgroundColor={theme.errorDim}><text content=" q " fg={theme.error} /></box><text content="quit" fg={theme.textSecondary} /></box>
1984
2470
  </box>
@@ -2022,43 +2508,56 @@ export function App({ initialDomains, batchFile, autoRegister = false }: AppProp
2022
2508
  <text content={dLine(width)} fg={theme.border} paddingLeft={1} />
2023
2509
  <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1}>
2024
2510
  <box flexDirection="row" gap={1}>
2025
- {mode !== "input" ? (
2026
- (() => {
2027
- const footerHints: { key: string; label: string; priority: number; onClick?: () => void }[] = [
2028
- { key: "/", label: "scan", priority: 1, onClick: () => { setInputMode("domain"); setMode("input"); setInputValue(""); } },
2029
- { key: "␣", label: "tag", priority: 2 },
2030
- { key: "?", label: "help", priority: 3, onClick: () => setShowHelp((v) => !v) },
2031
- { key: "n", label: reconMode ? "recon:ON" : "recon", priority: 4, onClick: () => setReconMode((v) => !v) },
2032
- { key: "M", label: "market", priority: 5, onClick: () => { if (!showMarket) { setShowMarket(true); setMarketView("browse"); setMarketSelectedIdx(0); void loadMarketListings(); } else { setShowMarket(false); } } },
2033
- { key: "S", label: "snipe", priority: 6 },
2034
- { key: "e", label: "expand", priority: 7, onClick: () => { setInputMode("expand"); setMode("input"); setInputValue(""); } },
2035
- { key: "Tab", label: "tabs", priority: 8 },
2036
- ...(registrarConfig?.apiKey ? [{ key: "r", label: "reg", priority: 9 }] : []),
2037
- { key: "d", label: "suggest", priority: 10 },
2038
- { key: "P", label: showPortfolio ? "close" : "dash", priority: 11, onClick: () => setShowPortfolio((v) => !v) },
2039
- { key: "p", label: "portfolio", priority: 12 },
2040
- ];
2041
- const maxHints = Math.floor((width - 20) / 10);
2042
- const visibleHints = footerHints.slice(0, maxHints);
2043
- return (
2044
- <>
2045
- {visibleHints.map((h) => (
2046
- <box key={h.key} flexDirection="row" gap={0} onMouseDown={h.onClick}>
2047
- <box backgroundColor={theme.textDisabled}><text content={` ${h.key} `} fg={theme.background} /></box>
2048
- <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} />
2049
- </box>
2050
- ))}
2051
- </>
2052
- );
2053
- })()
2054
- ) : (
2511
+ {showHistory ? (
2512
+ <>
2513
+ <text content={"\u2191\u2193 navigate"} fg={theme.textMuted} />
2514
+ <box backgroundColor={theme.primaryDim}><text content={" \u23ce "} fg={theme.primary} /></box><text content="load scan" fg={theme.textMuted} />
2515
+ <box backgroundColor={theme.textDisabled}><text content=" esc " fg={theme.background} /></box><text content="close" fg={theme.textMuted} />
2516
+ </>
2517
+ ) : showActionMenu ? (
2518
+ <>
2519
+ <text content="Press a key or" fg={theme.textMuted} />
2520
+ <box backgroundColor={theme.textDisabled}><text content=" esc " fg={theme.background} /></box>
2521
+ <text content="cancel" fg={theme.textMuted} />
2522
+ </>
2523
+ ) : showMarket ? (
2524
+ <>
2525
+ <box backgroundColor={theme.textDisabled}><text content=" 1 " fg={theme.background} /></box><text content="browse" fg={theme.textMuted} />
2526
+ <box backgroundColor={theme.textDisabled}><text content=" / " fg={theme.background} /></box><text content="search" fg={theme.textMuted} />
2527
+ <box backgroundColor={theme.textDisabled}><text content=" m " fg={theme.background} /></box><text content="close" fg={theme.textMuted} />
2528
+ </>
2529
+ ) : showPortfolio ? (
2530
+ <>
2531
+ <box backgroundColor={theme.textDisabled}><text content=" b " fg={theme.background} /></box><text content="close" fg={theme.textMuted} />
2532
+ </>
2533
+ ) : mode === "input" ? (
2055
2534
  <>
2056
2535
  <box backgroundColor={theme.textDisabled}><text content=" ⏎ " fg={theme.background} /></box><text content="submit" fg={theme.textMuted} />
2057
2536
  <box backgroundColor={theme.textDisabled}><text content=" esc " fg={theme.background} /></box><text content="cancel" fg={theme.textMuted} />
2058
2537
  </>
2538
+ ) : mode === "idle" ? (
2539
+ <>
2540
+ <box backgroundColor={theme.primaryDim} onMouseDown={() => { setInputMode("domain"); setMode("input"); setInputValue(""); }}><text content=" / " fg={theme.primary} /></box><text content="scan" fg={theme.textMuted} />
2541
+ <box backgroundColor={theme.primaryDim} onMouseDown={() => { setInputMode("expand"); setMode("input"); setInputValue(""); }}><text content=" e " fg={theme.primary} /></box><text content="expand" fg={theme.textMuted} />
2542
+ <box backgroundColor={theme.primaryDim} onMouseDown={() => { setInputMode("file"); setMode("input"); setInputValue(""); }}><text content=" f " fg={theme.primary} /></box><text content="file" fg={theme.textMuted} />
2543
+ <box backgroundColor={theme.textDisabled} onMouseDown={() => setShowHelp(true)}><text content=" ? " fg={theme.background} /></box><text content="help" fg={theme.textMuted} />
2544
+ </>
2545
+ ) : selected ? (
2546
+ <>
2547
+ <box backgroundColor={theme.primaryDim} onMouseDown={() => setShowActionMenu(true)}><text content=" ⏎ " fg={theme.primary} /></box><text content="actions" fg={theme.textMuted} />
2548
+ <box backgroundColor={theme.textDisabled}><text content=" ␣ " fg={theme.background} /></box><text content="tag" fg={theme.textMuted} />
2549
+ <box backgroundColor={theme.textDisabled} onMouseDown={() => { setInputMode("domain"); setMode("input"); setInputValue(""); }}><text content=" / " fg={theme.background} /></box><text content="scan" fg={theme.textMuted} />
2550
+ <box backgroundColor={theme.textDisabled}><text content=" Tab " fg={theme.background} /></box><text content="tabs" fg={theme.textMuted} />
2551
+ <box backgroundColor={theme.textDisabled} onMouseDown={() => setShowHelp(true)}><text content=" ? " fg={theme.background} /></box><text content="all" fg={theme.textMuted} />
2552
+ </>
2553
+ ) : (
2554
+ <>
2555
+ <box backgroundColor={theme.textDisabled}><text content=" / " fg={theme.background} /></box><text content="scan" fg={theme.textMuted} />
2556
+ <box backgroundColor={theme.textDisabled}><text content=" ? " fg={theme.background} /></box><text content="help" fg={theme.textMuted} />
2557
+ </>
2059
2558
  )}
2060
2559
  </box>
2061
- <text content={stats.total > 0 ? `${stats.available + stats.expired}/${stats.total} actionable` : "v0.1.2"} fg={theme.textDisabled} />
2560
+ <text content={stats.total > 0 ? `${stats.available + stats.expired}/${stats.total} actionable` : "v0.2.0"} fg={theme.textDisabled} />
2062
2561
  </box>
2063
2562
  </box>
2064
2563
  </box>