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

@@ -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]);
}
}