// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// console_tab.cpp — Interactive RPC console with command history,
// tab completion, daemon log display, and color-coded output.

#include "console_tab.h"
#include "console_command_executor.h"
#include "console_command_reference.h"
#include "console_input_model.h"
#include "console_output_model.h"
#include "console_tab_helpers.h"
#include "../material/colors.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../layout.h"
#include "../effects/imgui_acrylic.h"
#include "../material/color_theme.h"
#include "../theme.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../../util/i18n.h"

#include <imgui.h>
#include <cctype>
#include <cstring>
#include <sstream>
#include <algorithm>
#include <cmath>
#include <mutex>
#include <ctime>
#include <unordered_set>

namespace dragonx {
namespace ui {

// Static color definitions — defaults; overridden from ui.toml in constructor
ImU32 ConsoleTab::COLOR_COMMAND = IM_COL32(191, 209, 229, 255);
ImU32 ConsoleTab::COLOR_RESULT  = IM_COL32(200, 200, 200, 255);
ImU32 ConsoleTab::COLOR_ERROR   = IM_COL32(246,  71,  64, 255);
ImU32 ConsoleTab::COLOR_DAEMON  = IM_COL32(160, 160, 160, 180);
ImU32 ConsoleTab::COLOR_INFO    = IM_COL32(191, 209, 229, 255);
ImU32 ConsoleTab::COLOR_RPC     = IM_COL32(120, 180, 255, 210);
bool  ConsoleTab::s_scanline_enabled = true;
bool  ConsoleTab::s_line_accents_enabled = true;
bool  ConsoleTab::s_line_text_color_enabled = true;
float ConsoleTab::s_console_zoom = 1.0f;
bool  ConsoleTab::s_daemon_messages_enabled = true;
bool  ConsoleTab::s_errors_only_enabled = false;
bool  ConsoleTab::s_rpc_trace_enabled = false;
bool  ConsoleTab::s_app_messages_enabled = true;

namespace {

std::mutex s_rpc_trace_console_mutex;
ConsoleTab* s_rpc_trace_console = nullptr;

std::string rpcTraceTimestamp()
{
    // Called on RPC worker threads. std::localtime shares a process-wide static tm, so a private
    // mutex here can't stop another thread's localtime call from clobbering it between the call and
    // the copy. Use the reentrant variant into a local tm instead (matches the codebase pattern).
    std::time_t now = std::time(nullptr);
    std::tm localTime{};
#ifdef _WIN32
    localtime_s(&localTime, &now);
#else
    localtime_r(&now, &localTime);
#endif

    char buffer[16];
    std::strftime(buffer, sizeof(buffer), "%H:%M:%S", &localTime);
    return buffer;
}

// Result-body channels (plain command output + JSON syntax roles) — the lines that carry
// no left accent bar and get the JSON indent guides.
bool isResultBodyChannel(ConsoleChannel ch)
{
    switch (ch) {
        case ConsoleChannel::None:
        case ConsoleChannel::JsonKey:
        case ConsoleChannel::JsonString:
        case ConsoleChannel::JsonNumber:
        case ConsoleChannel::JsonBrace:
            return true;
        default:
            return false;
    }
}

} // namespace

namespace {

// ── WCAG contrast helpers — keep console text high-contrast while following each theme's palette. ──
inline float srgbToLinear(float c8) {
    float c = c8 / 255.0f;
    return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f);
}
inline float relLuminance(float r, float g, float b) {
    return 0.2126f * srgbToLinear(r) + 0.7152f * srgbToLinear(g) + 0.0722f * srgbToLinear(b);
}
inline float contrastRatio(float lumA, float lumB) {
    float hi = std::max(lumA, lumB), lo = std::min(lumA, lumB);
    return (hi + 0.05f) / (lo + 0.05f);
}
// Nudge `fg` toward black (light surface) or white (dark surface), preserving hue + alpha, until its
// WCAG contrast against `bg` reaches `minRatio`. Colors already passing come back essentially unchanged.
inline ImU32 EnsureContrast(ImU32 fg, ImU32 bg, float minRatio) {
    const float bgL = relLuminance((float)((bg >> IM_COL32_R_SHIFT) & 0xFF),
                                   (float)((bg >> IM_COL32_G_SHIFT) & 0xFF),
                                   (float)((bg >> IM_COL32_B_SHIFT) & 0xFF));
    float r = (float)((fg >> IM_COL32_R_SHIFT) & 0xFF);
    float g = (float)((fg >> IM_COL32_G_SHIFT) & 0xFF);
    float b = (float)((fg >> IM_COL32_B_SHIFT) & 0xFF);
    const int a = (int)((fg >> IM_COL32_A_SHIFT) & 0xFF);
    const bool darken = bgL > 0.5f;   // light surface -> darken the text; dark surface -> lighten it
    for (int i = 0; i < 40 && contrastRatio(relLuminance(r, g, b), bgL) < minRatio; ++i) {
        if (darken) { r *= 0.90f; g *= 0.90f; b *= 0.90f; }
        else        { r += (255.0f - r) * 0.12f; g += (255.0f - g) * 0.12f; b += (255.0f - b) * 0.12f; }
    }
    return IM_COL32((int)(r + 0.5f), (int)(g + 0.5f), (int)(b + 0.5f), a);
}
// Floor a dynamically-derived channel color to a readable contrast on light terminals; no-op on dark.
inline ImU32 FloorLight(ImU32 c) {
    return material::IsLightTheme() ? EnsureContrast(c, IM_COL32(255, 255, 255, 255), 4.5f) : c;
}

} // namespace

void ConsoleTab::refreshColors()
{
    using namespace material;
    auto& S = schema::UI();
    // Derive light/dark from the SAME live background-luminance predicate the terminal surface uses
    // (the overlay at line ~274 is chosen by IsLightTheme()). Using the stored IsDarkTheme() flag here
    // let the two disagree — baking dark-theme (pale) text onto the light near-white console surface.
    bool dark = !IsLightTheme();

    // Per-theme channel defaults.
    ImU32 defCmd, defRes, defErr, defDmn, defInf, defRpc;
    if (dark) {
        // Dark terminal: keep the authored light-on-dark palette (schema overrides honored below).
        defCmd = IM_COL32(191, 209, 229, 255);
        defRes = IM_COL32(200, 200, 200, 255);
        defErr = IM_COL32(246,  71,  64, 255);
        defDmn = IM_COL32(160, 160, 160, 180);
        defInf = IM_COL32(191, 209, 229, 255);
        defRpc = IM_COL32(120, 180, 255, 210);
    } else {
        // Light terminal: follow THIS theme's own palette (Marble slate/taupe, Dune sand, Light blue, …)
        // so the console reads on-theme — each color nudged toward black until it clears a WCAG contrast
        // floor on the near-white console surface. High-contrast AND theme-colored.
        const ImU32 W = IM_COL32(255, 255, 255, 255);   // overlay is white(205); target pure white = safe floor
        defCmd = EnsureContrast(Primary(),                   W, 4.5f);   // command echo — theme primary
        defRes = EnsureContrast(OnSurface(),                 W, 7.0f);   // result body — theme text (already dark)
        defErr = EnsureContrast(Error(),                     W, 4.5f);   // errors — theme error
        defDmn = WithAlpha(EnsureContrast(OnSurfaceMedium(), W, 4.5f), 235); // node log — dimmer, still legible
        defInf = EnsureContrast(Primary(),                   W, 4.5f);   // info / app log — theme primary
        defRpc = EnsureContrast(Secondary(),                 W, 4.5f);   // rpc trace — theme secondary
    }

    // Schema console colors are authored for the dark terminal (light-on-dark); honor them only in dark
    // themes. Light themes always use the palette-derived, contrast-floored defaults above.
    if (S.isLoaded()) {
        auto cmd = S.drawElement("console", "color-command");
        auto res = S.drawElement("console", "color-result");
        auto err = S.drawElement("console", "color-error");
        auto dmn = S.drawElement("console", "color-daemon");
        auto inf = S.drawElement("console", "color-info");
        auto rpc = S.drawElement("console", "color-rpc");
        COLOR_COMMAND = (dark && !cmd.color.empty()) ? S.resolveColor(cmd.color, defCmd) : defCmd;
        COLOR_RESULT  = (dark && !res.color.empty()) ? S.resolveColor(res.color, defRes) : defRes;
        COLOR_ERROR   = (dark && !err.color.empty()) ? S.resolveColor(err.color, defErr) : defErr;
        COLOR_DAEMON  = (dark && !dmn.color.empty()) ? S.resolveColor(dmn.color, defDmn) : defDmn;
        COLOR_INFO    = (dark && !inf.color.empty()) ? S.resolveColor(inf.color, defInf) : defInf;
        COLOR_RPC     = (dark && !rpc.color.empty()) ? S.resolveColor(rpc.color, defRpc) : defRpc;
    } else {
        COLOR_COMMAND = defCmd;
        COLOR_RESULT  = defRes;
        COLOR_ERROR   = defErr;
        COLOR_DAEMON  = defDmn;
        COLOR_INFO    = defInf;
        COLOR_RPC     = defRpc;
    }
}

ImU32 ConsoleTab::channelTextColor(ConsoleChannel channel) const
{
    using namespace material;
    // Monochrome mode: collapse every channel (and JSON syntax role) to the neutral result-body
    // color. COLOR_RESULT is theme-correct (contrast-floored on light terminals in refreshColors),
    // so text stays readable. The left accent bars are gated separately and stay independent.
    if (!s_line_text_color_enabled) return COLOR_RESULT;
    switch (channel) {
        // COLOR_* channels are already palette-derived + contrast-floored in refreshColors(). The
        // roles below are computed live from the theme palette, so floor them to a readable contrast on
        // light terminals here (FloorLight is a no-op on dark themes, preserving the authored look).
        case ConsoleChannel::Command:    return COLOR_COMMAND;
        case ConsoleChannel::Info:       return COLOR_INFO;
        case ConsoleChannel::Success:    return FloorLight(WithAlpha(Success(), 255));
        case ConsoleChannel::Warning:    return FloorLight(Warning());
        case ConsoleChannel::Error:      return COLOR_ERROR;
        case ConsoleChannel::Rpc:        return COLOR_RPC;
        case ConsoleChannel::Daemon:     return COLOR_DAEMON;
        case ConsoleChannel::Xmrig:      return COLOR_DAEMON;
        case ConsoleChannel::App:        return COLOR_INFO;
        // JSON syntax roles — highlight against the plain-result body.
        case ConsoleChannel::JsonKey:    return FloorLight(WithAlpha(Secondary(), 255));
        case ConsoleChannel::JsonString: return FloorLight(WithAlpha(Success(), 255));
        case ConsoleChannel::JsonNumber: return FloorLight(WithAlpha(Warning(), 255));
        case ConsoleChannel::JsonBrace:  return IsLightTheme() ? IM_COL32(90, 90, 90, 180) : IM_COL32(200, 200, 200, 150);
        case ConsoleChannel::None:
        default:                         return COLOR_RESULT;
    }
}

ImU32 ConsoleTab::channelAccentColor(ConsoleChannel channel)
{
    using namespace material;
    switch (channel) {
        case ConsoleChannel::Command: return Primary();
        case ConsoleChannel::Error:   return Error();
        case ConsoleChannel::Warning: return Warning();
        case ConsoleChannel::Success: return Success();
        case ConsoleChannel::Rpc:     return Secondary();
        case ConsoleChannel::Daemon:  return IM_COL32(90, 130, 190, 210);   // node log — blue
        case ConsoleChannel::Xmrig:   return Warning();                     // mining — amber
        case ConsoleChannel::App:     return IM_COL32(120, 190, 160, 210);  // app — teal
        case ConsoleChannel::Info:    return OnSurfaceDisabled();
        default:                      return 0;   // result / JSON roles get no accent
    }
}

ConsoleTab::ConsoleTab()
{
    {
        std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
        s_rpc_trace_console = this;
    }
    rpc::RPCClient::setTraceCallback([](const std::string& source, const std::string& method) {
        // Dereference under the lock, not after releasing it: ~ConsoleTab clears
        // s_rpc_trace_console under the same lock, so holding it here blocks destruction until the
        // call returns — closing the shutdown use-after-free window (this fires on RPC worker threads).
        std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
        if (s_rpc_trace_console) s_rpc_trace_console->addRpcTraceLine(source, method);
    });
    rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);

