@emaxe/tuigram 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
- import { createScreen } from "./screen.js";
3
+ import { createScreen, setMouseCapture } from "./screen.js";
4
4
  import { getTheme } from "./theme.js";
5
5
  import { createHeader } from "./components/header.js";
6
6
  import { createChatList } from "./components/chatList.js";
@@ -12,16 +12,18 @@ import { createChatInfoModal } from "./components/modals/chatInfoModal.js";
12
12
  import { createActionModal } from "./components/modals/actionModal.js";
13
13
  import { createFileModal } from "./components/modals/fileModal.js";
14
14
  import { createConfirmModal } from "./components/modals/confirmModal.js";
15
+ import { createImageViewerModal } from "./components/modals/imageViewerModal.js";
15
16
  import { state } from "../state.js";
16
17
  import { config } from "../config.js";
17
18
  import { fetchDialogs } from "../telegram/dialogs.js";
18
19
  import { setMessagePalette } from "../telegram/formatter.js";
19
- import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
20
+ import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail } from "../telegram/messages.js";
20
21
  import { startTelegramListener } from "../telegram/listener.js";
21
22
  import { logout } from "../telegram/auth.js";
22
23
  import { ensureDir, inspectLocalFile } from "../utils/storage.js";
23
24
  import { formatFileSize } from "../utils/time.js";
24
25
  import { parseSendFileArgs } from "../utils/commands.js";
26
+ import { isRightClick } from "../utils/mouse.js";
25
27
 
