commands: centralize interactive command catalog

This commit is contained in:
m1ngsama 2026-05-24 11:25:46 +08:00
parent 8eb311e54b
commit 57bf3cfc67
18 changed files with 606 additions and 274 deletions

1
.gitignore vendored
View file

@ -14,6 +14,7 @@ tests/unit/test_chat_room
tests/unit/test_history_view
tests/unit/test_i18n
tests/unit/test_system_message
tests/unit/test_command_catalog
tests/unit/test_help_text
tests/unit/test_manual_text
tests/unit/test_support_text

View file

@ -250,6 +250,7 @@ TNT/
├── src/ # source code
│ ├── main.c # entry point
│ ├── cli_text.c # startup CLI help and option text
│ ├── command_catalog.c # command metadata
│ ├── ssh_server.c # SSH server implementation
│ ├── bootstrap.c # SSH authentication and session bootstrap
│ ├── chat_room.c # chat room logic

View file

@ -3,6 +3,8 @@
## Unreleased
### Changed
- Command names, aliases, help summaries, concise-manual command rows, and
unknown-command suggestions now share a dedicated `command_catalog` module.
- Collapsed the interactive help surface around a concise Unix-style `:help`
manual and the `?` full key reference; `:support` is no longer a user-facing
command.

View file

@ -73,6 +73,7 @@ src/
├── bootstrap.c - SSH authentication/session bootstrap
├── input.c - Interactive session loop and key handling
├── commands.c - COMMAND-mode command dispatch
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
├── exec.c - SSH exec command dispatch
├── chat_room.c - Chat room logic and message broadcasting
├── message.c - Message persistence (RFC3339 format)
@ -97,6 +98,7 @@ include/
├── bootstrap.h - SSH session bootstrap interface
├── chat_room.h - Chat room interface
├── message.h - Message structure and persistence
├── command_catalog.h - COMMAND-mode command metadata interface
├── history_view.h - Scroll-state helpers
├── tui.h - TUI rendering functions
├── i18n.h - Language and shared text IDs

View file

@ -46,6 +46,7 @@ INSERT MODE
STRUCTURE
src/main.c entry, signals
src/cli_text.c startup CLI text
src/command_catalog.c command metadata
src/ssh_server.c SSH listener and server setup
src/bootstrap.c SSH auth/session bootstrap
src/chat_room.c broadcast and room state

37
include/command_catalog.h Normal file
View file

@ -0,0 +1,37 @@
#ifndef COMMAND_CATALOG_H
#define COMMAND_CATALOG_H
#include "common.h"
typedef enum {
TNT_COMMAND_USERS,
TNT_COMMAND_HELP,
TNT_COMMAND_LANG,
TNT_COMMAND_MSG,
TNT_COMMAND_INBOX,
TNT_COMMAND_NICK,
TNT_COMMAND_LAST,
TNT_COMMAND_SEARCH,
TNT_COMMAND_MUTE_JOINS,
TNT_COMMAND_QUIT,
TNT_COMMAND_CLEAR,
TNT_COMMAND_COUNT
} tnt_command_id_t;
typedef struct {
tnt_command_id_t id;
const char *canonical;
const char *names[4];
bool accepts_args;
} tnt_command_spec_t;
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id);
bool command_catalog_match(const char *line, tnt_command_id_t *id,
const char **args);
const char *command_catalog_suggest(const char *name);
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang);
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang);
#endif /* COMMAND_CATALOG_H */

View file

@ -3,6 +3,7 @@
#include "common.h"
const char *help_text_full(help_lang_t lang);
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang);
#endif /* HELP_TEXT_H */

View file

@ -3,6 +3,7 @@
#include "common.h"
const char *manual_text_interactive(help_lang_t lang);
void manual_text_append_interactive(char *buffer, size_t buf_size,
size_t *pos, help_lang_t lang);
#endif /* MANUAL_TEXT_H */

259
src/command_catalog.c Normal file
View file

