tui: improve history browsing and support guide

This commit is contained in:
m1ngsama 2026-05-21 11:57:59 +08:00
parent 87d6572156
commit 67d21ad0e9
25 changed files with 940 additions and 148 deletions

View file

@ -1,8 +1,10 @@
name: Deploy
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
@ -13,7 +15,7 @@ jobs:
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libssh-dev
sudo apt-get install -y expect libssh-dev
- name: Build
run: make
@ -26,20 +28,3 @@ jobs:
make test
cd tests
./test_security_features.sh
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /home/admin/repo/tnt
git pull origin main
make clean && make release
cp tnt /home/admin/tnt/tnt
sudo systemctl restart tnt

View file

@ -5,6 +5,7 @@ CC = gcc
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
LDFLAGS = -pthread -lssh
INCLUDES = -Iinclude
DEPFLAGS = -MMD -MP
# Detect libssh location (homebrew on macOS)
ifeq ($(shell uname), Darwin)
@ -21,9 +22,10 @@ OBJ_DIR = obj
SOURCES = $(wildcard $(SRC_DIR)/*.c)
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
DEPS = $(OBJECTS:.o=.d)
TARGET = tnt
.PHONY: all clean install uninstall debug release asan valgrind check info
.PHONY: all clean install uninstall debug release asan valgrind check test unit-test info
all: $(TARGET)
@ -32,7 +34,7 @@ $(TARGET): $(OBJECTS)
@echo "Build complete: $(TARGET)"
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
$(OBJ_DIR):
mkdir -p $(OBJ_DIR)
@ -76,7 +78,9 @@ check:
# Test
test: all unit-test
@echo "Running integration tests..."
@cd tests && ./test_basic.sh || echo "(integration tests are advisory)"
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh || echo "(basic integration tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh || echo "(exec mode tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh || echo "(interactive input tests are advisory)"
unit-test:
@echo "Running unit tests..."
@ -88,3 +92,5 @@ info:
@echo "Flags: $(CFLAGS)"
@echo "Sources: $(SOURCES)"
@echo "Objects: $(OBJECTS)"
-include $(DEPS)

View file

@ -62,15 +62,23 @@ Backspace - Delete character
Ctrl+W - Delete last word
Ctrl+U - Delete line
Ctrl+C - Enter NORMAL mode
Paste - Multi-line paste stays in the input buffer
```
The input line shows remaining bytes near the message limit. Extra input
past the limit is ignored with a terminal bell.
**NORMAL mode**
```
Opens at latest messages
Stays pinned to latest until you scroll up
i - Return to INSERT mode
: - Enter COMMAND mode
j/k - Scroll down/up one line
Ctrl+D/U - Scroll half page down/up
Ctrl+F/B - Scroll full page down/up
PgDn/PgUp - Scroll full page down/up
End/Home - Jump to bottom/top
g/G - Jump to top/bottom
? - Show help
Ctrl+C - Exit chat
@ -85,6 +93,7 @@ Ctrl+C - Exit chat
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search full message history (case-insensitive)
:mute-joins - Toggle join/leave system notifications
:support - Show quick support guide
:help - Show available commands
:clear - Clear command output
:q, :quit, :exit - Disconnect
@ -161,6 +170,7 @@ TNT also exposes a small non-interactive SSH surface for scripts:
ssh -p 2222 chat.m1ng.space health
ssh -p 2222 chat.m1ng.space stats --json
ssh -p 2222 chat.m1ng.space users
ssh -p 2222 chat.m1ng.space support
ssh -p 2222 chat.m1ng.space "tail -n 20"
ssh -p 2222 operator@chat.m1ng.space post "service notice"
ssh -p 2222 chat.m1ng.space post "/me deploys v2.0"
@ -230,7 +240,10 @@ TNT/
│ ├── ssh_server.c # SSH server implementation
│ ├── chat_room.c # chat room logic
│ ├── message.c # message persistence
│ ├── history_view.c # message viewport and scroll state
│ ├── support.c # quick support guide content
│ ├── tui.c # terminal UI rendering
│ ├── tui_status.c # status/input line rendering
│ └── utf8.c # UTF-8 character handling
├── include/ # header files
├── tests/ # test scripts

View file

@ -1,5 +1,44 @@
# Changelog
## 2026-05-21 - Message browsing polish
### Changed
- NORMAL mode now opens at the latest visible messages instead of the oldest
in-memory message. Use `k`/PageUp to browse older history and `G`/End to
return to the latest messages.
- NORMAL mode status now shows the visible message range and points users to
`G latest` when new messages arrive while they are browsing.
- NORMAL mode now keeps following the latest messages while the view is pinned
to the bottom; scrolling upward switches into history browsing.
- NORMAL mode now accepts arrow keys, PageUp/PageDown, and Home/End in addition
to the existing Vim-style keys.
- Message viewport and scroll-state rules now live in a focused
`history_view` module instead of being split across input and rendering code.
- Added unit coverage for `history_view` scroll boundaries, live-follow state,
and date-divider-aware latest windows.
- Status/input line rendering now lives in a focused `tui_status` module,
keeping the main TUI renderer closer to layout orchestration.
- Added `:support` / `support` quick guides so interactive users and SSH exec
clients can discover common actions and troubleshooting paths in-product.
- The GitHub workflow formerly named deploy now runs CI only; production
deployment remains a manual operator action.
## 2026-05-18 - Interactive input polish
### Added
- Bracketed paste handling keeps multi-line pasted text in the input buffer
until the user presses Enter, then sends it as one message.
- Input and paste overflow now rings the terminal bell when the 1023-byte
message limit is reached.
- Added an interactive `expect` regression test for basic TTY input,
bracketed paste, and overlong paste capping.
- Added the exec-mode regression test to the main `make test` path.
### Fixed
- SSH exec clients now survive stdin EOF long enough to flush stdout, exit
status, EOF, and channel close. This fixes non-interactive commands such as
`ssh localhost health` and `ssh user@host post "message"`.
## 2026-05-16 - Internal cleanup
### Fixed

View file

@ -1,16 +1,19 @@
CI/CD USAGE GUIDE
=================
CI / RELEASE GUIDE
==================
AUTOMATIC TESTING
-----------------
Every push or PR automatically runs:
- Build on Ubuntu and macOS
- AddressSanitizer checks
- Valgrind memory leak detection
- Build on Ubuntu
- AddressSanitizer build
- Unit and integration tests
Check status:
https://github.com/m1ngsama/TNT/actions
Production deployment is intentionally manual. The CI workflow must not SSH
into production or restart services on push.
CREATING RELEASES
-----------------
@ -32,11 +35,16 @@ CREATING RELEASES
DEPLOYING TO SERVERS
--------------------
Single command on any server:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
Deployments are operator-driven:
1. Build and test locally or in a temporary server directory.
2. Back up the installed binary.
3. Install the new binary.
4. Restart the service.
5. Run black-box checks (`health`, `stats --json`, `users --json`,
`support`, and a post/tail smoke test).
Or with specific version:
VERSION=v1.0.0 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
The installer can still be used manually on a server:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
PRODUCTION SETUP (systemd)
@ -58,14 +66,11 @@ PRODUCTION SETUP (systemd)
UPDATING SERVERS
----------------
Stop service:
sudo systemctl stop tnt
Run installer again:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
Restart:
sudo systemctl start tnt
Manual binary replacement pattern:
backup=/usr/local/bin/tnt.bak-$(date +%Y%m%d%H%M%S)
sudo cp -a /usr/local/bin/tnt "$backup"
sudo install -m 755 ./tnt /usr/local/bin/tnt
sudo systemctl restart tnt
PLATFORMS SUPPORTED
@ -87,8 +92,7 @@ git tag v1.0.1
git push origin v1.0.1
# Wait 5 minutes for builds
# Deploy to production servers
for server in server1 server2 server3; do
ssh $server "curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | VERSION=v1.0.1 sh"
ssh $server "sudo systemctl restart tnt"
done
# Deploy to production manually after validation
ssh server "sudo install -m 755 /tmp/tnt-build/tnt /usr/local/bin/tnt"
ssh server "sudo systemctl restart tnt"
ssh -p 2222 server health

View file

@ -25,6 +25,7 @@ COMMANDS (COMMAND mode, prefix with :)
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
support quick support guide
help show all commands
clear clear output
q / quit / exit disconnect
@ -32,13 +33,19 @@ COMMANDS (COMMAND mode, prefix with :)
INSERT MODE
/me <action> action message
@username mention (bell + highlight)
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
STRUCTURE
src/main.c entry, signals
src/ssh_server.c SSH, threads, commands
src/chat_room.c broadcast
src/message.c persistence, search
src/history_view.c message viewport / scroll state
src/support.c quick support guide content
src/tui.c rendering, help
src/tui_status.c status/input line rendering
src/utf8.c unicode
LIMITS

16
include/history_view.h Normal file
View file

@ -0,0 +1,16 @@
#ifndef HISTORY_VIEW_H
#define HISTORY_VIEW_H
#include "message.h"
int history_view_height(int terminal_height);
int history_view_max_scroll(int message_count, int view_height);
void history_view_scroll_to_latest(int *scroll_pos, bool *follow_tail,
int message_count, int view_height);
void history_view_scroll_to_oldest(int *scroll_pos, bool *follow_tail);
void history_view_scroll_by(int *scroll_pos, bool *follow_tail,
int message_count, int view_height, int delta);
int history_view_latest_start_for_height(const message_t *messages, int count,
int height);
#endif /* HISTORY_VIEW_H */

View file

@ -28,6 +28,7 @@ typedef struct client {
client_mode_t mode;
help_lang_t help_lang;
int scroll_pos;
bool follow_tail; /* NORMAL stays pinned to latest until user scrolls up */
int help_scroll_pos;
bool show_help;
char command_input[256];

10
include/support.h Normal file
View file

@ -0,0 +1,10 @@
#ifndef SUPPORT_H
#define SUPPORT_H
#include "common.h"
void support_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos);
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos);
#endif /* SUPPORT_H */

