Whispers used to flash on the recipient's terminal and disappear with
the next redraw. No history, no record, no signal if you weren't
looking.
Now whispers are stored per-recipient in a bounded inbox (16 slots,
FIFO eviction):
typedef struct { time_t timestamp; char from[]; char content[]; } whisper_t;
whisper_t whisper_inbox[16];
int whisper_inbox_count;
_Atomic int unread_whispers;
Sender side (:msg / :w):
- resolves target as before
- pushes the whisper into target->whisper_inbox under target->io_lock
(so two simultaneous senders to the same recipient don't tear the
ring)
- bumps target->unread_whispers atomically
- sends a single \a bell + triggers redraw_pending
- no longer writes whisper text directly to the channel (which used
to get clobbered by the next redraw)
Recipient side:
- new :inbox command in COMMAND mode prints the snapshot under
io_lock, in M7 chat-list style:
悄悄话 · whispers · 3
05-17 13:42 alice: 一会儿要不要喝咖啡
...
- viewing :inbox resets unread_whispers to 0
Title bar (extends UX-11):
- bright magenta "✉ N" chip alongside the yellow "★ N" mention chip
- same priority / degradation rules as ★
Whispers remain private — they're never broadcast to the room and
never persisted to messages.log. The inbox lives only in client_t,
so disconnecting drops it.
:list used to look like an ASCII printout from the 90s:
========================================
Online Users / 在线用户
========================================
Total / 总数: 3
----------------------------------------
* 1. alice (5m)
2. bob (12s)
3. carol (1h2m)
========================================
* = you / 你
Now it matches the rest of the TUI:
在线用户 · online · 3
▎ alice · 5m
bob · 12s
carol · 1h2m
- bold cyan title chip, dim grey total count
- 1-column ▎ gutter on your own row (same vocabulary as UX-1
message gutter)
- dim grey "· duration" separator instead of parentheses + ASCII rule
- no trailing rule, no legend explaining "* = you" because the
gutter speaks for itself
:search dumps the matching lines with username and content, but the
query word itself was just rendered as-is. In a result set with 15
matches you had to eye-scan each line for the keyword.
Now each occurrence of the (case-insensitively matched) needle is
wrapped in a reverse-yellow ANSI chip both in the username column and
in the content column. Original casing of the matched substring is
preserved.
Helper:
append_highlighted(output, buf_size, &pos, text, needle)
emits text into the output buffer with every case-insensitive hit
wrapped in `\033[7;33m … \033[0m`.
strcasestr() needs _DEFAULT_SOURCE / _DARWIN_C_SOURCE feature macros;
the same dance message.c already does is now mirrored in commands.c.
:nick used to swap client->username unconditionally, so two clients
could both end up named "alice" — and the subsequent :msg / :w
disambiguation would just hit whichever came first in g_room->clients.
Now the rename happens under wrlock with an explicit scan: if any
*other* client already owns that username, the change is refused with
Nickname 'alice' is already taken
If the requested name equals the current one, return a short
"Nickname unchanged" instead of broadcasting a no-op system message.
The room-lock-held scan is O(n) over current clients (n ≤ 64 by
default) and folds naturally into the existing wrlock, so there's no
new lock acquisition.
Move client_t I/O and lifecycle out of ssh_server.c into a dedicated
module. ssh_server.c is now down to the listening socket, host key
setup, ssh_server_init / ssh_server_start, and the accept loop.
Migrated to client.{c,h}:
- client_send, client_printf
- client_addref, client_release (and the ssh_session / ssh_channel /
channel_cb teardown that fires on the final release)
- client_install_channel_callbacks
- client_channel_window_change / _eof / _close (the post-bootstrap
callbacks that target the client_t)
The client_t struct definition stays in ssh_server.h so we don't have
to revisit every existing #include chain. bootstrap, commands, exec,
input, and tui pick up the new client.h alongside their existing
ssh_server.h include.
ssh_server.c shrinks from 402 to 249 lines (-153).
Final per-module size after PR2:
ssh_server.c 249 accept loop + ssh_server_init/start + host key
bootstrap.c 493 per-connection SSH handshake / auth / channel
client.c 162 client_t I/O + lifecycle + channel callbacks
input.c 637 username read + handle_key + main loop +
notify_mentions + idle timeout
commands.c 269 vim ':' command dispatcher
exec.c 453 SSH exec subcommand dispatcher
ratelimit.c 197 IP rate limit + connection counters
tui.c 614 screen rendering
chat_room.c 151 room + client list
message.c 350 message log load/save/search
utf8.c 250 UTF-8 width / validation
common.c 133 buffer_*, env_int, is_valid_username,
sanitize_terminal_size, tnt_state_*
main.c 109 process entry
Total: ~4 000 lines of C across 13 files, no file over 650 lines, every
file is single-purpose. Behaviour is preserved: the migrated code is
byte-for-byte identical.
Move the vim-mode `:` command dispatcher (`:list`, `:nick`, `:msg`,
`:last`, `:search`, `:mute-joins`, `:help`, `:clear`, `:q`, …) out of
ssh_server.c into a dedicated module.
New API (include/commands.h):
- commands_dispatch(client_t *) -- single entry point invoked from
handle_key when Enter is pressed in MODE_COMMAND.
ssh_server.c shrinks from 1769 to 1513 lines (-256).
Behaviour preserved: implementation is byte-for-byte the same.