sermo 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sermo
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Command-line Sermo client
5
5
  Author: Sermo
6
6
  Project-URL: Homepage, https://sermo.jyonn.space
@@ -27,6 +27,7 @@ Sermo 的命令行客户端。它直接接入 `sermo-backend` API,适合在终
27
27
  - 支持进入空间并保存登录状态。
28
28
  - 支持查看当前账户、空间成员、聊天列表、好友列表、好友申请。
29
29
  - 支持查看聊天记录、发送文字消息、持续刷新新消息、监听全局新消息。
30
+ - 支持 `sermo tui` 打开终端聊天界面,会话列表、消息流和输入框集中展示。
30
31
  - 支持好友申请处理、好友邀请链接、私聊打开、群聊创建和成员管理。
31
32
  - 支持账号资料、联系方式绑定、通知偏好、空间创建和管理员基础操作。
32
33
  - 图片、视频、语音、文件等多媒体消息在终端中展示为 `[图片]`、`[视频]`、`[语音]`、`[文件]`;也支持上传发送图片、视频和语音文件。
@@ -91,6 +92,20 @@ sermo me
91
92
  sermo chats
92
93
  ```
93
94
 
95
+ 打开 TUI:
96
+
97
+ ```bash
98
+ sermo tui
99
+ ```
100
+
101
+ 在 TUI 里:
102
+
103
+ - `↑/↓` 切换会话。
104
+ - 直接输入文字,按 `Enter` 发送。
105
+ - `F5` 手动刷新。
106
+ - `Ctrl+U` 清空输入。
107
+ - `Ctrl+Q` 退出。
108
+
94
109
  打开聊天:
95
110
 
96
111
  ```bash
@@ -7,6 +7,7 @@ Sermo 的命令行客户端。它直接接入 `sermo-backend` API,适合在终
7
7
  - 支持进入空间并保存登录状态。
8
8
  - 支持查看当前账户、空间成员、聊天列表、好友列表、好友申请。
9
9
  - 支持查看聊天记录、发送文字消息、持续刷新新消息、监听全局新消息。
10
+ - 支持 `sermo tui` 打开终端聊天界面,会话列表、消息流和输入框集中展示。
10
11
  - 支持好友申请处理、好友邀请链接、私聊打开、群聊创建和成员管理。
11
12
  - 支持账号资料、联系方式绑定、通知偏好、空间创建和管理员基础操作。
12
13
  - 图片、视频、语音、文件等多媒体消息在终端中展示为 `[图片]`、`[视频]`、`[语音]`、`[文件]`;也支持上传发送图片、视频和语音文件。
@@ -71,6 +72,20 @@ sermo me
71
72
  sermo chats
72
73
  ```
73
74
 
75
+ 打开 TUI:
76
+
77
+ ```bash
78
+ sermo tui
79
+ ```
80
+
81
+ 在 TUI 里:
82
+
83
+ - `↑/↓` 切换会话。
84
+ - 直接输入文字,按 `Enter` 发送。
85
+ - `F5` 手动刷新。
86
+ - `Ctrl+U` 清空输入。
87
+ - `Ctrl+Q` 退出。
88
+
74
89
  打开聊天:
75
90
 
76
91
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sermo"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Command-line Sermo client"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sermo
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Command-line Sermo client
5
5
  Author: Sermo
6
6
  Project-URL: Homepage, https://sermo.jyonn.space
@@ -27,6 +27,7 @@ Sermo 的命令行客户端。它直接接入 `sermo-backend` API,适合在终
27
27
  - 支持进入空间并保存登录状态。
28
28
  - 支持查看当前账户、空间成员、聊天列表、好友列表、好友申请。
29
29
  - 支持查看聊天记录、发送文字消息、持续刷新新消息、监听全局新消息。
30
+ - 支持 `sermo tui` 打开终端聊天界面,会话列表、消息流和输入框集中展示。
30
31
  - 支持好友申请处理、好友邀请链接、私聊打开、群聊创建和成员管理。
31
32
  - 支持账号资料、联系方式绑定、通知偏好、空间创建和管理员基础操作。
32
33
  - 图片、视频、语音、文件等多媒体消息在终端中展示为 `[图片]`、`[视频]`、`[语音]`、`[文件]`;也支持上传发送图片、视频和语音文件。
@@ -91,6 +92,20 @@ sermo me
91
92
  sermo chats
92
93
  ```
93
94
 
