From 1f1c2398b6ca9df64f4bb151bc24af275572b1ba Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 24 May 2026 11:55:26 +0800 Subject: [PATCH] tui: make command output scrollable --- docs/CHANGELOG.md | 3 ++ include/common.h | 1 + include/i18n.h | 4 +- include/ssh_server.h | 3 +- src/commands.c | 6 +-- src/help_text.c | 14 ++++++ src/i18n.c | 8 ++-- src/input.c | 79 +++++++++++++++++++++++++++++---- src/tui.c | 40 +++++++++++++---- tests/test_interactive_input.sh | 71 +++++++++++++++++++++++------ tests/unit/test_help_text.c | 2 + tests/unit/test_i18n.c | 4 ++ 12 files changed, 194 insertions(+), 41 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1002eac..8ae32ca 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changed - Command names, aliases, help summaries, concise-manual command rows, and unknown-command suggestions now share a dedicated `command_catalog` module. +- COMMAND-mode output is now a small scrollable pager with `j/k`, page + movement, `g/G`, and `q`/Esc close controls, so long `:last` and `:search` + results are readable instead of being cut off by the terminal height. - Collapsed the interactive help surface around a concise Unix-style `:help` manual and the `?` full key reference; `:support` is no longer a user-facing command. diff --git a/include/common.h b/include/common.h index 1c3af09..73edf40 100644 --- a/include/common.h +++ b/include/common.h @@ -20,6 +20,7 @@ #define MAX_USERNAME_LEN 64 #define MAX_MESSAGE_LEN 1024 #define MAX_EXEC_COMMAND_LEN 1024 +#define MAX_COMMAND_OUTPUT_LEN 8192 #define MAX_CLIENTS 64 #define LOG_FILE "messages.log" #define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */ diff --git a/include/i18n.h b/include/i18n.h index 0021ea0..37e9134 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -17,6 +17,7 @@ typedef enum { I18N_HELP_TITLE, I18N_HELP_STATUS_FORMAT, I18N_COMMAND_OUTPUT_TITLE, + I18N_COMMAND_OUTPUT_STATUS_FORMAT, I18N_MOTD_TITLE, I18N_MOTD_CONTINUE_HINT, I18N_TITLE_ONLINE_FORMAT, @@ -59,8 +60,7 @@ typedef enum { I18N_EXEC_POST_USAGE, I18N_EXEC_POST_EMPTY, I18N_EXEC_POST_INVALID_UTF8, - I18N_EXEC_UNKNOWN_COMMAND_FORMAT, - I18N_CONTINUE_PROMPT + I18N_EXEC_UNKNOWN_COMMAND_FORMAT } i18n_text_id_t; bool i18n_try_parse_lang(const char *value, help_lang_t *lang); diff --git a/include/ssh_server.h b/include/ssh_server.h index 37b6761..7dfc116 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -40,7 +40,8 @@ typedef struct client { char insert_history[16][MAX_MESSAGE_LEN]; int insert_history_count; int insert_history_pos; - char command_output[2048]; + char command_output[MAX_COMMAND_OUTPUT_LEN]; + int command_output_scroll; bool show_motd; /* command_output holds MOTD text */ char exec_command[MAX_EXEC_COMMAND_LEN]; char ssh_login[MAX_USERNAME_LEN]; diff --git a/src/commands.c b/src/commands.c index 2bbc0f3..502d279 100644 --- a/src/commands.c +++ b/src/commands.c @@ -52,7 +52,7 @@ void commands_dispatch(client_t *client) { strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1); cmd_buf[sizeof(cmd_buf) - 1] = '\0'; char *cmd = cmd_buf; - char output[2048] = {0}; + char output[MAX_COMMAND_OUTPUT_LEN] = {0}; size_t pos = 0; /* Trim whitespace */ @@ -402,10 +402,8 @@ void commands_dispatch(client_t *client) { } cmd_done: - buffer_appendf(output, sizeof(output), &pos, "%s", - i18n_text(client->help_lang, I18N_CONTINUE_PROMPT)); - snprintf(client->command_output, sizeof(client->command_output), "%s", output); + client->command_output_scroll = 0; client->command_input[0] = '\0'; tui_render_command_output(client); } diff --git a/src/help_text.c b/src/help_text.c index d528c01..96c630d 100644 --- a/src/help_text.c +++ b/src/help_text.c @@ -38,6 +38,13 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "AVAILABLE COMMANDS:\n"); command_catalog_append_full(buffer, buf_size, pos, lang); buffer_appendf(buffer, buf_size, pos, + "\n" + "COMMAND OUTPUT KEYS:\n" + " q, ESC - Close output\n" + " j/k - Scroll down/up\n" + " Ctrl+D/U - Scroll half page down/up\n" + " Ctrl+F/B - Scroll full page down/up\n" + " g/G - Jump to top/bottom\n" "\n" "SPECIAL MESSAGES:\n" " /me - Send action (e.g. /me waves)\n" @@ -86,6 +93,13 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "可用命令:\n"); command_catalog_append_full(buffer, buf_size, pos, lang); buffer_appendf(buffer, buf_size, pos, + "\n" + "命令输出按键:\n" + " q, ESC - 关闭输出\n" + " j/k - 向下/上滚动\n" + " Ctrl+D/U - 向下/上滚动半页\n" + " Ctrl+F/B - 向下/上滚动整页\n" + " g/G - 跳到顶部/底部\n" "\n" "特殊消息:\n" " /me <动作> - 发送动作 (如 /me 挥手)\n" diff --git a/src/i18n.c b/src/i18n.c index 007f93d..bc4fb29 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -114,6 +114,8 @@ const char *i18n_text(help_lang_t lang, i18n_text_id_t id) { return "-- 按键参考 -- (%d/%d) j/k:滚动 g/G:首尾 e/z:语言 q:关闭"; case I18N_COMMAND_OUTPUT_TITLE: return " 命令输出 "; + case I18N_COMMAND_OUTPUT_STATUS_FORMAT: + return "-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 q:关闭"; case I18N_MOTD_TITLE: return " 公告 "; case I18N_MOTD_CONTINUE_HINT: @@ -213,8 +215,6 @@ const char *i18n_text(help_lang_t lang, i18n_text_id_t id) { return "post: 输入不是有效 UTF-8\n"; case I18N_EXEC_UNKNOWN_COMMAND_FORMAT: return "未知命令: %s\n"; - case I18N_CONTINUE_PROMPT: - return "\n按任意键继续..."; } } @@ -245,6 +245,8 @@ const char *i18n_text(help_lang_t lang, i18n_text_id_t id) { return "-- KEY REFERENCE -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close"; case I18N_COMMAND_OUTPUT_TITLE: return " COMMAND OUTPUT "; + case I18N_COMMAND_OUTPUT_STATUS_FORMAT: + return "-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom q:close"; case I18N_MOTD_TITLE: return " NOTICE "; case I18N_MOTD_CONTINUE_HINT: @@ -344,8 +346,6 @@ const char *i18n_text(help_lang_t lang, i18n_text_id_t id) { return "post: invalid UTF-8 input\n"; case I18N_EXEC_UNKNOWN_COMMAND_FORMAT: return "Unknown command: %s\n"; - case I18N_CONTINUE_PROMPT: - return "\nPress any key to continue..."; } return ""; diff --git a/src/input.c b/src/input.c index 458dfe0..b9d2ca1 100644 --- a/src/input.c +++ b/src/input.c @@ -205,12 +205,32 @@ static void normal_scroll_by(client_t *client, int delta) { history_view_height(client->height), delta); } +static void dismiss_command_output(client_t *client) { + bool was_motd; + + if (!client) return; + + was_motd = client->show_motd; + client->command_output[0] = '\0'; + client->command_output_scroll = 0; + client->show_motd = false; + client->mode = MODE_NORMAL; + if (was_motd) { + normal_scroll_to_latest(client); + } + tui_render_screen(client); +} + /* 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) { client_mode_t previous_mode = client->mode; + if (client->command_output[0] != '\0') { + dismiss_command_output(client); + return true; + } if (previous_mode != MODE_NORMAL) { client->mode = MODE_NORMAL; client->command_input[0] = '\0'; @@ -275,16 +295,57 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { return true; /* Key consumed */ } - /* Handle command output / MOTD display: any key dismisses */ + /* Handle command output / MOTD display. MOTD remains a simple notice; + * command output behaves like a small pager so long results can be read. */ 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); + int page = client->height - 2; + int half; + + if (client->show_motd) { + dismiss_command_output(client); + return true; + } + + if (page < 1) page = 1; + half = page / 2; + if (half < 1) half = 1; + + if (key == 'q' || key == 27) { + dismiss_command_output(client); + } else if (key == 'j') { + client->command_output_scroll++; + tui_render_command_output(client); + } else if (key == 'k') { + client->command_output_scroll--; + if (client->command_output_scroll < 0) { + client->command_output_scroll = 0; + } + tui_render_command_output(client); + } else if (key == 4) { /* Ctrl+D: half page down */ + client->command_output_scroll += half; + tui_render_command_output(client); + } else if (key == 21) { /* Ctrl+U: half page up */ + client->command_output_scroll -= half; + if (client->command_output_scroll < 0) { + client->command_output_scroll = 0; + } + tui_render_command_output(client); + } else if (key == 6) { /* Ctrl+F: full page down */ + client->command_output_scroll += page; + tui_render_command_output(client); + } else if (key == 2) { /* Ctrl+B: full page up */ + client->command_output_scroll -= page; + if (client->command_output_scroll < 0) { + client->command_output_scroll = 0; + } + tui_render_command_output(client); + } else if (key == 'g') { + client->command_output_scroll = 0; + tui_render_command_output(client); + } else if (key == 'G') { + client->command_output_scroll = 999; + tui_render_command_output(client); } - tui_render_screen(client); return true; /* Key consumed */ } @@ -669,6 +730,7 @@ void input_run_session(client_t *client) { client->connected = true; client->command_history_count = 0; client->command_history_pos = 0; + client->command_output_scroll = 0; client->connect_time = time(NULL); client->last_active = time(NULL); @@ -721,6 +783,7 @@ void input_run_session(client_t *client) { snprintf(client->command_output, sizeof(client->command_output), "%s", motd_buf); + client->command_output_scroll = 0; client->show_motd = true; tui_render_motd(client); seen_update_seq = room_get_update_seq(g_room); diff --git a/src/tui.c b/src/tui.c index 8334564..a59393a 100644 --- a/src/tui.c +++ b/src/tui.c @@ -615,7 +615,7 @@ void tui_render_command_output(client_t *client) { if (rw < 10) rw = 10; if (rh < 4) rh = 4; - char buffer[4096]; + char buffer[MAX_COMMAND_OUTPUT_LEN + 1024]; size_t pos = 0; buffer[0] = '\0'; @@ -639,23 +639,47 @@ void tui_render_command_output(client_t *client) { buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n"); /* Command output - use a copy to avoid strtok corruption */ - char output_copy[2048]; + char output_copy[MAX_COMMAND_OUTPUT_LEN]; strncpy(output_copy, client->command_output, sizeof(output_copy) - 1); output_copy[sizeof(output_copy) - 1] = '\0'; - char *line = strtok(output_copy, "\n"); + char *lines[256]; int line_count = 0; - int max_lines = rh - 2; + char *line = strtok(output_copy, "\n"); + while (line && line_count < (int)(sizeof(lines) / sizeof(lines[0]))) { + lines[line_count++] = line; + line = strtok(NULL, "\n"); + } - while (line && line_count < max_lines) { + int content_height = rh - 2; + if (content_height < 1) content_height = 1; + int max_scroll = line_count - content_height; + if (max_scroll < 0) max_scroll = 0; + if (client->command_output_scroll < 0) client->command_output_scroll = 0; + if (client->command_output_scroll > max_scroll) { + client->command_output_scroll = max_scroll; + } + + int start = client->command_output_scroll; + int end = start + content_height; + if (end > line_count) end = line_count; + + for (int i = start; i < end; i++) { char truncated[1024]; - utf8_ansi_truncate(line, truncated, sizeof(truncated), rw); + utf8_ansi_truncate(lines[i], truncated, sizeof(truncated), rw); buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", truncated); - line = strtok(NULL, "\n"); - line_count++; } + for (int i = end - start; i < content_height; i++) { + buffer_appendf(buffer, sizeof(buffer), &pos, "\033[K\r\n"); + } + + buffer_appendf(buffer, sizeof(buffer), &pos, + i18n_text(client->help_lang, + I18N_COMMAND_OUTPUT_STATUS_FORMAT), + start + 1, max_scroll + 1); + client_send(client, buffer, pos); } diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index bfbd577..338498e 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -146,14 +146,14 @@ send -- ":" expect ":" send -- "help\r" expect "TNT\\(1\\) 帮助" -expect "按任意键" +expect "q:关闭" send -- "q" expect "NORMAL" send -- ":" expect ":" send -- "lang en\r" expect "Language set to: en" -expect "Press any key" +expect "q:close" send -- "q" sleep 0.2 send -- "\003" @@ -185,7 +185,7 @@ send -- ":" expect ":" send -- "hlep\r" expect "你是想输入 :help 吗?" -expect "按任意键" +expect "q:关闭" send -- "q" sleep 0.2 send -- "\003" @@ -219,14 +219,14 @@ send -- "mute-joins\r" expect "命令输出" expect "加入/离开提示" expect "已静音" -expect "按任意键" +expect "q:关闭" send -- "q" expect "NORMAL" send -- ":" expect ":" send -- "lang en\r" expect "Language set to: en" -expect "Press any key" +expect "q:close" send -- "q" expect "NORMAL" expect "online" @@ -235,7 +235,7 @@ expect ":" send -- "users\r" expect "COMMAND OUTPUT" expect "Online users" -expect "Press any key" +expect "q:close" send -- "q" sleep 0.2 send -- "\003" @@ -267,28 +267,28 @@ send -- ":" expect ":" send -- "search\r" expect "用法: search <关键词>" -expect "按任意键" +expect "q:关闭" send -- "q" expect "NORMAL" send -- ":" expect ":" send -- "msg\r" expect "用法: msg <用户名> <消息>" -expect "按任意键" +expect "q:关闭" send -- "q" expect "NORMAL" send -- ":" expect ":" send -- "nick\r" expect "用法: nick <新用户名>" -expect "按任意键" +expect "q:关闭" send -- "q" expect "NORMAL" send -- ":" expect ":" send -- "lang en\r" expect "Language set to: en" -expect "Press any key" +expect "q:close" send -- "q" expect "NORMAL" send -- ":" @@ -296,14 +296,14 @@ expect ":" send -- "inbox\r" expect "Whispers" expect "(empty)" -expect "Press any key" +expect "q:close" send -- "q" expect "NORMAL" send -- ":" expect ":" send -- "last 999\r" expect "Usage: last \\[N\\]" -expect "Press any key" +expect "q:close" send -- "q" sleep 0.2 send -- "\003" @@ -322,6 +322,49 @@ else FAIL=$((FAIL + 1)) fi +scroll_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +scroll_i=1 +while [ "$scroll_i" -le 30 ]; do + printf '%s|fixture|scroll fixture %02d\n' "$scroll_ts" "$scroll_i" >>"$STATE_DIR/messages.log" + scroll_i=$((scroll_i + 1)) +done + +COMMAND_OUTPUT_SCROLL_SCRIPT="$STATE_DIR/command-output-scroll.expect" +cat >"$COMMAND_OUTPUT_SCROLL_SCRIPT" <"$STATE_DIR/command-output-scroll.log" 2>&1; then + echo "✓ command output can scroll before closing" + PASS=$((PASS + 1)) +else + echo "x command output scrolling failed" + sed -n '1,220p' "$STATE_DIR/command-output-scroll.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect" cat >"$SYSTEM_MESSAGES_SCRIPT" < systemuser2" -expect "Press any key" +expect "q:close" send -- "q" sleep 0.2 send -- "\003" diff --git a/tests/unit/test_help_text.c b/tests/unit/test_help_text.c index 5887c85..4db040a 100644 --- a/tests/unit/test_help_text.c +++ b/tests/unit/test_help_text.c @@ -26,6 +26,7 @@ TEST(full_help_matches_language) { assert(strstr(en, "TNT KEY REFERENCE") != NULL); assert(strstr(en, "AVAILABLE COMMANDS") != NULL); + assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL); assert(strstr(en, ":inbox") != NULL); assert(strstr(en, ":support") == NULL); assert(strstr(en, ":commands") == NULL); @@ -33,6 +34,7 @@ TEST(full_help_matches_language) { assert(strstr(zh, "TNT 按键参考") != NULL); assert(strstr(zh, "可用命令") != NULL); + assert(strstr(zh, "命令输出按键") != NULL); assert(strstr(zh, ":inbox") != NULL); assert(strstr(zh, ":support") == NULL); assert(strstr(zh, ":commands") == NULL); diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index 24deaf6..f53ab15 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -94,6 +94,10 @@ TEST(text_lookup_matches_language) { "COMMAND") != NULL); assert(strstr(i18n_text(LANG_ZH, I18N_COMMAND_OUTPUT_TITLE), "命令输出") != NULL); + assert(strstr(i18n_text(LANG_EN, I18N_COMMAND_OUTPUT_STATUS_FORMAT), + "q:close") != NULL); + assert(strstr(i18n_text(LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT), + "q:关闭") != NULL); assert(strstr(i18n_text(LANG_EN, I18N_MOTD_CONTINUE_HINT), "Press any key") != NULL); assert(strstr(i18n_text(LANG_ZH, I18N_MOTD_CONTINUE_HINT),