@will-17173/telegram-cli 0.3.0 → 0.4.1

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.
Files changed (145) hide show
  1. package/README.md +73 -335
  2. package/README.zh-CN.md +72 -333
  3. package/dist/account/account-authenticator.js +95 -0
  4. package/dist/account/account-authenticator.js.map +1 -0
  5. package/dist/account/account-context.js +7 -0
  6. package/dist/account/account-context.js.map +1 -1
  7. package/dist/account/account-name.js +17 -0
  8. package/dist/account/account-name.js.map +1 -0
  9. package/dist/account/account-presets.js +20 -3
  10. package/dist/account/account-presets.js.map +1 -1
  11. package/dist/account/account-store.js +277 -37
  12. package/dist/account/account-store.js.map +1 -1
  13. package/dist/cli/app.js +12 -1
  14. package/dist/cli/app.js.map +1 -1
  15. package/dist/cli/output.js +21 -6
  16. package/dist/cli/output.js.map +1 -1
  17. package/dist/cli/secure-input.js +408 -0
  18. package/dist/cli/secure-input.js.map +1 -0
  19. package/dist/commands/account-options.js +37 -7
  20. package/dist/commands/account-options.js.map +1 -1
  21. package/dist/commands/account.js +144 -62
  22. package/dist/commands/account.js.map +1 -1
  23. package/dist/commands/archive.js +151 -0
  24. package/dist/commands/archive.js.map +1 -0
  25. package/dist/commands/config.js +119 -12
  26. package/dist/commands/config.js.map +1 -1
  27. package/dist/commands/contact.js +58 -0
  28. package/dist/commands/contact.js.map +1 -0
  29. package/dist/commands/data.js +14 -7
  30. package/dist/commands/data.js.map +1 -1
  31. package/dist/commands/dialog.js +156 -0
  32. package/dist/commands/dialog.js.map +1 -0
  33. package/dist/commands/folder.js +113 -0
  34. package/dist/commands/folder.js.map +1 -0
  35. package/dist/commands/group-write.js +45 -5
  36. package/dist/commands/group-write.js.map +1 -1
  37. package/dist/commands/group.js +23 -1
  38. package/dist/commands/group.js.map +1 -1
  39. package/dist/commands/notification.js +76 -0
  40. package/dist/commands/notification.js.map +1 -0
  41. package/dist/commands/query.js +22 -15
  42. package/dist/commands/query.js.map +1 -1
  43. package/dist/commands/telegram-runner.js +40 -18
  44. package/dist/commands/telegram-runner.js.map +1 -1
  45. package/dist/commands/telegram.js +82 -38
  46. package/dist/commands/telegram.js.map +1 -1
  47. package/dist/commands/time-range.js +39 -0
  48. package/dist/commands/time-range.js.map +1 -0
  49. package/dist/commands/types.js +3 -2
  50. package/dist/commands/types.js.map +1 -1
  51. package/dist/config/credential-store.js +24 -4
  52. package/dist/config/credential-store.js.map +1 -1
  53. package/dist/config/env.js +4 -1
  54. package/dist/config/env.js.map +1 -1
  55. package/dist/group-commands/catalog.js +10 -0
  56. package/dist/group-commands/catalog.js.map +1 -1
  57. package/dist/group-commands/executor.js +13 -4
  58. package/dist/group-commands/executor.js.map +1 -1
  59. package/dist/presenters/group.js +16 -0
  60. package/dist/presenters/group.js.map +1 -1
  61. package/dist/presenters/human.js +113 -0
  62. package/dist/presenters/human.js.map +1 -1
  63. package/dist/presenters/ink/group-command-result.js +2 -0
  64. package/dist/presenters/ink/group-command-result.js.map +1 -1
  65. package/dist/presenters/ink/listen-scroll.js +4 -3
  66. package/dist/presenters/ink/listen-scroll.js.map +1 -1
  67. package/dist/presenters/ink/listen.js +245 -57
  68. package/dist/presenters/ink/listen.js.map +1 -1
  69. package/dist/presenters/ink/mouse-scroll.js +12 -0
  70. package/dist/presenters/ink/mouse-scroll.js.map +1 -1
  71. package/dist/presenters/ink/secure-input.js +68 -0
  72. package/dist/presenters/ink/secure-input.js.map +1 -0
  73. package/dist/presenters/ink/use-group-command.js +78 -17
  74. package/dist/presenters/ink/use-group-command.js.map +1 -1
  75. package/dist/presenters/markdown.js +54 -0
  76. package/dist/presenters/markdown.js.map +1 -0
  77. package/dist/presenters/structured.js +8 -3
  78. package/dist/presenters/structured.js.map +1 -1
  79. package/dist/services/account-session-service.js +241 -0
  80. package/dist/services/account-session-service.js.map +1 -0
  81. package/dist/services/archive-layout.js +61 -0
  82. package/dist/services/archive-layout.js.map +1 -0
  83. package/dist/services/archive-manifest.js +187 -0
  84. package/dist/services/archive-manifest.js.map +1 -0
  85. package/dist/services/archive-markdown.js +383 -0
  86. package/dist/services/archive-markdown.js.map +1 -0
  87. package/dist/services/archive-service.js +718 -0
  88. package/dist/services/archive-service.js.map +1 -0
  89. package/dist/services/archive-types.js +2 -0
  90. package/dist/services/archive-types.js.map +1 -0
  91. package/dist/services/attachment-download.js +7 -2
  92. package/dist/services/attachment-download.js.map +1 -1
  93. package/dist/services/auto-download-coordinator.js +2 -4
  94. package/dist/services/auto-download-coordinator.js.map +1 -1
  95. package/dist/services/contact-service.js +78 -0
  96. package/dist/services/contact-service.js.map +1 -0
  97. package/dist/services/dialog-service.js +140 -0
  98. package/dist/services/dialog-service.js.map +1 -0
  99. package/dist/services/folder-service.js +200 -0
  100. package/dist/services/folder-service.js.map +1 -0
  101. package/dist/services/group-write-service.js +78 -18
  102. package/dist/services/group-write-service.js.map +1 -1
  103. package/dist/services/message-service.js +17 -1
  104. package/dist/services/message-service.js.map +1 -1
  105. package/dist/services/notification-service.js +142 -0
  106. package/dist/services/notification-service.js.map +1 -0
  107. package/dist/services/write-access-policy.js +19 -0
  108. package/dist/services/write-access-policy.js.map +1 -0
  109. package/dist/telegram/archive-types.js +2 -0
  110. package/dist/telegram/archive-types.js.map +1 -0
  111. package/dist/telegram/client-factory.js +51 -11
  112. package/dist/telegram/client-factory.js.map +1 -1
  113. package/dist/telegram/contact-types.js +10 -0
  114. package/dist/telegram/contact-types.js.map +1 -0
  115. package/dist/telegram/dialog-types.js +2 -0
  116. package/dist/telegram/dialog-types.js.map +1 -0
  117. package/dist/telegram/errors.js +14 -0
  118. package/dist/telegram/errors.js.map +1 -0
  119. package/dist/telegram/fake-group-management.js +5 -0
  120. package/dist/telegram/fake-group-management.js.map +1 -1
  121. package/dist/telegram/folder-types.js +13 -0
  122. package/dist/telegram/folder-types.js.map +1 -0
  123. package/dist/telegram/group-write-types.js +28 -1
  124. package/dist/telegram/group-write-types.js.map +1 -1
  125. package/dist/telegram/mtcute-archive.js +190 -0
  126. package/dist/telegram/mtcute-archive.js.map +1 -0
  127. package/dist/telegram/mtcute-client.js +72 -11
  128. package/dist/telegram/mtcute-client.js.map +1 -1
  129. package/dist/telegram/mtcute-contacts.js +90 -0
  130. package/dist/telegram/mtcute-contacts.js.map +1 -0
  131. package/dist/telegram/mtcute-dialogs.js +186 -0
  132. package/dist/telegram/mtcute-dialogs.js.map +1 -0
  133. package/dist/telegram/mtcute-folders.js +318 -0
  134. package/dist/telegram/mtcute-folders.js.map +1 -0
  135. package/dist/telegram/mtcute-group-members.js +43 -3
  136. package/dist/telegram/mtcute-group-members.js.map +1 -1
  137. package/dist/telegram/mtcute-message-normalizer.js +63 -0
  138. package/dist/telegram/mtcute-message-normalizer.js.map +1 -0
  139. package/dist/telegram/mtcute-notifications.js +101 -0
  140. package/dist/telegram/mtcute-notifications.js.map +1 -0
  141. package/dist/telegram/notification-types.js +11 -0
  142. package/dist/telegram/notification-types.js.map +1 -0
  143. package/dist/telegram/types.js +6 -1
  144. package/dist/telegram/types.js.map +1 -1
  145. package/package.json +2 -2
