Polish live inbox command output

This commit is contained in:
m1ngsama 2026-05-26 12:22:33 +08:00
parent f3e2762f30
commit e603a55cb3
16 changed files with 129 additions and 38 deletions

View file

@ -105,6 +105,10 @@ Up/Down - Browse command history
ESC - Return to NORMAL mode 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)** **Special messages (INSERT mode)**
``` ```
/me <action> - Send action (e.g. /me waves) /me <action> - Send action (e.g. /me waves)

View file

@ -17,6 +17,8 @@
the main onboarding, chat, help, history, search, private-message, nickname, the main onboarding, chat, help, history, search, private-message, nickname,
action-message, and exit paths. action-message, and exit paths.
- Added a VHS tape draft for recording the core TNT terminal-chat experience. - 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 ### Changed
- `make install-systemd` now rewrites the installed unit's `ExecStart` to match - `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 - 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 flush against the remote SSH window from that client's session loop. Exec
sessions still write synchronously to preserve script output ordering. 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 - Private-message inbox access now uses its own mutex instead of sharing the
SSH channel write lock, reducing unrelated contention on slow clients. SSH channel write lock, reducing unrelated contention on slow clients.
- Client writes now check the SSH channel's remote window before writing and - Client writes now check the SSH channel's remote window before writing and

View file