@ -0,0 +1,259 @@
#include "command_catalog.h"
#include <string.h>
typedef struct {
tnt_command_spec_t spec;
const char *full_usage_en;
const char *full_usage_zh;
const char *summary_en;
const char *summary_zh;
const char *manual_usage_en;
const char *manual_usage_zh;
int manual_group;
} command_catalog_entry_t;
static const command_catalog_entry_t entries[] = {
{
{TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}, false},
":users, :list, :who", ":users, :list, :who",
"Show online users", "显示在线用户",
":users", ":users", 1
},
{
{TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}, true},
":msg <user> <text>, :w <user> <text>",
":msg <用户> <文本>, :w <用户> <文本>",
"Whisper to user", "私聊",
":msg <user> <text>", ":msg <用户> <文本>", 2
},
{
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}, false},
":inbox", ":inbox",
"Show whispers", "查看私聊",
":inbox", ":inbox", 2
},
{
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}, true},
":nick <name>, :name <name>", ":nick <名字>, :name <名字>",
"Change nickname", "更改昵称",
":nick <name>", ":nick <名字>", 2
},
{
{TNT_COMMAND_LAST, "last", {"last", NULL}, true},
":last [N]", ":last [N]",
"Show last N messages (max 50)", "显示最后 N 条消息(最多50)",
":last [N]", ":last [N]", 1
},
{
{TNT_COMMAND_SEARCH, "search", {"search", NULL}, true},
":search <keyword>", ":search <关键词>",
"Search message history", "搜索消息历史",
":search <keyword>", ":search <词>", 1
},
{
{TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}, false},
":mute-joins, :mute", ":mute-joins, :mute",
"Toggle join/leave notices", "切换加入/离开提示",
":mute-joins", ":mute-joins", 3
},
{
{TNT_COMMAND_HELP, "help", {"help", NULL}, false},
":help", ":help",
"Show concise manual", "显示简明手册",
NULL, NULL, 0
},
{
{TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}, true},
":lang <en|zh>", ":lang <en|zh>",
"Switch UI language", "切换界面语言",
NULL, NULL, 0
},
{
{TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}, false},
":clear, :cls", ":clear, :cls",
"Clear command output", "清空命令输出",
":clear", ":clear", 3
},
{
{TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}, false},
":q, :quit, :exit", ":q, :quit, :exit",
"Disconnect", "断开连接",
":q", ":q", 3
}
};
static const command_catalog_entry_t *entry_for_id(tnt_command_id_t id) {
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
if (entries[i].spec.id == id) {
return &entries[i];
}
}
return NULL;
}
static const char *skip_spaces(const char *value) {
while (value && *value == ' ') {
value++;
}
return value;
}
static bool name_matches(const char *line, const char *name,
const char **args) {
size_t len;
if (!line || !name) {
return false;
}
len = strlen(name);
if (strncmp(line, name, len) != 0) {
return false;
}
if (line[len] != '\0' && line[len] != ' ') {
return false;
}
if (args) {
*args = skip_spaces(line + len);
}
return true;
}
static int min3(int a, int b, int c) {
int m = a < b ? a : b;
return m < c ? m : c;
}
static int edit_distance(const char *a, const char *b) {
size_t la = strlen(a);
size_t lb = strlen(b);
int prev[32];
int curr[32];
if (la >= 32 || lb >= 32) {
return 99;
}
for (size_t j = 0; j <= lb; j++) {
prev[j] = (int)j;
}
for (size_t i = 1; i <= la; i++) {
curr[0] = (int)i;
for (size_t j = 1; j <= lb; j++) {
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
curr[j] = min3(prev[j] + 1, curr[j - 1] + 1,
prev[j - 1] + cost);
}
for (size_t j = 0; j <= lb; j++) {
prev[j] = curr[j];
}
}
return prev[lb];
}
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id) {
const command_catalog_entry_t *entry = entry_for_id(id);
return entry ? &entry->spec : NULL;
}
bool command_catalog_match(const char *line, tnt_command_id_t *id,
const char **args) {
line = skip_spaces(line);
if (!line || line[0] == '\0') {
return false;
}
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const tnt_command_spec_t *spec = &entries[i].spec;
for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) {
const char *candidate_args = NULL;
if (!spec->names[n]) {
break;
}
if (!name_matches(line, spec->names[n], &candidate_args)) {
continue;
}
if (candidate_args && candidate_args[0] != '\0' &&
!spec->accepts_args) {
continue;
}
if (id) {
*id = spec->id;
}
if (args) {
*args = candidate_args ? candidate_args : "";
}
return true;
}
}
return false;
}
const char *command_catalog_suggest(const char *name) {
const char *best = NULL;
int best_distance = 99;
if (!name || !*name) {
return NULL;
}
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const tnt_command_spec_t *spec = &entries[i].spec;
for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) {
int distance;
if (!spec->names[n]) {
break;
}
distance = edit_distance(name, spec->names[n]);
if (distance < best_distance) {
best_distance = distance;
best = spec->canonical;
}
}
}
return best_distance <= 2 ? best : NULL;
}
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang) {
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const char *usage = lang == LANG_ZH ? entries[i].full_usage_zh
: entries[i].full_usage_en;
const char *summary = lang == LANG_ZH ? entries[i].summary_zh
: entries[i].summary_en;
buffer_appendf(buffer, buf_size, pos, " %-40s - %s\n",
usage, summary);
}
}
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang) {
for (int group = 1; group <= 3; group++) {
bool first = true;
buffer_appendf(buffer, buf_size, pos, " ");
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const char *usage;
if (entries[i].manual_group != group) {
continue;
}
usage = lang == LANG_ZH ? entries[i].manual_usage_zh
: entries[i].manual_usage_en;
if (!usage) {
continue;
}
if (!first) {
buffer_appendf(buffer, buf_size, pos, ", ");
}
buffer_appendf(buffer, buf_size, pos, "%s", usage);
first = false;
}
buffer_appendf(buffer, buf_size, pos, "\n");
}
}

