feat(console): parameter builder in the command explorer (phase 3)

The detail pane's Parameters section is now an editable form: each parameter is
a type-hinted input field (string / number / json, optional marked), and a live
"Builds" preview shows the command being assembled. String values are
auto-quoted; a required field left empty keeps its placeholder and disables
"Insert & run"; trailing empty optionals are omitted.

Insert and Insert & run now use the assembled command (not just the template),
so Insert & run works for parameterized commands once the required fields are
filled — destructive commands still confirm first. Param fields reset when the
selected command changes or the modal opens. Keyboard nav (Up/Down/Enter) is
scoped to the search box so typing in a field doesn't move the selection.

A best-effort parseParamSpecs turns the human-readable param templates into
fields; JSON-array params (z_sendmany, createrawtransaction) surface as a single
"json" field the user pastes into. Adds the console_ref_builds label + 8
translations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 11:32:35 -05:00
parent 44a17f3ad0
commit 4416e01f9c
11 changed files with 144 additions and 44 deletions

View File

@@ -229,6 +229,7 @@
"console_no_daemon": "Kein Daemon", "console_no_daemon": "Kein Daemon",
"console_not_connected": "Fehler: Nicht mit Daemon verbunden", "console_not_connected": "Fehler: Nicht mit Daemon verbunden",
"console_quit_note": "'quit'/'exit' werden hier nicht benötigt — schließen Sie einfach das Fenster.", "console_quit_note": "'quit'/'exit' werden hier nicht benötigt — schließen Sie einfach das Fenster.",
"console_ref_builds": "Ergibt",
"console_ref_cancel": "Abbrechen", "console_ref_cancel": "Abbrechen",
"console_ref_destructive": "Folgenreich", "console_ref_destructive": "Folgenreich",
"console_ref_example": "Beispiel", "console_ref_example": "Beispiel",

View File

@@ -229,6 +229,7 @@
"console_no_daemon": "Sin daemon", "console_no_daemon": "Sin daemon",
"console_not_connected": "Error: No conectado al daemon", "console_not_connected": "Error: No conectado al daemon",
"console_quit_note": "'quit'/'exit' no son necesarios aquí — simplemente cierra la ventana.", "console_quit_note": "'quit'/'exit' no son necesarios aquí — simplemente cierra la ventana.",
"console_ref_builds": "Genera",
"console_ref_cancel": "Cancelar", "console_ref_cancel": "Cancelar",
"console_ref_destructive": "Delicado", "console_ref_destructive": "Delicado",
"console_ref_example": "Ejemplo", "console_ref_example": "Ejemplo",

View File

@@ -229,6 +229,7 @@
"console_no_daemon": "Pas de daemon", "console_no_daemon": "Pas de daemon",
"console_not_connected": "Erreur : Non connecté au daemon", "console_not_connected": "Erreur : Non connecté au daemon",
"console_quit_note": "'quit'/'exit' ne sont pas nécessaires ici — fermez simplement la fenêtre.", "console_quit_note": "'quit'/'exit' ne sont pas nécessaires ici — fermez simplement la fenêtre.",
"console_ref_builds": "Génère",
"console_ref_cancel": "Annuler", "console_ref_cancel": "Annuler",
"console_ref_destructive": "Sensible", "console_ref_destructive": "Sensible",
"console_ref_example": "Exemple", "console_ref_example": "Exemple",

View File

@@ -229,6 +229,7 @@
"console_no_daemon": "デーモンなし", "console_no_daemon": "デーモンなし",
"console_not_connected": "エラー:デーモンに接続されていません", "console_not_connected": "エラー:デーモンに接続されていません",
"console_quit_note": "ここでは 'quit''exit' は不要です — ウィンドウを閉じるだけで構いません。", "console_quit_note": "ここでは 'quit''exit' は不要です — ウィンドウを閉じるだけで構いません。",
"console_ref_builds": "生成",
"console_ref_cancel": "キャンセル", "console_ref_cancel": "キャンセル",
"console_ref_destructive": "要注意", "console_ref_destructive": "要注意",
"console_ref_example": "例", "console_ref_example": "例",