    // Load console colors from ui.toml schema (uses current theme)
    refreshColors();

    // Add welcome message
    addLine(TR("console_welcome"), ConsoleChannel::Info);
    addLine(TR("console_type_help"), ConsoleChannel::Info);
    addLine("", ConsoleChannel::None);
}

ConsoleTab::~ConsoleTab()
{
    rpc::RPCClient::setTraceEnabled(false);
    rpc::RPCClient::setTraceCallback(nullptr);
    std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
    if (s_rpc_trace_console == this) s_rpc_trace_console = nullptr;
}

void ConsoleTab::render(ConsoleCommandExecutor& exec)
{
    using namespace material;

    // Refresh the console theme colors whenever the theme changes. Keyed on the schema generation
    // (bumped on every theme/skin load) rather than a dark/light toggle: the constructor's refreshColors()
    // can run before the theme is applied (s_isDarkTheme still defaults true), and a toggle-only trigger
    // would never correct that on startup. Line colors are derived from each line's channel at draw time.
    {
        static uint32_t s_lastGen = ~0u;
        uint32_t gen = schema::UI().generation();
        if (gen != s_lastGen) {
            refreshColors();
            s_lastGen = gen;
        }
    }

    // Run a command the RPC-reference modal's "Insert & run" queued (the modal has no executor, so it
    // defers to here). Done before drain so the echoed "> cmd" line surfaces this frame.
    if (!pending_submit_.empty()) {
        std::string cmd;
        cmd.swap(pending_submit_);
        submitConsoleCommand(exec, cmd);
    }

    // Pull passive log lines (daemon/xmrig output, or the lite diagnostics ring) and any
    // completed command results from the backend executor.
    exec.pollLogLines([this](const std::string& l, ConsoleChannel c) { addLine(l, c); });
    {
        std::string result;
        bool isError = false;
        while (exec.pollResult(result, isError)) addFormattedResult(result, isError);
    }

    // Drain the ingest queue into the visible model once per frame, after this frame's own
    // ingests above and any that arrived from worker threads. From here on lines_ (via
    // model_) and the selection/scroll state are touched only on this (main) thread.
    drainModel();

    // Compute the filtered visible-line set once per frame, BEFORE the toolbar — so its "<N> matches"
    // label reflects the current frame (not last frame's stale count). renderOutput reuses the result.
    computeVisibleLines(has_text_filter_, filter_lower_);

    // Main console layout
    // NoScrollWithMouse: the inner ConsoleOutput owns wheel scrolling (via ApplySmoothScroll). Without
    // this, a wheel over the output would scroll BOTH the output (smooth-scroll) and this outer container
    // (ImGui forwards the NoScrollWithMouse child's wheel to its scrollable ancestor) — a double-scroll.
    // Safe because the output panel is sized to fill the remaining height, so this outer never overflows.
    ImGui::BeginChild("ConsoleContainer", ImVec2(0, 0), false,
                      ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
    
    // Optional status header (lite: sync + last error) above the toolbar.
    renderStatusHeader(exec);

    // Toolbar
    renderToolbar(exec);

    ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
    
    // Output area (scrollable) — glass panel background
    float availHeight = ImGui::GetContentRegionAvail().y;
    float input_height = ComputeConsoleInputHeight(
        ImGui::GetFrameHeightWithSpacing(),
        ImGui::GetStyle().ItemSpacing.y,
        Layout::spacingSm(),
        Layout::spacingXs(),
        schema::UI().drawElement("tabs.console", "input-cursor-offset").size);
    float outputH = ComputeConsoleOutputHeight(
        availHeight,
        input_height,
        schema::UI().drawElement("tabs.console", "output-min-height").size * Layout::dpiScale(),
        schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
    
    ImDrawList* dlOut = ImGui::GetWindowDrawList();
    ImVec2 outPanelMin = ImGui::GetCursorScreenPos();
    ImVec2 outPanelMax(outPanelMin.x + ImGui::GetContentRegionAvail().x, outPanelMin.y + outputH);
    GlassPanelSpec outGlass;
    outGlass.rounding = Layout::glassRounding();
    outGlass.fillAlpha = 12;
    DrawGlassPanel(dlOut, outPanelMin, outPanelMax, outGlass);
    // Terminal-dark overlay: dim the glass so console text reads like a real terminal.
    {
        int darkA = static_cast<int>(schema::UI().drawElement("tabs.console", "bg-darken-alpha").sizeOr(110.0f));
        // Light skins get a near-white terminal surface (not a black darken) so the dark, theme-aware
        // log text stays legible instead of sitting dark-on-dark-grey.
        ImU32 termOverlay = IsLightTheme() ? IM_COL32(255, 255, 255, 205) : IM_COL32(0, 0, 0, darkA);
        dlOut->AddRectFilled(outPanelMin, outPanelMax, termOverlay, outGlass.rounding);
    }
    
    int consoleParentVtx = dlOut->VtxBuffer.Size;

    ImGui::BeginChild("ConsoleOutput", ImVec2(0, outputH), false, 
                      ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
    ApplySmoothScroll();
    ImDrawList* consoleChildDL = ImGui::GetWindowDrawList();
    int consoleChildVtx = consoleChildDL->VtxBuffer.Size;
    float consoleScrollY    = ImGui::GetScrollY();
    float consoleScrollMaxY = ImGui::GetScrollMaxY();
    // Console output uses the monospace font so terminal text + JSON columns align.
    ImGui::PushFont(Type().mono());
    ImGui::SetWindowFontScale(s_console_zoom);
    renderOutput();
    ImGui::SetWindowFontScale(1.0f);
    ImGui::PopFont();
    ImGui::EndChild();

    // Auto-toggle auto-scroll based on scroll position:
    // At the bottom → re-enable; scrolled up → already disabled by wheel handler.
    // After wheel-up, wait for the cooldown so smooth-scroll can animate
    // away from the bottom before we check position again.
    scroll_.tickCooldown(ImGui::GetIO().DeltaTime);
    {
        float tolerance = Type().mono()->LegacySize * 1.5f;
        bool atBottom = consoleScrollMaxY > 0.0f &&
                        consoleScrollY >= consoleScrollMaxY - tolerance;
        scroll_.considerReenable(atBottom);
    }

    // CSS-style clipping mask
    // When auto-scroll is off, force bottom fade to always show by
    // inflating scrollMax so the mask thinks there's content below.
    {
        float fadeZone = std::min(Type().mono()->LegacySize * 3.0f, outputH * 0.18f);
        float effectiveScrollMax = scroll_.autoScroll() ? consoleScrollMaxY
                                                : std::max(consoleScrollMaxY, consoleScrollY + 10.0f);
        ApplyScrollEdgeMask(dlOut, consoleParentVtx, consoleChildDL, consoleChildVtx,
                            outPanelMin.y, outPanelMax.y, fadeZone, consoleScrollY, effectiveScrollMax);
    }

    // CRT scanline effect over output area — aligned to text lines
    if (s_scanline_enabled) {
        float textLineH = output_line_height_;
        if (textLineH <= 1.0f) textLineH = Type().mono()->LegacySize * s_console_zoom + 2.0f;

        int lightAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(10.0f), 0, 255);
        int darkAlpha = std::clamp(lightAlpha + 10, 0, 255);
        if (textLineH >= 1.0f && (lightAlpha > 0 || darkAlpha > 0)) {
            ImU32 lightCol = IM_COL32(255, 255, 255, lightAlpha);
            ImU32 darkCol = IM_COL32(0, 0, 0, darkAlpha);
            for (const auto& row : scanline_rows_) {
                float yTop = std::max(row.yTop, outPanelMin.y);
                float yBot = std::min(row.yBot, outPanelMax.y);
                if (yTop < yBot) {
                    dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot),
                                         (row.rowIndex % 2 == 0) ? lightCol : darkCol);
                }
            }
            // Continue the banding into any empty space below the last text row (the bottom
            // fade padding, or when the content is shorter than the panel) so the zebra fills
            // the whole panel instead of stopping short.
            if (!scanline_rows_.empty()) {
                const auto& last = scanline_rows_.back();
                int parity = (last.rowIndex + 1) & 1;
                for (float y = last.yBot; y < outPanelMax.y; y += textLineH) {
                    float yTop = std::max(y, outPanelMin.y);
                    float yBot = std::min(y + textLineH, outPanelMax.y);
                    if (yTop < yBot)
                        dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot),
                                             (parity == 0) ? lightCol : darkCol);
                    parity ^= 1;
                }
            }
        }

        float panelH = outPanelMax.y - outPanelMin.y;
        float scanSpeed = schema::UI().drawElement("tabs.console", "scanline-speed").sizeOr(40.0f);
        float rawScanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(textLineH * 2.0f);
        int scanAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(8.0f), 0, 255);
        if (panelH > 1.0f && textLineH >= 1.0f && scanSpeed > 0.0f && scanAlpha > 0) {
            float scanLines = std::max(1.0f, std::round(rawScanH / textLineH));
            float scanH = scanLines * textLineH;
            float t = (float)std::fmod(ImGui::GetTime() * scanSpeed, (double)(panelH + scanH));
            float scanY = outPanelMin.y + t - scanH;
            float yTop = std::max(scanY, outPanelMin.y);
            float yBot = std::min(scanY + scanH, outPanelMax.y);
            if (yTop < yBot) {
                float mid = (yTop + yBot) * 0.5f;
                ImU32 clear = IM_COL32(255, 255, 255, 0);
                ImU32 peak = IM_COL32(255, 255, 255, scanAlpha);
                dlOut->AddRectFilledMultiColor(
                    ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, mid),
                    clear, clear, peak, peak);
                dlOut->AddRectFilledMultiColor(
                    ImVec2(outPanelMin.x, mid), ImVec2(outPanelMax.x, yBot),
                    peak, peak, clear, clear);
            }
        }
    }

    ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

    // Input area
    renderInput(exec);

    ImGui::EndChild();
}

void ConsoleTab::renderCommandsPopupModal(ConsoleCommandExecutor* exec)
{
    if (!show_commands_popup_) {
        return;
    }
    // Need a backend that offers a reference table. If the console hasn't built its executor yet
    // (popup can't have been opened normally) or the backend has none, just dismiss.
    if (!exec || !exec->commandReference()) {
        show_commands_popup_ = false;
        return;
    }
    renderCommandsPopup(*exec);
}

