Improve TUI pager and search ergonomics

This commit is contained in:
m1ngsama 2026-05-27 19:24:55 +08:00
parent 1c451b7722
commit 797ecbb992
9 changed files with 380 additions and 89 deletions

View file

@ -28,6 +28,8 @@
- Added a VHS tape draft for recording the core TNT terminal-chat experience. - Added a VHS tape draft for recording the core TNT terminal-chat experience.
- Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and - Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and
an open inbox refreshes when a new private message arrives. an open inbox refreshes when a new private message arrives.
- Added `/` in NORMAL mode as a fast history-search entrypoint backed by the
existing `:search` command.
- Added `make slow-client-test`, an opt-in regression for an unread - Added `make slow-client-test`, an opt-in regression for an unread
interactive SSH client under backpressure while health, stats, post, tail, interactive SSH client under backpressure while health, stats, post, tail,
and server survival stay responsive. and server survival stay responsive.
@ -73,6 +75,12 @@
contract. contract.
- The two-user lifecycle test now covers opening `:inbox` before a private - The two-user lifecycle test now covers opening `:inbox` before a private
message arrives, matching the way users often leave an inbox page open. message arrives, matching the way users often leave an inbox page open.
- Help and command-output pagers now accept arrow keys, PgUp/PgDn, Home/End,
and Space/`b` in addition to the existing Vim-style keys.
- Pre-login username entry now handles Ctrl+C/Ctrl+D cancel, Ctrl+U clear
line, and Ctrl+W delete-word before the user joins the room.
- Long COMMAND-mode input is now left-truncated with a visible marker in the
status line instead of wrapping and damaging the TUI.
- Private-message inbox access now uses its own mutex instead of sharing the - Private-message inbox access now uses its own mutex instead of sharing the
SSH channel write lock, reducing unrelated contention on slow clients. SSH channel write lock, reducing unrelated contention on slow clients.
- Client writes now check the SSH channel's remote window before writing and - Client writes now check the SSH channel's remote window before writing and

View file

@ -64,7 +64,10 @@ Esc enter NORMAL mode
i return to INSERT mode i return to INSERT mode
: enter COMMAND mode : enter COMMAND mode
? open the full key reference ? open the full key reference
/ search message history
G or End jump to latest messages G or End jump to latest messages
Up/Down recall sent messages in INSERT mode
Tab complete @mention in INSERT mode
Ctrl+C disconnect from NORMAL mode Ctrl+C disconnect from NORMAL mode
``` ```
@ -209,7 +212,10 @@ Esc 进入 NORMAL 模式
i 回到 INSERT 模式 i 回到 INSERT 模式
: 输入命令 : 输入命令
? 查看完整按键参考 ? 查看完整按键参考
/ 搜索消息历史
G 或 End 回到最新消息 G 或 End 回到最新消息
Up/Down 在 INSERT 模式调出已发送消息
Tab 在 INSERT 模式补全 @mention
:help 查看简明手册 :help 查看简明手册
:lang en|zh 切换界面语言 :lang en|zh 切换界面语言
:q 断开连接 :q 断开连接

View file

