Files
ObsidianDragon/src/ui/windows/lite_console_tab.cpp
DanS 4a65dce947 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>
2026-06-09 20:32:03 -05:00

275 lines
12 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// 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"
#include "../../util/i18n.h"
#include "../../wallet/lite_diagnostics.h"
#include "../../wallet/lite_wallet_controller.h"
#include "../layout.h"
#include "../material/type.h"
#include "../material/colors.h"
#include "imgui.h"
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
using namespace material;
namespace {
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 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") ||
has("Error") || has("error"))
return Error();
if (has(": connected") || has("opened") || has("wallet ready") || has("Ready"))
return Success();
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) ───────
{
const char* connText;
ImU32 connCol;
if (!lw) {
connText = "Lite backend not linked in this build (rebuild with --lite-backend)";
connCol = Error();
} else if (lw->walletOpen()) {
connText = "Connected";
connCol = Success();
} else if (lw->openInProgress()) {
connText = "Connecting\xE2\x80\xA6"; // ellipsis
connCol = Warning();
} else {
connText = "Disconnected";
connCol = Error();
}
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("lite_console_status"));
ImGui::SameLine();
Type().textColored(TypeStyle::Body2, connCol, connText);
if (lw && lw->walletOpen()) {
const SyncInfo& sync = app->state().sync;
char buf[96];
if (sync.syncing && !sync.isSynced()) {
double vp = sync.verification_progress;
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
std::snprintf(buf, sizeof(buf), "Syncing %.1f%% (block %d / %d)",
vp * 100.0, sync.blocks, sync.headers);
} else {
std::snprintf(buf, sizeof(buf), "Synced (block %d)", sync.blocks);
}
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf);
}
const std::string& openErr = app->liteOpenError();
if (!openErr.empty() && (!lw || !lw->walletOpen())) {
Type().textColored(TypeStyle::Caption, Error(),
(std::string("Last error: ") + openErr).c_str());
}
}
ImGui::Spacing();
// ── Ingest new automatic diagnostics events into the unified buffer ───────────
auto& diag = wallet::LiteDiagnostics::instance();
const std::uint64_t gen = diag.generation();
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"))) {
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.text; all.push_back('\n'); }
ImGui::SetClipboardText(all.c_str());
}
ImGui::SameLine();
ImGui::Checkbox(TR("lite_console_autoscroll"), &s_autoScroll);
ImGui::Spacing();
// ── 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, -inputRowH), true,
ImGuiWindowFlags_HorizontalScrollbar);
ImGui::PushFont(Type().caption());
if (s_lines.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceDisabled());
ImGui::TextUnformatted(TR("lite_console_empty"));
ImGui::PopStyleColor();
} else {
for (const auto& line : s_lines) {
ImGui::PushStyleColor(ImGuiCol_Text, line.color);
ImGui::TextUnformatted(line.text.c_str()); // not format-interpreted — safe for any content
ImGui::PopStyleColor();
}
}
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
} // namespace dragonx