From 78481ac1246573e00e1b2a51c59853ba8a24f530 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Apr 2026 02:34:32 -0500 Subject: [PATCH 1/6] feat(tui): per-section visibility for the details accordion Adds optional per-section overrides on top of the existing global details_mode (hidden | collapsed | expanded). Lets users keep the accordion collapsed by default while auto-expanding tools, or hide the activity panel entirely without touching thinking/tools/subagents. Config (~/.hermes/config.yaml): display: details_mode: collapsed sections: thinking: expanded tools: expanded activity: hidden Slash command: /details show current global + overrides /details [hidden|collapsed|expanded] set global mode (existing) /details
per-section override (new) /details
reset clear override Sections: thinking, tools, subagents, activity. Implementation: - ui-tui/src/types.ts SectionName + SectionVisibility - ui-tui/src/domain/details.ts parseSectionMode / resolveSections / sectionMode + SECTION_NAMES - ui-tui/src/app/uiStore.ts + app/interfaces.ts + app/useConfigSync.ts sections threaded into UiState - ui-tui/src/components/ thinking.tsx ToolTrail consults per-section mode for hidden/expanded behaviour; expandAll skips hidden sections; floating-alert fallback respects activity:hidden - ui-tui/src/components/ messageLine.tsx + appLayout.tsx pass sections through render tree - ui-tui/src/app/slash/ commands/core.ts /details
syntax - tui_gateway/server.py config.set details_mode.
writes to display.sections.
(empty value clears the override) - website/docs/user-guide/tui.md documented Tests: 14 new (4 domain, 4 useConfigSync, 3 slash, 3 gateway). Total: 269/269 vitest, all gateway tests pass. --- tests/test_tui_gateway_server.py | 65 +++++++++ tui_gateway/server.py | 35 +++++ .../src/__tests__/createSlashHandler.test.ts | 35 +++++ ui-tui/src/__tests__/details.test.ts | 84 ++++++++++++ ui-tui/src/__tests__/useConfigSync.test.ts | 47 +++++++ ui-tui/src/app/interfaces.ts | 2 + ui-tui/src/app/slash/commands/core.ts | 58 +++++++-- ui-tui/src/app/uiStore.ts | 1 + ui-tui/src/app/useConfigSync.ts | 3 +- ui-tui/src/components/appLayout.tsx | 19 ++- ui-tui/src/components/messageLine.tsx | 7 +- ui-tui/src/components/thinking.tsx | 123 ++++++++++-------- ui-tui/src/domain/details.ts | 37 +++++- ui-tui/src/gatewayTypes.ts | 1 + ui-tui/src/types.ts | 8 ++ website/docs/user-guide/tui.md | 23 +++- 16 files changed, 478 insertions(+), 70 deletions(-) create mode 100644 ui-tui/src/__tests__/details.test.ts diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 2c50065b2..107d23897 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -160,6 +160,71 @@ def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): assert saved["display"]["tui_statusbar"] == "bottom" +def test_config_set_section_writes_per_section_override(tmp_path, monkeypatch): + import yaml + + cfg_path = tmp_path / "config.yaml" + monkeypatch.setattr(server, "_hermes_home", tmp_path) + + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "details_mode.activity", "value": "hidden"}, + } + ) + + assert resp["result"] == {"key": "details_mode.activity", "value": "hidden"} + saved = yaml.safe_load(cfg_path.read_text()) + assert saved["display"]["sections"] == {"activity": "hidden"} + + +def test_config_set_section_clears_override_on_empty_value(tmp_path, monkeypatch): + import yaml + + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text( + yaml.safe_dump( + {"display": {"sections": {"activity": "hidden", "tools": "expanded"}}} + ) + ) + monkeypatch.setattr(server, "_hermes_home", tmp_path) + + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "details_mode.activity", "value": ""}, + } + ) + + assert resp["result"] == {"key": "details_mode.activity", "value": ""} + saved = yaml.safe_load(cfg_path.read_text()) + assert saved["display"]["sections"] == {"tools": "expanded"} + + +def test_config_set_section_rejects_unknown_section_or_mode(tmp_path, monkeypatch): + monkeypatch.setattr(server, "_hermes_home", tmp_path) + + bad_section = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "details_mode.bogus", "value": "hidden"}, + } + ) + assert bad_section["error"]["code"] == 4002 + + bad_mode = server.handle_request( + { + "id": "2", + "method": "config.set", + "params": {"key": "details_mode.tools", "value": "maximised"}, + } + ) + assert bad_mode["error"]["code"] == 4002 + + def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f0a870b6e..0727ed0e0 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2642,6 +2642,41 @@ def _(rid, params: dict) -> dict: _write_config_key("display.details_mode", nv) return _ok(rid, {"key": key, "value": nv}) + if key.startswith("details_mode."): + # Per-section override: `details_mode.
` writes to + # `display.sections.
`. Empty value clears the override + # and lets the section fall back to the global details_mode. + section = key.split(".", 1)[1] + allowed_sections = frozenset({"thinking", "tools", "subagents", "activity"}) + if section not in allowed_sections: + return _err(rid, 4002, f"unknown section: {section}") + + cfg = _load_cfg() + display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} + sections_cfg = ( + display.get("sections") + if isinstance(display.get("sections"), dict) + else {} + ) + + nv = str(value or "").strip().lower() + if not nv: + sections_cfg.pop(section, None) + display["sections"] = sections_cfg + cfg["display"] = display + _save_cfg(cfg) + return _ok(rid, {"key": key, "value": ""}) + + allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) + if nv not in allowed_dm: + return _err(rid, 4002, f"unknown details_mode: {value}") + + sections_cfg[section] = nv + display["sections"] = sections_cfg + cfg["display"] = display + _save_cfg(cfg) + return _ok(rid, {"key": key, "value": nv}) + if key == "thinking_mode": nv = str(value or "").strip().lower() allowed_tm = frozenset({"collapsed", "truncated", "full"}) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 901564f73..4bb8b6bda 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -88,6 +88,41 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded') }) + it('sets a per-section override and persists it under details_mode.
', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/details activity hidden')).toBe(true) + expect(getUiState().sections.activity).toBe('hidden') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { + key: 'details_mode.activity', + value: 'hidden' + }) + expect(ctx.transcript.sys).toHaveBeenCalledWith('details activity: hidden') + }) + + it('clears a per-section override on /details
reset', () => { + const ctx = buildCtx() + createSlashHandler(ctx)('/details tools expanded') + expect(getUiState().sections.tools).toBe('expanded') + + createSlashHandler(ctx)('/details tools reset') + expect(getUiState().sections.tools).toBeUndefined() + expect(ctx.gateway.rpc).toHaveBeenLastCalledWith('config.set', { + key: 'details_mode.tools', + value: '' + }) + expect(ctx.transcript.sys).toHaveBeenCalledWith('details tools: reset') + }) + + it('rejects unknown section modes with a usage hint', () => { + const ctx = buildCtx() + createSlashHandler(ctx)('/details tools blink') + expect(getUiState().sections.tools).toBeUndefined() + expect(ctx.transcript.sys).toHaveBeenCalledWith( + 'usage: /details
[hidden|collapsed|expanded|reset]' + ) + }) + it('shows tool enable usage when names are missing', () => { const ctx = buildCtx() diff --git a/ui-tui/src/__tests__/details.test.ts b/ui-tui/src/__tests__/details.test.ts new file mode 100644 index 000000000..b6c28329b --- /dev/null +++ b/ui-tui/src/__tests__/details.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' + +import { isSectionName, parseDetailsMode, resolveSections, sectionMode, SECTION_NAMES } from '../domain/details.js' + +describe('parseDetailsMode', () => { + it('accepts the canonical modes case-insensitively', () => { + expect(parseDetailsMode('hidden')).toBe('hidden') + expect(parseDetailsMode(' COLLAPSED ')).toBe('collapsed') + expect(parseDetailsMode('Expanded')).toBe('expanded') + }) + + it('rejects junk', () => { + expect(parseDetailsMode('truncated')).toBeNull() + expect(parseDetailsMode('')).toBeNull() + expect(parseDetailsMode(undefined)).toBeNull() + expect(parseDetailsMode(42)).toBeNull() + }) +}) + +describe('isSectionName', () => { + it('only lets the four canonical sections through', () => { + expect(isSectionName('thinking')).toBe(true) + expect(isSectionName('tools')).toBe(true) + expect(isSectionName('subagents')).toBe(true) + expect(isSectionName('activity')).toBe(true) + + expect(isSectionName('Thinking')).toBe(false) // case-sensitive on purpose + expect(isSectionName('bogus')).toBe(false) + expect(isSectionName('')).toBe(false) + expect(isSectionName(7)).toBe(false) + }) + + it('SECTION_NAMES exposes them all', () => { + expect([...SECTION_NAMES].sort()).toEqual(['activity', 'subagents', 'thinking', 'tools']) + }) +}) + +describe('resolveSections', () => { + it('parses a well-formed sections object', () => { + expect( + resolveSections({ + thinking: 'expanded', + tools: 'expanded', + subagents: 'collapsed', + activity: 'hidden' + }) + ).toEqual({ + thinking: 'expanded', + tools: 'expanded', + subagents: 'collapsed', + activity: 'hidden' + }) + }) + + it('drops unknown section names and unknown modes', () => { + expect( + resolveSections({ + thinking: 'expanded', + tools: 'maximised', + bogus: 'hidden', + activity: 'hidden' + }) + ).toEqual({ thinking: 'expanded', activity: 'hidden' }) + }) + + it('treats nullish/non-objects as empty overrides', () => { + expect(resolveSections(undefined)).toEqual({}) + expect(resolveSections(null)).toEqual({}) + expect(resolveSections('hidden')).toEqual({}) + expect(resolveSections([])).toEqual({}) + }) +}) + +describe('sectionMode', () => { + it('falls back to the global mode when no override is set', () => { + expect(sectionMode('tools', 'collapsed', {})).toBe('collapsed') + expect(sectionMode('tools', 'expanded', undefined)).toBe('expanded') + }) + + it('honours per-section overrides over the global mode', () => { + expect(sectionMode('activity', 'expanded', { activity: 'hidden' })).toBe('hidden') + expect(sectionMode('tools', 'collapsed', { tools: 'expanded' })).toBe('expanded') + }) +}) diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index c5a0a97dc..568251744 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -62,6 +62,53 @@ describe('applyDisplay', () => { expect(s.showReasoning).toBe(false) expect(s.statusBar).toBe('top') expect(s.streaming).toBe(true) + expect(s.sections).toEqual({}) + }) + + it('parses display.sections into per-section overrides', () => { + const setBell = vi.fn() + + applyDisplay( + { + config: { + display: { + details_mode: 'collapsed', + sections: { + activity: 'hidden', + tools: 'expanded', + thinking: 'expanded', + bogus: 'expanded' + } + } + } + }, + setBell + ) + + const s = $uiState.get() + expect(s.detailsMode).toBe('collapsed') + expect(s.sections).toEqual({ + activity: 'hidden', + tools: 'expanded', + thinking: 'expanded' + }) + }) + + it('drops invalid section modes', () => { + const setBell = vi.fn() + + applyDisplay( + { + config: { + display: { + sections: { tools: 'maximised' as unknown as string, activity: 'hidden' } + } + } + }, + setBell + ) + + expect($uiState.get().sections).toEqual({ activity: 'hidden' }) }) it('treats a null config like an empty display block', () => { diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 81def036c..92529ca79 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -16,6 +16,7 @@ import type { Msg, PanelSection, SecretReq, + SectionVisibility, SessionInfo, SlashCatalog, SubagentProgress, @@ -87,6 +88,7 @@ export interface UiState { detailsMode: DetailsMode info: null | SessionInfo inlineDiffs: boolean + sections: SectionVisibility showCost: boolean showReasoning: boolean sid: null | string diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 904882c21..9483de0f5 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 { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' +import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' import type { ConfigGetValueResponse, ConfigSetResponse, @@ -10,7 +10,7 @@ import type { } from '../../../gatewayTypes.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js' -import type { DetailsMode, Msg, PanelSection } from '../../../types.js' +import type { DetailsMode, Msg, PanelSection, SectionName } from '../../../types.js' import type { StatusBarMode } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' @@ -57,7 +57,8 @@ export const coreCommands: SlashCommand[] = [ sections.push( { rows: [ - ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/details [hidden|collapsed|expanded|cycle]', 'set global agent detail visibility mode'], + ['/details
[hidden|collapsed|expanded|reset]', 'override one section (thinking/tools/subagents/activity)'], ['/fortune [random|daily]', 'show a random or daily local fortune'] ], title: 'TUI' @@ -140,7 +141,7 @@ export const coreCommands: SlashCommand[] = [ { aliases: ['detail'], - help: 'control agent detail visibility', + help: 'control agent detail visibility (global or per-section)', name: 'details', run: (arg, ctx) => { const { gateway, transcript, ui } = ctx @@ -154,9 +155,14 @@ export const coreCommands: SlashCommand[] = [ } const mode = parseDetailsMode(r?.value) ?? ui.detailsMode - patchUiState({ detailsMode: mode }) - transcript.sys(`details: ${mode}`) + + const overrides = SECTION_NAMES + .filter(s => ui.sections[s]) + .map(s => `${s}=${ui.sections[s]}`) + .join(' ') + + transcript.sys(`details: ${mode}${overrides ? ` (${overrides})` : ''}`) }) .catch(() => { if (!ctx.stale()) { @@ -167,10 +173,46 @@ export const coreCommands: SlashCommand[] = [ return } - const mode = arg.trim().toLowerCase() + const tokens = arg.trim().toLowerCase().split(/\s+/) + + // Per-section override: `/details
` + if (tokens.length >= 2 && isSectionName(tokens[0])) { + const section = tokens[0] as SectionName + const action = tokens[1] ?? '' + + if (action === 'reset' || action === 'clear' || action === 'default') { + const { [section]: _drop, ...rest } = ui.sections + patchUiState({ sections: rest }) + gateway + .rpc('config.set', { key: `details_mode.${section}`, value: '' }) + .catch(() => {}) + transcript.sys(`details ${section}: reset`) + + return + } + + const sectionMode = parseDetailsMode(action) + + if (!sectionMode) { + return transcript.sys('usage: /details
[hidden|collapsed|expanded|reset]') + } + + patchUiState({ sections: { ...ui.sections, [section]: sectionMode } }) + gateway + .rpc('config.set', { key: `details_mode.${section}`, value: sectionMode }) + .catch(() => {}) + transcript.sys(`details ${section}: ${sectionMode}`) + + return + } + + // Global mode (existing behavior). + const mode = tokens[0] ?? '' if (!DETAIL_MODES.has(mode)) { - return transcript.sys('usage: /details [hidden|collapsed|expanded|cycle]') + return transcript.sys( + 'usage: /details [hidden|collapsed|expanded|cycle] or /details
[hidden|collapsed|expanded|reset]' + ) } const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index fcf2e5d88..0b3fd9740 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -12,6 +12,7 @@ const buildUiState = (): UiState => ({ detailsMode: 'collapsed', info: null, inlineDiffs: true, + sections: {}, showCost: false, showReasoning: false, sid: null, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 9e7c93ce9..cb98eed81 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import { resolveDetailsMode } from '../domain/details.js' +import { resolveDetailsMode, resolveSections } from '../domain/details.js' import type { GatewayClient } from '../gatewayClient.js' import type { ConfigFullResponse, @@ -46,6 +46,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea compact: !!d.tui_compact, detailsMode: resolveDetailsMode(d), inlineDiffs: d.inline_diffs !== false, + sections: resolveSections(d.sections), showCost: !!d.show_cost, showReasoning: !!d.show_reasoning, statusBar: normalizeStatusBar(d.tui_statusbar), diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 19bc7736b..d85645175 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -8,7 +8,7 @@ import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStor import { $uiState } from '../app/uiStore.js' import { PLACEHOLDER } from '../content/placeholders.js' import type { Theme } from '../theme.js' -import type { DetailsMode } from '../types.js' +import type { DetailsMode, SectionVisibility } from '../types.js' import { AgentsOverlay } from './agentsOverlay.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' @@ -25,6 +25,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ compact, detailsMode, progress, + sections, t }: StreamingAssistantProps) { if (!progress.showProgressArea && !progress.showStreamingArea) { @@ -34,7 +35,15 @@ const StreamingAssistant = memo(function StreamingAssistant({ return ( <> {progress.streamSegments.map((msg, i) => ( - + ))} {progress.showProgressArea && ( @@ -48,6 +57,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ reasoningActive={progress.reasoningActive} reasoningStreaming={progress.reasoningStreaming} reasoningTokens={progress.reasoningTokens} + sections={sections} subagents={progress.subagents} t={t} tools={progress.tools} @@ -68,6 +78,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ text: progress.streaming, ...(progress.streamPendingTools.length && { tools: progress.streamPendingTools }) }} + sections={sections} t={t} /> )} @@ -78,6 +89,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ compact={compact} detailsMode={detailsMode} msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }} + sections={sections} t={t} /> )} @@ -115,6 +127,7 @@ const TranscriptPane = memo(function TranscriptPane({ compact={ui.compact} detailsMode={ui.detailsMode} msg={row.msg} + sections={ui.sections} t={ui.theme} /> )} @@ -129,6 +142,7 @@ const TranscriptPane = memo(function TranscriptPane({ compact={ui.compact} detailsMode={ui.detailsMode} progress={progress} + sections={ui.sections} t={ui.theme} /> @@ -337,5 +351,6 @@ interface StreamingAssistantProps { compact?: boolean detailsMode: DetailsMode progress: AppLayoutProgressProps + sections?: SectionVisibility t: Theme } diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 635c119a0..524c0f572 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -6,7 +6,7 @@ import { userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { DetailsMode, Msg } from '../types.js' +import type { DetailsMode, Msg, SectionVisibility } from '../types.js' import { Md } from './markdown.js' import { ToolTrail } from './thinking.js' @@ -17,12 +17,13 @@ export const MessageLine = memo(function MessageLine({ detailsMode = 'collapsed', isStreaming = false, msg, + sections, t }: MessageLineProps) { if (msg.kind === 'trail' && msg.tools?.length) { return detailsMode === 'hidden' ? null : ( - + ) } @@ -98,6 +99,7 @@ export const MessageLine = memo(function MessageLine({ detailsMode={detailsMode} reasoning={thinking} reasoningTokens={msg.thinkingTokens} + sections={sections} t={t} toolTokens={msg.toolTokens} trail={msg.tools} @@ -124,5 +126,6 @@ interface MessageLineProps { detailsMode?: DetailsMode isStreaming?: boolean msg: Msg + sections?: SectionVisibility t: Theme } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index a59cdc41d..9b3285a36 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -3,6 +3,7 @@ import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' +import { sectionMode } from '../domain/details.js' import { buildSubagentTree, fmtCost, @@ -25,7 +26,15 @@ import { toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool, ActivityItem, DetailsMode, SubagentNode, SubagentProgress, ThinkingMode } from '../types.js' +import type { + ActiveTool, + ActivityItem, + DetailsMode, + SectionVisibility, + SubagentNode, + SubagentProgress, + ThinkingMode +} from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] @@ -675,6 +684,7 @@ export const ToolTrail = memo(function ToolTrail({ reasoning = '', reasoningTokens, reasoningStreaming = false, + sections, subagents = [], t, tools = [], @@ -689,6 +699,7 @@ export const ToolTrail = memo(function ToolTrail({ reasoning?: string reasoningTokens?: number reasoningStreaming?: boolean + sections?: SectionVisibility subagents?: SubagentProgress[] t: Theme tools?: ActiveTool[] @@ -696,38 +707,34 @@ export const ToolTrail = memo(function ToolTrail({ trail?: string[] activity?: ActivityItem[] }) { + const thinkingSection = sectionMode('thinking', detailsMode, sections) + const toolsSection = sectionMode('tools', detailsMode, sections) + const subagentsSection = sectionMode('subagents', detailsMode, sections) + const activitySection = sectionMode('activity', detailsMode, sections) + const [now, setNow] = useState(() => Date.now()) - const [openThinking, setOpenThinking] = useState(false) - const [openTools, setOpenTools] = useState(false) - const [openSubagents, setOpenSubagents] = useState(false) - const [deepSubagents, setDeepSubagents] = useState(false) - const [openMeta, setOpenMeta] = useState(false) + const [openThinking, setOpenThinking] = useState(thinkingSection === 'expanded') + const [openTools, setOpenTools] = useState(toolsSection === 'expanded') + const [openSubagents, setOpenSubagents] = useState(subagentsSection === 'expanded') + const [deepSubagents, setDeepSubagents] = useState(subagentsSection === 'expanded') + const [openMeta, setOpenMeta] = useState(activitySection === 'expanded') useEffect(() => { - if (!tools.length || (detailsMode === 'collapsed' && !openTools)) { + if (!tools.length || (toolsSection !== 'expanded' && !openTools)) { return } const id = setInterval(() => setNow(Date.now()), 500) return () => clearInterval(id) - }, [detailsMode, openTools, tools.length]) + }, [toolsSection, openTools, tools.length]) useEffect(() => { - if (detailsMode === 'expanded') { - setOpenThinking(true) - setOpenTools(true) - setOpenSubagents(true) - setOpenMeta(true) - } - - if (detailsMode === 'hidden') { - setOpenThinking(false) - setOpenTools(false) - setOpenSubagents(false) - setOpenMeta(false) - } - }, [detailsMode]) + setOpenThinking(thinkingSection === 'expanded') + setOpenTools(toolsSection === 'expanded') + setOpenSubagents(subagentsSection === 'expanded') + setOpenMeta(activitySection === 'expanded') + }, [thinkingSection, toolsSection, subagentsSection, activitySection]) const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) @@ -863,8 +870,17 @@ export const ToolTrail = memo(function ToolTrail({ const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null // ── Hidden: errors/warnings only ────────────────────────────── + // + // 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. if (detailsMode === 'hidden') { + if (activitySection === 'hidden') { + return null + } + const alerts = activity.filter(i => i.tone !== 'info').slice(-2) return alerts.length ? ( @@ -879,13 +895,18 @@ export const ToolTrail = memo(function ToolTrail({ } // ── Tree render fragments ────────────────────────────────────── + // + // Shift+click on any chevron expands every NON-hidden section at once — + // hidden sections stay hidden so the override is honoured. const expandAll = () => { - setOpenThinking(true) - setOpenTools(true) - setOpenSubagents(true) - setDeepSubagents(true) - setOpenMeta(true) + if (thinkingSection !== 'hidden') setOpenThinking(true) + if (toolsSection !== 'hidden') setOpenTools(true) + if (subagentsSection !== 'hidden') { + setOpenSubagents(true) + setDeepSubagents(true) + } + if (activitySection !== 'hidden') setOpenMeta(true) } const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') @@ -899,7 +920,7 @@ export const ToolTrail = memo(function ToolTrail({ {spawnTree.map((node, index) => ( ) - const sections: { + const panels: { header: ReactNode key: string open: boolean render: (rails: boolean[]) => ReactNode }[] = [] - if (hasThinking) { - sections.push({ + if (hasThinking && thinkingSection !== 'hidden') { + panels.push({ header: ( { @@ -930,7 +951,7 @@ export const ToolTrail = memo(function ToolTrail({ }} > - {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} + {thinkingSection === 'expanded' || openThinking ? '▾ ' : '▸ '} {thinkingLive ? ( Thinking @@ -950,7 +971,7 @@ export const ToolTrail = memo(function ToolTrail({ ), key: 'thinking', - open: detailsMode === 'expanded' || openThinking, + open: thinkingSection === 'expanded' || openThinking, render: rails => ( !v) } }} - open={detailsMode === 'expanded' || openTools} + open={toolsSection === 'expanded' || openTools} suffix={toolTokensLabel} t={t} title="Tool calls" /> ), key: 'tools', - open: detailsMode === 'expanded' || openTools, + open: toolsSection === 'expanded' || openTools, render: rails => ( {groups.map((group, index) => { @@ -1024,12 +1045,12 @@ export const ToolTrail = memo(function ToolTrail({ }) } - if (hasSubagents && !inlineDelegateKey) { + if (hasSubagents && !inlineDelegateKey && subagentsSection !== 'hidden') { // Spark + summary give a one-line read on the branch shape before // opening the subtree. `/agents` opens the full-screen audit overlay. const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)` - sections.push({ + panels.push({ header: ( ), key: 'subagents', - open: detailsMode === 'expanded' || openSubagents, + open: subagentsSection === 'expanded' || openSubagents, render: renderSubagentList }) } - if (hasMeta) { - sections.push({ + if (hasMeta && activitySection !== 'hidden') { + panels.push({ header: ( !v) } }} - open={detailsMode === 'expanded' || openMeta} + open={activitySection === 'expanded' || openMeta} t={t} title="Activity" tone={metaTone} /> ), key: 'meta', - open: detailsMode === 'expanded' || openMeta, + open: activitySection === 'expanded' || openMeta, render: rails => ( {meta.map((row, index) => ( @@ -1092,19 +1113,19 @@ export const ToolTrail = memo(function ToolTrail({ }) } - const topCount = sections.length + (totalTokensLabel ? 1 : 0) + const topCount = panels.length + (totalTokensLabel ? 1 : 0) return ( - {sections.map((section, index) => ( + {panels.map((panel, index) => ( - {section.render} + {panel.render} ))} {totalTokensLabel ? ( diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts index fa01092f5..4ac2e6f80 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -1,6 +1,7 @@ -import type { DetailsMode } from '../types.js' +import type { DetailsMode, SectionName, SectionVisibility } from '../types.js' const MODES = ['hidden', 'collapsed', 'expanded'] as const +export const SECTION_NAMES: readonly SectionName[] = ['thinking', 'tools', 'subagents', 'activity'] const THINKING_FALLBACK: Record = { collapsed: 'collapsed', @@ -14,6 +15,9 @@ export const parseDetailsMode = (v: unknown): DetailsMode | null => { return MODES.find(m => m === s) ?? null } +export const isSectionName = (v: unknown): v is SectionName => + typeof v === 'string' && (SECTION_NAMES as readonly string[]).includes(v) + export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode => parseDetailsMode(d?.details_mode) ?? THINKING_FALLBACK[ @@ -23,4 +27,35 @@ export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: ] ?? 'collapsed' +// Build a SectionVisibility from a free-form `display.sections` config blob. +// Skips keys that aren't recognized section names or don't parse to a valid +// mode — partial overrides are intentional, missing keys fall through to the +// global details_mode at render time. +export const resolveSections = (raw: unknown): SectionVisibility => { + const out: SectionVisibility = {} + + if (!raw || typeof raw !== 'object') { + return out + } + + for (const [k, v] of Object.entries(raw as Record)) { + const mode = parseDetailsMode(v) + + if (mode && isSectionName(k)) { + out[k] = mode + } + } + + return out +} + +// Resolve the effective mode for one section: explicit override wins, +// otherwise the global details_mode. Single source of truth — every render +// site that needs to know "is this section open by default" calls this. +export const sectionMode = ( + name: SectionName, + global: DetailsMode, + sections?: SectionVisibility +): DetailsMode => sections?.[name] ?? global + export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]! diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 05f8d9a41..a38e06804 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -55,6 +55,7 @@ export interface ConfigDisplayConfig { bell_on_complete?: boolean details_mode?: string inline_diffs?: boolean + sections?: Record show_cost?: boolean show_reasoning?: boolean streaming?: boolean diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 191e63900..eb8053610 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -116,6 +116,14 @@ export type Role = 'assistant' | 'system' | 'tool' | 'user' export type DetailsMode = 'hidden' | 'collapsed' | 'expanded' export type ThinkingMode = 'collapsed' | 'truncated' | 'full' +// Per-section overrides on top of the global DetailsMode. Each missing key +// falls back to the global mode; an explicit value overrides for that one +// section only — so users can keep the accordion collapsed by default while +// auto-expanding tools, or hide the activity panel entirely without touching +// thinking/tools/subagents. +export type SectionName = 'thinking' | 'tools' | 'subagents' | 'activity' +export type SectionVisibility = Partial> + export interface McpServerStatus { connected: boolean name: string diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 72c0a4712..fe032cf51 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -87,7 +87,7 @@ All slash commands work unchanged. A few are TUI-owned — they produce richer o | `/sessions` | Modal session picker — preview, title, token totals, resume inline | | `/model` | Modal model picker grouped by provider, with cost hints | | `/skin` | Live preview — theme change applies as you browse | -| `/details` | Toggle verbose tool-call details in the transcript | +| `/details` | Toggle verbose tool-call details (global or per-section) | | `/usage` | Rich token / cost / context panel | Every other slash command (including installed skills, quick commands, and personality toggles) works identically to the classic CLI. See [Slash Commands Reference](../reference/slash-commands.md). @@ -114,13 +114,26 @@ A handful of keys tune the TUI surface specifically: ```yaml display: - skin: default # any built-in or custom skin + skin: default # any built-in or custom skin personality: helpful - details_mode: compact # or "verbose" — default tool-call detail level - mouse_tracking: true # disable if your terminal conflicts with mouse reporting + details_mode: collapsed # hidden | collapsed | expanded — global accordion default + sections: # optional: per-section overrides (any subset) + thinking: expanded # always open + tools: expanded # always open + activity: hidden # never show errors/warnings/info panel + mouse_tracking: true # disable if your terminal conflicts with mouse reporting ``` -`/details on` / `/details off` / `/details cycle` toggle this at runtime. +Runtime toggles: + +- `/details [hidden|collapsed|expanded|cycle]` — set the global mode +- `/details
[hidden|collapsed|expanded|reset]` — override one section + (sections: `thinking`, `tools`, `subagents`, `activity`) + +Per-section overrides take precedence over the global `details_mode`. With +`activity: hidden`, errors/warnings are suppressed entirely (the floating-alert +fallback that normally surfaces under `details_mode: hidden` is also silenced +when activity is explicitly hidden). ## Sessions From 728767e910c0d10d9ed25f38469c89cfd2d935f6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Apr 2026 02:37:42 -0500 Subject: [PATCH 2/6] feat(tui): hide the activity panel by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity panel (gateway hints, terminal-parity nudges, background notifications) is noise for the typical day-to-day user, who only cares about thinking + tools + streamed content. Make `hidden` the built-in default for that section so users land on the quiet mode out of the box. Tool failures still render inline on the failing tool row, so this default suppresses the noise feed without losing the signal. Opt back in with `display.sections.activity: collapsed` (chevron) or `expanded` (always open) in `~/.hermes/config.yaml`, or live with `/details activity collapsed`. Implementation: SECTION_DEFAULTS in domain/details.ts, applied as the fallback in `sectionMode()` between the explicit override and the global details_mode. Existing `display.sections.activity` overrides take precedence — no migration needed for users who already set it. --- ui-tui/src/__tests__/details.test.ts | 15 ++++++++++++--- ui-tui/src/domain/details.ts | 19 ++++++++++++++++--- website/docs/user-guide/tui.md | 26 +++++++++++++++++++++----- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/ui-tui/src/__tests__/details.test.ts b/ui-tui/src/__tests__/details.test.ts index b6c28329b..a2babd15d 100644 --- a/ui-tui/src/__tests__/details.test.ts +++ b/ui-tui/src/__tests__/details.test.ts @@ -72,13 +72,22 @@ describe('resolveSections', () => { }) describe('sectionMode', () => { - it('falls back to the global mode when no override is set', () => { + it('falls back to the global mode for sections without a built-in default', () => { expect(sectionMode('tools', 'collapsed', {})).toBe('collapsed') expect(sectionMode('tools', 'expanded', undefined)).toBe('expanded') + expect(sectionMode('thinking', 'collapsed', {})).toBe('collapsed') + expect(sectionMode('subagents', 'expanded', {})).toBe('expanded') }) - it('honours per-section overrides over the global mode', () => { - expect(sectionMode('activity', 'expanded', { activity: 'hidden' })).toBe('hidden') + it('hides the activity panel by default regardless of global mode', () => { + expect(sectionMode('activity', 'collapsed', {})).toBe('hidden') + expect(sectionMode('activity', 'expanded', undefined)).toBe('hidden') + expect(sectionMode('activity', 'hidden', {})).toBe('hidden') + }) + + it('honours per-section overrides over both the section default and global mode', () => { + expect(sectionMode('activity', 'collapsed', { activity: 'expanded' })).toBe('expanded') + expect(sectionMode('activity', 'expanded', { activity: 'collapsed' })).toBe('collapsed') expect(sectionMode('tools', 'collapsed', { tools: 'expanded' })).toBe('expanded') }) }) diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts index 4ac2e6f80..059dcdabf 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -49,13 +49,26 @@ export const resolveSections = (raw: unknown): SectionVisibility => { return out } +// Built-in per-section defaults applied when the user has no explicit +// override. The activity panel (gateway hints, terminal-parity nudges, +// background-process notifications) is hidden out of the box — it's noise +// for the typical day-to-day user, who only cares about thinking + tools + +// streamed content. Tool failures still surface inline on the failing tool +// row; this default only suppresses the ambient meta feed. +// +// Opt back in with `display.sections.activity: collapsed` (under chevron) +// or `expanded` (always open) in `~/.hermes/config.yaml`, or live with +// `/details activity collapsed`. +const SECTION_DEFAULTS: SectionVisibility = { activity: 'hidden' } + // Resolve the effective mode for one section: explicit override wins, -// otherwise the global details_mode. Single source of truth — every render -// site that needs to know "is this section open by default" calls this. +// then the SECTION_DEFAULTS fallback, then the global details_mode. +// Single source of truth — every render site that needs to know "is this +// section open by default" calls this. export const sectionMode = ( name: SectionName, global: DetailsMode, sections?: SectionVisibility -): DetailsMode => sections?.[name] ?? global +): DetailsMode => sections?.[name] ?? SECTION_DEFAULTS[name] ?? global export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]! diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index fe032cf51..7e76653c5 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -120,7 +120,7 @@ display: sections: # optional: per-section overrides (any subset) thinking: expanded # always open tools: expanded # always open - activity: hidden # never show errors/warnings/info panel + activity: collapsed # opt back IN to the activity panel (hidden by default) mouse_tracking: true # disable if your terminal conflicts with mouse reporting ``` @@ -130,10 +130,26 @@ Runtime toggles: - `/details
[hidden|collapsed|expanded|reset]` — override one section (sections: `thinking`, `tools`, `subagents`, `activity`) -Per-section overrides take precedence over the global `details_mode`. With -`activity: hidden`, errors/warnings are suppressed entirely (the floating-alert -fallback that normally surfaces under `details_mode: hidden` is also silenced -when activity is explicitly hidden). +**Default visibility** + +- `thinking`, `tools`, `subagents` — fall through to the global `details_mode` + (collapsed under chevron by default, click to expand). +- `activity` — **hidden by default**. The activity panel surfaces ambient + meta (gateway hints, terminal-parity nudges, background notifications) and + is noise for most day-to-day use. Tool failures still render inline on the + failing tool row, so this default suppresses the noise feed without losing + the signal. + +Per-section overrides take precedence over both the section default and the +global `details_mode`. To opt the activity panel back in: + +- `display.sections.activity: collapsed` — under a chevron +- `display.sections.activity: expanded` — always open +- `/details activity collapsed` at runtime + +With `activity: hidden` (the default), errors/warnings are suppressed entirely +— the floating-alert fallback that surfaces under `details_mode: hidden` is +silenced as well. ## Sessions From 005cc29e98da5b824c08aa1ff463a5b42a5e2d50 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Apr 2026 02:42:03 -0500 Subject: [PATCH 3/6] refactor(tui): /clean pass on per-section visibility plumbing - domain/details: extract `norm()`, fold parseDetailsMode + resolveSections into terser functional form, reject array values for resolveSections - slash /details: destructure tokens, factor reset/mode into one dispatch, drop DETAIL_MODES set + DetailsMode/SectionName imports (parseDetailsMode + isSectionName narrow + return), centralize usage strings - ToolTrail: collapse 4 separate xxxSection vars into one memoized `visible` map; effect deps stabilize on the memo identity instead of 4 primitives --- ui-tui/src/app/slash/commands/core.ts | 61 ++++++++------------- ui-tui/src/components/thinking.tsx | 73 +++++++++++++------------ ui-tui/src/domain/details.ts | 78 ++++++++++----------------- 3 files changed, 88 insertions(+), 124 deletions(-) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 9483de0f5..95a26bcc1 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -10,7 +10,7 @@ import type { } from '../../../gatewayTypes.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js' -import type { DetailsMode, Msg, PanelSection, SectionName } from '../../../types.js' +import type { Msg, PanelSection } from '../../../types.js' import type { StatusBarMode } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' @@ -38,7 +38,11 @@ const flagFromArg = (arg: string, current: boolean): boolean | null => { return null } -const DETAIL_MODES = new Set(['collapsed', 'cycle', 'expanded', 'hidden', 'toggle']) +const RESET_WORDS = new Set(['reset', 'clear', 'default']) +const CYCLE_WORDS = new Set(['cycle', 'toggle']) +const DETAILS_USAGE = + 'usage: /details [hidden|collapsed|expanded|cycle] or /details
[hidden|collapsed|expanded|reset]' +const DETAILS_SECTION_USAGE = 'usage: /details
[hidden|collapsed|expanded|reset]' export const coreCommands: SlashCommand[] = [ { @@ -150,9 +154,7 @@ export const coreCommands: SlashCommand[] = [ gateway .rpc('config.get', { key: 'details_mode' }) .then(r => { - if (ctx.stale()) { - return - } + if (ctx.stale()) return const mode = parseDetailsMode(r?.value) ?? ui.detailsMode patchUiState({ detailsMode: mode }) @@ -164,59 +166,38 @@ export const coreCommands: SlashCommand[] = [ transcript.sys(`details: ${mode}${overrides ? ` (${overrides})` : ''}`) }) - .catch(() => { - if (!ctx.stale()) { - transcript.sys(`details: ${ui.detailsMode}`) - } - }) + .catch(() => !ctx.stale() && transcript.sys(`details: ${ui.detailsMode}`)) return } - const tokens = arg.trim().toLowerCase().split(/\s+/) + const [first, second] = arg.trim().toLowerCase().split(/\s+/) - // Per-section override: `/details
` - if (tokens.length >= 2 && isSectionName(tokens[0])) { - const section = tokens[0] as SectionName - const action = tokens[1] ?? '' + if (second && isSectionName(first)) { + const reset = RESET_WORDS.has(second) + const mode = reset ? null : parseDetailsMode(second) - if (action === 'reset' || action === 'clear' || action === 'default') { - const { [section]: _drop, ...rest } = ui.sections - patchUiState({ sections: rest }) - gateway - .rpc('config.set', { key: `details_mode.${section}`, value: '' }) - .catch(() => {}) - transcript.sys(`details ${section}: reset`) - - return + if (!reset && !mode) { + return transcript.sys(DETAILS_SECTION_USAGE) } - const sectionMode = parseDetailsMode(action) + const { [first]: _drop, ...rest } = ui.sections - if (!sectionMode) { - return transcript.sys('usage: /details
[hidden|collapsed|expanded|reset]') - } - - patchUiState({ sections: { ...ui.sections, [section]: sectionMode } }) + patchUiState({ sections: mode ? { ...rest, [first]: mode } : rest }) gateway - .rpc('config.set', { key: `details_mode.${section}`, value: sectionMode }) + .rpc('config.set', { key: `details_mode.${first}`, value: mode ?? '' }) .catch(() => {}) - transcript.sys(`details ${section}: ${sectionMode}`) + transcript.sys(`details ${first}: ${mode ?? 'reset'}`) return } - // Global mode (existing behavior). - const mode = tokens[0] ?? '' + const next = CYCLE_WORDS.has(first ?? '') ? nextDetailsMode(ui.detailsMode) : parseDetailsMode(first) - if (!DETAIL_MODES.has(mode)) { - return transcript.sys( - 'usage: /details [hidden|collapsed|expanded|cycle] or /details
[hidden|collapsed|expanded|reset]' - ) + if (!next) { + return transcript.sys(DETAILS_USAGE) } - const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) - patchUiState({ detailsMode: next }) gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) transcript.sys(`details: ${next}`) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 9b3285a36..fb4e9687e 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -707,34 +707,39 @@ export const ToolTrail = memo(function ToolTrail({ trail?: string[] activity?: ActivityItem[] }) { - const thinkingSection = sectionMode('thinking', detailsMode, sections) - const toolsSection = sectionMode('tools', detailsMode, sections) - const subagentsSection = sectionMode('subagents', detailsMode, sections) - const activitySection = sectionMode('activity', detailsMode, sections) + const visible = useMemo( + () => ({ + thinking: sectionMode('thinking', detailsMode, sections), + tools: sectionMode('tools', detailsMode, sections), + subagents: sectionMode('subagents', detailsMode, sections), + activity: sectionMode('activity', detailsMode, sections) + }), + [detailsMode, sections] + ) const [now, setNow] = useState(() => Date.now()) - const [openThinking, setOpenThinking] = useState(thinkingSection === 'expanded') - const [openTools, setOpenTools] = useState(toolsSection === 'expanded') - const [openSubagents, setOpenSubagents] = useState(subagentsSection === 'expanded') - const [deepSubagents, setDeepSubagents] = useState(subagentsSection === 'expanded') - const [openMeta, setOpenMeta] = useState(activitySection === 'expanded') + const [openThinking, setOpenThinking] = useState(visible.thinking === 'expanded') + const [openTools, setOpenTools] = useState(visible.tools === 'expanded') + const [openSubagents, setOpenSubagents] = useState(visible.subagents === 'expanded') + const [deepSubagents, setDeepSubagents] = useState(visible.subagents === 'expanded') + const [openMeta, setOpenMeta] = useState(visible.activity === 'expanded') useEffect(() => { - if (!tools.length || (toolsSection !== 'expanded' && !openTools)) { + if (!tools.length || (visible.tools !== 'expanded' && !openTools)) { return } const id = setInterval(() => setNow(Date.now()), 500) return () => clearInterval(id) - }, [toolsSection, openTools, tools.length]) + }, [openTools, tools.length, visible.tools]) useEffect(() => { - setOpenThinking(thinkingSection === 'expanded') - setOpenTools(toolsSection === 'expanded') - setOpenSubagents(subagentsSection === 'expanded') - setOpenMeta(activitySection === 'expanded') - }, [thinkingSection, toolsSection, subagentsSection, activitySection]) + setOpenThinking(visible.thinking === 'expanded') + setOpenTools(visible.tools === 'expanded') + setOpenSubagents(visible.subagents === 'expanded') + setOpenMeta(visible.activity === 'expanded') + }, [visible]) const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) @@ -877,7 +882,7 @@ export const ToolTrail = memo(function ToolTrail({ // override means "I don't want to see meta at all", so respect it. if (detailsMode === 'hidden') { - if (activitySection === 'hidden') { + if (visible.activity === 'hidden') { return null } @@ -900,13 +905,13 @@ export const ToolTrail = memo(function ToolTrail({ // hidden sections stay hidden so the override is honoured. const expandAll = () => { - if (thinkingSection !== 'hidden') setOpenThinking(true) - if (toolsSection !== 'hidden') setOpenTools(true) - if (subagentsSection !== 'hidden') { + if (visible.thinking !== 'hidden') setOpenThinking(true) + if (visible.tools !== 'hidden') setOpenTools(true) + if (visible.subagents !== 'hidden') { setOpenSubagents(true) setDeepSubagents(true) } - if (activitySection !== 'hidden') setOpenMeta(true) + if (visible.activity !== 'hidden') setOpenMeta(true) } const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') @@ -920,7 +925,7 @@ export const ToolTrail = memo(function ToolTrail({ {spawnTree.map((node, index) => ( ReactNode }[] = [] - if (hasThinking && thinkingSection !== 'hidden') { + if (hasThinking && visible.thinking !== 'hidden') { panels.push({ header: ( - {thinkingSection === 'expanded' || openThinking ? '▾ ' : '▸ '} + {visible.thinking === 'expanded' || openThinking ? '▾ ' : '▸ '} {thinkingLive ? ( Thinking @@ -971,7 +976,7 @@ export const ToolTrail = memo(function ToolTrail({ ), key: 'thinking', - open: thinkingSection === 'expanded' || openThinking, + open: visible.thinking === 'expanded' || openThinking, render: rails => ( !v) } }} - open={toolsSection === 'expanded' || openTools} + open={visible.tools === 'expanded' || openTools} suffix={toolTokensLabel} t={t} title="Tool calls" /> ), key: 'tools', - open: toolsSection === 'expanded' || openTools, + open: visible.tools === 'expanded' || openTools, render: rails => ( {groups.map((group, index) => { @@ -1045,7 +1050,7 @@ export const ToolTrail = memo(function ToolTrail({ }) } - if (hasSubagents && !inlineDelegateKey && subagentsSection !== 'hidden') { + if (hasSubagents && !inlineDelegateKey && visible.subagents !== 'hidden') { // Spark + summary give a one-line read on the branch shape before // opening the subtree. `/agents` opens the full-screen audit overlay. const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)` @@ -1063,19 +1068,19 @@ export const ToolTrail = memo(function ToolTrail({ setDeepSubagents(false) } }} - open={subagentsSection === 'expanded' || openSubagents} + open={visible.subagents === 'expanded' || openSubagents} suffix={suffix} t={t} title="Spawn tree" /> ), key: 'subagents', - open: subagentsSection === 'expanded' || openSubagents, + open: visible.subagents === 'expanded' || openSubagents, render: renderSubagentList }) } - if (hasMeta && activitySection !== 'hidden') { + if (hasMeta && visible.activity !== 'hidden') { panels.push({ header: ( !v) } }} - open={activitySection === 'expanded' || openMeta} + open={visible.activity === 'expanded' || openMeta} t={t} title="Activity" tone={metaTone} /> ), key: 'meta', - open: activitySection === 'expanded' || openMeta, + open: visible.activity === 'expanded' || openMeta, render: rails => ( {meta.map((row, index) => ( diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts index 059dcdabf..752d44a75 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -1,7 +1,15 @@ import type { DetailsMode, SectionName, SectionVisibility } from '../types.js' const MODES = ['hidden', 'collapsed', 'expanded'] as const -export const SECTION_NAMES: readonly SectionName[] = ['thinking', 'tools', 'subagents', 'activity'] + +export const SECTION_NAMES = ['thinking', 'tools', 'subagents', 'activity'] as const + +// Activity panel = ambient meta (gateway hints, terminal-parity nudges, +// background-process notifications). Hidden out of the box because tool +// failures already render inline on the failing tool row — the panel itself +// is noise for typical use. Opt back in via `display.sections.activity` or +// `/details activity collapsed`. +const SECTION_DEFAULTS: SectionVisibility = { activity: 'hidden' } const THINKING_FALLBACK: Record = { collapsed: 'collapsed', @@ -9,66 +17,36 @@ const THINKING_FALLBACK: Record = { truncated: 'collapsed' } -export const parseDetailsMode = (v: unknown): DetailsMode | null => { - const s = typeof v === 'string' ? v.trim().toLowerCase() : '' +const norm = (v: unknown) => String(v ?? '').trim().toLowerCase() - return MODES.find(m => m === s) ?? 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) export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode => - parseDetailsMode(d?.details_mode) ?? - THINKING_FALLBACK[ - String(d?.thinking_mode ?? '') - .trim() - .toLowerCase() - ] ?? - 'collapsed' + parseDetailsMode(d?.details_mode) ?? THINKING_FALLBACK[norm(d?.thinking_mode)] ?? 'collapsed' -// Build a SectionVisibility from a free-form `display.sections` config blob. -// Skips keys that aren't recognized section names or don't parse to a valid -// mode — partial overrides are intentional, missing keys fall through to the -// global details_mode at render time. -export const resolveSections = (raw: unknown): SectionVisibility => { - const out: SectionVisibility = {} +// Build SectionVisibility from a free-form blob. Unknown section names and +// invalid modes are dropped silently — partial overrides are intentional, so +// missing keys fall through to SECTION_DEFAULTS / global at lookup time. +export const resolveSections = (raw: unknown): SectionVisibility => + raw && typeof raw === 'object' && !Array.isArray(raw) + ? (Object.fromEntries( + Object.entries(raw as Record) + .map(([k, v]) => [k, parseDetailsMode(v)] as const) + .filter(([k, m]) => !!m && isSectionName(k)) + ) as SectionVisibility) + : {} - if (!raw || typeof raw !== 'object') { - return out - } - - for (const [k, v] of Object.entries(raw as Record)) { - const mode = parseDetailsMode(v) - - if (mode && isSectionName(k)) { - out[k] = mode - } - } - - return out -} - -// Built-in per-section defaults applied when the user has no explicit -// override. The activity panel (gateway hints, terminal-parity nudges, -// background-process notifications) is hidden out of the box — it's noise -// for the typical day-to-day user, who only cares about thinking + tools + -// streamed content. Tool failures still surface inline on the failing tool -// row; this default only suppresses the ambient meta feed. -// -// Opt back in with `display.sections.activity: collapsed` (under chevron) -// or `expanded` (always open) in `~/.hermes/config.yaml`, or live with -// `/details activity collapsed`. -const SECTION_DEFAULTS: SectionVisibility = { activity: 'hidden' } - -// Resolve the effective mode for one section: explicit override wins, -// then the SECTION_DEFAULTS fallback, then the global details_mode. -// Single source of truth — every render site that needs to know "is this -// section open by default" calls this. +// 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 nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]! +export const nextDetailsMode = (m: DetailsMode): DetailsMode => + MODES[(MODES.indexOf(m) + 1) % MODES.length]! From 70925363b66790661a4e381ca7fa01111df60dbf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Apr 2026 02:49:58 -0500 Subject: [PATCH 4/6] 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]! From 67bfd4b8283c2f5f7e9fbfe7681459f39990c5ee Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Apr 2026 02:53:44 -0500 Subject: [PATCH 5/6] feat(tui): stream thinking + tools expanded by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends SECTION_DEFAULTS so the out-of-the-box TUI shows the turn as a live transcript (reasoning + tool calls streaming inline) instead of a wall of `▸` chevrons the user has to click every turn. Final default matrix: - thinking: expanded - tools: expanded - activity: hidden (unchanged from the previous commit) - subagents: falls through to details_mode (collapsed by default) Everything explicit in `display.sections` still wins, so anyone who already pinned an override keeps their layout. One-line revert is `display.sections.: collapsed`. --- ui-tui/src/__tests__/details.test.ts | 19 +++++++++++----- ui-tui/src/domain/details.ts | 27 ++++++++++++++++++----- website/docs/user-guide/tui.md | 33 ++++++++++++++++------------ 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/ui-tui/src/__tests__/details.test.ts b/ui-tui/src/__tests__/details.test.ts index 56b82087e..15ef681dc 100644 --- a/ui-tui/src/__tests__/details.test.ts +++ b/ui-tui/src/__tests__/details.test.ts @@ -73,10 +73,16 @@ describe('resolveSections', () => { describe('sectionMode', () => { it('falls back to the global mode for sections without a built-in default', () => { - expect(sectionMode('tools', 'collapsed', {})).toBe('collapsed') - expect(sectionMode('tools', 'expanded', undefined)).toBe('expanded') - expect(sectionMode('thinking', 'collapsed', {})).toBe('collapsed') - expect(sectionMode('subagents', 'expanded', {})).toBe('expanded') + expect(sectionMode('subagents', 'collapsed', {})).toBe('collapsed') + expect(sectionMode('subagents', 'expanded', undefined)).toBe('expanded') + expect(sectionMode('subagents', 'hidden', {})).toBe('hidden') + }) + + it('streams thinking + tools expanded by default regardless of global mode', () => { + expect(sectionMode('thinking', 'collapsed', {})).toBe('expanded') + expect(sectionMode('thinking', 'hidden', undefined)).toBe('expanded') + expect(sectionMode('tools', 'collapsed', {})).toBe('expanded') + expect(sectionMode('tools', 'hidden', undefined)).toBe('expanded') }) it('hides the activity panel by default regardless of global mode', () => { @@ -86,16 +92,17 @@ describe('sectionMode', () => { }) it('honours per-section overrides over both the section default and global mode', () => { + expect(sectionMode('thinking', 'collapsed', { thinking: 'collapsed' })).toBe('collapsed') + expect(sectionMode('tools', 'collapsed', { tools: 'hidden' })).toBe('hidden') expect(sectionMode('activity', 'collapsed', { activity: 'expanded' })).toBe('expanded') 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('subagents', 'hidden', { subagents: '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/domain/details.ts b/ui-tui/src/domain/details.ts index 75f25a3aa..079b08ea7 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -4,12 +4,27 @@ const MODES = ['hidden', 'collapsed', 'expanded'] as const export const SECTION_NAMES = ['thinking', 'tools', 'subagents', 'activity'] as const -// Activity panel = ambient meta (gateway hints, terminal-parity nudges, -// background-process notifications). Hidden out of the box because tool -// failures already render inline on the failing tool row — the panel itself -// is noise for typical use. Opt back in via `display.sections.activity` or -// `/details activity collapsed`. -const SECTION_DEFAULTS: SectionVisibility = { activity: 'hidden' } +// Out-of-the-box per-section defaults — applied when the user hasn't pinned +// an explicit override and layered ABOVE the global details_mode: +// +// - thinking / tools: expanded — stream open so the turn reads like a +// live transcript (reasoning + tool calls side by side) instead of a +// wall of chevrons the user has to click every turn. +// - activity: hidden — ambient meta (gateway hints, terminal-parity +// nudges, background notifications) is noise for typical use. Tool +// failures still render inline on the failing tool row, and ambient +// errors/warnings surface via the floating-alert backstop when every +// panel resolves to hidden. +// - subagents: not set — falls through to the global details_mode so +// Spawn trees stay under a chevron until a delegation actually happens. +// +// Opt out of any of these with `display.sections.` in config.yaml +// or at runtime via `/details collapsed|hidden`. +const SECTION_DEFAULTS: SectionVisibility = { + thinking: 'expanded', + tools: 'expanded', + activity: 'hidden' +} const THINKING_FALLBACK: Record = { collapsed: 'collapsed', diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 7e76653c5..8c1b179b6 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -132,24 +132,29 @@ Runtime toggles: **Default visibility** -- `thinking`, `tools`, `subagents` — fall through to the global `details_mode` - (collapsed under chevron by default, click to expand). -- `activity` — **hidden by default**. The activity panel surfaces ambient - meta (gateway hints, terminal-parity nudges, background notifications) and - is noise for most day-to-day use. Tool failures still render inline on the - failing tool row, so this default suppresses the noise feed without losing - the signal. +The TUI ships with opinionated per-section defaults that stream the turn as +a live transcript instead of a wall of chevrons: + +- `thinking` — **expanded**. Reasoning streams inline as the model emits it. +- `tools` — **expanded**. Tool calls and their results render open. +- `subagents` — falls through to the global `details_mode` (collapsed under + chevron by default — stays quiet until a delegation actually happens). +- `activity` — **hidden**. Ambient meta (gateway hints, terminal-parity + nudges, background notifications) is noise for most day-to-day use. Tool + failures still render inline on the failing tool row; ambient + errors/warnings surface via a floating-alert backstop when every panel + is hidden. Per-section overrides take precedence over both the section default and the -global `details_mode`. To opt the activity panel back in: +global `details_mode`. To reshape the layout: -- `display.sections.activity: collapsed` — under a chevron -- `display.sections.activity: expanded` — always open -- `/details activity collapsed` at runtime +- `display.sections.thinking: collapsed` — put thinking back under a chevron +- `display.sections.tools: collapsed` — put tool calls back under a chevron +- `display.sections.activity: collapsed` — opt the activity panel back in +- `/details
` at runtime -With `activity: hidden` (the default), errors/warnings are suppressed entirely -— the floating-alert fallback that surfaces under `details_mode: hidden` is -silenced as well. +Anything set explicitly in `display.sections` wins over the defaults, so +existing configs keep working unchanged. ## Sessions From 6604e94c75ac4b4967e17c5456411267c7ecdb89 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Apr 2026 03:01:06 -0500 Subject: [PATCH 6/6] fix(tui): gate messageLine on content-bearing sections, not all sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 Copilot review on #14968 caught two leftover spots that didn't fully respect per-section overrides: - messageLine.tsx (trail branch): the previous fix gated on `SECTION_NAMES.some(...)`, which stayed true whenever any section was visible. With `thinking: 'expanded'` as the new built-in default, that meant `display.sections.tools: hidden` left an empty wrapper Box alive for trail messages. Now gates on the actual content-bearing sections for a trail message — `tools` OR `activity` — so a tools-hidden config drops the wrapper cleanly. - messageLine.tsx (showDetails): still keyed off the global `detailsMode !== 'hidden'`, so per-section overrides like `sections.thinking: expanded` couldn't escape global hidden for assistant messages with reasoning + tool metadata. Recomputed via resolved per-section modes (`thinkingMode`/`toolsMode`). - types.ts: rewrote the SectionVisibility doc comment to reflect the actual resolution order (explicit override → SECTION_DEFAULTS → global), so the docstring stops claiming "missing keys fall back to the global mode" when SECTION_DEFAULTS now layers in between. All three lookups (thinking/tools/activity) are computed once at the top of MessageLine and shared by every branch. --- ui-tui/src/components/messageLine.tsx | 25 +++++++++++++++++-------- ui-tui/src/types.ts | 10 +++++----- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index f2c0241ff..a73a4f01d 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,7 +1,7 @@ import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' -import { SECTION_NAMES, sectionMode } from '../domain/details.js' +import { 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,13 +21,19 @@ export const MessageLine = memo(function MessageLine({ sections, t }: MessageLineProps) { - if (msg.kind === 'trail' && msg.tools?.length) { - // 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') + // Per-section overrides win over the global mode, so resolve each section + // we might consume here once and gate visibility on the *content-bearing* + // sections only — never on the global mode. A `trail` message feeds Tool + // calls + Activity; an assistant message with thinking/tools metadata + // feeds Thinking + Tool calls. Gating on every section would let + // `thinking` (expanded by default) keep an empty wrapper alive when only + // `tools` is hidden — exactly the empty-Box bug Copilot caught. + const thinkingMode = sectionMode('thinking', detailsMode, sections) + const toolsMode = sectionMode('tools', detailsMode, sections) + const activityMode = sectionMode('activity', detailsMode, sections) - return anyVisible ? ( + if (msg.kind === 'trail' && msg.tools?.length) { + return toolsMode !== 'hidden' || activityMode !== 'hidden' ? ( @@ -56,7 +62,10 @@ export const MessageLine = memo(function MessageLine({ const { body, glyph, prefix } = ROLE[msg.role](t) const thinking = msg.thinking?.trim() ?? '' - const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking)) + + const showDetails = + (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || + (thinkingMode !== 'hidden' && Boolean(thinking)) const content = (() => { if (msg.kind === 'slash') { diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index eb8053610..3fdb39b82 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -116,11 +116,11 @@ export type Role = 'assistant' | 'system' | 'tool' | 'user' export type DetailsMode = 'hidden' | 'collapsed' | 'expanded' export type ThinkingMode = 'collapsed' | 'truncated' | 'full' -// Per-section overrides on top of the global DetailsMode. Each missing key -// falls back to the global mode; an explicit value overrides for that one -// section only — so users can keep the accordion collapsed by default while -// auto-expanding tools, or hide the activity panel entirely without touching -// thinking/tools/subagents. +// Per-section overrides for the agent details accordion. Resolution order +// at lookup time is: explicit `display.sections.` → built-in +// SECTION_DEFAULTS → global `details_mode`. Today the built-in defaults +// expand `thinking`/`tools` and hide `activity`; `subagents` falls through +// to the global mode. Any explicit value still wins for that one section. export type SectionName = 'thinking' | 'tools' | 'subagents' | 'activity' export type SectionVisibility = Partial>