From e603a55cb3594aa5769342c4d71e7977cbac0124 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 26 May 2026 12:22:33 +0800 Subject: [PATCH] Polish live inbox command output --- README.md | 4 ++ docs/CHANGELOG.md | 4 ++ docs/USER_LIFECYCLE.md | 6 ++- include/commands.h | 5 ++ include/i18n.h | 1 + include/ssh_server.h | 7 +++ src/commands.c | 82 +++++++++++++++++++++------------ src/help_text.c | 2 + src/i18n_text.c | 4 ++ src/input.c | 13 ++++++ src/tui.c | 5 +- tests/test_interactive_input.sh | 3 ++ tests/test_user_lifecycle.sh | 15 +++--- tests/unit/test_help_text.c | 2 + tests/unit/test_i18n.c | 6 +++ tnt.1 | 8 ++++ 16 files changed, 129 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f31b8aa..e6a1cc3 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ Up/Down - Browse command history ESC - Return to NORMAL mode ``` +Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox` +is live: press `r` to refresh it manually, and it refreshes when a new private +message arrives while the inbox is open. + **Special messages (INSERT mode)** ``` /me - Send action (e.g. /me waves) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d8adcf5..586430b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,8 @@ the main onboarding, chat, help, history, search, private-message, nickname, action-message, and exit paths. - 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. ### Changed - `make install-systemd` now rewrites the installed unit's `ExecStart` to match @@ -40,6 +42,8 @@ - Interactive client writes now pass through a bounded per-client outbox and flush against the remote SSH window from that client's session loop. Exec sessions still write synchronously to preserve script output ordering. +- The two-user lifecycle test now covers opening `:inbox` before a private + message arrives, matching the way users often leave an inbox page open. - 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/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index 878bb15..43c9bd9 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -29,6 +29,9 @@ The product path should stay short: they do not change the command language. - Private messages are visible only in the recipient inbox and are not written to `messages.log`. +- `:inbox` is live enough for normal chat use: it can be refreshed with `r` + and refreshes automatically when a new private message arrives while the + inbox is open. - Long command output uses a small pager so `:last` and `:search` are readable on small terminals. @@ -41,7 +44,8 @@ The product path should stay short: `:last` and `:search` - first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends `/me`, and exits -- second user reads `:inbox` +- second user opens `:inbox` before the private message arrives and sees it + auto-refresh after delivery - exec `tail` sees public messages - `messages.log` contains public history and excludes private-message content diff --git a/include/commands.h b/include/commands.h index 09f6b47..8d60eca 100644 --- a/include/commands.h +++ b/include/commands.h @@ -19,4 +19,9 @@ * path; callers must not hold client->io_lock before dispatching. */ void commands_dispatch(client_t *client); +/* Rebuild the currently visible command output when it is backed by live + * client state, such as :inbox. Returns true if output changed and the caller + * should render it again. */ +bool commands_refresh_active_output(client_t *client); + #endif /* COMMANDS_H */ diff --git a/include/i18n.h b/include/i18n.h index a5785af..219965b 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -25,6 +25,7 @@ typedef enum { I18N_HELP_STATUS_FORMAT, I18N_COMMAND_OUTPUT_TITLE, I18N_COMMAND_OUTPUT_STATUS_FORMAT, + I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT, I18N_MOTD_TITLE, I18N_MOTD_CONTINUE_HINT, I18N_TITLE_ONLINE_FORMAT, diff --git a/include/ssh_server.h b/include/ssh_server.h index 3e35c37..f584567 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -17,6 +17,12 @@ typedef struct { char content[MAX_MESSAGE_LEN]; } whisper_t; +typedef enum { + TNT_COMMAND_OUTPUT_NONE, + TNT_COMMAND_OUTPUT_GENERIC, + TNT_COMMAND_OUTPUT_INBOX +} tnt_command_output_kind_t; + /* Client connection structure */ typedef struct client { ssh_session session; /* SSH session */ @@ -42,6 +48,7 @@ typedef struct client { int insert_history_pos; char command_output[MAX_COMMAND_OUTPUT_LEN]; int command_output_scroll; + tnt_command_output_kind_t command_output_kind; bool show_motd; /* command_output holds MOTD text */ char exec_command[MAX_EXEC_COMMAND_LEN]; bool exec_command_too_long; diff --git a/src/commands.c b/src/commands.c index 0ea7527..318c720 100644 --- a/src/commands.c +++ b/src/commands.c @@ -52,12 +52,60 @@ static void append_command_usage(char *output, size_t buf_size, size_t *pos, command_catalog_append_usage(output, buf_size, pos, id, lang); } +static void append_inbox_output(client_t *client, char *output, + size_t buf_size, size_t *pos) { + whisper_t snapshot[WHISPER_INBOX_SIZE]; + int snap_count; + + pthread_mutex_lock(&client->whisper_lock); + snap_count = client->whisper_inbox_count; + memcpy(snapshot, client->whisper_inbox, + snap_count * sizeof(whisper_t)); + client->unread_whispers = 0; + pthread_mutex_unlock(&client->whisper_lock); + + buffer_appendf(output, buf_size, pos, + "\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n", + i18n_text(client->ui_lang, I18N_INBOX_TITLE), + snap_count); + if (snap_count == 0) { + buffer_appendf(output, buf_size, pos, + " \033[2;37m%s\033[0m\n", + i18n_text(client->ui_lang, I18N_INBOX_EMPTY)); + } + for (int i = 0; i < snap_count; i++) { + char ts[20]; + struct tm tmi; + localtime_r(&snapshot[i].timestamp, &tmi); + strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); + buffer_appendf(output, buf_size, pos, + " \033[90m%s\033[0m \033[35m%s\033[0m: %s\n", + ts, snapshot[i].from, snapshot[i].content); + } +} + +bool commands_refresh_active_output(client_t *client) { + char output[MAX_COMMAND_OUTPUT_LEN] = {0}; + size_t pos = 0; + + if (!client || client->command_output_kind != TNT_COMMAND_OUTPUT_INBOX) { + return false; + } + + append_inbox_output(client, output, sizeof(output), &pos); + snprintf(client->command_output, sizeof(client->command_output), "%s", + output); + client->command_output_scroll = 0; + return true; +} + void commands_dispatch(client_t *client) { char cmd_buf[256]; strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1); cmd_buf[sizeof(cmd_buf) - 1] = '\0'; char *cmd = cmd_buf; char output[MAX_COMMAND_OUTPUT_LEN] = {0}; + tnt_command_output_kind_t output_kind = TNT_COMMAND_OUTPUT_GENERIC; size_t pos = 0; /* Trim whitespace */ @@ -219,9 +267,9 @@ void commands_dispatch(client_t *client) { snprintf(target->whisper_inbox[slot].content, sizeof(target->whisper_inbox[slot].content), "%s", rest); + target->unread_whispers++; pthread_mutex_unlock(&target->whisper_lock); - target->unread_whispers++; /* Audible nudge — the title bar ✉ counter (UX-11 style) * carries the persistent signal. */ client_queue_bell(target); @@ -242,35 +290,8 @@ void commands_dispatch(client_t *client) { } } else if (command_id == TNT_COMMAND_INBOX) { - /* Snapshot the inbox under whisper_lock so a concurrent sender doesn't - * tear what we're rendering. Counter reset happens after copy. */ - whisper_t snapshot[WHISPER_INBOX_SIZE]; - int snap_count; - pthread_mutex_lock(&client->whisper_lock); - snap_count = client->whisper_inbox_count; - memcpy(snapshot, client->whisper_inbox, - snap_count * sizeof(whisper_t)); - pthread_mutex_unlock(&client->whisper_lock); - client->unread_whispers = 0; - - buffer_appendf(output, sizeof(output), &pos, - "\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n", - i18n_text(client->ui_lang, I18N_INBOX_TITLE), - snap_count); - if (snap_count == 0) { - buffer_appendf(output, sizeof(output), &pos, - " \033[2;37m%s\033[0m\n", - i18n_text(client->ui_lang, I18N_INBOX_EMPTY)); - } - for (int i = 0; i < snap_count; i++) { - char ts[20]; - struct tm tmi; - localtime_r(&snapshot[i].timestamp, &tmi); - strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); - buffer_appendf(output, sizeof(output), &pos, - " \033[90m%s\033[0m \033[35m%s\033[0m: %s\n", - ts, snapshot[i].from, snapshot[i].content); - } + output_kind = TNT_COMMAND_OUTPUT_INBOX; + append_inbox_output(client, output, sizeof(output), &pos); } else if (command_id == TNT_COMMAND_NICK) { const char *new_name = arg; @@ -414,6 +435,7 @@ void commands_dispatch(client_t *client) { cmd_done: snprintf(client->command_output, sizeof(client->command_output), "%s", output); client->command_output_scroll = 0; + client->command_output_kind = output_kind; client->command_input[0] = '\0'; tui_render_command_output(client); } diff --git a/src/help_text.c b/src/help_text.c index e1e221e..0a8c919 100644 --- a/src/help_text.c +++ b/src/help_text.c @@ -75,6 +75,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " 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" + " r - Refresh live output (:inbox)\n" "\n" "SPECIAL MESSAGES:\n" " /me - Send action (e.g. /me waves)\n" @@ -94,6 +95,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" " g/G - 跳到顶部/底部\n" + " r - 刷新动态输出 (:inbox)\n" "\n" "特殊消息:\n" " /me - 发送动作 (如 /me waves)\n" diff --git a/src/i18n_text.c b/src/i18n_text.c index cfb76f4..5fe3f60 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -57,6 +57,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom q:close", "-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 q:关闭" ), + [I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT] = I18N_STRING( + "-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom r:refresh q:close", + "-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 r:刷新 q:关闭" + ), [I18N_MOTD_TITLE] = I18N_STRING( " NOTICE ", " 公告 " diff --git a/src/input.c b/src/input.c index c122c0a..8cd2573 100644 --- a/src/input.c +++ b/src/input.c @@ -221,6 +221,7 @@ static void dismiss_command_output(client_t *client) { was_motd = client->show_motd; client->command_output[0] = '\0'; client->command_output_scroll = 0; + client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = false; client->mode = MODE_NORMAL; if (was_motd) { @@ -349,6 +350,9 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { } 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 */ } @@ -735,6 +739,7 @@ void input_run_session(client_t *client) { client->command_history_count = 0; client->command_history_pos = 0; client->command_output_scroll = 0; + client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->connect_time = time(NULL); client->last_active = time(NULL); @@ -788,6 +793,7 @@ void input_run_session(client_t *client) { sizeof(client->command_output), "%s", motd_buf); client->command_output_scroll = 0; + client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = true; tui_render_motd(client); seen_update_seq = room_get_update_seq(g_room); @@ -836,6 +842,13 @@ main_loop: room_updated = true; } + if (client->command_output_kind == TNT_COMMAND_OUTPUT_INBOX && + client->command_output[0] != '\0' && + client->unread_whispers > 0) { + commands_refresh_active_output(client); + client->redraw_pending = true; + } + if (client->redraw_pending || (room_updated && !client->show_help && client->command_output[0] == '\0')) { diff --git a/src/tui.c b/src/tui.c index 1330ac7..be2f185 100644 --- a/src/tui.c +++ b/src/tui.c @@ -677,7 +677,10 @@ void tui_render_command_output(client_t *client) { buffer_appendf(buffer, sizeof(buffer), &pos, i18n_text(client->ui_lang, - I18N_COMMAND_OUTPUT_STATUS_FORMAT), + client->command_output_kind == + TNT_COMMAND_OUTPUT_INBOX + ? I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT + : 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 cc457e4..26322d6 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -304,6 +304,9 @@ expect ":" send -- "inbox\r" expect "Private messages" expect "(empty)" +expect "r:refresh" +send -- "r" +expect "Private messages" expect "q:close" send -- "q" expect "NORMAL" diff --git a/tests/test_user_lifecycle.sh b/tests/test_user_lifecycle.sh index e007ab7..e6a4e8f 100755 --- a/tests/test_user_lifecycle.sh +++ b/tests/test_user_lifecycle.sh @@ -36,7 +36,7 @@ fi SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" BOB_READY="$STATE_DIR/bob.ready" -ALICE_DONE="$STATE_DIR/alice.done" +PRIVATE_SENT="$STATE_DIR/private.sent" wait_for_health() { out="" @@ -80,14 +80,17 @@ spawn ssh $SSH_OPTS bob@127.0.0.1 sleep 1 send -- "bob\r" expect ":help" -exec touch "$BOB_READY" -exec sh -c "while \[ ! -f '$ALICE_DONE' \]; do sleep 1; done" send -- "\033" expect "NORMAL" send -- ":" expect ":" send -- "inbox\r" expect "私信" +expect "(空)" +expect "r:刷新" +exec touch "$BOB_READY" +exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done" +expect "私信" expect "alice" expect "private lifecycle ping" expect "q:关闭" @@ -194,6 +197,7 @@ send -- ":" expect ":" send -- "msg bob private lifecycle ping\r" expect "私信已发送给 bob" +exec touch "$PRIVATE_SENT" expect "q:关闭" send -- "q" expect "NORMAL" @@ -208,7 +212,6 @@ send -- "i" expect ":help" send -- "/me ships lifecycle\r" sleep 1 -exec touch "$ALICE_DONE" send -- "\003" sleep 0.2 send -- "\003" @@ -222,11 +225,11 @@ else echo "✗ primary user lifecycle failed" sed -n '1,240p' "$STATE_DIR/alice.log" FAIL=$((FAIL + 1)) - touch "$ALICE_DONE" + touch "$PRIVATE_SENT" fi if wait "$BOB_PID" 2>/dev/null; then - echo "✓ recipient read private-message inbox" + echo "✓ recipient inbox auto-refreshed after private message" PASS=$((PASS + 1)) else echo "✗ recipient inbox journey failed" diff --git a/tests/unit/test_help_text.c b/tests/unit/test_help_text.c index d7ca9aa..09b888c 100644 --- a/tests/unit/test_help_text.c +++ b/tests/unit/test_help_text.c @@ -29,6 +29,7 @@ TEST(full_help_matches_language) { assert(strstr(en, "AVAILABLE COMMANDS") != NULL); assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL); assert(strstr(en, ":inbox") != NULL); + assert(strstr(en, "Refresh live output") != NULL); assert(strstr(en, ":support") == NULL); assert(strstr(en, ":commands") == NULL); assert(strstr(en, "Cycle UI language") != NULL); @@ -38,6 +39,7 @@ TEST(full_help_matches_language) { assert(strstr(zh, "可用命令") != NULL); assert(strstr(zh, "命令输出按键") != NULL); assert(strstr(zh, ":inbox") != NULL); + assert(strstr(zh, "刷新动态输出") != NULL); assert(strstr(zh, "/me ") != NULL); assert(strstr(zh, "@username") != NULL); assert(strstr(zh, "<动作>") == NULL); diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index 1279f06..73675ed 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -111,6 +111,12 @@ TEST(text_lookup_matches_language) { "q:close") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT), "q:关闭") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, + I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT), + "r:refresh") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, + I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT), + "r:刷新") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT), "Press any key") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT), diff --git a/tnt.1 b/tnt.1 index 4e207c1..cecd23f 100644 --- a/tnt.1 +++ b/tnt.1 @@ -199,6 +199,14 @@ l l. Up/Down Browse command history ESC Cancel and return to NORMAL .TE +.PP +Command output pages use j/k, Ctrl+D/Ctrl+U, and g/G for paging. +The +.B :inbox +page can also be refreshed with +.B r +and refreshes automatically when a new private message arrives while it is +open. .SH EXEC INTERFACE Commands can be run non\-interactively for scripting: .PP