tui: make command output scrollable

This commit is contained in:
m1ngsama 2026-05-24 11:55:26 +08:00
parent 57bf3cfc67
commit 1f1c2398b6
12 changed files with 194 additions and 41 deletions

View file

@ -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.

View file

@ -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 */

View file

@ -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);

View file

@ -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];

View file

@ -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);
}

View file

@ -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 <action> - 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"

View file

@ -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 "";

View file

@ -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);

View file

@ -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);
}

View file

@ -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" <<EOF
set timeout 10
stty rows 8 columns 80
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "pageruser\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "last 50\r"
expect "j/k:滚动"
expect -re {\(1/[2-9][0-9]*\)}
send -- "j"
expect -re {\(2/[2-9][0-9]*\)}
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$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" <<EOF
set timeout 10
@ -335,14 +378,14 @@ send -- ":"
expect ":"
send -- "lang en\r"
expect "Language set to: en"
expect "Press any key"
expect "q:close"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "nick systemuser2\r"
expect "Nickname changed: systemuser -> systemuser2"
expect "Press any key"
expect "q:close"
send -- "q"
sleep 0.2
send -- "\003"

View file

@ -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);

View file

@ -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),