From 797ecbb9924f807751a839beeea48dedc8595384 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 27 May 2026 19:24:55 +0800 Subject: [PATCH] Improve TUI pager and search ergonomics --- docs/CHANGELOG.md | 8 ++ docs/EASY_SETUP.md | 6 + docs/USER_LIFECYCLE.md | 9 +- src/help_text.c | 26 +++- src/input.c | 225 +++++++++++++++++++++----------- src/manual_text.c | 8 +- src/tui_status.c | 55 +++++++- tests/test_interactive_input.sh | 126 ++++++++++++++++++ tests/test_user_lifecycle.sh | 6 + 9 files changed, 380 insertions(+), 89 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8f8f4b0..73d689b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,8 @@ - Added a VHS tape draft for recording the core TNT terminal-chat experience. - Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and an open inbox refreshes when a new private message arrives. +- Added `/` in NORMAL mode as a fast history-search entrypoint backed by the + existing `:search` command. - Added `make slow-client-test`, an opt-in regression for an unread interactive SSH client under backpressure while health, stats, post, tail, and server survival stay responsive. @@ -73,6 +75,12 @@ contract. - The two-user lifecycle test now covers opening `:inbox` before a private message arrives, matching the way users often leave an inbox page open. +- Help and command-output pagers now accept arrow keys, PgUp/PgDn, Home/End, + and Space/`b` in addition to the existing Vim-style keys. +- Pre-login username entry now handles Ctrl+C/Ctrl+D cancel, Ctrl+U clear + line, and Ctrl+W delete-word before the user joins the room. +- Long COMMAND-mode input is now left-truncated with a visible marker in the + status line instead of wrapping and damaging the TUI. - Private-message inbox access now uses its own mutex instead of sharing the SSH channel write lock, reducing unrelated contention on slow clients. - Client writes now check the SSH channel's remote window before writing and diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index b4a2dbd..7af0f4d 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -64,7 +64,10 @@ Esc enter NORMAL mode i return to INSERT mode : enter COMMAND mode ? open the full key reference +/ search message history G or End jump to latest messages +Up/Down recall sent messages in INSERT mode +Tab complete @mention in INSERT mode Ctrl+C disconnect from NORMAL mode ``` @@ -209,7 +212,10 @@ Esc 进入 NORMAL 模式 i 回到 INSERT 模式 : 输入命令 ? 查看完整按键参考 +/ 搜索消息历史 G 或 End 回到最新消息 +Up/Down 在 INSERT 模式调出已发送消息 +Tab 在 INSERT 模式补全 @mention :help 查看简明手册 :lang en|zh 切换界面语言 :q 断开连接 diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index 1f03184..d2ea47e 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -11,8 +11,9 @@ The product path should stay short: 4. User lands in INSERT mode at the live tail and can type immediately. 5. User presses Esc to browse history with Vim-style movement. 6. User uses `:help` for the concise manual or `?` for the full key reference. -7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`, - `:search`, `:nick`, `:mute-joins`, and `:q`. +7. User searches from NORMAL with `/term`, or uses commands when needed: + `:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`, + and `:q`. 8. Scripts and operators use `tntctl` or SSH exec commands for `health`, `stats`, `users`, `tail`, `dump`, and `post`. @@ -23,6 +24,10 @@ The product path should stay short: - INSERT mode is the default because most users arrive to send a message. - NORMAL mode opens at the latest messages, not the oldest history. Users can move upward for older context and use `G` or End to return to live chat. +- NORMAL mode accepts `/` as the fast path for history search, matching a + common terminal-reader habit while reusing the existing `:search` command. +- INSERT mode keeps a small per-session sent-message history on Up/Down and + completes trailing `@mention` prefixes with Tab. - `:help` is a compact manual, while `?` is a full key reference. Do not add parallel support commands for the same task. - Command syntax stays ASCII even in localized UI text. Translations explain; diff --git a/src/help_text.c b/src/help_text.c index 0a8c919..9c10e0d 100644 --- a/src/help_text.c +++ b/src/help_text.c @@ -19,6 +19,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Backspace - Delete character\n" " Ctrl+W - Delete last word\n" " Ctrl+U - Delete line\n" + " Up/Down - Recall sent messages\n" + " Tab - Complete @mention\n" " Ctrl+C - Enter NORMAL mode\n" "\n" "NORMAL MODE KEYS:\n" @@ -26,6 +28,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Follows latest until you scroll up\n" " i - Return to INSERT mode\n" " : - Enter COMMAND mode\n" + " / - Search message history\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" @@ -49,6 +52,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Backspace - 删除字符\n" " Ctrl+W - 删除上个单词\n" " Ctrl+U - 删除整行\n" + " Up/Down - 调出已发送消息\n" + " Tab - 补全 @mention\n" " Ctrl+C - 进入 NORMAL 模式\n" "\n" "NORMAL 模式按键:\n" @@ -56,6 +61,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " 未向上翻阅时自动跟随最新消息\n" " i - 返回 INSERT 模式\n" " : - 进入 COMMAND 模式\n" + " / - 搜索消息历史\n" " j/k - 向下/上滚动一行\n" " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" @@ -71,9 +77,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "\n" "COMMAND OUTPUT KEYS:\n" " q, ESC - Close output\n" - " j/k - Scroll down/up\n" + " j/k, arrows - Scroll down/up\n" " Ctrl+D/U - Scroll half page down/up\n" " Ctrl+F/B - Scroll full page down/up\n" + " Space/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" " r - Refresh live output (:inbox)\n" "\n" @@ -83,17 +92,23 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "\n" "HELP SCREEN KEYS:\n" " q, ESC - Close help\n" - " j/k - Scroll down/up\n" + " j/k, arrows - Scroll down/up\n" " Ctrl+D/U - Scroll half page down/up\n" " Ctrl+F/B - Scroll full page down/up\n" + " Space/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" " l - Cycle UI language\n", "\n" "命令输出按键:\n" " q, ESC - 关闭输出\n" - " j/k - 向下/上滚动\n" + " j/k, arrows - 向下/上滚动\n" " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" + " Space/b - 向下/上滚动整页\n" + " PgDn/PgUp - 向下/上滚动整页\n" + " End/Home - 跳到底部/顶部\n" " g/G - 跳到顶部/底部\n" " r - 刷新动态输出 (:inbox)\n" "\n" @@ -103,9 +118,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "\n" "帮助界面按键:\n" " q, ESC - 关闭帮助\n" - " j/k - 向下/上滚动\n" + " j/k, arrows - 向下/上滚动\n" " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" + " Space/b - 向下/上滚动整页\n" + " PgDn/PgUp - 向下/上滚动整页\n" + " End/Home - 跳到底部/顶部\n" " g/G - 跳到顶部/底部\n" " l - 切换界面语言\n" ); diff --git a/src/input.c b/src/input.c index cf54e22..01eadc9 100644 --- a/src/input.c +++ b/src/input.c @@ -32,10 +32,10 @@ static int read_username(client_t *client) { char username[MAX_USERNAME_LEN] = {0}; int pos = 0; char buf[4]; + const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT); tui_render_welcome(client); - client_printf(client, "%s", i18n_text(client->ui_lang, - I18N_USERNAME_PROMPT)); + client_printf(client, "%s", prompt); while (1) { int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */ @@ -54,6 +54,18 @@ static int read_username(client_t *client) { if (b == '\r' || b == '\n') { break; + } else if (b == 3 || b == 4) { /* Ctrl+C / Ctrl+D */ + return -1; + } else if (b == 21) { /* Ctrl+U: clear line */ + username[0] = '\0'; + pos = 0; + client_printf(client, "\r\033[K%s", prompt); + } else if (b == 23) { /* Ctrl+W: delete word */ + if (username[0] != '\0') { + utf8_remove_last_word(username); + pos = (int)strlen(username); + client_printf(client, "\r\033[K%s%s", prompt, username); + } } else if (b == 127 || b == 8) { /* Backspace */ if (pos > 0) { /* Compute width of the last character before removing it */ @@ -230,6 +242,110 @@ static void dismiss_command_output(client_t *client) { tui_render_screen(client); } +typedef enum { + PAGER_ACTION_NONE, + PAGER_ACTION_SCROLL, + PAGER_ACTION_CLOSE, + PAGER_ACTION_REFRESH +} pager_action_t; + +static int pager_page_height(client_t *client) { + int page = client->height - 2; + if (page < 1) page = 1; + return page; +} + +static void pager_scroll_by(int *scroll_pos, int delta) { + *scroll_pos += delta; + if (*scroll_pos < 0) { + *scroll_pos = 0; + } +} + +static pager_action_t pager_apply_key(client_t *client, unsigned char key, + int *scroll_pos, bool allow_refresh) { + int page = pager_page_height(client); + int half = page / 2; + if (half < 1) half = 1; + + if (key == 'q') { + return PAGER_ACTION_CLOSE; + } else if (key == 'j') { + pager_scroll_by(scroll_pos, 1); + return PAGER_ACTION_SCROLL; + } else if (key == 'k') { + pager_scroll_by(scroll_pos, -1); + return PAGER_ACTION_SCROLL; + } else if (key == 4) { /* Ctrl+D: half page down */ + pager_scroll_by(scroll_pos, half); + return PAGER_ACTION_SCROLL; + } else if (key == 21) { /* Ctrl+U: half page up */ + pager_scroll_by(scroll_pos, -half); + return PAGER_ACTION_SCROLL; + } else if (key == 6 || key == ' ') { /* Ctrl+F / Space: page down */ + pager_scroll_by(scroll_pos, page); + return PAGER_ACTION_SCROLL; + } else if (key == 2 || key == 'b') { /* Ctrl+B / b: page up */ + pager_scroll_by(scroll_pos, -page); + return PAGER_ACTION_SCROLL; + } else if (key == 'g') { + *scroll_pos = 0; + return PAGER_ACTION_SCROLL; + } else if (key == 'G') { + *scroll_pos = 999; + return PAGER_ACTION_SCROLL; + } else if ((key == 'r' || key == 'R') && allow_refresh) { + return PAGER_ACTION_REFRESH; + } else if (key == 27) { + char seq[3]; + int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50); + if (n != 1) { + return PAGER_ACTION_CLOSE; + } + if (seq[0] != '[') { + return PAGER_ACTION_NONE; + } + + n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50); + if (n != 1) { + return PAGER_ACTION_NONE; + } + + if (seq[1] == 'A') { /* Up arrow */ + pager_scroll_by(scroll_pos, -1); + return PAGER_ACTION_SCROLL; + } else if (seq[1] == 'B') { /* Down arrow */ + pager_scroll_by(scroll_pos, 1); + return PAGER_ACTION_SCROLL; + } else if (seq[1] == 'H') { /* Home */ + *scroll_pos = 0; + return PAGER_ACTION_SCROLL; + } else if (seq[1] == 'F') { /* End */ + *scroll_pos = 999; + return PAGER_ACTION_SCROLL; + } 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 */ + pager_scroll_by(scroll_pos, -page); + return PAGER_ACTION_SCROLL; + } else if (seq[1] == '6') { /* PageDown */ + pager_scroll_by(scroll_pos, page); + return PAGER_ACTION_SCROLL; + } else if (seq[1] == '1') { /* Home */ + *scroll_pos = 0; + return PAGER_ACTION_SCROLL; + } else if (seq[1] == '4') { /* End */ + *scroll_pos = 999; + return PAGER_ACTION_SCROLL; + } + } + } + } + + return PAGER_ACTION_NONE; +} + /* 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) { @@ -257,44 +373,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { /* Handle help screen */ if (client->show_help) { - /* Page size: roughly the visible help body region. */ - int page = client->height - 2; - if (page < 1) page = 1; - int half = page / 2; - if (half < 1) half = 1; + pager_action_t action; - if (key == 'q' || key == 27) { - client->show_help = false; - tui_render_screen(client); - } else if (key == 'l' || key == 'L') { + if (key == 'l' || key == 'L') { client->ui_lang = i18n_next_ui_lang(client->ui_lang); client->help_scroll_pos = 0; tui_render_help(client); - } else if (key == 'j') { - client->help_scroll_pos++; - tui_render_help(client); - } else if (key == 'k' && client->help_scroll_pos > 0) { - client->help_scroll_pos--; - tui_render_help(client); - } else if (key == 4) { /* Ctrl+D: half page down */ - client->help_scroll_pos += half; - tui_render_help(client); - } else if (key == 21) { /* Ctrl+U: half page up */ - client->help_scroll_pos -= half; - if (client->help_scroll_pos < 0) client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 6) { /* Ctrl+F: full page down */ - client->help_scroll_pos += page; - tui_render_help(client); - } else if (key == 2) { /* Ctrl+B: full page up */ - client->help_scroll_pos -= page; - if (client->help_scroll_pos < 0) client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 'g') { - client->help_scroll_pos = 0; - tui_render_help(client); - } else if (key == 'G') { - client->help_scroll_pos = 999; /* Large number */ + return true; + } + + action = pager_apply_key(client, key, &client->help_scroll_pos, false); + if (action == PAGER_ACTION_CLOSE) { + client->show_help = false; + tui_render_screen(client); + } else if (action == PAGER_ACTION_SCROLL) { tui_render_help(client); } return true; /* Key consumed */ @@ -303,56 +395,23 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { /* 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') { - int page = client->height - 2; - int half; + pager_action_t action; 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) { + action = pager_apply_key(client, key, &client->command_output_scroll, + true); + if (action == PAGER_ACTION_CLOSE) { dismiss_command_output(client); - } else if (key == 'j') { - client->command_output_scroll++; + } else if (action == PAGER_ACTION_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; + } else if (action == PAGER_ACTION_REFRESH) { + if (commands_refresh_active_output(client)) { + tui_render_command_output(client); } - 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); - } else if ((key == 'r' || key == 'R') && - commands_refresh_active_output(client)) { - tui_render_command_output(client); } return true; /* Key consumed */ } @@ -571,6 +630,12 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { client->command_input[0] = '\0'; tui_render_screen(client); return true; + } else if (key == '/') { + client->mode = MODE_COMMAND; + snprintf(client->command_input, sizeof(client->command_input), + "search "); + tui_render_screen(client); + return true; } else if (key == 'j') { normal_scroll_by(client, 1); tui_render_screen(client); @@ -953,6 +1018,8 @@ main_loop: client->command_input[len] = b; client->command_input[len + 1] = '\0'; tui_render_screen(client); + } else { + client_send(client, "\a", 1); } } else if (b >= 128) { /* UTF-8 multi-byte */ int char_len = utf8_byte_length(b); @@ -965,10 +1032,12 @@ main_loop: } if (!utf8_is_valid_sequence(buf, char_len)) continue; size_t len = strlen(client->command_input); - if (len + (size_t)char_len < sizeof(client->command_input) - 1) { + if (len + (size_t)char_len <= sizeof(client->command_input) - 1) { memcpy(client->command_input + len, buf, char_len); client->command_input[len + char_len] = '\0'; tui_render_screen(client); + } else { + client_send(client, "\a", 1); } } } diff --git a/src/manual_text.c b/src/manual_text.c index 0887b72..f7badaf 100644 --- a/src/manual_text.c +++ b/src/manual_text.c @@ -12,8 +12,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size, " TNT - SSH terminal chat room\n" "\n" "\033[1;37mUse\033[0m\n" - " Type a message and press Enter; Esc browses; G latest; i types\n" - " : runs commands; ? opens the full key reference\n" + " Type, Enter sends; Up/Down recalls; Tab completes @mentions\n" + " Esc browses; / searches; G latest; i types; : commands; ? keys\n" "\n" "\033[1;37mCommands\033[0m\n", "\033[1;36mTNT(1) 帮助\033[0m\n" @@ -22,8 +22,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size, " TNT - SSH 终端聊天室\n" "\n" "\033[1;37m使用\033[0m\n" - " 输入消息并 Enter 发送;Esc 浏览历史;G 最新;i 输入\n" - " : 运行命令;? 打开完整按键参考\n" + " 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n" + " Esc 浏览;/ 搜索;G 最新;i 输入;: 命令;? 按键\n" "\n" "\033[1;37m命令\033[0m\n" ); diff --git a/src/tui_status.c b/src/tui_status.c index ed3b851..af72a7e 100644 --- a/src/tui_status.c +++ b/src/tui_status.c @@ -1,6 +1,54 @@ #include "tui_status.h" #include "i18n.h" #include "ssh_server.h" +#include "utf8.h" + +static void format_command_input_tail(const char *input, int avail_width, + char *display, size_t display_size) { + if (!input || !display || display_size == 0) return; + + display[0] = '\0'; + if (avail_width < 1) { + return; + } + + if (utf8_string_width(input) <= avail_width) { + strncpy(display, input, display_size - 1); + display[display_size - 1] = '\0'; + return; + } + + const char *marker = "<"; + int marker_width = 1; + int tail_width = avail_width - marker_width; + if (tail_width < 1) { + snprintf(display, display_size, "%s", marker); + return; + } + + const char *p = input + strlen(input); + const char *tail = p; + int width = 0; + + while (p > input && width < tail_width) { + const char *q = p - 1; + while (q > input && ((*q & 0xC0) == 0x80)) { + q--; + } + + int bytes_read = 0; + uint32_t cp = utf8_decode(q, &bytes_read); + int char_width = utf8_char_width(cp); + if (width + char_width > tail_width) { + break; + } + width += char_width; + tail = q; + p = q; + } + + snprintf(display, display_size, "%s%s", marker, tail); +} void tui_status_append(char *buffer, size_t buf_size, size_t *pos, const struct client *client, int msg_count, @@ -48,7 +96,12 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos, i18n_text(client->ui_lang, I18N_NORMAL_LATEST)); } } else if (client->mode == MODE_COMMAND) { + char display[sizeof(client->command_input) + 2]; + int avail = client->width - 1; + if (avail < 1) avail = 1; + format_command_input_tail(client->command_input, avail, display, + sizeof(display)); buffer_appendf(buffer, buf_size, pos, - "\033[35m:\033[0m%s\033[K", client->command_input); + "\033[35m:\033[0m%s\033[K", display); } } diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index 26322d6..da7b6f8 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -58,6 +58,51 @@ else exit 1 fi +USERNAME_CANCEL_SCRIPT="$STATE_DIR/username-cancel.expect" +cat >"$USERNAME_CANCEL_SCRIPT" <"$STATE_DIR/username-cancel.log" 2>&1; then + echo "✓ Ctrl+C cancels before username join" + PASS=$((PASS + 1)) +else + echo "x Ctrl+C before username failed" + sed -n '1,120p' "$STATE_DIR/username-cancel.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +USERNAME_EDIT_SCRIPT="$STATE_DIR/username-edit.expect" +cat >"$USERNAME_EDIT_SCRIPT" <"$STATE_DIR/username-edit.log" 2>&1 && + grep -q 'editeduser' "$STATE_DIR/messages.log" && + ! grep -q 'wrongediteduser' "$STATE_DIR/messages.log"; then + echo "✓ Ctrl+U edits username before join" + PASS=$((PASS + 1)) +else + echo "x username line editing failed" + sed -n '1,120p' "$STATE_DIR/username-edit.log" 2>/dev/null || true + cat "$STATE_DIR/messages.log" 2>/dev/null || true + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect" cat >"$EXPECT_SCRIPT" <"$HELP_PAGER_KEYS_SCRIPT" <"$STATE_DIR/help-pager-keys.log" 2>&1; then + echo "✓ help pager accepts terminal paging keys" + PASS=$((PASS + 1)) +else + echo "x help pager terminal keys failed" + sed -n '1,220p' "$STATE_DIR/help-pager-keys.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect" cat >"$UNKNOWN_SCRIPT" <"$COMMAND_INPUT_WRAP_SCRIPT" <"$STATE_DIR/command-input-wrap.log" 2>&1; then + echo "✓ long command input stays on one status line" + PASS=$((PASS + 1)) +else + echo "x long command input display failed" + sed -n '1,220p' "$STATE_DIR/command-input-wrap.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" <