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

@@ -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();