void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
{
    using namespace material;
    ImDrawList* dl = ImGui::GetWindowDrawList();

    // Glass panel for toolbar
    float toolbarH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingMd();
    ImVec2 tbMin = ImGui::GetCursorScreenPos();
    ImVec2 tbMax(tbMin.x + ImGui::GetContentRegionAvail().x, tbMin.y + toolbarH);
    GlassPanelSpec tbGlass;
    tbGlass.rounding = Layout::glassRounding();
    tbGlass.fillAlpha = 12;
    DrawGlassPanel(dl, tbMin, tbMax, tbGlass);

    ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (toolbarH - ImGui::GetFrameHeight()) * 0.5f);
    ImGui::SetCursorPosX(ImGui::GetCursorPosX() + Layout::spacingMd());

    // Backend status with colored dot (daemon state / lite connection).
    drawToolbarStatus(exec);

    ImGui::SameLine();
    ImGui::Spacing();
    ImGui::SameLine();
    
    // Auto-scroll toggle
    bool autoScroll = scroll_.autoScroll();
    if (ImGui::Checkbox(TR("console_auto_scroll"), &autoScroll)) {
        scroll_.setAutoScroll(autoScroll);
    }
    
    ImGui::SameLine();
    ImGui::Spacing();
    ImGui::SameLine();

    // Log-filter toggles — only the ones the backend supports (full node = all; lite =
    // errors-only + app), each tinted with its channel color.
    ConsoleLogFilterCaps filterCaps = exec.logFilterCaps();
    if (filterCaps.any()) {
        drawLogFilterToggles(filterCaps);
    }

    // Clear button
    if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
        clear();   // also drops the stale visible_indices_ + selection (see ConsoleTab::clear)
    }
    
    ImGui::SameLine();
    
    // Copy button — material styled
    if (TactileButton(TR("copy"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
        if (selection_.active()) {
            std::string selected = selectedText();
            if (!selected.empty()) {
                ImGui::SetClipboardText(selected.c_str());
            }
        } else {
            // Copy all output if nothing selected
            std::string all;
            for (const auto& line : model_.lines()) {
                all += line.text + "\n";
            }
            if (!all.empty()) {
                ImGui::SetClipboardText(all.c_str());
            }
        }
    }
    if (ImGui::IsItemHovered()) {
        material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
    }
    
    ImGui::SameLine();
    
    // Commands reference button — shown whenever the backend offers a reference table (full-node
    // JSON-RPC commands, or the lite backend's own verbs).
    if (exec.commandReference()) {
        if (TactileButton(TR("console_commands"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
            command_search_[0] = '\0';   // fresh search each open (dismiss paths don't all reset it)
            show_commands_popup_ = true;
        }
        if (ImGui::IsItemHovered()) {
            material::Tooltip("%s", exec.hasRpcReference() ? TR("console_show_rpc_ref")
                                                           : TR("console_show_backend_ref"));
        }
        ImGui::SameLine();
    }
    
    // Line count — the least-critical trailing element. When the row is too narrow to fit the
    // filter box (at its placeholder-sized minimum) AND its trailing controls, drop the line count
    // rather than starve/hide the filter (worst at 1024px). Mirror the reservation formula in
    // drawFilterInput(): trailing = 4 frame-height buttons + the group spacers, and the filter's
    // hard floor = its placeholder width + frame padding.
    {
        char lineCountBuf[64];
        snprintf(lineCountBuf, sizeof(lineCountBuf), TR("console_line_count"), model_.size());
        float lineCountW  = ImGui::CalcTextSize(lineCountBuf).x + Layout::spacingSm() * 2.0f;  // text + its trailing spacer
        float trailingW   = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f;
        float filterMinW  = ImGui::CalcTextSize(TR("console_filter_hint")).x
                          + ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale();
        bool showLineCount = ImGui::GetContentRegionAvail().x >= lineCountW + trailingW + filterMinW;

        if (showLineCount) {
            ImGui::TextDisabled(TR("console_line_count"), model_.size());
            ImGui::SameLine();
            ImGui::Spacing();
            ImGui::SameLine();
        }
    }

    // Output filter input
    drawFilterInput();

    // Color-accent toggle: hide/show the per-line left accent bars (appearance, grouped with zoom).
    ImGui::SameLine();
    ImGui::Spacing();
    ImGui::SameLine();
    {
        float btnSz = ImGui::GetFrameHeight();
        // Capture the flag BEFORE the button can flip it — the push/pop guards must use the SAME value,
        // or a click leaves the colour stack unbalanced (ImGui then draws a red error rect on the window).
        const bool dim = !s_line_accents_enabled;
        const char* icon = s_line_accents_enabled ? ICON_MD_FORMAT_COLOR_FILL : ICON_MD_FORMAT_COLOR_RESET;
        if (dim) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
        if (TactileButton(icon, ImVec2(btnSz, btnSz), Type().iconMed()))
            s_line_accents_enabled = !s_line_accents_enabled;
        if (dim) ImGui::PopStyleColor();
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_accents"));
    }

    // Text-color toggle: colored per-channel text vs. monochrome (grouped with the accent toggle).
    ImGui::SameLine();
    {
        float btnSz = ImGui::GetFrameHeight();
        const bool dim = !s_line_text_color_enabled;   // capture before the click flips it (balanced push/pop)
        if (dim) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
        if (TactileButton(ICON_MD_FORMAT_COLOR_TEXT, ImVec2(btnSz, btnSz), Type().iconMed()))
            s_line_text_color_enabled = !s_line_text_color_enabled;
        if (dim) ImGui::PopStyleColor();
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color"));
    }

    // Zoom +/- buttons (right side of toolbar)
    ImGui::SameLine();
    ImGui::Spacing();
    ImGui::SameLine();
    drawZoomControls();
}

void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec)
{
    using namespace material;
    ImDrawList* dl = ImGui::GetWindowDrawList();
    ConsoleStatusLine st = exec.toolbarStatus();
    if (!st.text.empty()) {
        ImVec2 cp = ImGui::GetCursorScreenPos();
        float dotR = (schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size) * Layout::hScale();
        float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f;
        float dotX = cp.x + dotR + 2.0f * Layout::dpiScale();

        if (st.pulse) {
            float a = schema::UI().drawElement("animations", "pulse-base-glow").size + schema::UI().drawElement("animations", "pulse-amp-glow").size * (float)std::sin((double)ImGui::GetTime() * schema::UI().drawElement("animations", "pulse-speed-fast").size);
            ImU32 pCol = (st.color & 0x00FFFFFF) | ((ImU32)(255 * a) << 24);
            dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, pCol);
        } else {
            dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, st.color);
        }

        ImGui::Dummy(ImVec2(dotR * 2 + 6.0f * Layout::dpiScale(), 0));
        ImGui::SameLine();
        Type().textColored(TypeStyle::Caption, st.color, st.text.c_str());
    } else {
        Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("console_no_daemon"));
    }
}

void ConsoleTab::drawLogFilterToggles(const ConsoleLogFilterCaps& caps)
{
    // Each toggle is tinted with its channel's accent color (the same color as that channel's
    // left accent bar in the output), so the filter maps visually to the lines it controls.
    // Only the toggles the backend supports (caps) are shown — lite has no daemon/rpc-trace.
    bool drawnAny = false;
    auto separate = [&drawnAny]() {
        if (drawnAny) { ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); }
        drawnAny = true;
    };
    auto colored = [](ConsoleChannel ch, const char* label, bool* v, const char* tip) -> bool {
        ImVec4 c = ImGui::ColorConvertU32ToFloat4(channelAccentColor(ch));
        ImGui::PushStyleColor(ImGuiCol_CheckMark, c);
        ImGui::PushStyleColor(ImGuiCol_Text, c);
        bool changed = ImGui::Checkbox(label, v);
        ImGui::PopStyleColor(2);
        if (ImGui::IsItemHovered()) material::Tooltip("%s", tip);
        return changed;
    };

    if (caps.daemon) {
        separate();
        colored(ConsoleChannel::Daemon, TR("console_daemon"), &s_daemon_messages_enabled,
                TR("console_show_daemon_output"));
    }
    if (caps.errorsOnly) {
        separate();
        colored(ConsoleChannel::Error, TR("console_errors"), &s_errors_only_enabled,
                TR("console_show_errors_only"));
    }
    if (caps.rpcTrace) {
        separate();
        // Captures method/source only, never results or params.
        if (colored(ConsoleChannel::Rpc, TR("console_rpc_trace"), &s_rpc_trace_enabled,
                    TR("console_show_rpc_trace")))
            rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
    }
    if (caps.appMessages) {
        separate();
        colored(ConsoleChannel::App, TR("console_app"), &s_app_messages_enabled,
                TR("console_show_app_output"));
    }

    // Trailing spacer so the next toolbar item (Clear) stays on the same line.
    if (drawnAny) { ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); }
}

void ConsoleTab::drawFilterInput()
{
    using namespace material;
    // Reserve room for EVERY trailing same-line control drawn AFTER this filter on the toolbar
    // row: the two icon toggles (accent-fill + text-color) and the two zoom buttons, plus the
    // group spacers between them. Otherwise the filter eats the row and the trailing controls
    // run off-window (worst at 1024px / font_scale 1.5). All four are GetFrameHeight() wide.
    float trailingBtnSpace = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f;
    float filterAvail  = ImGui::GetContentRegionAvail().x - trailingBtnSpace;
    float filterMaxW = schema::UI().drawElement("tabs.console", "filter-max-width").size * Layout::dpiScale();
    float filterW = std::min(filterMaxW, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size);
    // Never shrink below the placeholder — otherwise the hint clips to "Filter outp" (or the box
    // vanishes) at narrow widths. Floor = placeholder text + frame padding + a little breathing room.
    // (drawToolbar() drops the "NNN lines" count when even this floor won't fit alongside the row's
    // trailing controls, so this max() doesn't push the zoom/color buttons off-window.)
    float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x
                     + ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale();
    filterW = std::max(filterMinW, filterW);
    ImGui::SetNextItemWidth(filterW);
    ImGui::InputTextWithHint("##ConsoleFilter", TR("console_filter_hint"), filter_text_, sizeof(filter_text_));
    if (filter_text_[0] != '\0') {
        ImGui::SameLine(0, Layout::spacingSm());
        std::string mc = std::to_string(filter_match_count_) + " " + TR("console_matches");
        Type().textColored(TypeStyle::Caption,
                           filter_match_count_ > 0 ? OnSurfaceMedium() : Error(), mc.c_str());
    }
}

void ConsoleTab::drawZoomControls()
{
    using namespace material;
    auto& S = schema::UI();
    float zoomStep = S.drawElement("tabs.console", "zoom-step").sizeOr(0.1f);
    float zoomMin  = S.drawElement("tabs.console", "zoom-min").sizeOr(0.5f);
    float zoomMax  = S.drawElement("tabs.console", "zoom-max").sizeOr(3.0f);
    float btnSz    = ImGui::GetFrameHeight();

    if (TactileButton(ICON_MD_REMOVE, ImVec2(btnSz, btnSz), Type().iconMed())) {
        s_console_zoom = std::max(zoomMin, s_console_zoom - zoomStep);
    }
    if (ImGui::IsItemHovered()) {
        material::Tooltip(TR("console_zoom_out"), s_console_zoom * 100.0f);
    }
    ImGui::SameLine();
    if (TactileButton(ICON_MD_ADD, ImVec2(btnSz, btnSz), Type().iconMed())) {
        s_console_zoom = std::min(zoomMax, s_console_zoom + zoomStep);
    }
    if (ImGui::IsItemHovered()) {
        material::Tooltip(TR("console_zoom_in"), s_console_zoom * 100.0f);
    }
}

namespace {
// Extract a hash/address-like token (a long alphanumeric run, >= 16 chars) around byte
// offset `col` in `text`, for the right-click "Copy value" action. Returns "" if none.
std::string extractCopyableToken(const std::string& text, int col)
{
    const int n = static_cast<int>(text.size());
    auto isTok = [](unsigned char c) { return std::isalnum(c) != 0; };
    int at = col;
    if (at >= n || (at >= 0 && !isTok(static_cast<unsigned char>(text[at])))) at--;
    if (at < 0 || at >= n || !isTok(static_cast<unsigned char>(text[at]))) return {};
    int s = at, e = at;
    while (s > 0 && isTok(static_cast<unsigned char>(text[s - 1]))) s--;
    while (e + 1 < n && isTok(static_cast<unsigned char>(text[e + 1]))) e++;
    std::string tok = text.substr(s, e - s + 1);
    return tok.size() >= 16 ? tok : std::string();  // txids/blockhashes/addresses are long
}

// Production ConsoleTextMeasure backed by the active ImGui font (used for word-wrap +
// per-character positioning). The pure layout module is measured against this in prod and
// a fixed-width stub in tests.
struct ImFontConsoleMeasure : ConsoleTextMeasure {
    ImFont* font;
    float   fontSize;
    ImFontConsoleMeasure(ImFont* f, float s) : font(f), fontSize(s) {}
    float width(const char* begin, const char* end) const override {
        return font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, begin, end).x;
    }
    const char* wrapPosition(const char* begin, const char* end, float wrapWidth) const override {
        return font->CalcWordWrapPositionA(fontSize / font->LegacySize, begin, end, wrapWidth);
    }
};
} // namespace