View File

@@ -229,6 +229,7 @@
"console_no_daemon": "데몬 없음", "console_no_daemon": "데몬 없음",
"console_not_connected": "오류: 데몬에 연결되지 않았습니다", "console_not_connected": "오류: 데몬에 연결되지 않았습니다",
"console_quit_note": "여기서는 'quit'/'exit'가 필요 없습니다 — 그냥 창을 닫으세요.", "console_quit_note": "여기서는 'quit'/'exit'가 필요 없습니다 — 그냥 창을 닫으세요.",
"console_ref_builds": "생성",
"console_ref_cancel": "취소", "console_ref_cancel": "취소",
"console_ref_destructive": "주의", "console_ref_destructive": "주의",
"console_ref_example": "예시", "console_ref_example": "예시",

View File

@@ -229,6 +229,7 @@
"console_no_daemon": "Sem daemon", "console_no_daemon": "Sem daemon",
"console_not_connected": "Erro: Não conectado ao daemon", "console_not_connected": "Erro: Não conectado ao daemon",
"console_quit_note": "'quit'/'exit' não são necessários aqui — basta fechar a janela.", "console_quit_note": "'quit'/'exit' não são necessários aqui — basta fechar a janela.",
"console_ref_builds": "Gera",
"console_ref_cancel": "Cancelar", "console_ref_cancel": "Cancelar",
"console_ref_destructive": "Sensível", "console_ref_destructive": "Sensível",
"console_ref_example": "Exemplo", "console_ref_example": "Exemplo",

View File

@@ -229,6 +229,7 @@
"console_no_daemon": "Нет daemon", "console_no_daemon": "Нет daemon",
"console_not_connected": "Ошибка: Не подключено к daemon", "console_not_connected": "Ошибка: Не подключено к daemon",
"console_quit_note": "Здесь не нужны 'quit'/'exit' — просто закройте окно.", "console_quit_note": "Здесь не нужны 'quit'/'exit' — просто закройте окно.",
"console_ref_builds": "Формирует",
"console_ref_cancel": "Отмена", "console_ref_cancel": "Отмена",
"console_ref_destructive": "Осторожно", "console_ref_destructive": "Осторожно",
"console_ref_example": "Пример", "console_ref_example": "Пример",

View File

@@ -229,6 +229,7 @@
"console_no_daemon": "无守护进程", "console_no_daemon": "无守护进程",
"console_not_connected": "错误:未连接到守护进程", "console_not_connected": "错误:未连接到守护进程",
"console_quit_note": "这里不需要 'quit'/'exit'——直接关闭窗口即可。", "console_quit_note": "这里不需要 'quit'/'exit'——直接关闭窗口即可。",
"console_ref_builds": "生成",
"console_ref_cancel": "取消", "console_ref_cancel": "取消",
"console_ref_destructive": "谨慎", "console_ref_destructive": "谨慎",
"console_ref_example": "示例", "console_ref_example": "示例",

View File

