feat(console): collapsible JSON in command output

Add fold/unfold of JSON objects and arrays in console command results, built on
the refactored channel + model foundation.

- ComputeConsoleFoldSpans() (console_input_model): pure brace/bracket matcher
  over a block of pretty-printed JSON lines; each opener line gets the offset to
  its matching closer at the same indentation (empty/one-line blocks are not
  foldable). Unit tested (nested objects, arrays, empty blocks, non-JSON, and a
  string value that merely contains a brace).
- ConsoleModelLine gains foldSpan (relative, so it survives front-eviction by the
  line cap) + a collapsed flag; ingest() carries the span and toggleCollapsed()
  flips openers (main thread). addFormattedResult computes the block's spans and
  ingests them atomically.
- computeVisibleLines() skips a collapsed block's interior (opener stays, its
  foldSpan lines through the closer are hidden); folding is bypassed while a
  filter is active so every match stays reachable.
- drawVisibleLines() draws a fold triangle in the free left gutter of opener
  lines (result/JSON channels carry no accent bar there), toggled by a click in
  the gutter cell, and appends a dim " ... }" / " ... ]" summary on collapsed
  openers.

Full-node + lite build clean; ctest green (incl. fold-span + model-fold tests);
source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 20:04:59 -05:00
parent 1c248d727a
commit 245d9bf976
6 changed files with 205 additions and 10 deletions

View File

@@ -236,5 +236,42 @@ std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& re
return lines;
}
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
{
std::vector<int> spans(lines.size(), 0);
auto leadingSpaces = [](const std::string& s) -> std::size_t {
std::size_t n = 0;
while (n < s.size() && s[n] == ' ') ++n;
return n;
};
auto lastNonSpace = [](const std::string& s) -> char {
for (std::size_t i = s.size(); i-- > 0; )
if (s[i] != ' ' && s[i] != '\t') return s[i];
return '\0';
};
auto firstNonSpace = [](const std::string& s) -> char {
for (char c : s)
if (c != ' ' && c != '\t') return c;
return '\0';
};
for (std::size_t i = 0; i < lines.size(); ++i) {
const char last = lastNonSpace(lines[i]);
if (last != '{' && last != '[') continue; // not a block opener
const std::size_t indent = leadingSpaces(lines[i]);
const char wantClose = (last == '{') ? '}' : ']';
// Matching closer = the next line at the same indentation whose first non-space char
// is the corresponding closing bracket (pretty-printed JSON has aligned brackets).
for (std::size_t j = i + 1; j < lines.size(); ++j) {
if (leadingSpaces(lines[j]) == indent && firstNonSpace(lines[j]) == wantClose) {
if (j - i >= 2) spans[i] = static_cast<int>(j - i); // >=1 inner line
break;
}
}
}
return spans;
}
} // namespace ui
} // namespace dragonx

View File

@@ -50,5 +50,12 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
bool isError);
// For a block of pretty-printed JSON lines, return a per-line fold span: for a line that
// opens a collapsible block (its last non-space char is '{' or '[') the value is the offset
// to its matching closing-bracket line at the same indentation; 0 for every other line and
// for empty/one-line blocks not worth folding (span < 2). Pure; used to drive console
// JSON folding.
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines);
} // namespace ui
} // namespace dragonx

View File

@@ -9,10 +9,14 @@
namespace dragonx {
namespace ui {
void ConsoleModel::ingest(const std::string& text, ConsoleChannel channel)
void ConsoleModel::ingest(const std::string& text, ConsoleChannel channel, int foldSpan)
{
std::lock_guard<std::mutex> lock(ingest_mutex_);
pending_.push_back({text, channel});
ConsoleModelLine line;
line.text = text;
line.channel = channel;
line.foldSpan = foldSpan;
pending_.push_back(std::move(line));
}
ConsoleModel::DrainResult ConsoleModel::drain()
@@ -41,5 +45,12 @@ void ConsoleModel::clear()
lines_.clear();
}
bool ConsoleModel::toggleCollapsed(std::size_t i)
{
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
lines_[i].collapsed = !lines_[i].collapsed;
return lines_[i].collapsed;
}
} // namespace ui
} // namespace dragonx

View File