12
include/tui_status.h Normal file
View file

@ -0,0 +1,12 @@
#ifndef TUI_STATUS_H
#define TUI_STATUS_H
#include "common.h"
struct client;
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
const struct client *client, int msg_count,
int start, int end);
#endif /* TUI_STATUS_H */

View file

@ -33,6 +33,10 @@ int client_send(client_t *client, const char *data, size_t len) {
total += (size_t)sent;
}
if (client->exec_command[0] != '\0') {
ssh_blocking_flush(client->session, 1000);
}
pthread_mutex_unlock(&client->io_lock);
return 0;
}
@ -58,7 +62,9 @@ void client_release(client_t *client) {
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
}
if (client->channel) {
if (ssh_channel_is_open(client->channel)) {
ssh_channel_close(client->channel);
}
ssh_channel_free(client->channel);
}
if (client->session) {
@ -120,9 +126,14 @@ static void client_channel_eof(ssh_session session, ssh_channel channel,
client_t *client = (client_t *)userdata;
if (client) {
/* Exec clients commonly half-close stdin immediately after sending
* the command. Keep stdout usable so the exec handler can return
* output and an exit status. */
if (client->exec_command[0] == '\0') {
client->connected = false;
}
}
}
static void client_channel_close(ssh_session session, ssh_channel channel,
void *userdata) {

View file

@ -9,6 +9,7 @@
#include "client.h"
#include "common.h"
#include "message.h"
#include "support.h"
#include "tui.h"
#include "utf8.h"
#include <stdio.h>
@ -117,6 +118,7 @@ void commands_dispatch(client_t *client) {
"last [N] - Show last N messages\n"
"search <keyword> - Search message history\n"
"mute-joins - Toggle join/leave notices\n"
"support - Show quick support guide\n"
"help, commands - Show this help\n"
"clear, cls - Clear command output\n"
"q, quit, exit - Disconnect\n"
@ -127,6 +129,9 @@ void commands_dispatch(client_t *client) {
" @username - Mention (bell notify)\n"
"========================================\n");
} else if (strcmp(cmd, "support") == 0 || strcmp(cmd, "guide") == 0) {
support_append_interactive_panel(output, sizeof(output), &pos);
} else if (strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 2 : cmd + 4;
while (*rest == ' ') rest++;

View file

@ -5,6 +5,7 @@
#include "input.h"
#include "message.h"
#include "ratelimit.h"
#include "support.h"
#include "utf8.h"
#include <ctype.h>
#include <stdio.h>
@ -126,11 +127,20 @@ static int exec_command_help(client_t *client) {
" tail -n N Print recent messages\n"
" post MESSAGE Post a message non-interactively\n"
" post \"/me act\" Post an action message\n"
" support Show quick support guide\n"
" exit Exit successfully\n";
return client_send(client, help_text, sizeof(help_text) - 1) == 0 ? 0 : 1;
}
static int exec_command_support(client_t *client) {
char output[2048] = {0};
size_t pos = 0;
support_append_exec_panel(output, sizeof(output), &pos);
return client_send(client, output, pos) == 0 ? 0 : 1;
}
static int exec_command_health(client_t *client) {
static const char ok[] = "ok\n";
return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1;
@ -382,13 +392,17 @@ static int exec_command_post(client_t *client, const char *args) {
}
room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
if (message_save(&msg) < 0) {
client_printf(client, "post: failed to persist message\n");
if (client_send(client, "posted\n", 7) != 0) {
return 1;
}
return client_send(client, "posted\n", 7) == 0 ? 0 : 1;
notify_mentions(msg.content, client);
if (message_save(&msg) < 0) {
fprintf(stderr, "post: failed to persist message\n");
return 1;
}
return 0;
}
int exec_dispatch(client_t *client) {
@ -421,6 +435,9 @@ int exec_dispatch(client_t *client) {
if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) {
return exec_command_help(client);
}
if (strcmp(cmd, "support") == 0 || strcmp(cmd, "guide") == 0) {
return exec_command_support(client);
}
if (strcmp(cmd, "health") == 0) {
return exec_command_health(client);
}

84
src/history_view.c Normal file
View file

@ -0,0 +1,84 @@
#include "history_view.h"
static void message_date_key(const message_t *msg, char out[11]) {
struct tm tmi;
localtime_r(&msg->timestamp, &tmi);
strftime(out, 11, "%Y-%m-%d", &tmi);
}
static int rendered_rows_for_slice(const message_t *messages, int start,
int end) {
int rows = 0;
char last_date[11] = "";
for (int i = start; i < end; i++) {
char this_date[11];
message_date_key(&messages[i], this_date);
if (strcmp(this_date, last_date) != 0) {
rows++;
memcpy(last_date, this_date, sizeof(last_date));
}
rows++;
}
return rows;
}
int history_view_height(int terminal_height) {
int height = terminal_height - 3;
return height < 1 ? 1 : height;
}
int history_view_max_scroll(int message_count, int view_height) {
int max_scroll = message_count - view_height;
return max_scroll < 0 ? 0 : max_scroll;
}
void history_view_scroll_to_latest(int *scroll_pos, bool *follow_tail,
int message_count, int view_height) {
if (!scroll_pos || !follow_tail) return;
*scroll_pos = history_view_max_scroll(message_count, view_height);
*follow_tail = true;
}
void history_view_scroll_to_oldest(int *scroll_pos, bool *follow_tail) {
if (!scroll_pos || !follow_tail) return;
*scroll_pos = 0;
*follow_tail = false;
}
void history_view_scroll_by(int *scroll_pos, bool *follow_tail,
int message_count, int view_height, int delta) {
if (!scroll_pos || !follow_tail) return;
int max_scroll = history_view_max_scroll(message_count, view_height);
if (*follow_tail && delta < 0) {
*scroll_pos = max_scroll;
}
*scroll_pos += delta;
if (*scroll_pos < 0) {
*scroll_pos = 0;
} else if (*scroll_pos > max_scroll) {
*scroll_pos = max_scroll;
}
*follow_tail = *scroll_pos >= max_scroll;
}
int history_view_latest_start_for_height(const message_t *messages, int count,
int height) {
int start = count;
for (int candidate = count - 1; candidate >= 0; candidate--) {
int rows = rendered_rows_for_slice(messages, candidate, count);
if (rows > height) {
break;
}
start = candidate;
}
if (start == count && count > 0) {
start = count - 1;
}
return start;
}

View file

@ -4,6 +4,7 @@
#include "commands.h"
#include "common.h"
#include "exec.h"
#include "history_view.h"
#include "message.h"
#include "ratelimit.h"
#include "tui.h"
@ -150,15 +151,67 @@ void notify_mentions(const char *content, const client_t *sender) {
}
}
static int read_channel_exact(client_t *client, char *buf, size_t len,
int timeout_ms) {
size_t got = 0;
while (got < len) {
int n = ssh_channel_read_timeout(client->channel, buf + got,
len - got, 0, timeout_ms);
if (n == SSH_AGAIN || n <= 0) {
break;
}
got += (size_t)n;
}
return (int)got;
}
static bool append_paste_byte(char *input, unsigned char b) {
if (b == '\r' || b == '\n' || b == '\t') {
b = ' ';
}
if (b < 32) {
return true;
}
size_t cur = strlen(input);
if (cur < MAX_MESSAGE_LEN - 1) {
input[cur] = (char)b;
input[cur + 1] = '\0';
return true;
}
return false;
}
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),
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),
history_view_height(client->height), delta);
}
/* Handle a single key press. Returns true if the key was fully consumed
* (no further character buffering needed). */
static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle Ctrl+C (Exit or switch to NORMAL) */
if (key == 3) {
if (client->mode != MODE_NORMAL) {
client_mode_t previous_mode = client->mode;
if (previous_mode != MODE_NORMAL) {
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
client->show_help = false;
if (previous_mode == MODE_INSERT) {
normal_scroll_to_latest(client);
}
tui_render_screen(client);
} else {
/* In NORMAL mode, Ctrl+C exits */
@ -218,9 +271,13 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle command output / MOTD display: any key dismisses */
if (client->command_output[0] != '\0') {
bool was_motd = client->show_motd;
client->command_output[0] = '\0';
client->show_motd = false;
client->mode = MODE_NORMAL;
if (was_motd) {
normal_scroll_to_latest(client);
}
tui_render_screen(client);
return true; /* Key consumed */
}
@ -260,12 +317,64 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
}
tui_render_input(client, input);
return true;
} else if (seq[1] == '2') {
/* Could be bracketed-paste start "ESC[200~".
* Read the next 3 bytes and confirm. */
char rest[3];
int m = read_channel_exact(client, rest,
sizeof(rest), 500);
if (m == 3 && rest[0] == '0' && rest[1] == '0'
&& rest[2] == '~') {
/* Drain bytes into `input` until we see
* the end marker ESC[201~. Newlines become
* spaces so a multi-line paste stays a
* single message instead of N sends. */
bool overflow = false;
while (1) {
char b;
int k = ssh_channel_read_timeout(
client->channel, &b, 1, 0, 5000);
if (k != 1) break;
if (b == '\033') {
char tail[5];
int t = read_channel_exact(
client, tail, sizeof(tail), 500);
if (t == 5 && tail[0] == '['
&& tail[1] == '2'
&& tail[2] == '0'
&& tail[3] == '1'
&& tail[4] == '~') {
break; /* end of paste */
}
/* Stray ESC inside paste: drop the ESC
* but keep printable bytes that
* followed it. */
for (int i = 0; i < t; i++) {
if (!append_paste_byte(
input,
(unsigned char)tail[i])) {
overflow = true;
}
}
continue;
}
if (!append_paste_byte(input,
(unsigned char)b)) {
overflow = true;
}
}
tui_render_input(client, input);
if (overflow) {
client_send(client, "\a", 1);
}
}
return true;
}
}
}
/* Plain ESC — fall through to NORMAL mode */
client->mode = MODE_NORMAL;
client->scroll_pos = 0;
normal_scroll_to_latest(client);
tui_render_screen(client);
return true;
} else if (key == '\r' || key == '\n') { /* Enter */
@ -350,8 +459,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
if (plen == 0
? strcmp(uname, client->username) != 0
: strncasecmp(uname, prefix, plen) == 0) {
strncpy(match, uname, sizeof(match) - 1);
match[sizeof(match) - 1] = '\0';
snprintf(match, sizeof(match), "%s", uname);
break;
}
}
@ -375,14 +483,11 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
break;
case MODE_NORMAL: {
int nm_msg_count = room_get_message_count(g_room);
int nm_msg_height = client->height - 3;
if (nm_msg_height < 1) nm_msg_height = 1;
int nm_max_scroll = nm_msg_count - nm_msg_height;
if (nm_max_scroll < 0) nm_max_scroll = 0;
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);
return true;
@ -392,48 +497,79 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
tui_render_screen(client);
return true;
} else if (key == 'j') {
if (client->scroll_pos < nm_max_scroll) {
client->scroll_pos++;
normal_scroll_by(client, 1);
tui_render_screen(client);
}
return true;
} else if (key == 'k' && client->scroll_pos > 0) {
client->scroll_pos--;
normal_scroll_by(client, -1);
tui_render_screen(client);
return true;
} else if (key == 4) { /* Ctrl+D: half page down */
int half = nm_msg_height / 2;
if (half < 1) half = 1;
client->scroll_pos += half;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
normal_scroll_by(client, half);
tui_render_screen(client);
return true;
} else if (key == 21) { /* Ctrl+U: half page up */
int half = nm_msg_height / 2;
if (half < 1) half = 1;
client->scroll_pos -= half;
if (client->scroll_pos < 0) client->scroll_pos = 0;
normal_scroll_by(client, -half);
tui_render_screen(client);
return true;
} else if (key == 6) { /* Ctrl+F: full page down */
client->scroll_pos += nm_msg_height;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
normal_scroll_by(client, nm_msg_height);
tui_render_screen(client);
return true;
} else if (key == 2) { /* Ctrl+B: full page up */
client->scroll_pos -= nm_msg_height;
if (client->scroll_pos < 0) client->scroll_pos = 0;
normal_scroll_by(client, -nm_msg_height);
tui_render_screen(client);
return true;
} else if (key == 'g') {
client->scroll_pos = 0;
history_view_scroll_to_oldest(&client->scroll_pos,
&client->follow_tail);
tui_render_screen(client);
return true;
} else if (key == 'G') {
client->scroll_pos = nm_max_scroll;
normal_scroll_to_latest(client);
client->unread_mentions = 0;
tui_render_screen(client);
return true;
} else if (key == 27) {
char seq[4];
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
if (n == 1 && seq[0] == '[') {
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
if (n == 1) {
if (seq[1] == 'A') { /* Up arrow */
normal_scroll_by(client, -1);
} else if (seq[1] == 'B') { /* Down arrow */
normal_scroll_by(client, 1);
} else if (seq[1] == 'H') { /* Home */
history_view_scroll_to_oldest(&client->scroll_pos,
&client->follow_tail);
} else if (seq[1] == 'F') { /* End */
normal_scroll_to_latest(client);
} else if (seq[1] >= '1' && seq[1] <= '6') {
n = ssh_channel_read_timeout(client->channel,
&seq[2], 1, 0, 50);
if (n == 1 && seq[2] == '~') {
if (seq[1] == '5') { /* PageUp */
normal_scroll_by(client, -nm_msg_height);
} else if (seq[1] == '6') { /* PageDown */
normal_scroll_by(client, nm_msg_height);
} else if (seq[1] == '1') { /* Home */
history_view_scroll_to_oldest(
&client->scroll_pos,
&client->follow_tail);
} else if (seq[1] == '4') { /* End */
normal_scroll_to_latest(client);
}
}
}
tui_render_screen(client);
}
}
return true;
} else if (key == '?') {
client->show_help = true;
client->help_scroll_pos = 0;
@ -516,11 +652,13 @@ void input_run_session(client_t *client) {
char input[MAX_MESSAGE_LEN] = {0};
char buf[4];
bool joined_room = false;
bool bracketed_paste_enabled = false;
uint64_t seen_update_seq;
time_t last_keepalive = time(NULL);
/* Terminal size already set from PTY request */
client->mode = MODE_INSERT;
client->follow_tail = true;
client->help_lang = LANG_ZH;
client->connected = true;
client->command_history_count = 0;
@ -532,6 +670,9 @@ void input_run_session(client_t *client) {
if (client->exec_command[0] != '\0') {
int exit_status = exec_dispatch(client);
ssh_channel_request_send_exit_status(client->channel, exit_status);
ssh_channel_send_eof(client->channel);
ssh_blocking_flush(client->session, 1000);
ssh_channel_close(client->channel);
goto cleanup;
}
@ -547,6 +688,12 @@ void input_run_session(client_t *client) {
}
joined_room = true;
/* Enable xterm bracketed-paste mode only for interactive chat, so
* multi-line pastes arrive framed by ESC[200~...ESC[201~ instead of
* as a stream of Enters. Terminals that don't recognise it ignore it. */
client_send(client, "\033[?2004h", 8);
bracketed_paste_enabled = true;
/* Broadcast join message */
message_t join_msg = {
.timestamp = time(NULL),
@ -619,6 +766,10 @@ main_loop:
} else if (client->command_output[0] != '\0') {
tui_render_command_output(client);
} else {
if (room_updated && client->mode == MODE_NORMAL &&
client->follow_tail) {
normal_scroll_to_latest(client);
}
tui_render_screen(client);
if (client->mode == MODE_INSERT && input[0] != '\0') {
tui_render_input(client, input);
@ -666,6 +817,8 @@ main_loop:
input[len] = b;
input[len + 1] = '\0';
tui_render_input(client, input);
} else {
client_send(client, "\a", 1);
}
} else if (b >= 128) { /* UTF-8 multi-byte */
int char_len = utf8_byte_length(b);
@ -687,10 +840,12 @@ main_loop:
continue;
}
int len = strlen(input);
if (len + char_len < MAX_MESSAGE_LEN - 1) {
if (len + char_len <= MAX_MESSAGE_LEN - 1) {
memcpy(input + len, buf, char_len);
input[len + char_len] = '\0';
tui_render_input(client, input);
} else {
client_send(client, "\a", 1);
}
}
} else if (client->mode == MODE_COMMAND && !client->show_help &&
@ -724,6 +879,11 @@ main_loop:
}
cleanup:
if (bracketed_paste_enabled && client->channel &&
ssh_channel_is_open(client->channel)) {
client_send(client, "\033[?2004l", 8);
}
/* Broadcast leave message */
if (joined_room) {
message_t leave_msg = {

54
src/support.c Normal file
View file

@ -0,0 +1,54 @@
#include "support.h"
void support_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos) {
if (!buffer || !pos) return;
buffer_appendf(buffer, buf_size, pos,
"\033[1;36m支持 · support\033[0m\n"
"\n"
"\033[1;37m快速开始\033[0m\n"
" INSERT 输入消息Enter 发送ESC 进入 NORMAL\n"
" NORMAL 浏览消息G 回到最新i 继续输入\n"
" COMMAND 按 : 输入命令q/ESC 关闭当前面板\n"
"\n"
"\033[1;37m常用动作\033[0m\n"
" :users 查看在线用户\n"
" :last 20 查看最近 20 条历史\n"
" :search <keyword> 搜索聊天记录\n"
" :msg <user> <text> 私聊\n"
" :inbox 查看私聊收件箱\n"
" :mute-joins 静音加入/离开提示\n"
"\n"
"\033[1;37m遇到问题\033[0m\n"
" 看不到新消息: 在 NORMAL 按 G 或 End 回到最新\n"
" 粘贴多行文本: 直接粘贴TNT 会等 Enter 后一次发送\n"
" 输入太长: 状态行接近限制时会提示,超出会响铃\n"
" 连接断开: 可能是空闲超时、连接数限制或网络重连\n"
"\n"
"\033[2;37m更多: ? 打开完整按键帮助,:help 查看命令列表\033[0m\n");
}
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos) {
if (!buffer || !pos) return;
buffer_appendf(buffer, buf_size, pos,
"TNT support\n"
"\n"
"Interactive use:\n"
" ssh -p 2222 HOST\n"
" INSERT: type and press Enter to send\n"
" NORMAL: press G for latest, k/PageUp for older messages\n"
" COMMAND: press : then run users, last, search, msg, inbox\n"
"\n"
"Non-interactive checks:\n"
" ssh -p 2222 HOST health\n"
" ssh -p 2222 HOST stats --json\n"
" ssh -p 2222 HOST users --json\n"
" ssh -p 2222 HOST 'tail -n 20'\n"
" ssh -p 2222 USER@HOST post 'message'\n"
"\n"
"Troubleshooting:\n"
" Connection closes early: check rate limits, idle timeout,\n"
" global connection capacity, per-IP limits, and firewall rules.\n");
}

View file

@ -2,6 +2,8 @@
#include "client.h"
#include "ssh_server.h"
#include "chat_room.h"
#include "history_view.h"
#include "tui_status.h"
#include "utf8.h"
#include <unistd.h>
@ -255,22 +257,25 @@ void tui_render_screen(client_t *client) {
int msg_count = g_room->message_count;
pthread_rwlock_unlock(&g_room->lock);
/* Calculate which messages to show */
int msg_height = render_height - 3;
if (msg_height < 1) msg_height = 1;
/* Calculate which messages to show. The initial slice is capped by
* message count; the lock-held copy below tightens "latest" slices so
* date dividers cannot push the newest messages off-screen. */
int msg_height = history_view_height(render_height);
int start = 0;
int latest_scroll_start = history_view_max_scroll(msg_count, msg_height);
bool 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 > msg_count - msg_height) {
start = msg_count - msg_height;
if (start > latest_scroll_start) {
start = latest_scroll_start;
}
if (start < 0) start = 0;
} else {
/* INSERT mode: show latest */
if (msg_count > msg_height) {
start = msg_count - msg_height;
}
start = latest_scroll_start;
}
int end = start + msg_height;
@ -278,10 +283,11 @@ void tui_render_screen(client_t *client) {
/* Allocate snapshot outside the lock to avoid blocking writers */
message_t *msg_snapshot = NULL;
int snapshot_capacity = msg_height;
int snapshot_count = end - start;
if (snapshot_count > 0) {
msg_snapshot = calloc(snapshot_count, sizeof(message_t));
if (snapshot_count > 0 && snapshot_capacity > 0) {
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
}
/* Second pass under lock: copy messages */
@ -289,12 +295,22 @@ void tui_render_screen(client_t *client) {
pthread_rwlock_rdlock(&g_room->lock);
/* Re-clamp in case msg_count changed */
int actual_count = g_room->message_count;
int actual_end = (end <= actual_count) ? end : actual_count;
int actual_start = (start < actual_end) ? start : actual_end;
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_count) {
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;
@ -507,30 +523,7 @@ void tui_render_screen(client_t *client) {
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
/* Status/Input line */
if (client->mode == MODE_INSERT) {
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m\033[0m \033[K");
} else if (client->mode == MODE_NORMAL) {
int total = msg_count;
int scroll_pos = client->scroll_pos + 1;
if (total == 0) scroll_pos = 0;
int unseen = msg_count - end;
/* mode reverse-video chip + dim position + optional unseen marker */
if (unseen > 0) {
buffer_appendf(buffer, buf_size, &pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d / %d\033[0m"
" \033[33m▼ %d new\033[0m\033[K",
scroll_pos, total, unseen);
} else {
buffer_appendf(buffer, buf_size, &pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d / %d\033[0m\033[K",
scroll_pos, total);
}
} else if (client->mode == MODE_COMMAND) {
buffer_appendf(buffer, buf_size, &pos,
"\033[35m:\033[0m%s\033[K", client->command_input);
}
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
client_send(client, buffer, pos);
free(buffer);
@ -771,11 +764,15 @@ const char* tui_get_help_text(help_lang_t lang) {
" Ctrl+C - Enter NORMAL mode\n"
"\n"
"NORMAL MODE KEYS:\n"
" Opens at latest messages\n"
" Follows latest until you scroll up\n"
" i - Return to INSERT mode\n"
" : - Enter COMMAND mode\n"
" j/k - Scroll down/up one line\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n"
" ? - Show this help\n"
" Ctrl+C - Exit chat\n"
@ -788,6 +785,7 @@ const char* tui_get_help_text(help_lang_t lang) {
" :last [N] - Show last N messages (max 50)\n"
" :search <keyword> - Search message history\n"
" :mute-joins - Toggle join/leave notices\n"
" :support - Show quick support guide\n"
" :help - Show available commands\n"
" :clear - Clear command output\n"
" :q, :quit, :exit - Disconnect\n"
@ -820,11 +818,15 @@ const char* tui_get_help_text(help_lang_t lang) {
" Ctrl+C - 进入 NORMAL 模式\n"
"\n"
"NORMAL 模式按键:\n"
" 默认停在最新消息\n"
" 未向上翻阅时自动跟随最新消息\n"
" i - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n"
" j/k - 向下/上滚动一行\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n"
" ? - 显示此帮助\n"
" Ctrl+C - 退出聊天\n"
@ -837,6 +839,7 @@ const char* tui_get_help_text(help_lang_t lang) {
" :last [N] - 显示最后 N 条消息(最多50)\n"
" :search <关键词> - 搜索消息历史\n"
" :mute-joins - 切换加入/离开提示\n"
" :support - 显示快速支持指南\n"
" :help - 显示可用命令\n"
" :clear - 清空命令输出\n"
" :q, :quit, :exit - 断开连接\n"

34
src/tui_status.c Normal file
View file

@ -0,0 +1,34 @@
#include "tui_status.h"
#include "ssh_server.h"
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
const struct client *client, int msg_count,
int start, int end) {
if (!buffer || !pos || !client) return;
if (client->mode == MODE_INSERT) {
buffer_appendf(buffer, buf_size, pos, "\033[2;37m\033[0m \033[K");
} else if (client->mode == MODE_NORMAL) {
int total = msg_count;
int range_start = total == 0 ? 0 : start + 1;
int range_end = total == 0 ? 0 : end;
int unseen = msg_count - end;
if (unseen > 0) {
buffer_appendf(buffer, buf_size, pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d-%d / %d\033[0m"
" \033[33m▼ %d new · G latest\033[0m\033[K",
range_start, range_end, total, unseen);
} else {
buffer_appendf(buffer, buf_size, pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d-%d / %d\033[0m"
" \033[2;37mG latest\033[0m\033[K",
range_start, range_end, total);
}
} else if (client->mode == MODE_COMMAND) {
buffer_appendf(buffer, buf_size, pos,
"\033[35m:\033[0m%s\033[K", client->command_input);
}
}

View file

@ -5,34 +5,36 @@
PORT=${PORT:-2222}
PASS=0
FAIL=0
BIN="../tnt"
SERVER_PID=""
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-basic-test.XXXXXX")
cleanup() {
kill $SERVER_PID 2>/dev/null
rm -f test.log
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
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
fi
echo "=== TNT Basic Tests ==="
# Path to binary
BIN="../tnt"
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
if ! command -v expect >/dev/null 2>&1; then
echo "expect not installed; skipping basic interactive tests"
exit 0
fi
# Start server
$BIN -p $PORT >test.log 2>&1 &
"$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
sleep 5
sleep 2
# Test 1: Server started
if kill -0 $SERVER_PID 2>/dev/null; then
@ -40,29 +42,58 @@ if kill -0 $SERVER_PID 2>/dev/null; then
PASS=$((PASS + 1))
else
echo "✗ Server failed to start"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
exit 1
fi
# Test 2: SSH connection
if $TIMEOUT_CMD 5 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o BatchMode=yes -p $PORT localhost exit 2>/dev/null; then
CONNECT_SCRIPT="$STATE_DIR/connect.expect"
cat >"$CONNECT_SCRIPT" <<EOF
set timeout 10
spawn ssh -e none -p $PORT -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR anonymous@127.0.0.1
sleep 1
send -- "basic\r"
expect ""
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$CONNECT_SCRIPT" >"$STATE_DIR/connect.log" 2>&1; then
echo "✓ SSH connection works"
PASS=$((PASS + 1))
else
echo "✗ SSH connection failed"
sed -n '1,120p' "$STATE_DIR/connect.log"
FAIL=$((FAIL + 1))
fi
# Test 3: Message logging
(echo "testuser"; echo "test message"; sleep 1) | $TIMEOUT_CMD 5 ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -p $PORT localhost >/dev/null 2>&1 &
sleep 3
if [ -f messages.log ]; then
MESSAGE_SCRIPT="$STATE_DIR/message.expect"
cat >"$MESSAGE_SCRIPT" <<EOF
set timeout 10
spawn ssh -e none -p $PORT -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR anonymous@127.0.0.1
sleep 1
send -- "testuser\r"
expect ""
send -- "test message\r"
sleep 1
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$MESSAGE_SCRIPT" >"$STATE_DIR/message.log.out" 2>&1 &&
grep -q 'testuser|test message' "$STATE_DIR/messages.log"; then
echo "✓ Message logging works"
PASS=$((PASS + 1))
else
echo "✗ Message logging failed"
sed -n '1,120p' "$STATE_DIR/message.log.out"
cat "$STATE_DIR/messages.log" 2>/dev/null || true
FAIL=$((FAIL + 1))
fi

View file

@ -25,7 +25,7 @@ if [ ! -f "$BIN" ]; then
exit 1
fi
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
echo "=== TNT Exec Mode Tests ==="
@ -75,6 +75,18 @@ else
FAIL=$((FAIL + 1))
fi
SUPPORT_OUTPUT=$(ssh $SSH_OPTS localhost support 2>/dev/null || true)
printf '%s\n' "$SUPPORT_OUTPUT" | grep -q '^TNT support$' &&
printf '%s\n' "$SUPPORT_OUTPUT" | grep -q '^Troubleshooting:'
if [ $? -eq 0 ]; then
echo "✓ support returns quick guide"
PASS=$((PASS + 1))
else
echo "✗ support output unexpected"
printf '%s\n' "$SUPPORT_OUTPUT"
FAIL=$((FAIL + 1))
fi
POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true)
if [ "$POST_OUTPUT" = "posted" ]; then
echo "✓ post publishes a message"
@ -112,7 +124,7 @@ EOF
expect "$EXPECT_SCRIPT" >"${STATE_DIR}/expect.log" 2>&1 &
INTERACTIVE_PID=$!
for _ in 1 2 3 4 5 6 7 8 9 10; do
for _ in 1 2 3 4 5; do
[ -f "$WATCHER_READY" ] && break
sleep 1
done
@ -138,7 +150,7 @@ else
fi
USERS_JSON=""
for _ in 1 2 3 4 5; do
for _ in 1 2 3 4 5 6 7 8 9 10; do
USERS_JSON=$(ssh $SSH_OPTS localhost users --json 2>/dev/null || true)
printf '%s\n' "$USERS_JSON" | grep -q '"watcher"' && break
sleep 1

172
tests/test_interactive_input.sh Executable file
View file

@ -0,0 +1,172 @@
#!/bin/sh
# Interactive input regression tests for TNT.
PORT=${PORT:-12347}
PASS=0
FAIL=0
BIN="../tnt"
SERVER_PID=""
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-input-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 interactive input tests"
exit 0
fi
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_OPTS="-e none -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT"
echo "=== TNT Interactive Input Tests ==="
TNT_RATE_LIMIT=0 "$BIN" -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
EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
cat >"$EXPECT_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "tester\r"
expect ""
send -- "\033\[200~"
send -- "line1\nline2\nline3"
send -- "\033\[201~"
sleep 1
send -- "\r"
sleep 1
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$EXPECT_SCRIPT" >"$STATE_DIR/expect.log" 2>&1; then
if grep -q 'tester|line1 line2 line3' "$STATE_DIR/messages.log" &&
! grep -q 'tester|line1$' "$STATE_DIR/messages.log"; then
echo "✓ bracketed paste becomes one message"
PASS=$((PASS + 1))
else
echo "x bracketed paste message log unexpected"
cat "$STATE_DIR/messages.log" 2>/dev/null || true
FAIL=$((FAIL + 1))
fi
else
echo "x bracketed paste client failed"
sed -n '1,120p' "$STATE_DIR/expect.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
LONG_SCRIPT="$STATE_DIR/long-paste.expect"
cat >"$LONG_SCRIPT" <<EOF
set timeout 10
set payload [string repeat a 1100]
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "longer\r"
expect ""
send -- "\033\[200~"
send -- \$payload
send -- "\033\[201~"
sleep 1
send -- "\r"
sleep 1
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$LONG_SCRIPT" >"$STATE_DIR/long-paste.log" 2>&1; then
long_line=$(grep 'longer|' "$STATE_DIR/messages.log" | tail -1)
content=${long_line#*|}
content=${content#*|}
content_len=$(printf '%s' "$content" | wc -c | tr -d ' ')
if [ "$content_len" -eq 1023 ]; then
echo "✓ overlong paste is capped at message limit"
PASS=$((PASS + 1))
else
echo "x overlong paste length unexpected: $content_len"
FAIL=$((FAIL + 1))
fi
else
echo "x overlong paste client failed"
sed -n '1,120p' "$STATE_DIR/long-paste.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
SUPPORT_SCRIPT="$STATE_DIR/support.expect"
cat >"$SUPPORT_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "supporter\r"
expect ""
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "support\r"
expect "支持"
expect "Press any key"
send -- "q"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$SUPPORT_SCRIPT" >"$STATE_DIR/support.log" 2>&1; then
echo "✓ :support renders quick guide"
PASS=$((PASS + 1))
else
echo "x :support command failed"
sed -n '1,160p' "$STATE_DIR/support.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

@ -14,8 +14,9 @@ UTF8_SRC = ../../src/utf8.c
MESSAGE_SRC = ../../src/message.c
COMMON_SRC = ../../src/common.c
CHAT_ROOM_SRC = ../../src/chat_room.c
HISTORY_VIEW_SRC = ../../src/history_view.c
TESTS = test_utf8 test_message test_chat_room
TESTS = test_utf8 test_message test_chat_room test_history_view
.PHONY: all clean run
@ -30,6 +31,9 @@ test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_history_view: test_history_view.c $(HISTORY_VIEW_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
run: all
@echo "=== Running UTF-8 Tests ==="
./test_utf8
@ -39,6 +43,9 @@ run: all
@echo ""
@echo "=== Running Chat Room Tests ==="
./test_chat_room
@echo ""
@echo "=== Running History View Tests ==="
./test_history_view
clean:
rm -f $(TESTS) *.o test_messages.log

View file

@ -0,0 +1,110 @@
/* Unit tests for history_view viewport and scroll rules */
#include "../../include/history_view.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#define TEST(name) static void test_##name()
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("\n"); \
tests_passed++; \
} while(0)
static int tests_passed = 0;
static message_t make_msg(time_t timestamp, const char *content) {
message_t msg = { .timestamp = timestamp };
snprintf(msg.username, sizeof(msg.username), "user");
snprintf(msg.content, sizeof(msg.content), "%s", content);
return msg;
}
TEST(height_clamps_to_message_area) {
assert(history_view_height(24) == 21);
assert(history_view_height(4) == 1);
assert(history_view_height(1) == 1);
assert(history_view_height(0) == 1);
}
TEST(max_scroll_clamps_to_zero) {
assert(history_view_max_scroll(0, 20) == 0);
assert(history_view_max_scroll(10, 20) == 0);
assert(history_view_max_scroll(20, 20) == 0);
assert(history_view_max_scroll(25, 20) == 5);
}
TEST(scroll_to_latest_enables_follow_tail) {
int scroll = 0;
bool follow = false;
history_view_scroll_to_latest(&scroll, &follow, 30, 10);
assert(scroll == 20);
assert(follow == true);
}
TEST(scroll_to_oldest_disables_follow_tail) {
int scroll = 12;
bool follow = true;
history_view_scroll_to_oldest(&scroll, &follow);
assert(scroll == 0);
assert(follow == false);
}
TEST(scroll_by_clamps_and_toggles_follow) {
int scroll = 20;
bool follow = true;
history_view_scroll_by(&scroll, &follow, 30, 10, -3);
assert(scroll == 17);
assert(follow == false);
history_view_scroll_by(&scroll, &follow, 30, 10, 100);
assert(scroll == 20);
assert(follow == true);
history_view_scroll_by(&scroll, &follow, 30, 10, -100);
assert(scroll == 0);
assert(follow == false);
}
TEST(latest_start_counts_date_dividers) {
message_t messages[6];
messages[0] = make_msg(1704067200, "day1-1"); /* 2024-01-01 */
messages[1] = make_msg(1704067260, "day1-2");
messages[2] = make_msg(1704153600, "day2-1"); /* 2024-01-02 */
messages[3] = make_msg(1704153660, "day2-2");
messages[4] = make_msg(1704240000, "day3-1"); /* 2024-01-03 */
messages[5] = make_msg(1704240060, "day3-2");
assert(history_view_latest_start_for_height(messages, 6, 3) == 4);
assert(history_view_latest_start_for_height(messages, 6, 4) == 4);
assert(history_view_latest_start_for_height(messages, 6, 5) == 3);
assert(history_view_latest_start_for_height(messages, 6, 6) == 2);
}
TEST(latest_start_handles_empty_and_tiny_view) {
message_t messages[1];
messages[0] = make_msg(1704067200, "only");
assert(history_view_latest_start_for_height(messages, 0, 3) == 0);
assert(history_view_latest_start_for_height(messages, 1, 1) == 0);
}
int main(void) {
printf("=== History View Unit Tests ===\n");
RUN_TEST(height_clamps_to_message_area);
RUN_TEST(max_scroll_clamps_to_zero);
RUN_TEST(scroll_to_latest_enables_follow_tail);
RUN_TEST(scroll_to_oldest_disables_follow_tail);
RUN_TEST(scroll_by_clamps_and_toggles_follow);
RUN_TEST(latest_start_counts_date_dividers);
RUN_TEST(latest_start_handles_empty_and_tiny_view);
printf("\nAll %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -22,18 +22,6 @@ static void cleanup_test_log(void) {
unlink(test_log);
}
/* Helper: Create test log with N messages */
static void create_test_log(int count) {
FILE *fp = fopen(test_log, "w");
assert(fp != NULL);
for (int i = 0; i < count; i++) {
fprintf(fp, "2026-02-08T10:00:%02d+08:00|user%d|Test message %d\n",
i, i, i);
}
fclose(fp);
}
/* Test message initialization */
TEST(message_init) {
message_init();
@ -48,7 +36,6 @@ TEST(message_load_empty) {
FILE *fp = fopen(test_log, "w");
fclose(fp);
message_t *messages = NULL;
/* Can't easily override LOG_FILE constant, so this is a documentation test */
cleanup_test_log();

12
tnt.1
View file

@ -84,21 +84,31 @@ ESC Switch to NORMAL
Ctrl+W Delete last word
Ctrl+U Clear input line
Ctrl+C Switch to NORMAL
Paste Keep multi-line paste in the input buffer
/me \fIaction\fR Send action message (e.g. /me waves)
@\fIusername\fR Mention user (bell notification + highlight)
.TE
.PP
The input line shows remaining bytes near the message limit. Extra input
past the limit is ignored with a terminal bell.
.SS NORMAL mode
.TS
l l.
j/k Scroll down/up one line
Ctrl+D/Ctrl+U Scroll half page down/up
Ctrl+F/Ctrl+B Scroll full page down/up
PageDown/PageUp Scroll full page down/up
End/Home Jump to bottom/top
g/G Jump to top/bottom
i Switch to INSERT
: Enter COMMAND mode
? Open help screen
Ctrl+C Disconnect
.TE
.PP
NORMAL mode opens on the latest visible messages and stays pinned there
until you scroll up. Use k, Ctrl+U, Ctrl+B, or PageUp to move toward
older history; use G or End to return to the latest messages.
.SS COMMAND mode
.TS
l l.
@ -110,6 +120,7 @@ l l.
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
:search \fIkeyword\fR Case\-insensitive search across full message history
:mute\-joins Toggle join/leave system notifications on/off
:support Show quick support guide
:help Show available commands
:clear Clear command output
:q, :quit, :exit Disconnect
@ -121,6 +132,7 @@ Commands can be run non\-interactively for scripting:
.PP
.nf
ssh host \-p 2222 help
ssh host \-p 2222 support
ssh host \-p 2222 users \-\-json
ssh host \-p 2222 stats \-\-json
ssh host \-p 2222 tail 20