@emaxe/tuigram 1.2.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -13,6 +13,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
13
13
 
14
14
  - English localization of the TUI and CLI interface strings.
15
15
 
16
+ ## [1.3.0] — 2026-09-01
17
+
18
+ ### Added
19
+
20
+ - Comprehensive mouse support across the entire TUI:
21
+ - Direct panel focus on click (left panel for dialog list, middle panel for message feed, bottom panel for input box).
22
+ - Single-click dialog selection and immediate opening.
23
+ - Smooth mouse wheel scrolling for dialog list and message feed with automatic loading of older history when scrolling up past the top.
24
+ - Clicking on any message opens its contextual action menu (reactions, replies, edits, downloads, deletion).
25
+ - Clickable filter tabs (`1:Все`, `2:ЛС`, `3:Группы`, `4:Каналы`, `5:Боты`, `6:Непроч`) and search input focusing.
26
+ - Interactive status bar buttons (`[Tab]`, `[1-6]`, `[/]`, `[F1]`, `[Ctrl+A]`, `[Ctrl+P]`, `[Ctrl+Q]`).
27
+ - Interactive header elements: click logo for Help, click chat title for Chat Info, click status badge for network state.
28
+ - Interactive context bar in input box: click to cancel reply/edit mode or trigger shortcuts.
29
+ - Full mouse navigation inside modals (button clicks, checkbox toggles, file browsing) and dismissing any modal by clicking outside its boundary.
30
+ - Robust terminal mouse mode initialization (`\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h`) supporting modern terminals (macOS Terminal.app, iTerm2, Alacritty, Kitty, Windows Terminal, xterm).
31
+ - Pure mouse coordinate calculation utilities (`src/utils/mouse.js`) with full test coverage in `test/unit.test.js`.
32
+
16
33
  ## [1.2.0] — 2026-09-01
17
34
 
18
35
  ### Added
@@ -118,7 +135,8 @@ First public release.
118
135
  - A WCAG contrast test (3:1 threshold) applied to every theme **after** conversion
119
136
  to xterm-256 — the colors are checked exactly as the user sees them.
120
137
 
