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 {