From 67d21ad0e92bd4eafbebcedf1c2ce9406fedd044 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 21 May 2026 11:57:59 +0800 Subject: [PATCH] tui: improve history browsing and support guide --- .github/workflows/deploy.yml | 23 +--- Makefile | 12 +- README.md | 13 ++ docs/CHANGELOG.md | 39 ++++++ docs/CICD.md | 48 ++++---- docs/QUICKREF.md | 7 ++ include/history_view.h | 16 +++ include/ssh_server.h | 1 + include/support.h | 10 ++ include/tui_status.h | 12 ++ src/client.c | 15 ++- src/commands.c | 5 + src/exec.c | 25 +++- src/history_view.c | 84 +++++++++++++ src/input.c | 210 ++++++++++++++++++++++++++++---- src/support.c | 54 ++++++++ src/tui.c | 77 ++++++------ src/tui_status.c | 34 ++++++ tests/test_basic.sh | 69 ++++++++--- tests/test_exec_mode.sh | 18 ++- tests/test_interactive_input.sh | 172 ++++++++++++++++++++++++++ tests/unit/Makefile | 9 +- tests/unit/test_history_view.c | 110 +++++++++++++++++ tests/unit/test_message.c | 13 -- tnt.1 | 12 ++ 25 files changed, 940 insertions(+), 148 deletions(-) create mode 100644 include/history_view.h create mode 100644 include/support.h create mode 100644 include/tui_status.h create mode 100644 src/history_view.c create mode 100644 src/support.c create mode 100644 src/tui_status.c create mode 100755 tests/test_interactive_input.sh create mode 100644 tests/unit/test_history_view.c diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8e941b1..74eb204 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/Makefile b/Makefile index 290c2da..01f64f9 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index 72a7078..7b93aa7 100644 --- a/README.md +++ b/README.md @@ -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 - 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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 007961b..7863e39 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CICD.md b/docs/CICD.md index d31c584..c775b23 100644 --- a/docs/CICD.md +++ b/docs/CICD.md @@ -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 diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index fef3ba4..084eab5 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -25,6 +25,7 @@ COMMANDS (COMMAND mode, prefix with :) last [N] last N messages from log (default 10, max 50) search search full history (case-insensitive, 15 results) mute-joins toggle join/leave notifications + 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 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 diff --git a/include/history_view.h b/include/history_view.h new file mode 100644 index 0000000..215eb97 --- /dev/null +++ b/include/history_view.h @@ -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 */ diff --git a/include/ssh_server.h b/include/ssh_server.h index 8cbafc3..37b6761 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -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]; diff --git a/include/support.h b/include/support.h new file mode 100644 index 0000000..4768aa4 --- /dev/null +++ b/include/support.h @@ -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 */ diff --git a/include/tui_status.h b/include/tui_status.h new file mode 100644 index 0000000..7509cbd --- /dev/null +++ b/include/tui_status.h @@ -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 */ diff --git a/src/client.c b/src/client.c index def3c9d..c219d0d 100644 --- a/src/client.c +++ b/src/client.c @@ -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) { - ssh_channel_close(client->channel); + if (ssh_channel_is_open(client->channel)) { + ssh_channel_close(client->channel); + } ssh_channel_free(client->channel); } if (client->session) { @@ -120,7 +126,12 @@ static void client_channel_eof(ssh_session session, ssh_channel channel, client_t *client = (client_t *)userdata; if (client) { - client->connected = false; + /* 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; + } } } diff --git a/src/commands.c b/src/commands.c index 4200463..a67725c 100644 --- a/src/commands.c +++ b/src/commands.c @@ -9,6 +9,7 @@ #include "client.h" #include "common.h" #include "message.h" +#include "support.h" #include "tui.h" #include "utf8.h" #include @@ -117,6 +118,7 @@ void commands_dispatch(client_t *client) { "last [N] - Show last N messages\n" "search - 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++; diff --git a/src/exec.c b/src/exec.c index fae6ba6..3df1d3d 100644 --- a/src/exec.c +++ b/src/exec.c @@ -5,6 +5,7 @@ #include "input.h" #include "message.h" #include "ratelimit.h" +#include "support.h" #include "utf8.h" #include #include @@ -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); } diff --git a/src/history_view.c b/src/history_view.c new file mode 100644 index 0000000..3563b82 --- /dev/null +++ b/src/history_view.c @@ -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; +} diff --git a/src/input.c b/src/input.c index fd2acda..bec45a1 100644 --- a/src/input.c +++ b/src/input.c @@ -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++; - tui_render_screen(client); - } + 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 = { diff --git a/src/support.c b/src/support.c new file mode 100644 index 0000000..376eced --- /dev/null +++ b/src/support.c @@ -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 搜索聊天记录\n" + " :msg 私聊\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"); +} diff --git a/src/tui.c b/src/tui.c index 78643f5..2a20461 100644 --- a/src/tui.c +++ b/src/tui.c @@ -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 @@ -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 - 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" diff --git a/src/tui_status.c b/src/tui_status.c new file mode 100644 index 0000000..5d6f48b --- /dev/null +++ b/src/tui_status.c @@ -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); + } +} diff --git a/tests/test_basic.sh b/tests/test_basic.sh index 791a084..46f4d9e 100755 --- a/tests/test_basic.sh +++ b/tests/test_basic.sh @@ -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" <"$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" <"$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 diff --git a/tests/test_exec_mode.sh b/tests/test_exec_mode.sh index d43a046..a03d288 100755 --- a/tests/test_exec_mode.sh +++ b/tests/test_exec_mode.sh @@ -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 diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh new file mode 100755 index 0000000..0f5facd --- /dev/null +++ b/tests/test_interactive_input.sh @@ -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" <"$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" <"$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" <"$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" diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 5185ecf..333db75 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -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 diff --git a/tests/unit/test_history_view.c b/tests/unit/test_history_view.c new file mode 100644 index 0000000..1fcd9b1 --- /dev/null +++ b/tests/unit/test_history_view.c @@ -0,0 +1,110 @@ +/* Unit tests for history_view viewport and scroll rules */ + +#include "../../include/history_view.h" +#include +#include +#include + +#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; +} diff --git a/tests/unit/test_message.c b/tests/unit/test_message.c index 4b7ad3e..0eb9012 100644 --- a/tests/unit/test_message.c +++ b/tests/unit/test_message.c @@ -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(); diff --git a/tnt.1 b/tnt.1 index b77da27..5ddd6c4 100644 --- a/tnt.1 +++ b/tnt.1 @@ -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