121
- [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.2.0...HEAD
138
+ [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.3.0...HEAD
139
+ [1.3.0]: https://github.com/emaxe/tuigram/compare/v1.2.0...v1.3.0
122
140
  [1.2.0]: https://github.com/emaxe/tuigram/compare/v1.1.0...v1.2.0
123
141
  [1.1.0]: https://github.com/emaxe/tuigram/compare/v1.0.1...v1.1.0
124
142
  [1.0.1]: https://github.com/emaxe/tuigram/compare/v1.0.0...v1.0.1
package/CHANGELOG.ru.md CHANGED
@@ -13,6 +13,23 @@
13
13
 
14
14
  - Английская локализация строк интерфейса TUI и CLI.
15
15
 
16
+ ## [1.3.0] — 2026-09-01
17
+
18
+ ### Добавлено
19
+
20
+ - Полноценная поддержка управления мышью во всём TUI-интерфейсе:
21
+ - Фокусировка любой панели по клику (список чатов, лента сообщений, поле ввода).
22
+ - Открытие диалога в один клик по элементу списка.
23
+ - Плавная прокрутка списка чатов и ленты сообщений колесом мыши с автоматической подгрузкой старой истории при прокрутке вверх.
24
+ - Открытие меню действий над сообщением (реакции, ответ, редактирование, скачивание, удаление) по прямому клику на сообщение.
25
+ - Интерактивные вкладки фильтрации (`1:Все`, `2:ЛС`, `3:Группы`, `4:Каналы`, `5:Боты`, `6:Непроч`) и строка поиска в списке диалогов.
26
+ - Интерактивная нижняя строка состояния (`[Tab]`, `[1-6]`, `[/]`, `[F1]`, `[Ctrl+A]`, `[Ctrl+P]`, `[Ctrl+Q]`).
27
+ - Интерактивный верхний заголовок: клик по логотипу открывает справку, по названию активного чата — сведения о чате, по статусу — информацию о сети.
28
+ - Контекстная строка поля ввода: клик сбрасывает режим ответа/редактирования либо запускает команды.
29
+ - Поддержка мыши во всех модальных окнах (клики по кнопкам, чекбоксам, файлам в проводнике) и закрытие модалок по клику мимо окна.
30
+ - Надёжная инициализация режимов мыши терминала (`\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h`) с поддержкой современных эмуляторов (macOS Terminal.app, iTerm2, Alacritty, Kitty, Windows Terminal, xterm).
31
+ - Чистые утилиты расчёта координат мыши (`src/utils/mouse.js`) и покрытие юнит-тестами в `test/unit.test.js`.
32
+
16
33
  ## [1.2.0] — 2026-09-01
17
34
 
18
35
  ### Добавлено
@@ -120,7 +137,8 @@
120
137
  - Тест контраста по WCAG (порог 3:1) для каждой темы — **после** конверсии в xterm-256,
121
138
  то есть ровно в том виде, в каком цвет увидит пользователь.
122
139
 
123
- [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.2.0...HEAD
140
+ [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.3.0...HEAD
141
+ [1.3.0]: https://github.com/emaxe/tuigram/compare/v1.2.0...v1.3.0
124
142
  [1.2.0]: https://github.com/emaxe/tuigram/compare/v1.1.0...v1.2.0
125
143
  [1.1.0]: https://github.com/emaxe/tuigram/compare/v1.0.1...v1.1.0
126
144
  [1.0.1]: https://github.com/emaxe/tuigram/compare/v1.0.0...v1.0.1
package/README.md CHANGED
@@ -52,6 +52,7 @@ TuiGram lets you use Telegram entirely from the terminal: browse your dialog lis
52
52
  - [Where files are stored](#-where-files-are-stored)
53
53
  - [Development](#-development)
54
54
  - [Keyboard shortcuts](#️-keyboard-shortcuts)
55
+ - [Mouse controls](#-mouse-controls)
55
56
  - [Slash commands](#-slash-commands-in-the-input-box)
56
57
  - [Sending files and images](#-sending-files-and-images)
57
58
  - [Themes](#-themes)
@@ -452,6 +453,28 @@ node scripts/check-package.js # verifies that .env and the session do not leak
452
453
 
453
454
  ---
454
455
 
456
+ ## 🖱 Mouse controls
457
+
458
+ TuiGram fully supports mouse interaction in terminals that support mouse reporting (macOS Terminal, iTerm2, Alacritty, Kitty, Windows Terminal, Linux virtual terminals):
459
+
460
+ | Action | Target | Result |
461
+ |---|---|---|
462
+ | Click | Left / Middle / Bottom panel | Focus the clicked panel |
463
+ | Click | Chat item in dialog list | Immediately select and open the chat |
464
+ | Click | Filter tabs `1:Все` .. `6:Непроч` | Switch chat category filter |
465
+ | Click | Search bar `[/]` | Focus search box and clear placeholder |
466
+ | Wheel scroll | Dialog list / Message feed | Smooth scroll (scrolling up to the top loads older messages) |
467
+ | Click | Any message line | Open message action menu (reactions, reply, edit, download, delete) |
468
+ | Click | Header logo 🚀 | Open Help window |
469
+ | Click | Header active chat title | Open Chat Info window |
470
+ | Click | Header connection badge | Show active network status |
471
+ | Click | Input context bar | Cancel reply/edit mode or insert `/` |
472
+ | Click | Status bar items | Trigger corresponding action (`[Tab]`, `[F1]`, `[Ctrl+A]`, `[Ctrl+Q]`, etc.) |
473
+ | Click | Outside any modal window | Dismiss / close modal |
474
+ | Click | Buttons in modals | Click buttons (`[ Отправить ]`, `[ Отмена ]`, `[ Закрыть ]`, toggle checkboxes) |
475
+
476
+ ---
477
+
455
478
  ## 💬 Slash commands in the input box
456
479
 
457
480
  Quick commands are available in the message input box (they start with `/`):
package/README.ru.md CHANGED
@@ -48,6 +48,7 @@
48
48
  - [Где хранятся файлы](#-где-хранятся-файлы)
49
49
  - [Разработка](#-разработка)
50
50
  - [Горячие клавиши](#️-горячие-клавиши)
51
+ - [Управление мышью](#-управление-мышью)
51
52
  - [Слэш-команды](#-слэш-команды-в-поле-ввода)
52
53
  - [Отправка файлов и картинок](#-отправка-файлов-и-картинок)
53
54
  - [Темы оформления](#-темы-оформления)
@@ -444,6 +445,28 @@ node scripts/check-package.js # проверка, что в пакет не у
444
445
 
445
446
  ---
446
447
 
448
+ ## 🖱 Управление мышью
449
+
450
+ TuiGram полноценно поддерживает управление мышью во всех современных терминалах (Terminal.app, iTerm2, Alacritty, Kitty, Windows Terminal, xterm):
451
+
452
+ | Действие | Где | Результат |
453
+ |---|---|---|
454
+ | Клик | Левая / Центральная / Нижняя панель | Переключение фокуса на выбранную панель |
455
+ | Клик | Диалог в списке чатов | Мгновенный выбор и открытие чата |
456
+ | Клик | Вкладки `1:Все` .. `6:Непроч` | Переключение фильтра категории чатов |
457
+ | Клик | Поле поиска `[/]` | Фокус в поиск с очисткой подсказки |
458
+ | Колесо мыши | Список чатов / Лента сообщений | Плавная прокрутка (прокрутка вверх в ленте подгружает историю) |
459
+ | Клик | Любое сообщение | Открытие меню действий (реакции, ответ, редактирование, удаление) |
460
+ | Клик | Логотип 🚀 в шапке | Открытие окна справки |
461
+ | Клик | Название активного чата в шапке | Открытие информации о текущем чате |
462
+ | Клик | Индикатор статуса в шапке | Просмотр статуса MTProto подключения |
463
+ | Клик | Контекстная плашка ввода | Сброс режима ответа/редактирования или вставка `/` |
464
+ | Клик | Кнопки строки состояния | Быстрый вызов действий (`[Tab]`, `[F1]`, `[Ctrl+A]`, `[Ctrl+Q]` и др.) |
465
+ | Клик | Мимо любого модального окна | Закрытие модального окна |
466
+ | Клик | Кнопки в модалках | Нажатие кнопки (`[ Отправить ]`, `[ Отмена ]`, `[ Закрыть ]`, чекбоксы) |
467
+
468
+ ---
469
+
447
470
  ## 💬 Слэш-команды в поле ввода
448
471
 
449
472
  В поле ввода сообщения доступны быстрые команды (начинаются с `/`):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emaxe/tuigram",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Полнофункциональный TUI & CLI клиент Telegram на Node.js на базе MTProto (teleproto)",
5
5
  "type": "module",
6
6
  "exports": {
package/src/ui/app.js CHANGED
@@ -43,8 +43,79 @@ export async function startTui(client, me) {
43
43
  state.setConnectionStatus("connected");
44
44
 
45
45
  // 1. Создание визуальных компонентов
46
- const header = createHeader(screen, theme);
47
- const statusBar = createStatusBar(screen, theme);
46
+ const header = createHeader(screen, theme, {
47
+ onHelp: () => {
48
+ releaseInputs();
49
+ helpModal.show();
50
+ },
51
+ onChatInfo: () => {
52
+ if (state.activeChat) {
53
+ releaseInputs();
54
+ chatInfoModal.show(state.activeChat);
55
+ } else {
56
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
57
+ }
58
+ },
59
+ onStatusClick: () => {
60
+ const statusText = state.connectionStatus === "connected"
61
+ ? "Подключение активно: MTProto онлайн"
62
+ : `Статус сети: ${state.connectionStatus}`;
63
+ statusBar.showMessage(statusText, "info");
64
+ },
65
+ });
66
+
67
+ const statusBar = createStatusBar(screen, theme, {
68
+ onFocusNext: () => moveFocus(1),
69
+ onSelectOrSubmit: () => {
70
+ const focused = detectFocus();
71
+ if (focused === "input") {
72
+ inputBox.textarea.emit("key enter");
73
+ } else if (focused === "chatList") {
74
+ chatList.list.emit("key enter");
75
+ }
76
+ },
77
+ onFilterTabs: () => {
78
+ releaseInputs();
79
+ chatList.focus();
80
+ statusBar.showMessage("Фильтры: нажмите 1-6 для выбора вкладки", "info");
81
+ },
82
+ onSearch: () => {
83
+ chatList.searchBox.setValue("");
84
+ chatList.searchBox.focus();
85
+ screen.render();
86
+ },
87
+ onHelp: () => {
88
+ releaseInputs();
89
+ helpModal.show();
90
+ },
91
+ onAction: () => {
92
+ if (!state.activeChat) {
93
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
94
+ return;
95
+ }
96
+ const msgs = state.getMessages(state.activeChat.id);
97
+ if (msgs.length > 0) {
98
+ releaseInputs();
99
+ actionModal.show(msgs[msgs.length - 1]);
100
+ }
101
+ },
102
+ onChatInfo: () => {
103
+ if (state.activeChat) {
104
+ releaseInputs();
105
+ chatInfoModal.show(state.activeChat);
106
+ } else {
107
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
108
+ }
109
+ },
110
+ onQuit: () => {
111
+ releaseInputs();
112
+ confirmModal.ask("Выйти из TuiGram?", () => {
113
+ listener?.stop();
114
+ screen.destroy();
115
+ process.exit(0);
116
+ });
117
+ },
118
+ });
48
119
 
49
120
  // Модальные окна
50
121
  const helpModal = createHelpModal(screen, theme);
@@ -205,6 +276,21 @@ export async function startTui(client, me) {
205
276
  onCancelContext: () => {
206
277
  statusBar.showMessage("Режим ответа/редактирования сброшен", "info", 2000);
207
278
  },
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
+ },
208
294
  onSlashCommand: (cmd, args) => {
209
295
  switch (cmd) {
210
296
  case "help":
@@ -269,6 +355,33 @@ export async function startTui(client, me) {
269
355
  },
270
356
  });
271
357
 
358
+ // Переключение фокуса по клику мышью на любую из трех панелей
359
+ chatList.container.on("click", () => {
360
+ if (screen.focused !== chatList.list && screen.focused !== chatList.searchBox) {
361
+ releaseInputs();
362
+ chatList.focus();
363
+ statusBar.showMessage("Фокус: список чатов", "info", 2000);
364
+ screen.render();
365
+ }
366
+ });
367
+
368
+ chatView.container.on("click", () => {
369
+ if (screen.focused !== chatView.scrollBox) {
370
+ releaseInputs();
371
+ chatView.focus();
372
+ statusBar.showMessage("Фокус: лента сообщений", "info", 2000);
373
+ screen.render();
374
+ }
375
+ });
376
+
377
+ inputBox.container.on("click", () => {
378
+ if (screen.focused !== inputBox.textarea) {
379
+ inputBox.focus();
380
+ statusBar.showMessage("Фокус: поле ввода", "info", 2000);
381
+ screen.render();
382
+ }
383
+ });
384
+
272
385
  // 5. Реакция на события состояния
273
386
  state.on("dialogs_updated", (dialogs) => {
274
387
  chatList.setDialogs(dialogs);
@@ -4,6 +4,8 @@ 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";
8
+
7
9
  /**
8
10
  * Создаёт компонент списка диалогов (левая панель).
9
11
  * @param {blessed.Widgets.Screen} screen
@@ -20,6 +22,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
20
22
  left: 0,
21
23
  width: "35%",
22
24
  bottom: 1,
25
+ mouse: true,
23
26
  border: {
24
27
  type: "line",
25
28
  },
@@ -40,6 +43,8 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
40
43
  right: 0,
41
44
  height: 1,
42
45
  tags: true,
46
+ mouse: true,
47
+ clickable: true,
43
48
  style: {
44
49
  bg: theme.tabs.bg,
45
50
  fg: theme.tabs.fg,
@@ -64,6 +69,17 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
64
69
  tabsBox.setContent(rendered);
65
70
  }
66
71
 
72
+ tabsBox.on("click", (data) => {
73
+ const relX = data.x - (tabsBox.aleft || 0);
74
+ const tabKey = getTabByCoordinate(relX, TAB_KEYS, TAB_NAMES);
75
+ if (tabKey) {
76
+ currentTab = tabKey;
77
+ renderTabs();
78
+ onTabChange?.(currentTab);
79
+ screen.render();
80
+ }
81
+ });
82
+
67
83
  // 2. Строка поиска / фильтра
68
84
  const searchBox = blessed.textbox({
69
85
  parent: container,
@@ -72,6 +88,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
72
88
  right: 0,
73
89
  height: 1,
74
90
  inputOnFocus: true,
91
+ mouse: true,
75
92
  style: {
76
93
  bg: theme.search.bg,
77
94
  fg: theme.search.fg,
@@ -80,6 +97,14 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
80
97
  const SEARCH_PLACEHOLDER = "[/] Поиск чатов...";
81
98
  searchBox.setValue(SEARCH_PLACEHOLDER);
82
99
 
100
+ searchBox.on("click", () => {
101
+ if (searchBox.getValue() === SEARCH_PLACEHOLDER) {
102
+ searchBox.setValue("");
103
+ }
104
+ searchBox.focus();
105
+ screen.render();
106
+ });
107
+
83
108
  // 3. Список диалогов
84
109
  const list = blessed.list({
85
110
  parent: container,
@@ -118,13 +143,31 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
118
143
  // не влезающий хвост на вторую строку, которой в элементе высотой 1 просто
119
144
  // нет: хвост исчезал, а начатый перед разрывом фон бейджа непрочитанных
120
145
  // оставался залитым до края панели. С wrap: false лишнее просто обрезается.
146
+ // По клику мыши сразу выбираем и открываем диалог.
121
147
  const baseCreateItem = list.createItem.bind(list);
122
148
  list.createItem = (content) => {
123
149
  const item = baseCreateItem(content);
124
150
  item.wrap = false;
151
+ item.on("click", () => {
152
+ const index = list.getItemIndex(item);
153
+ if (index !== -1 && currentDialogs[index]) {
154
+ list.select(index);
155
+ onSelectDialog?.(currentDialogs[index]);
156
+ }
157
+ });
125
158
  return item;
126
159
  };
127
160
 
161
+ container.on("wheelup", () => {
162
+ list.select(list.selected - 2);
163
+ screen.render();
164
+ });
165
+
166
+ container.on("wheeldown", () => {
167
+ list.select(list.selected + 2);
168
+ screen.render();
169
+ });
170
+
128
171
  let currentDialogs = [];
129
172
 
130
173
  /**
@@ -275,6 +318,25 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
275
318
 
276
319
  renderTabs();
277
320
 
321
+ /** Подсвечивает рамку, когда список диалогов или строка поиска в фокусе. */
322
+ function setFocusHighlight(active) {
323
+ container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
324
+ screen.render();
325
+ }
326
+
327
+ list.on("focus", () => setFocusHighlight(true));
328
+ list.on("blur", (newTarget) => {
329
+ if (newTarget !== searchBox && screen.focused !== searchBox) {
330
+ setFocusHighlight(false);
331
+ }
332
+ });
333
+ searchBox.on("focus", () => setFocusHighlight(true));
334
+ searchBox.on("blur", (newTarget) => {
335
+ if (newTarget !== list && screen.focused !== list) {
336
+ setFocusHighlight(false);
337
+ }
338
+ });
339
+
278
340
  return {
279
341
  container,
280
342
  list,
@@ -3,6 +3,8 @@ 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";
7
+
6
8
  /**
7
9
  * Создаёт компонент просмотра сообщений чата (правая центральная панель).
8
10
  * @param {blessed.Widgets.Screen} screen
@@ -18,6 +20,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
18
20
  left: "35%",
19
21
  right: 0,
20
22
  bottom: 6,
23
+ mouse: true,
21
24
  border: {
22
25
  type: "line",
23
26
  },
@@ -54,26 +57,43 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
54
57
  },
55
58
  });
56
59
 
60
+ /** Подсвечивает рамку, когда лента сообщений в фокусе. */
61
+ function setFocusHighlight(active) {
62
+ container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
63
+ screen.render();
64
+ }
65
+
66
+ scrollBox.on("focus", () => setFocusHighlight(true));
67
+ scrollBox.on("blur", () => setFocusHighlight(false));
68
+
57
69
  let currentMessages = [];
70
+ let currentRanges = [];
58
71
 
59
72
  /**
60
- * Форматирует список сообщений в единую ленту текста с разметкой Blessed.
73
+ * Форматирует список сообщений в единую ленту текста с разметкой Blessed
74
+ * и вычисляет координаты строк каждого сообщения для кликов мыши.
61
75
  * @param {Array<object>} messages
62
- * @returns {string}
76
+ * @returns {{ text: string, ranges: Array<{ message: object, startLine: number, endLine: number }> }}
63
77
  */
64
- function renderMessages(messages) {
78
+ function renderMessagesWithRanges(messages) {
65
79
  if (!messages || messages.length === 0) {
66
- return `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`;
80
+ return {
81
+ text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
82
+ ranges: [],
83
+ };
67
84
  }
68
85
 
69
86
  let output = "";
70
87
  let lastDateString = "";
88
+ const ranges = [];
89
+ let lineCursor = 0;
71
90
 
72
91
  for (const msg of messages) {
73
92
  // Разделитель дат
74
93
  const dateStr = formatDateDivider(msg.date);
75
94
  if (dateStr && dateStr !== lastDateString) {
76
95
  output += `\n ${fg(theme.chatView.dateDivider, `─────── ${escapeBlessed(dateStr)} ───────`)}\n\n`;
96
+ lineCursor += 3;
77
97
  lastDateString = dateStr;
78
98
  }
79
99
 
@@ -92,8 +112,10 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
92
112
 
93
113
  // Блок ответа (Reply)
94
114
  let replyBlock = "";
115
+ let replyLines = 0;
95
116
  if (msg.replyToMsgId) {
96
117
  replyBlock = ` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}\n`;
118
+ replyLines = 1;
97
119
  }
98
120
 
99
121
  // Текст сообщения и entities
@@ -118,12 +140,15 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
118
140
  .split("\n")
119
141
  .map((line) => ` ${line}`)
120
142
  .join("\n");
143
+ const bodyLinesCount = indentedBody.split("\n").length;
121
144
 
122
145
  // Реакции
123
146
  let reactionsLine = "";
147
+ let reactionLinesCount = 0;
124
148
  if (msg.reactions && msg.reactions.length > 0) {
125
149
  const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
126
150
  reactionsLine = `\n ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`;
151
+ reactionLinesCount = 1;
127
152
  }
128
153
 
129
154
  // Метка редактирования
@@ -132,10 +157,17 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
132
157
  editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
133
158
  }
134
159
 
160
+ const startLine = lineCursor;
161
+ const totalMsgLines = 1 + replyLines + bodyLinesCount + reactionLinesCount;
162
+ const endLine = startLine + totalMsgLines - 1;
163
+
164
+ ranges.push({ message: msg, startLine, endLine });
165
+ lineCursor += totalMsgLines + 2;
166
+
135
167
  output += ` ${authorTag}${editedTag}\n${replyBlock}${indentedBody}${reactionsLine}\n\n`;
136
168
  }
137
169
 
138
- return output;
170
+ return { text: output, ranges };
139
171
  }
140
172
 
141
173
  /**
@@ -148,7 +180,9 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
148
180
  const prevScroll = scrollBox.getScroll();
149
181
  const prevHeight = scrollBox.getScrollHeight();
150
182
 
151
- scrollBox.setContent(renderMessages(messages));
183
+ const rendered = renderMessagesWithRanges(messages);
184
+ currentRanges = rendered.ranges;
185
+ scrollBox.setContent(rendered.text);
152
186
 
153
187
  if (autoScrollToBottom) {
154
188
  scrollBox.setScrollPerc(100);
@@ -195,8 +229,31 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
195
229
  });
196
230
 
197
231
  scrollBox.on("wheelup", () => {
198
- if (scrollBox.getScroll() <= 0) {
199
- onLoadMoreHistory?.();
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
+ });
246
+
247
+ 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 {
255
+ scrollBox.focus();
256
+ screen.render();
200
257
  }
201
258
  });
202
259
 
@@ -2,13 +2,21 @@ import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../telegram/formatter.js";
3
3
  import { fg } from "../theme.js";
4
4
 
5
+ import { getHeaderActionAt } from "../../utils/mouse.js";
6
+
5
7
  /**
6
8
  * Верхняя шапка приложения с информацией о пользователе, активном чате и статусе соединения.
7
9
  * @param {blessed.Widgets.Screen} screen
8
10
  * @param {object} theme
11
+ * @param {object} [callbacks]
12
+ * @param {() => void} [callbacks.onHelp]
13
+ * @param {() => void} [callbacks.onChatInfo]
14
+ * @param {() => void} [callbacks.onStatusClick]
9
15
  * @returns {blessed.Widgets.BoxElement & { updateInfo: (data: object) => void }}
10
16
  */
11
- export function createHeader(screen, theme) {
17
+ export function createHeader(screen, theme, { onHelp, onChatInfo, onStatusClick } = {}) {
18
+ let currentActiveChat = null;
19
+
12
20
  const headerBox = blessed.box({
13
21
  parent: screen,
14
22
  top: 0,
@@ -17,6 +25,7 @@ export function createHeader(screen, theme) {
17
25
  // 4 = рамка (2) + две строки контента (профиль + активный чат)
18
26
  height: 4,
19
27
  tags: true,
28
+ mouse: true,
20
29
  border: {
21
30
  type: "line",
22
31
  },
@@ -29,6 +38,19 @@ export function createHeader(screen, theme) {
29
38
  },
30
39
  });
31
40
 
41
+ headerBox.on("click", (data) => {
42
+ const relX = data.x - (headerBox.aleft || 0);
43
+ const relY = data.y - (headerBox.atop || 0);
44
+ const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
45
+ if (action === "help") {
46
+ onHelp?.();
47
+ } else if (action === "info") {
48
+ onChatInfo?.();
49
+ } else if (action === "status") {
50
+ onStatusClick?.();
51
+ }
52
+ });
53
+
32
54
  /**
33
55
  * Обновляет содержимое шапки.
34
56
  * @param {object} data
@@ -38,6 +60,7 @@ export function createHeader(screen, theme) {
38
60
  * @param {string|null} [data.typingUser]
39
61
  */
40
62
  headerBox.updateInfo = function ({ me, status = "connected", activeChat, typingUser }) {
63
+ currentActiveChat = activeChat;
41
64
  let statusBadge = fg(theme.status.online, "● В сети");
42
65
  if (status === "connecting") {
43
66
  statusBadge = fg(theme.status.connecting, "◌ Подключение...");