26
28
  /**
27
29
  * Запускает полноэкранный TUI-клиент Telegram.
@@ -93,10 +95,10 @@ export async function startTui(client, me) {
93
95
  statusBar.showMessage("Сначала выберите чат слева!", "warning");
94
96
  return;
95
97
  }
96
- const msgs = state.getMessages(state.activeChat.id);
97
- if (msgs.length > 0) {
98
+ const msg = chatView.getTargetMessage();
99
+ if (msg) {
98
100
  releaseInputs();
99
- actionModal.show(msgs[msgs.length - 1]);
101
+ actionModal.show(msg);
100
102
  }
101
103
  },
102
104
  onChatInfo: () => {
@@ -125,6 +127,16 @@ export async function startTui(client, me) {
125
127
  onSendFile: (files, options) => sendFilesToActiveChat(files, options),
126
128
  });
127
129
 
130
+ const imageViewerModal = createImageViewerModal(screen, theme, {
131
+ onLoadFullImage: (msg) => downloadImageBuffer(client, msg.rawMessage),
132
+ // Пока качается оригинал, показываем встроенную в сообщение миниатюру
133
+ onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
134
+ maxWidth: size.maxWidth,
135
+ maxHeight: size.maxHeight,
136
+ useCache: false,
137
+ }),
138
+ });
139
+
128
140
  const actionModal = createActionModal(screen, theme, {
129
141
  onAction: async (actionId, msg) => {
130
142
  if (!state.activeChat) return;
@@ -239,6 +251,19 @@ export async function startTui(client, me) {
239
251
  releaseInputs();
240
252
  actionModal.show(msg);
241
253
  },
254
+ onSelectMessage: (msg) => {
255
+ const preview = (msg.text || msg.mediaDescription || "вложение").replace(/\s+/g, " ").slice(0, 30);
256
+ statusBar.showMessage(
257
+ `Выделено #${msg.id}: "${preview}" · [Enter] или правый клик — действия`,
258
+ "info",
259
+ 3000
260
+ );
261
+ },
262
+ onOpenImage: (msg) => {
263
+ releaseInputs();
264
+ imageViewerModal.show(msg);
265
+ },
266
+ onFocusRequest: () => releaseInputs(),
242
267
  });
243
268
 
244
269
  // 4. Поле ввода (нижняя панель)
@@ -276,21 +301,8 @@ export async function startTui(client, me) {
276
301
  onCancelContext: () => {
277
302
  statusBar.showMessage("Режим ответа/редактирования сброшен", "info", 2000);
278
303
  },
279
- onReplyLast: () => {
280
- if (!state.activeChat) return;
281
- const msgs = state.getMessages(state.activeChat.id);
282
- if (msgs.length > 0) {
283
- inputBox.setContext("reply", msgs[msgs.length - 1]);
284
- }
285
- },
286
- onEditLast: () => {
287
- if (!state.activeChat) return;
288
- const msgs = state.getMessages(state.activeChat.id);
289
- const ownMsgs = msgs.filter((m) => m.out);
290
- if (ownMsgs.length > 0) {
291
- inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
292
- }
293
- },
304
+ onReplyLast: () => startReply(),
305
+ onEditLast: () => startEdit(),
294
306
  onSlashCommand: (cmd, args) => {
295
307
  switch (cmd) {
296
308
  case "help":
@@ -356,7 +368,8 @@ export async function startTui(client, me) {
356
368
  });
357
369
 
358
370
  // Переключение фокуса по клику мышью на любую из трех панелей
359
- chatList.container.on("click", () => {
371
+ chatList.container.on("click", (data) => {
372
+ if (isRightClick(data)) return;
360
373
  if (screen.focused !== chatList.list && screen.focused !== chatList.searchBox) {
361
374
  releaseInputs();
362
375
  chatList.focus();
@@ -365,7 +378,8 @@ export async function startTui(client, me) {
365
378
  }
366
379
  });
367
380
 
368
- chatView.container.on("click", () => {
381
+ chatView.container.on("click", (data) => {
382
+ if (isRightClick(data)) return;
369
383
  if (screen.focused !== chatView.scrollBox) {
370
384
  releaseInputs();
371
385
  chatView.focus();
@@ -374,7 +388,8 @@ export async function startTui(client, me) {
374
388
  }
375
389
  });
376
390
 
377
- inputBox.container.on("click", () => {
391
+ inputBox.container.on("click", (data) => {
392
+ if (isRightClick(data)) return;
378
393
  if (screen.focused !== inputBox.textarea) {
379
394
  inputBox.focus();
380
395
  statusBar.showMessage("Фокус: поле ввода", "info", 2000);
@@ -403,6 +418,7 @@ export async function startTui(client, me) {
403
418
  typingUser: state.getTypingUser(chat?.id),
404
419
  });
405
420
  const msgs = chat ? state.getMessages(chat.id) : [];
421
+ chatView.setSelected(null);
406
422
  chatView.setMessages(msgs);
407
423
  });
408
424
 
@@ -572,6 +588,31 @@ export async function startTui(client, me) {
572
588
  chatList.release?.();
573
589
  }
574
590
 
591
+ /** Включает режим ответа на выделенное сообщение, иначе — на последнее в ленте. */
592
+ function startReply() {
593
+ if (!state.activeChat) return;
594
+ const target = chatView.getTargetMessage();
595
+ if (target) {
596
+ inputBox.setContext("reply", target);
597
+ }
598
+ }
599
+
600
+ /** Включает правку выделенного своего сообщения, иначе — последнего своего. */
601
+ function startEdit() {
602
+ if (!state.activeChat) return;
603
+ const selected = chatView.getSelected();
604
+ if (selected?.out) {
605
+ inputBox.setContext("edit", selected);
606
+ return;
607
+ }
608
+ const ownMsgs = state.getMessages(state.activeChat.id).filter((m) => m.out);
609
+ if (ownMsgs.length > 0) {
610
+ inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
611
+ } else {
612
+ statusBar.showMessage("В этом чате нет ваших сообщений для правки", "warning", 3000);
613
+ }
614
+ }
615
+
575
616
  // 7. Глобальные сочетания клавиш
576
617
  // Цикл фокуса: список чатов -> лента сообщений -> поле ввода -> список чатов.
577
618
  // Без ленты в цикле были недостижимы прокрутка, подгрузка истории и меню действий.
@@ -614,17 +655,18 @@ export async function startTui(client, me) {
614
655
  screen.key(["tab"], () => moveFocus(1));
615
656
  screen.key(["S-tab"], () => moveFocus(-1));
616
657
 
658
+ // Когда лента в фокусе, эти клавиши обрабатывает она сама — иначе прокрутка удваивается
617
659
  screen.key(["pageup", "C-u"], () => {
618
- if (!state.activeChat) return;
660
+ if (!state.activeChat || screen.focused === chatView.scrollBox) return;
619
661
  chatView.scrollBox.scroll(-10);
620
- if (chatView.scrollBox.getScroll() <= 0) {
662
+ if ((chatView.scrollBox.childBase || 0) <= 0) {
621
663
  chatView.loadMore?.();
622
664
  }
623
665
  screen.render();
624
666
  });
625
667
 
626
668
  screen.key(["pagedown", "C-d"], () => {
627
- if (!state.activeChat) return;
669
+ if (!state.activeChat || screen.focused === chatView.scrollBox) return;
628
670
  chatView.scrollBox.scroll(10);
629
671
  screen.render();
630
672
  });
@@ -653,23 +695,36 @@ export async function startTui(client, me) {
653
695
  }
654
696
  });
