From 70925363b66790661a4e381ca7fa01111df60dbf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Apr 2026 02:49:58 -0500 Subject: [PATCH] fix(tui): per-section overrides escape global details_mode: hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #14968 caught that the early returns gated on the global `detailsMode === 'hidden'` short-circuited every render path before sectionMode() got a chance to apply per-section overrides — so `details_mode: hidden` + `sections.tools: expanded` was silently a no-op. Three call sites had the same bug shape; all now key off the resolved section modes: - ToolTrail: replace the `detailsMode === 'hidden'` early return with an `allHidden = every section resolved to hidden` check. When that's true, fall back to the floating-alert backstop (errors/warnings) so quiet-mode users aren't blind to ambient failures, and update the comment block to match the actual condition. - messageLine.tsx: drop the same `detailsMode === 'hidden'` pre-check on `msg.kind === 'trail'`; only skip rendering the wrapper when every section resolves to hidden (`SECTION_NAMES.some(...) !== 'hidden'`). - useMainApp.ts: rebuild `showProgressArea` around `anyPanelVisible` instead of branching on the global mode. This also fixes the suppressed Copilot concern about an empty wrapper Box rendering above the streaming area when ToolTrail returns null. Regression test in details.test.ts pins the override-escapes-hidden behaviour for tools/thinking/activity. 271/271 vitest, lints clean. --- ui-tui/src/__tests__/details.test.ts | 9 +++++++++ ui-tui/src/app/slash/commands/core.ts | 10 ++++++---- ui-tui/src/app/useMainApp.ts | 18 ++++++++++++------ ui-tui/src/components/messageLine.tsx | 10 ++++++++-- ui-tui/src/components/thinking.tsx | 24 ++++++++++++++---------- ui-tui/src/domain/details.ts | 18 ++++++++---------- 6 files changed, 57 insertions(+), 32 deletions(-) diff --git a/ui-tui/src/__tests__/details.test.ts b/ui-tui/src/__tests__/details.test.ts index a2babd15d..56b82087e 100644 --- a/ui-tui/src/__tests__/details.test.ts +++ b/ui-tui/src/__tests__/details.test.ts @@ -90,4 +90,13 @@ describe('sectionMode', () => { expect(sectionMode('activity', 'expanded', { activity: 'collapsed' })).toBe('collapsed') expect(sectionMode('tools', 'collapsed', { tools: 'expanded' })).toBe('expanded') }) + + it('lets per-section overrides escape the global hidden mode', () => { + // Regression for the case where global details_mode: hidden used to + // short-circuit the entire accordion and prevent overrides from + // surfacing — `sections.tools: expanded` must still resolve to expanded. + expect(sectionMode('tools', 'hidden', { tools: 'expanded' })).toBe('expanded') + expect(sectionMode('thinking', 'hidden', { thinking: 'collapsed' })).toBe('collapsed') + expect(sectionMode('activity', 'hidden', { activity: 'expanded' })).toBe('expanded') + }) }) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 95a26bcc1..efea1c112 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,7 +1,7 @@ import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' -import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' +import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' import type { ConfigGetValueResponse, ConfigSetResponse, @@ -62,7 +62,10 @@ export const coreCommands: SlashCommand[] = [ { rows: [ ['/details [hidden|collapsed|expanded|cycle]', 'set global agent detail visibility mode'], - ['/details
[hidden|collapsed|expanded|reset]', 'override one section (thinking/tools/subagents/activity)'], + [ + '/details
[hidden|collapsed|expanded|reset]', + 'override one section (thinking/tools/subagents/activity)' + ], ['/fortune [random|daily]', 'show a random or daily local fortune'] ], title: 'TUI' @@ -159,8 +162,7 @@ export const coreCommands: SlashCommand[] = [ const mode = parseDetailsMode(r?.value) ?? ui.detailsMode patchUiState({ detailsMode: mode }) - const overrides = SECTION_NAMES - .filter(s => ui.sections[s]) + const overrides = SECTION_NAMES.filter(s => ui.sections[s]) .map(s => `${s}=${ui.sections[s]}`) .join(' ') diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 7b742478e..d2e5494a9 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' +import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { fmtCwdBranch, shortCwd } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' @@ -630,11 +631,15 @@ export function useMainApp(gw: GatewayClient) { const hasReasoning = Boolean(turn.reasoning.trim()) - const showProgressArea = - ui.detailsMode === 'hidden' - ? turn.activity.some(item => item.tone !== 'info') - : Boolean( - ui.busy || + // Per-section overrides win over the global mode — when every section is + // resolved to hidden, the only thing ToolTrail will surface is the + // floating-alert backstop (errors/warnings). Mirror that so we don't + // render an empty wrapper Box above the streaming area in quiet mode. + const anyPanelVisible = SECTION_NAMES.some(s => sectionMode(s, ui.detailsMode, ui.sections) !== 'hidden') + + const showProgressArea = anyPanelVisible + ? Boolean( + ui.busy || turn.outcome || turn.streamPendingTools.length || turn.streamSegments.length || @@ -643,7 +648,8 @@ export function useMainApp(gw: GatewayClient) { turn.turnTrail.length || hasReasoning || turn.activity.length - ) + ) + : turn.activity.some(item => item.tone !== 'info') const appActions = useMemo( () => ({ diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 524c0f572..f2c0241ff 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,6 +1,7 @@ import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' +import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { LONG_MSG } from '../config/limits.js' import { userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' @@ -21,11 +22,16 @@ export const MessageLine = memo(function MessageLine({ t }: MessageLineProps) { if (msg.kind === 'trail' && msg.tools?.length) { - return detailsMode === 'hidden' ? null : ( + // Per-section overrides win over the global mode, so don't pre-empt on + // `detailsMode === 'hidden'` — only skip when EVERY section is hidden, + // matching ToolTrail's own internal short-circuit. + const anyVisible = SECTION_NAMES.some(s => sectionMode(s, detailsMode, sections) !== 'hidden') + + return anyVisible ? ( - ) + ) : null } if (msg.role === 'tool') { diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index fb4e9687e..da8b3d396 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,5 +1,5 @@ import { Box, NoSelect, Text } from '@hermes/ink' -import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' +import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' @@ -874,18 +874,22 @@ export const ToolTrail = memo(function ToolTrail({ const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task')) const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null - // ── Hidden: errors/warnings only ────────────────────────────── + // ── Backstop: floating alerts when every panel is hidden ───────── // - // When the global details_mode is 'hidden' (or all sections are individually - // hidden), the accordion collapses entirely. Errors/warnings still float - // as inline alerts UNLESS the activity section is explicitly hidden — that - // override means "I don't want to see meta at all", so respect it. + // Per-section overrides win over the global details_mode (they're computed + // by sectionMode), so we only collapse to nothing when EVERY section is + // resolved to hidden — that way `details_mode: hidden` + `sections.tools: + // expanded` still renders the tools panel. When all panels are hidden + // AND ambient errors/warnings exist, surface them as a compact inline + // backstop so quiet-mode users aren't blind to failures. - if (detailsMode === 'hidden') { - if (visible.activity === 'hidden') { - return null - } + const allHidden = + visible.thinking === 'hidden' && + visible.tools === 'hidden' && + visible.subagents === 'hidden' && + visible.activity === 'hidden' + if (allHidden) { const alerts = activity.filter(i => i.tone !== 'info').slice(-2) return alerts.length ? ( diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts index 752d44a75..75f25a3aa 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -17,10 +17,12 @@ const THINKING_FALLBACK: Record = { truncated: 'collapsed' } -const norm = (v: unknown) => String(v ?? '').trim().toLowerCase() +const norm = (v: unknown) => + String(v ?? '') + .trim() + .toLowerCase() -export const parseDetailsMode = (v: unknown): DetailsMode | null => - MODES.find(m => m === norm(v)) ?? null +export const parseDetailsMode = (v: unknown): DetailsMode | null => MODES.find(m => m === norm(v)) ?? null export const isSectionName = (v: unknown): v is SectionName => typeof v === 'string' && (SECTION_NAMES as readonly string[]).includes(v) @@ -42,11 +44,7 @@ export const resolveSections = (raw: unknown): SectionVisibility => // Effective mode for one section: explicit override → SECTION_DEFAULTS → global. // Single source of truth for "is this section open by default / rendered at all". -export const sectionMode = ( - name: SectionName, - global: DetailsMode, - sections?: SectionVisibility -): DetailsMode => sections?.[name] ?? SECTION_DEFAULTS[name] ?? global +export const sectionMode = (name: SectionName, global: DetailsMode, sections?: SectionVisibility): DetailsMode => + sections?.[name] ?? SECTION_DEFAULTS[name] ?? global -export const nextDetailsMode = (m: DetailsMode): DetailsMode => - MODES[(MODES.indexOf(m) + 1) % MODES.length]! +export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]!