feat(lite): make the Console tab interactive (run backend commands)

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 "<command> [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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:32:03 -05:00
parent c8183241c3
commit 4a65dce947
5 changed files with 294 additions and 26 deletions

View File

@@ -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 <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
@@ -26,14 +31,27 @@ using namespace material;
namespace {
// Re-snapshot the (mutex-guarded) log only when it actually changes, not every frame.
std::vector<std::string> s_lines;
std::uint64_t s_cachedGeneration = static_cast<std::uint64_t>(-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<ConsoleLine> s_lines;
std::uint64_t s_diagGen = static_cast<std::uint64_t>(-1); // last consumed diagnostics generation
char s_input[512] = "";
std::vector<std::string> 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<char>(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<std::uint64_t>(-1)) {
const std::uint64_t added = gen - s_diagGen;
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(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

View File

@@ -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";

View File

@@ -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<std::mutex> 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<std::mutex> lk(*mutex);
*slot = std::move(r);
}
running->store(false);
});
return true;
}
bool LiteWalletController::takeConsoleResult(LiteConsoleResult& out)
{
{
std::lock_guard<std::mutex> 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;

View File

@@ -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 "<command> [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<std::optional<LiteBroadcastResult>> broadcastResult_ =
std::make_shared<std::optional<LiteBroadcastResult>>();
// 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<std::atomic<bool>> consoleRunning_ = std::make_shared<std::atomic<bool>>(false);
std::shared_ptr<std::mutex> consoleResultMutex_ = std::make_shared<std::mutex>();
std::shared_ptr<std::optional<LiteConsoleResult>> consoleResult_ =
std::make_shared<std::optional<LiteConsoleResult>>();
// 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.

View File

@@ -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);
}
// "<command> <args>" 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();