@andocorp/cli 0.1.1 → 0.1.2

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.
@@ -1,832 +0,0 @@
1
- import readline from "node:readline";
2
- import { createAndoCliClient } from "./client.js";
3
- import { promptLine, PromptLineOptions } from "./components/prompt-line.js";
4
- import { renderTranscriptLines } from "./components/transcript-pane.js";
5
- import { getConversationLabel, padVisibleEnd, truncate } from "./format.js";
6
- import { ConversationPage, Message, Membership } from "./types.js";
7
-
8
- type ConversationFilter = "channels" | "dms";
9
- type FocusPane = "nav" | "messages" | "thread";
10
- type PaneKey = "nav" | "messages" | "thread";
11
-
12
- type InteractiveApp = {
13
- run: () => Promise<void>;
14
- };
15
-
16
- type InteractiveState = {
17
- currentConversationId: string | null;
18
- currentMemberId: string | null;
19
- currentPage: ConversationPage | null;
20
- error: string | null;
21
- filter: ConversationFilter;
22
- focus: FocusPane;
23
- memberships: Membership[];
24
- messagePageHistory: ConversationPage[];
25
- messages: Message[];
26
- searchTerm: string;
27
- selectedMembershipIndex: number;
28
- selectedMessageIndex: number;
29
- selectedThreadIndex: number;
30
- status: string | null;
31
- threadReplies: Message[];
32
- threadRootMessage: Message | null;
33
- };
34
-
35
- function clearTerminal() {
36
- process.stdout.write("\u001Bc");
37
- }
38
-
39
- const ANSI_RESET = "\u001b[0m";
40
- const ANSI_SELECTION_MARKER = "\u001b[38;2;255;215;0m";
41
-
42
- function clamp(value: number, minimum: number, maximum: number) {
43
- return Math.max(minimum, Math.min(maximum, value));
44
- }
45
-
46
- function getVisibleWindowStart(
47
- totalItems: number,
48
- selectedIndex: number,
49
- windowSize: number
50
- ) {
51
- if (windowSize <= 0 || totalItems <= windowSize) {
52
- return 0;
53
- }
54
-
55
- return clamp(
56
- selectedIndex - Math.floor(windowSize / 2),
57
- 0,
58
- totalItems - windowSize
59
- );
60
- }
61
-
62
- function getFilteredMemberships(state: InteractiveState) {
63
- const normalizedSearch = state.searchTerm.trim().toLowerCase();
64
-
65
- return state.memberships.filter((membership) => {
66
- const isChannel = membership.conversation.type === 0;
67
- if (state.filter === "channels" && !isChannel) {
68
- return false;
69
- }
70
-
71
- if (state.filter === "dms" && isChannel) {
72
- return false;
73
- }
74
-
75
- if (normalizedSearch === "") {
76
- return true;
77
- }
78
-
79
- const haystacks = [
80
- membership.conversation.name ?? "",
81
- membership.conversation.id,
82
- membership.conversation.description ?? "",
83
- membership.member.display_name ?? "",
84
- ];
85
-
86
- return haystacks.some((value) =>
87
- value.toLowerCase().includes(normalizedSearch)
88
- );
89
- });
90
- }
91
-
92
- function getSidebarWidth(params: {
93
- memberships: Membership[];
94
- filter: ConversationFilter;
95
- totalWidth: number;
96
- }) {
97
- const { memberships, filter, totalWidth } = params;
98
- const minimumSidebarWidth = 18;
99
- const maximumSidebarWidth = Math.min(30, Math.max(minimumSidebarWidth, totalWidth - 33));
100
- const headerWidth = filter === "channels" ? "Channels".length : "DMs".length;
101
- const widestLabel = memberships.reduce((maximum, membership) => {
102
- return Math.max(maximum, getConversationLabel(membership).length);
103
- }, headerWidth);
104
-
105
- return clamp(widestLabel + 3, minimumSidebarWidth, maximumSidebarWidth);
106
- }
107
-
108
- function getSelectedMembership(state: InteractiveState) {
109
- const memberships = getFilteredMemberships(state);
110
- if (memberships.length === 0) {
111
- return null;
112
- }
113
-
114
- return memberships[
115
- clamp(state.selectedMembershipIndex, 0, memberships.length - 1)
116
- ] ?? null;
117
- }
118
-
119
- function getSelectedMessage(state: InteractiveState) {
120
- if (state.messages.length === 0) {
121
- return null;
122
- }
123
-
124
- return state.messages[
125
- clamp(state.selectedMessageIndex, 0, state.messages.length - 1)
126
- ] ?? null;
127
- }
128
-
129
- function getSelectedThreadMessage(state: InteractiveState) {
130
- if (state.threadReplies.length === 0) {
131
- return null;
132
- }
133
-
134
- return state.threadReplies[
135
- clamp(state.selectedThreadIndex, 0, state.threadReplies.length - 1)
136
- ] ?? null;
137
- }
138
-
139
- function syncMembershipSelectionToCurrentConversation(state: InteractiveState) {
140
- if (state.currentConversationId == null) {
141
- return;
142
- }
143
-
144
- const memberships = getFilteredMemberships(state);
145
- const currentMembershipIndex = memberships.findIndex(
146
- (membership) => membership.conversation.id === state.currentConversationId
147
- );
148
-
149
- if (currentMembershipIndex !== -1) {
150
- state.selectedMembershipIndex = currentMembershipIndex;
151
- }
152
- }
153
-
154
- function hasOpenThread(state: InteractiveState) {
155
- return state.threadRootMessage != null;
156
- }
157
-
158
- function getVisiblePaneOrder(state: InteractiveState): PaneKey[] {
159
- if (hasOpenThread(state)) {
160
- return ["messages", "thread"];
161
- }
162
-
163
- return ["nav", "messages"];
164
- }
165
-
166
- function getNextVisibleFocus(
167
- state: InteractiveState,
168
- direction: "left" | "right"
169
- ): FocusPane {
170
- const panes = getVisiblePaneOrder(state);
171
- const activePane = panes.includes(state.focus) ? state.focus : panes[0];
172
- if (activePane == null) {
173
- return "messages";
174
- }
175
-
176
- const activeIndex = panes.indexOf(activePane);
177
- if (activeIndex === -1) {
178
- return "messages";
179
- }
180
-
181
- const nextIndex =
182
- direction === "left"
183
- ? Math.max(0, activeIndex - 1)
184
- : Math.min(panes.length - 1, activeIndex + 1);
185
- return panes[nextIndex] ?? "messages";
186
- }
187
-
188
- function cycleVisibleFocus(state: InteractiveState): FocusPane {
189
- const panes = getVisiblePaneOrder(state);
190
- if (panes.length === 0) {
191
- return "messages";
192
- }
193
-
194
- const activeIndex = panes.indexOf(state.focus);
195
- if (activeIndex === -1) {
196
- return panes[0] ?? "messages";
197
- }
198
-
199
- return panes[(activeIndex + 1) % panes.length] ?? "messages";
200
- }
201
-
202
- function getSelectedItemPrefix(params: {
203
- isFocusedPane: boolean;
204
- isSelected: boolean;
205
- }) {
206
- if (!params.isSelected) {
207
- return " ";
208
- }
209
-
210
- return `${ANSI_SELECTION_MARKER}${params.isFocusedPane ? ">" : "-"}${ANSI_RESET}`;
211
- }
212
-
213
- export function createInteractiveApp(params: {
214
- client: ReturnType<typeof createAndoCliClient>;
215
- currentMemberId: string | null;
216
- }): InteractiveApp {
217
- let isLoading = false;
218
- let isPromptActive = false;
219
- let isCleanedUp = false;
220
- let conversationUnsubscribe: (() => void) | null = null;
221
- let threadUnsubscribe: (() => void) | null = null;
222
-
223
- async function withPrompt(label: string, options?: PromptLineOptions) {
224
- isPromptActive = true;
225
- try {
226
- return await promptLine(label, options);
227
- } finally {
228
- isPromptActive = false;
229
- }
230
- }
231
-
232
- const state: InteractiveState = {
233
- currentConversationId: null,
234
- currentMemberId: params.currentMemberId,
235
- currentPage: null,
236
- error: null,
237
- filter: "channels",
238
- focus: "nav",
239
- memberships: [],
240
- messagePageHistory: [],
241
- messages: [],
242
- searchTerm: "",
243
- selectedMembershipIndex: 0,
244
- selectedMessageIndex: 0,
245
- selectedThreadIndex: 0,
246
- status: null,
247
- threadReplies: [],
248
- threadRootMessage: null,
249
- };
250
-
251
- async function loadMemberships() {
252
- state.memberships = await params.client.getAllMemberships();
253
- const first = getSelectedMembership(state);
254
- if (first != null) {
255
- await openConversation(first.conversation.id);
256
- }
257
- }
258
-
259
- async function openConversation(conversationId: string) {
260
- if (conversationUnsubscribe != null) {
261
- conversationUnsubscribe();
262
- conversationUnsubscribe = null;
263
- }
264
-
265
- const page = await params.client.getConversationMessages(conversationId, 25);
266
- state.currentConversationId = conversationId;
267
- state.currentPage = page;
268
- state.messagePageHistory = [];
269
- state.messages = page.items;
270
- state.selectedMessageIndex = Math.max(0, state.messages.length - 1);
271
- state.threadReplies = [];
272
- state.threadRootMessage = null;
273
- state.focus = "messages";
274
- state.status = `Opened conversation ${conversationId} (live)`;
275
-
276
- conversationUnsubscribe = params.client.subscribeToConversationMessages({
277
- conversationId,
278
- limit: 25,
279
- onUpdate: (updatedPage) => {
280
- if (isPromptActive || isCleanedUp || state.currentConversationId !== conversationId) {
281
- return;
282
- }
283
-
284
- const hadMessages = state.messages.length > 0;
285
- const lastMessageId = state.messages[state.messages.length - 1]?.id;
286
-
287
- state.currentPage = updatedPage;
288
- state.messages = updatedPage.items;
289
-
290
- const newLastMessageId = state.messages[state.messages.length - 1]?.id;
291
- if (hadMessages && newLastMessageId !== lastMessageId) {
292
- state.selectedMessageIndex = Math.max(0, state.messages.length - 1);
293
- }
294
-
295
- render();
296
- },
297
- });
298
- }
299
-
300
- async function loadOlderMessages() {
301
- if (
302
- state.currentConversationId == null ||
303
- state.currentPage == null ||
304
- !state.currentPage.hasMore ||
305
- state.currentPage.cursor == null
306
- ) {
307
- state.status = "No older messages available.";
308
- return;
309
- }
310
-
311
- state.messagePageHistory.push(state.currentPage);
312
- const page = await params.client.getConversationMessagesPage({
313
- conversationId: state.currentConversationId,
314
- cursor: state.currentPage.cursor,
315
- limit: 25,
316
- });
317
- state.currentPage = page;
318
- state.messages = page.items;
319
- state.selectedMessageIndex = Math.max(0, state.messages.length - 1);
320
- state.status = "Loaded older messages.";
321
- }
322
-
323
- async function loadNewerMessages() {
324
- const previousPage = state.messagePageHistory.pop() ?? null;
325
- if (previousPage == null) {
326
- state.status = "No newer messages available.";
327
- return;
328
- }
329
-
330
- state.currentPage = previousPage;
331
- state.messages = previousPage.items;
332
- state.selectedMessageIndex = Math.max(0, state.messages.length - 1);
333
- state.status = "Loaded newer messages.";
334
- }
335
-
336
- async function openThread() {
337
- const selectedMessage = getSelectedMessage(state);
338
- if (selectedMessage == null) {
339
- state.status = "Select a message first.";
340
- return;
341
- }
342
-
343
- if (threadUnsubscribe != null) {
344
- threadUnsubscribe();
345
- threadUnsubscribe = null;
346
- }
347
-
348
- const threadRootId = selectedMessage.thread_root_id ?? selectedMessage.id;
349
- const threadRootMessage =
350
- selectedMessage.thread_root_id == null
351
- ? selectedMessage
352
- : await params.client.getMessage(threadRootId);
353
-
354
- if (threadRootMessage == null) {
355
- state.status = "Thread root not found.";
356
- return;
357
- }
358
-
359
- const replies = await params.client.getThreadReplies(threadRootId);
360
- state.threadRootMessage = threadRootMessage;
361
- state.threadReplies = replies;
362
- state.selectedThreadIndex = Math.max(0, replies.length - 1);
363
- state.focus = "thread";
364
- state.status = `Opened thread for ${threadRootId} (live)`;
365
-
366
- threadUnsubscribe = params.client.subscribeToThreadReplies({
367
- threadRootId,
368
- onUpdate: (updatedReplies) => {
369
- if (
370
- isPromptActive ||
371
- isCleanedUp ||
372
- state.threadRootMessage?.id !== threadRootId
373
- ) {
374
- return;
375
- }
376
-
377
- const hadReplies = state.threadReplies.length > 0;
378
- const lastReplyId = state.threadReplies[state.threadReplies.length - 1]?.id;
379
-
380
- state.threadReplies = updatedReplies;
381
-
382
- const newLastReplyId = state.threadReplies[state.threadReplies.length - 1]?.id;
383
- if (hadReplies && newLastReplyId !== lastReplyId) {
384
- state.selectedThreadIndex = Math.max(0, state.threadReplies.length - 1);
385
- }
386
-
387
- render();
388
- },
389
- });
390
- }
391
-
392
- async function submitMessage() {
393
- if (state.currentConversationId == null) {
394
- state.status = "Open a conversation first.";
395
- return;
396
- }
397
-
398
- const body = await withPrompt("Post message: ");
399
- if (body === "") {
400
- state.status = "Message cancelled.";
401
- return;
402
- }
403
-
404
- await params.client.postMessage({
405
- conversationId: state.currentConversationId,
406
- markdownContent: body,
407
- });
408
- state.status = "Message posted (updating live)...";
409
- }
410
-
411
- async function submitReply() {
412
- const selectedMessage =
413
- state.focus === "thread"
414
- ? getSelectedThreadMessage(state) ?? state.threadRootMessage
415
- : getSelectedMessage(state);
416
-
417
- if (selectedMessage == null) {
418
- state.status = "Select a message first.";
419
- return;
420
- }
421
-
422
- const threadRootId = selectedMessage.thread_root_id ?? selectedMessage.id;
423
- const body = await withPrompt("Post thread reply: ");
424
- if (body === "") {
425
- state.status = "Reply cancelled.";
426
- return;
427
- }
428
-
429
- await params.client.postMessage({
430
- conversationId: selectedMessage.conversation_id,
431
- markdownContent: body,
432
- threadRootId,
433
- });
434
-
435
- state.status = "Reply posted (updating live)...";
436
- }
437
-
438
- async function addReaction() {
439
- const selectedMessage =
440
- state.focus === "thread"
441
- ? getSelectedThreadMessage(state) ?? state.threadRootMessage
442
- : getSelectedMessage(state);
443
-
444
- if (selectedMessage == null) {
445
- state.status = "Select a message first.";
446
- return;
447
- }
448
-
449
- const emoji = await withPrompt("Add reaction (emoji): ", {
450
- enableEmojiSuggestions: true,
451
- enableNumberSelect: true,
452
- });
453
- if (emoji === "") {
454
- state.status = "Reaction cancelled.";
455
- return;
456
- }
457
-
458
- await params.client.addReaction(selectedMessage.id, emoji);
459
- state.status = `Added reaction ${emoji} (updating live)...`;
460
- }
461
-
462
- async function updateSearch() {
463
- const nextSearchTerm = await withPrompt("Filter channels or DMs: ");
464
- state.searchTerm = nextSearchTerm;
465
- state.selectedMembershipIndex = 0;
466
- state.status =
467
- nextSearchTerm === ""
468
- ? "Search cleared."
469
- : `Filtering conversations by "${nextSearchTerm}"`;
470
- }
471
-
472
- function render() {
473
- const width = process.stdout.columns ?? 120;
474
- const height = process.stdout.rows ?? 40;
475
- const memberships = getFilteredMemberships(state);
476
- const navWidth = getSidebarWidth({
477
- memberships,
478
- filter: state.filter,
479
- totalWidth: width,
480
- });
481
- const threadOpen = hasOpenThread(state);
482
- const leftPane: PaneKey = threadOpen ? "messages" : "nav";
483
- const rightPane: PaneKey = threadOpen ? "thread" : "messages";
484
- const leftWidth = threadOpen ? Math.max(30, Math.floor((width - 3) / 2)) : navWidth;
485
- const rightWidth = Math.max(30, width - leftWidth - 3);
486
- const conversation = getSelectedMembership(state)?.conversation ?? null;
487
-
488
- const outputLines: string[] = [];
489
- outputLines.push(
490
- "[c] Channels [d] DMs [/] Search [Tab] Focus [Enter] Open [t] Thread"
491
- );
492
- outputLines.push(
493
- "[u] Older [n] Newer [p] Post [r] Reply [a] React [b] Back [q] Quit"
494
- );
495
- outputLines.push("=".repeat(Math.max(20, width - 1)));
496
-
497
- const bodyRows = Math.max(8, height - 10);
498
- const navWindowStart = getVisibleWindowStart(
499
- memberships.length,
500
- state.selectedMembershipIndex,
501
- bodyRows
502
- );
503
- const navHasAbove = navWindowStart > 0;
504
- const navHasBelow = navWindowStart + bodyRows < memberships.length;
505
-
506
- const paneTitle = (pane: PaneKey) => {
507
- if (pane === "nav") {
508
- return state.filter === "channels" ? "Channels" : "DMs";
509
- }
510
-
511
- return pane === "thread" ? "Thread" : "Messages";
512
- };
513
-
514
- const renderPane = (pane: PaneKey, paneWidth: number) => {
515
- if (pane === "nav") {
516
- return {
517
- kind: "nav" as const,
518
- title: paneTitle(pane),
519
- width: paneWidth,
520
- hasAbove: navHasAbove,
521
- hasBelow: navHasBelow,
522
- lines: Array.from({ length: bodyRows }, (_, row) => {
523
- const membership = memberships[navWindowStart + row];
524
- if (membership == null) {
525
- return "";
526
- }
527
-
528
- return `${getSelectedItemPrefix({
529
- isFocusedPane: state.focus === "nav",
530
- isSelected: navWindowStart + row === state.selectedMembershipIndex,
531
- })} ${truncate(getConversationLabel(membership), paneWidth - 3)}`;
532
- }),
533
- };
534
- }
535
-
536
- const items = pane === "thread" ? state.threadReplies : state.messages;
537
- const selectedIndex =
538
- pane === "thread"
539
- ? clamp(state.selectedThreadIndex, 0, Math.max(0, items.length - 1))
540
- : clamp(state.selectedMessageIndex, 0, Math.max(0, items.length - 1));
541
- const transcript = renderTranscriptLines({
542
- items,
543
- selectedIndex,
544
- width: paneWidth,
545
- bodyRows,
546
- isFocusedPane: state.focus === pane,
547
- currentMemberId: state.currentMemberId,
548
- });
549
-
550
- return {
551
- kind: "transcript" as const,
552
- title: paneTitle(pane),
553
- width: paneWidth,
554
- hasAbove: transcript.hasAbove,
555
- hasBelow: transcript.hasBelow,
556
- lines: transcript.lines,
557
- emptyLabel:
558
- pane === "thread"
559
- ? `Thread: ${state.threadRootMessage?.id ?? "No thread open"}`
560
- : `Messages: ${conversation?.name ?? state.currentConversationId ?? ""}`,
561
- };
562
- };
563
-
564
- const renderedLeftPane = renderPane(leftPane, leftWidth);
565
- const renderedRightPane = renderPane(rightPane, rightWidth);
566
-
567
- outputLines.push(
568
- `${truncate(
569
- ` ${renderedLeftPane.title} ${renderedLeftPane.hasAbove ? "^" : " "} ${renderedLeftPane.hasBelow ? "v" : " "}`.padEnd(
570
- renderedLeftPane.width
571
- ),
572
- renderedLeftPane.width
573
- )} | ${truncate(
574
- ` ${renderedRightPane.title} ${renderedRightPane.hasAbove ? "^" : " "} ${
575
- renderedRightPane.hasBelow ? "v" : " "
576
- }`,
577
- renderedRightPane.width
578
- )}`
579
- );
580
-
581
- for (let row = 0; row < bodyRows; row += 1) {
582
- const leftLine =
583
- renderedLeftPane.lines[row] ??
584
- (renderedLeftPane.kind === "transcript" && row === 0
585
- ? truncate(renderedLeftPane.emptyLabel, renderedLeftPane.width)
586
- : "");
587
- const rightLine =
588
- renderedRightPane.lines[row] ??
589
- (renderedRightPane.kind === "transcript" && row === 0
590
- ? truncate(renderedRightPane.emptyLabel, renderedRightPane.width)
591
- : "");
592
-
593
- outputLines.push(
594
- `${truncate(padVisibleEnd(leftLine, renderedLeftPane.width), renderedLeftPane.width)} | ${truncate(
595
- rightLine,
596
- renderedRightPane.width
597
- )}`
598
- );
599
- }
600
-
601
- if (state.error != null) {
602
- outputLines.push(`Error: ${state.error}`);
603
- }
604
-
605
- clearTerminal();
606
- process.stdout.write(`${outputLines.join("\n")}\n`);
607
- }
608
-
609
- async function handleKeypress(keyName: string | undefined, sequence: string) {
610
- if (isPromptActive || isLoading) {
611
- return;
612
- }
613
-
614
- const memberships = getFilteredMemberships(state);
615
-
616
- if (keyName === "q" || (keyName === "c" && sequence === "\u0003")) {
617
- cleanup();
618
- clearTerminal();
619
- process.exit(0);
620
- }
621
-
622
- isLoading = true;
623
- try {
624
- switch (keyName) {
625
- case "c":
626
- state.filter = "channels";
627
- state.selectedMembershipIndex = 0;
628
- state.focus = "nav";
629
- state.status = "Showing channels.";
630
- break;
631
- case "d":
632
- state.filter = "dms";
633
- state.selectedMembershipIndex = 0;
634
- state.focus = "nav";
635
- state.status = "Showing DMs.";
636
- break;
637
- case "tab":
638
- state.focus = cycleVisibleFocus(state);
639
- break;
640
- case "left":
641
- if (state.focus === "thread" && hasOpenThread(state)) {
642
- if (threadUnsubscribe != null) {
643
- threadUnsubscribe();
644
- threadUnsubscribe = null;
645
- }
646
- state.focus = "messages";
647
- state.threadReplies = [];
648
- state.threadRootMessage = null;
649
- state.selectedThreadIndex = 0;
650
- state.status = "Back to message list.";
651
- break;
652
- }
653
-
654
- state.focus = getNextVisibleFocus(state, "left");
655
- break;
656
- case "right":
657
- if (state.focus === "messages") {
658
- await openThread();
659
- break;
660
- }
661
-
662
- if (state.focus === "nav") {
663
- syncMembershipSelectionToCurrentConversation(state);
664
- }
665
-
666
- state.focus = getNextVisibleFocus(state, "right");
667
- break;
668
- case "up":
669
- if (state.focus === "nav") {
670
- state.selectedMembershipIndex = clamp(
671
- state.selectedMembershipIndex - 1,
672
- 0,
673
- Math.max(0, memberships.length - 1)
674
- );
675
- } else if (state.focus === "messages") {
676
- state.selectedMessageIndex = clamp(
677
- state.selectedMessageIndex - 1,
678
- 0,
679
- Math.max(0, state.messages.length - 1)
680
- );
681
- } else {
682
- state.selectedThreadIndex = clamp(
683
- state.selectedThreadIndex - 1,
684
- 0,
685
- Math.max(0, state.threadReplies.length - 1)
686
- );
687
- }
688
- break;
689
- case "down":
690
- if (state.focus === "nav") {
691
- state.selectedMembershipIndex = clamp(
692
- state.selectedMembershipIndex + 1,
693
- 0,
694
- Math.max(0, memberships.length - 1)
695
- );
696
- } else if (state.focus === "messages") {
697
- state.selectedMessageIndex = clamp(
698
- state.selectedMessageIndex + 1,
699
- 0,
700
- Math.max(0, state.messages.length - 1)
701
- );
702
- } else {
703
- state.selectedThreadIndex = clamp(
704
- state.selectedThreadIndex + 1,
705
- 0,
706
- Math.max(0, state.threadReplies.length - 1)
707
- );
708
- }
709
- break;
710
- case "return":
711
- if (state.focus === "nav") {
712
- const selectedMembership = getSelectedMembership(state);
713
- if (selectedMembership != null) {
714
- await openConversation(selectedMembership.conversation.id);
715
- }
716
- } else if (state.focus === "messages") {
717
- await openThread();
718
- }
719
- break;
720
- case "t":
721
- await openThread();
722
- break;
723
- case "u":
724
- await loadOlderMessages();
725
- break;
726
- case "n":
727
- await loadNewerMessages();
728
- break;
729
- case "/":
730
- await updateSearch();
731
- break;
732
- case "p":
733
- await submitMessage();
734
- break;
735
- case "r":
736
- await submitReply();
737
- break;
738
- case "a":
739
- await addReaction();
740
- break;
741
- case "b":
742
- if (threadUnsubscribe != null) {
743
- threadUnsubscribe();
744
- threadUnsubscribe = null;
745
- }
746
- state.focus = "messages";
747
- state.threadReplies = [];
748
- state.threadRootMessage = null;
749
- state.selectedThreadIndex = 0;
750
- state.status = "Back to message list.";
751
- break;
752
- default:
753
- break;
754
- }
755
- state.error = null;
756
- } catch (error) {
757
- state.error = error instanceof Error ? error.message : String(error);
758
- } finally {
759
- isLoading = false;
760
- }
761
-
762
- render();
763
- }
764
-
765
- function cleanup() {
766
- if (isCleanedUp) {
767
- return;
768
- }
769
-
770
- isCleanedUp = true;
771
-
772
- if (conversationUnsubscribe != null) {
773
- conversationUnsubscribe();
774
- conversationUnsubscribe = null;
775
- }
776
-
777
- if (threadUnsubscribe != null) {
778
- threadUnsubscribe();
779
- threadUnsubscribe = null;
780
- }
781
-
782
- process.stdin.off("keypress", handleKeypressListener);
783
- process.stdout.off("resize", handleResize);
784
- process.off("SIGINT", handleSigint);
785
- if (process.stdin.isTTY) {
786
- process.stdin.setRawMode(false);
787
- }
788
-
789
- void params.client.close();
790
- }
791
-
792
- const handleResize = () => {
793
- if (isPromptActive || isCleanedUp) {
794
- return;
795
- }
796
-
797
- render();
798
- };
799
-
800
- const handleSigint = () => {
801
- cleanup();
802
- clearTerminal();
803
- process.exit(0);
804
- };
805
-
806
- const handleKeypressListener = async (sequence: string, key: readline.Key) => {
807
- await handleKeypress(key?.name, sequence);
808
- };
809
-
810
- async function run() {
811
- readline.emitKeypressEvents(process.stdin);
812
- if (process.stdin.isTTY) {
813
- process.stdin.setRawMode(true);
814
- }
815
-
816
- process.on("SIGINT", handleSigint);
817
- process.stdout.on("resize", handleResize);
818
-
819
- try {
820
- await loadMemberships();
821
- render();
822
- process.stdin.on("keypress", handleKeypressListener);
823
- } catch (error) {
824
- cleanup();
825
- throw error;
826
- }
827
- }
828
-
829
- return {
830
- run,
831
- };
832
- }