View file

@ -7,6 +7,7 @@
#include "commands.h"
#include "chat_room.h"
#include "client.h"
#include "command_catalog.h"
#include "common.h"
#include "i18n.h"
#include "manual.h"
@ -46,65 +47,6 @@ static void append_highlighted(char *output, size_t buf_size, size_t *pos,
}
}
static int min3(int a, int b, int c) {
int m = a < b ? a : b;
return m < c ? m : c;
}
static int command_edit_distance(const char *a, const char *b) {
size_t la = strlen(a);
size_t lb = strlen(b);
int prev[32];
int curr[32];
if (la >= 32 || lb >= 32) {
return 99;
}
for (size_t j = 0; j <= lb; j++) {
prev[j] = (int)j;
}
for (size_t i = 1; i <= la; i++) {
curr[0] = (int)i;
for (size_t j = 1; j <= lb; j++) {
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
curr[j] = min3(prev[j] + 1, curr[j - 1] + 1,
prev[j - 1] + cost);
}
for (size_t j = 0; j <= lb; j++) {
prev[j] = curr[j];
}
}
return prev[lb];
}
static const char *suggest_command(const char *cmd) {
static const char *commands[] = {
"list", "users", "who", "nick", "name", "msg", "w", "inbox",
"last", "search", "mute-joins", "mute", "lang", "language",
"help", "clear", "cls",
"q", "quit", "exit"
};
const char *best = NULL;
int best_distance = 99;
if (!cmd || !*cmd) {
return NULL;
}
for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) {
int distance = command_edit_distance(cmd, commands[i]);
if (distance < best_distance) {
best_distance = distance;
best = commands[i];
}
}
return best_distance <= 2 ? best : NULL;
}
void commands_dispatch(client_t *client) {
char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
@ -138,8 +80,34 @@ void commands_dispatch(client_t *client) {
client->command_history_pos = client->command_history_count;
}
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
strcmp(cmd, "who") == 0) {
if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
}
tnt_command_id_t command_id;
const char *arg = "";
if (!command_catalog_match(cmd, &command_id, &arg)) {
const char *suggestion = command_catalog_suggest(cmd);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_UNKNOWN_COMMAND_FORMAT),
cmd);
if (suggestion) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_DID_YOU_MEAN_FORMAT),
suggestion);
}
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_UNKNOWN_GUIDANCE));
goto cmd_done;
}
if (command_id == TNT_COMMAND_USERS) {
pthread_rwlock_rdlock(&g_room->lock);
int total = g_room->client_count;
buffer_appendf(output, sizeof(output), &pos,
@ -167,22 +135,13 @@ void commands_dispatch(client_t *client) {
}
pthread_rwlock_unlock(&g_room->lock);
} else if (strcmp(cmd, "help") == 0) {
} else if (command_id == TNT_COMMAND_HELP) {
manual_append_interactive_panel(output, sizeof(output), &pos,
client->help_lang);
} else if (strcmp(cmd, "lang") == 0 || strcmp(cmd, "language") == 0 ||
strncmp(cmd, "lang ", 5) == 0 ||
strncmp(cmd, "language ", 9) == 0) {
char *arg = NULL;
} else if (command_id == TNT_COMMAND_LANG) {
help_lang_t next_lang;
if (strncmp(cmd, "lang ", 5) == 0) {
arg = cmd + 5;
} else if (strncmp(cmd, "language ", 9) == 0) {
arg = cmd + 9;
}
if (!arg || arg[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
@ -201,9 +160,8 @@ void commands_dispatch(client_t *client) {
arg);
}
} else if (strcmp(cmd, "msg") == 0 || strcmp(cmd, "w") == 0 ||
strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 1 : cmd + 3;
} else if (command_id == TNT_COMMAND_MSG) {
const char *rest = arg;
while (*rest == ' ') rest++;
char target_name[MAX_USERNAME_LEN] = {0};
int ti = 0;
@ -273,7 +231,7 @@ void commands_dispatch(client_t *client) {
}
}
} else if (strcmp(cmd, "inbox") == 0) {
} else if (command_id == TNT_COMMAND_INBOX) {
/* Snapshot the inbox under io_lock so a concurrent sender doesn't
* tear what we're rendering. Counter reset happens after copy. */
whisper_t snapshot[WHISPER_INBOX_SIZE];
@ -304,9 +262,8 @@ void commands_dispatch(client_t *client) {
ts, snapshot[i].from, snapshot[i].content);
}
} else if (strcmp(cmd, "nick") == 0 || strcmp(cmd, "name") == 0 ||
strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) {
char *new_name = cmd + 4;
} else if (command_id == TNT_COMMAND_NICK) {
const char *new_name = arg;
while (*new_name == ' ') new_name++;
if (new_name[0] == '\0') {
@ -367,8 +324,7 @@ void commands_dispatch(client_t *client) {
}
}
} else if (strncmp(cmd, "last", 4) == 0 && (cmd[4] == ' ' || cmd[4] == '\0')) {
char *arg = cmd + 4;
} else if (command_id == TNT_COMMAND_LAST) {
while (*arg == ' ') arg++;
int n = 10;
if (*arg != '\0') {
@ -397,8 +353,8 @@ void commands_dispatch(client_t *client) {
}
free(last_msgs);
} else if (strcmp(cmd, "search") == 0 || strncmp(cmd, "search ", 7) == 0) {
char *query = cmd + 6;
} else if (command_id == TNT_COMMAND_SEARCH) {
const char *query = arg;
while (*query == ' ') query++;
if (*query == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
@ -427,7 +383,7 @@ void commands_dispatch(client_t *client) {
free(found);
}
} else if (strcmp(cmd, "mute-joins") == 0 || strcmp(cmd, "mute") == 0) {
} else if (command_id == TNT_COMMAND_MUTE_JOINS) {
client->mute_joins = !client->mute_joins;
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang, I18N_MUTE_JOINS_FORMAT),
@ -436,36 +392,13 @@ void commands_dispatch(client_t *client) {
I18N_MUTE_JOINS_MUTED :
I18N_MUTE_JOINS_UNMUTED));
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
strcmp(cmd, "exit") == 0) {
} else if (command_id == TNT_COMMAND_QUIT) {
client->connected = false;
return;
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
} else if (command_id == TNT_COMMAND_CLEAR) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_CLEAR_DONE));
} else if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
} else {
const char *suggestion = suggest_command(cmd);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_UNKNOWN_COMMAND_FORMAT),
cmd);
if (suggestion) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_DID_YOU_MEAN_FORMAT),
suggestion);
}
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_UNKNOWN_GUIDANCE));
}
cmd_done:

View file

@ -1,115 +1,101 @@
#include "help_text.h"
const char *help_text_full(help_lang_t lang) {
#include "command_catalog.h"
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang) {
if (lang == LANG_EN) {
return "TNT KEY REFERENCE\n"
"\n"
"OPERATING MODES:\n"
" INSERT - Type and send messages (default)\n"
" NORMAL - Browse message history\n"
" COMMAND - Execute commands\n"
"\n"
"INSERT MODE KEYS:\n"
" ESC - Enter NORMAL mode\n"
" Enter - Send message\n"
" Backspace - Delete character\n"
" Ctrl+W - Delete last word\n"
" Ctrl+U - Delete line\n"
" Ctrl+C - Enter NORMAL mode\n"
"\n"
"NORMAL MODE KEYS:\n"
" Opens at latest messages\n"
" Follows latest until you scroll up\n"
" i - Return to INSERT mode\n"
" : - Enter COMMAND mode\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"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n"
" ? - Show full key reference\n"
" Ctrl+C - Exit chat\n"
"\n"
"AVAILABLE COMMANDS:\n"
" :list, :users - Show online users\n"
" :nick <name> - Change nickname\n"
" :msg <user> <text> - Whisper to user\n"
" :w <user> <text> - Short alias for :msg\n"
" :inbox - Show whispers\n"
" :last [N] - Show last N messages (max 50)\n"
" :search <keyword> - Search message history\n"
" :mute-joins - Toggle join/leave notices\n"
" :help - Show concise manual\n"
" :lang <en|zh> - Switch UI language\n"
" :clear - Clear command output\n"
" :q, :quit, :exit - Disconnect\n"
"\n"
"SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\n"
" @username - Mention user (bell + highlight)\n"
"\n"
"HELP SCREEN KEYS:\n"
" q, ESC - Close help\n"
" j/k - Scroll down/up\n"
" 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"
" e/z - Switch English/Chinese\n";
buffer_appendf(buffer, buf_size, pos,
"TNT KEY REFERENCE\n"
"\n"
"OPERATING MODES:\n"
" INSERT - Type and send messages (default)\n"
" NORMAL - Browse message history\n"
" COMMAND - Execute commands\n"
"\n"
"INSERT MODE KEYS:\n"
" ESC - Enter NORMAL mode\n"
" Enter - Send message\n"
" Backspace - Delete character\n"
" Ctrl+W - Delete last word\n"
" Ctrl+U - Delete line\n"
" Ctrl+C - Enter NORMAL mode\n"
"\n"
"NORMAL MODE KEYS:\n"
" Opens at latest messages\n"
" Follows latest until you scroll up\n"
" i - Return to INSERT mode\n"
" : - Enter COMMAND mode\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"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n"
" ? - Show full key reference\n"
" Ctrl+C - Exit chat\n"
"\n"
"AVAILABLE COMMANDS:\n");
command_catalog_append_full(buffer, buf_size, pos, lang);
buffer_appendf(buffer, buf_size, pos,
"\n"
"SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\n"
" @username - Mention user (bell + highlight)\n"
"\n"
"HELP SCREEN KEYS:\n"
" q, ESC - Close help\n"
" j/k - Scroll down/up\n"
" 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"
" e/z - Switch English/Chinese\n");
return;
}
return "TNT 按键参考\n"
"\n"
"操作模式:\n"
" INSERT - 输入和发送消息(默认)\n"
" NORMAL - 浏览消息历史\n"
" COMMAND - 执行命令\n"
"\n"
"INSERT 模式按键:\n"
" ESC - 进入 NORMAL 模式\n"
" Enter - 发送消息\n"
" Backspace - 删除字符\n"
" Ctrl+W - 删除上个单词\n"
" Ctrl+U - 删除整行\n"
" Ctrl+C - 进入 NORMAL 模式\n"
"\n"
"NORMAL 模式按键:\n"
" 默认停在最新消息\n"
" 未向上翻阅时自动跟随最新消息\n"
" i - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n"
" j/k - 向下/上滚动一行\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n"
" ? - 显示完整按键参考\n"
" Ctrl+C - 退出聊天\n"
"\n"
"可用命令:\n"
" :list, :users - 显示在线用户\n"
" :nick <名字> - 更改昵称\n"
" :msg <用户> <文本> - 私聊\n"
" :w <用户> <文本> - :msg 的简写\n"
" :inbox - 查看私聊\n"
" :last [N] - 显示最后 N 条消息(最多50)\n"
" :search <关键词> - 搜索消息历史\n"
" :mute-joins - 切换加入/离开提示\n"
" :help - 显示简明手册\n"
" :lang <en|zh> - 切换界面语言\n"
" :clear - 清空命令输出\n"
" :q, :quit, :exit - 断开连接\n"
"\n"
"特殊消息:\n"
" /me <动作> - 发送动作 (如 /me 挥手)\n"
" @用户名 - 提及用户 (响铃+高亮)\n"
"\n"
"帮助界面按键:\n"
" q, ESC - 关闭帮助\n"
" j/k - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" g/G - 跳到顶部/底部\n"
" e/z - 切换英文/中文\n";
buffer_appendf(buffer, buf_size, pos,
"TNT 按键参考\n"
"\n"
"操作模式:\n"
" INSERT - 输入和发送消息(默认)\n"
" NORMAL - 浏览消息历史\n"
" COMMAND - 执行命令\n"
"\n"
"INSERT 模式按键:\n"
" ESC - 进入 NORMAL 模式\n"
" Enter - 发送消息\n"
" Backspace - 删除字符\n"
" Ctrl+W - 删除上个单词\n"
" Ctrl+U - 删除整行\n"
" Ctrl+C - 进入 NORMAL 模式\n"
"\n"
"NORMAL 模式按键:\n"
" 默认停在最新消息\n"
" 未向上翻阅时自动跟随最新消息\n"
" i - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n"
" j/k - 向下/上滚动一行\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n"
" ? - 显示完整按键参考\n"
" Ctrl+C - 退出聊天\n"
"\n"
"可用命令:\n");
command_catalog_append_full(buffer, buf_size, pos, lang);
buffer_appendf(buffer, buf_size, pos,
"\n"
"特殊消息:\n"
" /me <动作> - 发送动作 (如 /me 挥手)\n"
" @用户名 - 提及用户 (响铃+高亮)\n"
"\n"
"帮助界面按键:\n"
" q, ESC - 关闭帮助\n"
" j/k - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" g/G - 跳到顶部/底部\n"
" e/z - 切换英文/中文\n");
}

View file

@ -5,6 +5,5 @@ void manual_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos, help_lang_t lang) {
if (!buffer || !pos) return;
buffer_appendf(buffer, buf_size, pos, "%s",
manual_text_interactive(lang));
manual_text_append_interactive(buffer, buf_size, pos, lang);
}

View file

@ -1,47 +1,51 @@
#include "manual_text.h"
const char *manual_text_interactive(help_lang_t lang) {
#include "command_catalog.h"
void manual_text_append_interactive(char *buffer, size_t buf_size,
size_t *pos, help_lang_t lang) {
if (lang == LANG_ZH) {
return "\033[1;36mTNT(1) 帮助\033[0m\n"
"\n"
"\033[1;37m名称\033[0m\n"
" TNT - SSH 终端聊天室\n"
"\n"
"\033[1;37m使用\033[0m\n"
" 输入消息并 Enter 发送Esc 浏览历史G 最新i 输入\n"
" : 运行命令;? 打开完整按键参考\n"
"\n"
"\033[1;37m命令\033[0m\n"
" :users, :last [N], :search <词>\n"
" :msg <用户> <文本>, :inbox, :nick <名字>\n"
" :mute-joins, :clear, :q\n"
"\n"
"\033[1;37m语言\033[0m\n"
" :lang 显示当前语言\n"
" :lang en|zh 切换语言\n"
"\n"
"\033[1;37m参见\033[0m\n"
" ? 完整按键参考\n";
buffer_appendf(buffer, buf_size, pos,
"\033[1;36mTNT(1) 帮助\033[0m\n"
"\n"
"\033[1;37m名称\033[0m\n"
" TNT - SSH 终端聊天室\n"
"\n"
"\033[1;37m使用\033[0m\n"
" 输入消息并 Enter 发送Esc 浏览历史G 最新i 输入\n"
" : 运行命令;? 打开完整按键参考\n"
"\n"
"\033[1;37m命令\033[0m\n");
command_catalog_append_manual(buffer, buf_size, pos, lang);
buffer_appendf(buffer, buf_size, pos,
"\n"
"\033[1;37m语言\033[0m\n"
" :lang 显示当前语言\n"
" :lang en|zh 切换语言\n"
"\n"
"\033[1;37m参见\033[0m\n"
" ? 完整按键参考\n");
return;
}
return "\033[1;36mTNT(1) help\033[0m\n"
"\n"
"\033[1;37mName\033[0m\n"
" 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"
"\n"
"\033[1;37mCommands\033[0m\n"
" :users, :last [N], :search <keyword>\n"
" :msg <user> <text>, :inbox, :nick <name>\n"
" :mute-joins, :clear, :q\n"
"\n"
"\033[1;37mLanguage\033[0m\n"
" :lang show current language\n"
" :lang en|zh switch language\n"
"\n"
"\033[1;37mSee also\033[0m\n"
" ? full key reference\n";
buffer_appendf(buffer, buf_size, pos,
"\033[1;36mTNT(1) help\033[0m\n"
"\n"
"\033[1;37mName\033[0m\n"
" 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"
"\n"
"\033[1;37mCommands\033[0m\n");
command_catalog_append_manual(buffer, buf_size, pos, lang);
buffer_appendf(buffer, buf_size, pos,
"\n"
"\033[1;37mLanguage\033[0m\n"
" :lang show current language\n"
" :lang en|zh switch language\n"
"\n"
"\033[1;37mSee also\033[0m\n"
" ? full key reference\n");
}

View file

@ -777,11 +777,11 @@ void tui_render_help(client_t *client) {
}
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
/* Help content */
const char *help_text = help_text_full(client->help_lang);
char help_copy[8192];
strncpy(help_copy, help_text, sizeof(help_copy) - 1);
help_copy[sizeof(help_copy) - 1] = '\0';
size_t help_pos = 0;
help_copy[0] = '\0';
help_text_append_full(help_copy, sizeof(help_copy), &help_pos,
client->help_lang);
/* Split into lines and display with scrolling */
char *lines[100];

View file

@ -13,6 +13,7 @@ endif
UTF8_SRC = ../../src/utf8.c
MESSAGE_SRC = ../../src/message.c
COMMON_SRC = ../../src/common.c
COMMAND_CATALOG_SRC = ../../src/command_catalog.c
CLI_TEXT_SRC = ../../src/cli_text.c
CHAT_ROOM_SRC = ../../src/chat_room.c
HISTORY_VIEW_SRC = ../../src/history_view.c
@ -22,7 +23,7 @@ HELP_TEXT_SRC = ../../src/help_text.c
MANUAL_TEXT_SRC = ../../src/manual_text.c
RATELIMIT_SRC = ../../src/ratelimit.c
TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_help_text test_manual_text test_cli_text test_ratelimit
TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_help_text test_manual_text test_cli_text test_ratelimit
.PHONY: all clean run
@ -46,10 +47,13 @@ test_i18n: test_i18n.c $(I18N_SRC)
test_system_message: test_system_message.c $(SYSTEM_MESSAGE_SRC) $(I18N_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_help_text: test_help_text.c $(HELP_TEXT_SRC) $(COMMON_SRC)
test_command_catalog: test_command_catalog.c $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC)
test_help_text: test_help_text.c $(HELP_TEXT_SRC) $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC) $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC)
@ -77,6 +81,9 @@ run: all
@echo "=== Running System Message Tests ==="
./test_system_message
@echo ""
@echo "=== Running Command Catalog Tests ==="
./test_command_catalog
@echo ""
@echo "=== Running Help Text Tests ==="
./test_help_text
@echo ""

