refactor(console): phase 2 — channel-as-semantic key; decouple executor from UI colors

Make ConsoleChannel the canonical semantic classification of every console
line. Producers (command executor, app log forwarders, result formatter) tag
each line with a channel; the UI derives both the text color and the left
accent-bar color from it at draw time, and the output filter keys off it.

This replaces the prior scheme of storing an ImU32 color per line and then
reverse-engineering the source from color-equality plus a "[daemon] "/"[xmrig] "
/"[app] "/"[rpc] " text prefix. Consequences:

- ConsoleLine now stores {text, ConsoleChannel} — no per-line color.
- addLine()/addRpcTraceLine() take a channel; the redundant text prefixes are
  gone (the accent bar carries the origin).
- The executor's ConsoleAddLineFn hands a channel, not an ImU32, so the backend
  no longer depends on ConsoleTab::COLOR_* — FullNode/Lite executors emit clean
  text + channel.
- The theme-remap loop that rewrote stored colors on light/dark flip is deleted;
  colors are resolved per-channel each frame (refreshColors on flip only).
- ConsoleOutputFilter drops its color fields; consoleLinePassesFilter() keys off
  the channel. Test updated to the channel-based API.
- Added a Warning channel (amber severity peer of Error/Success) so the
  notification + logger warning forwarders keep their color.

Full-node + lite build clean; ctest green; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:22:35 -05:00
parent 19e83eb12a
commit 33735b1d52
9 changed files with 219 additions and 175 deletions

View File