void ConsoleTab::renderOutput()
{
    using namespace material;
    auto& S = schema::UI();
    // No lock: render() already drained the ingest queue this frame, and the visible model
    // (model_) plus all selection/scroll state are touched only on this (main) thread.

    // Zero item spacing so Dummy items advance the cursor by exactly their
    // height.  The inter-line gap is added explicitly to layout_.heights
    // so that layout_.cumulativeY stays perfectly in sync with actual
    // cursor positions (avoiding selection-offset drift).
    // Raw logical px from the schema; scale it so the inter-line gap grows at font_scale 1.5
    // (it is added to the already-DPI-scaled GetTextLineHeight in BuildConsoleLayout — scale the
    // gap only, never the line height).
    float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f) * Layout::dpiScale();
    ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
    
    // Inner padding for glass panel
    float padX = Layout::spacingMd();
    float padY = Layout::spacingSm();
    ImGui::SetCursorPosY(ImGui::GetCursorPosY() + padY);
    ImGui::Indent(padX);
    
    float line_height = ImGui::GetTextLineHeight();
    output_line_height_ = line_height;  // store for scanline alignment
    output_origin_ = ImGui::GetCursorScreenPos();
    output_scroll_y_ = ImGui::GetScrollY();
    scanline_rows_.clear();
    
    // visible_indices_ / has_text_filter_ / filter_lower_ were already computed once at the top of
    // render() (before the toolbar). screenToTextPos maps through visible_indices_, so it's ready.

    // Calculate wrapped heights AND build sub-row segments for each visible line. Each
    // segment records which bytes of the source text appear on that visual row, so
    // hit-testing and selection highlight can map screen positions to exact char offsets.
    float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
    // Memoize the text-shaping pass: rebuild only when the visible set, wrap width, line height, gap or
    // zoom changed. Otherwise this re-wrapped and re-measured every visible line (glyph-by-glyph) every
    // frame, even while idle/scrolled. layout_ is a member, so the cached geometry stays valid for
    // drawVisibleLines / screenToTextPos when we skip the rebuild.
    std::uint64_t layoutKey = vis_generation_ * 1000003ull;
    layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(wrap_width   * 16.0f);
    layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(line_height  * 16.0f);
    layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(interLineGap * 16.0f);
    layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(s_console_zoom * 1000.0f);
    if (layoutKey != layout_key_) {
        ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
        layout_ = BuildConsoleLayout(
            static_cast<int>(visible_indices_.size()),
            [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
            wrap_width, line_height, interLineGap, measure);
        layout_key_ = layoutKey;
    }

    // Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses
    // the child window's event consumption.
    ImVec2 mouse_pos = ImGui::GetIO().MousePos;
    ImVec2 win_min = ImGui::GetWindowPos();
    ImVec2 win_max = ImVec2(win_min.x + ImGui::GetWindowWidth(),
                             win_min.y + ImGui::GetWindowHeight());
    bool mouse_in_output = (mouse_pos.x >= win_min.x && mouse_pos.x < win_max.x &&
                            mouse_pos.y >= win_min.y && mouse_pos.y < win_max.y &&
                            !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopup) &&
                            // The RPC reference is a BeginOverlayDialog (not an ImGui popup), so it
                            // isn't caught above; suppress the output's text cursor + selection under it.
                            !show_commands_popup_);
    handleOutputInteraction(mouse_pos, mouse_in_output);

    // Draw the visible lines: accent bars, JSON indent guides, selection + filter highlight,
    // scanline capture, and the text itself.
    drawVisibleLines(padX, line_height, has_text_filter_, filter_lower_);

    ImGui::Unindent(padX);
    ImGui::PopStyleVar();

    // Bottom padding keeps the last line above the fade-out zone.
    // Always present so that scrollMaxY stays stable when auto-scroll
    // toggles — otherwise the geometry shift clamps the user back to
    // bottom and a single scroll-up tick can't escape.
    {
        float fadeZone = std::min(Type().mono()->LegacySize * 3.0f,
                                  ImGui::GetWindowHeight() * 0.18f);
        ImGui::Dummy(ImVec2(0, fadeZone));
    }

    // Auto-scroll - when enabled, always scroll to bottom of content
    // This ensures daemon output stays visible and scrolled to bottom
    if (scroll_.autoScroll()) {
        ImGui::SetScrollHereY(1.0f);
        scroll_.resetNewLines();
    }

    // Filter indicator (text filter only — daemon toggle is already visible in toolbar)
    if (has_text_filter_) {
        char filterBuf[128];
        snprintf(filterBuf, sizeof(filterBuf), TR("console_showing_lines"),
                 static_cast<int>(visible_indices_.size()), model_.size());
        ImVec2 indicatorPos = ImGui::GetCursorScreenPos();
        ImGui::GetWindowDrawList()->AddText(indicatorPos,
            WithAlpha(Warning(), 180), filterBuf);
        ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight()));
    }
    
    // Capture the hash/address under the cursor on right-click, for "Copy value".
    if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
        ConsoleTextPos tp = screenToTextPos(ImGui::GetMousePos());
        context_token_.clear();
        if (tp.line >= 0 && tp.line < static_cast<int>(model_.size()))
            context_token_ = extractCopyableToken(model_[tp.line].text, tp.col);
    }

    drawOutputContextMenu();
    drawNewOutputIndicator();
}

// ── renderOutput sub-steps ───────────────────────────────────────────────────

void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
{
    // Memoize: rebuild the visible set only when the model changed or the filter state changed.
    // Otherwise this scanned the entire (up to 10k-line) model with a per-line filter predicate every
    // frame. The out-params + filter_match_count_/folding_active_ are members that stay valid until the
    // key moves, so an early return leaves last frame's (still-correct) results in place.
    std::uint64_t visKey = model_.revision() * 1000003ull;
    for (const char* p = filter_text_; *p; ++p) visKey = visKey * 131ull + static_cast<unsigned char>(*p);
    visKey = visKey * 2ull + (s_daemon_messages_enabled ? 1u : 0u);
    visKey = visKey * 2ull + (s_errors_only_enabled     ? 1u : 0u);
    visKey = visKey * 2ull + (s_rpc_trace_enabled       ? 1u : 0u);
    visKey = visKey * 2ull + (s_app_messages_enabled    ? 1u : 0u);
    if (visKey == vis_key_) return;   // nothing that affects the visible set changed
    vis_key_ = visKey;
    ++vis_generation_;                // the layout pass keys off this

    ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
                                     s_errors_only_enabled, s_rpc_trace_enabled,
                                     s_app_messages_enabled};
    hasTextFilter = !outputFilter.text.empty();
    // Lowercased needle for in-line match highlighting.
    if (hasTextFilter) {
        filterLower = std::string(filter_text_);
        std::transform(filterLower.begin(), filterLower.end(), filterLower.begin(),
                       [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
    }
    bool has_filter = hasTextFilter || !outputFilter.daemonMessagesEnabled ||
                      !outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled ||
                      outputFilter.errorsOnly;
    // Folding is only honored in the unfiltered view; the filtered view is flat. Record it so the
    // fold triangles aren't drawn/clickable while filtering (else clicks would silently mutate the
    // collapsed flag with no visible effect and the glyph would disagree with the rendered block).
    folding_active_ = !has_filter;
    visible_indices_.clear();
    const int n = static_cast<int>(model_.size());
    if (has_filter) {
        // Filtered view is flat — folding is bypassed so every match is reachable.
        for (int i = 0; i < n; i++) {
            if (!consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter)) continue;
            visible_indices_.push_back(i);
        }
    } else {
        // No filter — show all lines, but hide the interior of any collapsed JSON block
        // (its opener stays visible; the opener + foldSpan lines through the closer are skipped).
        for (int i = 0; i < n; ) {
            visible_indices_.push_back(i);
            const ConsoleModelLine& line = model_[i];
            if (line.foldSpan > 0 && line.collapsed) i += line.foldSpan + 1;  // skip inner + closer
            else i++;
        }
    }
    filter_match_count_ = hasTextFilter ? static_cast<int>(visible_indices_.size()) : 0;
}

void ConsoleTab::handleOutputInteraction(ImVec2 mousePos, bool mouseInOutput)
{
    ImGuiIO& io = ImGui::GetIO();

    // Disable auto-scroll when the user scrolls up (wheel). Scrolling back down to the very
    // bottom re-enables it; that position check happens after EndChild() in render(), and is
    // skipped on the wheel-up frame (the scroll position hasn't caught up yet).
    if (mouseInOutput && io.MouseWheel > 0.0f) {
        scroll_.onUserScrolledUp();
    }
    // Text-selection cursor while hovering.
    if (mouseInOutput) {
        ImGui::SetMouseCursor(ImGuiMouseCursor_TextInput);
    }
    // Selection drag lifecycle (continues even if the mouse leaves the window). Ignore clicks in the
    // left gutter (< output_origin_.x) — that strip holds the accent bar + fold triangles, so a fold
    // toggle there shouldn't begin (and thus clear) a text selection.
    if (mouseInOutput && io.MouseClicked[0] && mousePos.x >= output_origin_.x) {
        selection_.beginDrag(screenToTextPos(mousePos));
    }
    if (selection_.dragging() && io.MouseDown[0]) {
        selection_.updateDrag(screenToTextPos(mousePos));
    }
    if (selection_.dragging() && io.MouseReleased[0]) {
        selection_.endDrag(screenToTextPos(mousePos));
    }
    // Ctrl+C / Ctrl+A
    if (mouseInOutput || selection_.active()) {
        if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) {
            std::string selected = selectedText();
            if (!selected.empty()) ImGui::SetClipboardText(selected.c_str());
        }
        if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A) && !model_.empty()) {
            selection_.selectAll(static_cast<int>(model_.size()),
                                 static_cast<int>(model_.back().text.size()));
        }
    }
}