@@ -1495,6 +1495,49 @@ std::vector<std::string> splitParamTemplate(const char* params)
return out; return out;
} }
// A parsed parameter for the builder form. type in {string, number, json}; `raw` is the original
// template token (used as a placeholder when a required field is left empty).
struct ConsoleParamSpec {
std::string label;
std::string type;
bool optional = false;
std::string raw;
};
// Best-effort parse of a param template into fillable fields. The templates are human-readable, not
// a strict schema, so heuristics: [word] / ["word"] = an optional scalar; a [ / { with JSON content
// (a brace or comma) = a JSON field the user pastes; otherwise a required string/number.
std::vector<ConsoleParamSpec> parseParamSpecs(const char* params)
{
std::vector<ConsoleParamSpec> out;
for (const std::string& t : splitParamTemplate(params)) {
ConsoleParamSpec p;
p.raw = t;
std::string inner = t;
if (inner.size() >= 2 && inner.front() == '[' && inner.back() == ']') {
std::string body = inner.substr(1, inner.size() - 2);
bool looksJson = body.find('{') != std::string::npos ||
body.find(',') != std::string::npos ||
(!body.empty() && body.front() == '[');
if (!looksJson) { p.optional = true; inner = body; } // optional scalar wrapper
}
char c0 = inner.empty() ? 0 : inner.front();
if (c0 == '"' || c0 == '\'') {
p.type = "string";
if (inner.size() >= 2 && inner.back() == c0) inner = inner.substr(1, inner.size() - 2);
p.label = inner;
} else if (c0 == '{' || c0 == '[') {
p.type = "json";
p.label = "json";
} else {
p.type = "number";
p.label = inner;
}
out.push_back(p);
}
return out;
}
// Translate a command-category name from the static reference tables (English) for display. The // Translate a command-category name from the static reference tables (English) for display. The
// per-command descriptions stay in English (technical RPC docs); only the 7 category labels are i18n'd. // per-command descriptions stay in English (technical RPC docs); only the 7 category labels are i18n'd.
const char* consoleCategoryLabel(const char* name) const char* consoleCategoryLabel(const char* name)
@@ -1554,35 +1597,72 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
ImGui::PopTextWrapPos(); ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd())); ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// Parameters — each token with a rough type tag; optional ([...]) tokens are dimmed. // Parameters — an editable form that assembles the command. Reset the fields whenever the
// selected command changes so values don't carry across commands.
if (cmd_param_owner_ != &cmd) {
for (auto& b : cmd_param_bufs_) b[0] = '\0';
cmd_param_owner_ = &cmd;
}
std::vector<ConsoleParamSpec> specs = parseParamSpecs(cmd.params);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_parameters")); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_parameters"));
std::vector<std::string> toks = splitParamTemplate(cmd.params); if (specs.empty()) {
if (toks.empty()) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", TR("console_ref_no_params")); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", TR("console_ref_no_params"));
} else { } else {
ImGui::Indent(Layout::spacingMd()); float labelW = 110.0f * dp;
for (const std::string& t : toks) { ImGui::Indent(Layout::spacingSm());
bool optional = (!t.empty() && t.front() == '['); for (size_t k = 0; k < specs.size() && k < 6; k++) {
char c0 = t.empty() ? 0 : t[0]; const ConsoleParamSpec& s = specs[k];
if (optional && t.size() >= 2) c0 = t[1]; ImGui::PushID((int)k);
const char* type = (c0 == '"' || c0 == '\'') ? "string"
: (c0 == '{' || c0 == '[') ? "json" : "number";
ImGui::PushFont(mono); ImGui::PushFont(mono);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(optional ? OnSurfaceMedium() : OnSurface()), ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(s.optional ? OnSurfaceMedium() : OnSurface()),
"%s", t.c_str()); "%s", s.label.c_str());
ImGui::PopFont(); ImGui::PopFont();
ImGui::SameLine(); ImGui::SameLine(labelW);
if (optional) ImGui::SetNextItemWidth(-1);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), std::string hint = s.optional
" %s \xC2\xB7 %s", type, TR("console_ref_optional")); ? (s.type + " \xC2\xB7 " + std::string(TR("console_ref_optional"))) : s.type;
else ImGui::InputTextWithHint("##pv", hint.c_str(), cmd_param_bufs_[k], sizeof(cmd_param_bufs_[k]));
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), " %s", type); ImGui::PopID();
} }
ImGui::Unindent(Layout::spacingMd()); ImGui::Unindent(Layout::spacingSm());
}
// Assemble the command from the filled fields. A required field left empty keeps its placeholder
// token (so Insert still shows what's needed) and marks the command incomplete (Insert & run
// disabled). String values are auto-quoted; trailing empty optionals are omitted.
auto trimStr = [](const std::string& s) -> std::string {
size_t a = s.find_first_not_of(" \t");
if (a == std::string::npos) return std::string();
return s.substr(a, s.find_last_not_of(" \t") - a + 1);
};
bool complete = specs.size() <= 6;
std::string built = cmd.name;
for (size_t k = 0, stop = 0; k < specs.size() && k < 6 && !stop; k++) {
std::string val = trimStr(cmd_param_bufs_[k]);
if (val.empty()) {
if (specs[k].optional) stop = 1; // omit this + trailing optionals
else { built += " " + specs[k].raw; complete = false; } // required placeholder
} else {
if (specs[k].type == "string" && val.front() != '"' && val.front() != '\'' &&
val.front() != '[' && val.front() != '{')
val = "\"" + val + "\"";
built += " " + val;
}
}
// Live "Builds" preview when the command takes parameters.
if (!specs.empty()) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_builds"));
ImGui::PushFont(mono);
ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(IM_COL32(150, 200, 150, 255)), "%s", built.c_str());
ImGui::PopTextWrapPos();
ImGui::PopFont();
} }
ImGui::Dummy(ImVec2(0, Layout::spacingMd())); ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// Example (curated commands only). // Example (curated commands only) — reference alongside the builder.
if (cmd.example[0]) { if (cmd.example[0]) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_example")); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_example"));
ImGui::PushFont(mono); ImGui::PushFont(mono);
@@ -1593,7 +1673,7 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
ImGui::Dummy(ImVec2(0, Layout::spacingMd())); ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
} }
// Actions (or the destructive run confirmation). // Actions (or the destructive run confirmation) — Insert/run use the assembled command.
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (run_confirm_cmd_ == &cmd) { if (run_confirm_cmd_ == &cmd) {
ImVec4 warn = ImGui::ColorConvertU32ToFloat4(Warning()); ImVec4 warn = ImGui::ColorConvertU32ToFloat4(Warning());
@@ -1607,28 +1687,32 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
if (material::TactileButton(TR("console_ref_cancel"), ImVec2(cbw, 0))) run_confirm_cmd_ = nullptr; if (material::TactileButton(TR("console_ref_cancel"), ImVec2(cbw, 0))) run_confirm_cmd_ = nullptr;
ImGui::SameLine(); ImGui::SameLine();
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) { if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
pending_submit_ = cmd.name; pending_submit_ = built;
command_search_[0] = '\0'; command_search_[0] = '\0';
run_confirm_cmd_ = nullptr; run_confirm_cmd_ = nullptr;
show_commands_popup_ = false; show_commands_popup_ = false;
} }
} else { } else {
if (material::TactileButton(TR("console_ref_insert"), ImVec2(200.0f * dp, 0))) if (material::TactileButton(TR("console_ref_insert"), ImVec2(200.0f * dp, 0))) {
insertCommandToInput(cmd); snprintf(input_buffer_, sizeof(input_buffer_), "%s", built.c_str());
// "Insert & run" only for commands that take no parameters — running a template with command_search_[0] = '\0';
// placeholders ("address", amount) would just error. run_confirm_cmd_ = nullptr;
if (cmd.params[0] == '\0') { show_commands_popup_ = false;
ImGui::SameLine(); }
if (material::TactileButton(TR("console_ref_insert_run"), ImVec2(140.0f * dp, 0))) { // Insert & run: enabled once every required field is filled (a template with unfilled
if (cmd.destructive) { // placeholders would just error).
run_confirm_cmd_ = &cmd; ImGui::SameLine();
} else { ImGui::BeginDisabled(!complete);
pending_submit_ = cmd.name; if (material::TactileButton(TR("console_ref_insert_run"), ImVec2(140.0f * dp, 0))) {
command_search_[0] = '\0'; if (cmd.destructive) {
show_commands_popup_ = false; run_confirm_cmd_ = &cmd;
} } else {
pending_submit_ = built;
command_search_[0] = '\0';
show_commands_popup_ = false;
} }
} }
ImGui::EndDisabled();
} }
} }
@@ -1652,11 +1736,15 @@ void ConsoleTab::renderCommandsPopup()
else show_commands_popup_ = false; else show_commands_popup_ = false;
} }
// Search box — auto-focus on open so the user can type immediately. // Search box — auto-focus on open so the user can type immediately. Enter inserts the selected
// command's template; reset the param-builder fields on open.
if (ImGui::IsWindowAppearing()) { ImGui::SetKeyboardFocusHere(); cmd_param_owner_ = nullptr; }
ImGui::SetNextItemWidth(-1); ImGui::SetNextItemWidth(-1);
if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); bool searchEnter = ImGui::InputTextWithHint("##CmdSearch", TR("console_ref_search_hint"),
bool searchChanged = ImGui::InputTextWithHint("##CmdSearch", TR("console_ref_search_hint"), command_search_, sizeof(command_search_),
command_search_, sizeof(command_search_)); ImGuiInputTextFlags_EnterReturnsTrue);
bool searchChanged = ImGui::IsItemEdited();
bool searchActive = ImGui::IsItemActive();
ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
std::string q(command_search_); std::string q(command_search_);
@@ -1693,15 +1781,16 @@ void ConsoleTab::renderCommandsPopup()
else { cmd_sel_cat_ = cmd_sel_idx_ = -1; } else { cmd_sel_cat_ = cmd_sel_idx_ = -1; }
} }
// Keyboard nav: Up/Down move the selection, Enter inserts (disabled while a run-confirm shows). // Keyboard nav from the SEARCH box only (so typing in a param field doesn't move the selection):
if (!run_confirm_cmd_ && !order.empty() && selPos >= 0) { // Up/Down move, Enter inserts the selected command's template. Disabled while a run-confirm shows.
if (!run_confirm_cmd_ && searchActive && !order.empty() && selPos >= 0) {
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) && selPos + 1 < (int)order.size()) selPos++; if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) && selPos + 1 < (int)order.size()) selPos++;
else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && selPos > 0) selPos--; else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && selPos > 0) selPos--;
cmd_sel_cat_ = order[selPos].first; cmd_sel_cat_ = order[selPos].first;
cmd_sel_idx_ = order[selPos].second; cmd_sel_idx_ = order[selPos].second;
if (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
insertCommandToInput(categories[cmd_sel_cat_].commands[cmd_sel_idx_]);
} }
if (searchEnter && !run_confirm_cmd_ && cmd_sel_cat_ >= 0)
insertCommandToInput(categories[cmd_sel_cat_].commands[cmd_sel_idx_]);
// Two-pane body sized above the footer. // Two-pane body sized above the footer.
float footerH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingXs(); float footerH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingXs();