655
697
 
656
- screen.key(["C-r"], () => {
657
- if (!state.activeChat) return;
658
- const msgs = state.getMessages(state.activeChat.id);
659
- if (msgs.length > 0) {
660
- const lastMsg = msgs[msgs.length - 1];
661
- inputBox.setContext("reply", lastMsg);
698
+ screen.key(["C-r"], () => startReply());
699
+
700
+ screen.key(["C-e"], () => startEdit());
701
+
702
+ // Меню действий над выделенным сообщением из любой панели.
703
+ // Когда лента в фокусе, Ctrl+A обрабатывает она сама.
704
+ screen.key(["C-a"], () => {
705
+ if (screen.focused === chatView.scrollBox) return;
706
+ if (!state.activeChat) {
707
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
708
+ return;
709
+ }
710
+ const msg = chatView.getTargetMessage();
711
+ if (msg) {
712
+ releaseInputs();
713
+ actionModal.show(msg);
662
714
  }
663
715
  });
664
716
 
665
- screen.key(["C-e"], () => {
666
- if (!state.activeChat) return;
667
- const msgs = state.getMessages(state.activeChat.id);
668
- const ownMsgs = msgs.filter((m) => m.out);
669
- if (ownMsgs.length > 0) {
670
- const lastOwn = ownMsgs[ownMsgs.length - 1];
671
- inputBox.setContext("edit", lastOwn);
672
- }
717
+ // Пока мышь захвачена приложением, терминал не даёт выделять текст для копирования
718
+ screen.key(["f12"], () => {
719
+ const enabled = !screen.mouseCaptured;
720
+ setMouseCapture(screen, enabled);
721
+ statusBar.showMessage(
722
+ enabled
723
+ ? "Мышь снова управляет интерфейсом"
724
+ : "Мышь отдана терминалу — можно выделять и копировать текст. [F12] вернуть",
725
+ enabled ? "info" : "warning",
726
+ 5000
727
+ );
673
728
  });
674
729
 
675
730
  screen.key(["C-q"], () => {
@@ -4,7 +4,7 @@ import { escapeBlessed } from "../../telegram/formatter.js";
4
4
  import { fg, badge } from "../theme.js";
5
5
  import unicode from "neo-blessed/lib/unicode.js";
6
6
 
7
- import { getTabByCoordinate } from "../../utils/mouse.js";
7
+ import { getTabByCoordinate, isRightClick } from "../../utils/mouse.js";
8
8
 
9
9
  /**
10
10
  * Создаёт компонент списка диалогов (левая панель).
@@ -70,6 +70,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
70
70
  }
71
71
 
72
72
  tabsBox.on("click", (data) => {
73
+ if (isRightClick(data)) return;
73
74
  const relX = data.x - (tabsBox.aleft || 0);
74
75
  const tabKey = getTabByCoordinate(relX, TAB_KEYS, TAB_NAMES);
75
76
  if (tabKey) {
@@ -148,7 +149,8 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
148
149
  list.createItem = (content) => {
149
150
  const item = baseCreateItem(content);
150
151
  item.wrap = false;
151
- item.on("click", () => {
152
+ item.on("click", (data) => {
153
+ if (isRightClick(data)) return;
152
154
  const index = list.getItemIndex(item);
153
155
  if (index !== -1 && currentDialogs[index]) {
154
156
  list.select(index);
@@ -3,7 +3,10 @@ import { formatMessageTime, formatDateDivider } from "../../utils/time.js";
3
3
  import { formatMessageText, escapeBlessed } from "../../telegram/formatter.js";
4
4
  import { fg } from "../theme.js";
5
5
 
6
- import { getMessageAtLine } from "../../utils/mouse.js";
6
+ import { getMessagePartAtPoint, isRightClick, stringCellWidth } from "../../utils/mouse.js";
7
+
8
+ /** Отступ тела сообщения от левого края ленты, в ячейках. */
9
+ const BODY_INDENT = 2;
7
10
 
8
11
  /**
9
12
  * Создаёт компонент просмотра сообщений чата (правая центральная панель).
@@ -11,9 +14,18 @@ import { getMessageAtLine } from "../../utils/mouse.js";
11
14
  * @param {object} theme
12
15
  * @param {object} callbacks
13
16
  * @param {() => void} [callbacks.onLoadMoreHistory]
14
- * @param {(msg: object) => void} [callbacks.onActionMenu]
17
+ * @param {(msg: object) => void} [callbacks.onActionMenu] правый клик / Enter / Ctrl+A
18
+ * @param {(msg: object) => void} [callbacks.onSelectMessage] сообщение выделено
19
+ * @param {(msg: object) => void} [callbacks.onOpenImage] клик по превью изображения
20
+ * @param {() => void} [callbacks.onFocusRequest] вызывается перед взятием фокуса мышью
15
21
  */
16
- export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu } = {}) {
22
+ export function createChatView(screen, theme, {
23
+ onLoadMoreHistory,
24
+ onActionMenu,
25
+ onSelectMessage,
26
+ onOpenImage,
27
+ onFocusRequest,
28
+ } = {}) {
17
29
  const container = blessed.box({
18
30
  parent: screen,
19
31
  top: 4,
@@ -41,9 +53,13 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
41
53
  bottom: 0,
42
54
  tags: true,
43
55
  scrollable: true,
56
+ // alwaysScroll обязателен: без него blessed копит смещение в childOffset,
57
+ // не двигая содержимое (первые щелчки колеса «проглатываются»), а getScroll()
58
+ // перестаёт совпадать с реальным сдвигом ленты — клики попадали не в то сообщение.
59
+ alwaysScroll: true,
44
60
  mouse: true,
45
- keys: true,
46
- vi: true,
61
+ // keys/vi намеренно выключены: их встроенные обработчики скроллят ленту на
62
+ // стрелках, а стрелки здесь двигают выделение. Все клавиши навешаны явно ниже.
47
63
  scrollbar: {
48
64
  ch: "│",
49
65
  style: {
@@ -57,6 +73,11 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
57
73
  },
58
74
  });
59
75
 
76
+ // blessed сам вешает на scrollable-box с mouse:true прокрутку колесом на пол-экрана.
77
+ // Вместе с нашими обработчиками получалось height/2 + 3 строки за щелчок.
78
+ scrollBox.removeAllListeners("wheelup");
79
+ scrollBox.removeAllListeners("wheeldown");
80
+
60
81
  /** Подсвечивает рамку, когда лента сообщений в фокусе. */
61
82
  function setFocusHighlight(active) {
62
83
  container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
@@ -68,12 +89,13 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
68
89
 
69
90
  let currentMessages = [];
70
91
  let currentRanges = [];
92
+ let selectedId = null;
71
93
 
72
94
  /**
73
95
  * Форматирует список сообщений в единую ленту текста с разметкой Blessed
74
96
  * и вычисляет координаты строк каждого сообщения для кликов мыши.
75
97
  * @param {Array<object>} messages
76
- * @returns {{ text: string, ranges: Array<{ message: object, startLine: number, endLine: number }> }}
98
+ * @returns {{ text: string, ranges: Array<object> }}
77
99
  */
78
100
  function renderMessagesWithRanges(messages) {
79
101
  if (!messages || messages.length === 0) {
@@ -110,12 +132,14 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
110
132
  authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
111
133
  }
112
134
 
135
+ // Метка редактирования
136
+ const editedTag = msg.editDate ? ` ${fg(theme.chatView.time, "(изменено)")}` : "";
137
+
138
+ const lines = [` ${authorTag}${editedTag}`];
139
+
113
140
  // Блок ответа (Reply)
114
- let replyBlock = "";
115
- let replyLines = 0;
116
141
  if (msg.replyToMsgId) {
117
- replyBlock = ` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}\n`;
118
- replyLines = 1;
142
+ lines.push(` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}`);
119
143
  }
120
144
 
121
145
  // Текст сообщения и entities
@@ -135,41 +159,67 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
135
159
  bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
136
160
  }
137
161
 
138
- // Отступ строк текста сообщения
139
- const indentedBody = bodyText
140
- .split("\n")
141
- .map((line) => ` ${line}`)
142
- .join("\n");
143
- const bodyLinesCount = indentedBody.split("\n").length;
162
+ // Строка, с которой начинается тело — нужна для координат превью
163
+ const bodyStartOffset = lines.length;
164
+ for (const line of bodyText.split("\n")) {
165
+ lines.push(` ${line}`);
166
+ }
144
167
 
145
168
  // Реакции
146
- let reactionsLine = "";
147
- let reactionLinesCount = 0;
148
169
  if (msg.reactions && msg.reactions.length > 0) {
149
170
  const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
150
- reactionsLine = `\n ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`;
151
- reactionLinesCount = 1;
152
- }
153
-
154
- // Метка редактирования
155
- let editedTag = "";
156
- if (msg.editDate) {
157
- editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
171
+ lines.push(` ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`);
158
172
  }
159
173
 
160
174
  const startLine = lineCursor;
161
- const totalMsgLines = 1 + replyLines + bodyLinesCount + reactionLinesCount;
162
- const endLine = startLine + totalMsgLines - 1;
163
175
 
164
- ranges.push({ message: msg, startLine, endLine });
165
- lineCursor += totalMsgLines + 2;
176
+ // Прямоугольник превью изображения внутри сообщения
177
+ let image = null;
178
+ if (msg.imagePreview) {
179
+ const descLines = msg.mediaDescription ? msg.mediaDescription.split("\n").length : 0;
180
+ const previewLines = msg.imagePreview.split("\n");
181
+ const imageStart = startLine + bodyStartOffset + descLines;
182
+ const width = previewLines.reduce((max, line) => Math.max(max, stringCellWidth(line)), 0);
183
+ image = {
184
+ startLine: imageStart,
185
+ endLine: imageStart + previewLines.length - 1,
186
+ left: BODY_INDENT,
187
+ right: BODY_INDENT + width,
188
+ };
189
+ }
166
190
 
167
- output += ` ${authorTag}${editedTag}\n${replyBlock}${indentedBody}${reactionsLine}\n\n`;
191
+ // Выделенное сообщение помечается полосой в первой колонке. Первая колонка
192
+ // каждой строки — пробел отступа, поэтому ширина строк не меняется и карта
193
+ // координат остаётся верной.
194
+ const rendered = msg.id === selectedId
195
+ ? lines.map((line) => `${fg(theme.accent, "▌")}${line.slice(1)}`)
196
+ : lines;
197
+
198
+ ranges.push({
199
+ message: msg,
200
+ startLine,
201
+ endLine: startLine + lines.length - 1,
202
+ image,
203
+ });
204
+
205
+ // lines.length строк + одна пустая строка-разделитель между сообщениями
206
+ lineCursor += lines.length + 1;
207
+ output += `${rendered.join("\n")}\n\n`;
168
208
  }
169
209
 
170
210
  return { text: output, ranges };
171
211
  }
172
212
 
213
+ /** Перерисовывает ленту, сохраняя позицию прокрутки (например, после смены выделения). */
214
+ function redraw() {
215
+ const prevBase = scrollBox.childBase || 0;
216
+ const rendered = renderMessagesWithRanges(currentMessages);
217
+ currentRanges = rendered.ranges;
218
+ scrollBox.setContent(rendered.text);
219
+ scrollBox.scrollTo(prevBase);
220
+ screen.render();
221
+ }
222
+
173
223
  /**
174
224
  * Устанавливает сообщения в ленту.
175
225
  * @param {Array<object>} messages
@@ -177,9 +227,14 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
177
227
  */
178
228
  function setMessages(messages, autoScrollToBottom = true) {
179
229
  currentMessages = messages;
180
- const prevScroll = scrollBox.getScroll();
230
+ const prevScroll = scrollBox.childBase || 0;
181
231
  const prevHeight = scrollBox.getScrollHeight();
182
232
 
233
+ // Выделенное сообщение могло быть удалено или относиться к другому чату
234
+ if (selectedId !== null && !messages.some((m) => m.id === selectedId)) {
235
+ selectedId = null;
236
+ }
237
+
183
238
  const rendered = renderMessagesWithRanges(messages);
184
239
  currentRanges = rendered.ranges;
185
240
  scrollBox.setContent(rendered.text);
@@ -200,10 +255,108 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
200
255
  screen.render();
201
256
  }
202
257
 
258
+ /**
259
+ * Переводит номер отрисованной строки в номер строки исходного содержимого.
260
+ * blessed переносит длинные строки, поэтому напрямую индексы не совпадают.
261
+ * @param {number} renderedLine
262
+ * @returns {number}
263
+ */
264
+ function toContentLine(renderedLine) {
265
+ const rtof = scrollBox._clines?.rtof;
266
+ if (Array.isArray(rtof) && renderedLine >= 0 && renderedLine < rtof.length) {
267
+ return rtof[renderedLine];
268
+ }
269
+ return renderedLine;
270
+ }
271
+
272
+ /**
273
+ * Обратное преобразование: первая и последняя отрисованные строки для строки содержимого.
274
+ * @param {number} contentLine
275
+ * @param {"first"|"last"} edge
276
+ * @returns {number}
277
+ */
278
+ function toRenderedLine(contentLine, edge = "first") {
279
+ const ftor = scrollBox._clines?.ftor;
280
+ const mapped = Array.isArray(ftor) ? ftor[contentLine] : null;
281
+ if (!Array.isArray(mapped) || mapped.length === 0) return contentLine;
282
+ return edge === "first" ? mapped[0] : mapped[mapped.length - 1];
283
+ }
284
+
285
+ /** Подкручивает ленту так, чтобы выделенное сообщение было видно целиком. */
286
+ function scrollMessageIntoView(range) {
287
+ if (!range) return;
288
+ const visible = scrollBox.height - scrollBox.iheight;
289
+ const base = scrollBox.childBase || 0;
290
+ const top = toRenderedLine(range.startLine, "first");
291
+ const bottom = toRenderedLine(range.endLine, "last");
292
+
293
+ if (top < base) {
294
+ scrollBox.scrollTo(top);
295
+ } else if (bottom >= base + visible) {
296
+ scrollBox.scrollTo(Math.max(0, bottom - visible + 1));
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Выделяет сообщение по идентификатору.
302
+ * @param {number|null} id
303
+ * @param {object} [options]
304
+ * @param {boolean} [options.scrollIntoView=false]
305
+ * @param {boolean} [options.notify=false] вызвать onSelectMessage
306
+ */
307
+ function setSelected(id, { scrollIntoView = false, notify = false } = {}) {
308
+ if (selectedId === id && !scrollIntoView) return;
309
+ selectedId = id;
310
+ redraw();
311
+
312
+ const range = currentRanges.find((r) => r.message.id === id);
313
+ if (scrollIntoView && range) {
314
+ scrollMessageIntoView(range);
315
+ screen.render();
316
+ }
317
+ if (notify && range) {
318
+ onSelectMessage?.(range.message);
319
+ }
320
+ }
321
+
322
+ /** @returns {object|null} выделенное сообщение */
323
+ function getSelected() {
324
+ if (selectedId === null) return null;
325
+ return currentMessages.find((m) => m.id === selectedId) || null;
326
+ }
327
+
328
+ /** Сообщение, над которым выполняются действия: выделенное, иначе последнее. */
329
+ function getTargetMessage() {
330
+ return getSelected() || currentMessages[currentMessages.length - 1] || null;
331
+ }
332
+
333
+ /**
334
+ * Двигает выделение на step сообщений и подкручивает ленту к нему.
335
+ * @param {number} step
336
+ */
337
+ function selectByOffset(step) {
338
+ if (currentMessages.length === 0) return;
339
+
340
+ const currentIndex = currentMessages.findIndex((m) => m.id === selectedId);
341
+ let nextIndex;
342
+ if (currentIndex === -1) {
343
+ nextIndex = currentMessages.length - 1;
344
+ } else {
345
+ nextIndex = Math.min(currentMessages.length - 1, Math.max(0, currentIndex + step));
346
+ }
347
+
348
+ setSelected(currentMessages[nextIndex].id, { scrollIntoView: true, notify: true });
349
+
350
+ // Дошли до верха ленты — подтягиваем предыдущую страницу истории
351
+ if (nextIndex === 0 && step < 0) {
352
+ onLoadMoreHistory?.();
353
+ }
354
+ }
355
+
203
356
  // Обработка прокрутки вверх для подгрузки истории
204
357
  function handleScrollUp(step = 10) {
205
358
  scrollBox.scroll(-step);
206
- if (scrollBox.getScroll() <= 0) {
359
+ if ((scrollBox.childBase || 0) <= 0) {
207
360
  onLoadMoreHistory?.();
208
361
  }
209
362
  screen.render();
@@ -216,8 +369,8 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
216
369
 
217
370
  scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
218
371
  scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
219
- scrollBox.key(["up", "k"], () => handleScrollUp(2));
220
- scrollBox.key(["down", "j"], () => handleScrollDown(2));
372
+ scrollBox.key(["up", "k"], () => selectByOffset(-1));
373
+ scrollBox.key(["down", "j"], () => selectByOffset(1));
221
374
  scrollBox.key(["home"], () => {
222
375
  scrollBox.scrollTo(0);
223
376
  onLoadMoreHistory?.();
@@ -228,41 +381,48 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
228
381
  screen.render();
229
382
  });
230
383
 
231
- scrollBox.on("wheelup", () => {
232
- handleScrollUp(3);
233
- });
234
-
235
- scrollBox.on("wheeldown", () => {
236
- handleScrollDown(3);
237
- });
238
-
239
- container.on("wheelup", () => {
240
- handleScrollUp(3);
241
- });
242
-
243
- container.on("wheeldown", () => {
244
- handleScrollDown(3);
245
- });
384
+ scrollBox.on("wheelup", () => handleScrollUp(3));
385
+ scrollBox.on("wheeldown", () => handleScrollDown(3));
386
+ container.on("wheelup", () => handleScrollUp(3));
387
+ container.on("wheeldown", () => handleScrollDown(3));
246
388
 
247
389
  scrollBox.on("click", (data) => {
248
- const clickY = data.y;
249
- const itop = scrollBox.itop || 0;
250
- const lineIndex = clickY - (scrollBox.atop || 0) + scrollBox.getScroll() - itop;
251
- const clickedMsg = getMessageAtLine(lineIndex, currentRanges);
252
- if (clickedMsg) {
253
- onActionMenu?.(clickedMsg);
254
- } else {
390
+ const renderedLine = data.y - (scrollBox.atop || 0) - (scrollBox.itop || 0) + (scrollBox.childBase || 0);
391
+ const relX = data.x - (scrollBox.aleft || 0) - (scrollBox.ileft || 0);
392
+ const hit = getMessagePartAtPoint(toContentLine(renderedLine), relX, currentRanges);
393
+
394
+ if (!hit) {
395
+ // Клик по пустому месту ленты — просто передаём ей фокус
396
+ onFocusRequest?.();
255
397
  scrollBox.focus();
256
398
  screen.render();
399
+ return;
400
+ }
401
+
402
+ if (isRightClick(data)) {
403
+ setSelected(hit.message.id);
404
+ onActionMenu?.(hit.message);
405
+ return;
406
+ }
407
+
408
+ if (hit.part === "image") {
409
+ setSelected(hit.message.id);
410
+ onOpenImage?.(hit.message);
411
+ return;
257
412
  }
413
+
414
+ onFocusRequest?.();
415
+ scrollBox.focus();
416
+ setSelected(hit.message.id, { notify: true });
258
417
  });
259
418
 
260
419
  // Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
261
420
  // висит на Ctrl+A — иначе оно недостижимо.
262
- scrollBox.key(["C-a"], () => {
263
- if (currentMessages.length > 0) {
264
- const lastMsg = currentMessages[currentMessages.length - 1];
265
- onActionMenu?.(lastMsg);
421
+ scrollBox.key(["C-a", "enter", "return"], () => {
422
+ const msg = getTargetMessage();
423
+ if (msg) {
424
+ setSelected(msg.id);
425
+ onActionMenu?.(msg);
266
426
  }
267
427
  });
268
428
 
@@ -270,6 +430,10 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
270
430
  container,
271
431
  scrollBox,
272
432
  setMessages,
433
+ setSelected,
434
+ getSelected,
435
+ getTargetMessage,
436
+ selectByOffset,
273
437
  loadMore: () => onLoadMoreHistory?.(),
274
438
  scrollToBottom: () => {
275
439
  scrollBox.setScrollPerc(100);