@@ -5,15 +5,15 @@ import stringWidth from 'string-width';
5
5
  import { existsSync, mkdirSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { dirname } from 'node:path';
8
- import { resolveAttachmentDestination } from '../../services/attachment-download.js';
8
+ import { attachmentDownloadProgress, resolveAttachmentDestination, } from '../../services/attachment-download.js';
9
9
  import { ListenAlbumAggregator } from '../../services/listen-album-aggregator.js';
10
10
  import { AutoDownloadCoordinator } from '../../services/auto-download-coordinator.js';
11
11
  import { attachmentDownloadTarget, attachmentFileName, discoverListenAttachments, listenAttachmentKey } from '../../services/listen-attachment.js';
12
12
  import { buildListenMessage } from '../listen-message.js';
13
- import { applyMessageArrival, takeListenViewport } from './listen-scroll.js';
13
+ import { applyMessageArrival, applyScroll, takeListenViewport } from './listen-scroll.js';
14
14
  import { decodeImagePreview } from './image-preview.js';
15
15
  import { ListenScrollbar, calculateScrollbar, listenContentWidth, useTransientScrollbar } from './listen-scrollbar.js';
16
- import { DISABLE_MOUSE_REPORTING, isMouseInput } from './mouse-scroll.js';
16
+ import { DISABLE_MOUSE_REPORTING, ENABLE_MOUSE_REPORTING, isMouseInput, useMouseScroll, withAlternateScroll, } from './mouse-scroll.js';
17
17
  import { createListenReplyResolver } from '../../services/listen-reply-resolver.js';
18
18
  import { formatReplyContext } from '../../services/reply-context.js';
19
19
  import { executeListenReply, parseListenComposerInput } from '../../services/listen-composer-command.js';
@@ -25,9 +25,12 @@ import { useGroupCommand } from './use-group-command.js';
25
25
  import { executeGroupCommand } from '../../group-commands/executor.js';
26
26
  import { GroupWriteService } from '../../services/group-write-service.js';
27
27
  import { ADMIN_RIGHT_KEYS } from '../../services/group-write-service.js';
28
+ import { WriteAccessPolicy } from '../../services/write-access-policy.js';
28
29
  import { GroupCommandConfirm } from './group-command-confirm.js';
29
30
  import { truncateCell } from './display-width.js';
31
+ import { SecureInput } from './secure-input.js';
30
32
  const MESSAGE_SEPARATOR = '────────────────────────────────────────────';
33
+ const OWNERSHIP_SHUTDOWN_GRACE_MS = 250;
31
34
  /** Maximum number of grouped messages retained by a long-running interactive listener. */
32
35
  export const LISTEN_HISTORY_LIMIT = 500;
33
36
  const LISTEN_IMAGE_PREVIEWS_ENABLED = false;
@@ -60,11 +63,14 @@ export const LISTEN_COMPOSER_THEME = {
60
63
  target: '#f0d38a',
61
64
  hint: '#9bdca8',
62
65
  };
63
- export function ListenComposer({ input, sendTargetLabel, terminalWidth, sending = false, hint = 'Enter to send · Ctrl+C to exit', }) {
66
+ export function listenComposerCursorColor(visible) {
67
+ return visible ? LISTEN_COMPOSER_THEME.cursor : LISTEN_COMPOSER_THEME.background;
68
+ }
69
+ export function ListenComposer({ input, sendTargetLabel, terminalWidth, sending = false, hint = 'Enter to send · Ctrl+C to exit', cursorVisible = true, }) {
64
70
  const inputText = `› ${input}${sending ? ' (sending...)' : ''}`;
65
71
  const blankRow = ' '.repeat(Math.max(0, terminalWidth));
66
72
  const inputRowFill = ' '.repeat(Math.max(0, terminalWidth - stringWidth(inputText) - 1));
67
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, children: blankRow }), _jsxs(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, color: LISTEN_COMPOSER_THEME.foreground, children: [inputText, _jsx(Text, { backgroundColor: LISTEN_COMPOSER_THEME.cursor, children: " " }), inputRowFill] }), _jsx(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, children: blankRow }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: LISTEN_COMPOSER_THEME.target, children: ["To: ", sendTargetLabel] }), _jsx(Text, { color: LISTEN_COMPOSER_THEME.hint, children: hint })] })] }));
73
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, children: blankRow }), _jsxs(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, color: LISTEN_COMPOSER_THEME.foreground, children: [inputText, _jsx(Text, { backgroundColor: listenComposerCursorColor(cursorVisible), children: " " }), inputRowFill] }), _jsx(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, children: blankRow }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: LISTEN_COMPOSER_THEME.target, children: ["To: ", sendTargetLabel] }), _jsx(Text, { color: LISTEN_COMPOSER_THEME.hint, children: hint })] })] }));
68
74
  }