void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilter,
                                  const std::string& filterLower)
{
    using namespace material;
    ImFont* font = ImGui::GetFont();
    float fontSize = ImGui::GetFontSize();
    const int visible_count = static_cast<int>(visible_indices_.size());

    // Ordered selection bounds (only consulted when selection_.active()).
    ConsoleTextPos sel_start_pos = selection_.rangeStart();
    ConsoleTextPos sel_end_pos = selection_.rangeEnd();

    // Cull to the visible viewport rows for cheap scrolling over large buffers.
    float scroll_y = ImGui::GetScrollY();
    float window_height = ImGui::GetWindowHeight();
    float visible_top = scroll_y;
    float visible_bottom = scroll_y + window_height;

    // Find first visible line using binary search.
    int first_visible = 0;
    if (!layout_.cumulativeY.empty()) {
        int lo = 0, hi = static_cast<int>(layout_.cumulativeY.size()) - 1;
        while (lo < hi) {
            int mid = (lo + hi) / 2;
            float line_bottom = layout_.cumulativeY[mid] + layout_.heights[mid];
            if (line_bottom < visible_top) lo = mid + 1;
            else hi = mid;
        }
        first_visible = lo;
    }

    // Invisible spacer for lines before the first visible one (keeps scroll correct).
    if (first_visible > 0 && first_visible < static_cast<int>(layout_.cumulativeY.size())) {
        ImGui::Dummy(ImVec2(0, layout_.cumulativeY[first_visible]));
    }

    ImDrawList* dl = ImGui::GetWindowDrawList();
    ImU32 selColor = WithAlpha(Secondary(), 80);

    // A fold triangle clicked this frame; applied after the loop so we don't mutate the model
    // mid-draw. -1 = none.
    int pendingFoldToggle = -1;

    // CRT scanline parity is a true per-visual-row counter (each drawn sub-row = one row).
    // Deriving it from cumulativeY/lineHeight drifted (cumulativeY includes the inter-line
    // gap) and skipped rows, breaking the alternation. Seed it with the sub-row ordinal of
    // the first on-screen line so the shading stays stable across scrolling.
    int scanRowOrdinal = 0;
    for (int k = 0; k < first_visible && k < static_cast<int>(layout_.segments.size()); k++)
        scanRowOrdinal += static_cast<int>(layout_.segments[k].size());

    int last_rendered_vi = first_visible - 1;
    for (int vi = first_visible; vi < visible_count; vi++) {
        if (vi < static_cast<int>(layout_.cumulativeY.size()) &&
            layout_.cumulativeY[vi] > visible_bottom) {
            break;
        }
        last_rendered_vi = vi;

        int i = visible_indices_[vi];
        const auto& line = model_[i];
        const auto& segs = layout_.segments[vi];
        ImVec2 lineOrigin = ImGui::GetCursorScreenPos();
        float totalH = layout_.heights[vi];

        // Left-edge channel accent bar, drawn in the padX margin so it never overlaps
        // the text or the selection highlight. Same color that tints the toolbar toggle.
        // Suppressed when the toolbar's color-accent toggle is off (cleaner monochrome gutter).
        if (s_line_accents_enabled && line.channel != ConsoleChannel::None) {
            ImU32 barCol = channelAccentColor(line.channel);
            if (barCol != 0) {
                float barW = 3.0f * Layout::hScale();
                float barX = output_origin_.x - padX + 3.0f * Layout::hScale();
                dl->AddRectFilled(ImVec2(barX, lineOrigin.y + 1.0f),
                                  ImVec2(barX + barW, lineOrigin.y + totalH - 1.0f),
                                  barCol, barW * 0.5f);
            }
        }

        // JSON indent guides — faint vertical lines per 2-space nesting level, on result
        // body lines only (plain result + JSON syntax roles). Draw-only, like the bar.
        if (isResultBodyChannel(line.channel)) {
            size_t leading = 0;
            while (leading < line.text.size() && line.text[leading] == ' ') ++leading;
            if (leading >= 2) {
                float spaceW = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, " ").x;
                for (size_t c = 2; c < leading; c += 2) {
                    float gx = lineOrigin.x + static_cast<float>(c) * spaceW;
                    dl->AddLine(ImVec2(gx, lineOrigin.y), ImVec2(gx, lineOrigin.y + totalH),
                                IM_COL32(255, 255, 255, 20), 1.0f * Layout::dpiScale());
                }
            }
        }

        // JSON fold toggle — a small triangle in the left gutter for block-opener lines.
        // Openers carry no accent bar (result/JSON channels), so the gutter is free. Only shown in the
        // unfiltered view, where folding actually applies (see folding_active_ in computeVisibleLines).
        if (folding_active_ && line.foldSpan > 0) {
            // Size from the (DPI/density-scaled) gutter width, not the zoomed font, and center it in
            // the gutter band [origin-padX, origin) so the glyph and its clickable cell stay aligned.
            float sz = std::min(fontSize * 0.30f, padX * 0.34f);
            float cx = output_origin_.x - padX * 0.5f;
            float cy = lineOrigin.y + lineHeight * 0.5f;
            ImU32 triCol = WithAlpha(OnSurfaceMedium(), 210);
            if (line.collapsed) {
                dl->AddTriangleFilled(ImVec2(cx - sz * 0.5f, cy - sz), ImVec2(cx - sz * 0.5f, cy + sz),
                                      ImVec2(cx + sz * 0.7f, cy), triCol);           // ▶ collapsed
            } else {
                dl->AddTriangleFilled(ImVec2(cx - sz, cy - sz * 0.5f), ImVec2(cx + sz, cy - sz * 0.5f),
                                      ImVec2(cx, cy + sz * 0.7f), triCol);           // ▼ expanded
            }
            // Click anywhere in the gutter cell for this line's first row toggles the fold.
            // Guard against a click that is actually dismissing the ConsoleContextMenu (or any
            // popup) — the same popup guard the text-selection path uses (see mouse_in_output).
            ImVec2 mp = ImGui::GetIO().MousePos;
            if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
                !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopup) &&
                mp.x >= output_origin_.x - padX && mp.x < output_origin_.x &&
                mp.y >= lineOrigin.y && mp.y < lineOrigin.y + lineHeight) {
                pendingFoldToggle = i;
            }
        }

        // Determine byte-level selection range for this line
        int selByteStart = 0, selByteEnd = 0;
        bool lineSelected = false;
        if (selection_.active() && i >= sel_start_pos.line && i <= sel_end_pos.line) {
            lineSelected = true;
            selByteStart = (i == sel_start_pos.line) ? sel_start_pos.col : 0;
            selByteEnd   = (i == sel_end_pos.line)   ? sel_end_pos.col
                                                     : static_cast<int>(line.text.size());
        }

        for (const auto& seg : segs) {
            float rowY = lineOrigin.y + seg.yOffset;
            const char* segStart = line.text.c_str() + seg.byteStart;
            const char* segEnd   = line.text.c_str() + seg.byteEnd;

            if (s_scanline_enabled && lineHeight > 0.0f) {
                scanline_rows_.push_back({rowY, rowY + seg.height, scanRowOrdinal});
            }
            ++scanRowOrdinal;  // one per visual sub-row, so parity always alternates

            // Selection highlight for this sub-row
            if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) {
                int hlStart = std::max(selByteStart, seg.byteStart) - seg.byteStart;
                int hlEnd   = std::min(selByteEnd,   seg.byteEnd)   - seg.byteStart;
                int segLen  = seg.byteEnd - seg.byteStart;

                float xStart = 0.0f;
                if (hlStart > 0) {
                    xStart = font->CalcTextSizeA(fontSize, FLT_MAX, 0,
                                                  segStart, segStart + hlStart).x;
                }
                float xEnd = font->CalcTextSizeA(fontSize, FLT_MAX, 0,
                                                  segStart, segStart + hlEnd).x;
                // Extend to window edge when selection reaches end of segment
                if (hlEnd >= segLen && selByteEnd >= static_cast<int>(line.text.size())) {
                    xEnd = std::max(xEnd + 8.0f, ImGui::GetWindowWidth());
                }

                dl->AddRectFilled(
                    ImVec2(lineOrigin.x + xStart, rowY),
                    ImVec2(lineOrigin.x + xEnd,   rowY + seg.height),
                    selColor);
            }

            // Filter match highlight (case-insensitive) within this segment.
            if (hasTextFilter && !filterLower.empty() && seg.byteStart < seg.byteEnd) {
                std::string segLower(segStart, segEnd);
                std::transform(segLower.begin(), segLower.end(), segLower.begin(),
                               [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
                size_t pos = 0;
                while ((pos = segLower.find(filterLower, pos)) != std::string::npos) {
                    float xs = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + static_cast<int>(pos)).x;
                    float xe = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart,
                                                   segStart + static_cast<int>(pos + filterLower.size())).x;
                    dl->AddRectFilled(ImVec2(lineOrigin.x + xs, rowY),
                                      ImVec2(lineOrigin.x + xe, rowY + seg.height),
                                      IM_COL32(255, 210, 0, 70));
                    pos += filterLower.size();
                }
            }

            // Render text segment
            if (seg.byteStart < seg.byteEnd) {
                dl->AddText(font, fontSize,
                            ImVec2(lineOrigin.x, rowY),
                            channelTextColor(line.channel), segStart, segEnd);
            }
        }

        // Collapsed JSON block: append a dim " ... }" summary after the opener's text so the
        // fold reads as e.g.  "outputs": [ ... ]. Anchor it on the opener's LAST visual row
        // and that row's measured width so it stays put even if the opener wraps.
        if (line.foldSpan > 0 && line.collapsed && !segs.empty()) {
            char closeCh = '}';
            for (size_t k = line.text.size(); k-- > 0; ) {
                if (line.text[k] != ' ' && line.text[k] != '\t') {
                    closeCh = (line.text[k] == '[') ? ']' : '}';
                    break;
                }
            }
            const auto& lastSeg = segs.back();
            const char* lsStart = line.text.c_str() + lastSeg.byteStart;
            const char* lsEnd   = line.text.c_str() + lastSeg.byteEnd;
            float lastRowW = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, lsStart, lsEnd).x;
            char summary[8];
            snprintf(summary, sizeof(summary), " ... %c", closeCh);
            dl->AddText(font, fontSize,
                        ImVec2(lineOrigin.x + lastRowW, lineOrigin.y + lastSeg.yOffset),
                        WithAlpha(OnSurfaceDisabled(), 220), summary);
        }

        // Advance ImGui cursor by the total wrapped height of this line
        ImGui::Dummy(ImVec2(0, totalH));
    }

    // Apply a fold toggle requested this frame (takes visible effect next frame).
    if (pendingFoldToggle >= 0) model_.toggleCollapsed(static_cast<std::size_t>(pendingFoldToggle));

    // Add spacer for lines after last visible (to maintain correct content height)
    if (last_rendered_vi >= 0 && last_rendered_vi < visible_count - 1) {
        float rendered_height = (last_rendered_vi < static_cast<int>(layout_.cumulativeY.size()))
            ? layout_.cumulativeY[last_rendered_vi] + layout_.heights[last_rendered_vi]
            : 0.0f;
        float remaining_height = layout_.totalHeight - rendered_height;
        if (remaining_height > 0) {
            ImGui::Dummy(ImVec2(0, remaining_height));
        }
    }
}

void ConsoleTab::drawOutputContextMenu()
{
    if (!ImGui::BeginPopupContextWindow("ConsoleContextMenu")) return;

    if (!context_token_.empty()) {
        std::string shortTok = context_token_.size() > 22
            ? context_token_.substr(0, 12) + "\xE2\x80\xA6" + context_token_.substr(context_token_.size() - 6)
            : context_token_;
        std::string label = std::string(TR("console_copy_value")) + " \"" + shortTok + "\"";
        if (ImGui::MenuItem(label.c_str())) {
            ImGui::SetClipboardText(context_token_.c_str());
        }
        ImGui::Separator();
    }
    if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, selection_.active())) {
        std::string selected = selectedText();
        if (!selected.empty()) {
            ImGui::SetClipboardText(selected.c_str());
        }
    }
    if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) {
        if (!model_.empty()) {
            selection_.selectAll(static_cast<int>(model_.size()),
                                 static_cast<int>(model_.back().text.size()));
        }
    }
    ImGui::Separator();
    if (ImGui::MenuItem(TR("console_clear_console"))) {
        // View-only clear — route through clear() so the stale visible_indices_/selection are dropped too
        // (a bare model_.clear() mid-frame would leave renderOutput() indexing the emptied model_ → crash).
        clear();
    }
    ImGui::EndPopup();
}