@@ -72,6 +72,22 @@ std::string rpcTraceTimestamp()
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
void ConsoleTab::refreshColors()
@@ -112,6 +128,29 @@ void ConsoleTab::refreshColors()
}
}
ImU32 ConsoleTab::channelTextColor(ConsoleChannel channel) const
{
using namespace material;
switch (channel) {
case ConsoleChannel::Command: return COLOR_COMMAND;
case ConsoleChannel::Info: return COLOR_INFO;
case ConsoleChannel::Success: return WithAlpha(Success(), 255);
case ConsoleChannel::Warning: return 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 WithAlpha(Secondary(), 255);
case ConsoleChannel::JsonString: return WithAlpha(Success(), 255);
case ConsoleChannel::JsonNumber: return WithAlpha(Warning(), 255);
case ConsoleChannel::JsonBrace: return IM_COL32(200, 200, 200, 150);
case ConsoleChannel::None:
default: return COLOR_RESULT;
}
}
ConsoleTab::ConsoleTab()
{
{
@@ -132,9 +171,9 @@ ConsoleTab::ConsoleTab()
refreshColors();
// Add welcome message
addLine(TR("console_welcome"), COLOR_INFO);
addLine(TR("console_type_help"), COLOR_INFO);
addLine("", COLOR_RESULT);
addLine(TR("console_welcome"), ConsoleChannel::Info);
addLine(TR("console_type_help"), ConsoleChannel::Info);
addLine("", ConsoleChannel::None);
}
ConsoleTab::~ConsoleTab()
@@ -149,35 +188,20 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
{
using namespace material;
// Refresh console colors when dark/light theme changes
// Refresh the console theme colors on a dark/light switch. Line colors are derived
// from each line's channel at draw time, so no per-line remap is needed.
{
static bool s_lastDark = IsDarkTheme();
bool nowDark = IsDarkTheme();
if (nowDark != s_lastDark) {
// Save old colors to remap existing lines
ImU32 oldCmd = COLOR_COMMAND, oldRes = COLOR_RESULT;
ImU32 oldErr = COLOR_ERROR, oldDmn = COLOR_DAEMON;
ImU32 oldInf = COLOR_INFO, oldRpc = COLOR_RPC;
refreshColors();
// Remap stored line colors from old to new
{
std::lock_guard<std::mutex> lock(lines_mutex_);
for (auto& line : lines_) {
if (line.color == oldCmd) line.color = COLOR_COMMAND;
else if (line.color == oldRes) line.color = COLOR_RESULT;
else if (line.color == oldErr) line.color = COLOR_ERROR;
else if (line.color == oldDmn) line.color = COLOR_DAEMON;
else if (line.color == oldInf) line.color = COLOR_INFO;
else if (line.color == oldRpc) line.color = COLOR_RPC;
}
}
s_lastDark = nowDark;
}
}
// 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, ImU32 c) { addLine(l, c); });
exec.pollLogLines([this](const std::string& l, ConsoleChannel c) { addLine(l, c); });
{
std::string result;
bool isError = false;
@@ -579,8 +603,7 @@ void ConsoleTab::renderOutput()
// Build filtered line index list BEFORE mouse handling (so screenToTextPos works)
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
s_errors_only_enabled, s_rpc_trace_enabled,
s_app_messages_enabled,
COLOR_DAEMON, COLOR_ERROR, COLOR_RPC};
s_app_messages_enabled};
bool has_text_filter = !outputFilter.text.empty();
// Lowercased needle for in-line match highlighting.
std::string filter_lower;
@@ -595,7 +618,7 @@ void ConsoleTab::renderOutput()
visible_indices_.clear();
if (has_filter) {
for (int i = 0; i < static_cast<int>(lines_.size()); i++) {
if (!consoleLinePassesFilter(lines_[i].text, lines_[i].color, outputFilter)) continue;
if (!consoleLinePassesFilter(lines_[i].text, lines_[i].channel, outputFilter)) continue;
visible_indices_.push_back(i);
}
} else {
@@ -745,17 +768,19 @@ void ConsoleTab::renderOutput()
// Left-edge channel accent bar, drawn in the padX margin so it never overlaps
// the text or the selection highlight.
if (line.channel != CH_NONE) {
if (line.channel != ConsoleChannel::None) {
ImU32 barCol = 0;
switch (line.channel) {
case CH_COMMAND: barCol = Primary(); break;
case CH_ERROR: barCol = Error(); break;
case CH_RPC: barCol = Secondary(); break;
case CH_DAEMON: barCol = IM_COL32(90, 130, 190, 210); break; // node log — blue
case CH_XMRIG: barCol = Warning(); break; // mining — amber
case CH_APP: barCol = IM_COL32(120, 190, 160, 210); break; // app — teal
case CH_INFO: barCol = OnSurfaceDisabled(); break;
default: break;
case ConsoleChannel::Command: barCol = Primary(); break;
case ConsoleChannel::Error: barCol = Error(); break;
case ConsoleChannel::Warning: barCol = Warning(); break;
case ConsoleChannel::Success: barCol = Success(); break;
case ConsoleChannel::Rpc: barCol = Secondary(); break;
case ConsoleChannel::Daemon: barCol = IM_COL32(90, 130, 190, 210); break; // node log — blue
case ConsoleChannel::Xmrig: barCol = Warning(); break; // mining — amber
case ConsoleChannel::App: barCol = IM_COL32(120, 190, 160, 210); break; // app — teal
case ConsoleChannel::Info: barCol = OnSurfaceDisabled(); break;
default: break; // result / JSON roles get no bar
}
if (barCol != 0) {
float barW = 3.0f * Layout::hScale();
@@ -767,8 +792,8 @@ void ConsoleTab::renderOutput()
}
// JSON indent guides — faint vertical lines per 2-space nesting level, on result
// (non-channel) lines only. Draw-only, like the channel bar.
if (line.channel == CH_NONE) {
// 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) {
@@ -846,7 +871,7 @@ void ConsoleTab::renderOutput()
if (seg.byteStart < seg.byteEnd) {
dl->AddText(font, fontSize,
ImVec2(lineOrigin.x, rowY),
line.color, segStart, segEnd);
channelTextColor(line.channel), segStart, segEnd);
}
}
@@ -1094,9 +1119,9 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
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"), ConsoleTab::COLOR_INFO);
console->addLine(TR("console_completions"), ConsoleChannel::Info);
for (const auto& line : FormatConsoleCompletionLines(completion.matches)) {
console->addLine(line, ConsoleTab::COLOR_RESULT);
console->addLine(line, ConsoleChannel::None);
}
// Complete to longest common prefix
@@ -1121,7 +1146,7 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
while (!cmd.empty() && (cmd.back() == ' ' || cmd.back() == '\t')) cmd.pop_back();
if (!cmd.empty()) {
addLine("> " + cmd, COLOR_COMMAND);
addLine("> " + cmd, ConsoleChannel::Command);
AppendConsoleHistory(command_history_, cmd, 100);
history_index_ = -1;
@@ -1134,7 +1159,7 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
std::transform(first.begin(), first.end(), first.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
}
auto add = [this](const std::string& l, ImU32 c) { addLine(l, c); };
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();
@@ -1142,9 +1167,9 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
} else if (first == "help") {
exec.printHelp(add);
} else if (first == "quit" || first == "exit") {
addLine(TR("console_quit_note"), COLOR_INFO);
addLine(TR("console_quit_note"), ConsoleChannel::Info);
} else if (!exec.isReady()) {
addLine(TR("console_not_connected"), COLOR_ERROR);
addLine(TR("console_not_connected"), ConsoleChannel::Error);
} else {
exec.submit(cmd);
}
@@ -1358,23 +1383,17 @@ void ConsoleTab::renderCommandsPopup()
void ConsoleTab::addFormattedResult(const std::string& result, bool is_error)
{
using namespace material;
ImU32 json_key_col = WithAlpha(Secondary(), 255);
ImU32 json_str_col = WithAlpha(Success(), 255);
ImU32 json_num_col = WithAlpha(Warning(), 255);
ImU32 json_brace_col = IM_COL32(200, 200, 200, 150);
for (const auto& resultLine : FormatConsoleRpcResultLines(result, is_error)) {
ImU32 lineCol = COLOR_RESULT;
ConsoleChannel channel = ConsoleChannel::None;
switch (resultLine.role) {
case ConsoleResultLineRole::Error: lineCol = COLOR_ERROR; break;
case ConsoleResultLineRole::JsonKey: lineCol = json_key_col; break;
case ConsoleResultLineRole::JsonString: lineCol = json_str_col; break;
case ConsoleResultLineRole::JsonNumber: lineCol = json_num_col; break;
case ConsoleResultLineRole::JsonBrace: lineCol = json_brace_col; break;
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;
}
addLine(resultLine.text, lineCol);
addLine(resultLine.text, channel);
}
}
@@ -1387,36 +1406,15 @@ void ConsoleTab::renderStatusHeader(ConsoleCommandExecutor& exec)
if (!lines.empty()) ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
void ConsoleTab::addLine(const std::string& line, ImU32 color)
void ConsoleTab::addLine(const std::string& line, ConsoleChannel channel)
{
std::lock_guard<std::mutex> lock(lines_mutex_);
// Infer the channel (for the left accent bar) from the color, then strip the now
// redundant "[daemon]"/"[xmrig]"/"[app]" text prefix so the bar carries the source.
int channel = CH_NONE;
if (color == COLOR_ERROR) channel = CH_ERROR;
else if (color == COLOR_RPC) channel = CH_RPC;
else if (color == COLOR_COMMAND) channel = CH_COMMAND;
else if (color == COLOR_DAEMON) channel = CH_DAEMON;
else if (color == COLOR_INFO) channel = CH_INFO;
// 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.
lines_.push_back({line, channel});
std::string text = line;
auto stripPrefix = [&text, &channel](const char* p, int ch) {
const size_t n = std::strlen(p);
if (text.rfind(p, 0) == 0) {
text.erase(0, n);
// Keep an error channel (red accent) for e.g. a "[daemon] error:" line so the
// bar matches the red text, rather than being downgraded to the source channel.
if (channel != CH_ERROR) channel = ch;
}
};
stripPrefix("[daemon] ", CH_DAEMON);
stripPrefix("[xmrig] ", CH_XMRIG);
stripPrefix("[app] ", CH_APP);
stripPrefix("[rpc] ", CH_RPC);
lines_.push_back({text, color, channel});
// Limit buffer size — adjust selection indices when lines are removed
// from the front so the highlight stays on the text the user selected.
int popped = 0;
@@ -1445,7 +1443,8 @@ void ConsoleTab::addLine(const std::string& line, ImU32 color)
void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method)
{
addLine("[rpc] [" + rpcTraceTimestamp() + "] [" + source + "] " + method, COLOR_RPC);
// The Rpc channel supplies the accent bar + color; no "[rpc] " text prefix needed.
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
}
void ConsoleTab::clear()
@@ -1456,7 +1455,7 @@ void ConsoleTab::clear()
}
// The executor keeps its own log cursors, so a view-clear stays cleared (new output
// still appends). addLine() takes the lock itself, so call it outside the locked scope.
addLine(TR("console_cleared"), COLOR_INFO);
addLine(TR("console_cleared"), ConsoleChannel::Info);
}
} // namespace ui