View file

@ -0,0 +1,86 @@
/* Unit tests for command catalog names, aliases, and generated help text */
#include "../../include/command_catalog.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#define TEST(name) static void test_##name()
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("\n"); \
tests_passed++; \
} while(0)
static int tests_passed = 0;
TEST(matches_canonical_names_and_aliases) {
tnt_command_id_t id;
const char *args;
assert(command_catalog_match("users", &id, &args));
assert(id == TNT_COMMAND_USERS);
assert(strcmp(args, "") == 0);
assert(command_catalog_match("list", &id, &args));
assert(id == TNT_COMMAND_USERS);
assert(command_catalog_match("msg alice hello", &id, &args));
assert(id == TNT_COMMAND_MSG);
assert(strcmp(args, "alice hello") == 0);
assert(command_catalog_match("w alice hello", &id, &args));
assert(id == TNT_COMMAND_MSG);
assert(strcmp(args, "alice hello") == 0);
assert(command_catalog_match("language zh", &id, &args));
assert(id == TNT_COMMAND_LANG);
assert(strcmp(args, "zh") == 0);
}
TEST(rejects_arguments_for_no_arg_commands) {
tnt_command_id_t id;
const char *args;
assert(!command_catalog_match("users extra", &id, &args));
assert(!command_catalog_match("help now", &id, &args));
assert(!command_catalog_match("q now", &id, &args));
}
TEST(suggests_from_catalog_aliases) {
assert(strcmp(command_catalog_suggest("hlep"), "help") == 0);
assert(strcmp(command_catalog_suggest("usres"), "users") == 0);
assert(strcmp(command_catalog_suggest("laguage"), "lang") == 0);
assert(command_catalog_suggest("not-even-close") == NULL);
}
TEST(generates_localized_help_sections) {
char en[4096] = {0};
char zh[4096] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
command_catalog_append_full(en, sizeof(en), &en_pos, LANG_EN);
command_catalog_append_full(zh, sizeof(zh), &zh_pos, LANG_ZH);
assert(strstr(en, ":users, :list, :who") != NULL);
assert(strstr(en, "Show online users") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(zh, ":users, :list, :who") != NULL);
assert(strstr(zh, "显示在线用户") != NULL);
assert(strstr(zh, ":support") == NULL);
}
int main(void) {
printf("Running command catalog unit tests...\n\n");
RUN_TEST(matches_canonical_names_and_aliases);
RUN_TEST(rejects_arguments_for_no_arg_commands);
RUN_TEST(suggests_from_catalog_aliases);
RUN_TEST(generates_localized_help_sections);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -16,8 +16,13 @@
static int tests_passed = 0;
TEST(full_help_matches_language) {
const char *en = help_text_full(LANG_EN);
const char *zh = help_text_full(LANG_ZH);
char en[8192] = {0};
char zh[8192] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
help_text_append_full(en, sizeof(en), &en_pos, LANG_EN);
help_text_append_full(zh, sizeof(zh), &zh_pos, LANG_ZH);
assert(strstr(en, "TNT KEY REFERENCE") != NULL);
assert(strstr(en, "AVAILABLE COMMANDS") != NULL);

View file

@ -29,14 +29,20 @@ static int count_lines(const char *text) {
}
TEST(interactive_manual_matches_language) {
const char *en = manual_text_interactive(LANG_EN);
const char *zh = manual_text_interactive(LANG_ZH);
char en[4096] = {0};
char zh[4096] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
manual_text_append_interactive(en, sizeof(en), &en_pos, LANG_EN);
manual_text_append_interactive(zh, sizeof(zh), &zh_pos, LANG_ZH);
assert(strstr(en, "TNT(1) help") != NULL);
assert(strstr(en, "Use") != NULL);
assert(strstr(en, "Commands") != NULL);
assert(strstr(en, ":lang en|zh") != NULL);
assert(strstr(en, ":mute-joins") != NULL);
assert(strstr(en, ":mute-joins, :clear, :q") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(en, ":commands") == NULL);
assert(count_lines(en) <= 20);
@ -46,6 +52,7 @@ TEST(interactive_manual_matches_language) {
assert(strstr(zh, "命令") != NULL);
assert(strstr(zh, ":lang en|zh") != NULL);
assert(strstr(zh, ":mute-joins") != NULL);
assert(strstr(zh, ":mute-joins, :clear, :q") != NULL);
assert(strstr(zh, ":support") == NULL);
assert(strstr(zh, ":commands") == NULL);
assert(count_lines(zh) <= 20);