@ -29,6 +29,9 @@ The product path should stay short:
they do not change the command language. they do not change the command language.
- Private messages are visible only in the recipient inbox and are not written - Private messages are visible only in the recipient inbox and are not written
to `messages.log`. 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 - Long command output uses a small pager so `:last` and `:search` are readable
on small terminals. on small terminals.
@ -41,7 +44,8 @@ The product path should stay short:
`:last` and `:search` `:last` and `:search`
- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends - first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends
`/me`, and exits `/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 - exec `tail` sees public messages
- `messages.log` contains public history and excludes private-message content - `messages.log` contains public history and excludes private-message content

View file

@ -19,4 +19,9 @@
* path; callers must not hold client->io_lock before dispatching. */ * path; callers must not hold client->io_lock before dispatching. */
void commands_dispatch(client_t *client); 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 */ #endif /* COMMANDS_H */

View file

@ -25,6 +25,7 @@ typedef enum {
I18N_HELP_STATUS_FORMAT, I18N_HELP_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_TITLE, I18N_COMMAND_OUTPUT_TITLE,
I18N_COMMAND_OUTPUT_STATUS_FORMAT, I18N_COMMAND_OUTPUT_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT,
I18N_MOTD_TITLE, I18N_MOTD_TITLE,
I18N_MOTD_CONTINUE_HINT, I18N_MOTD_CONTINUE_HINT,
I18N_TITLE_ONLINE_FORMAT, I18N_TITLE_ONLINE_FORMAT,

View file

@ -17,6 +17,12 @@ typedef struct {
char content[MAX_MESSAGE_LEN]; char content[MAX_MESSAGE_LEN];
} whisper_t; } whisper_t;
typedef enum {
TNT_COMMAND_OUTPUT_NONE,
TNT_COMMAND_OUTPUT_GENERIC,
TNT_COMMAND_OUTPUT_INBOX
} tnt_command_output_kind_t;
/* Client connection structure */ /* Client connection structure */
typedef struct client { typedef struct client {
ssh_session session; /* SSH session */ ssh_session session; /* SSH session */
@ -42,6 +48,7 @@ typedef struct client {
int insert_history_pos; int insert_history_pos;
char command_output[MAX_COMMAND_OUTPUT_LEN]; char command_output[MAX_COMMAND_OUTPUT_LEN];
int command_output_scroll; int command_output_scroll;
tnt_command_output_kind_t command_output_kind;
bool show_motd; /* command_output holds MOTD text */ bool show_motd; /* command_output holds MOTD text */
char exec_command[MAX_EXEC_COMMAND_LEN]; char exec_command[MAX_EXEC_COMMAND_LEN];
bool exec_command_too_long; bool exec_command_too_long;

View file

@ -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); 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) { void commands_dispatch(client_t *client) {
char cmd_buf[256]; char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1); strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
cmd_buf[sizeof(cmd_buf) - 1] = '\0'; cmd_buf[sizeof(cmd_buf) - 1] = '\0';
char *cmd = cmd_buf; char *cmd = cmd_buf;
char output[MAX_COMMAND_OUTPUT_LEN] = {0}; char output[MAX_COMMAND_OUTPUT_LEN] = {0};
tnt_command_output_kind_t output_kind = TNT_COMMAND_OUTPUT_GENERIC;
size_t pos = 0; size_t pos = 0;
/* Trim whitespace */ /* Trim whitespace */
@ -219,9 +267,9 @@ void commands_dispatch(client_t *client) {
snprintf(target->whisper_inbox[slot].content, snprintf(target->whisper_inbox[slot].content,
sizeof(target->whisper_inbox[slot].content), sizeof(target->whisper_inbox[slot].content),
"%s", rest); "%s", rest);
target->unread_whispers++;
pthread_mutex_unlock(&target->whisper_lock); pthread_mutex_unlock(&target->whisper_lock);
target->unread_whispers++;
/* Audible nudge — the title bar ✉ counter (UX-11 style) /* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */ * carries the persistent signal. */
client_queue_bell(target); client_queue_bell(target);
@ -242,35 +290,8 @@ void commands_dispatch(client_t *client) {
} }
} else if (command_id == TNT_COMMAND_INBOX) { } else if (command_id == TNT_COMMAND_INBOX) {
/* Snapshot the inbox under whisper_lock so a concurrent sender doesn't output_kind = TNT_COMMAND_OUTPUT_INBOX;
* tear what we're rendering. Counter reset happens after copy. */ append_inbox_output(client, output, sizeof(output), &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));
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);
}
} else if (command_id == TNT_COMMAND_NICK) { } else if (command_id == TNT_COMMAND_NICK) {
const char *new_name = arg; const char *new_name = arg;
@ -414,6 +435,7 @@ void commands_dispatch(client_t *client) {
cmd_done: cmd_done:
snprintf(client->command_output, sizeof(client->command_output), "%s", output); snprintf(client->command_output, sizeof(client->command_output), "%s", output);
client->command_output_scroll = 0; client->command_output_scroll = 0;
client->command_output_kind = output_kind;
client->command_input[0] = '\0'; client->command_input[0] = '\0';
tui_render_command_output(client); tui_render_command_output(client);
} }

View file

@ -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+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n" " Ctrl+F/B - Scroll full page down/up\n"
" g/G - Jump to top/bottom\n" " g/G - Jump to top/bottom\n"
" r - Refresh live output (:inbox)\n"
"\n" "\n"
"SPECIAL MESSAGES:\n" "SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\n" " /me <action> - 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+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n" " Ctrl+F/B - 向下/上滚动整页\n"
" g/G - 跳到顶部/底部\n" " g/G - 跳到顶部/底部\n"
" r - 刷新动态输出 (:inbox)\n"
"\n" "\n"
"特殊消息:\n" "特殊消息:\n"
" /me <action> - 发送动作 (如 /me waves)\n" " /me <action> - 发送动作 (如 /me waves)\n"

View file

@ -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", "-- 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:关闭" "-- 命令输出 -- (%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( [I18N_MOTD_TITLE] = I18N_STRING(
" NOTICE ", " NOTICE ",
" 公告 " " 公告 "

View file

@ -221,6 +221,7 @@ static void dismiss_command_output(client_t *client) {
was_motd = client->show_motd; was_motd = client->show_motd;
client->command_output[0] = '\0'; client->command_output[0] = '\0';
client->command_output_scroll = 0; client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->show_motd = false; client->show_motd = false;
client->mode = MODE_NORMAL; client->mode = MODE_NORMAL;
if (was_motd) { if (was_motd) {
@ -349,6 +350,9 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
} else if (key == 'G') { } else if (key == 'G') {
client->command_output_scroll = 999; client->command_output_scroll = 999;
tui_render_command_output(client); 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 */ return true; /* Key consumed */
} }
@ -735,6 +739,7 @@ void input_run_session(client_t *client) {
client->command_history_count = 0; client->command_history_count = 0;
client->command_history_pos = 0; client->command_history_pos = 0;
client->command_output_scroll = 0; client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->connect_time = time(NULL); client->connect_time = time(NULL);
client->last_active = time(NULL); client->last_active = time(NULL);
@ -788,6 +793,7 @@ void input_run_session(client_t *client) {
sizeof(client->command_output), sizeof(client->command_output),
"%s", motd_buf); "%s", motd_buf);
client->command_output_scroll = 0; client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->show_motd = true; client->show_motd = true;
tui_render_motd(client); tui_render_motd(client);
seen_update_seq = room_get_update_seq(g_room); seen_update_seq = room_get_update_seq(g_room);
@ -836,6 +842,13 @@ main_loop:
room_updated = true; 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 || if (client->redraw_pending ||
(room_updated && !client->show_help && (room_updated && !client->show_help &&
client->command_output[0] == '\0')) { client->command_output[0] == '\0')) {

View file

@ -677,7 +677,10 @@ void tui_render_command_output(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, buffer_appendf(buffer, sizeof(buffer), &pos,
i18n_text(client->ui_lang, 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); start + 1, max_scroll + 1);
client_send(client, buffer, pos); client_send(client, buffer, pos);

View file

@ -304,6 +304,9 @@ expect ":"
send -- "inbox\r" send -- "inbox\r"
expect "Private messages" expect "Private messages"
expect "(empty)" expect "(empty)"
expect "r:refresh"
send -- "r"
expect "Private messages"
expect "q:close" expect "q:close"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"

View file

@ -36,7 +36,7 @@ fi
SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" 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" SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
BOB_READY="$STATE_DIR/bob.ready" BOB_READY="$STATE_DIR/bob.ready"
ALICE_DONE="$STATE_DIR/alice.done" PRIVATE_SENT="$STATE_DIR/private.sent"
wait_for_health() { wait_for_health() {
out="" out=""
@ -80,14 +80,17 @@ spawn ssh $SSH_OPTS bob@127.0.0.1
sleep 1 sleep 1
send -- "bob\r" send -- "bob\r"
expect ":help" expect ":help"
exec touch "$BOB_READY"
exec sh -c "while \[ ! -f '$ALICE_DONE' \]; do sleep 1; done"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
expect ":" expect ":"
send -- "inbox\r" send -- "inbox\r"
expect "私信" expect "私信"
expect "(空)"
expect "r:刷新"
exec touch "$BOB_READY"
exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done"
expect "私信"
expect "alice" expect "alice"
expect "private lifecycle ping" expect "private lifecycle ping"
expect "q:关闭" expect "q:关闭"
@ -194,6 +197,7 @@ send -- ":"
expect ":" expect ":"
send -- "msg bob private lifecycle ping\r" send -- "msg bob private lifecycle ping\r"
expect "私信已发送给 bob" expect "私信已发送给 bob"
exec touch "$PRIVATE_SENT"
expect "q:关闭" expect "q:关闭"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
@ -208,7 +212,6 @@ send -- "i"
expect ":help" expect ":help"
send -- "/me ships lifecycle\r" send -- "/me ships lifecycle\r"
sleep 1 sleep 1
exec touch "$ALICE_DONE"
send -- "\003" send -- "\003"
sleep 0.2 sleep 0.2
send -- "\003" send -- "\003"
@ -222,11 +225,11 @@ else
echo "✗ primary user lifecycle failed" echo "✗ primary user lifecycle failed"
sed -n '1,240p' "$STATE_DIR/alice.log" sed -n '1,240p' "$STATE_DIR/alice.log"
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
touch "$ALICE_DONE" touch "$PRIVATE_SENT"
fi fi
if wait "$BOB_PID" 2>/dev/null; then 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)) PASS=$((PASS + 1))
else else
echo "✗ recipient inbox journey failed" echo "✗ recipient inbox journey failed"

View file

@ -29,6 +29,7 @@ TEST(full_help_matches_language) {
assert(strstr(en, "AVAILABLE COMMANDS") != NULL); assert(strstr(en, "AVAILABLE COMMANDS") != NULL);
assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL); assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL);
assert(strstr(en, ":inbox") != NULL); assert(strstr(en, ":inbox") != NULL);
assert(strstr(en, "Refresh live output") != NULL);
assert(strstr(en, ":support") == NULL); assert(strstr(en, ":support") == NULL);
assert(strstr(en, ":commands") == NULL); assert(strstr(en, ":commands") == NULL);
assert(strstr(en, "Cycle UI language") != 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, "命令输出按键") != NULL); assert(strstr(zh, "命令输出按键") != NULL);
assert(strstr(zh, ":inbox") != NULL); assert(strstr(zh, ":inbox") != NULL);
assert(strstr(zh, "刷新动态输出") != NULL);
assert(strstr(zh, "/me <action>") != NULL); assert(strstr(zh, "/me <action>") != NULL);
assert(strstr(zh, "@username") != NULL); assert(strstr(zh, "@username") != NULL);
assert(strstr(zh, "<动作>") == NULL); assert(strstr(zh, "<动作>") == NULL);

View file

@ -111,6 +111,12 @@ TEST(text_lookup_matches_language) {
"q:close") != NULL); "q:close") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT), assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
"q:关闭") != NULL); "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), assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT),
"Press any key") != NULL); "Press any key") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT), assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT),

8
tnt.1
View file

@ -199,6 +199,14 @@ l l.
Up/Down Browse command history Up/Down Browse command history
ESC Cancel and return to NORMAL ESC Cancel and return to NORMAL
.TE .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 .SH EXEC INTERFACE
Commands can be run non\-interactively for scripting: Commands can be run non\-interactively for scripting:
.PP .PP