mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:44:38 +08:00
Polish live inbox command output
This commit is contained in:
parent
f3e2762f30
commit
e603a55cb3
16 changed files with 129 additions and 38 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ",
|
||||
" 公告 "
|
||||
|
|
|
|||
13
src/input.c
13
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')) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
8
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue