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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user