void ConsoleTab::drawNewOutputIndicator()
{
    using namespace material;
    // "New output" indicator when the user is scrolled up and new lines arrived.
    if (scroll_.autoScroll() || scroll_.newLines() <= 0) return;

    // The box geometry is hand-drawn absolute px, so scale it by dpiScale() (the font metrics below
    // are already scaled — left alone). Without this the pill renders native-size on a HiDPI display.
    float dp = Layout::dpiScale();
    float indicW = 140.0f * dp;
    float indicH = 24.0f * dp;
    ImDrawList* dlInd = ImGui::GetWindowDrawList();
    ImVec2 wMin = ImGui::GetWindowPos();
    ImVec2 wSize = ImGui::GetWindowSize();
    float ix = wMin.x + (wSize.x - indicW) * 0.5f;
    float iy = wMin.y + wSize.y - indicH - 8.0f * dp;
    ImVec2 iMin(ix, iy);
    ImVec2 iMax(ix + indicW, iy + indicH);

    dlInd->AddRectFilled(iMin, iMax, IM_COL32(40, 40, 40, 220), 12.0f * dp);
    dlInd->AddRect(iMin, iMax, IM_COL32(255, 218, 0, 120), 12.0f * dp, 0, 1.0f * dp);

    char buf[48];
    snprintf(buf, sizeof(buf), TR("console_new_lines"), scroll_.newLines());
    ImFont* capFont = Type().caption();
    if (!capFont) capFont = ImGui::GetFont();
    ImFont* icoFont = Type().iconSmall();
    if (!icoFont) icoFont = capFont;

    // Measure icon + text to center them together
    ImVec2 icoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, ICON_MD_ARROW_DOWNWARD);
    ImVec2 txtSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
    float totalW = icoSz.x + txtSz.x;
    float startX = ix + (indicW - totalW) * 0.5f;
    float icoY = iy + (indicH - icoSz.y) * 0.5f;
    float txtY = iy + (indicH - txtSz.y) * 0.5f;
    ImU32 col = IM_COL32(255, 218, 0, 255);
    dlInd->AddText(icoFont, icoFont->LegacySize, ImVec2(startX, icoY), col, ICON_MD_ARROW_DOWNWARD);
    dlInd->AddText(capFont, capFont->LegacySize, ImVec2(startX + icoSz.x, txtY), col, buf);

    // Click to jump to bottom
    ImGui::SetCursorScreenPos(iMin);
    if (ImGui::InvisibleButton("##scrollToBottom", ImVec2(indicW, indicH))) {
        scroll_.jumpToBottom();
    }
}

ConsoleTextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const
{
    if (visible_indices_.empty()) return {0, 0};

    ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
    ConsoleHit hit = HitTestConsoleLayout(
        layout_, static_cast<int>(visible_indices_.size()),
        [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
        screen_pos.x - output_origin_.x, screen_pos.y - output_origin_.y, measure);

    ConsoleTextPos pos;
    pos.line = visible_indices_[hit.visibleRow];
    pos.col = hit.col;
    return pos;
}

std::string ConsoleTab::selectedText() const
{
    return selection_.extract(
        static_cast<int>(model_.size()),
        [this](int i) -> const std::string& { return model_[i].text; },
        visible_indices_);
}

void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
{
    using namespace material;
    
    // Glass panel for input area
    ImDrawList* dlIn = ImGui::GetWindowDrawList();
    float inputPanelH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingSm() + Layout::spacingXs();
    ImVec2 inMin = ImGui::GetCursorScreenPos();
    ImVec2 inMax(inMin.x + ImGui::GetContentRegionAvail().x, inMin.y + inputPanelH);
    // No light glass rectangle on the input — a flat terminal-dark bar instead, matching
    // the darkened output panel above.
    {
        int darkA = static_cast<int>(schema::UI().drawElement("tabs.console", "bg-darken-alpha").sizeOr(110.0f));
        const bool barLight = IsLightTheme();
        // Match the output panel: near-white terminal surface on light skins, dark on dark, with a
        // 1px glass-rim outline along the bar's own edges (so the outline hugs the input, not inset).
        dlIn->AddRectFilled(inMin, inMax, barLight ? IM_COL32(255, 255, 255, 205) : IM_COL32(0, 0, 0, darkA),
                            Layout::glassRounding());
        dlIn->AddRect(inMin, inMax, barLight ? IM_COL32(0, 0, 0, 45) : IM_COL32(255, 255, 255, 35),
                      Layout::glassRounding(), 0, 1.0f);
    }
    
    // Center content vertically within glass panel
    float inputFrameH = ImGui::GetFrameHeight();
    ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (inputPanelH - inputFrameH) * 0.5f);
    ImGui::SetCursorPosX(ImGui::GetCursorPosX() + Layout::spacingMd());



    // Input field — monospace to match the terminal-style output.
    ImGui::PushFont(Type().mono());
    ImGui::PushItemWidth(-Layout::spacingMd());

    ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue |
                                 ImGuiInputTextFlags_CallbackHistory |
                                 ImGuiInputTextFlags_CallbackCompletion;
    
    bool reclaim_focus = false;
    
    auto callback = [](ImGuiInputTextCallbackData* data) -> int {
        ConsoleTab* console = static_cast<ConsoleTab*>(data->UserData);
        
        if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) {
            // Handle history navigation
            if (console->command_history_.empty()) return 0;
            
            int prev_index = console->history_index_;
            console->history_index_ = NavigateConsoleHistoryIndex(
                console->history_index_,
                console->command_history_.size(),
                data->EventKey == ImGuiKey_UpArrow);
            
            if (prev_index != console->history_index_) {
                std::string history = ConsoleHistoryEntry(console->command_history_, console->history_index_);
                data->DeleteChars(0, data->BufTextLen);
                data->InsertChars(0, history.c_str());
            }
        }
        else if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) {
            std::string input(data->Buf);
            if (!input.empty()) {
                auto completion = CompleteConsoleCommand(input);
                
                if (completion.matches.size() == 1) {
                    // Single match — complete it
                    data->DeleteChars(0, data->BufTextLen);
                    data->InsertChars(0, completion.matches.front().c_str());
                } else if (completion.matches.size() > 1) {
                    // Multiple matches — show list in console and complete common prefix
                    console->addLine(TR("console_completions"), ConsoleChannel::Info);
                    for (const auto& line : FormatConsoleCompletionLines(completion.matches)) {
                        console->addLine(line, ConsoleChannel::None);
                    }
                    
                    // Complete to longest common prefix
                    if (completion.commonPrefix.length() > input.length()) {
                        data->DeleteChars(0, data->BufTextLen);
                        data->InsertChars(0, completion.commonPrefix.c_str());
                    }
                }
            }
        }
        
        return 0;
    };
    
    // Transparent frame with a subtle hover/active tint (the outline lives on the terminal bar above,
    // hugging the input's edges). Colors are theme-aware so they read on the light-skin console too.
    const bool inputLight = material::IsLightTheme();
    ImGui::PushStyleColor(ImGuiCol_FrameBg,        IM_COL32(0, 0, 0, 0));
    ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, inputLight ? IM_COL32(0, 0, 0, 10) : IM_COL32(255, 255, 255, 12));
    ImGui::PushStyleColor(ImGuiCol_FrameBgActive,  inputLight ? IM_COL32(0, 0, 0, 16) : IM_COL32(255, 255, 255, 18));

    const bool busy = exec.busy();
    if (busy) ImGui::BeginDisabled();
    if (ImGui::InputText("##ConsoleInput", input_buffer_, sizeof(input_buffer_), flags, callback, this)) {
        std::string cmd(input_buffer_);
        // Trim surrounding whitespace.
        size_t tb = cmd.find_first_not_of(" \t");
        if (tb == std::string::npos) cmd.clear(); else cmd = cmd.substr(tb);
        while (!cmd.empty() && (cmd.back() == ' ' || cmd.back() == '\t')) cmd.pop_back();

        if (submitConsoleCommand(exec, cmd)) {
            input_buffer_[0] = '\0';
            reclaim_focus = true;
        }
    }
    if (busy) ImGui::EndDisabled();

    // Blinking terminal caret at the end of the current input. Shown when the field isn't actively
    // being edited (ImGui draws its own caret while focused), so the console always reads as a live
    // prompt. Monospace font => caret X = frame-padding + charWidth * length.
    if (!busy && !ImGui::IsItemActive()) {
        if (std::fmod(ImGui::GetTime(), 1.06) < 0.53) {
            ImFont* mf = Type().mono();
            float charW = mf->CalcTextSizeA(mf->LegacySize, FLT_MAX, 0, "M").x;
            ImVec2 itMin = ImGui::GetItemRectMin();
            ImVec2 itMax = ImGui::GetItemRectMax();
            float caretW = std::max(2.0f, 2.0f * Layout::dpiScale());
            float caretX = itMin.x + ImGui::GetStyle().FramePadding.x + charW * (float)strlen(input_buffer_);
            caretX = std::min(caretX, itMax.x - ImGui::GetStyle().FramePadding.x - caretW);
            float pad = (itMax.y - itMin.y) * 0.18f;
            ImU32 caretCol = inputLight ? IM_COL32(0, 0, 0, 210) : IM_COL32(255, 255, 255, 210);
            dlIn->AddRectFilled(ImVec2(caretX, itMin.y + pad), ImVec2(caretX + caretW, itMax.y - pad), caretCol);
        }
    }

    ImGui::PopStyleColor(3);

    ImGui::PopItemWidth();
    ImGui::PopFont();

    // Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened
    // (focus_input_pending_, set by requestInputFocus() and gated on the console_auto_focus setting).
    // Skip while a command is running: SetKeyboardFocusHere can't focus the disabled field anyway.
    if ((reclaim_focus || focus_input_pending_) && !busy) {
        ImGui::SetKeyboardFocusHere(-1);
    }
    focus_input_pending_ = false;
}

bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
{
    if (cmd.empty()) return false;

    // Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
    // log and the recall history. The real `cmd` below is still executed unredacted.
    const std::string display = RedactConsoleCommand(cmd);
    addLine("> " + display, ConsoleChannel::Command);
    AppendConsoleHistory(command_history_, display, 100);
    history_index_ = -1;

    // First token, lowercased, for built-in interception.
    std::string first;
    {
        size_t fb = cmd.find_first_not_of(" \t");
        size_t fe = cmd.find_first_of(" \t", fb);
        first = cmd.substr(fb, fe == std::string::npos ? std::string::npos : fe - fb);
        std::transform(first.begin(), first.end(), first.begin(),
                       [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
    }
    // 'stop' shuts down the node — require a confirming second 'stop'; any other command clears the
    // pending state. Member (not a function-local static) so clear() can also cancel it — otherwise a
    // toolbar/context-menu clear between the two 'stop's would leave a stale arm and skip the warning.
    if (first != "stop") stop_confirm_pending_ = false;
    auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); };
    if (first == "clear" || first == "cls") {
        // View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
        clear();
        selection_.clear();
    } else if (first == "help") {
        exec.printHelp(add);
    } else if (first == "quit" || first == "exit") {
        addLine(TR("console_quit_note"), ConsoleChannel::Info);
    } else if (first == "stop" && exec.hasRpcReference()) {
        // Full-node 'stop' shuts down the daemon (destructive) — gate behind a confirming second
        // 'stop'. Lite has no node: `stop` isn't a backend verb, so it falls through below and the
        // backend reports it as unknown — no misleading "shut down the node" warning or dead gate.
        if (!stop_confirm_pending_) {
            stop_confirm_pending_ = true;
            addLine(TR("console_stop_confirm_node"), ConsoleChannel::Warning);
        } else {
            stop_confirm_pending_ = false;
            if (!exec.isReady()) addLine(TR("console_not_connected"), ConsoleChannel::Error);
            else                 exec.submit(cmd);
        }
    } else if (!exec.isReady()) {
        // Full node connects to a daemon; the lite backend opens a wallet — word the error per variant.
        addLine(exec.hasRpcReference() ? TR("console_not_connected") : TR("console_not_connected_lite"),
                ConsoleChannel::Error);
    } else {
        exec.submit(cmd);
    }
    return true;
}