69
75
  export function ListenAttachmentLine({ label, selected, state }) {
70
76
  const action = state.status === 'idle'
@@ -196,16 +202,23 @@ export async function runInteractiveAutoDownloadLifecycle(options) {
196
202
  : null;
197
203
  options.onCoordinator?.(coordinator);
198
204
  let currentClient = null;
205
+ let closeCurrentClient = null;
199
206
  const abort = () => {
200
207
  coordinator?.stop();
201
- void currentClient?.close().catch(() => undefined);
208
+ void closeCurrentClient?.();
202
209
  };
203
210
  options.signal.addEventListener('abort', abort);
204
211
  try {
205
212
  while (!options.signal.aborted) {
206
213
  options.onStatus?.('connecting');
207
214
  const client = options.createClient();
215
+ let closePromise;
216
+ const closeClient = () => {
217
+ closePromise ??= Promise.resolve().then(() => client.close()).catch(() => undefined);
218
+ return closePromise;
219
+ };
208
220
  currentClient = client;
221
+ closeCurrentClient = closeClient;
209
222
  options.onClient?.(client);
210
223
  coordinator?.setClient(client);
211
224
  let retry = false;
@@ -249,11 +262,13 @@ export async function runInteractiveAutoDownloadLifecycle(options) {
249
262
  else {
250
263
  coordinator?.stop();
251
264
  }
252
- await client.close().catch(() => undefined);
265
+ await closeClient();
253
266
  if (options.signal.aborted)
254
267
  await coordinator?.waitForActive();
255
268
  if (currentClient === client)
256
269
  currentClient = null;
270
+ if (closeCurrentClient === closeClient)
271
+ closeCurrentClient = null;
257
272
  options.onClient?.(null);
258
273
  }
259
274
  if (!retry)
@@ -376,8 +391,7 @@ export function calculateListenMessagePaneHeight(terminalHeight, hasNote, autoDo
376
391
  return Math.max(2, terminalHeight - reservedLines);
377
392
  }
378
393
  export async function runInteractiveListen(write, run) {
379
- write(DISABLE_MOUSE_REPORTING);
380
- return run();
394
+ return withAlternateScroll({ write, run });
381
395
  }
382
396
  export async function renderInteractiveListen(options) {
383
397
  await runInteractiveListen(process.stdout.write.bind(process.stdout), async () => {
@@ -385,7 +399,7 @@ export async function renderInteractiveListen(options) {
385
399
  await app.waitUntilExit();
386
400
  });
387
401
  }
388
- export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, createReplyResolver, }) {
402
+ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, createReplyResolver, shutdownRequests, }) {
389
403
  const { exit } = useApp();
390
404
  const { stdout } = useStdout();
391
405
  const terminalMetrics = useTerminalMetrics(stdout);
@@ -395,19 +409,23 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
395
409
  const [note, setNote] = useState('');
396
410
  const [sending, setSending] = useState(false);
397
411
  const [focus, setFocus] = useState('input');
398
- const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(0);
412
+ const [selectedAttachmentKey, setSelectedAttachmentKey] = useState(null);
399
413
  const [downloadStates, setDownloadStates] = useState({});
400
414
  const [scrollState, setScrollState] = useState({ offset: 0, unseenCount: 0 });
401
415
  const [sendTargetLabel, setSendTargetLabel] = useState(sendTo == null ? '' : buildSendTargetLabel(sendTo));
402
416
  const [knownGroup, setKnownGroup] = useState(undefined);
403
417
  const clientRef = useRef(null);
404
418
  const replyExecutionLockRef = useRef(false);
405
- const inputGenerationRef = useRef(0);
419
+ const inputGenerationRef = useRef({});
406
420
  const commandSelectionRef = useRef(0);
407
421
  const knownGroupRef = useRef(undefined);
408
- const groupLookupGenerationRef = useRef(0);
422
+ const groupLookupGenerationRef = useRef({});
423
+ const selectedAttachmentKeyRef = useRef(null);
424
+ const scrollOffsetRef = useRef(0);
425
+ selectedAttachmentKeyRef.current = selectedAttachmentKey;
426
+ scrollOffsetRef.current = scrollState.offset;
409
427
  useEffect(() => {
410
- groupLookupGenerationRef.current++;
428
+ groupLookupGenerationRef.current = {};
411
429
  knownGroupRef.current = undefined;
412
430
  setKnownGroup(undefined);
413
431
  }, [sendTo]);
@@ -418,19 +436,25 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
418
436
  if (sendTo == null)
419
437
  return { ok: false, error: { code: 'ambiguous_chat', message: 'Select exactly one target chat with --send-to.' } };
420
438
  let knownGroup = knownGroupRef.current;
421
- if (knownGroup == null) {
422
- const lookup = ++groupLookupGenerationRef.current;
439
+ const refreshOwnershipCapability = request.key === 'admin transfer-owner'
440
+ && options?.confirmed === true
441
+ && options.ownershipPassword == null;
442
+ if (knownGroup == null || refreshOwnershipCapability) {
443
+ const lookup = {};
444
+ groupLookupGenerationRef.current = lookup;
423
445
  knownGroup = await client.groups.getGroup(sendTo);
424
- if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
425
- knownGroupRef.current = knownGroup;
426
- setKnownGroup(knownGroup);
446
+ if (lookup !== groupLookupGenerationRef.current || clientRef.current !== client) {
447
+ return { ok: false, error: { code: 'connection_not_ready', message: 'Telegram connection changed during group verification.' } };
427
448
  }
449
+ knownGroupRef.current = knownGroup;
450
+ setKnownGroup(knownGroup);
428
451
  }
429
452
  return executeGroupCommand(request, {
430
453
  chat: sendTo,
431
454
  groups: new GroupWriteService(client.groups),
432
455
  confirmed: options?.confirmed ?? false,
433
456
  confirmationTitle: options?.confirmationTitle,
457
+ ownershipPassword: options?.ownershipPassword,
434
458
  knownGroup,
435
459
  connectionReady: true,
436
460
  targetAvailable: true,
@@ -440,7 +464,8 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
440
464
  setKnownGroup(undefined);
441
465
  if (request.key === 'chat delete' || request.key === 'chat leave')
442
466
  return;
443
- const lookup = ++groupLookupGenerationRef.current;
467
+ const lookup = {};
468
+ groupLookupGenerationRef.current = lookup;
444
469
  try {
445
470
  const refreshed = await client.groups.getGroup(sendTo);
446
471
  if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
@@ -455,6 +480,8 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
455
480
  },
456
481
  });
457
482
  }, [sendTo]));
483
+ const irreversibleGroupWriteRef = useRef(false);
484
+ irreversibleGroupWriteRef.current = groupCommand.state.kind === 'executing' && groupCommand.state.irreversible === true;
458
485
  const terminalWidth = terminalMetrics.columns;
459
486
  const terminalHeight = terminalMetrics.rows;
460
487
  const previewColorDepth = interactiveListenPreviewColorDepth(terminalMetrics.colorDepth);
@@ -479,22 +506,83 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
479
506
  if (operationControllerRef.current == null)
480
507
  operationControllerRef.current = createInteractiveOperationController();
481
508
  const stoppingRef = useRef(false);
509
+ const lifecycleStopRef = useRef(null);
510
+ const deferredStopRef = useRef(false);
511
+ const shutdownRequestedRef = useRef(false);
512
+ const shutdownGraceTimerRef = useRef(null);
513
+ function requestOwnershipShutdown(source) {
514
+ if (deferredStopRef.current || shutdownRequestedRef.current) {
515
+ deferredStopRef.current = false;
516
+ shutdownRequestedRef.current = false;
517
+ if (shutdownGraceTimerRef.current)
518
+ clearTimeout(shutdownGraceTimerRef.current);
519
+ shutdownGraceTimerRef.current = null;
520
+ groupCommand.setState({ kind: 'error', message: 'Ownership transfer outcome is indeterminate after forced shutdown.' });
521
+ setTimeout(stopListening, 0);
522
+ return;
523
+ }
524
+ if (source === 'terminal') {
525
+ deferredStopRef.current = true;
526
+ setNote('Group write is already running; wait for its outcome.');
527
+ return;
528
+ }
529
+ shutdownRequestedRef.current = true;
530
+ setNote('Shutdown requested; waiting briefly for the ownership transfer outcome.');
531
+ shutdownGraceTimerRef.current = setTimeout(() => {
532
+ shutdownGraceTimerRef.current = null;
533
+ groupCommand.setState({
534
+ kind: 'error',
535
+ message: 'Ownership transfer outcome is indeterminate because shutdown grace expired.',
536
+ });
537
+ setTimeout(stopListening, 0);
538
+ }, OWNERSHIP_SHUTDOWN_GRACE_MS);
539
+ }
482
540
  const seenRef = useRef(new Set());
483
541
  const seenOrderRef = useRef([]);
484
- const downloadableAttachments = collectDownloadableAttachments(visibleMessages);
485
- const selectedAttachment = downloadableAttachments[selectedAttachmentIndex] ?? downloadableAttachments[0];
486
- const { visible: scrollbarVisible } = useTransientScrollbar();
542
+ const downloadableAttachments = collectDownloadableAttachments(messages);
543
+ const visibleDownloadableAttachments = collectDownloadableAttachments(visibleMessages);
544
+ const selectedAttachment = downloadableAttachments.find((item) => item.key === selectedAttachmentKey);
545
+ const selectAttachment = (key) => {
546
+ selectedAttachmentKeyRef.current = key;
547
+ setSelectedAttachmentKey(key);
548
+ };
549
+ const { visible: scrollbarVisible, show: showScrollbar } = useTransientScrollbar();
487
550
  const scrollbarGeometry = calculateScrollbar({
488
551
  height: terminalHeight,
489
552
  total: messages.length,
490
553
  visible: visibleMessages.length,
491
554
  offset: scrollState.offset,
492
555
  });
556
+ const lastScrollDirectionRef = useRef('up');
557
+ const scrollMessages = useCallback((direction, amount = 1) => {
558
+ lastScrollDirectionRef.current = direction;
559
+ const step = Math.max(1, Math.floor(amount));
560
+ const maxOffset = Math.max(0, messages.length - 1);
561
+ scrollOffsetRef.current = direction === 'up'
562
+ ? Math.min(maxOffset, scrollOffsetRef.current + step)
563
+ : Math.max(0, scrollOffsetRef.current - step);
564
+ showScrollbar();
565
+ setScrollState((current) => applyScroll(current, direction, maxOffset, amount));
566
+ }, [messages.length, showScrollbar]);
567
+ const handleMouseScroll = useCallback((direction) => {
568
+ if (focus === 'attachments')
569
+ scrollMessages(direction);
570
+ }, [focus, scrollMessages]);
571
+ useMouseScroll(handleMouseScroll);
572
+ useEffect(() => {
573
+ if (focus !== 'attachments')
574
+ return;
575
+ stdout.write(ENABLE_MOUSE_REPORTING);
576
+ return () => {
577
+ stdout.write(DISABLE_MOUSE_REPORTING);
578
+ };
579
+ }, [focus, stdout]);
493
580
  useEffect(() => {
494
581
  const maxOffset = Math.max(0, messages.length - 1);
495
582
  setScrollState((current) => {
496
583
  const offset = Math.min(current.offset, maxOffset);
497
584
  const unseenCount = offset === 0 ? 0 : Math.min(current.unseenCount, messages.length);
585
+ scrollOffsetRef.current = offset;
498
586
  return offset === current.offset && unseenCount === current.unseenCount
499
587
  ? current
500
588
  : { offset, unseenCount };
@@ -505,18 +593,46 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
505
593
  setDownloadStates((current) => pruneAttachmentDownloadStates(current, validAttachmentKeys, pendingAttachmentKeysRef.current));
506
594
  for (const key of validAttachmentKeys)
507
595
  pendingAttachmentKeysRef.current.delete(key);
508
- setSelectedAttachmentIndex((current) => Math.min(current, Math.max(0, downloadableAttachments.length - 1)));
596
+ const currentSelectedKey = selectedAttachmentKeyRef.current;
597
+ if (currentSelectedKey != null && !validAttachmentKeys.has(currentSelectedKey)) {
598
+ selectAttachment(downloadableAttachments[0]?.key ?? null);
599
+ }
509
600
  if (downloadableAttachments.length === 0)
510
601
  setFocus('input');
511
602
  }, [messages, downloadableAttachments.length]);
603
+ useEffect(() => {
604
+ if (focus !== 'attachments' || visibleDownloadableAttachments.length === 0)
605
+ return;
606
+ if (visibleDownloadableAttachments.some((item) => item.key === selectedAttachment?.key))
607
+ return;
608
+ const fallback = lastScrollDirectionRef.current === 'up'
609
+ ? visibleDownloadableAttachments.at(-1)
610
+ : visibleDownloadableAttachments[0];
611
+ const index = fallback == null
612
+ ? -1
613
+ : downloadableAttachments.findIndex((item) => item.key === fallback.key);
614
+ if (index >= 0)
615
+ selectAttachment(downloadableAttachments[index].key);
616
+ }, [focus, messagePaneHeight, messages, scrollState.offset, selectedAttachmentKey]);
512
617
  useInput((inputText, key) => {
513
618
  if (isMouseInput(inputText))
514
619
  return;
620
+ const modal = groupCommand.state;
621
+ if (modal.kind === 'executing' && modal.irreversible === true) {
622
+ if (key.ctrl && (inputText === 'c' || inputText === 'C' || inputText === '\u0003')) {
623
+ requestOwnershipShutdown('terminal');
624
+ }
625
+ else if (key.escape) {
626
+ setNote('Group write is already running; wait for its outcome.');
627
+ }
628
+ return;
629
+ }
515
630
  if (key.ctrl && (inputText === 'c' || inputText === 'C' || inputText === '\u0003')) {
516
631
  stopListening();
517
632
  return;
518
633
  }
519
- const modal = groupCommand.state;
634
+ if (modal.kind === 'password')
635
+ return;
520
636
  if (modal.kind === 'confirm') {
521
637
  if (key.escape) {
522
638
  closeGroupCommand();
@@ -556,7 +672,8 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
556
672
  }
557
673
  else {
558
674
  const submittedTitle = modal.confirmText;
559
- const lookup = ++groupLookupGenerationRef.current;
675
+ const lookup = {};
676
+ groupLookupGenerationRef.current = lookup;
560
677
  void client.groups.getGroup(sendTo).then((fresh) => {
561
678
  if (lookup !== groupLookupGenerationRef.current || clientRef.current !== client)
562
679
  return;
@@ -618,12 +735,17 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
618
735
  closeGroupCommand();
619
736
  return;
620
737
  }
738
+ if (replyExecutionLockRef.current) {
739
+ if (key.escape)
740
+ setNote('Reply is still sending; wait for its outcome.');
741
+ return;
742
+ }
743
+ if (key.pageUp || key.pageDown) {
744
+ scrollMessages(key.pageUp ? 'up' : 'down', Math.max(1, visibleMessages.length));
745
+ return;
746
+ }
621
747
  const slashMode = input.trimStart().startsWith('/');
622
748
  if (slashMode && key.escape) {
623
- if (replyExecutionLockRef.current) {
624
- inputGenerationRef.current++;
625
- setSending(false);
626
- }
627
749
  closeGroupCommand();
628
750
  return;
629
751
  }
@@ -642,7 +764,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
642
764
  setNote(failure.error.message);
643
765
  return;
644
766
  }
645
- inputGenerationRef.current++;
767
+ inputGenerationRef.current = {};
646
768
  setInput(completeListenCommand(input, selected));
647
769
  commandSelectionRef.current = 0;
648
770
  groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
@@ -653,9 +775,12 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
653
775
  if (focus === 'attachments') {
654
776
  setFocus('input');
655
777
  }
656
- else if (downloadableAttachments.length > 0) {
778
+ else if (visibleDownloadableAttachments.length > 0) {
779
+ const currentSelectedKey = selectedAttachmentKeyRef.current;
780
+ const nextSelection = visibleDownloadableAttachments.find((item) => item.key === currentSelectedKey)
781
+ ?? visibleDownloadableAttachments[0];
657
782
  setFocus('attachments');
658
- setSelectedAttachmentIndex((current) => Math.min(current, downloadableAttachments.length - 1));
783
+ selectAttachment(nextSelection.key);
659
784
  }
660
785
  else {
661
786
  setNote('no downloadable attachments');
@@ -668,20 +793,42 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
668
793
  return;
669
794
  }
670
795
  if (key.upArrow || key.downArrow) {
671
- const offset = key.upArrow ? -1 : 1;
672
- setSelectedAttachmentIndex((current) => {
673
- const count = downloadableAttachments.length;
674
- return count === 0 ? 0 : (current + offset + count) % count;
675
- });
796
+ const direction = key.upArrow ? 'up' : 'down';
797
+ const delta = direction === 'up' ? -1 : 1;
798
+ const currentIndex = downloadableAttachments.findIndex((item) => item.key === selectedAttachmentKeyRef.current);
799
+ const nextIndex = currentIndex + delta;
800
+ if (currentIndex < 0 || nextIndex < 0 || nextIndex >= downloadableAttachments.length)
801
+ return;
802
+ const nextAttachment = downloadableAttachments[nextIndex];
803
+ selectAttachment(nextAttachment?.key ?? null);
804
+ const effectiveVisibleMessages = takeListenViewport(messages, messagePaneHeight, scrollOffsetRef.current);
805
+ if (nextAttachment != null && !effectiveVisibleMessages.some((message) => message.key === nextAttachment.message.key)) {
806
+ const messageIndex = messages.findIndex((message) => message.key === nextAttachment.message.key);
807
+ if (messageIndex >= 0) {
808
+ const targetOffset = messages.length - 1 - messageIndex;
809
+ scrollOffsetRef.current = targetOffset;
810
+ showScrollbar();
811
+ setScrollState((current) => {
812
+ if (current.offset === targetOffset)
813
+ return current;
814
+ return applyScroll(current, targetOffset > current.offset ? 'up' : 'down', Math.max(0, messages.length - 1), Math.abs(targetOffset - current.offset));
815
+ });
816
+ }
817
+ }
676
818
  return;
677
819
  }
678
- if (key.return && selectedAttachment != null) {
679
- const state = downloadStates[selectedAttachment.key] ?? { status: 'idle' };
820
+ const currentSelectedAttachment = downloadableAttachments.find((item) => item.key === selectedAttachmentKeyRef.current);
821
+ if (key.return && currentSelectedAttachment != null) {
822
+ const state = downloadStates[currentSelectedAttachment.key] ?? { status: 'idle' };
680
823
  if (canManuallyDownload(state))
681
- void downloadAttachment(selectedAttachment);
824
+ void downloadAttachment(currentSelectedAttachment);
682
825
  }
683
826
  return;
684
827
  }
828
+ if (key.upArrow || key.downArrow) {
829
+ scrollMessages(key.upArrow ? 'up' : 'down');
830
+ return;
831
+ }
685
832
  if (sending || groupCommand.state.kind === 'executing')
686
833
  return;
687
834
  if (key.return) {
@@ -699,7 +846,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
699
846
  }
700
847
  const parsed = parseSelectedListenCommand(input, match);
701
848
  if (parsed.kind === 'complete') {
702
- inputGenerationRef.current++;
849
+ inputGenerationRef.current = {};
703
850
  setInput(parsed.input);
704
851
  commandSelectionRef.current = 0;
705
852
  groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
@@ -721,6 +868,11 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
721
868
  setNote('connection is not ready');
722
869
  return;
723
870
  }
871
+ const access = new WriteAccessPolicy().check();
872
+ if (!access.ok) {
873
+ setNote(access.error.message);
874
+ return;
875
+ }
724
876
  replyExecutionLockRef.current = true;
725
877
  const ownedGeneration = inputGenerationRef.current;
726
878
  const originalInput = input;
@@ -761,7 +913,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
761
913
  return;
762
914
  }
763
915
  if (key.backspace || key.delete) {
764
- inputGenerationRef.current++;
916
+ inputGenerationRef.current = {};
765
917
  setInput((current) => current.slice(0, -1));
766
918
  return;
767
919
  }
@@ -769,7 +921,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
769
921
  return;
770
922
  }
771
923
  if (!key.ctrl && !key.meta && inputText.length > 0) {
772
- inputGenerationRef.current++;
924
+ inputGenerationRef.current = {};
773
925
  setInput((current) => {
774
926
  const next = current + inputText;
775
927
  if (next.trimStart().startsWith('/')) {
@@ -784,14 +936,24 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
784
936
  useEffect(() => {
785
937
  const generation = operationControllerRef.current.beginGeneration();
786
938
  const isActive = generation.isActive;
939
+ const lifecycleStop = new AbortController();
940
+ lifecycleStopRef.current = lifecycleStop;
787
941
  if (stopSignal.aborted) {
788
942
  exit();
789
943
  return;
790
944
  }
791
945
  const stopFromSignal = () => {
946
+ if (stoppingRef.current)
947
+ return;
948
+ if (irreversibleGroupWriteRef.current) {
949
+ requestOwnershipShutdown('external');
950
+ return;
951
+ }
792
952
  stopListening();
793
953
  };
794
- stopSignal.addEventListener('abort', stopFromSignal);
954
+ const unsubscribeShutdown = shutdownRequests?.subscribe(stopFromSignal);
955
+ if (shutdownRequests == null)
956
+ stopSignal.addEventListener('abort', stopFromSignal);
795
957
  const groupQueue = createInteractiveListenRuntime(dbPath, createReplyResolver ?? createListenReplyResolver, {
796
958
  isActive,
797
959
  onGroup: (group) => {
@@ -833,7 +995,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
833
995
  chats,
834
996
  persist,
835
997
  retrySeconds,
836
- signal: stopSignal,
998
+ signal: lifecycleStop.signal,
837
999
  createClient,
838
1000
  createCoordinator: () => autoDownloader,
839
1001
  onCoordinator: (coordinator) => {
@@ -843,12 +1005,13 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
843
1005
  onClient: (client) => {
844
1006
  if (!isActive())
845
1007
  return;
846
- groupLookupGenerationRef.current++;
1008
+ groupLookupGenerationRef.current = {};
847
1009
  knownGroupRef.current = undefined;
848
1010
  setKnownGroup(undefined);
849
1011
  clientRef.current = client;
850
1012
  if (client != null && sendTo != null) {
851
- const lookup = ++groupLookupGenerationRef.current;
1013
+ const lookup = {};
1014
+ groupLookupGenerationRef.current = lookup;
852
1015
  void client.groups.getGroup(sendTo).then((group) => {
853
1016
  if (isActive() && lookup === groupLookupGenerationRef.current && clientRef.current === client) {
854
1017
  knownGroupRef.current = group;
@@ -903,30 +1066,42 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
903
1066
  albumAggregator.flush();
904
1067
  },
905
1068
  }).finally(() => {
906
- if (isActive() && !stopSignal.aborted) {
1069
+ if (isActive() && !stopSignal.aborted && !stoppingRef.current) {
907
1070
  onRequestStop();
908
1071
  exit();
909
1072
  }
910
1073
  });
911
1074
  return () => {
912
- groupLookupGenerationRef.current += 1;
1075
+ groupLookupGenerationRef.current = {};
913
1076
  knownGroupRef.current = undefined;
914
- stopSignal.removeEventListener('abort', stopFromSignal);
1077
+ unsubscribeShutdown?.();
1078
+ if (shutdownRequests == null)
1079
+ stopSignal.removeEventListener('abort', stopFromSignal);
915
1080
  albumAggregator.flush();
916
1081
  generation.dispose();
917
1082
  albumAggregator.dispose();
918
1083
  void groupQueue.close();
919
1084
  if (albumAggregatorRef.current === albumAggregator)
920
1085
  albumAggregatorRef.current = null;
921
- void clientRef.current?.close().catch(() => undefined);
1086
+ lifecycleStop.abort();
922
1087
  clientRef.current = null;
923
1088
  autoDownloader?.stop();
924
1089
  void autoDownloader?.waitForActive();
925
1090
  if (autoDownloaderRef.current === autoDownloader)
926
1091
  autoDownloaderRef.current = null;
1092
+ if (lifecycleStopRef.current === lifecycleStop)
1093
+ lifecycleStopRef.current = null;
1094
+ if (shutdownGraceTimerRef.current)
1095
+ clearTimeout(shutdownGraceTimerRef.current);
1096
+ shutdownGraceTimerRef.current = null;
927
1097
  };
928
- }, [autoDownload, chats, createClient, createReplyResolver, dbPath, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
1098
+ }, [autoDownload, chats, createClient, createReplyResolver, dbPath, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, shutdownRequests, onRequestStop]);
929
1099
  const sendMessage = async (text) => {
1100
+ const access = new WriteAccessPolicy().check();
1101
+ if (!access.ok) {
1102
+ setNote(access.error.message);
1103
+ return;
1104
+ }
930
1105
  const trimmed = text.trim();
931
1106
  if (!trimmed)
932
1107
  return;
@@ -997,7 +1172,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
997
1172
  onProgress: (downloaded, total) => {
998
1173
  if (!isCurrent())
999
1174
  return;
1000
- const progress = Number.isFinite(total) && total > 0 ? Math.round(downloaded / total * 100) : null;
1175
+ const progress = attachmentDownloadProgress(downloaded, total);
1001
1176
  setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress } }));
1002
1177
  },
1003
1178
  });
@@ -1019,15 +1194,28 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
1019
1194
  setTimeout(exit, 0);
1020
1195
  onRequestStop();
1021
1196
  autoDownloaderRef.current?.stop();
1022
- clientRef.current?.close().catch(() => undefined);
1197
+ lifecycleStopRef.current?.abort();
1023
1198
  };
1199
+ useEffect(() => {
1200
+ if (groupCommand.state.kind !== 'result' && groupCommand.state.kind !== 'error')
1201
+ return;
1202
+ if (!deferredStopRef.current && !shutdownRequestedRef.current)
1203
+ return;
1204
+ deferredStopRef.current = false;
1205
+ shutdownRequestedRef.current = false;
1206
+ if (shutdownGraceTimerRef.current)
1207
+ clearTimeout(shutdownGraceTimerRef.current);
1208
+ shutdownGraceTimerRef.current = null;
1209
+ const pending = setTimeout(stopListening, 0);
1210
+ return () => clearTimeout(pending);
1211
+ }, [groupCommand.state]);
1024
1212
  const permissionState = groupCommand.state.kind === 'select-permissions' ? groupCommand.state : null;
1025
1213
  return (_jsxs(Box, { flexDirection: "row", width: terminalWidth, height: terminalHeight, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: contentWidth, height: terminalHeight, overflow: "hidden", children: [_jsx(ListenStatusArea, { status: status, unseenCount: scrollState.unseenCount, autoDownload: autoDownload }), note ? _jsx(Text, { dimColor: true, children: note }) : null, groupCommand.state.kind === 'result' ? _jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(GroupCommandResult, { state: groupCommand.state, width: contentWidth }) }) : _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsx(GroupCommandResult, { state: groupCommand.state, width: contentWidth }), groupCommand.state.kind === 'confirm' && 'confirmation' in groupCommand.state.pending ? _jsx(GroupCommandConfirm, { confirmation: groupCommand.state.pending.confirmation, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth }) : null, groupCommand.state.kind === 'confirm-title' && 'confirmation' in groupCommand.state.pending ? groupCommand.state.stage === 'confirm'
1026
1214
  ? _jsx(GroupCommandConfirm, { confirmation: groupCommand.state.pending.confirmation, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth })
1027
- : _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(Text, { color: "yellow", children: truncateCell('Type the exact title to permanently delete this chat:', contentWidth) }), _jsx(Text, { children: truncateCell(groupCommand.state.pending.confirmation.title ?? '', contentWidth) }), _jsx(Text, { color: "#8ecbff", children: truncateCell(`› ${groupCommand.state.confirmText}`, contentWidth) }), groupCommand.state.mismatch ? _jsx(Text, { color: "red", children: truncateCell('Title does not match exactly.', contentWidth) }) : null, _jsx(Text, { dimColor: true, children: truncateCell('Enter verify · Esc back', contentWidth) })] }) : null, permissionState ? _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(Text, { color: "#8ecbff", children: truncateCell('Administrator permissions', contentWidth) }), ADMIN_RIGHT_KEYS.map((right, index) => _jsx(Text, { color: index === permissionState.selectedIndex ? '#8ecbff' : undefined, children: truncateCell(`${index === permissionState.selectedIndex ? '› ' : ' '}[${permissionState.selected.includes(right) ? 'x' : ' '}] ${right}`, contentWidth) }, right)), permissionState.warning ? _jsx(Text, { color: "yellow", children: truncateCell(permissionState.warning, contentWidth) }) : null, _jsx(Text, { dimColor: true, children: truncateCell('↑/↓ select · Space toggle · Enter continue · Esc cancel', contentWidth) })] }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [messages.length === 0 ? _jsx(Text, { dimColor: true, children: "Waiting for new messages..." }) : null, visibleMessages.map((message) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: formatInteractiveListenHeader(message) }), message.replyContext == null ? null : _jsx(Text, { wrap: "truncate-end", children: formatReplyContext(message.replyContext) }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.mediaSummary == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.mediaSummary }), message.media.map((item, mediaIndex) => {
1215
+ : _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(Text, { color: "yellow", children: truncateCell('Type the exact title to permanently delete this chat:', contentWidth) }), _jsx(Text, { children: truncateCell(groupCommand.state.pending.confirmation.title ?? '', contentWidth) }), _jsx(Text, { color: "#8ecbff", children: truncateCell(`› ${groupCommand.state.confirmText}`, contentWidth) }), groupCommand.state.mismatch ? _jsx(Text, { color: "red", children: truncateCell('Title does not match exactly.', contentWidth) }) : null, _jsx(Text, { dimColor: true, children: truncateCell('Enter verify · Esc back', contentWidth) })] }) : null, groupCommand.state.kind === 'password' ? _jsx(SecureInput, { label: "Telegram 2FA password", onSubmit: (value) => { void groupCommand.runWithOwnershipPassword(value); }, onCancel: closeGroupCommand }) : null, permissionState ? _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(Text, { color: "#8ecbff", children: truncateCell('Administrator permissions', contentWidth) }), ADMIN_RIGHT_KEYS.map((right, index) => _jsx(Text, { color: index === permissionState.selectedIndex ? '#8ecbff' : undefined, children: truncateCell(`${index === permissionState.selectedIndex ? '› ' : ' '}[${permissionState.selected.includes(right) ? 'x' : ' '}] ${right}`, contentWidth) }, right)), permissionState.warning ? _jsx(Text, { color: "yellow", children: truncateCell(permissionState.warning, contentWidth) }) : null, _jsx(Text, { dimColor: true, children: truncateCell('↑/↓ select · Space toggle · Enter continue · Esc cancel', contentWidth) })] }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [messages.length === 0 ? _jsx(Text, { dimColor: true, children: "Waiting for new messages..." }) : null, visibleMessages.map((message) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: formatInteractiveListenHeader(message) }), message.replyContext == null ? null : _jsx(Text, { wrap: "truncate-end", children: formatReplyContext(message.replyContext) }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.mediaSummary == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.mediaSummary }), message.media.map((item, mediaIndex) => {
1028
1216
  const attachmentKey = attachmentDownloadKeyAt(message.media, mediaIndex);
1029
1217
  return (_jsx(ListenAttachmentWithPreview, { label: item.label, downloadable: item.downloadable, selected: focus === 'attachments' && selectedAttachment?.key === attachmentKey, state: downloadStates[attachmentKey] ?? { status: 'idle' }, previewCells: item.previewCells }, attachmentKey));
1030
- }), _jsx(Text, { dimColor: true, children: MESSAGE_SEPARATOR })] }, message.key)))] })] }), groupCommand.state.kind !== 'result' ? _jsxs(Box, { flexDirection: "column", children: [sendTo == null ? _jsx(Text, { dimColor: true, children: "Set --send-to <chat> (or pass one chat to listen) before sending messages." }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [input.trimStart().startsWith('/') && groupCommand.state.kind === 'menu' ? _jsx(ListenCommandMenu, { input: input, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth, knownGroup: knownGroup }) : null, _jsx(ListenComposer, { input: input, sendTargetLabel: sendTo == null ? '(not selected)' : sendTargetLabel, terminalWidth: contentWidth, sending: sending, hint: focus === 'attachments' ? '↑/↓ select · Enter download · Tab input' : 'Enter send · /reply <id> ... · Tab attachments · Ctrl+C exit' })] })] }) : null] }), _jsx(ListenScrollbar, { height: terminalHeight, visible: scrollbarVisible, geometry: scrollbarGeometry })] }));
1218
+ }), _jsx(Text, { dimColor: true, children: MESSAGE_SEPARATOR })] }, message.key)))] })] }), groupCommand.state.kind !== 'result' ? _jsxs(Box, { flexDirection: "column", children: [sendTo == null ? _jsx(Text, { dimColor: true, children: "Set --send-to <chat> (or pass one chat to listen) before sending messages." }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [input.trimStart().startsWith('/') && groupCommand.state.kind === 'menu' ? _jsx(ListenCommandMenu, { input: input, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth, knownGroup: knownGroup }) : null, _jsx(ListenComposer, { input: input, sendTargetLabel: sendTo == null ? '(not selected)' : sendTargetLabel, terminalWidth: contentWidth, sending: sending, cursorVisible: focus === 'input', hint: focus === 'attachments' ? 'Wheel · ↑/↓ select · Enter download · Tab input' : 'Wheel/↑/↓/PgUp/PgDn scroll · Drag select · Ctrl+C exit' })] })] }) : null] }), _jsx(ListenScrollbar, { height: terminalHeight, visible: scrollbarVisible, geometry: scrollbarGeometry })] }));
1031
1219
  }
1032
1220
  export class ListenMessageViewCache {
1033
1221
  entries = new Map();