@@ -33,6 +33,11 @@ namespace ui {
struct ConsoleModelLine {
std::string text;
ConsoleChannel channel = ConsoleChannel::None;
// JSON folding: for a line that opens a collapsible block, `foldSpan` is the offset to
// its matching closing-bracket line (>= 2); 0 for non-openers. `collapsed` is the user's
// fold toggle (main-thread UI state).
int foldSpan = 0;
bool collapsed = false;
};
class ConsoleModel {
@@ -42,8 +47,9 @@ public:
explicit ConsoleModel(std::size_t maxLines = kDefaultMaxLines) : max_lines_(maxLines) {}
// Append a line from ANY thread. Cheap: locks only the pending queue, does not touch
// the visible deque. The line becomes visible after the next drain().
void ingest(const std::string& text, ConsoleChannel channel);
// the visible deque. The line becomes visible after the next drain(). `foldSpan` marks a
// JSON block opener (offset to its matching closer); 0 for ordinary lines.
void ingest(const std::string& text, ConsoleChannel channel, int foldSpan = 0);
struct DrainResult {
std::size_t added = 0; // lines appended to the visible deque this drain
@@ -59,6 +65,10 @@ public:
// new output still appears — matching the console's view-only `clear` semantics.
void clear();
// Main thread only. Toggle a foldable line's collapsed state (no-op if out of range or
// not a block opener). Returns the new collapsed state.
bool toggleCollapsed(std::size_t i);
// Visible-model access — main thread only (no lock; the deque is main-thread-owned).
const std::deque<ConsoleModelLine>& lines() const { return lines_; }
std::size_t size() const { return lines_.size(); }

View File

@@ -710,15 +710,22 @@ void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLow
!outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled ||
outputFilter.errorsOnly;
visible_indices_.clear();
const int n = static_cast<int>(model_.size());
if (has_filter) {
for (int i = 0; i < static_cast<int>(model_.size()); i++) {
// 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 - all lines are visible
for (int i = 0; i < static_cast<int>(model_.size()); i++)
// 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;
}
@@ -799,6 +806,10 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt
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;
int last_rendered_vi = first_visible - 1;
for (int vi = first_visible; vi < visible_count; vi++) {
if (vi < static_cast<int>(layout_.cumulativeY.size()) &&
@@ -853,6 +864,29 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt
}
}
// 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.
if (line.foldSpan > 0) {
float sz = fontSize * 0.30f;
float cx = output_origin_.x - padX + sz + 1.0f * Layout::hScale();
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.
ImVec2 mp = ImGui::GetIO().MousePos;
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
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;
@@ -922,10 +956,35 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt
}
}
// 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()))
@@ -1367,9 +1426,19 @@ void ConsoleTab::renderCommandsPopup()
void ConsoleTab::addFormattedResult(const std::string& result, bool is_error)
{
for (const auto& resultLine : FormatConsoleRpcResultLines(result, 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 (resultLine.role) {
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;
@@ -1377,7 +1446,7 @@ void ConsoleTab::addFormattedResult(const std::string& result, bool is_error)
case ConsoleResultLineRole::JsonBrace: channel = ConsoleChannel::JsonBrace; break;
case ConsoleResultLineRole::Result: break;
}
addLine(resultLine.text, channel);
model_.ingest(resultLines[i].text, channel, foldSpans[i]);
}
}

View File

@@ -2562,6 +2562,66 @@ void testConsoleModel()
EXPECT_EQ(drained, static_cast<size_t>(kThreads * kPerThread));
EXPECT_EQ(m.size(), static_cast<size_t>(kThreads * kPerThread));
}
// JSON fold state: ingest carries a fold span; toggleCollapsed flips only openers.
{
ConsoleModel m;
m.ingest("{", ConsoleChannel::JsonBrace, 3);
m.ingest(" \"a\": 1", ConsoleChannel::JsonKey, 0);
m.ingest("}", ConsoleChannel::JsonBrace, 0);
m.drain();
EXPECT_EQ(m[0].foldSpan, 3);
EXPECT_FALSE(m[0].collapsed);
EXPECT_TRUE(m.toggleCollapsed(0)); // opener -> collapsed
EXPECT_TRUE(m[0].collapsed);
EXPECT_FALSE(m.toggleCollapsed(0)); // -> expanded
EXPECT_FALSE(m[0].collapsed);
EXPECT_FALSE(m.toggleCollapsed(1)); // not an opener -> no-op
EXPECT_FALSE(m.toggleCollapsed(99)); // out of range -> no-op
}
}
// ComputeConsoleFoldSpans — brace/bracket matching over pretty-printed JSON lines.
void testConsoleFoldSpans()
{
using dragonx::ui::ComputeConsoleFoldSpans;
// Nested object: outer '{' spans the whole block; inner "obj": { spans its two-line body.
std::vector<std::string> obj = {
"{", // 0 -> closer at 6, span 6
" \"a\": 1,", // 1
" \"obj\": {", // 2 -> closer at 4, span 2
" \"b\": 2", // 3
" },", // 4 (closer for 2)
" \"c\": 3", // 5
"}" // 6 (closer for 0)
};
auto spans = ComputeConsoleFoldSpans(obj);
EXPECT_EQ(spans.size(), static_cast<size_t>(7));
EXPECT_EQ(spans[0], 6);
EXPECT_EQ(spans[1], 0);
EXPECT_EQ(spans[2], 2);
EXPECT_EQ(spans[3], 0);
EXPECT_EQ(spans[4], 0); // a closer is not an opener
EXPECT_EQ(spans[5], 0);
EXPECT_EQ(spans[6], 0);
// Array opener '['.
std::vector<std::string> arr = { "\"outputs\": [", " 1,", " 2", "]" };
EXPECT_EQ(ComputeConsoleFoldSpans(arr)[0], 3);
// Empty block (opener immediately followed by closer) is not worth folding (span < 2).
std::vector<std::string> empty = { "{", "}" };
EXPECT_EQ(ComputeConsoleFoldSpans(empty)[0], 0);
// Plain non-JSON lines: no openers.
std::vector<std::string> plain = { "hello", "world" };
EXPECT_EQ(ComputeConsoleFoldSpans(plain)[0], 0);
EXPECT_EQ(ComputeConsoleFoldSpans(plain)[1], 0);
// A string value merely containing a brace does not open a block (last char isn't '{').
std::vector<std::string> str = { "\"note\": \"a { brace\",", "\"x\": 1" };
EXPECT_EQ(ComputeConsoleFoldSpans(str)[0], 0);
}
void testRendererHelpers()
@@ -5251,6 +5311,7 @@ int main()
testDaemonLifecycleAdapters();
testConsoleTextLayout();
testConsoleModel();
testConsoleFoldSpans();
testConsoleScrollController();
testConsoleSelectionController();
testRendererHelpers();