From b5ed0e058c98645c1cab752d012fdc4be22bceef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigbj=C3=B8rn=20Skj=C3=A6ret?= Date: Thu, 5 Mar 2026 10:47:28 +0100 Subject: [PATCH] cli : add command and file auto-completion (#19985) --- common/console.cpp | 34 +++++++++++-- common/console.h | 5 ++ tools/cli/cli.cpp | 120 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/common/console.cpp b/common/console.cpp index 2ea178f81e..a770416ab7 100644 --- a/common/console.cpp +++ b/common/console.cpp @@ -80,6 +80,8 @@ namespace console { static termios initial_state; #endif + static completion_callback completion_cb = nullptr; + // // Init and cleanup // @@ -493,7 +495,7 @@ namespace console { } static void set_line_contents(std::string new_line, std::string & line, std::vector & widths, size_t & char_pos, - size_t & byte_pos) { + size_t & byte_pos, int cursor_byte_pos = -1) { move_to_line_start(char_pos, byte_pos, widths); clear_current_line(widths); @@ -503,6 +505,7 @@ namespace console { char_pos = 0; size_t idx = 0; + int back_width = 0; while (idx < line.size()) { size_t advance = 0; char32_t cp = decode_utf8(line, idx, advance); @@ -511,8 +514,15 @@ namespace console { if (real_width < 0) real_width = 0; widths.push_back(real_width); idx += advance; - ++char_pos; - byte_pos = idx; + if (cursor_byte_pos >= 0 && static_cast(cursor_byte_pos) < idx) { + back_width += real_width; + } else { + ++char_pos; + byte_pos = idx; + } + } + if (cursor_byte_pos >= 0) { + move_cursor(-back_width); } } @@ -784,6 +794,20 @@ namespace console { break; } + if (completion_cb && input_char == '\t') { + auto candidates = completion_cb(line, byte_pos); + + if (!candidates.empty()) { + if (candidates.size() > 1 || candidates[0].first != line) { + // TODO?: Display all candidates + set_line_contents(candidates[0].first, line, widths, char_pos, byte_pos, candidates[0].second); + } else { + // TODO: Move cursor to new byte_pos + } + continue; + } + } + if (input_char == (char32_t) WEOF || input_char == 0x04 /* Ctrl+D */) { end_of_stream = true; break; @@ -1062,6 +1086,10 @@ namespace console { return readline_advanced(line, multiline_input); } + void set_completion_callback(completion_callback cb) { + completion_cb = cb; + } + namespace spinner { static const char LOADING_CHARS[] = {'|', '/', '-', '\\'}; static std::condition_variable cv_stop; diff --git a/common/console.h b/common/console.h index fad6d39531..72781bea6f 100644 --- a/common/console.h +++ b/common/console.h @@ -4,7 +4,9 @@ #include "common.h" +#include #include +#include enum display_type { DISPLAY_TYPE_RESET = 0, @@ -21,6 +23,9 @@ namespace console { void set_display(display_type display); bool readline(std::string & line, bool multiline_input); + using completion_callback = std::function>(std::string_view, size_t)>; + void set_completion_callback(completion_callback cb); + namespace spinner { void start(); void stop(); diff --git a/tools/cli/cli.cpp b/tools/cli/cli.cpp index e57bf52e36..65ff4ac6c0 100644 --- a/tools/cli/cli.cpp +++ b/tools/cli/cli.cpp @@ -6,7 +6,10 @@ #include "server-context.h" #include "server-task.h" +#include #include +#include +#include #include #include #include @@ -195,6 +198,122 @@ struct cli_context { } }; +// TODO?: Make this reusable, enums, docs +static const std::array cmds = { + "/audio ", + "/clear", + "/exit", + "/image ", + "/read ", + "/regen", +}; + +static std::vector> auto_completion_callback(std::string_view line, size_t cursor_byte_pos) { + std::vector> matches; + std::string cmd; + + if (line.length() > 1 && line[0] == '/' && !std::any_of(cmds.begin(), cmds.end(), [line](const std::string & prefix) { + return string_starts_with(line, prefix); + })) { + auto it = cmds.begin(); + + while ((it = std::find_if(it, cmds.end(), [line](const std::string & cmd_line) { + return string_starts_with(cmd_line, line); + })) != cmds.end()) { + matches.emplace_back(*it, (*it).length()); + ++it; + } + } else { + auto it = std::find_if(cmds.begin(), cmds.end(), [line](const std::string & prefix) { + return prefix.back() == ' ' && string_starts_with(line, prefix); + }); + + if (it != cmds.end()) { + cmd = *it; + } + } + + if (!cmd.empty() && line.length() >= cmd.length() && cursor_byte_pos >= cmd.length()) { + const std::string path_prefix = std::string(line.substr(cmd.length(), cursor_byte_pos - cmd.length())); + const std::string path_postfix = std::string(line.substr(cursor_byte_pos)); + auto cur_dir = std::filesystem::current_path(); + std::string cur_dir_str = cur_dir.string(); + std::string expanded_prefix = path_prefix; + +#if !defined(_WIN32) + if (string_starts_with(path_prefix, "~")) { + const char * home = std::getenv("HOME"); + if (home && home[0]) { + expanded_prefix = std::string(home) + path_prefix.substr(1); + } + } + if (string_starts_with(expanded_prefix, "/")) { +#else + if (std::isalpha(expanded_prefix[0]) && expanded_prefix.find(':') == 1) { +#endif + cur_dir = std::filesystem::path(expanded_prefix).parent_path(); + cur_dir_str = ""; + } else if (!path_prefix.empty()) { + cur_dir /= std::filesystem::path(path_prefix).parent_path(); + } + + std::error_code ec; + for (const auto & entry : std::filesystem::directory_iterator(cur_dir, ec)) { + if (ec) { + break; + } + if (!entry.exists(ec)) { + ec.clear(); + continue; + } + + const std::string path_full = entry.path().string(); + std::string path_entry = !cur_dir_str.empty() && string_starts_with(path_full, cur_dir_str) ? path_full.substr(cur_dir_str.length() + 1) : path_full; + + if (entry.is_directory(ec)) { + path_entry.push_back(std::filesystem::path::preferred_separator); + } + + if (expanded_prefix.empty() || string_starts_with(path_entry, expanded_prefix)) { + std::string updated_line = cmd + path_entry; + matches.emplace_back(updated_line + path_postfix, updated_line.length()); + } + + if (ec) { + ec.clear(); + } + } + + if (matches.empty()) { + std::string updated_line = cmd + path_prefix; + matches.emplace_back(updated_line + path_postfix, updated_line.length()); + } + + // Add the longest common prefix + if (!expanded_prefix.empty() && matches.size() > 1) { + const std::string_view match0(matches[0].first); + const std::string_view match1(matches[1].first); + auto it = std::mismatch(match0.begin(), match0.end(), match1.begin(), match1.end()); + size_t len = it.first - match0.begin(); + + for (size_t i = 2; i < matches.size(); ++i) { + const std::string_view matchi(matches[i].first); + auto cmp = std::mismatch(match0.begin(), match0.end(), matchi.begin(), matchi.end()); + len = std::min(len, static_cast(cmp.first - match0.begin())); + } + + std::string updated_line = std::string(match0.substr(0, len)); + matches.emplace_back(updated_line + path_postfix, updated_line.length()); + } + + std::sort(matches.begin(), matches.end(), [](const auto & a, const auto & b) { + return a.first.compare(0, a.second, b.first, 0, b.second) < 0; + }); + } + + return matches; +} + int main(int argc, char ** argv) { common_params params; @@ -223,6 +342,7 @@ int main(int argc, char ** argv) { atexit([]() { console::cleanup(); }); console::set_display(DISPLAY_TYPE_RESET); + console::set_completion_callback(auto_completion_callback); #if defined (__unix__) || (defined (__APPLE__) && defined (__MACH__)) struct sigaction sigint_action;