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]!