From 5ae02054ee0f30469e738ac76a1853b746500787 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Fri, 29 May 2026 17:05:22 +0800 Subject: [PATCH] Improve terminal UX and private message flow --- Makefile | 2 + README.md | 11 ++- docs/INTERFACE.md | 11 +++ docs/MESSAGE_LOG.md | 5 +- docs/QUICKREF.md | 5 +- docs/USER_LIFECYCLE.md | 14 +-- include/i18n.h | 5 + include/ssh_server.h | 4 + include/tui.h | 3 + src/client.c | 1 + src/commands.c | 122 +++++++++++++++++------ src/help_text.c | 4 +- src/i18n_text.c | 20 ++++ src/input.c | 59 +++++++---- src/manual_text.c | 4 +- src/tui.c | 167 +++++++++++++++++++++++++------- tests/test_empty_view.sh | 109 +++++++++++++++++++++ tests/test_interactive_input.sh | 32 ++++++ tests/test_mute_joins_view.sh | 132 +++++++++++++++++++++++++ tests/test_user_lifecycle.sh | 21 +++- tests/unit/test_i18n.c | 12 +++ tnt.1 | 15 +-- 22 files changed, 651 insertions(+), 107 deletions(-) create mode 100755 tests/test_empty_view.sh create mode 100755 tests/test_mute_joins_view.sh diff --git a/Makefile b/Makefile index 03fe85c..390d447 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,8 @@ integration-test: all @cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh @cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh @cd tests && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh + @cd tests && PORT=$$(($${PORT:-2222} + 4)) ./test_mute_joins_view.sh + @cd tests && PORT=$$(($${PORT:-2222} + 5)) ./test_empty_view.sh @cd tests && ./test_tntctl_cli.sh anonymous-access-test: all diff --git a/README.md b/README.md index 52b37e4..8137019 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ past the limit is ignored with a terminal bell. ``` Opens at latest messages Stays pinned to latest until you scroll up -i - Return to INSERT mode +i/a/o - Return to INSERT mode : - Enter COMMAND mode j/k - Scroll down/up one line Ctrl+D/U - Scroll half page down/up @@ -109,8 +109,10 @@ ESC - Return to NORMAL mode ``` Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox` -is live: press `r` to refresh it manually, and it refreshes when a new private -message arrives while the inbox is open. +shows incoming and sent private messages newest-first; press `r` to refresh it +manually, and it refreshes when a new private message arrives while the inbox +is open. Private messages are per-session only and are not written to +`messages.log`. **Special messages (INSERT mode)** ``` @@ -223,7 +225,8 @@ tntctl -l operator chat.example.com post "service notice" ### Log Maintenance Persisted public history is stored as `messages.log` in the TNT state -directory. For manual maintenance, archive and compact it with: +directory. Private messages and local inbox state are intentionally excluded. +For manual maintenance, archive and compact it with: ```sh scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000 diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index 6351a86..dd0acc9 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -157,6 +157,17 @@ posted In anonymous-access mode, the SSH login name is not authenticated. Operators should configure `TNT_ACCESS_TOKEN` before relying on exec-post identity. +## Interactive Private Messages + +`:msg user message` and its `:w` alias deliver private messages only to online +interactive clients. They are not persisted to `messages.log` and are not +included in exec `tail`, exec `dump`, `:last`, or `:search`. + +Each participant keeps a bounded in-memory `:inbox` for the current session. +Recipients see incoming private messages; senders see local sent-message +copies. `:inbox` displays newest messages first, can be refreshed with `r`, +and refreshes automatically while open when a new private message arrives. + ### `help` Prints a localized human-readable command summary. It is intended for people, diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md index 9d5f245..5f243d7 100644 --- a/docs/MESSAGE_LOG.md +++ b/docs/MESSAGE_LOG.md @@ -37,7 +37,10 @@ existing append-only logs remain readable. - `|`, `\n`, and `\r` in content become spaces. - Timestamps are written in UTC. -Private messages are not written to `messages.log`. +Private messages are not written to `messages.log`. `:inbox` stores incoming +and sent private-message copies only in each participant's live session memory, +so inbox state is lost on disconnect and never appears in `tail`, `dump`, +`:last`, or `:search`. ## Replay And Search diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 65809b0..9901bfc 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -30,7 +30,7 @@ COMMANDS (COMMAND mode, prefix with :) nick change nickname msg send private message w alias for msg - inbox show private messages + inbox show private messages, newest first last [N] last N messages from log (default 10, max 50) search search full history (case-insensitive, 15 results) mute-joins toggle join/leave notifications @@ -45,6 +45,7 @@ INSERT MODE paste multi-line paste stays in the input buffer limit 1023 bytes/message; over-limit input rings bell normal opens/follows latest; k/PgUp older, j/PgDn newer + insert aliases i/a/o enter INSERT mode from NORMAL EXEC COMMANDS health print service health @@ -94,7 +95,7 @@ LIMITS 1024 bytes/message FILES - messages.log chat log (RFC3339) + messages.log public chat log (RFC3339; excludes private messages) host_key SSH key (auto-generated) motd.txt message of the day (optional) CHANGELOG.md version history diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index d2ea47e..5881337 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -32,8 +32,10 @@ The product path should stay short: parallel support commands for the same task. - Command syntax stays ASCII even in localized UI text. Translations explain; they do not change the command language. -- Private messages are visible only in the recipient inbox and are not written - to `messages.log`. +- Private messages are visible in each participant's in-memory `:inbox`: + recipients see incoming messages, senders see local sent-message copies, + newest first. They are not written to `messages.log` and do not survive a + reconnect. - `:inbox` is live enough for normal chat use: it can be refreshed with `r` and refreshes automatically when a new private message arrives while the inbox is open. @@ -47,10 +49,10 @@ The product path should stay short: - second user joins and is visible through `users --json` - first user opens `?`, checks `:users`, sends a public message, scrolls, uses `:last` and `:search` -- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends - `/me`, and exits -- second user opens `:inbox` before the private message arrives and sees it - auto-refresh after delivery +- first user toggles `:mute-joins`, sends two `:msg` messages, confirms sent + copies in `:inbox`, changes nickname, sends `/me`, and exits +- second user opens `:inbox` before the private messages arrive and sees it + auto-refresh after delivery, newest first - exec `tail` sees public messages - `messages.log` contains public history and excludes private-message content diff --git a/include/i18n.h b/include/i18n.h index fc0bd10..e24593e 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -35,6 +35,8 @@ typedef enum { I18N_TITLE_ONLINE_FORMAT, I18N_TITLE_MUTED, I18N_TITLE_HELP_HINT, + I18N_EMPTY_ROOM, + I18N_EMPTY_FILTERED, I18N_IDLE_TIMEOUT_FORMAT, I18N_SYSTEM_USERNAME, I18N_SYSTEM_JOIN_FORMAT, @@ -45,12 +47,15 @@ typedef enum { I18N_MSG_USER_NOT_FOUND_FORMAT, I18N_INBOX_TITLE, I18N_INBOX_EMPTY, + I18N_INBOX_SENT_TO_FORMAT, I18N_NICK_INVALID, I18N_NICK_TAKEN_FORMAT, I18N_NICK_UNCHANGED, I18N_NICK_CHANGED_FORMAT, I18N_LAST_HEADER_FORMAT, + I18N_LAST_EMPTY, I18N_SEARCH_HEADER_FORMAT, + I18N_SEARCH_EMPTY, I18N_MUTE_JOINS_FORMAT, I18N_MUTE_JOINS_MUTED, I18N_MUTE_JOINS_UNMUTED, diff --git a/include/ssh_server.h b/include/ssh_server.h index 2bcd0e7..cbc14a5 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -14,7 +14,9 @@ typedef struct { time_t timestamp; char from[MAX_USERNAME_LEN]; + char to[MAX_USERNAME_LEN]; char content[MAX_MESSAGE_LEN]; + bool outgoing; } whisper_t; typedef enum { @@ -63,6 +65,8 @@ typedef struct client { size_t outbox_len; size_t outbox_pos; size_t outbox_capacity; + char *render_buffer; /* Reused main-screen render buffer */ + size_t render_buffer_capacity; /* Per-client whisper inbox. Protected separately from SSH channel I/O * so slow writes do not block in-memory private-message delivery. */ whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; diff --git a/include/tui.h b/include/tui.h index 7a33207..cbb43b5 100644 --- a/include/tui.h +++ b/include/tui.h @@ -24,6 +24,9 @@ void tui_render_motd(struct client *client); /* Render the input line */ void tui_render_input(struct client *client, const char *input); +/* Render only the command input/status line */ +void tui_render_command_input(struct client *client); + /* Clear the screen */ void tui_clear_screen(struct client *client); diff --git a/src/client.c b/src/client.c index aff4a9e..381bff4 100644 --- a/src/client.c +++ b/src/client.c @@ -237,6 +237,7 @@ void client_release(client_t *client) { free(client->channel_cb); } free(client->outbox); + free(client->render_buffer); pthread_mutex_destroy(&client->io_lock); pthread_mutex_destroy(&client->whisper_lock); pthread_mutex_destroy(&client->ref_lock); diff --git a/src/commands.c b/src/commands.c index 9e53de2..051fde8 100644 --- a/src/commands.c +++ b/src/commands.c @@ -52,6 +52,42 @@ static void append_command_usage(char *output, size_t buf_size, size_t *pos, command_catalog_append_usage(output, buf_size, pos, id, lang); } +static bool message_visible_for_client(const client_t *client, + const message_t *msg) { + return !client || !client->mute_joins || + !system_message_is_join_leave(msg); +} + +static void client_append_whisper(client_t *owner, const char *from, + const char *to, const char *content, + bool outgoing, bool count_unread) { + if (!owner || !from || !to || !content) return; + + pthread_mutex_lock(&owner->whisper_lock); + int slot; + if (owner->whisper_inbox_count < WHISPER_INBOX_SIZE) { + slot = owner->whisper_inbox_count++; + } else { + memmove(&owner->whisper_inbox[0], + &owner->whisper_inbox[1], + (WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t)); + slot = WHISPER_INBOX_SIZE - 1; + } + + owner->whisper_inbox[slot].timestamp = time(NULL); + snprintf(owner->whisper_inbox[slot].from, + sizeof(owner->whisper_inbox[slot].from), "%s", from); + snprintf(owner->whisper_inbox[slot].to, + sizeof(owner->whisper_inbox[slot].to), "%s", to); + snprintf(owner->whisper_inbox[slot].content, + sizeof(owner->whisper_inbox[slot].content), "%s", content); + owner->whisper_inbox[slot].outgoing = outgoing; + if (count_unread) { + owner->unread_whispers++; + } + pthread_mutex_unlock(&owner->whisper_lock); +} + static void append_inbox_output(client_t *client, char *output, size_t buf_size, size_t *pos) { whisper_t snapshot[WHISPER_INBOX_SIZE]; @@ -73,14 +109,23 @@ static void append_inbox_output(client_t *client, char *output, " \033[2;37m%s\033[0m\n", i18n_text(client->ui_lang, I18N_INBOX_EMPTY)); } - for (int i = 0; i < snap_count; i++) { + for (int i = snap_count - 1; i >= 0; i--) { char ts[20]; + char peer[MAX_USERNAME_LEN + 16]; struct tm tmi; localtime_r(&snapshot[i].timestamp, &tmi); strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); + if (snapshot[i].outgoing) { + snprintf(peer, sizeof(peer), + i18n_text(client->ui_lang, + I18N_INBOX_SENT_TO_FORMAT), + snapshot[i].to); + } else { + snprintf(peer, sizeof(peer), "%s", snapshot[i].from); + } buffer_appendf(output, buf_size, pos, " \033[90m%s\033[0m \033[35m%s\033[0m: %s\n", - ts, snapshot[i].from, snapshot[i].content); + ts, peer, snapshot[i].content); } } @@ -251,28 +296,12 @@ void commands_dispatch(client_t *client) { pthread_rwlock_unlock(&g_room->lock); if (target) { - /* Push into recipient's inbox. whisper_lock serialises so - * two senders to the same recipient don't tear the ring. */ - pthread_mutex_lock(&target->whisper_lock); - int slot; - if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) { - slot = target->whisper_inbox_count++; - } else { - /* FIFO evict the oldest */ - memmove(&target->whisper_inbox[0], - &target->whisper_inbox[1], - (WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t)); - slot = WHISPER_INBOX_SIZE - 1; + client_append_whisper(target, client->username, target_name, + rest, false, true); + if (target != client) { + client_append_whisper(client, client->username, + target_name, rest, true, false); } - target->whisper_inbox[slot].timestamp = time(NULL); - snprintf(target->whisper_inbox[slot].from, - sizeof(target->whisper_inbox[slot].from), - "%s", client->username); - snprintf(target->whisper_inbox[slot].content, - sizeof(target->whisper_inbox[slot].content), - "%s", rest); - target->unread_whispers++; - pthread_mutex_unlock(&target->whisper_lock); /* Audible nudge — the title bar ✉ counter (UX-11 style) * carries the persistent signal. */ @@ -374,17 +403,31 @@ void commands_dispatch(client_t *client) { } message_t *last_msgs = NULL; - int last_count = message_load(&last_msgs, n); + int load_count = message_load(&last_msgs, + client->mute_joins ? MAX_MESSAGES : n); + int visible_count = 0; + for (int i = 0; i < load_count; i++) { + if (message_visible_for_client(client, &last_msgs[i])) { + last_msgs[visible_count++] = last_msgs[i]; + } + } + int start = visible_count > n ? visible_count - n : 0; + int last_count = visible_count - start; buffer_appendf(output, sizeof(output), &pos, i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT), last_count); + if (last_count == 0) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, I18N_LAST_EMPTY)); + } for (int i = 0; i < last_count; i++) { + message_t *msg = &last_msgs[start + i]; char ts[20]; struct tm tmi; - localtime_r(&last_msgs[i].timestamp, &tmi); + localtime_r(&msg->timestamp, &tmi); strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); buffer_appendf(output, sizeof(output), &pos, - "[%s] %s: %s\n", ts, last_msgs[i].username, last_msgs[i].content); + "[%s] %s: %s\n", ts, msg->username, msg->content); } free(last_msgs); @@ -396,23 +439,38 @@ void commands_dispatch(client_t *client) { TNT_COMMAND_SEARCH, client->ui_lang); } else { message_t *found = NULL; - int found_count = message_search(query, &found, 15); + int search_limit = client->mute_joins ? MAX_MESSAGES : 15; + int found_count = message_search(query, &found, search_limit); + int visible_count = 0; + for (int i = 0; i < found_count; i++) { + if (message_visible_for_client(client, &found[i])) { + found[visible_count++] = found[i]; + } + } + int start = visible_count > 15 ? visible_count - 15 : 0; + int display_count = visible_count - start; buffer_appendf(output, sizeof(output), &pos, i18n_text(client->ui_lang, I18N_SEARCH_HEADER_FORMAT), - query, found_count); - for (int i = 0; i < found_count; i++) { + query, display_count); + if (display_count == 0) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_SEARCH_EMPTY)); + } + for (int i = 0; i < display_count; i++) { + message_t *msg = &found[start + i]; char ts[20]; struct tm tmi; - localtime_r(&found[i].timestamp, &tmi); + localtime_r(&msg->timestamp, &tmi); strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); buffer_appendf(output, sizeof(output), &pos, "[%s] ", ts); append_highlighted(output, sizeof(output), &pos, - found[i].username, query); + msg->username, query); buffer_appendf(output, sizeof(output), &pos, ": "); append_highlighted(output, sizeof(output), &pos, - found[i].content, query); + msg->content, query); buffer_appendf(output, sizeof(output), &pos, "\n"); } free(found); diff --git a/src/help_text.c b/src/help_text.c index 9c10e0d..637881c 100644 --- a/src/help_text.c +++ b/src/help_text.c @@ -26,7 +26,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "NORMAL MODE KEYS:\n" " Opens at latest messages\n" " Follows latest until you scroll up\n" - " i - Return to INSERT mode\n" + " i/a/o - Return to INSERT mode\n" " : - Enter COMMAND mode\n" " / - Search message history\n" " j/k - Scroll down/up one line\n" @@ -59,7 +59,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "NORMAL 模式按键:\n" " 默认停在最新消息\n" " 未向上翻阅时自动跟随最新消息\n" - " i - 返回 INSERT 模式\n" + " i/a/o - 返回 INSERT 模式\n" " : - 进入 COMMAND 模式\n" " / - 搜索消息历史\n" " j/k - 向下/上滚动一行\n" diff --git a/src/i18n_text.c b/src/i18n_text.c index 5f009e4..e53a662 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -81,6 +81,14 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "? keys", "? 按键" ), + [I18N_EMPTY_ROOM] = I18N_STRING( + "No messages yet", + "暂无消息" + ), + [I18N_EMPTY_FILTERED] = I18N_STRING( + "No visible messages", + "暂无可见消息" + ), [I18N_IDLE_TIMEOUT_FORMAT] = I18N_STRING( "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n", "\r\n\033[33m已断开: 空闲超时 (%d 分钟)\033[0m\r\n" @@ -121,6 +129,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "(empty)", "(空)" ), + [I18N_INBOX_SENT_TO_FORMAT] = I18N_STRING( + "you -> %s", + "你 -> %s" + ), [I18N_NICK_INVALID] = I18N_STRING( "Invalid username\n", "用户名无效\n" @@ -141,10 +153,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "--- Last %d message(s) ---\n", "--- 最近 %d 条消息 ---\n" ), + [I18N_LAST_EMPTY] = I18N_STRING( + "No messages to show\n", + "没有可显示的消息\n" + ), [I18N_SEARCH_HEADER_FORMAT] = I18N_STRING( "--- Search: \"%s\" (showing last %d match(es)) ---\n", "--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n" ), + [I18N_SEARCH_EMPTY] = I18N_STRING( + "No matches\n", + "没有匹配结果\n" + ), [I18N_MUTE_JOINS_FORMAT] = I18N_STRING( "Join/leave notifications: %s\n", "加入/离开提示: %s\n" diff --git a/src/input.c b/src/input.c index a19811b..640c6c5 100644 --- a/src/input.c +++ b/src/input.c @@ -24,6 +24,8 @@ static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT; static ui_lang_t g_default_ui_lang = UI_LANG_EN; +#define MAIN_LOOP_POLL_TIMEOUT_MS 250 + void input_init(void) { g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT); g_default_ui_lang = i18n_default_ui_lang(); @@ -212,20 +214,44 @@ static bool append_paste_byte(char *input, unsigned char b) { return false; } +static int normal_visible_message_count(const client_t *client) { + if (!client || !client->mute_joins) { + return room_get_message_count(g_room); + } + + int count = 0; + pthread_rwlock_rdlock(&g_room->lock); + for (int i = 0; i < g_room->message_count; i++) { + if (!system_message_is_join_leave(&g_room->messages[i])) { + count++; + } + } + pthread_rwlock_unlock(&g_room->lock); + return count; +} + static void normal_scroll_to_latest(client_t *client) { if (!client) return; history_view_scroll_to_latest(&client->scroll_pos, &client->follow_tail, - room_get_message_count(g_room), + normal_visible_message_count(client), history_view_height(client->height)); } static void normal_scroll_by(client_t *client, int delta) { if (!client) return; history_view_scroll_by(&client->scroll_pos, &client->follow_tail, - room_get_message_count(g_room), + normal_visible_message_count(client), history_view_height(client->height), delta); } +static void normal_enter_insert(client_t *client) { + if (!client) return; + client->mode = MODE_INSERT; + client->follow_tail = true; + client->unread_mentions = 0; + tui_render_screen(client); +} + static void dismiss_command_output(client_t *client) { bool was_motd; @@ -629,22 +655,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { case MODE_NORMAL: { int nm_msg_height = history_view_height(client->height); - if (key == 'i') { - client->mode = MODE_INSERT; - client->follow_tail = true; - client->unread_mentions = 0; - tui_render_screen(client); + if (key == 'i' || key == 'a' || key == 'A' || + key == 'o' || key == 'O') { + normal_enter_insert(client); return true; } else if (key == ':') { client->mode = MODE_COMMAND; client->command_input[0] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); return true; } else if (key == '/') { client->mode = MODE_COMMAND; snprintf(client->command_input, sizeof(client->command_input), "search "); - tui_render_screen(client); + tui_render_command_input(client); return true; } else if (key == 'j') { normal_scroll_by(client, 1); @@ -744,7 +768,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { client->command_history[client->command_history_pos], sizeof(client->command_input) - 1); client->command_input[sizeof(client->command_input) - 1] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); } return true; } else if (seq[1] == 'B') { /* Down arrow */ @@ -758,7 +782,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { client->command_history_pos = client->command_history_count; client->command_input[0] = '\0'; } - tui_render_screen(client); + tui_render_command_input(client); return true; } } @@ -773,19 +797,19 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { } else if (key == 127 || key == 8) { /* Backspace */ if (client->command_input[0] != '\0') { utf8_remove_last_char(client->command_input); - tui_render_screen(client); + tui_render_command_input(client); } return true; /* Key consumed */ } else if (key == 23) { /* Ctrl+W (Delete Word) */ if (client->command_input[0] != '\0') { utf8_remove_last_word(client->command_input); - tui_render_screen(client); + tui_render_command_input(client); } return true; } else if (key == 21) { /* Ctrl+U (Delete Line) */ if (client->command_input[0] != '\0') { client->command_input[0] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); } return true; } @@ -892,7 +916,8 @@ main_loop: break; } - int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); + int ready = ssh_channel_poll_timeout(client->channel, + MAIN_LOOP_POLL_TIMEOUT_MS, 0); if (ready == SSH_ERROR) { break; @@ -1029,7 +1054,7 @@ main_loop: if (len < sizeof(client->command_input) - 1) { client->command_input[len] = b; client->command_input[len + 1] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); } else { client_send(client, "\a", 1); } @@ -1047,7 +1072,7 @@ main_loop: if (len + (size_t)char_len <= sizeof(client->command_input) - 1) { memcpy(client->command_input + len, buf, char_len); client->command_input[len + char_len] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); } else { client_send(client, "\a", 1); } diff --git a/src/manual_text.c b/src/manual_text.c index f7badaf..54834e0 100644 --- a/src/manual_text.c +++ b/src/manual_text.c @@ -13,7 +13,7 @@ void manual_text_append_interactive(char *buffer, size_t buf_size, "\n" "\033[1;37mUse\033[0m\n" " Type, Enter sends; Up/Down recalls; Tab completes @mentions\n" - " Esc browses; / searches; G latest; i types; : commands; ? keys\n" + " Esc browses; / searches; G latest; i/a/o types; : commands; ? keys\n" "\n" "\033[1;37mCommands\033[0m\n", "\033[1;36mTNT(1) 帮助\033[0m\n" @@ -23,7 +23,7 @@ void manual_text_append_interactive(char *buffer, size_t buf_size, "\n" "\033[1;37m使用\033[0m\n" " 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n" - " Esc 浏览;/ 搜索;G 最新;i 输入;: 命令;? 按键\n" + " Esc 浏览;/ 搜索;G 最新;i/a/o 输入;: 命令;? 按键\n" "\n" "\033[1;37m命令\033[0m\n" ); diff --git a/src/tui.c b/src/tui.c index f671b70..31058f0 100644 --- a/src/tui.c +++ b/src/tui.c @@ -21,6 +21,25 @@ static const char *username_color(const char *name) { return colors[h % 6]; } +static char *client_render_buffer(client_t *client, size_t min_size) { + if (!client || min_size == 0) { + return NULL; + } + + if (client->render_buffer_capacity >= min_size) { + return client->render_buffer; + } + + char *grown = realloc(client->render_buffer, min_size); + if (!grown) { + return NULL; + } + + client->render_buffer = grown; + client->render_buffer_capacity = min_size; + return client->render_buffer; +} + static void format_message_colored(const message_t *msg, char *buffer, size_t buf_size, int width, const char *my_username) { @@ -245,7 +264,7 @@ void tui_render_screen(client_t *client) { if (render_height < 4) render_height = 4; const size_t buf_size = (size_t)(render_height + 10) * (MAX_MESSAGE_LEN + 64) + 2048; - char *buffer = malloc(buf_size); + char *buffer = client_render_buffer(client, buf_size); if (!buffer) return; size_t pos = 0; buffer[0] = '\0'; @@ -255,6 +274,7 @@ void tui_render_screen(client_t *client) { int online = g_room->client_count; int msg_count = g_room->message_count; pthread_rwlock_unlock(&g_room->lock); + int raw_msg_count = msg_count; /* Calculate which messages to show. The initial slice is capped by * message count; the lock-held copy below tightens "latest" slices so @@ -280,47 +300,95 @@ void tui_render_screen(client_t *client) { int end = start + msg_height; if (end > msg_count) end = msg_count; - /* Allocate snapshot outside the lock to avoid blocking writers */ + message_t *visible_messages = NULL; message_t *msg_snapshot = NULL; - int snapshot_capacity = msg_height; - int snapshot_count = end - start; + int snapshot_count = 0; - if (snapshot_count > 0 && snapshot_capacity > 0) { - msg_snapshot = calloc(snapshot_capacity, sizeof(message_t)); + if (client->mute_joins && msg_count > 0) { + visible_messages = calloc(MAX_MESSAGES, sizeof(message_t)); + if (visible_messages) { + int visible_count = 0; + + pthread_rwlock_rdlock(&g_room->lock); + online = g_room->client_count; + raw_msg_count = g_room->message_count; + for (int i = 0; i < g_room->message_count; i++) { + if (!system_message_is_join_leave(&g_room->messages[i])) { + visible_messages[visible_count++] = g_room->messages[i]; + } + } + pthread_rwlock_unlock(&g_room->lock); + + msg_count = visible_count; + latest_scroll_start = history_view_max_scroll(msg_count, msg_height); + anchor_latest = client->mode != MODE_NORMAL || + client->follow_tail || + client->scroll_pos >= latest_scroll_start; + if (client->mode == MODE_NORMAL) { + start = client->scroll_pos; + if (start > latest_scroll_start) { + start = latest_scroll_start; + } + if (start < 0) start = 0; + } else { + start = latest_scroll_start; + } + end = start + msg_height; + if (end > msg_count) end = msg_count; + if (anchor_latest) { + start = history_view_latest_start_for_height( + visible_messages, msg_count, msg_height); + end = msg_count; + } + snapshot_count = end - start; + if (snapshot_count > 0) { + msg_snapshot = visible_messages + start; + } + } } - /* Second pass under lock: copy messages */ - if (msg_snapshot) { - pthread_rwlock_rdlock(&g_room->lock); - /* Re-clamp in case msg_count changed */ - int actual_count = g_room->message_count; - int actual_start = start; - int actual_end = end; - if (anchor_latest) { - actual_end = actual_count; - actual_start = history_view_latest_start_for_height( - g_room->messages, actual_count, msg_height); - } else { - actual_end = (actual_end <= actual_count) ? actual_end : actual_count; - actual_start = (actual_start < actual_end) ? actual_start : actual_end; + if (!visible_messages) { + /* Allocate snapshot outside the lock to avoid blocking writers */ + int snapshot_capacity = msg_height; + snapshot_count = end - start; + + if (snapshot_count > 0 && snapshot_capacity > 0) { + msg_snapshot = calloc(snapshot_capacity, sizeof(message_t)); } - int actual_snapshot = actual_end - actual_start; - if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) { - memcpy(msg_snapshot, &g_room->messages[actual_start], - actual_snapshot * sizeof(message_t)); - start = actual_start; - end = actual_end; - snapshot_count = actual_snapshot; - } else { - snapshot_count = 0; + + /* Second pass under lock: copy messages */ + if (msg_snapshot) { + pthread_rwlock_rdlock(&g_room->lock); + /* Re-clamp in case msg_count changed */ + int actual_count = g_room->message_count; + int actual_start = start; + int actual_end = end; + if (anchor_latest) { + actual_end = actual_count; + actual_start = history_view_latest_start_for_height( + g_room->messages, actual_count, msg_height); + } else { + actual_end = (actual_end <= actual_count) ? actual_end : actual_count; + actual_start = (actual_start < actual_end) ? actual_start : actual_end; + } + int actual_snapshot = actual_end - actual_start; + if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) { + memcpy(msg_snapshot, &g_room->messages[actual_start], + actual_snapshot * sizeof(message_t)); + start = actual_start; + end = actual_end; + snapshot_count = actual_snapshot; + } else { + snapshot_count = 0; + } + pthread_rwlock_unlock(&g_room->lock); } - pthread_rwlock_unlock(&g_room->lock); } /* Now render using snapshot (no lock held) */ /* If mute_joins is set, remove join/leave messages from snapshot in place */ - if (client->mute_joins && msg_snapshot) { + if (client->mute_joins && msg_snapshot && !anchor_latest) { int filtered = 0; for (int i = 0; i < snapshot_count; i++) { if (!system_message_is_join_leave(&msg_snapshot[i])) { @@ -513,9 +581,26 @@ void tui_render_screen(client_t *client) { buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line); rows_written++; } - free(msg_snapshot); } + if (rows_written == 0) { + const char *empty_text = + client->mute_joins && raw_msg_count > 0 + ? i18n_text(client->ui_lang, I18N_EMPTY_FILTERED) + : i18n_text(client->ui_lang, I18N_EMPTY_ROOM); + int empty_width = utf8_string_width(empty_text); + int empty_pad = (render_width - empty_width) / 2; + if (empty_pad < 0) empty_pad = 0; + for (int i = 0; i < empty_pad; i++) { + buffer_append_bytes(buffer, buf_size, &pos, " ", 1); + } + buffer_appendf(buffer, buf_size, &pos, + "\033[2;37m%s\033[0m\033[K\r\n", empty_text); + rows_written++; + } + + free(visible_messages ? visible_messages : msg_snapshot); + /* Fill empty lines and clear them */ for (int i = rows_written; i < msg_height; i++) { buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n"); @@ -531,7 +616,6 @@ void tui_render_screen(client_t *client) { tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end); client_send(client, buffer, pos); - free(buffer); } /* Render the input line. @@ -608,6 +692,23 @@ void tui_render_input(client_t *client, const char *input) { client_send(client, buffer, strlen(buffer)); } +void tui_render_command_input(client_t *client) { + if (!client || !client->connected) return; + + int rh = client->height; + if (rh < 4) rh = 4; + + char buffer[sizeof(client->command_input) + 64]; + size_t pos = 0; + buffer[0] = '\0'; + + buffer_appendf(buffer, sizeof(buffer), &pos, + "\033[%d;1H" ANSI_CLEAR_LINE, rh); + tui_status_append(buffer, sizeof(buffer), &pos, client, 0, 0, 0); + + client_send(client, buffer, pos); +} + /* Render the command output screen */ void tui_render_command_output(client_t *client) { if (!client || !client->connected) return; diff --git a/tests/test_empty_view.sh b/tests/test_empty_view.sh new file mode 100755 index 0000000..a247d65 --- /dev/null +++ b/tests/test_empty_view.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# Regression test for the empty/filtered-empty main view. + +PORT=${PORT:-12350} +PASS=0 +FAIL=0 +BIN="../tnt" +SERVER_PID="" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-empty-view-test.XXXXXX") + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +if ! command -v expect >/dev/null 2>&1; then + echo "expect not installed; skipping empty view test" + exit 0 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" + +echo "=== TNT Empty View Test ===" + +TNT_LANG=en TNT_RATE_LIMIT=0 "$BIN" --bind 127.0.0.1 \ + -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +SERVER_READY=0 +for _ in 1 2 3 4 5; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "x Server failed to start" + sed -n '1,120p' "$STATE_DIR/server.log" + exit 1 + fi + if grep -q "TNT chat server listening" "$STATE_DIR/server.log"; then + SERVER_READY=1 + break + fi + sleep 1 +done + +if [ "$SERVER_READY" -eq 1 ]; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "x Server did not become ready" + sed -n '1,120p' "$STATE_DIR/server.log" + exit 1 +fi + +VIEW_SCRIPT="$STATE_DIR/empty-view.expect" +cat >"$VIEW_SCRIPT" <"$STATE_DIR/empty-view.log" 2>&1; then + echo "✓ filtered-empty main view shows a state hint" + PASS=$((PASS + 1)) +else + echo "x filtered-empty main view did not show state hint" + sed -n '1,220p' "$STATE_DIR/empty-view.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index f02e107..cee8813 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -591,6 +591,38 @@ else FAIL=$((FAIL + 1)) fi +VIM_INSERT_ALIASES_SCRIPT="$STATE_DIR/vim-insert-aliases.expect" +cat >"$VIM_INSERT_ALIASES_SCRIPT" <"$STATE_DIR/vim-insert-aliases.log" 2>&1; then + echo "✓ Vim insert aliases enter INSERT mode" + PASS=$((PASS + 1)) +else + echo "x Vim insert aliases failed" + sed -n '1,200p' "$STATE_DIR/vim-insert-aliases.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + echo "" echo "PASSED: $PASS" echo "FAILED: $FAIL" diff --git a/tests/test_mute_joins_view.sh b/tests/test_mute_joins_view.sh new file mode 100755 index 0000000..91c0277 --- /dev/null +++ b/tests/test_mute_joins_view.sh @@ -0,0 +1,132 @@ +#!/bin/sh +# Regression test for :mute-joins filling the latest view with real messages. + +PORT=${PORT:-12349} +PASS=0 +FAIL=0 +BIN="../tnt" +SERVER_PID="" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-mute-joins-test.XXXXXX") + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +if ! command -v expect >/dev/null 2>&1; then + echo "expect not installed; skipping mute-joins view test" + exit 0 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" + +echo "=== TNT Mute Joins View Test ===" + +seed_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +i=1 +while [ "$i" -le 18 ]; do + printf '%s|fixture|kept visible %02d\n' "$seed_ts" "$i" >>"$STATE_DIR/messages.log" + i=$((i + 1)) +done + +i=1 +while [ "$i" -le 20 ]; do + printf '%s|system|noise%02d joined the room\n' "$seed_ts" "$i" >>"$STATE_DIR/messages.log" + i=$((i + 1)) +done + +TNT_LANG=en TNT_RATE_LIMIT=0 "$BIN" --bind 127.0.0.1 \ + -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +SERVER_READY=0 +for _ in 1 2 3 4 5; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "x Server failed to start" + sed -n '1,120p' "$STATE_DIR/server.log" + exit 1 + fi + if grep -q "TNT chat server listening" "$STATE_DIR/server.log"; then + SERVER_READY=1 + break + fi + sleep 1 +done + +if [ "$SERVER_READY" -eq 1 ]; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "x Server did not become ready" + sed -n '1,120p' "$STATE_DIR/server.log" + exit 1 +fi + +VIEW_SCRIPT="$STATE_DIR/mute-joins-view.expect" +cat >"$VIEW_SCRIPT" <"$STATE_DIR/mute-joins-view.log" 2>&1; then + echo "✓ :mute-joins fills latest view with non-join messages" + PASS=$((PASS + 1)) +else + echo "x :mute-joins latest view did not show older non-join messages" + sed -n '1,220p' "$STATE_DIR/mute-joins-view.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_user_lifecycle.sh b/tests/test_user_lifecycle.sh index 1f76400..056ee08 100755 --- a/tests/test_user_lifecycle.sh +++ b/tests/test_user_lifecycle.sh @@ -92,7 +92,8 @@ exec touch "$BOB_READY" exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done" expect "私信" expect "alice" -expect "private lifecycle ping" +expect "private lifecycle second" +expect "private lifecycle first" expect "q:关闭" send -- "q" expect "NORMAL" @@ -201,7 +202,14 @@ send -- "q" expect "NORMAL" send -- ":" expect ":" -send -- "msg bob private lifecycle ping\r" +send -- "msg bob private lifecycle first\r" +expect "私信已发送给 bob" +expect "q:关闭" +send -- "q" +expect "NORMAL" +send -- ":" +expect ":" +send -- "msg bob private lifecycle second\r" expect "私信已发送给 bob" exec touch "$PRIVATE_SENT" expect "q:关闭" @@ -209,6 +217,15 @@ send -- "q" expect "NORMAL" send -- ":" expect ":" +send -- "inbox\r" +expect "你 -> bob" +expect "private lifecycle second" +expect "private lifecycle first" +expect "q:关闭" +send -- "q" +expect "NORMAL" +send -- ":" +expect ":" send -- "nick alice2\r" expect "昵称已修改: alice -> alice2" expect "q:关闭" diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index 838dbff..b08056a 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -136,6 +136,10 @@ TEST(text_lookup_matches_language) { "online") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_TITLE_ONLINE_FORMAT), "在线") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_EMPTY_ROOM), + "No messages") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_EMPTY_FILTERED), + "可见") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_IDLE_TIMEOUT_FORMAT), "idle timeout") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT), @@ -148,10 +152,18 @@ TEST(text_lookup_matches_language) { "Private messages") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE), "私信") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_SENT_TO_FORMAT), + "you ->") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_SENT_TO_FORMAT), + "你 ->") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT), "Search") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT), "搜索") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_LAST_EMPTY), + "No messages") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_EMPTY), + "匹配") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_LANG_CURRENT_FORMAT), "lang ") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT), diff --git a/tnt.1 b/tnt.1 index 86a134b..f671ca3 100644 --- a/tnt.1 +++ b/tnt.1 @@ -203,7 +203,7 @@ PageDown/PageUp Scroll full page down/up End/Home Jump to bottom/top g/G Jump to top/bottom / Search message history -i Switch to INSERT +i/a/o Switch to INSERT : Enter COMMAND mode ? Open full key reference Ctrl+C Disconnect @@ -220,7 +220,7 @@ l l. :name \fIname\fR Alias for :nick :msg \fIuser message\fR Send private message :w \fIuser text\fR Short alias for :msg -:inbox Show private messages +:inbox Show private messages, newest first :last [\fIN\fR] Show last N messages from history (1\-50, default 10) :search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches :mute\-joins Toggle join/leave system notifications on/off @@ -249,8 +249,10 @@ r Refresh live output (:inbox) .PP The .B :inbox -page refreshes automatically when a new private message arrives while it is -open. +page shows incoming messages and local sent-message copies for the current +session. It refreshes automatically when a new private message arrives while +it is open. Private messages are not written to +.IR messages.log . .SH EXEC INTERFACE Commands can be run non\-interactively for scripting: .PP @@ -340,10 +342,11 @@ libssh log verbosity from 0 to 4 (default: 1). .SH FILES .TP .I messages.log -Chat history in the TNT message log v1 format: +Public chat history in the TNT message log v1 format: RFC\ 3339 UTC pipe\-delimited records .RI ( timestamp | username | content ). -Stored in the state directory. +Stored in the state directory. Private messages and in-memory inbox state are +excluded. See .I docs/MESSAGE_LOG.md in the source distribution for parser and recovery rules.