95
+ 打开 TUI:
96
+
97
+ ```bash
98
+ sermo tui
99
+ ```
100
+
101
+ 在 TUI 里:
102
+
103
+ - `↑/↓` 切换会话。
104
+ - 直接输入文字,按 `Enter` 发送。
105
+ - `F5` 手动刷新。
106
+ - `Ctrl+U` 清空输入。
107
+ - `Ctrl+Q` 退出。
108
+
94
109
  打开聊天:
95
110
 
96
111
  ```bash
@@ -10,4 +10,5 @@ sermo_cli/__main__.py
10
10
  sermo_cli/api.py
11
11
  sermo_cli/cli.py
12
12
  sermo_cli/formatting.py
13
- sermo_cli/store.py
13
+ sermo_cli/store.py
14
+ sermo_cli/tui.py
@@ -247,6 +247,14 @@ def command_chat(args: argparse.Namespace) -> int:
247
247
  last_seen = max(last_seen, message.get("message_id") or last_seen)
248
248
 
249
249
 
250
+ def command_tui(args: argparse.Namespace) -> int:
251
+ client = build_client(args)
252
+ require_session(client)
253
+ from .tui import launch_tui
254
+
255
+ return launch_tui(client, refresh_interval=args.interval, limit=args.limit)
256
+
257
+
250
258
  def command_send(args: argparse.Namespace) -> int:
251
259
  client = build_client(args)
252
260
  session = require_session(client)
@@ -791,6 +799,11 @@ def build_parser() -> argparse.ArgumentParser:
791
799
  chat.add_argument("--no-mark-read", action="store_true", help="不标记已读")
792
800
  chat.set_defaults(func=command_chat)
793
801
 
802
+ tui = subparsers.add_parser("tui", help="打开终端聊天界面")
803
+ tui.add_argument("-n", "--limit", type=int, default=40, help="每个会话初始加载消息数")
804
+ tui.add_argument("--interval", type=float, default=5.0, help="自动刷新间隔秒数")
805
+ tui.set_defaults(func=command_tui)
806
+
794
807
  send = subparsers.add_parser("send", help="发送一条文字消息")
795
808
  send.add_argument("chat_id", type=int)
796
809
  send.add_argument("text", nargs="*")
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import curses
4
+ import time
5
+ import unicodedata
6
+ from typing import Any
7
+
8
+ from .api import ApiClient, SermoApiError
9
+ from .formatting import chat_title, format_time, message_text, user_name
10
+
11
+
12
+ def cell_width(char: str) -> int:
13
+ if not char:
14
+ return 0
15
+ if unicodedata.combining(char):
16
+ return 0
17
+ if unicodedata.east_asian_width(char) in {"F", "W"}:
18
+ return 2
19
+ return 1
20
+
21
+
22
+ def display_width(text: str) -> int:
23
+ return sum(cell_width(char) for char in str(text))
24
+
25
+
26
+ def clip_display(text: str, max_width: int) -> str:
27
+ if max_width <= 0:
28
+ return ""
29
+ used = 0
30
+ result: list[str] = []
31
+ for char in str(text).replace("\n", " "):
32
+ width = cell_width(char)
33
+ if used + width > max_width:
34
+ break
35
+ result.append(char)
36
+ used += width
37
+ return "".join(result)
38
+
39
+
40
+ def pad_display(text: str, width: int) -> str:
41
+ clipped = clip_display(text, width)
42
+ return clipped + " " * max(0, width - display_width(clipped))
43
+
44
+
45
+ def wrap_display(text: str, width: int) -> list[str]:
46
+ if width <= 0:
47
+ return [""]
48
+ lines: list[str] = []
49
+ current: list[str] = []
50
+ used = 0
51
+ for char in str(text).replace("\n", " "):
52
+ char_width = cell_width(char)
53
+ if used + char_width > width and current:
54
+ lines.append("".join(current))
55
+ current = []
56
+ used = 0
57
+ current.append(char)
58
+ used += char_width
59
+ lines.append("".join(current))
60
+ return lines or [""]
61
+
62
+
63
+ class SermoTui:
64
+ def __init__(self, client: ApiClient, *, refresh_interval: float = 5.0, limit: int = 40):
65
+ self.client = client
66
+ self.refresh_interval = max(1.0, refresh_interval)
67
+ self.limit = max(10, limit)
68
+ self.session = client.store.session() or {}
69
+ self.self_user_id = self.session.get("user", {}).get("user_id")
70
+ self.space = self.session.get("space") or {}
71
+ self.chats: list[dict[str, Any]] = []
72
+ self.messages_by_chat: dict[int, list[dict[str, Any]]] = {}
73
+ self.last_seen_by_chat: dict[int, int] = {}
74
+ self.selected_index = 0
75
+ self.input_line = ""
76
+ self.status = "正在连接 Sermo..."
77
+ self.next_refresh_at = 0.0
78
+ self.running = True
79
+
80
+ def run(self) -> int:
81
+ curses.wrapper(self._run)
82
+ return 0
83
+
84
+ def _run(self, stdscr: Any) -> None:
85
+ curses.curs_set(1)
86
+ curses.use_default_colors()
87
+ curses.start_color()
88
+ self._init_colors()
89
+ stdscr.keypad(True)
90
+ stdscr.timeout(100)
91
+
92
+ self._load_initial()
93
+ while self.running:
94
+ now = time.monotonic()
95
+ if now >= self.next_refresh_at:
96
+ self._refresh_current()
97
+ self.next_refresh_at = now + self.refresh_interval
98
+ self._draw(stdscr)
99
+ self._handle_key(stdscr.get_wch)
100
+
101
+ @staticmethod
102
+ def _init_colors() -> None:
103
+ curses.init_pair(1, curses.COLOR_CYAN, -1)
104
+ curses.init_pair(2, curses.COLOR_GREEN, -1)
105
+ curses.init_pair(3, curses.COLOR_RED, -1)
106
+ curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_CYAN)
107
+ curses.init_pair(5, curses.COLOR_BLUE, -1)
108
+
109
+ def _load_initial(self) -> None:
110
+ try:
111
+ self.chats = self._sorted_chats(self.client.chats())
112
+ if self.chats:
113
+ self.selected_index = min(self.selected_index, len(self.chats) - 1)
114
+ self._load_messages(self.current_chat_id)
115
+ self.status = "已同步"
116
+ else:
117
+ self.status = "暂无会话"
118
+ except SermoApiError as exc:
119
+ self.status = f"加载失败:{exc.message}"
120
+
121
+ def _sorted_chats(self, chats: list[dict[str, Any]]) -> list[dict[str, Any]]:
122
+ return sorted(chats, key=lambda chat: chat.get("last_chat_at") or 0, reverse=True)
123
+
124
+ @property
125
+ def current_chat(self) -> dict[str, Any] | None:
126
+ if not self.chats:
127
+ return None
128
+ return self.chats[min(self.selected_index, len(self.chats) - 1)]
129
+
130
+ @property
131
+ def current_chat_id(self) -> int | None:
132
+ chat = self.current_chat
133
+ if not chat:
134
+ return None
135
+ return int(chat["chat_id"])
136
+
137
+ def _load_messages(self, chat_id: int | None) -> None:
138
+ if not chat_id:
139
+ return
140
+ messages = self.client.messages(chat_id, limit=self.limit)
141
+ self.messages_by_chat[chat_id] = sorted(messages, key=lambda item: item.get("message_id") or 0)
142
+ self.last_seen_by_chat[chat_id] = max((item.get("message_id") or 0 for item in messages), default=0)
143
+ self._mark_read(chat_id)
144
+
145
+ def _refresh_current(self) -> None:
146
+ try:
147
+ previous_chat_id = self.current_chat_id
148
+ self.chats = self._sorted_chats(self.client.chats())
149
+ if not self.chats:
150
+ self.status = "暂无会话"
151
+ return
152
+ if previous_chat_id:
153
+ self.selected_index = next(
154
+ (index for index, chat in enumerate(self.chats) if int(chat["chat_id"]) == previous_chat_id),
155
+ min(self.selected_index, len(self.chats) - 1),
156
+ )
157
+ chat_id = self.current_chat_id
158
+ if chat_id not in self.messages_by_chat:
159
+ self._load_messages(chat_id)
160
+ else:
161
+ self._load_newer_messages(chat_id)
162
+ self.status = f"已同步 {format_time(time.time(), full=True)}"
163
+ except SermoApiError as exc:
164
+ self.status = f"同步失败:{exc.message}"
165
+
166
+ def _load_newer_messages(self, chat_id: int | None) -> None:
167
+ if not chat_id:
168
+ return
169
+ last_seen = self.last_seen_by_chat.get(chat_id, 0)
170
+ newer = self.client.messages(chat_id, limit=50, after=last_seen)
171
+ if not newer:
172
+ return
173
+ current = self.messages_by_chat.get(chat_id, [])
174
+ by_id = {item.get("message_id"): item for item in current if item.get("message_id")}
175
+ for message in newer:
176
+ if message.get("message_id"):
177
+ by_id[message["message_id"]] = message
178
+ merged = sorted(by_id.values(), key=lambda item: item.get("message_id") or 0)
179
+ self.messages_by_chat[chat_id] = merged[-200:]
180
+ self.last_seen_by_chat[chat_id] = max(item.get("message_id") or last_seen for item in newer)
181
+ self._mark_read(chat_id)
182
+
183
+ def _mark_read(self, chat_id: int) -> None:
184
+ try:
185
+ self.client.mark_read(chat_id)
186
+ except SermoApiError:
187
+ pass
188
+
189
+ def _send_current_message(self) -> None:
190
+ chat_id = self.current_chat_id
191
+ text = self.input_line.strip()
192
+ if not chat_id or not text:
193
+ return
194
+ self.input_line = ""
195
+ try:
196
+ message = self.client.send_text(chat_id, text)
197
+ current = self.messages_by_chat.get(chat_id, [])
198
+ self.messages_by_chat[chat_id] = [*current, message]
199
+ self.last_seen_by_chat[chat_id] = max(self.last_seen_by_chat.get(chat_id, 0), message.get("message_id") or 0)
200
+ self.status = "已发送"
201
+ self._refresh_current()
202
+ except SermoApiError as exc:
203
+ self.status = f"发送失败:{exc.message}"
204
+ self.input_line = text
205
+
206
+ def _select_chat(self, offset: int) -> None:
207
+ if not self.chats:
208
+ return
209
+ self.selected_index = min(len(self.chats) - 1, max(0, self.selected_index + offset))
210
+ chat_id = self.current_chat_id
211
+ if chat_id and chat_id not in self.messages_by_chat:
212
+ try:
213
+ self._load_messages(chat_id)
214
+ self.status = "已切换会话"
215
+ except SermoApiError as exc:
216
+ self.status = f"加载失败:{exc.message}"
217
+
218
+ def _handle_key(self, get_key: Any) -> None:
219
+ try:
220
+ key = get_key()
221
+ except curses.error:
222
+ return
223
+
224
+ if key in ("\x11", "\x03"):
225
+ self.running = False
226
+ return
227
+ if key == curses.KEY_F5:
228
+ self._refresh_current()
229
+ return
230
+ if key in (curses.KEY_UP, "\x10"):
231
+ self._select_chat(-1)
232
+ return
233
+ if key in (curses.KEY_DOWN, "\x0e"):
234
+ self._select_chat(1)
235
+ return
236
+ if key in ("\n", "\r", curses.KEY_ENTER):
237
+ self._send_current_message()
238
+ return
239
+ if key in (curses.KEY_BACKSPACE, "\b", "\x7f"):
240
+ self.input_line = self.input_line[:-1]
241
+ return
242
+ if key == "\x15":
243
+ self.input_line = ""
244
+ return
245
+ if isinstance(key, str) and key.isprintable():
246
+ self.input_line += key
247
+
248
+ def _draw(self, stdscr: Any) -> None:
249
+ stdscr.erase()
250
+ height, width = stdscr.getmaxyx()
251
+ if height < 10 or width < 50:
252
+ self._add(stdscr, 0, 0, "窗口太小,请放大终端。", curses.color_pair(3), width)
253
+ stdscr.refresh()
254
+ return
255
+
256
+ left_width = min(36, max(24, width // 3))
257
+ main_x = left_width + 1
258
+ input_y = height - 2
259
+ help_y = height - 1
260
+ content_height = height - 4
261
+
262
+ title = f"Sermo TUI · {self.space.get('name') or 'Sermo'} @{self.space.get('slug') or '-'}"
263
+ self._add(stdscr, 0, 0, title, curses.A_BOLD | curses.color_pair(1), width)
264
+ self._add(stdscr, 0, max(0, width - display_width(self.status) - 1), self.status, curses.color_pair(5), width)
265
+ self._draw_separator(stdscr, 1, width)
266
+ self._draw_chats(stdscr, 2, left_width, content_height)
267
+ self._draw_vertical_separator(stdscr, 2, left_width, height - 3)
268
+ self._draw_messages(stdscr, 2, main_x, width - main_x, content_height)
269
+ self._draw_input(stdscr, input_y, main_x, width - main_x)
270
+ help_text = "↑/↓ 切换会话 · Enter 发送 · F5 刷新 · Ctrl+U 清空输入 · Ctrl+Q 退出"
271
+ self._add(stdscr, help_y, 0, help_text, curses.A_DIM, width)
272
+ stdscr.move(input_y, min(width - 1, main_x + 2 + display_width(self.input_line)))
273
+ stdscr.refresh()
274
+
275
+ def _draw_chats(self, stdscr: Any, top: int, width: int, height: int) -> None:
276
+ self._add(stdscr, top - 1, 0, "会话", curses.A_BOLD, width)
277
+ if not self.chats:
278
+ self._add(stdscr, top, 1, "暂无会话", curses.A_DIM, width)
279
+ return
280
+ visible_count = max(1, height // 2)
281
+ start = max(0, min(self.selected_index - visible_count + 1, len(self.chats) - visible_count))
282
+ visible = self.chats[start:start + visible_count]
283
+ for row, chat in enumerate(visible):
284
+ index = start + row
285
+ y = top + row * 2
286
+ selected = index == self.selected_index
287
+ attrs = curses.color_pair(4) | curses.A_BOLD if selected else curses.A_NORMAL
288
+ unread = chat.get("unread_count") or 0
289
+ badge = f" +{unread}" if unread else ""
290
+ title = chat_title(chat, self.self_user_id)
291
+ preview = message_text(chat.get("last_message") or {}) if chat.get("last_message") else "暂无消息"
292
+ line = f"{title}{badge}"
293
+ sub = f" {preview}"
294
+ self._add(stdscr, y, 0, line, attrs, width)
295
+ if y + 1 < top + height:
296
+ self._add(stdscr, y + 1, 0, sub, attrs if selected else curses.A_DIM, width)
297
+
298
+ def _draw_messages(self, stdscr: Any, top: int, x: int, width: int, height: int) -> None:
299
+ chat = self.current_chat
300
+ if not chat:
301
+ self._add(stdscr, top, x, "选择一个会话开始聊天。", curses.A_DIM, width)
302
+ return
303
+
304
+ self._add(stdscr, top - 1, x, chat_title(chat, self.self_user_id), curses.A_BOLD, width)
305
+ chat_id = int(chat["chat_id"])
306
+ messages = self.messages_by_chat.get(chat_id, [])
307
+ lines = self._message_lines(messages, width)
308
+ visible = lines[-height:]
309
+ y = top
310
+ for line, attrs in visible:
311
+ self._add(stdscr, y, x, line, attrs, width)
312
+ y += 1
313
+
314
+ def _message_lines(self, messages: list[dict[str, Any]], width: int) -> list[tuple[str, int]]:
315
+ if not messages:
316
+ return [("暂无消息。", curses.A_DIM)]
317
+ lines: list[tuple[str, int]] = []
318
+ wrap_width = max(20, width - 2)
319
+ for message in messages:
320
+ is_self = message.get("user", {}).get("user_id") == self.self_user_id
321
+ author = "我" if is_self else user_name(message.get("user"))
322
+ text = message_text(message)
323
+ prefix = f"{format_time(message.get('created_at'))} {author}: "
324
+ attrs = curses.color_pair(2) if is_self else curses.A_NORMAL
325
+ prefix_width = display_width(prefix)
326
+ wrapped = wrap_display(text, width=max(8, wrap_width - prefix_width))
327
+ lines.append((prefix + wrapped[0], attrs))
328
+ for extra in wrapped[1:]:
329
+ lines.append((" " * min(prefix_width, wrap_width - 1) + extra, attrs))
330
+ return lines
331
+
332
+ def _draw_input(self, stdscr: Any, y: int, x: int, width: int) -> None:
333
+ self._draw_separator(stdscr, y - 1, stdscr.getmaxyx()[1])
334
+ text = f"> {self.input_line}"
335
+ self._add(stdscr, y, x, text, curses.A_BOLD, width)
336
+
337
+ def _draw_separator(self, stdscr: Any, y: int, width: int) -> None:
338
+ self._add(stdscr, y, 0, "─" * max(1, width - 1), curses.A_DIM, width)
339
+
340
+ def _draw_vertical_separator(self, stdscr: Any, top: int, x: int, bottom: int) -> None:
341
+ for y in range(top, bottom):
342
+ self._add(stdscr, y, x, "│", curses.A_DIM, 1)
343
+
344
+ @staticmethod
345
+ def _add(stdscr: Any, y: int, x: int, text: str, attrs: int = 0, width: int | None = None) -> None:
346
+ if y < 0 or x < 0:
347
+ return
348
+ max_y, max_x = stdscr.getmaxyx()
349
+ if y >= max_y or x >= max_x:
350
+ return
351
+ max_width = max(0, min(width or max_x - x, max_x - x))
352
+ if max_width <= 0:
353
+ return
354
+ safe = pad_display(str(text), max_width)
355
+ try:
356
+ stdscr.addstr(y, x, safe, attrs)
357
+ except curses.error:
358
+ pass
359
+
360
+
361
+ def launch_tui(client: ApiClient, *, refresh_interval: float = 5.0, limit: int = 40) -> int:
362
+ return SermoTui(client, refresh_interval=refresh_interval, limit=limit).run()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes