Merge pull request #53 from m1ngsama/feat/private-message-flow

Improve private message inbox and reply flow
This commit is contained in:
m1ngsama 2026-06-04 22:47:20 +08:00 committed by GitHub
commit 2fcfcad613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 916 additions and 147 deletions

View file

@ -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

View file

@ -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
@ -96,7 +96,10 @@ Ctrl+C - Exit chat
:nick <name> - Change nickname
:msg <user> <message> - Send private message
:w <user> <text> - Short alias for :msg
:reply <text> - Reply to latest private message
:r <text> - Short alias for :reply
:inbox - Show private messages
:inbox clear - Clear private messages for this session
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search message history (shows last 15 matches)
:mute-joins - Toggle join/leave system notifications
@ -109,8 +112,14 @@ 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. `:reply text` and `:r text` send to the latest private-message peer.
Unread incoming private messages are marked with `*` until `:inbox` renders.
The inbox title shows a transient unread count when new private messages are
present.
`:inbox clear` removes private messages and the reply target for this session.
Private messages are per-session only and are not written to `messages.log`.
**Special messages (INSERT mode)**
```
@ -223,7 +232,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

View file

@ -80,7 +80,9 @@ Common commands:
:users online users
:nick <name> change nickname
:msg <user> <message> send private message
:reply <message> reply to latest private message
:inbox show private messages
:inbox clear clear private messages
:last [N] recent messages
:search <keyword> search message history
:lang en|zh switch UI language

View file

@ -157,6 +157,23 @@ 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. `:reply message` and its `:r` alias send to the latest
private-message peer in the current session. Private messages 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. Unread incoming messages are marked with `*` until `:inbox` renders.
`:inbox` displays newest messages first, shows a transient unread count, can
be refreshed with `r`, and refreshes automatically while open when a new
private message arrives.
`:inbox clear` removes the current session's private messages, unread count,
and reply target.
### `help`
Prints a localized human-readable command summary. It is intended for people,

View file

@ -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

View file

@ -30,7 +30,10 @@ COMMANDS (COMMAND mode, prefix with :)
nick <name> change nickname
msg <user> <message> send private message
w <user> <text> alias for msg
inbox show private messages
reply <text> reply to latest private message
r <text> alias for reply
inbox show private messages, newest first
inbox clear clear private messages for this session
last [N] last N messages from log (default 10, max 50)
search <keyword> search full history (case-insensitive, 15 results)
mute-joins toggle join/leave notifications
@ -45,6 +48,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 +98,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

View file

@ -12,8 +12,8 @@ The product path should stay short:
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.
7. User searches from NORMAL with `/term`, or uses commands when needed:
`:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`,
and `:q`.
`:users`, `:msg`, `:reply`, `:inbox`, `:last`, `:search`, `:nick`,
`:mute-joins`, and `:q`.
8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
`stats`, `users`, `tail`, `dump`, and `post`.
@ -32,11 +32,18 @@ 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.
inbox is open. Incoming unread messages are marked with `*` and counted in
the inbox title until the inbox renders them. `:inbox clear` removes private
messages and the reply target for the current session.
- `:reply` / `:r` keeps the private-message path keyboard-short: it answers
the latest private-message peer in the current session without retyping a
username.
- Long command output uses a small pager so `:last` and `:search` are readable
on small terminals.
@ -47,10 +54,12 @@ 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, receives a
`:reply`, confirms private-message copies in `:inbox`, clears the inbox,
changes nickname, sends `/me`, and exits
- second user opens `:inbox` before the private messages arrive, sees it
auto-refresh after delivery, newest first, and replies without retyping the
sender's username
- exec `tail` sees public messages
- `messages.log` contains public history and excludes private-message content

View file

@ -8,6 +8,7 @@ typedef enum {
TNT_COMMAND_HELP,
TNT_COMMAND_LANG,
TNT_COMMAND_MSG,
TNT_COMMAND_REPLY,
TNT_COMMAND_INBOX,
TNT_COMMAND_NICK,
TNT_COMMAND_LAST,

View file

@ -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,
@ -43,14 +45,20 @@ typedef enum {
I18N_USERS_TITLE,
I18N_MSG_SENT_FORMAT,
I18N_MSG_USER_NOT_FOUND_FORMAT,
I18N_REPLY_NO_TARGET,
I18N_INBOX_TITLE,
I18N_INBOX_EMPTY,
I18N_INBOX_SENT_TO_FORMAT,
I18N_INBOX_CLEARED,
I18N_INBOX_UNREAD_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,

View file

@ -14,7 +14,10 @@
typedef struct {
time_t timestamp;
char from[MAX_USERNAME_LEN];
char to[MAX_USERNAME_LEN];
char content[MAX_MESSAGE_LEN];
bool outgoing;
bool unread;
} whisper_t;
typedef enum {
@ -59,10 +62,13 @@ typedef struct client {
_Atomic int pending_bells; /* Bell nudges for this client's loop */
_Atomic int unread_mentions; /* @-mentions received since last reset */
_Atomic int unread_whispers; /* whispers received since last :inbox view */
char last_whisper_peer[MAX_USERNAME_LEN]; /* Most recent private-message peer */
char *outbox; /* Bounded queued output for interactive writes */
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];

View file

@ -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);

View file

@ -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);

View file

@ -36,13 +36,25 @@ static const command_catalog_entry_t entries[] = {
" w <user> <message>\n"),
2, false, true
},
{
{TNT_COMMAND_REPLY, "reply", {"reply", "r", NULL}},
I18N_STRING(":reply <message>, :r <message>",
":reply <message>, :r <message>"),
I18N_STRING("Reply to latest private message", "回复最近私信"),
I18N_STRING(":reply <message>", ":reply <message>"),
I18N_STRING("Usage: reply <message>\n"
" r <message>\n",
"用法: reply <message>\n"
" r <message>\n"),
2, false, true
},
{
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
I18N_STRING(":inbox, :inbox clear", ":inbox, :inbox clear"),
I18N_STRING("Show or clear private messages", "查看或清空私信"),
I18N_STRING(":inbox", ":inbox"),
I18N_STRING("Show private messages", "查看私信"),
I18N_STRING(":inbox", ":inbox"),
I18N_STRING("Usage: inbox\n", "用法: inbox\n"),
2, true, false
I18N_STRING("Usage: inbox [clear]\n", "用法: inbox [clear]\n"),
2, false, false
},
{
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
@ -227,6 +239,9 @@ bool command_catalog_args_valid(tnt_command_id_t id, const char *args) {
if (!entry) {
return false;
}
if (id == TNT_COMMAND_INBOX) {
return !args || args[0] == '\0' || strcmp(args, "clear") == 0;
}
if (entry->no_args) {
return !args || args[0] == '\0';
}

View file

@ -52,38 +52,153 @@ 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;
owner->whisper_inbox[slot].unread = count_unread;
snprintf(owner->last_whisper_peer, sizeof(owner->last_whisper_peer), "%s",
outgoing ? to : from);
if (count_unread) {
owner->unread_whispers++;
}
pthread_mutex_unlock(&owner->whisper_lock);
}
static void send_private_message(client_t *client, const char *target_name,
const char *content, char *output,
size_t buf_size, size_t *pos) {
bool found = false;
client_t *target = NULL;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
target = g_room->clients[i];
client_addref(target);
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
if (target) {
client_append_whisper(target, client->username, target_name,
content, false, true);
if (target != client) {
client_append_whisper(client, client->username, target_name,
content, true, false);
}
/* Audible nudge: the title bar whisper counter carries the
* persistent signal without cross-client SSH writes. */
client_queue_bell(target);
client_release(target);
}
if (found) {
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang, I18N_MSG_SENT_FORMAT),
target_name);
} else {
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
}
}
static void append_inbox_output(client_t *client, char *output,
size_t buf_size, size_t *pos) {
whisper_t snapshot[WHISPER_INBOX_SIZE];
int snap_count;
int unread_count;
pthread_mutex_lock(&client->whisper_lock);
snap_count = client->whisper_inbox_count;
unread_count = client->unread_whispers;
memcpy(snapshot, client->whisper_inbox,
snap_count * sizeof(whisper_t));
for (int i = 0; i < snap_count; i++) {
client->whisper_inbox[i].unread = false;
}
client->unread_whispers = 0;
pthread_mutex_unlock(&client->whisper_lock);
buffer_appendf(output, buf_size, pos,
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
"\033[1;36m%s\033[0m \033[2;37m· %d",
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
snap_count);
if (unread_count > 0) {
buffer_appendf(output, buf_size, pos,
" · ");
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang,
I18N_INBOX_UNREAD_FORMAT),
unread_count);
}
buffer_appendf(output, buf_size, pos, "\033[0m\n");
if (snap_count == 0) {
buffer_appendf(output, buf_size, pos,
" \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];
const char *marker = snapshot[i].unread ? "\033[1;35m*\033[0m" : " ";
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);
" %s \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
marker, ts, peer, snapshot[i].content);
}
}
static void clear_inbox(client_t *client) {
pthread_mutex_lock(&client->whisper_lock);
memset(client->whisper_inbox, 0, sizeof(client->whisper_inbox));
client->whisper_inbox_count = 0;
client->unread_whispers = 0;
client->last_whisper_peer[0] = '\0';
pthread_mutex_unlock(&client->whisper_lock);
}
bool commands_refresh_active_output(client_t *client) {
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
size_t pos = 0;
@ -237,65 +352,49 @@ void commands_dispatch(client_t *client) {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_MSG, client->ui_lang);
} else {
bool found = false;
client_t *target = NULL;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
target = g_room->clients[i];
client_addref(target);
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
send_private_message(client, target_name, rest, output,
sizeof(output), &pos);
}
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;
}
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);
} else if (command_id == TNT_COMMAND_REPLY) {
const char *message = arg;
char target_name[MAX_USERNAME_LEN] = {0};
/* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */
client_queue_bell(target);
client_release(target);
}
while (*message == ' ') message++;
if (message[0] == '\0') {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_REPLY, client->ui_lang);
} else {
pthread_mutex_lock(&client->whisper_lock);
snprintf(target_name, sizeof(target_name), "%s",
client->last_whisper_peer);
pthread_mutex_unlock(&client->whisper_lock);
if (found) {
buffer_appendf(output, sizeof(output), &pos,
if (target_name[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_MSG_SENT_FORMAT),
target_name);
I18N_REPLY_NO_TARGET));
} else {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
send_private_message(client, target_name, message, output,
sizeof(output), &pos);
}
}
} else if (command_id == TNT_COMMAND_INBOX) {
output_kind = TNT_COMMAND_OUTPUT_INBOX;
append_inbox_output(client, output, sizeof(output), &pos);
const char *inbox_arg = arg;
while (*inbox_arg == ' ') inbox_arg++;
if (strcmp(inbox_arg, "clear") == 0) {
clear_inbox(client);
output_kind = TNT_COMMAND_OUTPUT_INBOX;
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_INBOX_CLEARED));
buffer_appendf(output, sizeof(output), &pos, "\n");
append_inbox_output(client, output, sizeof(output), &pos);
} else {
output_kind = TNT_COMMAND_OUTPUT_INBOX;
append_inbox_output(client, output, sizeof(output), &pos);
}
} else if (command_id == TNT_COMMAND_NICK) {
const char *new_name = arg;
@ -374,17 +473,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 +509,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);

View file

@ -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"

View file

@ -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"
@ -113,6 +121,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"User '%s' not found\n",
"未找到用户 '%s'\n"
),
[I18N_REPLY_NO_TARGET] = I18N_STRING(
"No private message to reply to\n",
"没有可回复的私信\n"
),
[I18N_INBOX_TITLE] = I18N_STRING(
"Private messages",
"私信"
@ -121,6 +133,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"(empty)",
"(空)"
),
[I18N_INBOX_SENT_TO_FORMAT] = I18N_STRING(
"you -> %s",
"你 -> %s"
),
[I18N_INBOX_CLEARED] = I18N_STRING(
"Private messages cleared\n",
"私信已清空\n"
),
[I18N_INBOX_UNREAD_FORMAT] = I18N_STRING(
"%d new",
"%d 新"
),
[I18N_NICK_INVALID] = I18N_STRING(
"Invalid username\n",
"用户名无效\n"
@ -141,10 +165,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"

View file

@ -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);
}

View file

@ -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"
);

167
src/tui.c
View file

@ -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;

109
tests/test_empty_view.sh Executable file
View file

@ -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" <<EOF
set timeout 10
stty rows 10 columns 80
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "viewer\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "mute-joins\r"
expect "Join/leave notifications"
expect "muted"
expect "q:close"
send -- "q"
expect "NORMAL"
expect "No visible messages"
send -- ":"
expect ":"
send -- "last 5\r"
expect "Last 0"
expect "No messages to show"
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$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"

View file

@ -591,6 +591,38 @@ else
FAIL=$((FAIL + 1))
fi
VIM_INSERT_ALIASES_SCRIPT="$STATE_DIR/vim-insert-aliases.expect"
cat >"$VIM_INSERT_ALIASES_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "vimalias\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- "a"
expect "INSERT"
send -- "\033"
expect "NORMAL"
send -- "o"
expect "INSERT"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$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"

132
tests/test_mute_joins_view.sh Executable file
View file

@ -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" <<EOF
set timeout 10
stty rows 12 columns 100
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "viewer\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "mute-joins\r"
expect "Join/leave notifications"
expect "muted"
expect "q:close"
send -- "q"
expect "NORMAL"
expect "kept visible 11"
expect "kept visible 18"
send -- "k"
expect "kept visible 09"
send -- ":"
expect ":"
send -- "last 10\r"
expect "Last"
expect "kept visible 18"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "search joined\r"
expect {showing last 0 match}
expect "No matches"
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$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"

View file

@ -37,6 +37,7 @@ SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/nul
SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
BOB_READY="$STATE_DIR/bob.ready"
PRIVATE_SENT="$STATE_DIR/private.sent"
REPLY_SENT="$STATE_DIR/reply.sent"
wait_for_health() {
out=""
@ -92,7 +93,16 @@ 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"
send -- ":"
expect ":"
send -- "reply private lifecycle reply\r"
expect "私信已发送给 alice"
exec touch "$REPLY_SENT"
expect "q:关闭"
send -- "q"
expect "NORMAL"
@ -201,12 +211,46 @@ 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:关闭"
send -- "q"
expect "NORMAL"
exec sh -c "while \[ ! -f '$REPLY_SENT' \]; do sleep 1; done"
send -- ":"
expect ":"
send -- "inbox\r"
expect "bob"
expect "private lifecycle reply"
expect "你 -> bob"
expect "private lifecycle second"
expect "private lifecycle first"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "inbox clear\r"
expect "私信已清空"
expect "(空)"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "reply should not send after clear\r"
expect "没有可回复的私信"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "nick alice2\r"
@ -244,6 +288,20 @@ else
fi
BOB_PID=""
if grep -q '.*alice.*private lifecycle second' "$STATE_DIR/bob.log" &&
grep -Eq '私信.*[0-9]+ 新' "$STATE_DIR/bob.log" &&
grep -q '\*.*alice.*private lifecycle second' "$STATE_DIR/bob.log" &&
grep -Eq '私信.*[0-9]+ 新' "$STATE_DIR/alice.log" &&
grep -q '\*.*bob.*private lifecycle reply' "$STATE_DIR/alice.log"; then
echo "✓ unread private messages are visibly marked in inbox"
PASS=$((PASS + 1))
else
echo "✗ inbox unread marker missing"
sed -n '1,220p' "$STATE_DIR/bob.log"
sed -n '1,260p' "$STATE_DIR/alice.log"
FAIL=$((FAIL + 1))
fi
TAIL_OUTPUT=$(ssh $SSH_EXEC_OPTS localhost "tail -n 10" 2>/dev/null || true)
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'hello lifecycle alpha' &&
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'alice2 ships lifecycle'

View file

@ -35,6 +35,18 @@ TEST(matches_canonical_names_and_aliases) {
assert(id == TNT_COMMAND_MSG);
assert(strcmp(args, "alice hello") == 0);
assert(command_catalog_match("reply hello back", &id, &args));
assert(id == TNT_COMMAND_REPLY);
assert(strcmp(args, "hello back") == 0);
assert(command_catalog_match("r hello back", &id, &args));
assert(id == TNT_COMMAND_REPLY);
assert(strcmp(args, "hello back") == 0);
assert(command_catalog_match("inbox clear", &id, &args));
assert(id == TNT_COMMAND_INBOX);
assert(strcmp(args, "clear") == 0);
assert(command_catalog_match("language zh", &id, &args));
assert(id == TNT_COMMAND_LANG);
assert(strcmp(args, "zh") == 0);
@ -65,6 +77,11 @@ TEST(validates_argument_shapes) {
assert(!command_catalog_args_valid(TNT_COMMAND_MSG, NULL));
assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello"));
assert(!command_catalog_args_valid(TNT_COMMAND_REPLY, ""));
assert(command_catalog_args_valid(TNT_COMMAND_REPLY, "hello back"));
assert(command_catalog_args_valid(TNT_COMMAND_INBOX, NULL));
assert(command_catalog_args_valid(TNT_COMMAND_INBOX, "clear"));
assert(!command_catalog_args_valid(TNT_COMMAND_INBOX, "clear now"));
assert(!command_catalog_args_valid(TNT_COMMAND_SEARCH, ""));
assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle"));
@ -92,13 +109,15 @@ TEST(generates_localized_help_sections) {
assert(strstr(en, ":users, :list, :who") != NULL);
assert(strstr(en, "Show online users") != NULL);
assert(strstr(en, ":msg <user> <message>") != NULL);
assert(strstr(en, "Show private messages") != NULL);
assert(strstr(en, ":reply <message>") != NULL);
assert(strstr(en, "Show or clear private messages") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(zh, ":users, :list, :who") != NULL);
assert(strstr(zh, "显示在线用户") != NULL);
assert(strstr(zh, "查看私信") != NULL);
assert(strstr(zh, "查看或清空私信") != NULL);
assert(strstr(zh, ":msg <user> <message>") != NULL);
assert(strstr(zh, ":reply <message>") != NULL);
assert(strstr(zh, "<用户>") == NULL);
assert(strstr(zh, "<消息>") == NULL);
assert(strstr(zh, ":support") == NULL);
@ -120,6 +139,19 @@ TEST(generates_localized_usage) {
assert(strcmp(zh, "用法: msg <user> <message>\n"
" w <user> <message>\n") == 0);
en[0] = '\0';
en_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_COMMAND_REPLY, UI_LANG_EN);
assert(strcmp(en, "Usage: reply <message>\n"
" r <message>\n") == 0);
zh[0] = '\0';
zh_pos = 0;
command_catalog_append_usage(zh, sizeof(zh), &zh_pos,
TNT_COMMAND_INBOX, UI_LANG_ZH);
assert(strcmp(zh, "用法: inbox [clear]\n") == 0);
en[0] = '\0';
en_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,

View file

@ -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),
@ -144,14 +148,34 @@ TEST(text_lookup_matches_language) {
"Private message sent") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT),
"私信已发送") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_REPLY_NO_TARGET),
"No private message") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_REPLY_NO_TARGET),
"可回复") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_TITLE),
"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_INBOX_CLEARED),
"cleared") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_CLEARED),
"清空") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_UNREAD_FORMAT),
"new") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_UNREAD_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 <en|zh>") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT),

27
tnt.1
View file

@ -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,10 @@ 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
:reply \fItext\fR Reply to latest private message
:r \fItext\fR Short alias for :reply
:inbox Show private messages, newest first
:inbox clear Clear private messages for this session
: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 +252,19 @@ 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. Incoming unread messages are marked with
.B *
and counted in the inbox title until the inbox renders them. Use
.B :reply
or
.B :r
to answer the latest private-message peer.
.B :inbox clear
removes private messages and the reply target for this session. Private
messages are not written to
.IR messages.log .
.SH EXEC INTERFACE
Commands can be run non\-interactively for scripting:
.PP
@ -340,10 +354,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.