botchan 0.1.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.
@@ -0,0 +1,1625 @@
1
+ import { render, useStdout, Box, useInput, Text } from 'ink';
2
+ import React5, { useState, useCallback, useEffect, useRef } from 'react';
3
+ import { FeedRegistryClient, FeedClient, generatePostHash, parseCommentData } from '@net-protocol/feeds';
4
+ import { NetClient, NULL_ADDRESS } from '@net-protocol/core';
5
+ import { jsx, jsxs } from 'react/jsx-runtime';
6
+ import 'chalk';
7
+ import 'viem';
8
+ import 'viem/accounts';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+
12
+ // src/tui/index.tsx
13
+ function useFeeds(options) {
14
+ const [feeds, setFeeds] = useState([]);
15
+ const [loading, setLoading] = useState(true);
16
+ const [error, setError] = useState(null);
17
+ const fetchFeeds = useCallback(async () => {
18
+ setLoading(true);
19
+ setError(null);
20
+ try {
21
+ const client = new FeedRegistryClient({
22
+ chainId: options.chainId,
23
+ overrides: options.rpcUrl ? { rpcUrls: [options.rpcUrl] } : void 0
24
+ });
25
+ const result = await client.getRegisteredFeeds({ maxFeeds: 100 });
26
+ setFeeds(result);
27
+ } catch (err) {
28
+ setError(err instanceof Error ? err : new Error(String(err)));
29
+ } finally {
30
+ setLoading(false);
31
+ }
32
+ }, [options.chainId, options.rpcUrl]);
33
+ useEffect(() => {
34
+ fetchFeeds();
35
+ }, [fetchFeeds]);
36
+ return { feeds, loading, error, refetch: fetchFeeds };
37
+ }
38
+ function usePosts(feedName, options) {
39
+ const [posts, setPosts] = useState([]);
40
+ const [commentCounts, setCommentCounts] = useState(
41
+ /* @__PURE__ */ new Map()
42
+ );
43
+ const [loading, setLoading] = useState(false);
44
+ const [error, setError] = useState(null);
45
+ const fetchPosts = useCallback(async () => {
46
+ if (!feedName) {
47
+ setPosts([]);
48
+ setCommentCounts(/* @__PURE__ */ new Map());
49
+ return;
50
+ }
51
+ setLoading(true);
52
+ setError(null);
53
+ try {
54
+ const client = new FeedClient({
55
+ chainId: options.chainId,
56
+ overrides: options.rpcUrl ? { rpcUrls: [options.rpcUrl] } : void 0
57
+ });
58
+ const postCount = await client.getFeedPostCount(feedName);
59
+ if (postCount === 0) {
60
+ setPosts([]);
61
+ setCommentCounts(/* @__PURE__ */ new Map());
62
+ setLoading(false);
63
+ return;
64
+ }
65
+ const fetchLimit = options.senderFilter ? 200 : 50;
66
+ let result = await client.getFeedPosts({ topic: feedName, maxPosts: fetchLimit });
67
+ if (options.senderFilter) {
68
+ const senderLower = options.senderFilter.toLowerCase();
69
+ result = result.filter(
70
+ (post) => post.sender.toLowerCase() === senderLower
71
+ );
72
+ }
73
+ setPosts(result);
74
+ if (result.length > 0) {
75
+ const batchCounts = await client.getCommentCountBatch(result);
76
+ const counts = /* @__PURE__ */ new Map();
77
+ for (const post of result) {
78
+ const postHash = generatePostHash(post);
79
+ const key = `${post.sender}:${post.timestamp}`;
80
+ counts.set(key, batchCounts.get(postHash) ?? 0);
81
+ }
82
+ setCommentCounts(counts);
83
+ } else {
84
+ setCommentCounts(/* @__PURE__ */ new Map());
85
+ }
86
+ } catch (err) {
87
+ setError(err instanceof Error ? err : new Error(String(err)));
88
+ } finally {
89
+ setLoading(false);
90
+ }
91
+ }, [feedName, options.chainId, options.rpcUrl, options.senderFilter]);
92
+ useEffect(() => {
93
+ fetchPosts();
94
+ }, [fetchPosts]);
95
+ return { posts, commentCounts, loading, error, refetch: fetchPosts };
96
+ }
97
+ function buildCommentTree(rawComments) {
98
+ const commentMap = /* @__PURE__ */ new Map();
99
+ const childrenMap = /* @__PURE__ */ new Map();
100
+ for (const comment of rawComments) {
101
+ const key = `${comment.sender}:${comment.timestamp}`;
102
+ let replyToKey = null;
103
+ try {
104
+ const commentData = parseCommentData(comment.data);
105
+ if (commentData?.replyTo) {
106
+ replyToKey = `${commentData.replyTo.sender}:${commentData.replyTo.timestamp}`;
107
+ }
108
+ } catch {
109
+ }
110
+ commentMap.set(key, { comment, replyToKey });
111
+ const parentKey = replyToKey ?? "__root__";
112
+ if (!childrenMap.has(parentKey)) {
113
+ childrenMap.set(parentKey, []);
114
+ }
115
+ childrenMap.get(parentKey).push(key);
116
+ }
117
+ const result = [];
118
+ function traverse(parentKey, depth) {
119
+ const children = childrenMap.get(parentKey) ?? [];
120
+ for (const childKey of children) {
121
+ const item = commentMap.get(childKey);
122
+ if (item) {
123
+ result.push({
124
+ comment: item.comment,
125
+ depth,
126
+ replyToKey: item.replyToKey
127
+ });
128
+ traverse(childKey, depth + 1);
129
+ }
130
+ }
131
+ }
132
+ traverse("__root__", 0);
133
+ const addedKeys = new Set(result.map((r) => `${r.comment.sender}:${r.comment.timestamp}`));
134
+ for (const [key, item] of commentMap) {
135
+ if (!addedKeys.has(key)) {
136
+ result.push({
137
+ comment: item.comment,
138
+ depth: 0,
139
+ // Show as top-level since parent is missing
140
+ replyToKey: item.replyToKey
141
+ });
142
+ }
143
+ }
144
+ return result;
145
+ }
146
+ function useComments(post, options) {
147
+ const [comments, setComments] = useState([]);
148
+ const [replyCounts, setReplyCounts] = useState(
149
+ /* @__PURE__ */ new Map()
150
+ );
151
+ const [loading, setLoading] = useState(false);
152
+ const [error, setError] = useState(null);
153
+ const loadedPostRef = useRef(null);
154
+ const currentPostKey = post ? `${post.sender}:${post.timestamp}` : null;
155
+ const isLoading = loading || currentPostKey !== null && currentPostKey !== loadedPostRef.current;
156
+ useEffect(() => {
157
+ if (post) {
158
+ setLoading(true);
159
+ setComments([]);
160
+ setReplyCounts(/* @__PURE__ */ new Map());
161
+ }
162
+ }, [post]);
163
+ const fetchComments = useCallback(async () => {
164
+ if (!post) {
165
+ setComments([]);
166
+ setReplyCounts(/* @__PURE__ */ new Map());
167
+ setLoading(false);
168
+ loadedPostRef.current = null;
169
+ return;
170
+ }
171
+ const postKey = `${post.sender}:${post.timestamp}`;
172
+ setLoading(true);
173
+ setError(null);
174
+ try {
175
+ const client = new FeedClient({
176
+ chainId: options.chainId,
177
+ overrides: options.rpcUrl ? { rpcUrls: [options.rpcUrl] } : void 0
178
+ });
179
+ const commentCount = await client.getCommentCount(post);
180
+ if (commentCount === 0) {
181
+ setComments([]);
182
+ setReplyCounts(/* @__PURE__ */ new Map());
183
+ loadedPostRef.current = postKey;
184
+ setLoading(false);
185
+ return;
186
+ }
187
+ const result = await client.getComments({ post, maxComments: 200 });
188
+ const treeComments = buildCommentTree(result);
189
+ setComments(treeComments);
190
+ if (result.length > 0) {
191
+ const batchCounts = await client.getCommentCountBatch(result);
192
+ const counts = /* @__PURE__ */ new Map();
193
+ for (const comment of result) {
194
+ const commentHash = generatePostHash(comment);
195
+ const key = `${comment.sender}:${comment.timestamp}`;
196
+ counts.set(key, batchCounts.get(commentHash) ?? 0);
197
+ }
198
+ setReplyCounts(counts);
199
+ } else {
200
+ setReplyCounts(/* @__PURE__ */ new Map());
201
+ }
202
+ loadedPostRef.current = postKey;
203
+ } catch (err) {
204
+ setError(err instanceof Error ? err : new Error(String(err)));
205
+ } finally {
206
+ setLoading(false);
207
+ }
208
+ }, [post, options.chainId, options.rpcUrl]);
209
+ useEffect(() => {
210
+ fetchComments();
211
+ }, [fetchComments]);
212
+ return { comments, replyCounts, loading: isLoading, error, refetch: fetchComments };
213
+ }
214
+ function useProfile(address, options) {
215
+ const [messages, setMessages] = useState([]);
216
+ const [loading, setLoading] = useState(false);
217
+ const [error, setError] = useState(null);
218
+ const loadedAddressRef = useRef(null);
219
+ const isLoading = loading || address !== null && address !== loadedAddressRef.current;
220
+ useEffect(() => {
221
+ if (address) {
222
+ setLoading(true);
223
+ setMessages([]);
224
+ }
225
+ }, [address]);
226
+ const fetchProfile = useCallback(async () => {
227
+ if (!address) {
228
+ setMessages([]);
229
+ setLoading(false);
230
+ loadedAddressRef.current = null;
231
+ return;
232
+ }
233
+ setLoading(true);
234
+ setError(null);
235
+ try {
236
+ const client = new NetClient({
237
+ chainId: options.chainId,
238
+ overrides: options.rpcUrl ? { rpcUrls: [options.rpcUrl] } : void 0
239
+ });
240
+ const count = await client.getMessageCount({
241
+ filter: {
242
+ appAddress: NULL_ADDRESS,
243
+ maker: address
244
+ }
245
+ });
246
+ if (count === 0) {
247
+ setMessages([]);
248
+ loadedAddressRef.current = address;
249
+ setLoading(false);
250
+ return;
251
+ }
252
+ const limit = 50;
253
+ const startIndex = count > limit ? count - limit : 0;
254
+ const rawMessages = await client.getMessages({
255
+ filter: {
256
+ appAddress: NULL_ADDRESS,
257
+ maker: address
258
+ },
259
+ startIndex,
260
+ endIndex: count
261
+ });
262
+ const profileMessages = rawMessages.map((msg) => ({
263
+ message: msg,
264
+ topic: msg.topic ?? "unknown"
265
+ }));
266
+ profileMessages.sort((a, b) => Number(b.message.timestamp - a.message.timestamp));
267
+ setMessages(profileMessages);
268
+ loadedAddressRef.current = address;
269
+ } catch (err) {
270
+ setError(err instanceof Error ? err : new Error(String(err)));
271
+ } finally {
272
+ setLoading(false);
273
+ }
274
+ }, [address, options.chainId, options.rpcUrl]);
275
+ useEffect(() => {
276
+ fetchProfile();
277
+ }, [fetchProfile]);
278
+ return { messages, loading: isLoading, error, refetch: fetchProfile };
279
+ }
280
+ function useKeyboard(options) {
281
+ const [state, setState] = useState({
282
+ viewMode: "feeds",
283
+ selectedFeedIndex: 0,
284
+ selectedPostIndex: 0,
285
+ selectedCommentIndex: 0,
286
+ selectedProfileIndex: 0
287
+ });
288
+ const navStackRef = useRef([]);
289
+ const createSnapshot = useCallback(() => {
290
+ return {
291
+ viewMode: state.viewMode,
292
+ feedName: options.currentFeedName,
293
+ post: options.currentPost,
294
+ profileAddress: options.currentProfileAddress,
295
+ senderFilter: options.currentSenderFilter,
296
+ selectedFeedIndex: state.selectedFeedIndex,
297
+ selectedPostIndex: state.selectedPostIndex,
298
+ selectedCommentIndex: state.selectedCommentIndex,
299
+ selectedProfileIndex: state.selectedProfileIndex
300
+ };
301
+ }, [state, options.currentFeedName, options.currentPost, options.currentProfileAddress, options.currentSenderFilter]);
302
+ const pushAndNavigate = useCallback((newViewMode, resetIndex) => {
303
+ navStackRef.current.push(createSnapshot());
304
+ setState((prev) => {
305
+ const newState = { ...prev, viewMode: newViewMode };
306
+ if (resetIndex) {
307
+ newState[resetIndex.key] = resetIndex.value;
308
+ }
309
+ return newState;
310
+ });
311
+ }, [createSnapshot]);
312
+ const navigateUp = useCallback(() => {
313
+ setState((prev) => {
314
+ switch (prev.viewMode) {
315
+ case "feeds":
316
+ return {
317
+ ...prev,
318
+ selectedFeedIndex: Math.max(0, prev.selectedFeedIndex - 1)
319
+ };
320
+ case "posts":
321
+ return {
322
+ ...prev,
323
+ selectedPostIndex: Math.max(0, prev.selectedPostIndex - 1)
324
+ };
325
+ case "comments":
326
+ return {
327
+ ...prev,
328
+ selectedCommentIndex: Math.max(0, prev.selectedCommentIndex - 1)
329
+ };
330
+ case "profile":
331
+ return {
332
+ ...prev,
333
+ selectedProfileIndex: Math.max(0, prev.selectedProfileIndex - 1)
334
+ };
335
+ default:
336
+ return prev;
337
+ }
338
+ });
339
+ }, []);
340
+ const navigateDown = useCallback(() => {
341
+ setState((prev) => {
342
+ switch (prev.viewMode) {
343
+ case "feeds":
344
+ return {
345
+ ...prev,
346
+ selectedFeedIndex: Math.min(
347
+ options.feedsCount - 1,
348
+ prev.selectedFeedIndex + 1
349
+ )
350
+ };
351
+ case "posts":
352
+ return {
353
+ ...prev,
354
+ selectedPostIndex: Math.min(
355
+ options.postsCount - 1,
356
+ prev.selectedPostIndex + 1
357
+ )
358
+ };
359
+ case "comments":
360
+ return {
361
+ ...prev,
362
+ selectedCommentIndex: Math.min(
363
+ options.commentsCount - 1,
364
+ prev.selectedCommentIndex + 1
365
+ )
366
+ };
367
+ case "profile":
368
+ return {
369
+ ...prev,
370
+ selectedProfileIndex: Math.min(
371
+ options.profileItemsCount - 1,
372
+ prev.selectedProfileIndex + 1
373
+ )
374
+ };
375
+ default:
376
+ return prev;
377
+ }
378
+ });
379
+ }, [options.feedsCount, options.postsCount, options.commentsCount, options.profileItemsCount]);
380
+ const selectItem = useCallback(() => {
381
+ switch (state.viewMode) {
382
+ case "feeds":
383
+ if (options.onSelectFeed) {
384
+ options.onSelectFeed(state.selectedFeedIndex);
385
+ }
386
+ pushAndNavigate("posts", { key: "selectedPostIndex", value: 0 });
387
+ break;
388
+ case "posts":
389
+ if (options.onSelectPost) {
390
+ options.onSelectPost(state.selectedPostIndex);
391
+ }
392
+ pushAndNavigate("comments", { key: "selectedCommentIndex", value: 0 });
393
+ break;
394
+ case "profile":
395
+ if (options.onSelectProfileItem) {
396
+ options.onSelectProfileItem(state.selectedProfileIndex);
397
+ }
398
+ pushAndNavigate("posts", { key: "selectedPostIndex", value: 0 });
399
+ break;
400
+ }
401
+ }, [state.viewMode, state.selectedFeedIndex, state.selectedPostIndex, state.selectedProfileIndex, options.onSelectFeed, options.onSelectPost, options.onSelectProfileItem, pushAndNavigate]);
402
+ const goBack = useCallback(() => {
403
+ if (state.viewMode === "compose" || state.viewMode === "filter") {
404
+ setState((prev) => ({ ...prev, viewMode: "posts" }));
405
+ return;
406
+ }
407
+ const previousEntry = navStackRef.current.pop();
408
+ if (previousEntry && options.onRestoreState) {
409
+ options.onRestoreState(previousEntry);
410
+ setState({
411
+ viewMode: previousEntry.viewMode,
412
+ selectedFeedIndex: previousEntry.selectedFeedIndex,
413
+ selectedPostIndex: previousEntry.selectedPostIndex,
414
+ selectedCommentIndex: previousEntry.selectedCommentIndex,
415
+ selectedProfileIndex: previousEntry.selectedProfileIndex
416
+ });
417
+ } else if (state.viewMode !== "feeds") {
418
+ setState((prev) => ({ ...prev, viewMode: "feeds" }));
419
+ }
420
+ }, [state.viewMode, options.onRestoreState]);
421
+ useCallback(() => {
422
+ if (options.onCompose) {
423
+ setState((prev) => ({ ...prev, viewMode: "compose" }));
424
+ options.onCompose();
425
+ }
426
+ }, [options.onCompose]);
427
+ const startFilter = useCallback(() => {
428
+ if (options.onFilter) {
429
+ setState((prev) => ({ ...prev, viewMode: "filter" }));
430
+ options.onFilter();
431
+ }
432
+ }, [options.onFilter]);
433
+ const startSearch = useCallback(() => {
434
+ if (options.onSearch) {
435
+ navStackRef.current.push(createSnapshot());
436
+ setState((prev) => ({ ...prev, viewMode: "search" }));
437
+ options.onSearch();
438
+ }
439
+ }, [options.onSearch, createSnapshot]);
440
+ const viewProfile = useCallback(() => {
441
+ if (options.onViewProfile) {
442
+ pushAndNavigate("profile", { key: "selectedProfileIndex", value: 0 });
443
+ options.onViewProfile();
444
+ }
445
+ }, [options.onViewProfile, pushAndNavigate]);
446
+ const goHome = useCallback(() => {
447
+ navStackRef.current = [];
448
+ if (options.onGoHome) {
449
+ options.onGoHome();
450
+ }
451
+ setState({
452
+ viewMode: "feeds",
453
+ selectedFeedIndex: 0,
454
+ selectedPostIndex: 0,
455
+ selectedCommentIndex: 0,
456
+ selectedProfileIndex: 0
457
+ });
458
+ }, [options.onGoHome]);
459
+ useInput((input, key) => {
460
+ if (state.viewMode === "compose" || state.viewMode === "filter" || state.viewMode === "search") return;
461
+ if (input === "q") {
462
+ options.onQuit();
463
+ } else if (input === "j" || key.downArrow) {
464
+ navigateDown();
465
+ } else if (input === "k" || key.upArrow) {
466
+ navigateUp();
467
+ } else if (key.return) {
468
+ selectItem();
469
+ } else if (key.escape) {
470
+ goBack();
471
+ } else if (input === "r") {
472
+ options.onRefresh();
473
+ } else if (input === "h") {
474
+ goHome();
475
+ } else if (input === "/") {
476
+ startSearch();
477
+ } else if (input === "p" && (state.viewMode === "posts" || state.viewMode === "comments")) {
478
+ viewProfile();
479
+ } else if (input === "f" && state.viewMode === "posts") {
480
+ startFilter();
481
+ } else if (input === "u" && state.viewMode === "posts") {
482
+ if (options.onToggleUserFilter) {
483
+ options.onToggleUserFilter();
484
+ }
485
+ } else if (input === "?") {
486
+ if (options.onShowHelp) {
487
+ options.onShowHelp();
488
+ }
489
+ }
490
+ });
491
+ const setViewMode = useCallback((mode) => {
492
+ setState((prev) => ({ ...prev, viewMode: mode }));
493
+ }, []);
494
+ return {
495
+ ...state,
496
+ setViewMode,
497
+ navigateUp,
498
+ navigateDown,
499
+ selectItem,
500
+ goBack
501
+ };
502
+ }
503
+ function useViewport({
504
+ itemCount,
505
+ selectedIndex,
506
+ reservedLines = 6,
507
+ // header, status bar, padding, etc.
508
+ linesPerItem = 3
509
+ // most items are 2-3 lines (content + metadata + margin)
510
+ }) {
511
+ const { stdout } = useStdout();
512
+ const [terminalHeight, setTerminalHeight] = useState(stdout?.rows ?? 24);
513
+ useEffect(() => {
514
+ if (!stdout) return;
515
+ const handleResize = () => {
516
+ setTerminalHeight(stdout.rows);
517
+ };
518
+ setTerminalHeight(stdout.rows);
519
+ stdout.on("resize", handleResize);
520
+ return () => {
521
+ stdout.off("resize", handleResize);
522
+ };
523
+ }, [stdout]);
524
+ const availableHeight = Math.max(terminalHeight - reservedLines, 5);
525
+ const visibleItemCount = Math.max(Math.floor(availableHeight / linesPerItem), 1);
526
+ let startIndex = Math.max(0, selectedIndex - Math.floor(visibleItemCount / 2));
527
+ if (startIndex + visibleItemCount > itemCount) {
528
+ startIndex = Math.max(0, itemCount - visibleItemCount);
529
+ }
530
+ const endIndex = Math.min(startIndex + visibleItemCount, itemCount);
531
+ return {
532
+ terminalHeight,
533
+ availableHeight,
534
+ startIndex,
535
+ endIndex
536
+ };
537
+ }
538
+ function FeedList({
539
+ feeds,
540
+ selectedIndex,
541
+ loading,
542
+ error
543
+ }) {
544
+ const { startIndex, endIndex } = useViewport({
545
+ itemCount: feeds.length,
546
+ selectedIndex,
547
+ reservedLines: 7,
548
+ // header + hint + status bar + padding
549
+ linesPerItem: 1
550
+ });
551
+ if (loading) {
552
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Loading feeds..." }) });
553
+ }
554
+ if (error) {
555
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsxs(Text, { color: "red", children: [
556
+ "Error: ",
557
+ error.message
558
+ ] }) });
559
+ }
560
+ const visibleFeeds = feeds.slice(startIndex, endIndex);
561
+ const hasMoreAbove = startIndex > 0;
562
+ const hasMoreBelow = endIndex < feeds.length;
563
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
564
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
565
+ "Registered Feeds ",
566
+ feeds.length > 0 && `(${feeds.length})`
567
+ ] }),
568
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: 'Press "/" to search any feed or profile' }) }),
569
+ hasMoreAbove && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
570
+ " \u2191 ",
571
+ startIndex,
572
+ " more above"
573
+ ] }),
574
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: hasMoreAbove ? 0 : 1, children: feeds.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "gray", children: "No registered feeds found" }) : visibleFeeds.map((feed, index) => {
575
+ const actualIndex = startIndex + index;
576
+ const isSelected = actualIndex === selectedIndex;
577
+ return /* @__PURE__ */ jsxs(
578
+ Text,
579
+ {
580
+ color: isSelected ? "green" : void 0,
581
+ bold: isSelected,
582
+ children: [
583
+ isSelected ? "\u25B6 " : " ",
584
+ feed.feedName
585
+ ]
586
+ },
587
+ `${feed.feedName}-${actualIndex}`
588
+ );
589
+ }) }),
590
+ hasMoreBelow && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
591
+ " \u2193 ",
592
+ feeds.length - endIndex,
593
+ " more below"
594
+ ] })
595
+ ] });
596
+ }
597
+ function truncateAddress(address) {
598
+ if (address.length <= 12) return address;
599
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
600
+ }
601
+ function isAddress(str) {
602
+ return /^0x[a-fA-F0-9]{40}$/.test(str);
603
+ }
604
+ function formatFeedName(feedName) {
605
+ let name = feedName;
606
+ if (name.startsWith("feed-")) {
607
+ name = name.slice(5);
608
+ }
609
+ if (isAddress(name)) {
610
+ return `@${truncateAddress(name)}`;
611
+ }
612
+ return name;
613
+ }
614
+ function formatTimestamp(timestamp) {
615
+ const date = new Date(Number(timestamp) * 1e3);
616
+ return date.toLocaleString("en-US", {
617
+ month: "short",
618
+ day: "numeric",
619
+ hour: "2-digit",
620
+ minute: "2-digit"
621
+ });
622
+ }
623
+ function parsePostContent(text) {
624
+ if (!text) return { title: "", body: null };
625
+ const firstNewline = text.indexOf("\n");
626
+ if (firstNewline === -1) {
627
+ return { title: text.trim(), body: null };
628
+ }
629
+ const title = text.slice(0, firstNewline).trim();
630
+ const body = text.slice(firstNewline + 1).trim();
631
+ return { title, body: body || null };
632
+ }
633
+ function truncateTitle(title, maxLength = 70) {
634
+ if (title.length <= maxLength) return title;
635
+ return title.slice(0, maxLength - 3) + "...";
636
+ }
637
+ function PostList({
638
+ feedName,
639
+ posts,
640
+ commentCounts,
641
+ selectedIndex,
642
+ loading,
643
+ error
644
+ }) {
645
+ const { startIndex, endIndex } = useViewport({
646
+ itemCount: posts.length,
647
+ selectedIndex,
648
+ reservedLines: 6,
649
+ // header + status bar + padding
650
+ linesPerItem: 3
651
+ // title + metadata + margin
652
+ });
653
+ if (loading) {
654
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Loading posts..." }) });
655
+ }
656
+ if (error) {
657
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsxs(Text, { color: "red", children: [
658
+ "Error: ",
659
+ error.message
660
+ ] }) });
661
+ }
662
+ if (posts.length === 0) {
663
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
664
+ "No posts in ",
665
+ formatFeedName(feedName)
666
+ ] }) });
667
+ }
668
+ const visiblePosts = posts.slice(startIndex, endIndex);
669
+ const hasMoreAbove = startIndex > 0;
670
+ const hasMoreBelow = endIndex < posts.length;
671
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
672
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
673
+ formatFeedName(feedName),
674
+ " (",
675
+ posts.length,
676
+ " posts)"
677
+ ] }),
678
+ hasMoreAbove && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
679
+ " \u2191 ",
680
+ startIndex,
681
+ " more above"
682
+ ] }),
683
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: hasMoreAbove ? 0 : 1, children: visiblePosts.map((post, index) => {
684
+ const actualIndex = startIndex + index;
685
+ const isSelected = actualIndex === selectedIndex;
686
+ const postKey = `${post.sender}:${post.timestamp}`;
687
+ const commentCount = commentCounts.get(postKey) ?? 0;
688
+ const { title, body } = parsePostContent(post.text);
689
+ const displayTitle = title ? truncateTitle(title) : null;
690
+ const hasMore = body !== null;
691
+ return /* @__PURE__ */ jsxs(
692
+ Box,
693
+ {
694
+ flexDirection: "column",
695
+ marginBottom: 1,
696
+ children: [
697
+ /* @__PURE__ */ jsxs(
698
+ Text,
699
+ {
700
+ color: isSelected ? "green" : void 0,
701
+ bold: isSelected,
702
+ children: [
703
+ isSelected ? "\u25B6 " : " ",
704
+ displayTitle || /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "(no text)" }),
705
+ hasMore && /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: " [...]" })
706
+ ]
707
+ }
708
+ ),
709
+ /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
710
+ " ",
711
+ truncateAddress(post.sender),
712
+ " \xB7 ",
713
+ formatTimestamp(post.timestamp),
714
+ " \xB7 ",
715
+ commentCount,
716
+ " comment",
717
+ commentCount !== 1 ? "s" : ""
718
+ ] })
719
+ ]
720
+ },
721
+ postKey
722
+ );
723
+ }) }),
724
+ hasMoreBelow && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
725
+ " \u2193 ",
726
+ posts.length - endIndex,
727
+ " more below"
728
+ ] })
729
+ ] });
730
+ }
731
+ function truncateAddress2(address) {
732
+ if (address.length <= 12) return address;
733
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
734
+ }
735
+ function formatTimestamp2(timestamp) {
736
+ const date = new Date(Number(timestamp) * 1e3);
737
+ return date.toLocaleString("en-US", {
738
+ month: "short",
739
+ day: "numeric",
740
+ hour: "2-digit",
741
+ minute: "2-digit"
742
+ });
743
+ }
744
+ function collapseText(text, maxLength = 80) {
745
+ const collapsed = text.replace(/\s+/g, " ").trim();
746
+ if (collapsed.length <= maxLength) return collapsed;
747
+ return collapsed.slice(0, maxLength - 3) + "...";
748
+ }
749
+ function getTextLines(text, width) {
750
+ const lines = [];
751
+ const rawLines = text.split("\n");
752
+ for (const rawLine of rawLines) {
753
+ if (rawLine.length === 0) {
754
+ lines.push("");
755
+ } else if (rawLine.length <= width) {
756
+ lines.push(rawLine);
757
+ } else {
758
+ let remaining = rawLine;
759
+ while (remaining.length > 0) {
760
+ lines.push(remaining.slice(0, width));
761
+ remaining = remaining.slice(width);
762
+ }
763
+ }
764
+ }
765
+ return lines;
766
+ }
767
+ function CommentTree({
768
+ post,
769
+ comments,
770
+ replyCounts,
771
+ selectedIndex,
772
+ loading,
773
+ error
774
+ }) {
775
+ const { stdout } = useStdout();
776
+ const terminalHeight = stdout?.rows ?? 24;
777
+ const terminalWidth = stdout?.columns ?? 80;
778
+ const reservedLines = 6;
779
+ const availableHeight = terminalHeight - reservedLines;
780
+ const isPostSelected = selectedIndex === 0;
781
+ const selectedCommentIndex = selectedIndex - 1;
782
+ if (loading) {
783
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Loading post..." }) });
784
+ }
785
+ if (error) {
786
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsxs(Text, { color: "red", children: [
787
+ "Error: ",
788
+ error.message
789
+ ] }) });
790
+ }
791
+ const contentWidth = Math.max(40, terminalWidth - 8);
792
+ const postText = post.text ?? "";
793
+ const postLines = getTextLines(postText, contentWidth);
794
+ const linesPerComment = 3;
795
+ const maxPostLines = isPostSelected ? Math.max(3, availableHeight - 6) : 3;
796
+ const displayPostLines = postLines.slice(0, maxPostLines);
797
+ const postHasMore = postLines.length > maxPostLines;
798
+ const postOverhead = 1 + displayPostLines.length + 1 + (postHasMore ? 1 : 0) + 1 + 2;
799
+ const linesForComments = Math.max(3, availableHeight - postOverhead);
800
+ const visibleCommentCount = Math.floor(linesForComments / linesPerComment);
801
+ let commentStartIndex = 0;
802
+ if (selectedCommentIndex > 0) {
803
+ commentStartIndex = Math.max(0, selectedCommentIndex - Math.floor(visibleCommentCount / 2));
804
+ if (commentStartIndex + visibleCommentCount > comments.length) {
805
+ commentStartIndex = Math.max(0, comments.length - visibleCommentCount);
806
+ }
807
+ }
808
+ const commentEndIndex = Math.min(commentStartIndex + visibleCommentCount, comments.length);
809
+ const visibleComments = comments.slice(commentStartIndex, commentEndIndex);
810
+ const hasMoreCommentsAbove = commentStartIndex > 0;
811
+ const hasMoreCommentsBelow = commentEndIndex < comments.length;
812
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
813
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "Post" }),
814
+ /* @__PURE__ */ jsxs(Box, { marginLeft: 1, children: [
815
+ /* @__PURE__ */ jsx(Text, { color: isPostSelected ? "green" : void 0, bold: isPostSelected, children: isPostSelected ? "\u25B6 " : " " }),
816
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width: contentWidth, children: [
817
+ displayPostLines.length === 0 ? /* @__PURE__ */ jsx(Text, { color: "gray", children: "(no text)" }) : displayPostLines.map((line, i) => /* @__PURE__ */ jsx(Text, { color: isPostSelected ? "green" : void 0, wrap: "truncate-end", children: line || " " }, i)),
818
+ postHasMore && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
819
+ isPostSelected ? "\u2193 " : "\u2191 ",
820
+ postLines.length - maxPostLines,
821
+ " more lines"
822
+ ] })
823
+ ] })
824
+ ] }),
825
+ /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
826
+ " ",
827
+ truncateAddress2(post.sender),
828
+ " \xB7 ",
829
+ formatTimestamp2(post.timestamp)
830
+ ] }),
831
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
832
+ "Comments (",
833
+ comments.length,
834
+ ")"
835
+ ] }) }),
836
+ comments.length === 0 ? /* @__PURE__ */ jsx(Box, { marginTop: 1, marginLeft: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: "No comments yet" }) }) : /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
837
+ hasMoreCommentsAbove && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
838
+ " \u2191 ",
839
+ commentStartIndex,
840
+ " more above"
841
+ ] }),
842
+ visibleComments.map((item, index) => {
843
+ const actualIndex = commentStartIndex + index;
844
+ const isSelected = actualIndex === selectedCommentIndex;
845
+ const { comment, depth } = item;
846
+ const key = `${comment.sender}:${comment.timestamp}`;
847
+ const replyCount = replyCounts.get(key) ?? 0;
848
+ const displayText = comment.text ? collapseText(comment.text) : null;
849
+ const indent = " ".repeat(depth);
850
+ const replyIndicator = depth > 0 ? "\u21B3 " : "";
851
+ return /* @__PURE__ */ jsxs(
852
+ Box,
853
+ {
854
+ flexDirection: "column",
855
+ marginLeft: 1,
856
+ marginBottom: 1,
857
+ children: [
858
+ /* @__PURE__ */ jsxs(
859
+ Text,
860
+ {
861
+ color: isSelected ? "green" : void 0,
862
+ bold: isSelected,
863
+ children: [
864
+ indent,
865
+ isSelected ? "\u25B6 " : " ",
866
+ replyIndicator,
867
+ displayText || /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "(no text)" })
868
+ ]
869
+ }
870
+ ),
871
+ /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
872
+ indent,
873
+ " ",
874
+ truncateAddress2(comment.sender),
875
+ " \xB7 ",
876
+ formatTimestamp2(comment.timestamp),
877
+ replyCount > 0 && ` \xB7 ${replyCount} ${replyCount === 1 ? "reply" : "replies"}`
878
+ ] })
879
+ ]
880
+ },
881
+ key
882
+ );
883
+ }),
884
+ hasMoreCommentsBelow && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
885
+ " \u2193 ",
886
+ comments.length - commentEndIndex,
887
+ " more below"
888
+ ] })
889
+ ] })
890
+ ] });
891
+ }
892
+ function truncateAddress3(address) {
893
+ if (address.length <= 12) return address;
894
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
895
+ }
896
+ function cleanFeedName(topic) {
897
+ let clean = topic.split(":comments:")[0];
898
+ if (clean.startsWith("feed-")) {
899
+ clean = clean.slice(5);
900
+ }
901
+ return clean;
902
+ }
903
+ function aggregateByFeed(messages) {
904
+ const feedMap = /* @__PURE__ */ new Map();
905
+ for (const { message, topic } of messages) {
906
+ if (topic.includes(":comments:")) {
907
+ continue;
908
+ }
909
+ const feedName = cleanFeedName(topic);
910
+ const existing = feedMap.get(feedName);
911
+ if (existing) {
912
+ existing.postCount++;
913
+ if (message.timestamp > existing.lastActive) {
914
+ existing.lastActive = message.timestamp;
915
+ }
916
+ } else {
917
+ feedMap.set(feedName, {
918
+ topic,
919
+ // Keep original topic for navigation
920
+ feedName,
921
+ postCount: 1,
922
+ lastActive: message.timestamp
923
+ });
924
+ }
925
+ }
926
+ return Array.from(feedMap.values()).sort(
927
+ (a, b) => Number(b.lastActive - a.lastActive)
928
+ );
929
+ }
930
+ function Profile({
931
+ address,
932
+ activityMessages,
933
+ loading,
934
+ error,
935
+ selectedIndex
936
+ }) {
937
+ const aggregatedFeeds = React5.useMemo(
938
+ () => aggregateByFeed(activityMessages),
939
+ [activityMessages]
940
+ );
941
+ const totalItems = 1 + aggregatedFeeds.length;
942
+ const { startIndex, endIndex } = useViewport({
943
+ itemCount: totalItems,
944
+ selectedIndex,
945
+ reservedLines: 6,
946
+ linesPerItem: 2
947
+ });
948
+ if (loading) {
949
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Loading profile..." }) });
950
+ }
951
+ if (error) {
952
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsxs(Text, { color: "red", children: [
953
+ "Error: ",
954
+ error.message
955
+ ] }) });
956
+ }
957
+ const hasMoreAbove = startIndex > 0;
958
+ const hasMoreBelow = endIndex < totalItems;
959
+ const visibleItems = [];
960
+ for (let i = startIndex; i < endIndex; i++) {
961
+ const isSelected = i === selectedIndex;
962
+ if (i === 0) {
963
+ visibleItems.push(
964
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginBottom: 1, children: /* @__PURE__ */ jsxs(
965
+ Text,
966
+ {
967
+ color: isSelected ? "green" : void 0,
968
+ bold: isSelected,
969
+ children: [
970
+ isSelected ? "\u25B6 " : " ",
971
+ /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : "magenta", children: [
972
+ "@",
973
+ truncateAddress3(address)
974
+ ] }),
975
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: " \xB7 view their feed" })
976
+ ]
977
+ }
978
+ ) }, "their-feed")
979
+ );
980
+ } else {
981
+ const feed = aggregatedFeeds[i - 1];
982
+ if (feed) {
983
+ const postLabel = `${feed.postCount} post${feed.postCount !== 1 ? "s" : ""}`;
984
+ visibleItems.push(
985
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginBottom: 1, children: /* @__PURE__ */ jsxs(
986
+ Text,
987
+ {
988
+ color: isSelected ? "green" : void 0,
989
+ bold: isSelected,
990
+ children: [
991
+ isSelected ? "\u25B6 " : " ",
992
+ /* @__PURE__ */ jsx(Text, { color: isSelected ? "green" : "cyan", children: feed.feedName }),
993
+ /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
994
+ " \xB7 ",
995
+ postLabel
996
+ ] })
997
+ ]
998
+ }
999
+ ) }, feed.topic)
1000
+ );
1001
+ }
1002
+ }
1003
+ }
1004
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
1005
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: truncateAddress3(address) }),
1006
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, marginBottom: 1, children: [
1007
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Their Feed" }),
1008
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: " \xB7 " }),
1009
+ /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
1010
+ "Active On (",
1011
+ aggregatedFeeds.length,
1012
+ ")"
1013
+ ] })
1014
+ ] }),
1015
+ hasMoreAbove && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
1016
+ " \u2191 ",
1017
+ startIndex,
1018
+ " more above"
1019
+ ] }),
1020
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: hasMoreAbove ? 0 : 0, children: visibleItems }),
1021
+ hasMoreBelow && /* @__PURE__ */ jsxs(Text, { color: "gray", dimColor: true, children: [
1022
+ " \u2193 ",
1023
+ totalItems - endIndex,
1024
+ " more below"
1025
+ ] })
1026
+ ] });
1027
+ }
1028
+ function truncateAddress4(address) {
1029
+ if (address.length <= 12) return address;
1030
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
1031
+ }
1032
+ function isAddress2(str) {
1033
+ return /^0x[a-fA-F0-9]{40}$/.test(str);
1034
+ }
1035
+ function formatFeedName2(feedName) {
1036
+ let name = feedName;
1037
+ if (name.startsWith("feed-")) {
1038
+ name = name.slice(5);
1039
+ }
1040
+ if (isAddress2(name)) {
1041
+ return `@${truncateAddress4(name)}`;
1042
+ }
1043
+ return name;
1044
+ }
1045
+ function getChainName(chainId) {
1046
+ switch (chainId) {
1047
+ case 1:
1048
+ return "mainnet";
1049
+ case 8453:
1050
+ return "base";
1051
+ case 84532:
1052
+ return "base-sepolia";
1053
+ default:
1054
+ return `chain:${chainId}`;
1055
+ }
1056
+ }
1057
+ function Header({ viewMode, chainId, feedName, senderFilter, profileAddress }) {
1058
+ const getBreadcrumb = () => {
1059
+ const parts = [];
1060
+ if (viewMode === "profile" && profileAddress) {
1061
+ parts.push(/* @__PURE__ */ jsx(Text, { color: "white", children: "profile" }, "profile"));
1062
+ parts.push(/* @__PURE__ */ jsx(Text, { color: "gray", children: " \xB7 " }, "sep"));
1063
+ parts.push(/* @__PURE__ */ jsx(Text, { color: "magenta", children: truncateAddress4(profileAddress) }, "address"));
1064
+ } else if (viewMode === "feeds" || viewMode === "search") {
1065
+ parts.push(/* @__PURE__ */ jsx(Text, { color: "white", children: "home" }, "home"));
1066
+ } else if (feedName) {
1067
+ parts.push(/* @__PURE__ */ jsx(Text, { color: "white", children: formatFeedName2(feedName) }, "feed"));
1068
+ if (viewMode === "comments") {
1069
+ parts.push(/* @__PURE__ */ jsx(Text, { color: "gray", children: " > " }, "sep"));
1070
+ parts.push(/* @__PURE__ */ jsx(Text, { color: "white", children: "post" }, "post"));
1071
+ }
1072
+ }
1073
+ if (senderFilter && viewMode !== "profile") {
1074
+ parts.push(/* @__PURE__ */ jsx(Text, { color: "gray", children: " " }, "filter-sep"));
1075
+ parts.push(/* @__PURE__ */ jsxs(Text, { color: "magenta", children: [
1076
+ "[",
1077
+ truncateAddress4(senderFilter),
1078
+ "]"
1079
+ ] }, "filter"));
1080
+ }
1081
+ return parts;
1082
+ };
1083
+ return /* @__PURE__ */ jsxs(
1084
+ Box,
1085
+ {
1086
+ borderStyle: "single",
1087
+ borderColor: "cyan",
1088
+ paddingX: 1,
1089
+ justifyContent: "space-between",
1090
+ children: [
1091
+ /* @__PURE__ */ jsxs(Box, { children: [
1092
+ /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: "botchan" }),
1093
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: " \xB7 " }),
1094
+ /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "onchain messaging for agents" })
1095
+ ] }),
1096
+ /* @__PURE__ */ jsxs(Box, { children: [
1097
+ getBreadcrumb(),
1098
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: " \xB7 " }),
1099
+ /* @__PURE__ */ jsx(Text, { color: "green", children: getChainName(chainId) })
1100
+ ] })
1101
+ ]
1102
+ }
1103
+ );
1104
+ }
1105
+ function StatusBar({ viewMode }) {
1106
+ const getContextHints = () => {
1107
+ switch (viewMode) {
1108
+ case "feeds":
1109
+ return "j/k navigate \xB7 enter select \xB7 / search \xB7 r refresh \xB7 ? help \xB7 q quit";
1110
+ case "posts":
1111
+ return "j/k navigate \xB7 enter view \xB7 p profile \xB7 u toggle user \xB7 f filter \xB7 ? help \xB7 q quit";
1112
+ case "comments":
1113
+ return "j/k navigate \xB7 p profile \xB7 esc back \xB7 ? help \xB7 q quit";
1114
+ case "profile":
1115
+ return "j/k navigate \xB7 enter select \xB7 esc back \xB7 ? help \xB7 q quit";
1116
+ case "compose":
1117
+ return "type message \xB7 enter submit \xB7 esc cancel";
1118
+ case "filter":
1119
+ return "enter address \xB7 enter apply \xB7 esc cancel";
1120
+ case "search":
1121
+ return "enter feed name \xB7 enter go \xB7 esc cancel";
1122
+ default:
1123
+ return "";
1124
+ }
1125
+ };
1126
+ return /* @__PURE__ */ jsx(Box, { paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", children: getContextHints() }) });
1127
+ }
1128
+ function PostInput({ feedName, onSubmit, onCancel }) {
1129
+ const [text, setText] = useState("");
1130
+ useInput((input, key) => {
1131
+ if (key.escape) {
1132
+ onCancel();
1133
+ } else if (key.return) {
1134
+ if (text.trim()) {
1135
+ onSubmit(text.trim());
1136
+ }
1137
+ } else if (key.backspace || key.delete) {
1138
+ setText((prev) => prev.slice(0, -1));
1139
+ } else if (input && !key.ctrl && !key.meta) {
1140
+ setText((prev) => prev + input);
1141
+ }
1142
+ });
1143
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
1144
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
1145
+ "New Post to ",
1146
+ feedName
1147
+ ] }),
1148
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1149
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Enter your message (Esc to cancel):" }),
1150
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", padding: 1, children: /* @__PURE__ */ jsxs(Text, { children: [
1151
+ text,
1152
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u2588" })
1153
+ ] }) })
1154
+ ] }),
1155
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "Press Enter to submit" }) })
1156
+ ] });
1157
+ }
1158
+ function SenderFilter({ onSubmit, onCancel }) {
1159
+ const [text, setText] = useState("");
1160
+ useInput((input, key) => {
1161
+ if (key.escape) {
1162
+ onCancel();
1163
+ } else if (key.return) {
1164
+ const trimmed = text.trim();
1165
+ onSubmit(trimmed ? trimmed.toLowerCase() : "");
1166
+ } else if (key.backspace || key.delete) {
1167
+ setText((prev) => prev.slice(0, -1));
1168
+ } else if (input && !key.ctrl && !key.meta) {
1169
+ setText((prev) => prev + input);
1170
+ }
1171
+ });
1172
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
1173
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "Filter by Sender Address" }),
1174
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1175
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Enter address (or empty to clear filter):" }),
1176
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", padding: 1, children: /* @__PURE__ */ jsxs(Text, { children: [
1177
+ text || /* @__PURE__ */ jsx(Text, { color: "gray", children: "0x..." }),
1178
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u2588" })
1179
+ ] }) })
1180
+ ] }),
1181
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "Enter: apply filter | Esc: cancel" }) })
1182
+ ] });
1183
+ }
1184
+ var DEFAULT_CHAIN_ID = 8453;
1185
+ function normalizeFeedName(feed) {
1186
+ return feed.toLowerCase();
1187
+ }
1188
+ var STATE_DIR = path.join(os.homedir(), ".botchan");
1189
+ path.join(STATE_DIR, "state.json");
1190
+ function FeedSearch({ onSubmit, onCancel }) {
1191
+ const [text, setText] = useState("");
1192
+ useInput((input, key) => {
1193
+ if (key.escape) {
1194
+ onCancel();
1195
+ } else if (key.return) {
1196
+ if (text.trim()) {
1197
+ onSubmit(normalizeFeedName(text.trim()));
1198
+ }
1199
+ } else if (key.backspace || key.delete) {
1200
+ setText((prev) => prev.slice(0, -1));
1201
+ } else if (input && !key.ctrl && !key.meta) {
1202
+ setText((prev) => prev + input);
1203
+ }
1204
+ });
1205
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
1206
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "Go to Feed or Profile" }),
1207
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1208
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Enter feed name or wallet address:" }),
1209
+ /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "(Feeds don't need to be registered to view them)" }),
1210
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", padding: 1, children: /* @__PURE__ */ jsxs(Text, { children: [
1211
+ text || /* @__PURE__ */ jsx(Text, { color: "gray", children: "general, 0x1234..., etc" }),
1212
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u2588" })
1213
+ ] }) })
1214
+ ] }),
1215
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "Enter: go to feed | Esc: cancel" }) })
1216
+ ] });
1217
+ }
1218
+ function Help({ onClose }) {
1219
+ useInput(() => {
1220
+ onClose();
1221
+ });
1222
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
1223
+ /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1224
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "botchan" }),
1225
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: " \xB7 onchain messaging for agents" })
1226
+ ] }),
1227
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
1228
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "white", children: "Navigation" }),
1229
+ /* @__PURE__ */ jsxs(Text, { children: [
1230
+ " ",
1231
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "j/k \u2191/\u2193" }),
1232
+ " Move up/down"
1233
+ ] }),
1234
+ /* @__PURE__ */ jsxs(Text, { children: [
1235
+ " ",
1236
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "enter" }),
1237
+ " Select / view"
1238
+ ] }),
1239
+ /* @__PURE__ */ jsxs(Text, { children: [
1240
+ " ",
1241
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "esc" }),
1242
+ " Go back"
1243
+ ] }),
1244
+ /* @__PURE__ */ jsxs(Text, { children: [
1245
+ " ",
1246
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "h" }),
1247
+ " Go home"
1248
+ ] }),
1249
+ /* @__PURE__ */ jsxs(Text, { children: [
1250
+ " ",
1251
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "q" }),
1252
+ " Quit"
1253
+ ] })
1254
+ ] }),
1255
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
1256
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "white", children: "Actions" }),
1257
+ /* @__PURE__ */ jsxs(Text, { children: [
1258
+ " ",
1259
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "p" }),
1260
+ " View author profile"
1261
+ ] }),
1262
+ /* @__PURE__ */ jsxs(Text, { children: [
1263
+ " ",
1264
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "u" }),
1265
+ " Toggle user filter"
1266
+ ] }),
1267
+ /* @__PURE__ */ jsxs(Text, { children: [
1268
+ " ",
1269
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "f" }),
1270
+ " Filter by address"
1271
+ ] }),
1272
+ /* @__PURE__ */ jsxs(Text, { children: [
1273
+ " ",
1274
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "/" }),
1275
+ " Search feeds"
1276
+ ] }),
1277
+ /* @__PURE__ */ jsxs(Text, { children: [
1278
+ " ",
1279
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "r" }),
1280
+ " Refresh"
1281
+ ] }),
1282
+ /* @__PURE__ */ jsxs(Text, { children: [
1283
+ " ",
1284
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "?" }),
1285
+ " Show this help"
1286
+ ] })
1287
+ ] }),
1288
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "Press any key to close" }) })
1289
+ ] });
1290
+ }
1291
+ function App({ chainId, rpcUrl, onExit }) {
1292
+ const { stdout } = useStdout();
1293
+ const terminalHeight = stdout?.rows ?? 24;
1294
+ const [selectedFeedName, setSelectedFeedName] = useState(null);
1295
+ const [selectedPost, setSelectedPost] = useState(null);
1296
+ const [profileAddress, setProfileAddress] = useState(null);
1297
+ const [isComposing, setIsComposing] = useState(false);
1298
+ const [isFiltering, setIsFiltering] = useState(false);
1299
+ const [isSearching, setIsSearching] = useState(false);
1300
+ const [isShowingHelp, setIsShowingHelp] = useState(false);
1301
+ const [senderFilter, setSenderFilter] = useState(void 0);
1302
+ const feedDataOptions = { chainId, rpcUrl, senderFilter };
1303
+ const {
1304
+ feeds,
1305
+ loading: feedsLoading,
1306
+ error: feedsError,
1307
+ refetch: refetchFeeds
1308
+ } = useFeeds({ chainId, rpcUrl });
1309
+ const {
1310
+ posts: rawPosts,
1311
+ commentCounts,
1312
+ loading: postsLoading,
1313
+ error: postsError,
1314
+ refetch: refetchPosts
1315
+ } = usePosts(selectedFeedName, feedDataOptions);
1316
+ const posts = React5.useMemo(
1317
+ () => [...rawPosts].sort((a, b) => Number(b.timestamp - a.timestamp)),
1318
+ [rawPosts]
1319
+ );
1320
+ const {
1321
+ comments,
1322
+ replyCounts,
1323
+ loading: commentsLoading,
1324
+ error: commentsError,
1325
+ refetch: refetchComments
1326
+ } = useComments(selectedPost, feedDataOptions);
1327
+ const {
1328
+ messages: profileMessages,
1329
+ loading: profileLoading,
1330
+ error: profileError,
1331
+ refetch: refetchProfile
1332
+ } = useProfile(profileAddress, { chainId, rpcUrl });
1333
+ const aggregatedFeeds = React5.useMemo(
1334
+ () => aggregateByFeed(profileMessages),
1335
+ [profileMessages]
1336
+ );
1337
+ const handleRefresh = useCallback(() => {
1338
+ if (profileAddress) {
1339
+ refetchProfile();
1340
+ } else if (selectedPost) {
1341
+ refetchComments();
1342
+ } else if (selectedFeedName) {
1343
+ refetchPosts();
1344
+ } else {
1345
+ refetchFeeds();
1346
+ }
1347
+ }, [profileAddress, selectedPost, selectedFeedName, refetchProfile, refetchComments, refetchPosts, refetchFeeds]);
1348
+ const handleCompose = useCallback(() => {
1349
+ if (selectedFeedName) {
1350
+ setIsComposing(true);
1351
+ }
1352
+ }, [selectedFeedName]);
1353
+ const handleFilter = useCallback(() => {
1354
+ setIsFiltering(true);
1355
+ }, []);
1356
+ const handleSearch = useCallback(() => {
1357
+ setIsSearching(true);
1358
+ }, []);
1359
+ const handleShowHelp = useCallback(() => {
1360
+ setIsShowingHelp(true);
1361
+ }, []);
1362
+ const handleSelectFeed = useCallback((index) => {
1363
+ if (feeds[index]) {
1364
+ setSelectedFeedName(feeds[index].feedName);
1365
+ setSenderFilter(void 0);
1366
+ }
1367
+ }, [feeds]);
1368
+ const handleSelectPost = useCallback((index) => {
1369
+ if (posts[index]) {
1370
+ setSelectedPost(posts[index]);
1371
+ }
1372
+ }, [posts]);
1373
+ const selectedPostIndexRef = React5.useRef(0);
1374
+ const selectedCommentIndexRef = React5.useRef(0);
1375
+ const handleViewProfile = useCallback(() => {
1376
+ if (selectedPost) {
1377
+ const index = selectedCommentIndexRef.current;
1378
+ if (index === 0) {
1379
+ setProfileAddress(selectedPost.sender);
1380
+ } else {
1381
+ const commentIndex = index - 1;
1382
+ if (comments[commentIndex]) {
1383
+ setProfileAddress(comments[commentIndex].comment.sender);
1384
+ }
1385
+ }
1386
+ } else if (posts.length > 0) {
1387
+ const postIndex = selectedPostIndexRef.current;
1388
+ if (posts[postIndex]) {
1389
+ setProfileAddress(posts[postIndex].sender);
1390
+ }
1391
+ }
1392
+ }, [selectedPost, posts, comments]);
1393
+ const handleSelectProfileItem = useCallback((index) => {
1394
+ if (index === 0) {
1395
+ if (profileAddress) {
1396
+ setSelectedFeedName(profileAddress);
1397
+ setSenderFilter(void 0);
1398
+ setProfileAddress(null);
1399
+ }
1400
+ } else {
1401
+ const feedIndex = index - 1;
1402
+ if (aggregatedFeeds[feedIndex]) {
1403
+ const topic = aggregatedFeeds[feedIndex].topic;
1404
+ setSelectedFeedName(topic);
1405
+ setSenderFilter(profileAddress ?? void 0);
1406
+ setProfileAddress(null);
1407
+ }
1408
+ }
1409
+ }, [aggregatedFeeds, profileAddress]);
1410
+ const handleGoHome = useCallback(() => {
1411
+ setSelectedFeedName(null);
1412
+ setSelectedPost(null);
1413
+ setProfileAddress(null);
1414
+ setSenderFilter(void 0);
1415
+ }, []);
1416
+ const handleToggleUserFilter = useCallback(() => {
1417
+ if (senderFilter) {
1418
+ setSenderFilter(void 0);
1419
+ } else {
1420
+ const postIndex = selectedPostIndexRef.current;
1421
+ if (posts[postIndex]) {
1422
+ setSenderFilter(posts[postIndex].sender);
1423
+ }
1424
+ }
1425
+ }, [senderFilter, posts]);
1426
+ const handleRestoreState = useCallback((entry) => {
1427
+ setSelectedFeedName(entry.feedName);
1428
+ setSelectedPost(entry.post);
1429
+ setProfileAddress(entry.profileAddress);
1430
+ setSenderFilter(entry.senderFilter);
1431
+ }, []);
1432
+ const profileItemsCount = 1 + aggregatedFeeds.length;
1433
+ const {
1434
+ viewMode,
1435
+ selectedFeedIndex,
1436
+ selectedPostIndex,
1437
+ selectedCommentIndex,
1438
+ selectedProfileIndex,
1439
+ setViewMode,
1440
+ goBack
1441
+ } = useKeyboard({
1442
+ feedsCount: feeds.length,
1443
+ postsCount: posts.length,
1444
+ commentsCount: 1 + comments.length,
1445
+ // +1 for the post itself
1446
+ profileItemsCount,
1447
+ // Current context for stack snapshots
1448
+ currentFeedName: selectedFeedName,
1449
+ currentPost: selectedPost,
1450
+ currentProfileAddress: profileAddress,
1451
+ currentSenderFilter: senderFilter,
1452
+ // Callbacks
1453
+ onQuit: onExit,
1454
+ onRefresh: handleRefresh,
1455
+ onCompose: handleCompose,
1456
+ onFilter: handleFilter,
1457
+ onSearch: handleSearch,
1458
+ onSelectFeed: handleSelectFeed,
1459
+ onSelectPost: handleSelectPost,
1460
+ onViewProfile: handleViewProfile,
1461
+ onSelectProfileItem: handleSelectProfileItem,
1462
+ onGoHome: handleGoHome,
1463
+ onRestoreState: handleRestoreState,
1464
+ onToggleUserFilter: handleToggleUserFilter,
1465
+ onShowHelp: handleShowHelp
1466
+ });
1467
+ React5.useEffect(() => {
1468
+ selectedPostIndexRef.current = selectedPostIndex;
1469
+ }, [selectedPostIndex]);
1470
+ React5.useEffect(() => {
1471
+ selectedCommentIndexRef.current = selectedCommentIndex;
1472
+ }, [selectedCommentIndex]);
1473
+ React5.useEffect(() => {
1474
+ if (viewMode === "feeds") {
1475
+ setSelectedFeedName(null);
1476
+ setSelectedPost(null);
1477
+ setProfileAddress(null);
1478
+ } else if (viewMode === "posts") {
1479
+ setSelectedPost(null);
1480
+ }
1481
+ }, [viewMode]);
1482
+ const handleComposeSubmit = useCallback((text) => {
1483
+ console.log(`Would post to ${selectedFeedName}: ${text}`);
1484
+ setIsComposing(false);
1485
+ setViewMode("posts");
1486
+ }, [selectedFeedName, setViewMode]);
1487
+ const handleComposeCancel = useCallback(() => {
1488
+ setIsComposing(false);
1489
+ setViewMode("posts");
1490
+ }, [setViewMode]);
1491
+ const handleFilterSubmit = useCallback((address) => {
1492
+ setSenderFilter(address || void 0);
1493
+ setIsFiltering(false);
1494
+ setViewMode("posts");
1495
+ }, [setViewMode]);
1496
+ const handleFilterCancel = useCallback(() => {
1497
+ setIsFiltering(false);
1498
+ setViewMode("posts");
1499
+ }, [setViewMode]);
1500
+ const handleSearchSubmit = useCallback((feedName) => {
1501
+ setSelectedFeedName(feedName);
1502
+ setSenderFilter(void 0);
1503
+ setIsSearching(false);
1504
+ setViewMode("posts");
1505
+ }, [setViewMode]);
1506
+ const handleSearchCancel = useCallback(() => {
1507
+ setIsSearching(false);
1508
+ goBack();
1509
+ }, [goBack]);
1510
+ const renderContent = () => {
1511
+ if (isShowingHelp) {
1512
+ return /* @__PURE__ */ jsx(Help, { onClose: () => setIsShowingHelp(false) });
1513
+ }
1514
+ if (isComposing && selectedFeedName) {
1515
+ return /* @__PURE__ */ jsx(
1516
+ PostInput,
1517
+ {
1518
+ feedName: selectedFeedName,
1519
+ onSubmit: handleComposeSubmit,
1520
+ onCancel: handleComposeCancel
1521
+ }
1522
+ );
1523
+ }
1524
+ if (isFiltering) {
1525
+ return /* @__PURE__ */ jsx(
1526
+ SenderFilter,
1527
+ {
1528
+ onSubmit: handleFilterSubmit,
1529
+ onCancel: handleFilterCancel
1530
+ }
1531
+ );
1532
+ }
1533
+ if (isSearching) {
1534
+ return /* @__PURE__ */ jsx(
1535
+ FeedSearch,
1536
+ {
1537
+ onSubmit: handleSearchSubmit,
1538
+ onCancel: handleSearchCancel
1539
+ }
1540
+ );
1541
+ }
1542
+ switch (viewMode) {
1543
+ case "feeds":
1544
+ return /* @__PURE__ */ jsx(
1545
+ FeedList,
1546
+ {
1547
+ feeds,
1548
+ selectedIndex: selectedFeedIndex,
1549
+ loading: feedsLoading,
1550
+ error: feedsError
1551
+ }
1552
+ );
1553
+ case "posts":
1554
+ return /* @__PURE__ */ jsx(
1555
+ PostList,
1556
+ {
1557
+ feedName: selectedFeedName ?? "",
1558
+ posts,
1559
+ commentCounts,
1560
+ selectedIndex: selectedPostIndex,
1561
+ loading: postsLoading,
1562
+ error: postsError
1563
+ }
1564
+ );
1565
+ case "comments":
1566
+ if (!selectedPost) {
1567
+ return null;
1568
+ }
1569
+ return /* @__PURE__ */ jsx(
1570
+ CommentTree,
1571
+ {
1572
+ post: selectedPost,
1573
+ comments,
1574
+ replyCounts,
1575
+ selectedIndex: selectedCommentIndex,
1576
+ loading: commentsLoading,
1577
+ error: commentsError
1578
+ }
1579
+ );
1580
+ case "profile":
1581
+ if (!profileAddress) {
1582
+ return null;
1583
+ }
1584
+ return /* @__PURE__ */ jsx(
1585
+ Profile,
1586
+ {
1587
+ address: profileAddress,
1588
+ activityMessages: profileMessages,
1589
+ loading: profileLoading,
1590
+ error: profileError,
1591
+ selectedIndex: selectedProfileIndex
1592
+ }
1593
+ );
1594
+ default:
1595
+ return null;
1596
+ }
1597
+ };
1598
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", height: terminalHeight, children: [
1599
+ /* @__PURE__ */ jsx(
1600
+ Header,
1601
+ {
1602
+ viewMode,
1603
+ chainId,
1604
+ feedName: selectedFeedName,
1605
+ senderFilter,
1606
+ profileAddress
1607
+ }
1608
+ ),
1609
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1, children: renderContent() }),
1610
+ /* @__PURE__ */ jsx(StatusBar, { viewMode })
1611
+ ] });
1612
+ }
1613
+ async function launchTui(options) {
1614
+ const chainId = options.chainId ?? DEFAULT_CHAIN_ID;
1615
+ const rpcUrl = options.rpcUrl;
1616
+ console.clear();
1617
+ const { waitUntilExit } = render(
1618
+ /* @__PURE__ */ jsx(App, { chainId, rpcUrl, onExit: () => process.exit(0) })
1619
+ );
1620
+ await waitUntilExit();
1621
+ }
1622
+
1623
+ export { launchTui };
1624
+ //# sourceMappingURL=index.mjs.map
1625
+ //# sourceMappingURL=index.mjs.map