momer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # ✨ momer ✨
2
+
3
+ cute iMessage client for your terminal 💕
4
+
5
+ ```
6
+ 。・:*:・゚★,。・:*:・゚☆
7
+ ```
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ # Clone and install
13
+ git clone <repo-url> momer
14
+ cd momer
15
+ ./install.sh
16
+ ```
17
+
18
+ Or manually:
19
+ ```bash
20
+ npm install -g .
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ momer # Launch interactive UI
27
+ momer config # View settings
28
+ ```
29
+
30
+ ### Settings
31
+
32
+ ```bash
33
+ momer config --theme cute # Pink & sparkly ✨
34
+ momer config --theme minimal # Clean & simple
35
+
36
+ momer config --view compact # Single-line messages
37
+ momer config --view spaced # Bubble-style messages
38
+ ```
39
+
40
+ ### Keyboard Shortcuts
41
+
42
+ **Conversation List:**
43
+ - `↑↓` Navigate
44
+ - `⏎` Open chat
45
+ - `n` New message
46
+ - `r` Refresh
47
+ - `q` Quit
48
+
49
+ **Chat View:**
50
+ - `↑↓` Scroll
51
+ - `⏎` Send message
52
+ - `esc` Back
53
+
54
+ ## Requirements
55
+
56
+ - macOS (uses iMessage database)
57
+ - Node.js 18+
58
+ - Full Disk Access permission for your terminal
59
+
60
+ ### Grant Full Disk Access
61
+
62
+ 1. Open **System Settings**
63
+ 2. Go to **Privacy & Security** > **Full Disk Access**
64
+ 3. Add your terminal app (Terminal, iTerm2, etc.)
65
+
66
+ ## Uninstall
67
+
68
+ ```bash
69
+ ./uninstall.sh
70
+ # or
71
+ npm uninstall -g momer
72
+ ```
73
+
74
+ ```
75
+ ☆゚・:*:・。 momer ★゚・:*:・。
76
+ ```
package/bin/momer.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const entryPoint = join(__dirname, '..', 'src', 'index.tsx');
9
+
10
+ const result = spawnSync(
11
+ 'npx',
12
+ ['tsx', entryPoint, ...process.argv.slice(2)],
13
+ {
14
+ stdio: 'inherit',
15
+ cwd: join(__dirname, '..'),
16
+ }
17
+ );
18
+
19
+ process.exit(result.status || 0);
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "momer",
3
+ "version": "1.0.0",
4
+ "description": "cute iMessage client for your terminal ✨",
5
+ "type": "module",
6
+ "main": "src/index.tsx",
7
+ "bin": {
8
+ "momer": "./bin/momer.js"
9
+ },
10
+ "scripts": {
11
+ "start": "tsx src/index.tsx",
12
+ "postinstall": "echo '\\n✨ momer installed! Run: momer\\n'"
13
+ },
14
+ "keywords": ["imessage", "cli", "macos", "messages", "terminal", "cute"],
15
+ "license": "MIT",
16
+ "author": "",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": ""
20
+ },
21
+ "engines": {
22
+ "node": ">=18.0.0"
23
+ },
24
+ "os": ["darwin"],
25
+ "dependencies": {
26
+ "better-sqlite3": "^11.0.0",
27
+ "chalk": "^5.3.0",
28
+ "commander": "^12.0.0",
29
+ "date-fns": "^3.0.0",
30
+ "ink": "^5.0.1",
31
+ "ink-text-input": "^6.0.0",
32
+ "react": "^18.2.0",
33
+ "sharp": "^0.33.0",
34
+ "tsx": "^4.7.0"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "src"
39
+ ]
40
+ }
package/src/app.tsx ADDED
@@ -0,0 +1,75 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, useApp, useInput } from 'ink';
3
+ import ConversationList from './components/ConversationList.js';
4
+ import ChatView from './components/ChatView.js';
5
+ import NewMessage from './components/NewMessage.js';
6
+ import Onboarding from './components/Onboarding.js';
7
+ import { closeDatabase } from './db.js';
8
+ import { isFirstRun } from './config.js';
9
+
10
+ export default function App() {
11
+ const { exit } = useApp();
12
+ const [screen, setScreen] = useState(isFirstRun() ? 'onboarding' : 'list');
13
+ const [selectedContact, setSelectedContact] = useState(null);
14
+
15
+ // Cleanup on unmount
16
+ useEffect(() => {
17
+ return () => {
18
+ closeDatabase();
19
+ };
20
+ }, []);
21
+
22
+ // Handle opening a chat
23
+ const openChat = (conversation) => {
24
+ setSelectedContact(conversation);
25
+ setScreen('chat');
26
+ };
27
+
28
+ // Handle going back to list
29
+ const goBack = () => {
30
+ setSelectedContact(null);
31
+ setScreen('list');
32
+ };
33
+
34
+ // Handle new message screen
35
+ const openNewMessage = () => {
36
+ setScreen('new');
37
+ };
38
+
39
+ // Global quit handler
40
+ useInput((input, key) => {
41
+ if (input === 'q' && screen === 'list') {
42
+ closeDatabase();
43
+ exit();
44
+ }
45
+ });
46
+
47
+ return (
48
+ <Box flexDirection="column" height="100%">
49
+ {screen === 'onboarding' && (
50
+ <Onboarding onComplete={() => setScreen('list')} />
51
+ )}
52
+ {screen === 'list' && (
53
+ <ConversationList
54
+ onSelect={openChat}
55
+ onNew={openNewMessage}
56
+ />
57
+ )}
58
+ {screen === 'chat' && selectedContact && (
59
+ <ChatView
60
+ contact={selectedContact}
61
+ onBack={goBack}
62
+ />
63
+ )}
64
+ {screen === 'new' && (
65
+ <NewMessage
66
+ onBack={goBack}
67
+ onSent={(contact) => {
68
+ setSelectedContact(contact);
69
+ setScreen('chat');
70
+ }}
71
+ />
72
+ )}
73
+ </Box>
74
+ );
75
+ }
@@ -0,0 +1,283 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { getMessages, getMessagesSince, refreshDatabase } from '../db.js';
5
+ import { sendMessage } from '../sender.js';
6
+ import { getTheme, isCuteMode, isCompactMode } from '../config.js';
7
+ import { isImagePath } from '../imageRenderer.js';
8
+ import ImageMessage from './ImageMessage.js';
9
+ import { format } from 'date-fns';
10
+
11
+ const VISIBLE_MESSAGES = 12;
12
+ const POLL_INTERVAL = 1500;
13
+ const MESSAGE_AREA_HEIGHT = 18;
14
+
15
+ function formatTime(date) {
16
+ if (!date) return '';
17
+ return format(date, 'h:mm a');
18
+ }
19
+
20
+ export default function ChatView({ contact, onBack }) {
21
+ const [messages, setMessages] = useState([]);
22
+ const [inputValue, setInputValue] = useState('');
23
+ const [sending, setSending] = useState(false);
24
+ const [error, setError] = useState(null);
25
+ const [scrollOffset, setScrollOffset] = useState(0);
26
+ const lastMessageId = useRef(0);
27
+ const pollRef = useRef(null);
28
+
29
+ const theme = getTheme();
30
+ const cute = isCuteMode();
31
+ const compact = isCompactMode();
32
+
33
+ const termWidth = process.stdout.columns || 60;
34
+ const bubbleMaxWidth = Math.floor((termWidth - 10) * 0.65);
35
+ const imageMaxWidth = Math.floor(bubbleMaxWidth * 0.8);
36
+
37
+ // Load all messages from DB
38
+ const loadMessages = () => {
39
+ try {
40
+ const msgs = getMessages(null, 100, contact.chatId);
41
+ // Deduplicate by message ID (attachment joins can create duplicates)
42
+ const seen = new Set();
43
+ const unique = msgs.filter(m => {
44
+ if (seen.has(m.id)) return false;
45
+ seen.add(m.id);
46
+ return true;
47
+ });
48
+ // Filter: keep messages with text OR image attachments
49
+ const filtered = unique.filter(m =>
50
+ (m.text && m.text.trim()) ||
51
+ (m.attachmentPath && isImagePath(m.attachmentPath))
52
+ );
53
+ setMessages(filtered);
54
+ if (filtered.length > 0) {
55
+ lastMessageId.current = Math.max(...filtered.map(m => m.id));
56
+ }
57
+ setScrollOffset(Math.max(0, filtered.length - VISIBLE_MESSAGES));
58
+ } catch (err) {
59
+ setError(err.message);
60
+ }
61
+ };
62
+
63
+ // Check for new messages
64
+ const checkNewMessages = () => {
65
+ try {
66
+ refreshDatabase(); // Refresh to see Messages app changes
67
+ const newMsgs = getMessagesSince(lastMessageId.current, null, contact.chatId);
68
+ // Deduplicate by message ID (attachment joins can create duplicates)
69
+ const seen = new Set();
70
+ const unique = newMsgs.filter(m => {
71
+ if (seen.has(m.id)) return false;
72
+ seen.add(m.id);
73
+ return true;
74
+ });
75
+ const filtered = unique.filter(m =>
76
+ (m.text && m.text.trim()) ||
77
+ (m.attachmentPath && isImagePath(m.attachmentPath))
78
+ );
79
+
80
+ if (filtered.length > 0) {
81
+ setMessages(prev => {
82
+ const existingIds = new Set(prev.map(m => m.id));
83
+ const uniqueNew = filtered.filter(m => !existingIds.has(m.id));
84
+
85
+ if (uniqueNew.length === 0) return prev;
86
+
87
+ const updated = [...prev, ...uniqueNew];
88
+ lastMessageId.current = Math.max(...updated.map(m => m.id));
89
+ setScrollOffset(Math.max(0, updated.length - VISIBLE_MESSAGES));
90
+ return updated;
91
+ });
92
+ }
93
+ } catch (err) {
94
+ // Silently ignore
95
+ }
96
+ };
97
+
98
+ useEffect(() => {
99
+ loadMessages();
100
+ pollRef.current = setInterval(checkNewMessages, POLL_INTERVAL);
101
+ return () => {
102
+ if (pollRef.current) clearInterval(pollRef.current);
103
+ };
104
+ }, [contact.chatId]);
105
+
106
+ useInput((input, key) => {
107
+ if (key.escape) {
108
+ onBack();
109
+ } else if (key.upArrow) {
110
+ setScrollOffset(prev => Math.max(0, prev - 1));
111
+ } else if (key.downArrow) {
112
+ setScrollOffset(prev => Math.min(Math.max(0, messages.length - VISIBLE_MESSAGES), prev + 1));
113
+ }
114
+ });
115
+
116
+ const handleSubmit = (value) => {
117
+ if (!value.trim() || sending) return;
118
+
119
+ setSending(true);
120
+ setError(null);
121
+ setInputValue('');
122
+
123
+ const recipient = contact.contactId || contact.identifier;
124
+ const result = sendMessage(recipient, value.trim(), {
125
+ isGroup: contact.isGroup,
126
+ service: contact.service,
127
+ chatIdentifier: contact.identifier
128
+ });
129
+
130
+ if (result.success) {
131
+ // Wait longer for Messages app to write to database, then refresh connection
132
+ setTimeout(() => {
133
+ refreshDatabase();
134
+ loadMessages();
135
+ setSending(false);
136
+ }, 1500);
137
+ } else {
138
+ setError(result.error);
139
+ setSending(false);
140
+ }
141
+ };
142
+
143
+ const visibleMessages = messages.slice(
144
+ scrollOffset,
145
+ scrollOffset + VISIBLE_MESSAGES
146
+ );
147
+
148
+ const displayName = contact.displayName || contact.contactId || contact.identifier;
149
+ const isGroup = contact.isGroup;
150
+
151
+ // Check if message has an image
152
+ const hasImage = (msg) => msg.attachmentPath && isImagePath(msg.attachmentPath);
153
+
154
+ return (
155
+ <Box flexDirection="column">
156
+ {/* Decorative top */}
157
+ {cute && theme.decorTop && (
158
+ <Box justifyContent="center">
159
+ <Text color={theme.primary}>{theme.decorTop}</Text>
160
+ </Box>
161
+ )}
162
+
163
+ {/* Header */}
164
+ <Text color={theme.headerBorder}>╭{'─'.repeat(termWidth - 2)}╮</Text>
165
+ <Box width={termWidth}>
166
+ <Text color={theme.headerBorder}>│ </Text>
167
+ <Text>
168
+ {isGroup && <Text>{theme.group} </Text>}
169
+ <Text bold color={theme.primary}>{displayName}</Text>
170
+ {cute && <Text> 💕</Text>}
171
+ </Text>
172
+ <Text>{' '}</Text>
173
+ {isGroup && <Text dimColor>{contact.participantCount} people</Text>}
174
+ </Box>
175
+ <Text color={theme.headerBorder}>╰{'─'.repeat(termWidth - 2)}╯</Text>
176
+
177
+ {/* Messages */}
178
+ <Box
179
+ flexDirection="column"
180
+ borderStyle="round"
181
+ borderColor={theme.border}
182
+ paddingX={1}
183
+ flexGrow={0}
184
+ flexShrink={0}
185
+ >
186
+ {visibleMessages.length === 0 ? (
187
+ <Text dimColor>{theme.noMessages}</Text>
188
+ ) : compact ? (
189
+ /* Compact view - single line per message */
190
+ visibleMessages.map((msg) => (
191
+ <Box
192
+ key={msg.id}
193
+ flexDirection="row"
194
+ justifyContent={msg.isFromMe ? 'flex-end' : 'flex-start'}
195
+ >
196
+ {hasImage(msg) && (
197
+ <Text dimColor>[img] </Text>
198
+ )}
199
+ {msg.text && msg.text.trim() && (
200
+ <Text>
201
+ {!msg.isFromMe && <Text dimColor>{msg.sender}: </Text>}
202
+ <Text color={msg.isFromMe ? theme.sent : 'white'}>{msg.text.slice(0, 50)}{msg.text.length > 50 ? '…' : ''}</Text>
203
+ <Text dimColor> {formatTime(msg.date)}</Text>
204
+ </Text>
205
+ )}
206
+ </Box>
207
+ ))
208
+ ) : (
209
+ /* Spaced view - bubbles with more detail */
210
+ visibleMessages.map((msg, index) => (
211
+ <Box
212
+ key={msg.id}
213
+ flexDirection="column"
214
+ alignItems={msg.isFromMe ? 'flex-end' : 'flex-start'}
215
+ marginBottom={index < visibleMessages.length - 1 ? 1 : 0}
216
+ >
217
+ {hasImage(msg) && (
218
+ <Box marginBottom={msg.text ? 0 : 0}>
219
+ <ImageMessage path={msg.attachmentPath} maxWidth={imageMaxWidth} />
220
+ </Box>
221
+ )}
222
+ {msg.text && msg.text.trim() && (
223
+ <Box
224
+ borderStyle="round"
225
+ borderColor={msg.isFromMe ? theme.sent : 'gray'}
226
+ paddingX={1}
227
+ maxWidth={bubbleMaxWidth}
228
+ >
229
+ <Text color={msg.isFromMe ? theme.sent : 'white'} wrap="wrap">
230
+ {msg.text}
231
+ </Text>
232
+ </Box>
233
+ )}
234
+ <Text dimColor>
235
+ {!msg.isFromMe && `${msg.sender} · `}
236
+ {formatTime(msg.date)}
237
+ </Text>
238
+ </Box>
239
+ ))
240
+ )}
241
+
242
+ {/* Scroll indicator */}
243
+ {messages.length > VISIBLE_MESSAGES && (
244
+ <Box justifyContent="center">
245
+ <Text dimColor>
246
+ {scrollOffset > 0 && '↑ '}
247
+ {Math.min(scrollOffset + VISIBLE_MESSAGES, messages.length)}/{messages.length}
248
+ {scrollOffset + VISIBLE_MESSAGES < messages.length && ' ↓'}
249
+ </Text>
250
+ </Box>
251
+ )}
252
+ </Box>
253
+
254
+ {/* Error */}
255
+ {error && (
256
+ <Box paddingX={1}>
257
+ <Text color="red">{cute ? '💔 ' : '✗ '}{error}</Text>
258
+ </Box>
259
+ )}
260
+
261
+ {/* Input */}
262
+ <Box borderStyle="round" borderColor={sending ? 'yellow' : theme.secondary} paddingX={1}>
263
+ <Text color={theme.secondary}>{sending ? '...' : '>'} </Text>
264
+ <TextInput
265
+ value={inputValue}
266
+ onChange={setInputValue}
267
+ onSubmit={handleSubmit}
268
+ placeholder={sending ? 'sending...' : 'Type a message...'}
269
+ />
270
+ </Box>
271
+
272
+ {/* Status Bar */}
273
+ <Box borderStyle="round" borderColor="gray" paddingX={1}>
274
+ {cute && <Text color={theme.primary}>{theme.decorBottom} </Text>}
275
+ <Text dimColor>
276
+ <Text color={theme.secondary}>esc</Text> back
277
+ <Text color={theme.secondary}> ↑↓</Text> scroll
278
+ <Text color={theme.secondary}> ⏎</Text> send
279
+ </Text>
280
+ </Box>
281
+ </Box>
282
+ );
283
+ }
@@ -0,0 +1,211 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { getConversations, refreshDatabase } from '../db.js';
4
+ import { getTheme, isCuteMode, isCompactMode } from '../config.js';
5
+ import { formatDistanceToNow } from 'date-fns';
6
+
7
+ const VISIBLE_COUNT = 12;
8
+ const POLL_INTERVAL = 3000;
9
+ const LIST_HEIGHT = 18;
10
+
11
+ function truncate(str, len) {
12
+ if (!str) return '';
13
+ const clean = str.replace(/\n/g, ' ').trim();
14
+ return clean.length > len ? clean.slice(0, len - 1) + '…' : clean;
15
+ }
16
+
17
+ function formatTime(date) {
18
+ if (!date) return '';
19
+ return formatDistanceToNow(date, { addSuffix: true });
20
+ }
21
+
22
+ export default function ConversationList({ onSelect, onNew }) {
23
+ const [conversations, setConversations] = useState([]);
24
+ const [selectedIndex, setSelectedIndex] = useState(0);
25
+ const [scrollOffset, setScrollOffset] = useState(0);
26
+ const [loading, setLoading] = useState(true);
27
+ const [error, setError] = useState(null);
28
+
29
+ const theme = getTheme();
30
+ const cute = isCuteMode();
31
+ const compact = isCompactMode();
32
+
33
+ const loadConversations = () => {
34
+ try {
35
+ refreshDatabase(); // Refresh to see Messages app changes
36
+ const convos = getConversations(50);
37
+ setConversations(convos);
38
+ setLoading(false);
39
+ setError(null);
40
+ } catch (err) {
41
+ setError(err.message);
42
+ setLoading(false);
43
+ }
44
+ };
45
+
46
+ useEffect(() => {
47
+ loadConversations();
48
+ const interval = setInterval(loadConversations, POLL_INTERVAL);
49
+ return () => clearInterval(interval);
50
+ }, []);
51
+
52
+ useInput((input, key) => {
53
+ if (key.upArrow) {
54
+ setSelectedIndex((prev) => {
55
+ const newIndex = Math.max(0, prev - 1);
56
+ if (newIndex < scrollOffset) {
57
+ setScrollOffset(newIndex);
58
+ }
59
+ return newIndex;
60
+ });
61
+ } else if (key.downArrow) {
62
+ setSelectedIndex((prev) => {
63
+ const newIndex = Math.min(conversations.length - 1, prev + 1);
64
+ if (newIndex >= scrollOffset + VISIBLE_COUNT) {
65
+ setScrollOffset(newIndex - VISIBLE_COUNT + 1);
66
+ }
67
+ return newIndex;
68
+ });
69
+ } else if (key.return) {
70
+ if (conversations[selectedIndex]) {
71
+ onSelect(conversations[selectedIndex]);
72
+ }
73
+ } else if (input === 'n') {
74
+ onNew();
75
+ } else if (input === 'r') {
76
+ setLoading(true);
77
+ loadConversations();
78
+ }
79
+ });
80
+
81
+ const visibleConversations = conversations.slice(
82
+ scrollOffset,
83
+ scrollOffset + VISIBLE_COUNT
84
+ );
85
+
86
+ const termWidth = process.stdout.columns || 60;
87
+ const boxWidth = Math.min(termWidth - 4, 65);
88
+
89
+ if (error) {
90
+ return (
91
+ <Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1}>
92
+ <Text color="red" bold>Error</Text>
93
+ <Text>{error}</Text>
94
+ <Text dimColor>Press r to retry</Text>
95
+ </Box>
96
+ );
97
+ }
98
+
99
+ return (
100
+ <Box flexDirection="column">
101
+ {/* Decorative top */}
102
+ {cute && theme.decorTop && (
103
+ <Box justifyContent="center">
104
+ <Text color={theme.primary}>{theme.decorTop}</Text>
105
+ </Box>
106
+ )}
107
+
108
+ {/* Header */}
109
+ <Text color={theme.headerBorder}>╭{'─'.repeat(termWidth - 2)}╮</Text>
110
+ <Box width={termWidth}>
111
+ <Text color={theme.headerBorder}>│ </Text>
112
+ <Text bold color={theme.primary}>{theme.title}</Text>
113
+ <Text>{' '}</Text>
114
+ <Text dimColor>{conversations.length} chats</Text>
115
+ </Box>
116
+ <Text color={theme.headerBorder}>╰{'─'.repeat(termWidth - 2)}╯</Text>
117
+
118
+ {/* Conversation List */}
119
+ <Box
120
+ flexDirection="column"
121
+ borderStyle="round"
122
+ borderColor={theme.border}
123
+ paddingX={1}
124
+ flexGrow={0}
125
+ flexShrink={0}
126
+ >
127
+ {loading ? (
128
+ <Text dimColor>Loading...</Text>
129
+ ) : conversations.length === 0 ? (
130
+ <Text dimColor>No conversations</Text>
131
+ ) : compact ? (
132
+ /* Compact view - single line */
133
+ visibleConversations.map((conv, index) => {
134
+ const actualIndex = scrollOffset + index;
135
+ const isSelected = actualIndex === selectedIndex;
136
+
137
+ return (
138
+ <Box key={conv.chatId}>
139
+ <Text color={isSelected ? theme.primary : 'white'}>
140
+ {isSelected ? '>' : ' '}
141
+ </Text>
142
+ {conv.isGroup && <Text>{theme.group}</Text>}
143
+ <Text bold={isSelected} color={isSelected ? theme.primary : 'white'}>
144
+ {truncate(conv.displayName, 20)}
145
+ </Text>
146
+ <Text dimColor> {conv.isFromMe ? 'You: ' : ''}{truncate(conv.lastMessage || '', 25)}</Text>
147
+ <Box flexGrow={1} />
148
+ <Text dimColor>{formatTime(conv.lastDate)}</Text>
149
+ </Box>
150
+ );
151
+ })
152
+ ) : (
153
+ /* Spaced view - two lines per conversation */
154
+ visibleConversations.map((conv, index) => {
155
+ const actualIndex = scrollOffset + index;
156
+ const isSelected = actualIndex === selectedIndex;
157
+
158
+ return (
159
+ <Box
160
+ key={conv.chatId}
161
+ flexDirection="column"
162
+ marginBottom={index < visibleConversations.length - 1 ? 1 : 0}
163
+ >
164
+ <Box>
165
+ <Text color={isSelected ? theme.primary : 'white'}>
166
+ {isSelected ? '> ' : ' '}
167
+ </Text>
168
+ {conv.isGroup && <Text>{theme.group} </Text>}
169
+ <Text bold color={isSelected ? theme.primary : 'white'}>
170
+ {truncate(conv.displayName, conv.isGroup ? 25 : 28)}
171
+ </Text>
172
+ <Box flexGrow={1} />
173
+ <Text dimColor>{formatTime(conv.lastDate)}</Text>
174
+ </Box>
175
+ <Box paddingLeft={3}>
176
+ <Text dimColor>
177
+ {conv.isFromMe ? 'You: ' : ''}
178
+ {truncate(conv.lastMessage || '', boxWidth - 12)}
179
+ </Text>
180
+ </Box>
181
+ </Box>
182
+ );
183
+ })
184
+ )}
185
+
186
+ {/* Scroll indicator */}
187
+ {conversations.length > VISIBLE_COUNT && (
188
+ <Box justifyContent="center">
189
+ <Text dimColor>
190
+ {scrollOffset > 0 && '↑ '}
191
+ {selectedIndex + 1}/{conversations.length}
192
+ {scrollOffset + VISIBLE_COUNT < conversations.length && ' ↓'}
193
+ </Text>
194
+ </Box>
195
+ )}
196
+ </Box>
197
+
198
+ {/* Status Bar */}
199
+ <Box borderStyle="round" borderColor="gray" paddingX={1}>
200
+ {cute && <Text color={theme.primary}>{theme.decorBottom} </Text>}
201
+ <Text dimColor>
202
+ <Text color={theme.secondary}>↑↓</Text> navigate
203
+ <Text color={theme.secondary}> ⏎</Text> open
204
+ <Text color={theme.secondary}> n</Text> new
205
+ <Text color={theme.secondary}> r</Text> refresh
206
+ <Text color={theme.secondary}> q</Text> quit
207
+ </Text>
208
+ </Box>
209
+ </Box>
210
+ );
211
+ }