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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user