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.
- {sermo-0.2.0 → sermo-0.3.1}/PKG-INFO +16 -1
- {sermo-0.2.0 → sermo-0.3.1}/README.md +15 -0
- {sermo-0.2.0 → sermo-0.3.1}/pyproject.toml +1 -1
- {sermo-0.2.0 → sermo-0.3.1}/sermo.egg-info/PKG-INFO +16 -1
- {sermo-0.2.0 → sermo-0.3.1}/sermo.egg-info/SOURCES.txt +2 -1
- {sermo-0.2.0 → sermo-0.3.1}/sermo_cli/cli.py +13 -0
- sermo-0.3.1/sermo_cli/tui.py +362 -0
- {sermo-0.2.0 → sermo-0.3.1}/sermo.egg-info/dependency_links.txt +0 -0
- {sermo-0.2.0 → sermo-0.3.1}/sermo.egg-info/entry_points.txt +0 -0
- {sermo-0.2.0 → sermo-0.3.1}/sermo.egg-info/top_level.txt +0 -0
- {sermo-0.2.0 → sermo-0.3.1}/sermo_cli/__init__.py +0 -0
- {sermo-0.2.0 → sermo-0.3.1}/sermo_cli/__main__.py +0 -0
- {sermo-0.2.0 → sermo-0.3.1}/sermo_cli/api.py +0 -0
- {sermo-0.2.0 → sermo-0.3.1}/sermo_cli/formatting.py +0 -0
- {sermo-0.2.0 → sermo-0.3.1}/sermo_cli/store.py +0 -0
- {sermo-0.2.0 → sermo-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sermo
|
|
3
|
-
Version: 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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sermo
|
|
3
|
-
Version: 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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|