From 4a65dce9474a2df78124556d1698fef452dd815a Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 9 Jun 2026 20:32:03 -0500 Subject: [PATCH] feat(lite): make the Console tab interactive (run backend commands) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lite backend's litelib_execute() is the same command interface as silentdragonxlite-cli (balance, info, height, list, notes, addresses, sync, syncstatus, new, send, shield, encrypt, …), so the lite Console can be a real interactive console — like the full-node RPC console — instead of a read-only diagnostics log. Controller: add an async arbitrary-command runner mirroring the broadcast pattern — runConsoleCommand() splits " [args]" (the first token is the command, the remainder is passed as the single arg string litelib_execute expects, since it does NOT whitespace-split), runs the bridge call on a detached thread that captures the shared bridge (never `this`), and delivers the result to a main-thread slot drained by takeConsoleResult(). Results are NEVER routed through LiteDiagnostics (seed/export can return secrets). Console tab: a command input (Enter to run, Up/Down history via the shared console_input_model helpers) over a unified scroll buffer that interleaves the automatic diagnostics events with user command I/O, colour-coded, with the live status header preserved. The input is disabled while a command runs. Two backend footguns are intercepted at the UI layer before forwarding: `clear` (the backend command WIPES wallet tx history — re-bound to clearing the view, what the user expects) and `quit`/`exit` (would only save; the embedded backend must stay running with the app). Test: runConsoleCommand drives the fake backend (info -> raw response; "new zs" -> exercises the command/arg split; blank line rejected). Co-Authored-By: Claude Opus 4.8 --- src/ui/windows/lite_console_tab.cpp | 171 ++++++++++++++++++++++---- src/util/i18n.cpp | 8 +- src/wallet/lite_wallet_controller.cpp | 65 ++++++++++ src/wallet/lite_wallet_controller.h | 32 +++++ tests/test_phase4.cpp | 44 +++++++ 5 files changed, 294 insertions(+), 26 deletions(-) diff --git a/src/ui/windows/lite_console_tab.cpp b/src/ui/windows/lite_console_tab.cpp index baa1218..3fb9419 100644 --- a/src/ui/windows/lite_console_tab.cpp +++ b/src/ui/windows/lite_console_tab.cpp @@ -3,6 +3,7 @@ // Released under the GPLv3 #include "lite_console_tab.h" +#include "console_input_model.h" // reuse the generic command-history helpers #include "../../app.h" #include "../../data/wallet_state.h" @@ -14,8 +15,12 @@ #include "../material/colors.h" #include "imgui.h" +#include +#include +#include #include #include +#include #include #include @@ -26,14 +31,27 @@ using namespace material; namespace { -// Re-snapshot the (mutex-guarded) log only when it actually changes, not every frame. -std::vector s_lines; -std::uint64_t s_cachedGeneration = static_cast(-1); +struct ConsoleLine { + std::string text; + ImU32 color; +}; + +// Unified, in-memory console buffer: it interleaves the automatic diagnostics log (lifecycle / +// connection / sync events) with the user's interactive commands and their responses, the same +// way the full-node Console interleaves the daemon log with RPC I/O. NOTE: this buffer can hold +// secret command output (e.g. `seed`/`export`) and is NEVER persisted to LiteDiagnostics. +std::vector s_lines; +std::uint64_t s_diagGen = static_cast(-1); // last consumed diagnostics generation +char s_input[512] = ""; +std::vector s_history; +int s_historyIndex = -1; bool s_autoScroll = true; +bool s_scrollToBottom = false; +bool s_focusInput = false; // Colour error/success lines for at-a-glance scanning (substring match on the messages the // controller emits). Anything else renders in the muted default colour. -ImU32 lineColor(const std::string& line) +ImU32 logColor(const std::string& line) { const auto has = [&line](const char* s) { return line.find(s) != std::string::npos; }; if (has("failed") || has(" unreachable") || has("blocked") || has("could not") || @@ -44,27 +62,61 @@ ImU32 lineColor(const std::string& line) return OnSurfaceMedium(); } +// Append text as one or more lines (splitting on '\n' so multi-line JSON responses render row by row). +void appendLines(const std::string& text, ImU32 color) +{ + std::size_t start = 0; + while (start <= text.size()) { + std::size_t nl = text.find('\n', start); + std::string line = text.substr(start, nl == std::string::npos ? std::string::npos : nl - start); + while (!line.empty() && (line.back() == '\r')) line.pop_back(); + s_lines.push_back({std::move(line), color}); + if (nl == std::string::npos) break; + start = nl + 1; + } +} + +std::string lowerFirstToken(const std::string& cmd) +{ + std::size_t b = cmd.find_first_not_of(" \t"); + if (b == std::string::npos) return {}; + std::size_t e = cmd.find_first_of(" \t", b); + std::string tok = cmd.substr(b, e == std::string::npos ? std::string::npos : e - b); + std::transform(tok.begin(), tok.end(), tok.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return tok; +} + +// InputText history navigation (Up/Down) — reuses the generic console history helpers. +int inputCallback(ImGuiInputTextCallbackData* data) +{ + if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + const bool up = (data->EventKey == ImGuiKey_UpArrow); + s_historyIndex = NavigateConsoleHistoryIndex(s_historyIndex, s_history.size(), up); + const std::string entry = ConsoleHistoryEntry(s_history, s_historyIndex); + data->DeleteChars(0, data->BufTextLen); + if (!entry.empty()) data->InsertChars(0, entry.c_str()); + } + return 0; +} + } // namespace void RenderLiteConsoleTab(App* app) { if (!app) return; + wallet::LiteWalletController* lw = app->liteWallet(); // ── Header ────────────────────────────────────────────────────────────────── Type().text(TypeStyle::H6, TR("lite_console_title")); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("lite_console_intro")); ImGui::Spacing(); - // ── Live status (read straight from the controller — always meaningful, even if the - // event log below is empty) ───────────────────────────────────────────────── + // ── Live status (read straight from the controller — always meaningful) ─────── { - const wallet::LiteWalletController* lw = app->liteWallet(); const char* connText; ImU32 connCol; if (!lw) { - // lite_wallet_ is null only when DRAGONX_ENABLE_LITE_BACKEND is off, i.e. the build - // was compiled without the SDXL backend. This can only appear in a misconfigured - // build, so name the fix directly. connText = "Lite backend not linked in this build (rebuild with --lite-backend)"; connCol = Error(); } else if (lw->walletOpen()) { @@ -94,7 +146,6 @@ void RenderLiteConsoleTab(App* app) } Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf); } - // Surface the last open failure (e.g. server unreachable) prominently. const std::string& openErr = app->liteOpenError(); if (!openErr.empty() && (!lw || !lw->walletOpen())) { Type().textColored(TypeStyle::Caption, Error(), @@ -103,29 +154,53 @@ void RenderLiteConsoleTab(App* app) } ImGui::Spacing(); - // Snapshot the event log (only when it changed). + // ── Ingest new automatic diagnostics events into the unified buffer ─────────── auto& diag = wallet::LiteDiagnostics::instance(); const std::uint64_t gen = diag.generation(); - if (gen != s_cachedGeneration) { - s_lines = diag.snapshot(); - s_cachedGeneration = gen; + if (gen != s_diagGen) { + const auto snap = diag.snapshot(); + // First pass dumps the whole ring; later passes append only the delta (generation counts + // total events ever added; the ring is bounded, so cap the delta at the snapshot size). + std::size_t startIdx = 0; + if (s_diagGen != static_cast(-1)) { + const std::uint64_t added = gen - s_diagGen; + startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast(added); + } + for (std::size_t i = startIdx; i < snap.size(); ++i) + s_lines.push_back({snap[i], logColor(snap[i])}); + if (gen != s_diagGen && !snap.empty()) s_scrollToBottom = true; + s_diagGen = gen; + } + + // ── Drain a completed interactive command ──────────────────────────────────── + if (lw) { + wallet::LiteConsoleResult res; + if (lw->takeConsoleResult(res)) { + appendLines(res.response.empty() ? "(no output)" : res.response, + res.ok ? OnSurface() : Error()); + s_scrollToBottom = true; + } } // ── Toolbar ───────────────────────────────────────────────────────────────── - if (ImGui::Button(TR("lite_console_clear"))) diag.clear(); + if (ImGui::Button(TR("lite_console_clear"))) { + s_lines.clear(); + s_diagGen = diag.generation(); // don't re-dump the whole ring on the next frame + } ImGui::SameLine(); if (ImGui::Button(TR("lite_console_copy"))) { std::string all; - for (const auto& l : s_lines) { all += l; all.push_back('\n'); } + for (const auto& l : s_lines) { all += l.text; all.push_back('\n'); } ImGui::SetClipboardText(all.c_str()); } ImGui::SameLine(); ImGui::Checkbox(TR("lite_console_autoscroll"), &s_autoScroll); ImGui::Spacing(); - // ── Event log (terminal-styled scroll region) ─────────────────────────────── + // ── Output log (terminal-styled scroll region) ─────────────────────────────── + const float inputRowH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingSm(); ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 90)); - ImGui::BeginChild("##LiteConsoleLog", ImVec2(0, 0), true, + ImGui::BeginChild("##LiteConsoleLog", ImVec2(0, -inputRowH), true, ImGuiWindowFlags_HorizontalScrollbar); ImGui::PushFont(Type().caption()); if (s_lines.empty()) { @@ -134,17 +209,65 @@ void RenderLiteConsoleTab(App* app) ImGui::PopStyleColor(); } else { for (const auto& line : s_lines) { - ImGui::PushStyleColor(ImGuiCol_Text, lineColor(line)); - ImGui::TextUnformatted(line.c_str()); // not format-interpreted — safe for any content + ImGui::PushStyleColor(ImGuiCol_Text, line.color); + ImGui::TextUnformatted(line.text.c_str()); // not format-interpreted — safe for any content ImGui::PopStyleColor(); } } - // Keep pinned to the newest line only while the user is already at the bottom. - if (s_autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) - ImGui::SetScrollHereY(1.0f); + if (s_scrollToBottom && s_autoScroll) ImGui::SetScrollHereY(1.0f); + else if (s_autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f); + s_scrollToBottom = false; ImGui::PopFont(); ImGui::EndChild(); ImGui::PopStyleColor(); + + // ── Command input row ──────────────────────────────────────────────────────── + const bool busy = lw && lw->consoleCommandInProgress(); + auto submit = [&]() { + std::string cmd = s_input; + // trim + const std::size_t b = cmd.find_first_not_of(" \t"); + if (b == std::string::npos) { s_input[0] = '\0'; return; } + cmd = cmd.substr(b); + while (!cmd.empty() && (cmd.back() == ' ' || cmd.back() == '\t')) cmd.pop_back(); + + s_lines.push_back({"> " + cmd, Primary()}); + AppendConsoleHistory(s_history, cmd); + s_historyIndex = -1; + + const std::string first = lowerFirstToken(cmd); + if (first == "clear" || first == "cls") { + // The backend's `clear` wipes wallet tx history — NOT the screen. Intercept it as a + // view-clear (what the user expects) so a stray "clear" can't destroy history. + s_lines.clear(); + s_diagGen = diag.generation(); + } else if (first == "quit" || first == "exit") { + appendLines(TR("lite_console_quit_note"), OnSurfaceMedium()); + } else if (!lw) { + appendLines("Lite backend not linked in this build.", Error()); + } else if (!lw->runConsoleCommand(cmd)) { + appendLines(TR("lite_console_busy"), Warning()); + } + s_input[0] = '\0'; + s_scrollToBottom = true; + s_focusInput = true; // keep focus for the next command + }; + + ImGui::PushFont(Type().caption()); + Type().textColored(TypeStyle::Caption, busy ? Warning() : Primary(), + busy ? TR("lite_console_running") : "\xE2\x80\xBA"); // ‹running…› / "›" + ImGui::SameLine(); + ImGui::SetNextItemWidth(-FLT_MIN); + if (busy) ImGui::BeginDisabled(); + const ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_CallbackHistory; + if (s_focusInput) { ImGui::SetKeyboardFocusHere(); s_focusInput = false; } + if (ImGui::InputTextWithHint("##LiteConsoleInput", TR("lite_console_input_hint"), + s_input, sizeof(s_input), flags, inputCallback)) { + submit(); + } + if (busy) ImGui::EndDisabled(); + ImGui::PopFont(); } } // namespace ui diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index ea1db68..269c4d5 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1108,12 +1108,16 @@ void I18n::loadBuiltinEnglish() // --- Lite Network tab (server browser) --- strings_["lite_console_title"] = "Console"; - strings_["lite_console_intro"] = "Diagnostic log: server connections, wallet open/create, and sync events."; + strings_["lite_console_intro"] = "Diagnostic log + interactive console. Type a command (e.g. info, balance, list) and press Enter; type help for the full list."; strings_["lite_console_status"] = "Status:"; strings_["lite_console_clear"] = "Clear"; strings_["lite_console_copy"] = "Copy"; strings_["lite_console_autoscroll"] = "Auto-scroll"; - strings_["lite_console_empty"] = "No diagnostic output yet."; + strings_["lite_console_empty"] = "No output yet. Type a command (try 'help') and press Enter."; + strings_["lite_console_input_hint"] = "Type a command (help, info, balance, list, sync, new zs)…"; + strings_["lite_console_running"] = "running…"; + strings_["lite_console_busy"] = "A command is already running — wait for it to finish."; + strings_["lite_console_quit_note"] = "'quit'/'exit' don't apply here — the embedded backend stays running with the app."; strings_["lite_net_title"] = "Lite Servers"; strings_["lite_net_intro"] = "Pick a server to use, or let the wallet choose one at random. Changes apply immediately."; strings_["lite_net_use_random"] = "Use a random server each time"; diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 03d2b86..e85c5e1 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -272,6 +272,8 @@ LiteWalletController::~LiteWalletController() // Likewise the broadcast thread (send/shield proving): it captures shared refs (bridge + // running flag + result slot), never `this`, so detaching is safe. if (broadcastThread_.joinable()) broadcastThread_.detach(); + // The interactive-console command thread follows the same shared-lifetime pattern. + if (consoleThread_.joinable()) consoleThread_.detach(); // The async-open failover thread captures only shared refs (bridge + running flag + result // slot), never `this`, so detaching is safe if it's still trying servers at shutdown. if (openThread_.joinable()) openThread_.detach(); @@ -753,6 +755,69 @@ bool LiteWalletController::takeBroadcastResult(LiteBroadcastResult& out) return true; } +bool LiteWalletController::runConsoleCommand(std::string commandLine) +{ + if (!bridge_ || consoleRunning_->load()) return false; + + // Split into command (first token) + the remainder as a single arg string: litelib_execute + // passes args through as ONE element (it does not whitespace-split), matching how send uses + // the JSON-array form. + const size_t cmdBegin = commandLine.find_first_not_of(" \t"); + if (cmdBegin == std::string::npos) return false; // blank line + const size_t cmdEnd = commandLine.find_first_of(" \t", cmdBegin); + std::string command = commandLine.substr( + cmdBegin, cmdEnd == std::string::npos ? std::string::npos : cmdEnd - cmdBegin); + std::string args; + if (cmdEnd != std::string::npos) { + const size_t argBegin = commandLine.find_first_not_of(" \t", cmdEnd); + if (argBegin != std::string::npos) args = commandLine.substr(argBegin); + } + + if (consoleThread_.joinable()) consoleThread_.join(); // a prior command has fully finished + { + std::lock_guard lk(*consoleResultMutex_); + consoleResult_->reset(); + } + consoleRunning_->store(true); + + // Capture only the shared bridge + flags/slot (never `this`) so the thread can outlive us. + auto bridge = bridge_; + auto running = consoleRunning_; + auto mutex = consoleResultMutex_; + auto slot = consoleResult_; + consoleThread_ = std::thread( + [bridge, command, args, echo = std::move(commandLine), running, mutex, slot]() { + LiteConsoleResult r; + r.command = echo; + if (bridge) { + const auto call = bridge->execute(command, args); + r.ok = call.ok; + r.response = call.ok ? call.value + : (call.error.empty() ? "command failed" : call.error); + } else { + r.response = "lite backend unavailable"; + } + { + std::lock_guard lk(*mutex); + *slot = std::move(r); + } + running->store(false); + }); + return true; +} + +bool LiteWalletController::takeConsoleResult(LiteConsoleResult& out) +{ + { + std::lock_guard lk(*consoleResultMutex_); + if (!consoleResult_->has_value()) return false; + out = std::move(**consoleResult_); + consoleResult_->reset(); + } + if (consoleThread_.joinable()) consoleThread_.join(); // producer set its result, then exits + return true; +} + LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKey) { LiteImportResult out; diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index fb3b92c..537e327 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -64,6 +64,15 @@ struct LiteNewAddressResult { std::string error; }; +// Result of an interactive console command (lite Console tab). `response` is the raw backend +// output (JSON or text). It may contain SECRET material (e.g. seed/export) because the user can +// run any command, so it is shown only in the console view and NEVER routed through LiteDiagnostics. +struct LiteConsoleResult { + std::string command; // the command line that was run, echoed back + std::string response; // raw backend response (or an error message) + bool ok = false; +}; + // A single send recipient. amountZatoshis is in zatoshis (1e-8 DRGX); memo is ignored by the // backend for transparent destination addresses. struct LiteSendRecipient { @@ -227,6 +236,21 @@ public: bool broadcastInProgress() const { return broadcastRunning_ && broadcastRunning_->load(); } bool takeBroadcastResult(LiteBroadcastResult& out); + // --- Interactive console (lite Console tab) --------------------------------------------- + // Run an arbitrary backend command — the same verbs as silentdragonxlite-cli (balance, info, + // height, list, notes, addresses, sync, syncstatus, new, send, shield, encrypt, …). ASYNC: + // some commands (sync/rescan/send/shield/import) block, so the bridge call runs on a detached + // thread (captures the shared bridge, never `this`) and the result is delivered to a main- + // thread slot drained by takeConsoleResult(). `commandLine` is " [args]" — the first + // whitespace-delimited token is the command and the remainder is passed as the single arg + // string the backend expects (litelib_execute does not split args; use the JSON form for send). + // Returns false if a console command is already running or no backend is linked. The caller + // (tab) intercepts `clear`/`quit` BEFORE calling this — backend `clear` wipes wallet history. + // Responses may contain secrets and are NEVER logged to LiteDiagnostics. + bool runConsoleCommand(std::string commandLine); + bool consoleCommandInProgress() const { return consoleRunning_ && consoleRunning_->load(); } + bool takeConsoleResult(LiteConsoleResult& out); + // Synchronous cores for send/shield (block on the backend; safe off the UI thread). Used by // the async entry points above and directly by tests. LiteBroadcastResult sendTransactionBlocking(const LiteSendRequest& request); @@ -317,6 +341,14 @@ private: std::shared_ptr> broadcastResult_ = std::make_shared>(); + // Detached interactive-console command runner (same shared-lifetime pattern as broadcast: + // captures the shared bridge + running flag + result slot, never `this`). + std::thread consoleThread_; + std::shared_ptr> consoleRunning_ = std::make_shared>(false); + std::shared_ptr consoleResultMutex_ = std::make_shared(); + std::shared_ptr> consoleResult_ = + std::make_shared>(); + // Asynchronous open with server failover (mirrors the sync/broadcast shared-lifetime pattern: // the detached thread captures only shared_ptrs + value copies, never `this`, so it can // safely outlive the controller). pumpAsyncOpen() finalizes the result on the main thread. diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 0baafbc..1220136 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3638,6 +3638,49 @@ void testLiteWalletControllerOpenFailover() dragonx::test::g_liteFakeWarmupServerSubstr.clear(); } +// Interactive console: runConsoleCommand() forwards an arbitrary backend command on a background +// thread and delivers the raw response to a main-thread slot drained by takeConsoleResult(). +void testLiteWalletControllerConsoleCommand() +{ + using namespace dragonx::wallet; + const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + LiteConnectionSettings conn; + conn.chainName = "main"; + conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} }; + + dragonx::test::resetLiteFakeCounters(); + LiteWalletController controller(liteCaps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + + const auto drainConsole = [&](LiteConsoleResult& res) { + for (int i = 0; i < 400 && !controller.takeConsoleResult(res); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + }; + + // A single-token command (no args) returns the backend's raw response. + { + EXPECT_TRUE(controller.runConsoleCommand("info")); + LiteConsoleResult res; + drainConsole(res); + EXPECT_TRUE(res.ok); + EXPECT_EQ(res.command, std::string("info")); + EXPECT_TRUE(res.response.find("sdxl-fake") != std::string::npos); + } + + // " " splits into the command token + the remainder as the single arg string + // (new zs -> the shielded-address path of the fake backend). + { + EXPECT_TRUE(controller.runConsoleCommand("new zs")); + LiteConsoleResult res; + drainConsole(res); + EXPECT_TRUE(res.ok); + EXPECT_TRUE(res.response.find("zs1fakenew") != std::string::npos); + } + + // A blank line is rejected (nothing to run). + EXPECT_FALSE(controller.runConsoleCommand(" ")); +} + // Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also // fails over: the request runs off the UI thread against the preferred server, then the other // usable defaults, finalized by pumpLifecycleResult() on the main thread. @@ -4596,6 +4639,7 @@ int main() testLiteWalletControllerLifecycle(); testLiteWalletControllerOpenFailover(); testLiteWalletControllerAsyncLifecycleFailover(); + testLiteWalletControllerConsoleCommand(); testLiteWalletControllerM4(); testLiteWalletControllerM5Persistence(); testLiteWalletControllerEncryption();