diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a9bd01a..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,531 +0,0 @@ -# XMTP CLI Chat v2.0 - Architecture Documentation - -## 🏗️ System Architecture - -This document provides a technical overview of the XMTP CLI Chat application architecture for AI assistants and developers. - -## Overview - -The application is built using a modern React-based CLI framework (Ink) with clean separation of concerns following component-based architecture patterns. The codebase is fully typed with TypeScript and uses Zustand for state management. - -## Directory Structure - -``` -src/ -├── cli.tsx # Main entry point -├── components/ # React UI components -│ ├── Layout.tsx # Main layout orchestrator -│ ├── Sidebar.tsx # Conversation list sidebar -│ ├── ChatView.tsx # Message display area -│ ├── StatusBar.tsx # Bottom status bar -│ ├── Input.tsx # Message input field -│ └── CommandPalette.tsx # Quick action picker -├── hooks/ # Custom React hooks -│ ├── useXMTP.ts # XMTP protocol integration -│ └── useKeyboard.ts # Keyboard event handling -├── store/ # State management -│ └── state.ts # Zustand store -├── types/ # TypeScript definitions -│ └── index.ts # Shared types -└── utils/ # Utility functions - ├── formatters.ts # Data formatting - └── helpers.ts # Helper functions -``` - -## Component Architecture - -### Layout Component (`components/Layout.tsx`) - -The root layout component that orchestrates the entire UI: - -```typescript - - {showSidebar && } - - {showCommandPalette && } - - - -``` - -**Props:** -- Conversation state (list, current, messages) -- UI state (sidebar, command palette visibility) -- Input handlers -- Status information - -**Responsibilities:** -- Layout composition -- Responsive pane sizing -- Conditional rendering of UI elements - -### Sidebar Component (`components/Sidebar.tsx`) - -Displays the conversation list with navigation indicators: - -**Features:** -- Conversation type badges ([D] for DM, [G] for Group) -- Selection indicator (▶) -- Active conversation indicator (●) -- Unread count badges -- Navigation hints - -**Visual States:** -- Selected (highlighted) -- Current conversation (marked) -- Unread messages (bold + count) - -### ChatView Component (`components/ChatView.tsx`) - -Renders messages for the active conversation: - -**Features:** -- Message list with timestamps -- Sender identification -- Multi-line message support -- Empty state placeholder -- Message scrolling - -**Message Format:** -``` -[HH:MM] Sender: Message content -``` - -### Input Component (`components/Input.tsx`) - -Handles user text input: - -**Features:** -- Command mode indicator (/) -- Placeholder text -- Real-time input -- Submit handling - -### CommandPalette Component (`components/CommandPalette.tsx`) - -Quick action selector: - -**Features:** -- Fuzzy search -- Keyboard navigation -- Shortcut display -- Command execution - -### StatusBar Component (`components/StatusBar.tsx`) - -Displays connection and app status: - -**Features:** -- Connection status indicator -- Wallet address (shortened) -- Conversation count -- Keybinding hints -- Error display - -## Hook Architecture - -### useXMTP Hook (`hooks/useXMTP.ts`) - -Encapsulates all XMTP protocol logic: - -**Responsibilities:** -1. Agent initialization -2. Conversation management -3. Message handling -4. Real-time streaming -5. Error handling - -**Key Methods:** -- `setCurrentConversationById(id)` - Switch conversations -- `sendMessage(content)` - Send message -- `findOrCreateConversation(identifiers)` - Create/find conversation - -**State Exposed:** -- `agent` - XMTP agent instance -- `conversations` - List of conversations -- `currentConversation` - Active conversation -- `messages` - Current conversation messages -- `isLoading` - Loading state -- `error` - Error messages - -**Implementation Details:** -- Uses `useEffect` for initialization -- Manages WebSocket stream with refs -- Handles Ethereum address resolution -- Converts XMTP types to app types - -### useKeyboard Hook (`hooks/useKeyboard.ts`) - -Handles keyboard navigation and shortcuts: - -**Keybindings:** -- `Ctrl+B` - Toggle sidebar -- `Ctrl+K` - Toggle command palette -- `Ctrl+N/P` - Next/previous conversation -- `↑/↓` - Navigate conversation list -- `Enter` - Select/submit -- `Esc` - Cancel/b - -**Features:** -- Context-aware (different modes) -- Callback system -- Integration with Zustand store - -## State Management - -### Zustand Store (`store/state.ts`) - -Global application state: - -**State Categories:** - -1. **Agent State** - - `agent` - XMTP agent instance - - `address` - Wallet address - - `inboxId` - XMTP inbox ID - - `url` - Network URL - - `installations` - Installation count - - `env` - Environment (dev/production) - -2. **Conversation State** - - `conversations` - Conversation list - - `currentConversation` - Active conversation - - `messages` - Message list - -3. **UI State** - - `showSidebar` - Sidebar visibility - - `showCommandPalette` - Command palette visibility - - `selectedConversationIndex` - Navigation index - - `inputValue` - Input field value - - `commandMode` - Command mode flag - -4. **Status State** - - `isLoading` - Loading flag - - `loadingStatus` - Status message - - `error` - Error message - - `connectionStatus` - Connection state - -**Actions:** -- State setters -- UI toggles -- Navigation methods (next/prev conversation) -- Selection methods - -## Type System - -### Core Types (`types/index.ts`) - -**FormattedMessage:** -```typescript -{ - id: string; - timestamp: string; - sender: string; - content: string; - isFromSelf: boolean; - sentAt: Date; -} -``` - -**ConversationInfo:** -```typescript -{ - id: string; - conversation: Conversation; - name: string; - type: "dm" | "group"; - peerAddress?: string; - peerInboxId?: string; - unreadCount: number; - lastMessageAt?: Date; - lastMessage?: string; -} -``` - -**AppState:** -- See Zustand Store section for complete structure - -## Data Flow - -### Message Reception Flow - -``` -XMTP Network - ↓ -WebSocket Stream (useXMTP) - ↓ -formatMessage() (formatters.ts) - ↓ -setMessages() / addMessage() (store) - ↓ -ChatView Component - ↓ -Terminal Display -``` - -### Message Send Flow - -``` -User Input - ↓ -handleInputSubmit() (cli.tsx) - ↓ -sendMessage() (useXMTP) - ↓ -conversation.send() (XMTP SDK) - ↓ -XMTP Network - ↓ -(echoed back via stream) -``` - -### Conversation Switch Flow - -``` -Keyboard Input (↑/↓/Ctrl+N/P) - ↓ -useKeyboard Hook - ↓ -nextConversation() / prevConversation() (store) - ↓ -selectedConversationIndex updated - ↓ -Sidebar re-renders with new selection - ↓ -User presses Enter - ↓ -setCurrentConversationById() (useXMTP) - ↓ -loadMessages() + startMessageStream() - ↓ -ChatView updates -``` - -## Utility Functions - -### Formatters (`utils/formatters.ts`) - -- `formatMessage()` - Convert XMTP message to display format -- `formatAddress()` - Shorten Ethereum address -- `formatTime()` - Relative time display - -### Helpers (`utils/helpers.ts`) - -- `isGroup()` - Type guard for group conversations -- `isDm()` - Type guard for DM conversations -- `isEthAddress()` - Validate Ethereum address -- `handleError()` - Error message formatting - -## Entry Point (`cli.tsx`) - -### Main Flow - -1. **Parse Arguments** - - Extract CLI flags - - Load environment variables - - Auto-detect agent addresses - -2. **Initialize App** - - Render React app with Ink - - Initialize XMTP (via useXMTP) - - Set up keyboard handlers - -3. **Loading State** - - Display initialization progress - - Show agent information - -4. **Main UI** - - Render Layout with all components - - Handle input and commands - - Process keyboard shortcuts - -5. **Command Processing** - - `/chat ` - Switch conversation - - `/exit` - Quit application - - Address input - Start conversation - -## Performance Considerations - -### Optimization Strategies - -1. **Message Windowing** - - Only render visible messages (last N) - - Prevents UI lag with long conversations - -2. **Lazy Loading** - - Conversations loaded on demand - - Messages loaded per conversation - -3. **Efficient Streaming** - - Single WebSocket connection - - Filter messages by conversation ID - -4. **React Optimization** - - Zustand prevents unnecessary re-renders - - Component memoization where needed - -## Error Handling - -### Error Display - -Errors are shown in the StatusBar with auto-clear: -- 5-second timeout by default -- Visual red indicator -- Clear error messages - -### Error Sources - -1. **XMTP Errors** - - Network connection issues - - Authentication failures - - Protocol errors - -2. **User Input Errors** - - Invalid commands - - Invalid addresses - - Invalid conversation numbers - -3. **Application Errors** - - State management issues - - Component rendering errors - -## Extension Points - -### Adding New Commands - -1. Add command to `commands` array in `cli.tsx`: -```typescript -{ - id: "my-command", - name: "My Command", - description: "Does something cool", - shortcut: "Ctrl+X", - action: () => { /* implementation */ } -} -``` - -2. Add keybinding in `useKeyboard.ts` if needed - -### Adding New UI Components - -1. Create component in `src/components/` -2. Add to Layout if needed -3. Wire up state/props -4. Add TypeScript types - -### Adding New Features - -1. Define types in `types/index.ts` -2. Add state to Zustand store if needed -3. Implement logic in hooks -4. Create/update UI components -5. Wire up in main app - -## Testing Strategy - -### Manual Testing - -1. **Basic Flow** - - Start app - - List conversations - - Switch conversations - - Send messages - -2. **Keyboard Navigation** - - Test all keybindings - - Verify navigation works - - Check command palette - -3. **Error Cases** - - Invalid input - - Network disconnection - - Invalid addresses - -### Future: Automated Testing - -Consider adding: -- Unit tests for utilities -- Integration tests for hooks -- E2E tests for user flows - -## Deployment - -### Build - -The app runs via `tsx` (TypeScript execution): -```bash -yarn dev -``` - -### Distribution - -For distribution, consider: -- Bundle with `esbuild` or `webpack` -- Create standalone executable with `pkg` -- Distribute via npm - -## Known Limitations - -1. **Message History** - - Limited to last 50 messages per conversation - - No pagination yet - -2. **Search** - - No message search yet - - No conversation search - -3. **Media** - - Text-only messages - - No image/file support yet - -4. **Offline** - - Requires active connection - - No offline message queue - -## Future Enhancements - -See TODO in main README for planned features: -- Message reactions -- Thread support -- Enhanced search -- Configuration file -- Themes/customization -- Plugin system - -## Debugging Tips - -### Enable Debug Logging - -Add to XMTP hook: -```typescript -console.log("[XMTP]", ...); -``` - -### Inspect State - -Use Zustand dev tools or add: -```typescript -console.log(useStore.getState()); -``` - -### Check Message Stream - -Add logging in `startMessageStream()`: -```typescript -for await (const message of streamRef.current) { - console.log("[Stream]", message); - // ... -} -``` - -## Resources - -- [Ink Documentation](https://github.com/vadimdemedes/ink) -- [XMTP Protocol](https://xmtp.org/docs) -- [Zustand Guide](https://github.com/pmndrs/zustand) -- [React Hooks](https://react.dev/reference/react) - ---- - -Last Updated: 2025-10-24 -Version: 2.0.0 diff --git a/src/cli-old.tsx b/src/cli-old.tsx deleted file mode 100644 index ea119d8..0000000 --- a/src/cli-old.tsx +++ /dev/null @@ -1,910 +0,0 @@ -import "dotenv/config"; -import React, { useState, useEffect, useRef } from "react"; -import { render, Box, Text, useApp } from "ink"; -import TextInput from "ink-text-input"; -import { - Agent, - IdentifierKind, - type Conversation, - type DecodedMessage, - type XmtpEnv, - type Group, - type Dm, -} from "@xmtp/agent-sdk"; -import { getRandomValues } from "node:crypto"; -import { Client } from "@xmtp/node-sdk"; -import { getTestUrl } from "@xmtp/agent-sdk/debug"; -import { createSigner, createUser } from "@xmtp/agent-sdk/user"; -import { generatePrivateKey, privateKeyToAddress } from "viem/accounts"; -import { fromString, toString } from "uint8arrays"; - -function showHelp(): void { - console.log(` -XMTP CLI Chat Interface - -Chat with your XMTP conversations directly from the terminal. - -USAGE: - yarn chat [options] - -OPTIONS: - --agent Connect to agent(s) by Ethereum address or inbox ID - Single address: creates/opens a DM - Multiple addresses: creates a group chat - [auto-detected in dev environment if not provided] - -h, --help Show this help message - -IN-CHAT COMMANDS: - /c Switch to a different conversation - /b Return to conversation list - /exit Quit the application - -EXAMPLES: - yarn chat - yarn chat --agent 0x7c40611372d354799d138542e77243c284e460b2 - yarn chat --agent 0x7c40611372d354799d138542e77243c284e460b2 0x1234567890abcdef1234567890abcdef12345678 - yarn chat --agent 1180478fde9f6dfd4559c25f99f1a3f1505e1ad36b9c3a4dd3d5afb68c419179 - -ENVIRONMENT VARIABLES: - XMTP_ENV Default environment - XMTP_CLIENT_WALLET_KEY Wallet private key (required) - XMTP_CLIENT_DB_ENCRYPTION_KEY Database encryption key (required) -`); -} - -// Red color - matching the original theme (rgb: 252, 76, 52) -const RED = "#fc4c34"; -// Standard red for errors -const ERROR_RED = "#fc4c34"; - -// ============================================================================ -// Types -// ============================================================================ -interface FormattedMessage { - timestamp: string; - sender: string; - content: string; - isFromSelf: boolean; -} - -// ============================================================================ -// Utility Functions -// ============================================================================ -const isGroup = (conversation: Conversation): conversation is Group => { - return conversation.constructor.name === "Group"; -}; - -const isDm = (conversation: Conversation): conversation is Dm => { - return conversation.constructor.name === "Dm"; -}; - -const isEthAddress = (identifier: string): boolean => { - return identifier.startsWith("0x") && identifier.length === 42; -}; - -const handleError = ( - error: unknown, - setError: (msg: string) => void, - context: string, - clearAfter?: number, -): void => { - const err = error as Error; - setError(`${context}: ${err.message}`); - - // Auto-clear error after specified time (default 5 seconds) - if (clearAfter) { - setTimeout(() => { - setError(""); - }, clearAfter); - } -}; - -// ============================================================================ -// Reusable UI Components -// ============================================================================ -interface StatusBoxProps { - children: React.ReactNode; - color?: string; - borderColor?: string; -} - -const StatusBox: React.FC = ({ - children, - color = ERROR_RED, - borderColor = ERROR_RED, -}) => ( - - - {children} - - -); - -interface InfoTextProps { - children: React.ReactNode; - marginTop?: number; -} - -const InfoText: React.FC = ({ children, marginTop = 1 }) => ( - - {children} - -); - -// ============================================================================ -// Header Component -// ============================================================================ -interface HeaderProps { - conversation: Conversation | null; - env: XmtpEnv; - url: string; - conversations: number; - installations: number; - address: string; - inboxId: string; - peerAddress: string; -} - -const Header: React.FC = ({ - conversation, - conversations, - env, - url, - installations, - address, - inboxId, - peerAddress, -}) => { - const logoLines = [ - " ██╗ ██╗███╗ ███╗████████╗██████╗ ", - " ╚██╗██╔╝████╗ ████║╚══██╔══╝██╔══██╗", - " ╚███╔╝ ██╔████╔██║ ██║ ██████╔╝", - " ██╔██╗ ██║╚██╔╝██║ ██║ ██╔═══╝ ", - " ██╔╝ ██╗██║ ╚═╝ ██║ ██║ ██║ ", - " ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ", - ]; - - if (!conversation) { - // Show initialization header - return ( - - - - {logoLines.map((line, i) => ( - - {line} - - ))} - - - - InboxId: {inboxId.slice(0, 36)}... - - - Address: {address} - - - Conversations: {conversations} - - - Installations: {installations} - - - Network: {env} - - - URL: {url.slice(0, 30)}... - - - - - ); - } - - return ( - - - {isGroup(conversation) ? ( - - {" "} - GROUP: {conversation.name || "Unnamed Group"}{" "} - - ) : ( - - {" "} - DM:{" "} - {peerAddress || - (conversation as Dm).peerInboxId.slice(0, 16) + "..."}{" "} - - )} - - Commands: /c <number> • /b • /exit - - ); -}; - -// ============================================================================ -// Messages Component -// ============================================================================ -interface MessagesProps { - messages: FormattedMessage[]; - height: number; -} - -const Messages: React.FC = ({ messages, height }) => { - // Show last N messages that fit in the height - const visibleMessages = messages.slice(-height); - - // Helper function to format long text with proper indentation - const formatLongText = (text: string, prefix: string) => { - const lines = text.split("\n"); - if (lines.length === 1) { - return text; - } - - // For multi-line content, indent continuation lines - return lines - .map((line, index) => { - if (index === 0) return line; - // Add indentation to match the prefix length - const indent = " ".repeat(prefix.length); - return `${indent}${line}`; - }) - .join("\n"); - }; - - return ( - - {visibleMessages.length === 0 ? ( - No messages yet... - ) : ( - visibleMessages.map((msg, index) => { - const prefix = `[${msg.timestamp}] ${msg.sender}: `; - const formattedContent = formatLongText(msg.content, prefix); - - return ( - - - [{msg.timestamp}] - - {msg.sender}: - - {formattedContent} - - - ); - }) - )} - - ); -}; - -// ============================================================================ -// Input Component -// ============================================================================ -interface InputBoxProps { - value: string; - onChange: (value: string) => void; - onSubmit: (value: string) => void; - placeholder?: string; -} - -const InputBox: React.FC = ({ - value, - onChange, - onSubmit, - placeholder = "Send a message to the agent", -}) => { - return ( - - - - - - ); -}; - -// ============================================================================ -// Conversation List Component -// ============================================================================ -interface ConversationListProps { - conversations: Conversation[]; - currentConversationId: string | null; -} -const getEthereumAddress = async ( - conversation: Conversation, -): Promise => { - const members = await conversation.members(); - - for (const member of members) { - // Get Ethereum address - const ethIdentifier = member.accountIdentifiers.find( - (id) => id.identifierKind === IdentifierKind.Ethereum, - ); - - if (ethIdentifier) { - return ethIdentifier.identifier; - } - } - return null; -}; -const ConversationList: React.FC = ({ - conversations, - currentConversationId, -}) => { - const [addressMap, setAddressMap] = React.useState>( - {}, - ); - const [loading, setLoading] = React.useState(true); - - React.useEffect(() => { - const loadAddresses = async () => { - const newAddressMap: Record = {}; - - for (const conv of conversations) { - if (!isGroup(conv)) { - const address = await getEthereumAddress(conv); - if (address) { - newAddressMap[conv.id] = address; - } - } - } - - setAddressMap(newAddressMap); - setLoading(false); - }; - - loadAddresses(); - }, [conversations]); - - return ( - - Your conversations - - {conversations.length === 0 ? ( - No conversations found - ) : loading ? ( - Loading conversations... - ) : ( - conversations.map((conv, index) => { - const isCurrent = conv.id === currentConversationId; - const label = isCurrent ? "●" : " "; - - if (isGroup(conv)) { - return ( - - {label} - {index + 1}. - [GROUP] - {conv.name || "Unnamed"} - - ); - } else { - const peerShort = addressMap[conv.id]; - if (peerShort) { - return ( - - {label} - {index + 1}. - [DM] - {peerShort} - - ); - } - } - }) - )} - - - Use /c <number> to switch conversations - - - ); -}; - -// ============================================================================ -// Main App Component -// ============================================================================ -interface AppProps { - env: XmtpEnv; - agentIdentifiers?: string[]; -} - -const App: React.FC = ({ env, agentIdentifiers }) => { - const { exit } = useApp(); - const [agent, setAgent] = useState(null); - const [address, setAddress] = useState(""); - const [url, setUrl] = useState(""); - const [installations, setInstallations] = useState(0); - const [inboxId, setInboxId] = useState(""); - const [currentConversation, setCurrentConversation] = - useState(null); - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(""); - const [conversations, setConversations] = useState([]); - const [showConversationList, setShowConversationList] = useState(false); - const [error, setError] = useState(""); - const [errorTimeout, setErrorTimeout] = useState(null); - const [loadingStatus, setLoadingStatus] = useState( - "Initializing XMTP cli client...", - ); - const [peerAddress, setPeerAddress] = useState(""); - - // Function to set error with auto-clear - const setErrorWithTimeout = (message: string, timeoutMs = 5000) => { - setError(message); - if (errorTimeout) { - clearTimeout(errorTimeout); - } - const timeout = setTimeout(() => { - setError(""); - setErrorTimeout(null); - }, timeoutMs); - setErrorTimeout(timeout); - }; - const streamRef = useRef | null>(null); - const isStreamingRef = useRef(false); - - // Initialize agent - useEffect(() => { - const initAgent = async () => { - setLoadingStatus("Initializing XMTP cli client..."); - - let walletKey = process.env.XMTP_CLIENT_WALLET_KEY; - let dbEncryptionKey = process.env.XMTP_CLIENT_DB_ENCRYPTION_KEY; - - if (!walletKey || !dbEncryptionKey) { - walletKey = generatePrivateKey(); - dbEncryptionKey = toString(getRandomValues(new Uint8Array(32)), "hex"); - } - - const user = createUser(walletKey as `0x${string}`); - const signer = createSigner(user); - - // Convert hex string to Uint8Array for dbEncryptionKey - const encryptionKeyBytes = fromString(dbEncryptionKey, "hex"); - - const newAgent = await Agent.create(signer, { - env, - dbEncryptionKey: encryptionKeyBytes, - dbPath: (inboxId) => "." + `/cli-${env}-${inboxId.slice(0, 8)}.db3`, - }); - - setAgent(newAgent); - setAddress(newAgent.address || ""); - setInboxId(newAgent.client.inboxId); - setUrl(getTestUrl(newAgent.client) || ""); - - const finalInboxState = await Client.inboxStateFromInboxIds( - [newAgent.client.inboxId], - env, - ); - setInstallations(finalInboxState[0].installations.length); - - setLoadingStatus("Syncing conversations..."); - // Sync conversations - await newAgent.client.conversations.sync(); - const convList = await newAgent.client.conversations.list(); - setConversations(convList); - - // If agent identifiers provided, create/find conversation - if (agentIdentifiers && agentIdentifiers.length > 0) { - setLoadingStatus("Connecting to agent..."); - const conv = await findOrCreateConversation(newAgent, agentIdentifiers); - if (conv) { - setLoadingStatus("Loading messages..."); - setCurrentConversation(conv); - - // Fetch peer address for DMs - if (!isGroup(conv)) { - const address = await getEthereumAddress(conv); - setPeerAddress(address || ""); - } else { - setPeerAddress(""); - } - - await loadMessages(conv, newAgent); - await startMessageStream(conv, newAgent); - } - } - - setLoadingStatus(""); - }; - - initAgent().catch((err) => { - handleError(err, setError, "Failed to initialize"); - setLoadingStatus(""); - }); - }, []); - - // Find or create conversation - const findOrCreateConversation = async ( - agentInstance: Agent, - identifiers: string[], - ): Promise => { - const client = agentInstance.client; - const groupOptions = { - groupName: "CLI Group Chat", - groupDescription: "Group created from CLI", - }; - - try { - if (identifiers.length > 1) { - // Create group - const allEthAddresses = identifiers.every(isEthAddress); - - if (allEthAddresses) { - const memberIdentifiers = identifiers.map((id) => ({ - identifier: id, - identifierKind: IdentifierKind.Ethereum, - })); - return await client.conversations.newGroupWithIdentifiers( - memberIdentifiers, - groupOptions, - ); - } - - return await client.conversations.newGroup(identifiers, groupOptions); - } - - // Create/find DM - const identifier = identifiers[0]; - - // Try to find existing conversation - await client.conversations.sync(); - const convs = await client.conversations.list(); - - for (const conv of convs) { - if (!isDm(conv)) continue; - - if (conv.peerInboxId.toLowerCase() === identifier.toLowerCase()) { - return conv; - } - - if (isEthAddress(identifier)) { - const members = await conv.members(); - const foundMember = members.find((member) => { - const ethId = member.accountIdentifiers.find( - (id) => id.identifierKind === IdentifierKind.Ethereum, - ); - return ethId?.identifier.toLowerCase() === identifier.toLowerCase(); - }); - - if (foundMember) return conv; - } - } - - // Create new DM - return isEthAddress(identifier) - ? await client.conversations.newDmWithIdentifier({ - identifier, - identifierKind: IdentifierKind.Ethereum, - }) - : await client.conversations.newDm(identifier); - } catch (err: unknown) { - handleError(err, setError, "Failed to create conversation"); - return null; - } - }; - - // Load messages - const loadMessages = async (conv: Conversation, agentInstance: Agent) => { - await conv.sync(); - const msgs = await conv.messages(); - const formatted = msgs - .slice(-50) - .map((msg) => formatMessage(msg, agentInstance)); - setMessages(formatted); - }; - - // Format message - const formatMessage = ( - message: DecodedMessage, - agentInstance: Agent, - ): FormattedMessage => { - const timestamp = message.sentAt.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - }); - - const isFromSelf = message.senderInboxId === agentInstance.client.inboxId; - const sender = isFromSelf - ? "You" - : agentInstance.address?.slice(0, 4) + - "..." + - agentInstance.address?.slice(-4); - - let content: string; - if (typeof message.content === "string") { - content = message.content; - } else { - // Try to format JSON nicely, fallback to compact if it fails - try { - content = JSON.stringify(message.content, null, 2); - } catch { - content = JSON.stringify(message.content); - } - } - - return { timestamp, sender, content, isFromSelf }; - }; - - // Start message stream - const startMessageStream = async ( - conv: Conversation, - agentInstance: Agent, - ) => { - if (isStreamingRef.current) return; - - isStreamingRef.current = true; - const client = agentInstance.client; - - try { - streamRef.current = await client.conversations.streamAllMessages(); - - (async () => { - if (!streamRef.current) return; - - for await (const message of streamRef.current) { - if (message.conversationId !== conv.id) continue; - - const formatted = formatMessage(message, agentInstance); - setMessages((prev) => [...prev, formatted]); - } - })().catch((err) => { - handleError(err, setError, "Stream error"); - isStreamingRef.current = false; - }); - } catch (err: unknown) { - handleError(err, setError, "Failed to start stream"); - isStreamingRef.current = false; - } - }; - - // Command handlers - const commands = { - "/exit": () => exit(), - "/b": () => { - setCurrentConversation(null); - setPeerAddress(""); - setMessages([]); - setShowConversationList(false); - } - }; - - const handleChatCommand = async (message: string) => { - const parts = message.split(" "); - if (parts.length !== 2) { - setErrorWithTimeout("Usage: /chat "); - return; - } - - const index = parseInt(parts[1]) - 1; - if (isNaN(index) || index < 0 || index >= conversations.length) { - setErrorWithTimeout("Invalid conversation number"); - return; - } - - const newConv = conversations[index]; - setCurrentConversation(newConv); - setShowConversationList(false); - - // Fetch peer address for DMs - if (!isGroup(newConv)) { - const address = await getEthereumAddress(newConv); - setPeerAddress(address || ""); - } else { - setPeerAddress(""); - } - - if (agent) { - await loadMessages(newConv, agent); - await startMessageStream(newConv, agent); - } - }; - - // Handle input submit - const handleSubmit = async (value: string) => { - if (!value.trim()) return; - - const message = value.trim(); - setInputValue(""); - - // Handle direct commands - if (commands[message as keyof typeof commands]) { - commands[message as keyof typeof commands](); - return; - } - - // Handle /chat command - if (message.startsWith("/chat ")) { - await handleChatCommand(message); - return; - } - - // If not in a conversation, try to connect to agent address - if (!currentConversation) { - if (agent) { - try { - const conv = await findOrCreateConversation(agent, [message]); - if (conv) { - setCurrentConversation(conv); - - // Fetch peer address for DMs - if (!isGroup(conv)) { - const address = await getEthereumAddress(conv); - setPeerAddress(address || ""); - } else { - setPeerAddress(""); - } - - await loadMessages(conv, agent); - await startMessageStream(conv, agent); - return; - } - } catch (err: unknown) { - handleError(err, setError, "Failed to connect to agent"); - return; - } - } - setErrorWithTimeout( - "No active conversation. Use /b to see available chats or /c to select one.", - ); - return; - } - - // Send message - if (!agent) { - setErrorWithTimeout("Agent not initialized"); - return; - } - - try { - await currentConversation.send(message); - } catch (err: unknown) { - handleError(err, setError, "Failed to send"); - } - }; - - // Show loading state - if (!agent || loadingStatus) { - return ( - - - - 🔄 {loadingStatus} - - - {agent && ( - - ✓ Agent initialized - - Address: {address.slice(0, 10)}...{address.slice(-8)} - - - )} - - ); - } - - return ( - -
- - {/* Show error inline if present */} - {error && ( - - - Error: {error} - - - )} - - {showConversationList && ( - - )} - - {currentConversation && } - - - - {!currentConversation && conversations.length > 0 && ( - - Available commands: /b, /c <n>, /exit - - )} - - ); -}; - -// ============================================================================ -// CLI Entry Point -// ============================================================================ -function parseArgs(): { env: XmtpEnv; help: boolean; agents?: string[] } { - const args = process.argv.slice(2); - const env = (process.env.XMTP_ENV as XmtpEnv) || "production"; - let help = false; - const agents: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - const nextArg = args[i + 1]; - - if (arg === "--help" || arg === "-h") { - help = true; - } else if (arg === "--agent" && nextArg) { - i++; - while (i < args.length && !args[i].startsWith("--")) { - agents.push(args[i]); - i++; - } - i--; - } - } - - // Auto-detect agent address if not provided and we're in dev environment - if (agents.length === 0 && env === "dev") { - // Try to get agent address from environment or use the known dev agent address - const autoAgentAddressKey = process.env.XMTP_WALLET_KEY || ""; - const autoAgentAddress = privateKeyToAddress( - autoAgentAddressKey as `0x${string}`, - ); - if (autoAgentAddress) { - agents.push(autoAgentAddress); - } - console.log(`🔗 Auto-connecting to agent: ${autoAgentAddress}`); - } - - return { env, help, agents: agents.length > 0 ? agents : undefined }; -} - -async function main(): Promise { - const { env, help, agents } = parseArgs(); - - if (help) { - showHelp(); - process.exit(0); - } - // Create a mutable array of agent identifiers - const agentIdentifiers = agents ? [...agents] : []; - - // If no agents specified, use the agent from XMTP_WALLET_KEY - if (agentIdentifiers.length === 0) { - const walletKey = process.env.XMTP_WALLET_KEY || ""; - if (walletKey) { - const publicKey = privateKeyToAddress(walletKey as `0x${string}`); - agentIdentifiers.push(publicKey); - } - } - - render(); -} - -void main(); diff --git a/src/components/ConversationSelector.tsx b/src/components/ConversationSelector.tsx index d1c72a8..ee2ff7f 100644 --- a/src/components/ConversationSelector.tsx +++ b/src/components/ConversationSelector.tsx @@ -3,6 +3,7 @@ import { Box, Text } from "ink"; import type { ConversationInfo } from "../types/index.js"; import { formatTime } from "../utils/formatters.js"; +const RED = "#fc4c34"; const DIM_GREY = "#666666"; const LIGHT_GREY = "#888888"; diff --git a/src/hooks/useXMTP.ts b/src/hooks/useXMTP.ts index 6b5afb5..78849b0 100644 --- a/src/hooks/useXMTP.ts +++ b/src/hooks/useXMTP.ts @@ -14,7 +14,7 @@ import { generatePrivateKey } from "viem/accounts"; import { getRandomValues } from "node:crypto"; import { fromString, toString } from "uint8arrays"; import { isGroup, isDm, isEthAddress } from "../utils/helpers.js"; -import { formatMessage } from "../utils/formatters.js"; +import { formatMessage, isReadReceipt } from "../utils/formatters.js"; import type { ConversationInfo, FormattedMessage } from "../types/index.js"; // Global agent instance to prevent multiple initializations @@ -44,9 +44,10 @@ interface UseXMTPReturn { refreshConversations: () => Promise; } -// Helper to get Ethereum address from conversation +// Helper to get Ethereum address from conversation (excluding self) const getEthereumAddress = async ( conversation: Conversation, + selfAddress?: string, ): Promise => { const members = await conversation.members(); @@ -56,6 +57,10 @@ const getEthereumAddress = async ( ); if (ethIdentifier) { + // Skip if this is the user's own address + if (selfAddress && ethIdentifier.identifier.toLowerCase() === selfAddress.toLowerCase()) { + continue; + } return ethIdentifier.identifier; } } @@ -65,6 +70,7 @@ const getEthereumAddress = async ( // Convert Conversation to ConversationInfo const toConversationInfo = async ( conversation: Conversation, + selfAddress?: string, ): Promise => { const messages = await conversation.messages(); const lastMessage = messages[messages.length - 1]; @@ -81,7 +87,7 @@ const toConversationInfo = async ( }; } else { const dm = conversation as Dm; - const peerAddress = await getEthereumAddress(conversation); + const peerAddress = await getEthereumAddress(conversation, selfAddress); return { id: conversation.id, conversation, @@ -111,10 +117,17 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - const streamRef = useRef | null>(null); - const isStreamingRef = useRef(false); - const refreshIntervalRef = useRef(null); + const messageStreamRef = useRef | null>(null); + const conversationStreamRef = useRef | null>(null); + const isMessageStreamingRef = useRef(false); + const isConversationStreamingRef = useRef(false); const isInitializingRef = useRef(false); + const currentConversationIdRef = useRef(null); + + // Keep ref in sync with current conversation + useEffect(() => { + currentConversationIdRef.current = currentConversation?.id || null; + }, [currentConversation]); // Initialize agent useEffect(() => { @@ -151,7 +164,7 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { const newAgent = await Agent.create(signer, { env, dbEncryptionKey: encryptionKeyBytes, - dbPath: (inboxId) => "." + `/cli-${env}-${inboxId.slice(0, 8)}.db3`, + dbPath: (inboxId) => ".xmtp" + `/cli-${env}-${inboxId.slice(0, 8)}.db3`, }); // Store globally to prevent re-initialization @@ -172,7 +185,7 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { await newAgent.client.conversations.sync(); const convList = await newAgent.client.conversations.list(); const conversationInfos = await Promise.all( - convList.map(toConversationInfo), + convList.map((conv) => toConversationInfo(conv, newAgent.address)), ); setConversations(conversationInfos); @@ -184,33 +197,20 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { agentIdentifiers, ); if (conv) { - const convInfo = await toConversationInfo(conv); + const convInfo = await toConversationInfo(conv, newAgent.address); setCurrentConversation(convInfo); + currentConversationIdRef.current = convInfo.id; await loadMessages(conv, newAgent); - await startMessageStream(conv, newAgent); } } + // Start streams for real-time updates + onStatusChange?.("Starting real-time streams..."); + await startGlobalMessageStream(newAgent); + await startConversationStream(newAgent); + onStatusChange?.(""); setIsLoading(false); - - // Set up live streaming - refresh conversations every 30 seconds - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); - } - refreshIntervalRef.current = setInterval(async () => { - try { - await newAgent.client.conversations.sync(); - const convList = await newAgent.client.conversations.list(); - const conversationInfos = await Promise.all( - convList.map(toConversationInfo), - ); - setConversations(conversationInfos); - } catch (err) { - // Silently fail for background refresh - console.warn("Background refresh failed:", err); - } - }, 30000); // 30 seconds } catch (err: unknown) { const errMsg = `Failed to initialize: ${(err as Error).message}`; setError(errMsg); @@ -224,14 +224,15 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { // Cleanup function return () => { - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); - refreshIntervalRef.current = null; + // Stop message streaming + if (messageStreamRef.current) { + isMessageStreamingRef.current = false; + messageStreamRef.current = null; } - if (streamRef.current) { - // Stop streaming if active - isStreamingRef.current = false; - streamRef.current = null; + // Stop conversation streaming + if (conversationStreamRef.current) { + isConversationStreamingRef.current = false; + conversationStreamRef.current = null; } }; }, [env, agentIdentifiers]); @@ -313,6 +314,7 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { await conv.sync(); const msgs = await conv.messages(); const formatted = msgs + .filter((msg) => !isReadReceipt(msg)) // Filter out read receipts .slice(-50) .map((msg) => formatMessage(msg, agentInstance.client.inboxId, agentInstance.address), @@ -320,43 +322,114 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { setMessages(formatted); }; - // Start message stream - const startMessageStream = async ( - conv: Conversation, - agentInstance: Agent, - ) => { - if (isStreamingRef.current) return; + // Start global message stream - listens to all conversations + const startGlobalMessageStream = async (agentInstance: Agent) => { + if (isMessageStreamingRef.current) return; - isStreamingRef.current = true; + isMessageStreamingRef.current = true; const client = agentInstance.client; try { - streamRef.current = await client.conversations.streamAllMessages(); + messageStreamRef.current = await client.conversations.streamAllMessages(); (async () => { - if (!streamRef.current) return; + if (!messageStreamRef.current) return; - for await (const message of streamRef.current) { - if (message.conversationId !== conv.id) continue; + for await (const message of messageStreamRef.current) { + if (!isMessageStreamingRef.current) break; + + // Skip read receipts + if (isReadReceipt(message)) { + continue; + } const formatted = formatMessage( message, agentInstance.client.inboxId, agentInstance.address, ); - setMessages((prev) => [...prev, formatted]); + + // Update messages if this is the current conversation + setMessages((prev) => { + const currentConvId = currentConversationIdRef.current; + if (currentConvId && message.conversationId === currentConvId) { + return [...prev, formatted]; + } + return prev; + }); + + // Update conversation list preview + setConversations((prev) => { + return prev.map((conv) => { + if (conv.id === message.conversationId) { + return { + ...conv, + lastMessage: typeof message.content === "string" ? message.content : "", + lastMessageAt: message.sentAt, + }; + } + return conv; + }).sort((a, b) => { + // Sort by last message time, most recent first + const timeA = a.lastMessageAt?.getTime() || 0; + const timeB = b.lastMessageAt?.getTime() || 0; + return timeB - timeA; + }); + }); } })().catch((err) => { - const errMsg = `Stream error: ${(err as Error).message}`; - setError(errMsg); - onError?.(errMsg); - isStreamingRef.current = false; + const errMsg = `Message stream error: ${(err as Error).message}`; + console.warn(errMsg); + isMessageStreamingRef.current = false; + }); + } catch (err: unknown) { + const errMsg = `Failed to start message stream: ${(err as Error).message}`; + setError(errMsg); + onError?.(errMsg); + isMessageStreamingRef.current = false; + } + }; + + // Start conversation stream - listens for new conversations + const startConversationStream = async (agentInstance: Agent) => { + if (isConversationStreamingRef.current) return; + + isConversationStreamingRef.current = true; + const client = agentInstance.client; + const selfAddress = agentInstance.address; + + try { + conversationStreamRef.current = await client.conversations.stream(); + + (async () => { + if (!conversationStreamRef.current) return; + + for await (const conversation of conversationStreamRef.current) { + if (!isConversationStreamingRef.current) break; + if (!conversation) continue; // Skip undefined values + + const convInfo = await toConversationInfo(conversation, selfAddress); + + // Add new conversation to the list + setConversations((prev) => { + // Check if conversation already exists + const exists = prev.find((c) => c.id === convInfo.id); + if (exists) return prev; + + // Add new conversation at the top + return [convInfo, ...prev]; + }); + } + })().catch((err) => { + const errMsg = `Conversation stream error: ${(err as Error).message}`; + console.warn(errMsg); + isConversationStreamingRef.current = false; }); } catch (err: unknown) { - const errMsg = `Failed to start stream: ${(err as Error).message}`; + const errMsg = `Failed to start conversation stream: ${(err as Error).message}`; setError(errMsg); onError?.(errMsg); - isStreamingRef.current = false; + isConversationStreamingRef.current = false; } }; @@ -365,8 +438,9 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { const conv = conversations.find((c) => c.id === id); if (conv && agent) { setCurrentConversation(conv); + currentConversationIdRef.current = conv.id; await loadMessages(conv.conversation, agent); - await startMessageStream(conv.conversation, agent); + // No need to restart stream - global stream is already running } }; @@ -383,8 +457,9 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { const conv = await findOrCreateConversationInternal(agent, identifiers); if (conv) { - const convInfo = await toConversationInfo(conv); + const convInfo = await toConversationInfo(conv, agent.address); setCurrentConversation(convInfo); + currentConversationIdRef.current = convInfo.id; // Update conversations list const exists = conversations.find((c) => c.id === convInfo.id); @@ -393,7 +468,7 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { } await loadMessages(conv, agent); - await startMessageStream(conv, agent); + // No need to restart stream - global stream is already running } }; @@ -405,7 +480,7 @@ export const useXMTP = (options: UseXMTPOptions): UseXMTPReturn => { await agent.client.conversations.sync(); const convList = await agent.client.conversations.list(); const conversationInfos = await Promise.all( - convList.map(toConversationInfo), + convList.map((conv) => toConversationInfo(conv, agent.address)), ); setConversations(conversationInfos); } catch (err: unknown) { diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index dd5ecde..ea42824 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -1,6 +1,14 @@ import type { DecodedMessage } from "@xmtp/agent-sdk"; import type { FormattedMessage } from "../types/index.js"; +// Check if a message is a read receipt +export const isReadReceipt = (message: DecodedMessage): boolean => { + if (typeof message.content === "string") { + return message.content === "[readReceipt]"; + } + return message.contentType?.typeId === "readReceipt" || false; +}; + export const formatMessage = ( message: DecodedMessage, selfInboxId: string, @@ -22,27 +30,8 @@ export const formatMessage = ( if (typeof message.content === "string") { content = message.content; } else { - // For non-string content (like JSON objects), show a summary instead of full JSON - try { - const obj = message.content as Record; - if (obj && typeof obj === "object") { - // Show a summary of the object structure - const keys = Object.keys(obj); - if (keys.length === 0) { - content = "[Empty object]"; - } else if (keys.length <= 3) { - // For small objects, show key-value pairs - content = keys.map(key => `${key}: ${typeof obj[key] === 'object' ? '[Object]' : obj[key]}`).join(", "); - } else { - // For large objects, show just the keys - content = `[Object with ${keys.length} properties: ${keys.slice(0, 3).join(", ")}${keys.length > 3 ? "..." : ""}]`; - } - } else { - content = `[${typeof message.content}]`; - } - } catch { - content = "[Complex data]"; - } + // For non-string content, show the content type ID + content = `[${message.contentType?.typeId || "unknown content type"}]`; } return {