From 1e9cca596afa2f30c1a53c2f4da1d3dfcf5bc3a7 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Mon, 29 Jun 2026 10:11:38 +0800 Subject: [PATCH 1/4] feat: add Tab completion for COMMAND mode Add command_catalog_complete(): a tested, case-insensitive prefix completer over canonical command names that reports matches and their longest common prefix. Wire Tab in COMMAND mode (mirroring the existing INSERT @mention Tab): - ':the'+Tab -> ':theme '; unique prefixes complete fully. - Ambiguous prefixes fill the longest common prefix and show candidates inline on the status line (e.g. ':m' -> 'msg mute-joins'); ':'+Tab lists all. - First-argument completion for closed sets: ':theme '/':lang ' values and online usernames for ':msg '/':w ' (reusing room data like @mention). New tui_render_command_hint() renders the dim, width-truncated candidate list. Covered by command_catalog unit tests and an interactive expect regression. --- include/command_catalog.h | 11 +++ include/tui.h | 6 ++ src/command_catalog.c | 46 +++++++++ src/input.c | 155 ++++++++++++++++++++++++++++++ src/tui.c | 35 +++++++ tests/test_interactive_input.sh | 54 +++++++++++ tests/unit/cat.log | 0 tests/unit/test_command_catalog.c | 61 ++++++++++++ 8 files changed, 368 insertions(+) create mode 100644 tests/unit/cat.log diff --git a/include/command_catalog.h b/include/command_catalog.h index 2b32fa1..e675e91 100644 --- a/include/command_catalog.h +++ b/include/command_catalog.h @@ -31,6 +31,17 @@ bool command_catalog_match(const char *line, tnt_command_id_t *id, const char **args); bool command_catalog_args_valid(tnt_command_id_t id, const char *args); const char *command_catalog_suggest(const char *name); + +/* Prefix-complete a command name for Tab completion. + * + * Case-insensitively matches `prefix` against canonical command names. An + * empty or NULL prefix matches every command. Up to `max` matching canonical + * names are written to `out` (pointers to static storage). The longest common + * prefix of *all* matches is written to `lcp` (NUL-terminated, truncated to + * `lcp_size`); it is empty when matches share no common prefix. Returns the + * total number of matches (which may exceed `max`). */ +size_t command_catalog_complete(const char *prefix, const char **out, + size_t max, char *lcp, size_t lcp_size); void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos, ui_lang_t lang); void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos, diff --git a/include/tui.h b/include/tui.h index cbb43b5..2eed3e4 100644 --- a/include/tui.h +++ b/include/tui.h @@ -27,6 +27,12 @@ void tui_render_input(struct client *client, const char *input); /* Render only the command input/status line */ void tui_render_command_input(struct client *client); +/* Render the command input/status line followed by a dim completion hint + * (e.g. a space-separated list of candidate commands). The hint is + * truncated to the remaining terminal width. A NULL or empty hint behaves + * like tui_render_command_input(). */ +void tui_render_command_hint(struct client *client, const char *hint); + /* Clear the screen */ void tui_clear_screen(struct client *client); diff --git a/src/command_catalog.c b/src/command_catalog.c index 625bcae..80a9c5e 100644 --- a/src/command_catalog.c +++ b/src/command_catalog.c @@ -3,6 +3,7 @@ #include "i18n.h" #include +#include typedef struct { tnt_command_spec_t spec; @@ -285,6 +286,51 @@ const char *command_catalog_suggest(const char *name) { return best_distance <= 2 ? best : NULL; } +size_t command_catalog_complete(const char *prefix, const char **out, + size_t max, char *lcp, size_t lcp_size) { + size_t count = 0; + size_t plen; + + if (lcp && lcp_size > 0) { + lcp[0] = '\0'; + } + if (!prefix) { + prefix = ""; + } + plen = strlen(prefix); + + for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { + const char *name = entries[i].spec.canonical; + + if (!name) { + continue; + } + if (plen > 0 && strncasecmp(name, prefix, plen) != 0) { + continue; + } + + if (out && count < max) { + out[count] = name; + } + + if (lcp && lcp_size > 0) { + if (count == 0) { + snprintf(lcp, lcp_size, "%s", name); + } else { + size_t k = 0; + while (lcp[k] && name[k] && lcp[k] == name[k]) { + k++; + } + lcp[k] = '\0'; + } + } + + count++; + } + + return count; +} + void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos, ui_lang_t lang) { for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { diff --git a/src/input.c b/src/input.c index 856bed4..dde5156 100644 --- a/src/input.c +++ b/src/input.c @@ -1,6 +1,7 @@ #include "input.h" #include "chat_room.h" #include "client.h" +#include "command_catalog.h" #include "commands.h" #include "config_defaults.h" #include "common.h" @@ -12,6 +13,7 @@ #include "module_runtime.h" #include "ratelimit.h" #include "system_message.h" +#include "theme.h" #include "tui.h" #include "utf8.h" #include @@ -361,6 +363,156 @@ static pager_action_t pager_apply_key(client_t *client, unsigned char key, return PAGER_ACTION_NONE; } +/* Join up to `shown` candidate strings into `out` as "a b c", adding a + * "(+K)" suffix when more candidates exist than are shown. */ +static void build_candidate_hint(char *out, size_t out_size, + const char **cands, size_t n, size_t shown) { + size_t pos = 0; + out[0] = '\0'; + for (size_t i = 0; i < n && i < shown; i++) { + buffer_appendf(out, out_size, &pos, "%s%s", i ? " " : "", cands[i]); + } + if (n > shown) { + buffer_appendf(out, out_size, &pos, " (+%u)", (unsigned)(n - shown)); + } +} + +/* Case-insensitive prefix filter over an ad-hoc candidate array, mirroring + * command_catalog_complete() for argument completion. Returns the total match + * count; writes up to `maxm` matches into `matches` and the longest common + * prefix of all matches into `lcp`. */ +static size_t prefix_filter(const char **cands, size_t ncand, + const char *prefix, const char **matches, + size_t maxm, char *lcp, size_t lcp_size) { + size_t count = 0; + size_t plen = prefix ? strlen(prefix) : 0; + + if (lcp && lcp_size > 0) { + lcp[0] = '\0'; + } + for (size_t i = 0; i < ncand; i++) { + const char *name = cands[i]; + if (!name) { + continue; + } + if (plen > 0 && strncasecmp(name, prefix, plen) != 0) { + continue; + } + if (matches && count < maxm) { + matches[count] = name; + } + if (lcp && lcp_size > 0) { + if (count == 0) { + snprintf(lcp, lcp_size, "%s", name); + } else { + size_t k = 0; + while (lcp[k] && name[k] && lcp[k] == name[k]) { + k++; + } + lcp[k] = '\0'; + } + } + count++; + } + return count; +} + +/* Tab completion for COMMAND mode. Completes the command name when no + * argument has been started, otherwise completes the first argument for the + * handful of commands with a closed/known candidate set (theme, lang, and + * online usernames for msg). Mutates client->command_input and renders. */ +static void command_tab_complete(client_t *client) { + char *buf = client->command_input; + size_t cap = sizeof(client->command_input); + char *sp = strchr(buf, ' '); + + if (sp == NULL) { + /* Command name completion. */ + const char *out[16]; + char lcp[64]; + size_t n = command_catalog_complete(buf, out, 16, lcp, sizeof(lcp)); + if (n == 0) { + client_send(client, "\a", 1); + return; + } + if (n == 1) { + snprintf(buf, cap, "%s ", out[0]); + tui_render_command_input(client); + return; + } + if (strlen(lcp) > strlen(buf)) { + snprintf(buf, cap, "%s", lcp); + } + char hint[512]; + build_candidate_hint(hint, sizeof(hint), out, n, 12); + tui_render_command_hint(client, hint); + return; + } + + /* Argument completion (first argument only). */ + char cmd[32]; + size_t cmdlen = (size_t)(sp - buf); + if (cmdlen == 0 || cmdlen >= sizeof(cmd)) { + return; + } + memcpy(cmd, buf, cmdlen); + cmd[cmdlen] = '\0'; + + const char *argregion = sp; + while (*argregion == ' ') { + argregion++; + } + if (strchr(argregion, ' ') != NULL) { + return; /* past the first argument: nothing to complete */ + } + + const char *cands[64]; + char namebufs[64][MAX_USERNAME_LEN]; + size_t ncand = 0; + + if (strcasecmp(cmd, "theme") == 0 || strcasecmp(cmd, "color") == 0) { + size_t tc = theme_count(); + for (size_t i = 0; i < tc && ncand < 64; i++) { + cands[ncand++] = theme_at(i)->name; + } + } else if (strcasecmp(cmd, "lang") == 0 || + strcasecmp(cmd, "language") == 0) { + cands[ncand++] = "en"; + cands[ncand++] = "zh"; + } else if (strcasecmp(cmd, "msg") == 0 || strcasecmp(cmd, "w") == 0) { + pthread_rwlock_rdlock(&g_room->lock); + for (int i = 0; i < g_room->client_count && ncand < 64; i++) { + snprintf(namebufs[ncand], MAX_USERNAME_LEN, "%s", + g_room->clients[i]->username); + cands[ncand] = namebufs[ncand]; + ncand++; + } + pthread_rwlock_unlock(&g_room->lock); + } else { + return; + } + + const char *out[64]; + char lcp[MAX_USERNAME_LEN]; + size_t n = prefix_filter(cands, ncand, argregion, out, 64, lcp, + sizeof(lcp)); + if (n == 0) { + client_send(client, "\a", 1); + return; + } + if (n == 1) { + snprintf(buf, cap, "%s %s ", cmd, out[0]); + tui_render_command_input(client); + return; + } + if (strlen(lcp) > strlen(argregion)) { + snprintf(buf, cap, "%s %s", cmd, lcp); + } + char hint[512]; + build_candidate_hint(hint, sizeof(hint), out, n, 12); + tui_render_command_hint(client, hint); +} + /* Handle a single key press. Returns true if the key was fully consumed * (no further character buffering needed). */ static bool handle_key(client_t *client, unsigned char key, char *input) { @@ -821,6 +973,9 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { tui_render_command_input(client); } return true; + } else if (key == 9) { /* Tab: complete command name or argument */ + command_tab_complete(client); + return true; } break; diff --git a/src/tui.c b/src/tui.c index d28767b..3e2556f 100644 --- a/src/tui.c +++ b/src/tui.c @@ -721,6 +721,41 @@ void tui_render_command_input(client_t *client) { client_send(client, buffer, pos); } +void tui_render_command_hint(client_t *client, const char *hint) { + if (!client || !client->connected) return; + + int rw = client->width; + int rh = client->height; + if (rw < 10) rw = 10; + if (rh < 4) rh = 4; + + char buffer[sizeof(client->command_input) + 512]; + size_t pos = 0; + buffer[0] = '\0'; + + buffer_appendf(buffer, sizeof(buffer), &pos, + "\033[%d;1H" ANSI_CLEAR_LINE, rh); + tui_status_append(buffer, sizeof(buffer), &pos, client, 0, 0, 0); + + if (hint && hint[0] != '\0') { + /* The status line shows ":". Reserve that width plus a + * two-column gap, then truncate the hint to whatever space remains. */ + int used = 1 + utf8_string_width(client->command_input) + 2; + int avail = rw - used; + if (avail >= 4) { + char hint_copy[512]; + snprintf(hint_copy, sizeof(hint_copy), "%s", hint); + if (utf8_string_width(hint_copy) > avail) { + utf8_truncate(hint_copy, avail); + } + buffer_appendf(buffer, sizeof(buffer), &pos, + " \033[2;37m%s\033[0m", hint_copy); + } + } + + client_send(client, buffer, pos); +} + /* Render the command output screen */ void tui_render_command_output(client_t *client) { if (!client || !client->connected) return; diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index fb6b2bb..5328b0a 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -406,6 +406,60 @@ else FAIL=$((FAIL + 1)) fi +COMPLETION_SCRIPT="$STATE_DIR/completion.expect" +cat >"$COMPLETION_SCRIPT" <"$STATE_DIR/completion.log" 2>&1; then + echo "✓ Tab completes command names and arguments" + PASS=$((PASS + 1)) +else + echo "x command completion failed" + sed -n '1,200p' "$STATE_DIR/completion.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + COMMAND_USAGE_SCRIPT="$STATE_DIR/command-usage.expect" cat >"$COMMAND_USAGE_SCRIPT" <= 2); + assert(strcmp(lcp, "m") == 0); + bool saw_msg = false, saw_mute = false; + for (size_t i = 0; i < n && i < 8; i++) { + if (strcmp(out[i], "msg") == 0) saw_msg = true; + if (strcmp(out[i], "mute-joins") == 0) saw_mute = true; + } + assert(saw_msg && saw_mute); +} + +TEST(completes_is_case_insensitive_but_returns_canonical) { + const char *out[4]; + char lcp[32]; + size_t n = command_catalog_complete("THE", out, 4, lcp, sizeof(lcp)); + assert(n == 1); + assert(strcmp(out[0], "theme") == 0); + assert(strcmp(lcp, "theme") == 0); +} + +TEST(empty_prefix_matches_all) { + const char *out[32]; + char lcp[32]; + size_t n = command_catalog_complete("", out, 32, lcp, sizeof(lcp)); + assert(n >= 10); + assert(lcp[0] == '\0'); +} + +TEST(no_match_returns_zero) { + const char *out[4]; + char lcp[32]; + size_t n = command_catalog_complete("zzz", out, 4, lcp, sizeof(lcp)); + assert(n == 0); +} + +TEST(complete_respects_max_but_reports_total) { + const char *out[2]; + char lcp[32]; + size_t n = command_catalog_complete("", out, 2, lcp, sizeof(lcp)); + assert(n >= 10); /* total reported even though only 2 written */ +} + int main(void) { printf("Running command catalog unit tests...\n\n"); + RUN_TEST(completes_unique_prefix); + RUN_TEST(completes_ambiguous_prefix_to_common_prefix); + RUN_TEST(completes_is_case_insensitive_but_returns_canonical); + RUN_TEST(empty_prefix_matches_all); + RUN_TEST(no_match_returns_zero); + RUN_TEST(complete_respects_max_but_reports_total); RUN_TEST(matches_canonical_names_and_aliases); RUN_TEST(matches_known_commands_before_argument_validation); RUN_TEST(validates_argument_shapes); From 650cab0d6de3de5a87847bff14d49de94eb8eb2b Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Mon, 29 Jun 2026 10:20:26 +0800 Subject: [PATCH 2/4] feat: guide newcomers via welcome line and richer INSERT hint Add a dim, centered 'getting started' line under the welcome banner (shown to every connecting user before the name prompt) and expand the always-visible wide INSERT-mode hint to surface ':' commands and ':help'. The empty-room placeholder is intentionally not used for onboarding since joining always writes a system message, so it is never shown to a logged-in user. Both new strings are localized (en/zh). --- include/i18n.h | 1 + src/i18n_text.c | 8 ++++++-- src/tui.c | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/include/i18n.h b/include/i18n.h index 6515603..f73e750 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -36,6 +36,7 @@ typedef enum { I18N_TITLE_MUTED, I18N_TITLE_HELP_HINT, I18N_EMPTY_ROOM, + I18N_WELCOME_GUIDE, I18N_EMPTY_FILTERED, I18N_IDLE_TIMEOUT_FORMAT, I18N_SYSTEM_USERNAME, diff --git a/src/i18n_text.c b/src/i18n_text.c index c87b1a3..12f78de 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -26,8 +26,8 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "TNT %s - SSH 匿名聊天室\r\n\r\n" ), [I18N_INSERT_HINT_WIDE] = I18N_STRING( - "Enter send · Esc NORMAL", - "Enter 发送 · Esc NORMAL" + "Enter send · : commands · :help guide · Esc NORMAL", + "Enter 发送 · : 命令 · :help 指引 · Esc NORMAL" ), [I18N_INSERT_HINT_NARROW] = I18N_STRING( "Enter · Esc", @@ -85,6 +85,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "No messages yet", "暂无消息" ), + [I18N_WELCOME_GUIDE] = I18N_STRING( + "i type · Enter send · : commands · :help guide", + "i 输入 · Enter 发送 · : 命令 · :help 指引" + ), [I18N_EMPTY_FILTERED] = I18N_STRING( "No visible messages", "暂无可见消息" diff --git a/src/tui.c b/src/tui.c index 3e2556f..7c1eea5 100644 --- a/src/tui.c +++ b/src/tui.c @@ -259,6 +259,23 @@ void tui_render_welcome(client_t *client) { for (int i = 0; i < inner_w; i++) buffer_append_bytes(buf, sizeof(buf), &pos, "─", strlen("─")); buffer_append_bytes(buf, sizeof(buf), &pos, "╯", strlen("╯")); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4); + buffer_appendf(buf, sizeof(buf), &pos, "\r\n"); + + /* Newcomer guide: a single dim, centered "getting started" line below the + * banner, shown to everyone before the name prompt. */ + { + const char *guide = i18n_text(client->ui_lang, I18N_WELCOME_GUIDE); + int guide_width = utf8_string_width(guide); + if (guide_width <= rw) { + int guide_pad = (rw - guide_width) / 2; + if (guide_pad < 0) guide_pad = 0; + buffer_appendf(buf, sizeof(buf), &pos, "\r\n"); + for (int i = 0; i < guide_pad; i++) { + buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1); + } + buffer_appendf(buf, sizeof(buf), &pos, "\033[2;37m%s\033[0m", guide); + } + } buffer_appendf(buf, sizeof(buf), &pos, "\r\n\r\n"); client_send(client, buf, pos); From 4b88530443bca848632ad571b8159084492b63a4 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Mon, 29 Jun 2026 10:23:02 +0800 Subject: [PATCH 3/4] feat: add tntctl shell completion scripts (bash/zsh/fish) Add packaging/completions/ with bash, zsh, and fish completion for tntctl: option flags, the stable subcommands (health/stats/users/tail/dump/post/ help/exit), --json after users/stats, and --host-key-checking modes. The remote host is free-form and intentionally not completed. Includes install instructions in packaging/completions/README.md. --- packaging/completions/README.md | 38 +++++++++++++++++++++++ packaging/completions/_tntctl | 37 ++++++++++++++++++++++ packaging/completions/tntctl.bash | 51 +++++++++++++++++++++++++++++++ packaging/completions/tntctl.fish | 22 +++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 packaging/completions/README.md create mode 100644 packaging/completions/_tntctl create mode 100644 packaging/completions/tntctl.bash create mode 100644 packaging/completions/tntctl.fish diff --git a/packaging/completions/README.md b/packaging/completions/README.md new file mode 100644 index 0000000..1c7aeb7 --- /dev/null +++ b/packaging/completions/README.md @@ -0,0 +1,38 @@ +# tntctl shell completions + +Tab-completion for the `tntctl` control client: option flags, the stable +subcommands (`health`, `stats`, `users`, `tail`, `dump`, `post`, `help`, +`exit`), `--json` after `users`/`stats`, and the `--host-key-checking` modes. + +These complete `tntctl`'s own interface only; the remote host argument is +free-form and is not completed. + +## bash + +```sh +# user-local +echo 'source /path/to/TNT/packaging/completions/tntctl.bash' >> ~/.bashrc +# or system-wide +sudo cp packaging/completions/tntctl.bash \ + /usr/share/bash-completion/completions/tntctl +``` + +## zsh + +```sh +mkdir -p ~/.zsh/completions +cp packaging/completions/_tntctl ~/.zsh/completions/_tntctl +# ensure these are in ~/.zshrc: +# fpath=(~/.zsh/completions $fpath) +# autoload -U compinit && compinit +``` + +## fish + +```sh +cp packaging/completions/tntctl.fish ~/.config/fish/completions/tntctl.fish +``` + +> Note: this is shell completion for the `tntctl` command line. Inside the +> interactive TNT chat (`ssh -p 2222 host`), COMMAND mode has its own built-in +> Tab completion for `:` commands and their arguments. diff --git a/packaging/completions/_tntctl b/packaging/completions/_tntctl new file mode 100644 index 0000000..cf8e843 --- /dev/null +++ b/packaging/completions/_tntctl @@ -0,0 +1,37 @@ +#compdef tntctl +# zsh completion for tntctl +# +# Install: place this file (named _tntctl) in a directory on your $fpath, +# e.g. ~/.zsh/completions, and ensure `autoload -U compinit && compinit` +# runs in ~/.zshrc. + +_tntctl() { + local -a opts commands + local prev + opts=(-p --port -l --login --host-key-checking --known-hosts -h --help -V --version) + commands=(health stats users tail dump post help exit) + + prev=${words[CURRENT-1]} + case $prev in + --host-key-checking) + compadd yes accept-new no + return ;; + --known-hosts) + _files + return ;; + -p|--port|-l|--login) + return ;; + esac + + if [[ ${words[CURRENT]} == -* ]]; then + if (( ${words[(I)users]} || ${words[(I)stats]} )); then + compadd -- $opts --json + else + compadd -- $opts + fi + else + compadd -- $commands + fi +} + +_tntctl "$@" diff --git a/packaging/completions/tntctl.bash b/packaging/completions/tntctl.bash new file mode 100644 index 0000000..13f4f0a --- /dev/null +++ b/packaging/completions/tntctl.bash @@ -0,0 +1,51 @@ +# bash completion for tntctl +# +# Install: source this file from ~/.bashrc, or copy it to +# /usr/share/bash-completion/completions/tntctl +# (or /etc/bash_completion.d/tntctl). + +_tntctl() { + local cur prev opts commands cmd i + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + opts="-p --port -l --login --host-key-checking --known-hosts -h --help -V --version" + commands="health stats users tail dump post help exit" + + case "$prev" in + --host-key-checking) + COMPREPLY=( $(compgen -W "yes accept-new no" -- "$cur") ) + return ;; + --known-hosts) + COMPREPLY=( $(compgen -f -- "$cur") ) + return ;; + -p|--port|-l|--login) + return ;; # free-form value + esac + + # Detect a subcommand already on the line. + cmd="" + for ((i = 1; i < COMP_CWORD; i++)); do + case "${COMP_WORDS[i]}" in + health|stats|users|tail|dump|post|help|exit) + cmd="${COMP_WORDS[i]}"; break ;; + esac + done + + if [[ "$cur" == -* ]]; then + local extra="" + case "$cmd" in + users|stats) extra="--json" ;; + esac + COMPREPLY=( $(compgen -W "$opts $extra" -- "$cur") ) + return + fi + + # Positional args after a subcommand are free-form. + if [[ -n "$cmd" ]]; then + return + fi + + # Otherwise offer the known subcommands (typed after the host). + COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) +} +complete -F _tntctl tntctl diff --git a/packaging/completions/tntctl.fish b/packaging/completions/tntctl.fish new file mode 100644 index 0000000..ebdc6ce --- /dev/null +++ b/packaging/completions/tntctl.fish @@ -0,0 +1,22 @@ +# fish completion for tntctl +# +# Install: copy this file to ~/.config/fish/completions/tntctl.fish + +# Options +complete -c tntctl -s p -l port -d 'Server port' -x +complete -c tntctl -s l -l login -d 'Login user' -x +complete -c tntctl -l host-key-checking -d 'SSH host key checking' -xa 'yes accept-new no' +complete -c tntctl -l known-hosts -d 'known_hosts file' -r +complete -c tntctl -s h -l help -d 'Show help' +complete -c tntctl -s V -l version -d 'Show version' +complete -c tntctl -l json -d 'JSON output' -n '__fish_seen_subcommand_from users stats' + +# Subcommands (typed after the host) +complete -c tntctl -f -a health -d 'Print service health' +complete -c tntctl -f -a stats -d 'Print room statistics' +complete -c tntctl -f -a users -d 'List online users' +complete -c tntctl -f -a tail -d 'Print recent messages' +complete -c tntctl -f -a dump -d 'Export persisted messages' +complete -c tntctl -f -a post -d 'Post a message' +complete -c tntctl -f -a help -d 'Show exec help' +complete -c tntctl -f -a exit -d 'Exit successfully' From 24544b6e09e0efc3bcea4923de0e9cf5f32a32ae Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Mon, 29 Jun 2026 10:25:37 +0800 Subject: [PATCH 4/4] docs: document COMMAND-mode Tab completion Add the Tab completion keybinding to README, QUICKREF, and the tnt.1 man page in the same change set as the feature. --- README.md | 2 ++ docs/QUICKREF.md | 1 + tnt.1 | 1 + 3 files changed, 4 insertions(+) diff --git a/README.md b/README.md index fd256a1..d4428bd 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ Ctrl+C - Exit chat :help - Show concise manual :clear - Clear command output :q, :quit, :exit - Disconnect +Tab - Complete command name; ambiguous prefixes list candidates + (also completes :theme/:lang values and :msg usernames) Up/Down - Browse command history ESC - Return to NORMAL mode ``` diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index cb8d79f..7f4a65f 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -42,6 +42,7 @@ COMMANDS (COMMAND mode, prefix with :) lang [en|zh] show or switch UI language clear clear output q / quit / exit disconnect + Tab complete command / argument (lists candidates if ambiguous) INSERT MODE /me action message diff --git a/tnt.1 b/tnt.1 index 3bb2d2d..7bb900b 100644 --- a/tnt.1 +++ b/tnt.1 @@ -234,6 +234,7 @@ l l. :help Show concise manual :clear Clear command output :q, :quit, :exit Disconnect +Tab Complete the command name or first argument; ambiguous prefixes list candidates Up/Down Browse command history ESC Cancel and return to NORMAL .TE