namespace {
// True if any of a command's name/desc/params contains `filterLower` (already lowercased).
// An empty filter matches everything.
static bool lcContains(const char* s, const std::string& needleLower)
{
    std::string v(s);
    std::transform(v.begin(), v.end(), v.begin(), ::tolower);
    return v.find(needleLower) != std::string::npos;
}
static bool lcStartsWith(const char* s, const std::string& needleLower)
{
    std::string v(s);
    std::transform(v.begin(), v.end(), v.begin(), ::tolower);
    return v.rfind(needleLower, 0) == 0;
}

// Search relevance of a command for a lowercased query. -1 = no match; higher = better. Keywords
// let novices find a command by intent ("balance" -> getbalance) even without the exact name.
int consoleCommandRank(const ConsoleCommandEntry& cmd, const std::string& q)
{
    if (q.empty()) return 0;
    if (lcStartsWith(cmd.name, q))                     return 100;
    if (lcContains(cmd.name, q))                       return 60;
    if (cmd.keywords[0] && lcContains(cmd.keywords, q)) return 40;
    if (lcContains(cmd.desc, q))                       return 25;
    if (cmd.details[0] && lcContains(cmd.details, q))  return 15;
    if (lcContains(cmd.params, q))                     return 10;
    return -1;
}

// Split a parameter template into top-level tokens, respecting quote/bracket nesting so a space
// inside "..." or [{...}] doesn't split (e.g. `"address" [{"a":1}]` -> {`"address"`, `[{"a":1}]`}).
std::vector<std::string> splitParamTemplate(const char* params)
{
    std::vector<std::string> out;
    std::string tok;
    int depth = 0;
    char q = 0;
    for (const char* p = params; *p; ++p) {
        char c = *p;
        if (q) { tok += c; if (c == q) q = 0; continue; }
        if (c == '"' || c == '\'') { q = c; tok += c; continue; }
        if (c == '[' || c == '{') { depth++; tok += c; continue; }
        if (c == ']' || c == '}') { if (depth > 0) depth--; tok += c; continue; }
        if (c == ' ' && depth == 0) { if (!tok.empty()) { out.push_back(tok); tok.clear(); } continue; }
        tok += c;
    }
    if (!tok.empty()) out.push_back(tok);
    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
// per-command descriptions stay in English (technical RPC docs); only the 7 category labels are i18n'd.
const char* consoleCategoryLabel(const char* name)
{
    if (!std::strcmp(name, "Control"))          return TR("console_cat_control");
    if (!std::strcmp(name, "Network"))          return TR("console_cat_network");
    if (!std::strcmp(name, "Blockchain"))       return TR("console_cat_blockchain");
    if (!std::strcmp(name, "Mining"))           return TR("console_cat_mining");
    if (!std::strcmp(name, "Wallet"))           return TR("console_cat_wallet");
    if (!std::strcmp(name, "Raw Transactions")) return TR("console_cat_raw_transactions");
    if (!std::strcmp(name, "Utility"))          return TR("console_cat_utility");
    // Lite backend reference categories.
    if (!std::strcmp(name, "Sync"))             return TR("console_cat_sync");
    if (!std::strcmp(name, "Send"))             return TR("console_cat_send");
    if (!std::strcmp(name, "Keys & Security"))  return TR("console_cat_keys");
    if (!std::strcmp(name, "Advanced"))         return TR("console_cat_advanced");
    return name;
}
} // namespace

void ConsoleTab::insertCommandToInput(const ConsoleCommandEntry& cmd)
{
    // Fill the console input with the command (+ its param template) and close the modal — the user
    // reviews/edits it and presses Enter to run.
    if (cmd.params[0] != '\0')
        snprintf(input_buffer_, sizeof(input_buffer_), "%s %s", cmd.name, cmd.params);
    else {
        strncpy(input_buffer_, cmd.name, sizeof(input_buffer_) - 1);
        input_buffer_[sizeof(input_buffer_) - 1] = '\0';
    }
    command_search_[0] = '\0';
    run_confirm_cmd_ = nullptr;
    show_commands_popup_ = false;
}

void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char* catLabel, bool jsonArgs)
{
    using namespace material;
    float dp = Layout::dpiScale();
    ImFont* mono = Type().mono();

    // Heading: command name, then category + a safety badge for consequential commands.
    ImGui::PushFont(Type().subtitle1());
    ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", cmd.name);
    ImGui::PopFont();
    ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", catLabel);
    if (cmd.destructive) {
        ImGui::SameLine(0, Layout::spacingMd());
        ImVec4 warn = ImGui::ColorConvertU32ToFloat4(Warning());
        ImGui::PushFont(Type().iconSmall());
        ImGui::TextColored(warn, ICON_MD_WARNING);
        ImGui::PopFont();
        ImGui::SameLine(0, Layout::spacingXs());
        ImGui::TextColored(warn, "%s", TR("console_ref_destructive"));
    }
    ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

    // Plain-language explanation (falls back to the terse summary when not enriched).
    ImGui::PushTextWrapPos(0.0f);
    ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurface()), "%s",
                       cmd.details[0] ? cmd.details : cmd.desc);
    ImGui::PopTextWrapPos();
    ImGui::Dummy(ImVec2(0, Layout::spacingMd()));

    // 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"));
    if (specs.empty()) {
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()), "%s", TR("console_ref_no_params"));
    } else {
        float labelW = 110.0f * dp;
        ImGui::Indent(Layout::spacingSm());
        for (size_t k = 0; k < specs.size() && k < 6; k++) {
            const ConsoleParamSpec& s = specs[k];
            ImGui::PushID((int)k);
            ImGui::PushFont(mono);
            ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(s.optional ? OnSurfaceMedium() : OnSurface()),
                               "%s", s.label.c_str());
            ImGui::PopFont();
            ImGui::SameLine(labelW);
            ImGui::SetNextItemWidth(-1);
            // Lite backend args are bare freeform tokens, so its param types (string/number) don't
            // apply — show a neutral "value" hint there instead of a misleading "number".
            std::string typeHint = jsonArgs ? s.type : std::string(TR("console_ref_value"));
            std::string hint = s.optional
                ? (typeHint + " \xC2\xB7 " + std::string(TR("console_ref_optional"))) : typeHint;
            ImGui::InputTextWithHint("##pv", hint.c_str(), cmd_param_bufs_[k], sizeof(cmd_param_bufs_[k]));
            ImGui::PopID();
        }
        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);
    };
    // Include fields up to the last one that is filled OR required; trailing empty optionals are
    // dropped. An empty field that must still be included (a blank required field, or a gap before a
    // later filled field — positional args can't skip a middle slot) keeps its placeholder and marks
    // the command incomplete, so Insert & run stays disabled and no typed value is silently lost.
    int lastNeeded = -1;
    for (size_t k = 0; k < specs.size() && k < 6; k++)
        if (!trimStr(cmd_param_bufs_[k]).empty() || !specs[k].optional) lastNeeded = (int)k;
    bool complete = specs.size() <= 6;
    std::string built = cmd.name;
    for (int k = 0; k <= lastNeeded; k++) {
        std::string val = trimStr(cmd_param_bufs_[k]);
        if (val.empty()) {
            built += " " + specs[k].raw;
            complete = false;
        } else {
            // JSON-RPC (full node) auto-quotes string args; the lite backend takes bare tokens, so
            // leave the value exactly as typed there (quoting would break its address/key parsing).
            if (jsonArgs && 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(FloorLight(IM_COL32(150, 200, 150, 255))), "%s", built.c_str());
        ImGui::PopTextWrapPos();
        ImGui::PopFont();
    }
    ImGui::Dummy(ImVec2(0, Layout::spacingMd()));

    // Example (curated commands only) — reference alongside the builder.
    if (cmd.example[0]) {
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("console_ref_example"));
        ImGui::PushFont(mono);
        ImGui::PushTextWrapPos(0.0f);
        ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(FloorLight(IM_COL32(150, 200, 150, 255))), "%s", cmd.example);
        ImGui::PopTextWrapPos();
        ImGui::PopFont();
        ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
    }

    // Actions (or the destructive run confirmation) — Insert/run use the assembled command.
    ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
    if (run_confirm_cmd_ == &cmd) {
        ImVec4 warn = ImGui::ColorConvertU32ToFloat4(Warning());
        ImGui::PushFont(Type().iconSmall());
        ImGui::TextColored(warn, ICON_MD_WARNING);
        ImGui::PopFont();
        ImGui::SameLine(0, Layout::spacingXs());
        ImGui::TextColored(warn, TR("console_ref_run_confirm"), cmd.name);
        ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
        float cbw = 120.0f * dp;
        if (material::TactileButton(TR("console_ref_cancel"), ImVec2(cbw, 0))) run_confirm_cmd_ = nullptr;
        ImGui::SameLine();
        if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
            pending_submit_ = built;
            command_search_[0] = '\0';
            run_confirm_cmd_ = nullptr;
            show_commands_popup_ = false;
        }
    } else {
        if (material::TactileButton(TR("console_ref_insert"), ImVec2(200.0f * dp, 0))) {
            snprintf(input_buffer_, sizeof(input_buffer_), "%s", built.c_str());
            command_search_[0] = '\0';
            run_confirm_cmd_ = nullptr;
            show_commands_popup_ = false;
        }
        // Insert & run: enabled once every required field is filled (a template with unfilled
        // placeholders would just error).
        ImGui::SameLine();
        ImGui::BeginDisabled(!complete);
        if (material::TactileButton(TR("console_ref_insert_run"), ImVec2(140.0f * dp, 0))) {
            if (cmd.destructive) {
                run_confirm_cmd_ = &cmd;
            } else {
                pending_submit_ = built;
                command_search_[0] = '\0';
                show_commands_popup_ = false;
            }
        }
        ImGui::EndDisabled();
    }
}

