Files
Shadowbroker/frontend/src/components/InfonetTerminal/InfonetShell.tsx
T
Shadowbroker 1d7fa5185a feat(infonet): private gate + DM hashchain spool with hardened propagation (#326)
Private gate messages and offline DMs now ride the Infonet hashchain
as ciphertext-only events, replicated across nodes via private
transports (Tor onion / RNS / loopback) and decrypted only by parties
holding the gate or recipient keys.

Hashchain core (mesh_hashchain.py)
----------------------------------

* New ``append_private_gate_message`` and ``append_private_dm_message``
  append paths with full signature verification, public-key binding,
  revocation check, and replay protection in a dedicated sequence
  domain (so a gate post does not consume the author's public broadcast
  sequence, and a DM cannot replay-block a public message at sequence=1).
* Fork validation and full-chain validation now accept the gate
  signature compatibility variants — older signatures that canonicalize
  with/without epoch or reply_to still verify, so a re-sync from an
  older peer doesn't reject still-valid history.
* DM hashchain spool: capped at 2 active sealed offline DMs per
  recipient mailbox, plus a per-(sender, recipient) cap so one prolific
  sender can't consume both slots. 1-hour TTL on the cap counter.
  Spool intentionally small — it's an offline bootstrap channel,
  not a persistent mailbox.
* Rebuild-state preserves the gate sequence domain across reloads so
  a chain reload doesn't accidentally let an old gate sequence
  replay-collide on next append.

Schema enforcement (mesh_schema.py)
-----------------------------------

* Private gate + DM payloads have closed allowlists of fields.
  Plaintext keys (``message``, ``plaintext``, ``_local_plaintext``,
  ``_local_reply_to``) are explicit rejection-bait — they raise before
  the event ever touches the chain.
* DM ciphertext + nonce must look like base64-ish sealed bytes;
  obvious base64-encoded plaintext shapes are rejected.
* ``transport_lock`` required: DM hashchain spool requires
  ``private_strong``; gate accepts ``private``/``private_strong``/
  ``rns``/``onion``.

Defense-in-depth at the network layer (main.py + mesh_public.py)
----------------------------------------------------------------

* ``_infonet_sync_response_events`` now silently redacts private events
  (gate_message + dm_message) unless the request looks like a loopback /
  onion / RNS / private transport caller. If an operator accidentally
  exposes :8000 to the public internet, an external puller gets
  public events only — never ciphertext.
* ``_sync_from_peer`` raises ``PeerSyncRateLimited`` for 429 (handled
  as 4-tuple return with retry_after_s) and ``PeerSyncHTTPError`` for
  other non-200 statuses (handled by ``_run_public_sync_cycle`` to
  honor server cooldown hints even outside the 429 path).

DM relay hydration (main.py)
-----------------------------

* New ``_hydrate_dm_relay_from_chain``: when accepted dm_message chain
  events arrive on a node, they get deposited into the local DM relay
  store with a deterministic sender_token_hash so re-sync of the same
  event is idempotent. Recipients see the ciphertext as a normal DM
  on their next poll and decrypt with their existing recipient key.

Other surfaces
--------------

* meshnode.bat / meshnode.sh now set ``MESH_INFONET_ALLOW_CLEARNET_SYNC=
  false`` and the participant runtime flags by default so a freshly
  spun-up node defaults to private-only sync.
* InfonetTerminal/InfonetShell.tsx adds a gate directory renderer for
  the new private-gate workflow.
* docker-compose.relay.yml binds the relay backend to 127.0.0.1:8000
  only; Tor's hidden service forwards onion traffic into 127.0.0.1.
  Public clearnet :8000 stays off the network edge.

Tests
-----

* 7 new tests in test_private_gate_hashchain.py + test_private_dm_
  hashchain.py covering: gate fork accepts ciphertext propagation,
  gate fork rejects plaintext, append rejects plaintext before
  normalize, append requires private_strong, append rejects
  non-sealed ciphertext shape, DM spool 2-per-recipient + 1-per-pair
  cap, DM hydration delivers to poll/claim.
* Updated test_mesh_node_bootstrap_runtime.py covers 429 backoff via
  PeerSyncRateLimited 4-tuple AND PeerSyncHTTPError exception.
* Updated test_s14b_public_sync_gate_filter.py + test_s9b_gate_store_
  hydration.py + test_gate_write_cutover.py cover the new private
  redaction on public sync responses.
* test_private_gate_hashchain.py + test_private_dm_hashchain.py:
  10 passed locally.
* Combined mesh-relevant suite (the 5 modified existing tests +
  2 new): 17 passed.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:25:18 -06:00

871 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Terminal, Radio, Globe, Key, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
import {
activateWormholeGatePersona,
createWormholeGatePersona,
enterWormholeGate,
fetchWormholeIdentity,
listWormholeGatePersonas,
} from '@/mesh/wormholeIdentityClient';
import GateView from './GateView';
import MarketView from './MarketView';
import ProfileView from './ProfileView';
import MessagesView from './MessagesView';
import TerminalDashboard from './TerminalDashboard';
import WeatherWidget from './WeatherWidget';
import TrendingPosts from './TrendingPosts';
import HashchainEvents from './HashchainEvents';
import NetworkStats from './NetworkStats';
import AIQueryView from './AIQueryView';
import PetitionsView from './PetitionsView';
import UpgradeView from './UpgradeView';
import ResolutionView from './ResolutionView';
import GateShutdownView from './GateShutdownView';
import BootstrapView from './BootstrapView';
import FunctionKeyView from './FunctionKeyView';
const ASCII_HEADER = `
T H E
██╗███╗ ██╗███████╗██████╗ ███╗ ██╗███████╗████████╗
██║████╗ ██║██╔════╝██╔═══██╗████╗ ██║██╔════╝╚══██╔══╝
██║██╔██╗ ██║█████╗ ██║ ██║██╔██╗ ██║█████╗ ██║
██║██║╚██╗██║██╔══╝ ██║ ██║██║╚██╗██║██╔══╝ ██║
██║██║ ╚████║██║ ╚██████╔╝██║ ╚████║███████╗ ██║
╚═╝╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ ╚═╝
C O M M O N S
======================================
INFONET SOVEREIGN SHELL v0.1.1 (TEST)
TEST-NET CONNECTION ESTABLISHED
======================================
`;
const COMING_SOON_MODULES: Record<string, { title: string; desc: string; status: string }> = {
// BALLOT entry removed 2026-04-28: the BALLOT command now navigates
// to PetitionsView (live governance DSL + petition lifecycle).
GIGS: {
title: 'GIGS — NETWORK BOUNTIES',
desc: 'Decentralized work contracts, intelligence bounties, and mesh task allocation. Accept jobs, deliver payloads, and earn credits through verified proof-of-work completion.',
status: 'MODULE STATUS: TESTNET ONLY — CONTRACT ENGINE IN DEVELOPMENT',
},
EXCHANGE: {
title: 'EXCHANGE — DECENTRALIZED TRADING',
desc: 'Zero-KYC peer-to-peer asset exchange. Trade crypto against credits with on-chain order books, stealth addresses, and privacy-preserving settlement.',
status: 'MODULE STATUS: TESTNET ONLY — LIQUIDITY POOLS NOT YET ACTIVE',
},
};
const GATES = [
'infonet', 'general-talk', 'gathered-intel', 'tracked-planes',
'ukraine-front', 'iran-front', 'world-news', 'prediction-markets',
'finance', 'cryptography', 'cryptocurrencies', 'meet-chat', 'opsec-lab'
];
const GATE_LAUNCH_RETRY_DELAY_MS = 3000;
const GATE_LAUNCH_RETRY_ATTEMPTS = 20;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function isGateLaneStartingError(detail: string): boolean {
const lowered = String(detail || '').trim().toLowerCase();
return lowered.includes('obfuscated lane is still starting');
}
const SHELL_ANON_PERSONAS_KEY = 'sb_infonet_shell_anon_personas';
function readShellAnonPersonas(): string[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(SHELL_ANON_PERSONAS_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.map((value) => String(value || '').trim()).filter(Boolean) : [];
} catch {
return [];
}
}
function writeShellAnonPersonas(personas: string[]): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(SHELL_ANON_PERSONAS_KEY, JSON.stringify(personas));
} catch {
/* ignore */
}
}
function allocateShellAnonPersona(): string {
const existing = readShellAnonPersonas();
const used = new Set(existing.map((persona) => persona.toLowerCase()));
for (let attempt = 0; attempt < 10_000; attempt += 1) {
const candidate = `anon_${Math.floor(100 + Math.random() * 9_900)}`;
if (used.has(candidate.toLowerCase())) continue;
writeShellAnonPersonas([...existing, candidate]);
return candidate;
}
const fallback = `anon_${Date.now()}`;
writeShellAnonPersonas([...existing, fallback]);
return fallback;
}
const SECTIONS = [
{ name: 'HELP', icon: <Terminal size={14} className="mr-2" /> },
{ name: 'AI', icon: <Brain size={14} className="mr-2" /> },
{ name: 'BALLOT', icon: <Vote size={14} className="mr-2" /> },
{ name: 'UPGRADES', icon: <GitBranch size={14} className="mr-2" /> },
{ name: 'BOOTSTRAP', icon: <Cpu size={14} className="mr-2" /> },
{ name: 'F-KEYS', icon: <KeyRound size={14} className="mr-2" /> },
{ name: 'GIGS', icon: <Briefcase size={14} className="mr-2" /> },
{ name: 'MESH', icon: <Globe size={14} className="mr-2" /> },
{ name: 'GATES', icon: <Key size={14} className="mr-2" /> },
{ name: 'MARKETS', icon: <Activity size={14} className="mr-2" /> },
{ name: 'EXCHANGE', icon: <ArrowRightLeft size={14} className="mr-2" /> },
{ name: 'PROFILE', icon: <User size={14} className="mr-2" /> },
{ name: 'MESSAGES', icon: <Mail size={14} className="mr-2" /> },
];
interface CommandHistory {
command: string;
output: React.ReactNode;
}
interface InfonetShellProps {
isOpen: boolean;
onClose: () => void;
onOpenLiveGate?: (gate: string) => void;
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
}
export default function InfonetShell({
isOpen,
onClose,
onOpenLiveGate,
onOpenDeadDrop,
}: InfonetShellProps) {
const [input, setInput] = useState('');
const [history, setHistory] = useState<CommandHistory[]>([]);
const [isBooting, setIsBooting] = useState(true);
const [bootText, setBootText] = useState<string[]>([]);
// Navigation & State
type ViewName =
| 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai'
| 'petitions' | 'upgrades' | 'resolution' | 'gate-shutdown'
| 'bootstrap' | 'function-keys';
const [currentView, setCurrentView] = useState<ViewName>('terminal');
const [activeGate, setActiveGate] = useState<string | null>(null);
const [persona, setPersona] = useState<string | null>(null);
const [activeGateMode, setActiveGateMode] = useState<'anonymous' | 'persona' | null>(null);
const [inputMode, setInputMode] = useState<'normal' | 'persona'>('normal');
const [pendingGate, setPendingGate] = useState<string | null>(null);
const [isCitizen] = useState(false);
const [comingSoonModule, setComingSoonModule] = useState<string | null>(null);
const [wormholePromptKey, setWormholePromptKey] = useState('');
// Targets for parameterized economy views.
const [resolutionMarketId, setResolutionMarketId] = useState<string | null>(null);
const [shutdownGateId, setShutdownGateId] = useState<string | null>(null);
const [bootstrapMarketId, setBootstrapMarketId] = useState<string | null>(null);
const endOfTerminalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const gateLaunchAttemptRef = useRef(0);
// Real mesh identity
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
const wormholeDescriptor = useMemo(() => getWormholeIdentityDescriptor(), []);
const promptHost = useMemo(
() =>
String(
nodeIdentity?.publicKey || wormholePromptKey || wormholeDescriptor?.publicKey || 'no-public-key',
).trim() || 'no-public-key',
[nodeIdentity?.publicKey, wormholeDescriptor?.publicKey, wormholePromptKey],
);
const shellPrompt = `${isCitizen ? 'citizen' : 'sovereign'}@${promptHost}:~$`;
/* Reset + boot sequence when opened */
useEffect(() => {
if (!isOpen) return;
// Reset state
setHistory([]);
setCurrentView('terminal');
setActiveGate(null);
setPersona(null);
setActiveGateMode(null);
setInputMode('normal');
setPendingGate(null);
setInput('');
gateLaunchAttemptRef.current += 1;
setIsBooting(true);
setBootText([]);
const bootLines = [
'INITIALIZING KERNEL...',
'LOADING MODULES: [OK]',
'MOUNTING VFS: [OK]',
'STARTING NETWORK INTERFACES...',
'CONNECTING TO INFONET MESH...',
'ESTABLISHING SECURE TUNNEL...',
'HANDSHAKE COMPLETE.',
'WELCOME SOVEREIGN.'
];
let currentLine = 0;
const interval = setInterval(() => {
if (currentLine < bootLines.length) {
setBootText(prev => [...prev, bootLines[currentLine]]);
currentLine++;
} else {
clearInterval(interval);
setTimeout(() => setIsBooting(false), 500);
}
}, 150);
return () => clearInterval(interval);
}, [isOpen]);
/* Focus input after boot — scoped to container */
useEffect(() => {
if (!isBooting && isOpen) {
inputRef.current?.focus();
const container = containerRef.current;
if (!container) return;
const handleGlobalClick = () => {
if (window.getSelection()?.toString()) return;
inputRef.current?.focus();
};
container.addEventListener('click', handleGlobalClick);
return () => container.removeEventListener('click', handleGlobalClick);
}
}, [isBooting, isOpen]);
useEffect(() => {
let cancelled = false;
if (!isOpen || nodeIdentity?.publicKey) return;
void (async () => {
try {
const identity = await fetchWormholeIdentity();
if (!cancelled) {
setWormholePromptKey(String(identity?.public_key || '').trim());
}
} catch {
if (!cancelled) {
setWormholePromptKey('');
}
}
})();
return () => {
cancelled = true;
};
}, [isOpen, nodeIdentity?.publicKey]);
/* Scroll to bottom */
useEffect(() => {
endOfTerminalRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [history]);
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai', gate?: string) => {
if (view === 'gate' && gate) {
if (onOpenLiveGate) {
setPendingGate(gate);
setInputMode('persona');
setHistory(prev => [...prev, {
command: `join ${gate}`,
output: (
<span className="text-cyan-400">
Type a gate face label to open the encrypted room, or type
{' '}
<span className="font-bold text-white">anon</span>
{' '}
for a rotating obfuscated session that opens the room under a fresh gate-scoped key.
{' '}
<span className="text-red-400">&apos;shadowbroker&apos; is reserved.</span>
</span>
)
}]);
return;
}
setActiveGate(gate);
setActiveGateMode(persona ? 'persona' : 'anonymous');
}
setCurrentView(view);
};
const renderGateDirectory = (variant: 'landing' | 'command' = 'command') => (
<div
className={
variant === 'landing'
? 'w-full max-w-3xl border border-cyan-950/50 bg-black/20 px-4 py-3 text-left shadow-[0_0_18px_rgba(6,182,212,0.06)]'
: 'text-gray-400'
}
>
<p className={`${variant === 'landing' ? 'text-[11px]' : ''} text-gray-400 uppercase tracking-[0.18em]`}>
AVAILABLE OBFUSCATED GATES:
</p>
<div className={`grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 ${variant === 'landing' ? 'gap-x-8 gap-y-1.5 mt-2' : 'gap-2 mt-2'}`}>
{GATES.map(gate => (
<button
key={gate}
type="button"
className="group flex min-h-[24px] items-center text-left text-gray-300 hover:text-white transition-colors"
onClick={() => handleNavigate('gate', gate)}
>
<span className="text-gray-500 mr-2 group-hover:text-cyan-400 transition-colors">[{'>'}]</span>
<span className="truncate group-hover:drop-shadow-[0_0_5px_rgba(6,182,212,0.8)]">{gate}</span>
</button>
))}
</div>
</div>
);
const openGateWhenReady = async (
gateTarget: string,
operation: () => Promise<void>,
options: { commandLabel: string; waitingOutput: React.ReactNode; failurePrefix: string },
) => {
const launchId = ++gateLaunchAttemptRef.current;
let waitingShown = false;
for (let attempt = 0; attempt < GATE_LAUNCH_RETRY_ATTEMPTS; attempt += 1) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
try {
await operation();
return;
} catch (error) {
const detail = error instanceof Error ? error.message : options.failurePrefix;
if (!isGateLaneStartingError(detail)) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
setHistory(prev => [...prev, {
command: options.commandLabel,
output: <span className="text-red-400">ERR: {detail}</span>,
}]);
return;
}
if (!waitingShown) {
waitingShown = true;
setHistory(prev => [...prev, {
command: options.commandLabel,
output: options.waitingOutput,
}]);
}
if (attempt === GATE_LAUNCH_RETRY_ATTEMPTS - 1) {
if (gateLaunchAttemptRef.current !== launchId) {
return;
}
setHistory(prev => [...prev, {
command: options.commandLabel,
output: (
<span className="text-red-400">
ERR: The obfuscated lane is taking too long to come online. It is still warming up in the background.
</span>
),
}]);
return;
}
await sleep(GATE_LAUNCH_RETRY_DELAY_MS);
}
}
};
const handleCommand = (cmd: string) => {
const trimmedCmd = cmd.trim().toLowerCase();
let output: React.ReactNode = '';
if (trimmedCmd === '') return;
if (inputMode === 'persona') {
if (trimmedCmd === 'shadowbroker') {
output = <span className="text-red-500 font-bold animate-pulse">ERR: Persona &apos;shadowbroker&apos; is reserved and cannot be claimed.</span>;
setHistory(prev => [...prev, { command: cmd, output }]);
return;
}
if (!pendingGate) {
setInputMode('normal');
output = <span className="text-red-400">ERR: No pending gate launch target.</span>;
setHistory(prev => [...prev, { command: cmd, output }]);
return;
}
const chosenPersona = trimmedCmd === 'anon' ? allocateShellAnonPersona() : cmd.trim();
setPersona(chosenPersona);
setInputMode('normal');
const gateTarget = pendingGate;
if (trimmedCmd === 'anon') {
output = (
<span className="text-amber-300">
Rotating anonymous gate key for g/{gateTarget}...
</span>
);
setHistory(prev => [...prev, { command: cmd, output }]);
setPendingGate(null);
void (async () => {
await openGateWhenReady(
gateTarget,
async () => {
await enterWormholeGate(gateTarget, true);
setActiveGateMode('anonymous');
setActiveGate(gateTarget);
setCurrentView('gate');
},
{
commandLabel: `gate ${gateTarget}`,
waitingOutput: (
<span className="text-cyan-400">
Warming the obfuscated lane for g/{gateTarget}. The room will open automatically as soon as it is ready.
</span>
),
failurePrefix: 'anonymous_gate_enter_failed',
},
);
})();
return;
}
output = <span className="text-green-400">Creating gate face &apos;{chosenPersona}&apos; for g/{gateTarget}...</span>;
setHistory(prev => [...prev, { command: cmd, output }]);
setPendingGate(null);
void (async () => {
await openGateWhenReady(
gateTarget,
async () => {
const personas = await listWormholeGatePersonas(gateTarget);
const existing = Array.isArray(personas?.personas)
? personas.personas.find(
(candidate) =>
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
)
: null;
const result = existing?.persona_id
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
: await createWormholeGatePersona(gateTarget, chosenPersona);
if (!result?.ok) {
throw new Error(result?.detail || 'gate_face_create_failed');
}
setActiveGateMode('persona');
setActiveGate(gateTarget);
setCurrentView('gate');
},
{
commandLabel: `join ${gateTarget}`,
waitingOutput: (
<span className="text-cyan-400">
Warming the obfuscated lane for g/{gateTarget}. Your gate face will open automatically when the room is ready.
</span>
),
failurePrefix: 'gate_face_create_failed',
},
);
})();
return;
}
if (trimmedCmd === 'help') {
output = (
<div className="text-gray-400">
<p>AVAILABLE COMMANDS:</p>
<ul className="list-disc list-inside ml-2 mt-1">
<li><span className="text-gray-300 font-bold">help</span> - Display this message</li>
<li><span className="text-gray-300 font-bold">clear</span> - Clear terminal output</li>
<li><span className="text-gray-300 font-bold">mesh</span> - Access public mesh ledger</li>
<li><span className="text-gray-300 font-bold">radio</span> - Open SIGINT / radio surfaces</li>
<li><span className="text-gray-300 font-bold">messages</span> - Open Secure Comms</li>
<li><span className="text-gray-300 font-bold">profile</span> - View sovereign identity & ledger</li>
<li><span className="text-gray-300 font-bold">ballot / petitions / governance</span> - File / sign / vote on petitions (DSL executor)</li>
<li><span className="text-gray-300 font-bold">upgrades</span> - Upgrade-hash governance + Heavy-Node readiness</li>
<li><span className="text-gray-300 font-bold">resolution [market_id]</span> - Evidence + dispute view</li>
<li><span className="text-gray-300 font-bold">shutdown [gate_id]</span> - Gate suspend / shutdown / appeal lifecycle</li>
<li><span className="text-gray-300 font-bold">bootstrap</span> - Bootstrap-mode resolution + ramp milestones</li>
<li><span className="text-gray-300 font-bold">fkeys / function-keys</span> - Anonymous citizenship proof design</li>
<li><span className="text-gray-300 font-bold">gigs</span> - View network bounties & jobs</li>
<li><span className="text-gray-300 font-bold">markets</span> - View prediction markets</li>
<li><span className="text-gray-300 font-bold">exchange</span> - Decentralized crypto exchange</li>
<li><span className="text-gray-300 font-bold">wormhole</span> - Check secure tunneling status</li>
<li><span className="text-gray-300 font-bold">gates</span> - List available obfuscated gates</li>
<li><span className="text-gray-300 font-bold">join [gate]</span> - Choose anonymous entry or a gate face, then enter the room</li>
<li><span className="text-gray-300 font-bold">exit</span> - Disconnect from Infonet</li>
</ul>
</div>
);
} else if (trimmedCmd === 'clear') {
setHistory([]);
return;
} else if (trimmedCmd === 'gates') {
output = renderGateDirectory('command');
} else if (trimmedCmd.startsWith('join ') || trimmedCmd.startsWith('g/')) {
const target = trimmedCmd.startsWith('g/') ? trimmedCmd.slice(2) : trimmedCmd.split(' ')[1];
if (GATES.includes(target)) {
handleNavigate('gate', target);
return;
} else {
output = <span className="text-red-400">ERR: Gate &apos;{target}&apos; not found or access denied.</span>;
}
} else if (trimmedCmd === 'ai' || trimmedCmd === 'copilot' || trimmedCmd === 'openclaw') {
handleNavigate('ai');
return;
} else if (trimmedCmd === 'markets') {
handleNavigate('market');
return;
} else if (trimmedCmd === 'messages') {
handleNavigate('messages');
return;
} else if (trimmedCmd === 'profile') {
handleNavigate('profile');
return;
} else if (trimmedCmd === 'ballot' || trimmedCmd === 'petitions' || trimmedCmd === 'governance') {
setCurrentView('petitions');
return;
} else if (trimmedCmd === 'upgrades' || trimmedCmd === 'upgrade') {
setCurrentView('upgrades');
return;
} else if (trimmedCmd === 'bootstrap') {
setBootstrapMarketId(null);
setCurrentView('bootstrap');
return;
} else if (trimmedCmd === 'function-keys' || trimmedCmd === 'fkeys') {
setCurrentView('function-keys');
return;
} else if (trimmedCmd.startsWith('resolution ')) {
const mid = trimmedCmd.slice('resolution '.length).trim();
if (mid) {
setResolutionMarketId(mid);
setCurrentView('resolution');
return;
}
output = <span className="text-red-400">Usage: resolution &lt;market_id&gt;</span>;
} else if (trimmedCmd.startsWith('shutdown ')) {
const gid = trimmedCmd.slice('shutdown '.length).trim();
if (gid) {
setShutdownGateId(gid);
setCurrentView('gate-shutdown');
return;
}
output = <span className="text-red-400">Usage: shutdown &lt;gate_id&gt;</span>;
} else if (trimmedCmd === 'work' || trimmedCmd === 'gigs') {
setComingSoonModule('GIGS');
return;
} else if (trimmedCmd === 'exchange') {
setComingSoonModule('EXCHANGE');
return;
} else if (trimmedCmd === 'mesh') {
output = (
<div className="text-gray-400">
<p>SYNCING PUBLIC MESH LEDGER...</p>
<p className="text-gray-500 mt-1">Block: #894921 | Hash: 0x9f8a...2b1c</p>
<p className="text-gray-500">Block: #894920 | Hash: 0x3e1d...9a4f</p>
<p className="text-gray-500">Block: #894919 | Hash: 0x7c2b...1e8d</p>
<p className="text-green-400 mt-2">Ledger synchronized.</p>
</div>
);
} else if (trimmedCmd === 'radio') {
output = (
<div className="text-gray-400">
<p className="flex items-center"><Radio size={14} className="mr-2 animate-pulse text-red-400" /> SCANNING FREQUENCIES...</p>
<p className="text-gray-500 mt-1">144.390 MHz - APRS traffic detected</p>
<p className="text-gray-500">462.562 MHz - Encrypted burst</p>
<p className="text-gray-500">8.992 MHz - EAM broadcast intercepted</p>
</div>
);
} else if (trimmedCmd === 'wormhole') {
output = (
<div className="text-gray-400">
<p>OBFUSCATED LANE STATUS:</p>
<p className="text-gray-500 mt-1">Status: <span className="text-green-400">ONLINE</span></p>
<p className="text-gray-500">Active Tunnels: 3</p>
<p className="text-gray-500 mt-2">Use <span className="text-gray-300 font-bold">join [gate]</span> to open an obfuscated gate room.</p>
</div>
);
} else if (trimmedCmd === 'whoami') {
output = (
<span className="text-gray-400">
{`${persona || 'unassigned'}${nodeIdentity?.nodeId ? ` (${nodeIdentity.nodeId})` : ''}${nodeIdentity?.publicKey ? ` / ${nodeIdentity.publicKey}` : ''}`}
</span>
);
} else if (trimmedCmd === 'date') {
output = <span className="text-gray-400">{new Date().toISOString()}</span>;
} else if (trimmedCmd === 'exit') {
onClose();
return;
} else {
output = <span className="text-red-400">Command not recognized: {trimmedCmd}. Type &apos;help&apos; for available commands.</span>;
}
setHistory(prev => [...prev, { command: cmd, output }]);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
handleCommand(`join ${searchMatch}`);
} else {
handleCommand(input);
}
setInput('');
} else if (e.key === 'Tab') {
e.preventDefault();
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
setInput(`g/${searchMatch}`);
}
}
};
// Autocomplete logic
const searchMatch = (inputMode === 'normal' && input.startsWith('g/'))
? GATES.find(g => g.startsWith(input.slice(2).toLowerCase()))
: null;
if (isBooting) {
return (
<div className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono flex flex-col justify-end pb-20 overflow-hidden">
<div className="space-y-1">
{bootText.map((line, i) => (
<div key={i} className="text-gray-400">{line}</div>
))}
<div className="animate-pulse w-2 h-4 bg-white mt-2"></div>
</div>
</div>
);
}
return (
<div ref={containerRef} className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono relative flex flex-col overflow-hidden">
{currentView === 'terminal' && (
<>
{/* Top Navigation / Quick Launch */}
<div className="flex flex-row justify-between items-center gap-2 mb-6 border-b border-gray-800/50 pb-4 shrink-0 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="flex flex-nowrap gap-1.5">
{SECTIONS.map((section) => (
<button
key={section.name}
onClick={() => handleCommand(
section.name === 'PROFILE' ? 'profile' :
section.name === 'F-KEYS' ? 'fkeys' :
section.name.toLowerCase()
)}
className="flex items-center px-2 py-1 bg-cyan-900/10 border border-cyan-900/50 text-cyan-500 hover:bg-cyan-900/30 hover:text-cyan-400 hover:border-cyan-500/50 transition-all text-sm md:text-xs uppercase tracking-widest whitespace-nowrap"
>
{section.icon}
{section.name === 'PROFILE' ? 'SOVEREIGN' : section.name}
</button>
))}
</div>
<WeatherWidget />
</div>
{/* Main Terminal Area */}
<div className="flex-1 overflow-y-auto pr-4 pb-4">
<div className="flex flex-col lg:flex-row justify-between items-start gap-6 mb-8">
<TrendingPosts />
<div className="flex-1 flex flex-col items-center">
<pre
className="text-white drop-shadow-[0_0_8px_rgba(156,163,175,0.8)] text-sm sm:text-xs md:text-sm leading-tight select-none text-left inline-block"
style={{ fontFamily: 'Consolas, "Courier New", monospace' }}
>
{ASCII_HEADER}
</pre>
<div className="text-gray-400/80 text-center mt-4">
<p>Welcome to Infonet. Type <span className="text-green-400 font-bold">&apos;help&apos;</span> to see available commands.</p>
<p>Type <span className="text-green-400 font-bold">&apos;gates&apos;</span> or <span className="text-green-400 font-bold">g/</span> to view available chatrooms.</p>
</div>
<NetworkStats />
<div className="mt-5 w-full flex justify-center">
{renderGateDirectory('landing')}
</div>
</div>
<HashchainEvents />
</div>
<div className="space-y-4">
<TerminalDashboard onNavigate={(view) => handleNavigate(view)} onComingSoon={(mod) => setComingSoonModule(mod)} />
{history.map((entry, i) => (
<div key={i} className="space-y-1">
<div className="flex items-center text-white">
<span className="text-gray-500 mr-2 inline-block max-w-[45%] truncate" title={shellPrompt}>
{shellPrompt}
</span>
<span>{entry.command}</span>
</div>
<div className="ml-4 text-gray-300">
{entry.output}
</div>
</div>
))}
<div ref={endOfTerminalRef} />
</div>
</div>
{/* Input Area */}
<div className="shrink-0 pt-4 mt-2 border-t border-gray-800/50 z-10 relative">
{searchMatch && input.length > 2 && (
<div className="absolute bottom-full left-0 mb-2 bg-[#0a0a0a] border border-gray-800 p-2 text-xs text-gray-400 z-20">
Jump to: <span className="text-white font-bold">g/{searchMatch}</span> [Press Tab to autocomplete, Enter to join]
</div>
)}
<div className="flex items-center max-w-full">
<span
className={`text-gray-500 mr-2 ${inputMode === 'persona' ? 'whitespace-nowrap' : 'inline-block max-w-[45%] truncate'}`}
title={inputMode === 'persona' ? 'Enter Persona:' : shellPrompt}
>
{inputMode === 'persona' ? 'Enter Persona: ' : shellPrompt}
</span>
<div className="relative flex-1 flex items-center">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full bg-transparent border-none outline-none text-white placeholder-gray-800 focus:ring-0 caret-transparent"
spellCheck={false}
autoComplete="off"
autoFocus
/>
{/* Custom cursor */}
<span
className="absolute animate-pulse w-2 h-4 bg-white pointer-events-none"
style={{ left: `${input.length}ch` }}
></span>
</div>
</div>
</div>
</>
)}
{currentView === 'gate' && activeGate && (
<GateView
gateName={activeGate}
persona={persona || 'anon'}
entryMode={activeGateMode}
onBack={() => handleNavigate('terminal')}
onNavigateGate={(gate) => handleNavigate('gate', gate)}
onOpenLiveGate={onOpenLiveGate}
onOpenShutdownPetition={(gate) => {
setShutdownGateId(gate);
setCurrentView('gate-shutdown');
}}
availableGates={GATES}
/>
)}
{currentView === 'market' && (
<MarketView onBack={() => handleNavigate('terminal')} />
)}
{currentView === 'profile' && (
<ProfileView
onBack={() => handleNavigate('terminal')}
persona={persona || 'unassigned'}
isCitizen={isCitizen}
nodeId={nodeIdentity?.nodeId}
publicKey={nodeIdentity?.publicKey}
/>
)}
{currentView === 'messages' && (
<MessagesView onBack={() => handleNavigate('terminal')} onOpenDeadDrop={onOpenDeadDrop} />
)}
{currentView === 'ai' && (
<AIQueryView onBack={() => handleNavigate('terminal')} />
)}
{currentView === 'petitions' && (
<PetitionsView onBack={() => setCurrentView('terminal')} />
)}
{currentView === 'upgrades' && (
<UpgradeView onBack={() => setCurrentView('terminal')} />
)}
{currentView === 'resolution' && resolutionMarketId && (
<ResolutionView
marketId={resolutionMarketId}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'gate-shutdown' && shutdownGateId && (
<GateShutdownView
gateId={shutdownGateId}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'bootstrap' && (
<BootstrapView
marketId={bootstrapMarketId ?? undefined}
onBack={() => setCurrentView('terminal')}
/>
)}
{currentView === 'function-keys' && (
<FunctionKeyView onBack={() => setCurrentView('terminal')} />
)}
{/* Coming Soon Popup */}
{comingSoonModule && COMING_SOON_MODULES[comingSoonModule] && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-[3px]">
<div className="border border-cyan-500/30 bg-[#060a0f] shadow-[0_0_40px_rgba(6,182,212,0.1),inset_0_0_60px_rgba(6,182,212,0.03)] max-w-md w-full mx-4">
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-cyan-900/40 bg-cyan-950/20">
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse shadow-[0_0_6px_rgba(245,158,11,0.6)]" />
<span className="text-[13px] tracking-[0.3em] text-amber-400/80 uppercase">System Notice</span>
</div>
<button
onClick={() => setComingSoonModule(null)}
className="text-gray-600 hover:text-white text-xs transition-colors"
>
[×]
</button>
</div>
{/* Content */}
<div className="p-6">
<div className="text-cyan-400 text-xs tracking-[0.25em] uppercase font-bold mb-4">
{COMING_SOON_MODULES[comingSoonModule].title}
</div>
<div className="border border-gray-800 bg-gray-900/20 p-3 mb-4">
<p className="text-[11px] text-gray-400 leading-relaxed">
{COMING_SOON_MODULES[comingSoonModule].desc}
</p>
</div>
<div className="flex items-center gap-2 mb-4 px-1">
<span className="w-1 h-1 rounded-full bg-amber-500 animate-pulse" />
<span className="text-[13px] tracking-[0.2em] text-amber-400/90 uppercase">
{COMING_SOON_MODULES[comingSoonModule].status}
</span>
</div>
<div className="border-t border-gray-800 pt-4 flex items-center justify-between">
<span className="text-[12px] text-gray-600 tracking-[0.2em] uppercase">
Infonet Sovereign Shell v0.1.1 Test-Net
</span>
<button
onClick={() => setComingSoonModule(null)}
className="px-4 py-1.5 border border-cyan-900/50 bg-cyan-950/20 text-cyan-400 text-sm tracking-[0.2em] uppercase hover:bg-cyan-900/30 hover:border-cyan-500/40 transition-all"
>
Acknowledged
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}