mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 04:34:38 +08:00
tui: improve history browsing and support guide
This commit is contained in:
parent
87d6572156
commit
67d21ad0e9
25 changed files with 940 additions and 148 deletions
23
.github/workflows/deploy.yml
vendored
23
.github/workflows/deploy.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
Makefile
12
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)
|
||||
|
|
|
|||
13
README.md
13
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 <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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
docs/CICD.md
48
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
|
||||
|
|
|
|||
|
|
@ -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
16
include/history_view.h
Normal 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 */
|
||||
|
|
@ -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
10
include/support.h
Normal 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
12
include/tui_status.h
Normal 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 */
|
||||
15
src/client.c
15
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
25
src/exec.c
25
src/exec.c
|
|
@ -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
84
src/history_view.c
Normal 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;
|
||||
}
|
||||
210
src/input.c
210
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 = {
|
||||
|
|
|
|||
54
src/support.c
Normal file
54
src/support.c
Normal 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");
|
||||
}
|
||||
77
src/tui.c
77
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 <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
34
src/tui_status.c
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
172
tests/test_interactive_input.sh
Executable 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
110
tests/unit/test_history_view.c
Normal file
110
tests/unit/test_history_view.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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
12
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue