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
```
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 <action> - Send action (e.g. /me waves)

View file

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

View file

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

View file

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

View file

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

View file

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

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

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+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 <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+F/B - 向下/上滚动整页\n"
" g/G - 跳到顶部/底部\n"
" r - 刷新动态输出 (:inbox)\n"
"\n"
"特殊消息:\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",
"-- 命令输出 -- (%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 ",
" 公告 "

View file

@ -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')) {

View file

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

View file

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

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_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"

View file

@ -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 <action>") != NULL);
assert(strstr(zh, "@username") != NULL);
assert(strstr(zh, "<动作>") == NULL);

View file

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

8
tnt.1
View file

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