Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)**
```
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/EASY_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion docs/MESSAGE_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions docs/QUICKREF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
27 changes: 18 additions & 9 deletions docs/USER_LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.

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

Expand Down
1 change: 1 addition & 0 deletions include/command_catalog.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions include/i18n.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions include/ssh_server.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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];
Expand Down
3 changes: 3 additions & 0 deletions include/tui.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions src/client.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 19 additions & 4 deletions src/command_catalog.c
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
Expand Down Expand Up @@ -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';
}
Expand Down
Loading