View File

@@ -193,6 +193,8 @@ private:
int cmd_sel_idx_ = -1; // detail-pane selection: command index within that category int cmd_sel_idx_ = -1; // detail-pane selection: command index within that category
std::string pending_submit_; // command to run next frame (deferred so the modal needs no executor) std::string pending_submit_; // command to run next frame (deferred so the modal needs no executor)
const ConsoleCommandEntry* run_confirm_cmd_ = nullptr; // destructive "Insert & run" awaiting confirmation const ConsoleCommandEntry* run_confirm_cmd_ = nullptr; // destructive "Insert & run" awaiting confirmation
char cmd_param_bufs_[6][256] = {{0}}; // parameter-builder input fields
const ConsoleCommandEntry* cmd_param_owner_ = nullptr; // command the param buffers currently hold
}; };
} // namespace ui } // namespace ui

View File

@@ -1306,6 +1306,7 @@ void I18n::loadBuiltinEnglish()
strings_["console_ref_no_params"] = "Takes no parameters."; strings_["console_ref_no_params"] = "Takes no parameters.";
strings_["console_ref_optional"] = "optional"; strings_["console_ref_optional"] = "optional";
strings_["console_ref_example"] = "Example"; strings_["console_ref_example"] = "Example";
strings_["console_ref_builds"] = "Builds";
strings_["console_ref_destructive"] = "Consequential"; strings_["console_ref_destructive"] = "Consequential";
strings_["console_ref_run_confirm"] = "Run %s now? This is a consequential command."; strings_["console_ref_run_confirm"] = "Run %s now? This is a consequential command.";
strings_["console_ref_cancel"] = "Cancel"; strings_["console_ref_cancel"] = "Cancel";