diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..70e0ccf
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [18.x, 20.x]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint
+
+ - name: Build
+ run: npm run build
diff --git a/.gitignore b/.gitignore
index 7fa9a66..1627065 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,7 @@ next-env.d.ts
# local clones / tooling
base-bridge/
+
+# IDE
+.vscode/
+.idea/
diff --git a/README.md b/README.md
index ce218a7..e570b47 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Terminally Onchain
+
+
diff --git a/src/app/globals.css b/src/app/globals.css
index bf1db73..cad69e2 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -121,3 +121,93 @@ body {
text-transform: uppercase !important;
letter-spacing: 1px !important;
}
+
+/* Additional Animations */
+@keyframes slide-in {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes pulse-subtle {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.7;
+ }
+}
+
+.animate-slide-in {
+ animation: slide-in 0.2s ease-out;
+}
+
+.animate-fade-in {
+ animation: fade-in 0.3s ease-out;
+}
+
+.animate-pulse-subtle {
+ animation: pulse-subtle 2s ease-in-out infinite;
+}
+
+/* Focus visible styles for accessibility */
+:focus-visible {
+ outline: 2px solid rgba(0, 255, 0, 0.5);
+ outline-offset: 2px;
+}
+
+/* Smooth scrolling */
+html {
+ scroll-behavior: smooth;
+}
+
+/* Custom scrollbar for terminal aesthetic */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgba(0, 255, 0, 0.3);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 255, 0, 0.5);
+}
+
+/* Selection styling */
+::selection {
+ background: rgba(0, 255, 0, 0.3);
+ color: #00ff00;
+}
+
+/* Terminal cursor blink */
+.terminal-cursor {
+ display: inline-block;
+ width: 8px;
+ height: 1.2em;
+ background: #00ff00;
+ animation: blink 1s step-end infinite;
+ vertical-align: text-bottom;
+ margin-left: 2px;
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d92d573..b88ba34 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -2,6 +2,9 @@
import { MainContent } from '../components/MainContent';
import { WalletConnection } from '../components/WalletConnection';
+import { Footer } from '../components/Footer';
+import { NetworkStatus, useNetworkStatus } from '../components/NetworkStatus';
+import { KeyboardShortcuts } from '../components/terminal/KeyboardShortcuts';
import { useNetwork } from '../contexts/NetworkContext';
import {
ENVIRONMENT_CHOICES,
@@ -12,6 +15,7 @@ import {
export default function Home() {
const { config, environment, setEnvironment } = useNetwork();
+ const { solanaStatus, baseStatus, solanaLatency, baseLatency } = useNetworkStatus();
return (
@@ -27,6 +31,12 @@ export default function Home() {
+
+
+
+
);
diff --git a/src/components/CopyableAddress.tsx b/src/components/CopyableAddress.tsx
new file mode 100644
index 0000000..17ad4f5
--- /dev/null
+++ b/src/components/CopyableAddress.tsx
@@ -0,0 +1,186 @@
+'use client';
+
+import React, { useState, useCallback } from 'react';
+
+interface CopyableAddressProps {
+ /** The full address to display and copy */
+ address: string;
+ /** Optional label to show before the address */
+ label?: string;
+ /** Number of characters to show at start and end (default: 6) */
+ truncateLength?: number;
+ /** Whether to show the full address without truncation */
+ showFull?: boolean;
+ /** Custom class name for styling */
+ className?: string;
+ /** Type of address for appropriate styling */
+ type?: 'solana' | 'base' | 'generic';
+}
+
+export const CopyableAddress: React.FC = ({
+ address,
+ label,
+ truncateLength = 6,
+ showFull = false,
+ className = '',
+ type = 'generic',
+}) => {
+ const [copied, setCopied] = useState(false);
+ const [showTooltip, setShowTooltip] = useState(false);
+
+ const truncatedAddress = showFull
+ ? address
+ : `${address.slice(0, truncateLength)}...${address.slice(-truncateLength)}`;
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(address);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.error('Failed to copy address:', error);
+ }
+ }, [address]);
+
+ const getTypeStyles = () => {
+ switch (type) {
+ case 'solana':
+ return 'text-purple-400 hover:text-purple-300';
+ case 'base':
+ return 'text-blue-400 hover:text-blue-300';
+ default:
+ return 'text-green-400 hover:text-green-300';
+ }
+ };
+
+ const getTypeIcon = () => {
+ switch (type) {
+ case 'solana':
+ return (
+
+ );
+ case 'base':
+ return (
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+ setShowTooltip(true)}
+ onMouseLeave={() => setShowTooltip(false)}
+ >
+ {label && (
+
+ {label}:
+
+ )}
+
+ {getTypeIcon() && (
+
{getTypeIcon()}
+ )}
+
+
+
+ {copied && (
+
+ Copied!
+
+ )}
+
+ );
+};
+
+// Utility component for transaction hashes
+interface CopyableTxHashProps {
+ hash: string;
+ explorerUrl?: string;
+ network: 'solana' | 'base';
+}
+
+export const CopyableTxHash: React.FC = ({
+ hash,
+ explorerUrl,
+ network,
+}) => {
+ return (
+
+
+ {explorerUrl && (
+
+
+
+ )}
+
+ );
+};
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 0000000..b0b201f
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import React from 'react';
+
+export const Footer: React.FC = () => {
+ const currentYear = new Date().getFullYear();
+
+ return (
+
+ );
+};
diff --git a/src/components/NetworkStatus.tsx b/src/components/NetworkStatus.tsx
new file mode 100644
index 0000000..7c18dfd
--- /dev/null
+++ b/src/components/NetworkStatus.tsx
@@ -0,0 +1,176 @@
+'use client';
+
+import React from 'react';
+
+export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
+
+interface NetworkStatusProps {
+ solanaStatus: ConnectionStatus;
+ baseStatus: ConnectionStatus;
+ solanaLatency?: number;
+ baseLatency?: number;
+}
+
+const getStatusColor = (status: ConnectionStatus): string => {
+ switch (status) {
+ case 'connected':
+ return 'bg-green-500';
+ case 'connecting':
+ return 'bg-yellow-500 animate-pulse';
+ case 'disconnected':
+ return 'bg-gray-500';
+ case 'error':
+ return 'bg-red-500';
+ default:
+ return 'bg-gray-500';
+ }
+};
+
+const getStatusText = (status: ConnectionStatus): string => {
+ switch (status) {
+ case 'connected':
+ return 'Connected';
+ case 'connecting':
+ return 'Connecting...';
+ case 'disconnected':
+ return 'Disconnected';
+ case 'error':
+ return 'Error';
+ default:
+ return 'Unknown';
+ }
+};
+
+interface StatusDotProps {
+ status: ConnectionStatus;
+ label: string;
+ latency?: number;
+}
+
+const StatusDot: React.FC = ({ status, label, latency }) => (
+
+
+
+
+ {label}
+
+
+ {latency !== undefined && status === 'connected' && (
+
+ {latency}ms
+
+ )}
+
+);
+
+export const NetworkStatus: React.FC = ({
+ solanaStatus,
+ baseStatus,
+ solanaLatency,
+ baseLatency,
+}) => {
+ const allConnected = solanaStatus === 'connected' && baseStatus === 'connected';
+ const hasError = solanaStatus === 'error' || baseStatus === 'error';
+
+ return (
+
+ );
+};
+
+// Hook to monitor network status
+export const useNetworkStatus = () => {
+ const [solanaStatus, setSolanaStatus] = React.useState('connecting');
+ const [baseStatus, setBaseStatus] = React.useState('connecting');
+ const [solanaLatency, setSolanaLatency] = React.useState(undefined);
+ const [baseLatency, setBaseLatency] = React.useState(undefined);
+
+ React.useEffect(() => {
+ const checkSolana = async () => {
+ const start = performance.now();
+ try {
+ const response = await fetch('https://api.devnet.solana.com', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'getHealth',
+ }),
+ });
+ const latency = Math.round(performance.now() - start);
+ if (response.ok) {
+ setSolanaStatus('connected');
+ setSolanaLatency(latency);
+ } else {
+ setSolanaStatus('error');
+ }
+ } catch {
+ setSolanaStatus('error');
+ }
+ };
+
+ const checkBase = async () => {
+ const start = performance.now();
+ try {
+ const response = await fetch('https://sepolia.base.org', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'eth_blockNumber',
+ params: [],
+ }),
+ });
+ const latency = Math.round(performance.now() - start);
+ if (response.ok) {
+ setBaseStatus('connected');
+ setBaseLatency(latency);
+ } else {
+ setBaseStatus('error');
+ }
+ } catch {
+ setBaseStatus('error');
+ }
+ };
+
+ // Initial check
+ checkSolana();
+ checkBase();
+
+ // Poll every 30 seconds
+ const interval = setInterval(() => {
+ checkSolana();
+ checkBase();
+ }, 30000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return { solanaStatus, baseStatus, solanaLatency, baseLatency };
+};
diff --git a/src/components/terminal/CommandHistory.tsx b/src/components/terminal/CommandHistory.tsx
new file mode 100644
index 0000000..277a328
--- /dev/null
+++ b/src/components/terminal/CommandHistory.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import React, { useState, useCallback, useEffect } from 'react';
+
+interface CommandHistoryProps {
+ /** Array of past commands */
+ commands: string[];
+ /** Callback when a command is selected for re-execution */
+ onSelectCommand: (command: string) => void;
+ /** Maximum number of commands to display */
+ maxDisplay?: number;
+}
+
+export const CommandHistory: React.FC = ({
+ commands,
+ onSelectCommand,
+ maxDisplay = 10,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [filter, setFilter] = useState('');
+
+ const filteredCommands = commands
+ .filter((cmd) => cmd.toLowerCase().includes(filter.toLowerCase()))
+ .slice(0, maxDisplay);
+
+ const handleCommandClick = useCallback(
+ (command: string) => {
+ onSelectCommand(command);
+ setIsExpanded(false);
+ },
+ [onSelectCommand]
+ );
+
+ // Close on escape key
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setIsExpanded(false);
+ }
+ };
+
+ if (isExpanded) {
+ document.addEventListener('keydown', handleEscape);
+ return () => document.removeEventListener('keydown', handleEscape);
+ }
+ }, [isExpanded]);
+
+ if (commands.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {isExpanded && (
+
+
+
+
+ Command History
+
+
setFilter(e.target.value)}
+ className="w-full bg-black/60 border border-green-500/30 rounded px-2 py-1 text-green-200 text-xs placeholder:text-green-500/40 focus:outline-none focus:border-green-400"
+ aria-label="Filter command history"
+ />
+
+
+
+ {filteredCommands.length === 0 ? (
+ -
+ No matching commands
+
+ ) : (
+ filteredCommands.map((command, index) => (
+ -
+
+
+ ))
+ )}
+
+
+
+
+ Click a command to insert it • Use ↑↓ in terminal
+
+
+
+ )}
+
+ );
+};
+
+// Hook to manage command history
+export const useCommandHistory = (maxHistory: number = 50) => {
+ const [commands, setCommands] = useState([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+
+ const addCommand = useCallback(
+ (command: string) => {
+ const trimmed = command.trim();
+ if (!trimmed) return;
+
+ setCommands((prev) => {
+ // Avoid duplicates at the top
+ if (prev[0] === trimmed) return prev;
+ return [trimmed, ...prev].slice(0, maxHistory);
+ });
+ setHistoryIndex(-1);
+ },
+ [maxHistory]
+ );
+
+ const navigateHistory = useCallback(
+ (direction: 'up' | 'down'): string | null => {
+ if (commands.length === 0) return null;
+
+ let newIndex = historyIndex;
+ if (direction === 'up') {
+ newIndex = Math.min(historyIndex + 1, commands.length - 1);
+ } else {
+ newIndex = Math.max(historyIndex - 1, -1);
+ }
+
+ setHistoryIndex(newIndex);
+ return newIndex >= 0 ? commands[newIndex] : null;
+ },
+ [commands, historyIndex]
+ );
+
+ const resetNavigation = useCallback(() => {
+ setHistoryIndex(-1);
+ }, []);
+
+ return {
+ commands,
+ addCommand,
+ navigateHistory,
+ resetNavigation,
+ };
+};
diff --git a/src/components/terminal/KeyboardShortcuts.tsx b/src/components/terminal/KeyboardShortcuts.tsx
new file mode 100644
index 0000000..5ac59c1
--- /dev/null
+++ b/src/components/terminal/KeyboardShortcuts.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import React, { useState } from 'react';
+
+interface Shortcut {
+ keys: string[];
+ description: string;
+}
+
+const SHORTCUTS: Shortcut[] = [
+ { keys: ['Enter'], description: 'Execute command' },
+ { keys: ['↑', '↓'], description: 'Navigate command history' },
+ { keys: ['Ctrl', 'L'], description: 'Clear terminal logs' },
+ { keys: ['Ctrl', 'C'], description: 'Cancel current operation' },
+ { keys: ['Tab'], description: 'Auto-complete command' },
+ { keys: ['Esc'], description: 'Close modals/dialogs' },
+];
+
+export const KeyboardShortcuts: React.FC = () => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ return (
+
+
+
+ {isExpanded && (
+
+
+
+ Keyboard Shortcuts
+
+
+ {SHORTCUTS.map((shortcut, index) => (
+ -
+ {shortcut.description}
+
+ {shortcut.keys.map((key, keyIndex) => (
+
+ {key}
+
+ ))}
+
+
+ ))}
+
+
+ Type help for all commands
+
+
+ )}
+
+ );
+};