@ -11,8 +11,9 @@ The product path should stay short:
4. User lands in INSERT mode at the live tail and can type immediately. 4. User lands in INSERT mode at the live tail and can type immediately.
5. User presses Esc to browse history with Vim-style movement. 5. User presses Esc to browse history with Vim-style movement.
6. User uses `:help` for the concise manual or `?` for the full key reference. 6. User uses `:help` for the concise manual or `?` for the full key reference.
7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`, 7. User searches from NORMAL with `/term`, or uses commands when needed:
`:search`, `:nick`, `:mute-joins`, and `:q`. `:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`,
and `:q`.
8. Scripts and operators use `tntctl` or SSH exec commands for `health`, 8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
`stats`, `users`, `tail`, `dump`, and `post`. `stats`, `users`, `tail`, `dump`, and `post`.
@ -23,6 +24,10 @@ The product path should stay short:
- INSERT mode is the default because most users arrive to send a message. - INSERT mode is the default because most users arrive to send a message.
- NORMAL mode opens at the latest messages, not the oldest history. Users can - NORMAL mode opens at the latest messages, not the oldest history. Users can
move upward for older context and use `G` or End to return to live chat. move upward for older context and use `G` or End to return to live chat.
- NORMAL mode accepts `/` as the fast path for history search, matching a
common terminal-reader habit while reusing the existing `:search` command.
- INSERT mode keeps a small per-session sent-message history on Up/Down and
completes trailing `@mention` prefixes with Tab.
- `:help` is a compact manual, while `?` is a full key reference. Do not add - `:help` is a compact manual, while `?` is a full key reference. Do not add
parallel support commands for the same task. parallel support commands for the same task.
- Command syntax stays ASCII even in localized UI text. Translations explain; - Command syntax stays ASCII even in localized UI text. Translations explain;

View file

@ -19,6 +19,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" Backspace - Delete character\n" " Backspace - Delete character\n"
" Ctrl+W - Delete last word\n" " Ctrl+W - Delete last word\n"
" Ctrl+U - Delete line\n" " Ctrl+U - Delete line\n"
" Up/Down - Recall sent messages\n"
" Tab - Complete @mention\n"
" Ctrl+C - Enter NORMAL mode\n" " Ctrl+C - Enter NORMAL mode\n"
"\n" "\n"
"NORMAL MODE KEYS:\n" "NORMAL MODE KEYS:\n"
@ -26,6 +28,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" Follows latest until you scroll up\n" " Follows latest until you scroll up\n"
" i - Return to INSERT mode\n" " i - Return to INSERT mode\n"
" : - Enter COMMAND mode\n" " : - Enter COMMAND mode\n"
" / - Search message history\n"
" j/k - Scroll down/up one line\n" " j/k - Scroll down/up one line\n"
" Ctrl+D/U - Scroll half page down/up\n" " Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n" " Ctrl+F/B - Scroll full page down/up\n"
@ -49,6 +52,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" Backspace - 删除字符\n" " Backspace - 删除字符\n"
" Ctrl+W - 删除上个单词\n" " Ctrl+W - 删除上个单词\n"
" Ctrl+U - 删除整行\n" " Ctrl+U - 删除整行\n"
" Up/Down - 调出已发送消息\n"
" Tab - 补全 @mention\n"
" Ctrl+C - 进入 NORMAL 模式\n" " Ctrl+C - 进入 NORMAL 模式\n"
"\n" "\n"
"NORMAL 模式按键:\n" "NORMAL 模式按键:\n"
@ -56,6 +61,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" 未向上翻阅时自动跟随最新消息\n" " 未向上翻阅时自动跟随最新消息\n"
" i - 返回 INSERT 模式\n" " i - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n" " : - 进入 COMMAND 模式\n"
" / - 搜索消息历史\n"
" j/k - 向下/上滚动一行\n" " j/k - 向下/上滚动一行\n"
" Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n" " Ctrl+F/B - 向下/上滚动整页\n"
@ -71,9 +77,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"\n" "\n"
"COMMAND OUTPUT KEYS:\n" "COMMAND OUTPUT KEYS:\n"
" q, ESC - Close output\n" " q, ESC - Close output\n"
" j/k - Scroll down/up\n" " j/k, arrows - Scroll down/up\n"
" Ctrl+D/U - Scroll half page down/up\n" " Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n" " Ctrl+F/B - Scroll full page down/up\n"
" Space/b - Scroll full page down/up\n"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n" " g/G - Jump to top/bottom\n"
" r - Refresh live output (:inbox)\n" " r - Refresh live output (:inbox)\n"
"\n" "\n"
@ -83,17 +92,23 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"\n" "\n"
"HELP SCREEN KEYS:\n" "HELP SCREEN KEYS:\n"
" q, ESC - Close help\n" " q, ESC - Close help\n"
" j/k - Scroll down/up\n" " j/k, arrows - Scroll down/up\n"
" Ctrl+D/U - Scroll half page down/up\n" " Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n" " Ctrl+F/B - Scroll full page down/up\n"
" Space/b - Scroll full page down/up\n"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n" " g/G - Jump to top/bottom\n"
" l - Cycle UI language\n", " l - Cycle UI language\n",
"\n" "\n"
"命令输出按键:\n" "命令输出按键:\n"
" q, ESC - 关闭输出\n" " q, ESC - 关闭输出\n"
" j/k - 向下/上滚动\n" " j/k, arrows - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n" " Ctrl+F/B - 向下/上滚动整页\n"
" Space/b - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n" " g/G - 跳到顶部/底部\n"
" r - 刷新动态输出 (:inbox)\n" " r - 刷新动态输出 (:inbox)\n"
"\n" "\n"
@ -103,9 +118,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"\n" "\n"
"帮助界面按键:\n" "帮助界面按键:\n"
" q, ESC - 关闭帮助\n" " q, ESC - 关闭帮助\n"
" j/k - 向下/上滚动\n" " j/k, arrows - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n" " Ctrl+F/B - 向下/上滚动整页\n"
" Space/b - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n" " g/G - 跳到顶部/底部\n"
" l - 切换界面语言\n" " l - 切换界面语言\n"
); );

View file

@ -32,10 +32,10 @@ static int read_username(client_t *client) {
char username[MAX_USERNAME_LEN] = {0}; char username[MAX_USERNAME_LEN] = {0};
int pos = 0; int pos = 0;
char buf[4]; char buf[4];
const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT);
tui_render_welcome(client); tui_render_welcome(client);
client_printf(client, "%s", i18n_text(client->ui_lang, client_printf(client, "%s", prompt);
I18N_USERNAME_PROMPT));
while (1) { while (1) {
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */ int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
@ -54,6 +54,18 @@ static int read_username(client_t *client) {
if (b == '\r' || b == '\n') { if (b == '\r' || b == '\n') {
break; break;
} else if (b == 3 || b == 4) { /* Ctrl+C / Ctrl+D */
return -1;
} else if (b == 21) { /* Ctrl+U: clear line */
username[0] = '\0';
pos = 0;
client_printf(client, "\r\033[K%s", prompt);
} else if (b == 23) { /* Ctrl+W: delete word */
if (username[0] != '\0') {
utf8_remove_last_word(username);
pos = (int)strlen(username);
client_printf(client, "\r\033[K%s%s", prompt, username);
}
} else if (b == 127 || b == 8) { /* Backspace */ } else if (b == 127 || b == 8) { /* Backspace */
if (pos > 0) { if (pos > 0) {
/* Compute width of the last character before removing it */ /* Compute width of the last character before removing it */
@ -230,6 +242,110 @@ static void dismiss_command_output(client_t *client) {
tui_render_screen(client); tui_render_screen(client);
} }
typedef enum {
PAGER_ACTION_NONE,
PAGER_ACTION_SCROLL,
PAGER_ACTION_CLOSE,
PAGER_ACTION_REFRESH
} pager_action_t;
static int pager_page_height(client_t *client) {
int page = client->height - 2;
if (page < 1) page = 1;
return page;
}
static void pager_scroll_by(int *scroll_pos, int delta) {
*scroll_pos += delta;
if (*scroll_pos < 0) {
*scroll_pos = 0;
}
}
static pager_action_t pager_apply_key(client_t *client, unsigned char key,
int *scroll_pos, bool allow_refresh) {
int page = pager_page_height(client);
int half = page / 2;
if (half < 1) half = 1;
if (key == 'q') {
return PAGER_ACTION_CLOSE;
} else if (key == 'j') {
pager_scroll_by(scroll_pos, 1);
return PAGER_ACTION_SCROLL;
} else if (key == 'k') {
pager_scroll_by(scroll_pos, -1);
return PAGER_ACTION_SCROLL;
} else if (key == 4) { /* Ctrl+D: half page down */
pager_scroll_by(scroll_pos, half);
return PAGER_ACTION_SCROLL;
} else if (key == 21) { /* Ctrl+U: half page up */
pager_scroll_by(scroll_pos, -half);
return PAGER_ACTION_SCROLL;
} else if (key == 6 || key == ' ') { /* Ctrl+F / Space: page down */
pager_scroll_by(scroll_pos, page);
return PAGER_ACTION_SCROLL;
} else if (key == 2 || key == 'b') { /* Ctrl+B / b: page up */
pager_scroll_by(scroll_pos, -page);
return PAGER_ACTION_SCROLL;
} else if (key == 'g') {
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (key == 'G') {
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
} else if ((key == 'r' || key == 'R') && allow_refresh) {
return PAGER_ACTION_REFRESH;
} else if (key == 27) {
char seq[3];
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
if (n != 1) {
return PAGER_ACTION_CLOSE;
}
if (seq[0] != '[') {
return PAGER_ACTION_NONE;
}
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
if (n != 1) {
return PAGER_ACTION_NONE;
}
if (seq[1] == 'A') { /* Up arrow */
pager_scroll_by(scroll_pos, -1);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'B') { /* Down arrow */
pager_scroll_by(scroll_pos, 1);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'H') { /* Home */
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'F') { /* End */
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
} else if (seq[1] >= '1' && seq[1] <= '6') {
n = ssh_channel_read_timeout(client->channel, &seq[2], 1, 0, 50);
if (n == 1 && seq[2] == '~') {
if (seq[1] == '5') { /* PageUp */
pager_scroll_by(scroll_pos, -page);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '6') { /* PageDown */
pager_scroll_by(scroll_pos, page);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '1') { /* Home */
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '4') { /* End */
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
}
}
}
}
return PAGER_ACTION_NONE;
}
/* Handle a single key press. Returns true if the key was fully consumed /* Handle a single key press. Returns true if the key was fully consumed
* (no further character buffering needed). */ * (no further character buffering needed). */
static bool handle_key(client_t *client, unsigned char key, char *input) { static bool handle_key(client_t *client, unsigned char key, char *input) {
@ -257,44 +373,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle help screen */ /* Handle help screen */
if (client->show_help) { if (client->show_help) {
/* Page size: roughly the visible help body region. */ pager_action_t action;
int page = client->height - 2;
if (page < 1) page = 1;
int half = page / 2;
if (half < 1) half = 1;
if (key == 'q' || key == 27) { if (key == 'l' || key == 'L') {
client->show_help = false;
tui_render_screen(client);
} else if (key == 'l' || key == 'L') {
client->ui_lang = i18n_next_ui_lang(client->ui_lang); client->ui_lang = i18n_next_ui_lang(client->ui_lang);
client->help_scroll_pos = 0; client->help_scroll_pos = 0;
tui_render_help(client); tui_render_help(client);
} else if (key == 'j') { return true;
client->help_scroll_pos++; }
tui_render_help(client);
} else if (key == 'k' && client->help_scroll_pos > 0) { action = pager_apply_key(client, key, &client->help_scroll_pos, false);
client->help_scroll_pos--; if (action == PAGER_ACTION_CLOSE) {
tui_render_help(client); client->show_help = false;
} else if (key == 4) { /* Ctrl+D: half page down */ tui_render_screen(client);
client->help_scroll_pos += half; } else if (action == PAGER_ACTION_SCROLL) {
tui_render_help(client);
} else if (key == 21) { /* Ctrl+U: half page up */
client->help_scroll_pos -= half;
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 6) { /* Ctrl+F: full page down */
client->help_scroll_pos += page;
tui_render_help(client);
} else if (key == 2) { /* Ctrl+B: full page up */
client->help_scroll_pos -= page;
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'g') {
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'G') {
client->help_scroll_pos = 999; /* Large number */
tui_render_help(client); tui_render_help(client);
} }
return true; /* Key consumed */ return true; /* Key consumed */
@ -303,56 +395,23 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle command output / MOTD display. MOTD remains a simple notice; /* Handle command output / MOTD display. MOTD remains a simple notice;
* command output behaves like a small pager so long results can be read. */ * command output behaves like a small pager so long results can be read. */
if (client->command_output[0] != '\0') { if (client->command_output[0] != '\0') {
int page = client->height - 2; pager_action_t action;
int half;
if (client->show_motd) { if (client->show_motd) {
dismiss_command_output(client); dismiss_command_output(client);
return true; return true;
} }
if (page < 1) page = 1; action = pager_apply_key(client, key, &client->command_output_scroll,
half = page / 2; true);
if (half < 1) half = 1; if (action == PAGER_ACTION_CLOSE) {
if (key == 'q' || key == 27) {
dismiss_command_output(client); dismiss_command_output(client);
} else if (key == 'j') { } else if (action == PAGER_ACTION_SCROLL) {
client->command_output_scroll++;
tui_render_command_output(client); tui_render_command_output(client);
} else if (key == 'k') { } else if (action == PAGER_ACTION_REFRESH) {
client->command_output_scroll--; if (commands_refresh_active_output(client)) {
if (client->command_output_scroll < 0) { tui_render_command_output(client);
client->command_output_scroll = 0;
} }
tui_render_command_output(client);
} else if (key == 4) { /* Ctrl+D: half page down */
client->command_output_scroll += half;
tui_render_command_output(client);
} else if (key == 21) { /* Ctrl+U: half page up */
client->command_output_scroll -= half;
if (client->command_output_scroll < 0) {
client->command_output_scroll = 0;
}
tui_render_command_output(client);
} else if (key == 6) { /* Ctrl+F: full page down */
client->command_output_scroll += page;
tui_render_command_output(client);
} else if (key == 2) { /* Ctrl+B: full page up */
client->command_output_scroll -= page;
if (client->command_output_scroll < 0) {
client->command_output_scroll = 0;
}
tui_render_command_output(client);
} else if (key == 'g') {
client->command_output_scroll = 0;
tui_render_command_output(client);
} else if (key == 'G') {
client->command_output_scroll = 999;
tui_render_command_output(client);
} else if ((key == 'r' || key == 'R') &&
commands_refresh_active_output(client)) {
tui_render_command_output(client);
} }
return true; /* Key consumed */ return true; /* Key consumed */
} }
@ -571,6 +630,12 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
client->command_input[0] = '\0'; client->command_input[0] = '\0';
tui_render_screen(client); tui_render_screen(client);
return true; return true;
} else if (key == '/') {
client->mode = MODE_COMMAND;
snprintf(client->command_input, sizeof(client->command_input),
"search ");
tui_render_screen(client);
return true;
} else if (key == 'j') { } else if (key == 'j') {
normal_scroll_by(client, 1); normal_scroll_by(client, 1);
tui_render_screen(client); tui_render_screen(client);
@ -953,6 +1018,8 @@ main_loop:
client->command_input[len] = b; client->command_input[len] = b;
client->command_input[len + 1] = '\0'; client->command_input[len + 1] = '\0';
tui_render_screen(client); tui_render_screen(client);
} else {
client_send(client, "\a", 1);
} }
} else if (b >= 128) { /* UTF-8 multi-byte */ } else if (b >= 128) { /* UTF-8 multi-byte */
int char_len = utf8_byte_length(b); int char_len = utf8_byte_length(b);
@ -965,10 +1032,12 @@ main_loop:
} }
if (!utf8_is_valid_sequence(buf, char_len)) continue; if (!utf8_is_valid_sequence(buf, char_len)) continue;
size_t len = strlen(client->command_input); size_t len = strlen(client->command_input);
if (len + (size_t)char_len < sizeof(client->command_input) - 1) { if (len + (size_t)char_len <= sizeof(client->command_input) - 1) {
memcpy(client->command_input + len, buf, char_len); memcpy(client->command_input + len, buf, char_len);
client->command_input[len + char_len] = '\0'; client->command_input[len + char_len] = '\0';
tui_render_screen(client); tui_render_screen(client);
} else {
client_send(client, "\a", 1);
} }
} }
} }

View file

@ -12,8 +12,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
" TNT - SSH terminal chat room\n" " TNT - SSH terminal chat room\n"
"\n" "\n"
"\033[1;37mUse\033[0m\n" "\033[1;37mUse\033[0m\n"
" Type a message and press Enter; Esc browses; G latest; i types\n" " Type, Enter sends; Up/Down recalls; Tab completes @mentions\n"
" : runs commands; ? opens the full key reference\n" " Esc browses; / searches; G latest; i types; : commands; ? keys\n"
"\n" "\n"
"\033[1;37mCommands\033[0m\n", "\033[1;37mCommands\033[0m\n",
"\033[1;36mTNT(1) 帮助\033[0m\n" "\033[1;36mTNT(1) 帮助\033[0m\n"
@ -22,8 +22,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
" TNT - SSH 终端聊天室\n" " TNT - SSH 终端聊天室\n"
"\n" "\n"
"\033[1;37m使用\033[0m\n" "\033[1;37m使用\033[0m\n"
" 输入消息并 Enter 发送Esc 浏览历史G 最新i 输入\n" " 输入并 Enter 发送Up/Down 调出消息Tab 补全 @mention\n"
" : 运行命令;? 打开完整按键参考\n" " Esc 浏览;/ 搜索G 最新i 输入;: 命令;? 按键\n"
"\n" "\n"
"\033[1;37m命令\033[0m\n" "\033[1;37m命令\033[0m\n"
); );

View file

@ -1,6 +1,54 @@
#include "tui_status.h" #include "tui_status.h"
#include "i18n.h" #include "i18n.h"
#include "ssh_server.h" #include "ssh_server.h"
#include "utf8.h"
static void format_command_input_tail(const char *input, int avail_width,
char *display, size_t display_size) {
if (!input || !display || display_size == 0) return;
display[0] = '\0';
if (avail_width < 1) {
return;
}
if (utf8_string_width(input) <= avail_width) {
strncpy(display, input, display_size - 1);
display[display_size - 1] = '\0';
return;
}
const char *marker = "<";
int marker_width = 1;
int tail_width = avail_width - marker_width;
if (tail_width < 1) {
snprintf(display, display_size, "%s", marker);
return;
}
const char *p = input + strlen(input);
const char *tail = p;
int width = 0;
while (p > input && width < tail_width) {
const char *q = p - 1;
while (q > input && ((*q & 0xC0) == 0x80)) {
q--;
}
int bytes_read = 0;
uint32_t cp = utf8_decode(q, &bytes_read);
int char_width = utf8_char_width(cp);
if (width + char_width > tail_width) {
break;
}
width += char_width;
tail = q;
p = q;
}
snprintf(display, display_size, "%s%s", marker, tail);
}
void tui_status_append(char *buffer, size_t buf_size, size_t *pos, void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
const struct client *client, int msg_count, const struct client *client, int msg_count,
@ -48,7 +96,12 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
i18n_text(client->ui_lang, I18N_NORMAL_LATEST)); i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
} }
} else if (client->mode == MODE_COMMAND) { } else if (client->mode == MODE_COMMAND) {
char display[sizeof(client->command_input) + 2];
int avail = client->width - 1;
if (avail < 1) avail = 1;
format_command_input_tail(client->command_input, avail, display,
sizeof(display));
buffer_appendf(buffer, buf_size, pos, buffer_appendf(buffer, buf_size, pos,
"\033[35m:\033[0m%s\033[K", client->command_input); "\033[35m:\033[0m%s\033[K", display);
} }
} }

View file

@ -58,6 +58,51 @@ else
exit 1 exit 1
fi fi
USERNAME_CANCEL_SCRIPT="$STATE_DIR/username-cancel.expect"
cat >"$USERNAME_CANCEL_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "\003"
expect eof
EOF
if expect "$USERNAME_CANCEL_SCRIPT" >"$STATE_DIR/username-cancel.log" 2>&1; then
echo "✓ Ctrl+C cancels before username join"
PASS=$((PASS + 1))
else
echo "x Ctrl+C before username failed"
sed -n '1,120p' "$STATE_DIR/username-cancel.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
USERNAME_EDIT_SCRIPT="$STATE_DIR/username-edit.expect"
cat >"$USERNAME_EDIT_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "wrong\025editeduser\r"
expect ":help"
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$USERNAME_EDIT_SCRIPT" >"$STATE_DIR/username-edit.log" 2>&1 &&
grep -q 'editeduser' "$STATE_DIR/messages.log" &&
! grep -q 'wrongediteduser' "$STATE_DIR/messages.log"; then
echo "✓ Ctrl+U edits username before join"
PASS=$((PASS + 1))
else
echo "x username line editing failed"
sed -n '1,120p' "$STATE_DIR/username-edit.log" 2>/dev/null || true
cat "$STATE_DIR/messages.log" 2>/dev/null || true
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect" EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
cat >"$EXPECT_SCRIPT" <<EOF cat >"$EXPECT_SCRIPT" <<EOF
set timeout 10 set timeout 10
@ -146,14 +191,17 @@ send -- ":"
expect ":" expect ":"
send -- "help\r" send -- "help\r"
expect "TNT\\(1\\) 帮助" expect "TNT\\(1\\) 帮助"
expect "Tab 补全 @mention"
expect "q:关闭" expect "q:关闭"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
send -- "?" send -- "?"
expect "TNT 按键参考" expect "TNT 按键参考"
expect "Tab - 补全 @mention"
expect "l:语言" expect "l:语言"
send -- "l" send -- "l"
expect "TNT KEY REFERENCE" expect "TNT KEY REFERENCE"
expect "Complete @mention"
expect "l:lang" expect "l:lang"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
@ -180,6 +228,45 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
HELP_PAGER_KEYS_SCRIPT="$STATE_DIR/help-pager-keys.expect"
cat >"$HELP_PAGER_KEYS_SCRIPT" <<EOF
set timeout 10
stty rows 8 columns 80
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "helppager\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- "?"
expect -re {\(1/[2-9][0-9]*\)}
send -- "\033\[6~"
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[5~"
expect -re {\(1/[2-9][0-9]*\)}
send -- "\033\[F"
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[H"
expect -re {\(1/[2-9][0-9]*\)}
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$HELP_PAGER_KEYS_SCRIPT" >"$STATE_DIR/help-pager-keys.log" 2>&1; then
echo "✓ help pager accepts terminal paging keys"
PASS=$((PASS + 1))
else
echo "x help pager terminal keys failed"
sed -n '1,220p' "$STATE_DIR/help-pager-keys.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect" UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect"
cat >"$UNKNOWN_SCRIPT" <<EOF cat >"$UNKNOWN_SCRIPT" <<EOF
set timeout 10 set timeout 10
@ -371,6 +458,14 @@ expect "j/k:滚动"
expect -re {\(1/[2-9][0-9]*\)} expect -re {\(1/[2-9][0-9]*\)}
send -- "j" send -- "j"
expect -re {\(2/[2-9][0-9]*\)} expect -re {\(2/[2-9][0-9]*\)}
send -- "\033\[6~"
expect -re {\([3-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[5~"
expect -re {\([1-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[F"
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[H"
expect -re {\(1/[2-9][0-9]*\)}
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
sleep 0.2 sleep 0.2
@ -390,6 +485,37 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
COMMAND_INPUT_WRAP_SCRIPT="$STATE_DIR/command-input-wrap.expect"
cat >"$COMMAND_INPUT_WRAP_SCRIPT" <<EOF
set timeout 10
stty rows 10 columns 40
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "wrapcmd\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "search aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaatail"
expect -re {<a+tail}
send -- "\003"
expect "NORMAL"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$COMMAND_INPUT_WRAP_SCRIPT" >"$STATE_DIR/command-input-wrap.log" 2>&1; then
echo "✓ long command input stays on one status line"
PASS=$((PASS + 1))
else
echo "x long command input display failed"
sed -n '1,220p' "$STATE_DIR/command-input-wrap.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect" SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect"
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
set timeout 10 set timeout 10

View file

@ -185,6 +185,12 @@ expect "alpha"
expect "q:关闭" expect "q:关闭"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
send -- "/alpha\r"
expect "搜索"
expect "alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":" send -- ":"
expect ":" expect ":"
send -- "mute-joins\r" send -- "mute-joins\r"