From 245d9bf9761dede99116788e539bf0932e292f16 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 1 Jul 2026 20:04:59 -0500 Subject: [PATCH] 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) --- src/ui/windows/console_input_model.cpp | 37 ++++++++++++ src/ui/windows/console_input_model.h | 7 +++ src/ui/windows/console_model.cpp | 15 ++++- src/ui/windows/console_model.h | 14 ++++- src/ui/windows/console_tab.cpp | 81 ++++++++++++++++++++++++-- tests/test_phase4.cpp | 61 +++++++++++++++++++ 6 files changed, 205 insertions(+), 10 deletions(-) diff --git a/src/ui/windows/console_input_model.cpp b/src/ui/windows/console_input_model.cpp index ee9fc91..2d7d923 100644 --- a/src/ui/windows/console_input_model.cpp +++ b/src/ui/windows/console_input_model.cpp @@ -236,5 +236,42 @@ std::vector FormatConsoleRpcResultLines(const std::string& re return lines; } +std::vector ComputeConsoleFoldSpans(const std::vector& lines) +{ + std::vector 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(j - i); // >=1 inner line + break; + } + } + } + return spans; +} + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/console_input_model.h b/src/ui/windows/console_input_model.h index 62eb8cd..102a435 100644 --- a/src/ui/windows/console_input_model.h +++ b/src/ui/windows/console_input_model.h @@ -50,5 +50,12 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command); std::vector 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 ComputeConsoleFoldSpans(const std::vector& lines); + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/console_model.cpp b/src/ui/windows/console_model.cpp index 52555fd..c4de6dc 100644 --- a/src/ui/windows/console_model.cpp +++ b/src/ui/windows/console_model.cpp @@ -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 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 diff --git a/src/ui/windows/console_model.h b/src/ui/windows/console_model.h index 8e410a5..ba25497 100644 --- a/src/ui/windows/console_model.h +++ b/src/ui/windows/console_model.h @@ -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& lines() const { return lines_; } std::size_t size() const { return lines_.size(); } diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 957cfa1..56538ea 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -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(model_.size()); if (has_filter) { - for (int i = 0; i < static_cast(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(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(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(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(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(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 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 texts; + texts.reserve(resultLines.size()); + for (const auto& rl : resultLines) texts.push_back(rl.text); + const std::vector 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]); } } diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 77398a9..5efa322 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -2562,6 +2562,66 @@ void testConsoleModel() EXPECT_EQ(drained, static_cast(kThreads * kPerThread)); EXPECT_EQ(m.size(), static_cast(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 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(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 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 empty = { "{", "}" }; + EXPECT_EQ(ComputeConsoleFoldSpans(empty)[0], 0); + + // Plain non-JSON lines: no openers. + std::vector 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 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();