void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec)
{
    using namespace material;
    float dp = Layout::dpiScale();

    // Full node speaks JSON-RPC (quote string args); the lite backend takes bare tokens.
    const bool jsonArgs = exec.hasRpcReference();

    material::OverlayDialogSpec ov;
    ov.title      = jsonArgs ? TR("console_rpc_reference") : TR("console_backend_reference");
    ov.p_open     = &show_commands_popup_;
    ov.style      = material::OverlayStyle::BlurFloat;   // floating content on the blur, plain heading
    ov.cardWidth  = 960.0f;                              // wide enough for two panes
    ov.cardHeight = ImGui::GetMainViewport()->Size.y * 0.74f / dp;   // fixed; both panes fill it
    ov.idSuffix   = "cmdref";
    if (!material::BeginOverlayDialog(ov)) return;

    // Esc dismisses (a first Esc cancels a pending run-confirm). Not built into the overlay.
    if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
        if (run_confirm_cmd_) run_confirm_cmd_ = nullptr;
        else show_commands_popup_ = false;
    }

    // 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);
    bool searchEnter = ImGui::InputTextWithHint("##CmdSearch", TR("console_ref_search_hint"),
                                                command_search_, sizeof(command_search_),
                                                ImGuiInputTextFlags_EnterReturnsTrue);
    bool searchChanged = ImGui::IsItemEdited();
    bool searchActive = ImGui::IsItemActive();
    ImGui::Dummy(ImVec2(0, Layout::spacingXs()));

    std::string q(command_search_);
    std::transform(q.begin(), q.end(), q.begin(), ::tolower);
    const bool searching = !q.empty();

    const auto& categories = *exec.commandReference();  // non-null: guarded by renderCommandsPopupModal

    // Flat display order of (cat,idx): ranked when searching, category order when browsing. Drives
    // keyboard nav + auto-selection; the browse view still renders grouped headers below.
    std::vector<std::pair<int, int>> order;
    if (searching) {
        std::vector<std::pair<int, std::pair<int, int>>> scored;  // (score, (cat,idx))
        for (int c = 0; c < (int)categories.size(); c++)
            for (int i = 0; i < categories[c].count; i++) {
                int s = consoleCommandRank(categories[c].commands[i], q);
                if (s >= 0) scored.push_back({s, {c, i}});
            }
        std::stable_sort(scored.begin(), scored.end(),
                         [](const auto& a, const auto& b) { return a.first > b.first; });
        for (auto& s : scored) order.push_back(s.second);
    } else {
        for (int c = 0; c < (int)categories.size(); c++)
            for (int i = 0; i < categories[c].count; i++) order.push_back({c, i});
    }

    // Keep a valid selection: reset to the top when the query changes or the current selection falls
    // out of the visible set, so the detail pane is always populated.
    int selPos = -1;
    for (int k = 0; k < (int)order.size(); k++)
        if (order[k].first == cmd_sel_cat_ && order[k].second == cmd_sel_idx_) { selPos = k; break; }
    if (searchChanged || selPos < 0) {
        if (!order.empty()) { cmd_sel_cat_ = order[0].first; cmd_sel_idx_ = order[0].second; selPos = 0; }
        else { cmd_sel_cat_ = cmd_sel_idx_ = -1; }
    }

    // Keyboard nav from the SEARCH box only (so typing in a param field doesn't move the selection):
    // 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++;
        else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && selPos > 0) selPos--;
        cmd_sel_cat_ = order[selPos].first;
        cmd_sel_idx_ = order[selPos].second;
    }
    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.
    float footerH  = ImGui::GetFrameHeightWithSpacing() + Layout::spacingXs();
    float bodyH    = std::max(120.0f, ImGui::GetContentRegionAvail().y - footerH);
    float contentW = ImGui::GetContentRegionAvail().x;
    float gap      = Layout::spacingLg();
    float masterW  = std::min(std::max(contentW * 0.34f, 280.0f * dp), 380.0f * dp);
    float detailW  = contentW - masterW - gap;

    ImFont* mono   = Type().mono();
    // The panes are theme-adaptive glass (light on light skins), so floor the link-blue to a readable
    // contrast on light surfaces (FloorLight is a no-op on dark themes).
    ImU32   nameCol = FloorLight(IM_COL32(100, 180, 255, 255));
    float   rowH   = std::max(20.0f * dp, mono->LegacySize + 6.0f * dp);

    // One master row: mono command name + a warning dot for consequential commands, with a soft
    // rounded selection/hover fill (Material, not the default sharp Selectable highlight). Click
    // selects (drives the detail pane); double-click inserts.
    auto drawRow = [&](int c, int i) {
        const ConsoleCommandEntry& cmd = categories[c].commands[i];
        bool sel = (cmd_sel_cat_ == c && cmd_sel_idx_ == i);
        ImGui::PushID(c * 1000 + i);
        ImVec2 rmn = ImGui::GetCursorScreenPos();
        ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0));         // we draw our own fill
        ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0, 0, 0, 0));
        ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0, 0, 0, 0));
        bool clicked = ImGui::Selectable("##cmdrow", sel, ImGuiSelectableFlags_SpanAvailWidth,
                                         ImVec2(0, rowH));
        ImGui::PopStyleColor(3);
        bool hov = ImGui::IsItemHovered();
        if (clicked) { cmd_sel_cat_ = c; cmd_sel_idx_ = i; }
        if (hov) {
            ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
            if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) insertCommandToInput(cmd);
        }
        ImDrawList* dl = ImGui::GetWindowDrawList();
        float rowW = ImGui::GetItemRectSize().x;
        if (sel || hov)
            dl->AddRectFilled(ImVec2(rmn.x, rmn.y + 1.0f * dp),
                              ImVec2(rmn.x + rowW, rmn.y + rowH - 1.0f * dp),
                              sel ? WithAlpha(Primary(), 52) : WithAlpha(OnSurface(), 16), 6.0f * dp);
        float tx = rmn.x + Layout::spacingSm();
        if (cmd.destructive) {
            float r = 3.0f * dp;
            dl->AddCircleFilled(ImVec2(tx + r, rmn.y + rowH * 0.5f), r, Warning());
            tx += r * 2.0f + Layout::spacingXs();
        }
        dl->AddText(mono, mono->LegacySize, ImVec2(tx, rmn.y + (rowH - mono->LegacySize) * 0.5f),
                    sel ? OnSurface() : nameCol, cmd.name);
        ImGui::PopID();
    };

    // Both panes sit on soft Material glass surfaces (no hard 1px child border) with inner padding.
    GlassPanelSpec paneGlass;
    paneGlass.rounding = 14.0f * dp;
    paneGlass.fillAlpha = 30;
    paneGlass.borderAlpha = 30;

    // MASTER pane.
    {
        ImVec2 mMin = ImGui::GetCursorScreenPos();
        DrawGlassPanel(ImGui::GetWindowDrawList(), mMin, ImVec2(mMin.x + masterW, mMin.y + bodyH), paneGlass);
    }
    ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingMd(), Layout::spacingSm()));
    ImGui::BeginChild("##cmdMaster", ImVec2(masterW, bodyH), ImGuiChildFlags_AlwaysUseWindowPadding);
    {
        if (order.empty()) {
            Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("console_ref_no_match"));
        } else if (searching) {
            for (auto& p : order) drawRow(p.first, p.second);
        } else {
            for (int c = 0; c < (int)categories.size(); c++) {
                // Subtle accent-labelled section header (no heavy filled bar).
                ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0));
                ImGui::PushStyleColor(ImGuiCol_HeaderHovered,
                    ImGui::ColorConvertU32ToFloat4(WithAlpha(OnSurface(), 14)));
                ImGui::PushStyleColor(ImGuiCol_HeaderActive,
                    ImGui::ColorConvertU32ToFloat4(WithAlpha(OnSurface(), 20)));
                ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Primary()));
                bool open = ImGui::CollapsingHeader(consoleCategoryLabel(categories[c].name),
                                                    ImGuiTreeNodeFlags_DefaultOpen);
                ImGui::PopStyleColor(4);
                if (open)
                    for (int i = 0; i < categories[c].count; i++) drawRow(c, i);
            }
        }
    }
    ImGui::EndChild();
    ImGui::PopStyleVar();

    ImGui::SameLine(0, gap);

    // DETAIL pane.
    {
        ImVec2 dMin = ImGui::GetCursorScreenPos();
        DrawGlassPanel(ImGui::GetWindowDrawList(), dMin, ImVec2(dMin.x + detailW, dMin.y + bodyH), paneGlass);
    }
    ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingLg(), Layout::spacingMd()));
    ImGui::BeginChild("##cmdDetail", ImVec2(detailW, bodyH), ImGuiChildFlags_AlwaysUseWindowPadding);
    if (cmd_sel_cat_ >= 0 && cmd_sel_cat_ < (int)categories.size() &&
        cmd_sel_idx_ >= 0 && cmd_sel_idx_ < categories[cmd_sel_cat_].count) {
        renderCommandDetail(categories[cmd_sel_cat_].commands[cmd_sel_idx_],
                            consoleCategoryLabel(categories[cmd_sel_cat_].name), jsonArgs);
    } else {
        ImVec2 av = ImGui::GetContentRegionAvail();
        ImGui::SetCursorPosY(av.y * 0.4f);
        Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("console_ref_select_hint"));
    }
    ImGui::EndChild();
    ImGui::PopStyleVar();

    // Footer.
    ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
    if (material::TactileButton(TR("console_close"), ImVec2(-1, 0))) {
        command_search_[0] = '\0';
        run_confirm_cmd_ = nullptr;
        show_commands_popup_ = false;
    }

    material::EndOverlayDialog();
}

void ConsoleTab::addFormattedResult(const std::string& result, bool is_error)
{
    const std::vector<ConsoleResultLine> resultLines = FormatConsoleRpcResultLines(result, is_error);

    // Precompute JSON fold spans over the whole block so each opener line carries the offset
    // to its matching closer (the spans are relative, so they stay valid as the model's line
    // cap evicts from the front). This block is ingested atomically on the main thread.
    std::vector<std::string> texts;
    texts.reserve(resultLines.size());
    for (const auto& rl : resultLines) texts.push_back(rl.text);
    const std::vector<int> foldSpans = ComputeConsoleFoldSpans(texts);

    for (std::size_t i = 0; i < resultLines.size(); ++i) {
        ConsoleChannel channel = ConsoleChannel::None;
        switch (resultLines[i].role) {
            case ConsoleResultLineRole::Error:      channel = ConsoleChannel::Error; break;
            case ConsoleResultLineRole::JsonKey:    channel = ConsoleChannel::JsonKey; break;
            case ConsoleResultLineRole::JsonString: channel = ConsoleChannel::JsonString; break;
            case ConsoleResultLineRole::JsonNumber: channel = ConsoleChannel::JsonNumber; break;
            case ConsoleResultLineRole::JsonBrace:  channel = ConsoleChannel::JsonBrace; break;
            case ConsoleResultLineRole::Result: break;
        }
        model_.ingest(resultLines[i].text, channel, foldSpans[i]);
    }
}

void ConsoleTab::renderStatusHeader(ConsoleCommandExecutor& exec)
{
    using namespace material;
    auto lines = exec.statusLines();
    for (const auto& sl : lines)
        Type().textColored(TypeStyle::Caption, sl.color, sl.text.c_str());
    if (!lines.empty()) ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}

void ConsoleTab::addLine(const std::string& line, ConsoleChannel channel)
{
    // The channel is the canonical semantic key — text color and the left accent bar are
    // both derived from it at draw time (channelTextColor / the bar switch). Sources emit
    // clean text (no "[daemon]"/"[xmrig]"/"[app]"/"[rpc]" prefix); the bar carries origin.
    //
    // ingest() is thread-safe, so this is callable from the RPC-trace / app-logger worker
    // threads. The line becomes visible on the next render() drain (main thread), where the
    // line-cap eviction and selection/scroll bookkeeping happen — none of the UI-only state
    // is touched here, so there is no cross-thread access to it.
    model_.ingest(line, channel);
}

void ConsoleTab::drainModel()
{
    const ConsoleModel::DrainResult dr = model_.drain();
    if (dr.added == 0) return;

    // Lines evicted from the front by the cap shift every index down — move the selection
    // with them so the highlight stays on the text the user selected.
    selection_.shiftForEviction(static_cast<int>(dr.popped));

    // Track new output that arrived while the user is scrolled up (for the "N new lines" indicator).
    // Count only the newly-added lines that pass the active filter, so the indicator isn't inflated
    // by lines the current filter / errors-only view hides — the user wouldn't see those on jumping
    // to the bottom. (The new lines are the last dr.added entries; eviction is from the front.)
    ConsoleOutputFilter f{filter_text_, s_daemon_messages_enabled, s_errors_only_enabled,
                          s_rpc_trace_enabled, s_app_messages_enabled};
    const int n = static_cast<int>(model_.size());
    int visibleAdded = 0;
    for (int i = std::max(0, n - static_cast<int>(dr.added)); i < n; i++)
        if (consoleLinePassesFilter(model_[i].text, model_[i].channel, f)) ++visibleAdded;
    scroll_.onLinesAdded(visibleAdded);
}

void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method)
{
    // The Rpc channel supplies the accent bar + color; no "[rpc] " text prefix needed.
    addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
}

void ConsoleTab::clear()
{
    // View-only clear (main thread). The executor keeps its own log cursors, so new output
    // still appends. The "cleared" line is ingested and appears on the next frame's drain.
    model_.clear();
    // visible_indices_ was computed at the top of render() (line 311), BEFORE the toolbar's Clear button
    // ran; those indices now point past the emptied model_. renderOutput() (this same frame, after the
    // toolbar) indexes model_[visible_indices_[vi]] — so drop them (and the selection, which also holds
    // line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
    visible_indices_.clear();
    selection_.clear();
    // Force both memoized passes to rebuild: renderOutput() runs later THIS frame against the now-empty
    // visible_indices_, and computeVisibleLines() recomputes next frame — without these resets the layout
    // memo would skip the rebuild and keep stale geometry for the just-emptied set.
    vis_key_ = ~0ull;
    layout_key_ = ~0ull;
    ++vis_generation_;
    stop_confirm_pending_ = false;   // a pending 'stop' confirmation is cancelled by clearing
    addLine(TR("console_cleared"), ConsoleChannel::Info);
}

} // namespace ui
} // namespace dragonx
