fix(console): keep JSON folding active under channel filters

JSON folding in the console was bypassed in any filtered view, including
when only a channel toggle (daemon, RPC or app) was active. A collapsed
block silently expanded the moment a channel was toggled.

- Folding is now kept active unless the view is text-filtered or
  errors-only, which are flat so that every match stays reachable.
  Channel toggles keep folding, since JSON blocks are atomic per channel.
- The fold triangles and the collapsed " ... }" summary are drawn only
  in the fold-aware view, so the glyph and the rendered block can never
  disagree.

User-visible: JSON results stay foldable while channel filters are
applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 08:23:30 -05:00
co-authored by Claude Opus 4.8
parent c1b289cc27
commit b8a4a11f90
+38 -18
View File
@@ -915,6 +915,26 @@ void ConsoleTab::renderOutput()
// ── renderOutput sub-steps ───────────────────────────────────────────────────
namespace {
// Folding applies unless the view is text-filtered or errors-only (flat views).
inline bool consoleFoldingActive(bool hasTextFilter, bool errorsOnly) { return !(hasTextFilter || errorsOnly); }
// Visible-index build for the fold-aware view. A collapsed opener that passes the filter hides its
// interior + closer (foldSpan lines); a hidden opener hides only itself (blocks are atomic per channel).
template <class SpanFn, class CollapsedFn, class PassFn>
void BuildConsoleVisibleIndices(std::vector<int>& out, int n, SpanFn span, CollapsedFn collapsed, PassFn pass)
{
out.clear();
for (int i = 0; i < n; ) {
if (!pass(i)) { ++i; continue; }
out.push_back(i);
const int fs = span(i);
if (fs > 0 && collapsed(i)) i += fs + 1;
else ++i;
}
}
} // namespace
void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
{
// Memoize: rebuild the visible set only when the model changed or the filter state changed.
@@ -944,10 +964,12 @@ void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLow
bool has_filter = hasTextFilter || !outputFilter.daemonMessagesEnabled ||
!outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled ||
outputFilter.errorsOnly;
// Folding is only honored in the unfiltered view; the filtered view is flat. Record it so the
// fold triangles aren't drawn/clickable while filtering (else clicks would silently mutate the
// Folding is honored unless the view is text-filtered or errors-only (those are flat so every match is
// reachable). Channel toggles alone keep folding: JSON blocks are atomic per channel. Record it so the
// fold triangles aren't drawn/clickable in the flat view (else clicks would silently mutate the
// collapsed flag with no visible effect and the glyph would disagree with the rendered block).
folding_active_ = !has_filter;
const bool folding = consoleFoldingActive(hasTextFilter, outputFilter.errorsOnly);
folding_active_ = folding;
const int n = static_cast<int>(model_.size());
const std::uint64_t seqFirst = model_.firstSeq();
const int flatFlags = (outputFilter.daemonMessagesEnabled ? 1 : 0) | (outputFilter.errorsOnly ? 2 : 0) |
@@ -955,31 +977,29 @@ void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLow
// Filtered (flat) view with the exact same filter as the last pass and a model that only appended/evicted
// since: drop evicted indices, rebase, test only the new lines. Identical to the full scan below (line text +
// channel are immutable; the flat view ignores folds) without its per-drain O(total chars) lowercase pass.
const bool extended = has_filter && vis_flat_valid_ && vis_flat_flags_ == flatFlags &&
const bool extended = !folding && vis_flat_valid_ && vis_flat_flags_ == flatFlags &&
vis_flat_filter_ == outputFilter.text &&
ExtendFlatVisibleIndices(visible_indices_, vis_flat_first_, vis_flat_end_, seqFirst, n,
[&](int i) { return consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter); });
if (extended) {
// visible_indices_ updated in place
} else if (has_filter) {
} else if (!folding) {
visible_indices_.clear();
// Filtered view is flat — folding is bypassed so every match is reachable.
// Flat view — 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 {
visible_indices_.clear();
// 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++;
}
// Fold-aware view: hide the interior of any collapsed JSON block (its opener stays visible; the
// opener + foldSpan lines through the closer are skipped). Channel toggles still apply.
BuildConsoleVisibleIndices(visible_indices_, n,
[&](int i) { return model_[i].foldSpan; },
[&](int i) { return model_[i].collapsed; },
[&](int i) { return !has_filter ||
consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter); });
}
vis_flat_valid_ = has_filter;
vis_flat_valid_ = !folding;
vis_flat_flags_ = flatFlags;
vis_flat_filter_ = outputFilter.text;
vis_flat_first_ = seqFirst;
@@ -1125,7 +1145,7 @@ 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. Only shown in the
// unfiltered view, where folding actually applies (see folding_active_ in computeVisibleLines).
// fold-aware view, where folding actually applies (see folding_active_ in computeVisibleLines).
if (folding_active_ && line.foldSpan > 0) {
// Size from the (DPI/density-scaled) gutter width, not the zoomed font, and center it in
// the gutter band [origin-padX, origin) so the glyph and its clickable cell stay aligned.
@@ -1231,7 +1251,7 @@ 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()) {
if (folding_active_ && 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') {