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 <section> <mode|reset>       per-section override (new)
  /details <section> 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 <section> <mode|reset> syntax
- tui_gateway/server.py           config.set details_mode.<section>
                                  writes to display.sections.<section>
                                  (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.
This commit is contained in:
Brooklyn Nicholson 2026-04-24 02:34:32 -05:00
parent 6051fba9dc
commit 78481ac124
16 changed files with 478 additions and 70 deletions

View file

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

View file

@ -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.<section>` writes to
# `display.sections.<section>`. 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"})

View file

@ -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.<section>', () => {
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 <section> 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 <section> [hidden|collapsed|expanded|reset]'
)
})
it('shows tool enable usage when names are missing', () => {
const ctx = buildCtx()

View file

@ -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')
})
})

View file

@ -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', () => {

View file

@ -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

View file

@ -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 <section> [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 <section> <mode>`
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<ConfigSetResponse>('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 <section> [hidden|collapsed|expanded|reset]')
}
patchUiState({ sections: { ...ui.sections, [section]: sectionMode } })
gateway
.rpc<ConfigSetResponse>('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 <section> [hidden|collapsed|expanded|reset]'
)
}
const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode)

View file

@ -12,6 +12,7 @@ const buildUiState = (): UiState => ({
detailsMode: 'collapsed',
info: null,
inlineDiffs: true,
sections: {},
showCost: false,
showReasoning: false,
sid: null,

View file

@ -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),

View file

@ -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) => (
<MessageLine cols={cols} compact={compact} detailsMode={detailsMode} key={`seg:${i}`} msg={msg} t={t} />
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
key={`seg:${i}`}
msg={msg}
sections={sections}
t={t}
/>
))}
{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}
/>
</Box>
@ -337,5 +351,6 @@ interface StreamingAssistantProps {
compact?: boolean
detailsMode: DetailsMode
progress: AppLayoutProgressProps
sections?: SectionVisibility
t: Theme
}

View file

@ -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 : (
<Box flexDirection="column" marginTop={1}>
<ToolTrail detailsMode={detailsMode} t={t} trail={msg.tools} />
<ToolTrail detailsMode={detailsMode} sections={sections} t={t} trail={msg.tools} />
</Box>
)
}
@ -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
}

View file

@ -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) => (
<SubagentAccordion
branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
expanded={detailsMode === 'expanded' || deepSubagents}
expanded={subagentsSection === 'expanded' || deepSubagents}
key={node.item.id}
node={node}
peak={spawnPeak}
@ -910,15 +931,15 @@ export const ToolTrail = memo(function ToolTrail({
</Box>
)
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: (
<Box
onClick={(e: any) => {
@ -930,7 +951,7 @@ export const ToolTrail = memo(function ToolTrail({
}}
>
<Text color={t.color.dim} dim={!thinkingLive}>
<Text color={t.color.amber}>{detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
<Text color={t.color.amber}>{thinkingSection === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
{thinkingLive ? (
<Text bold color={t.color.cornsilk}>
Thinking
@ -950,7 +971,7 @@ export const ToolTrail = memo(function ToolTrail({
</Box>
),
key: 'thinking',
open: detailsMode === 'expanded' || openThinking,
open: thinkingSection === 'expanded' || openThinking,
render: rails => (
<Thinking
active={reasoningActive}
@ -965,8 +986,8 @@ export const ToolTrail = memo(function ToolTrail({
})
}
if (hasTools) {
sections.push({
if (hasTools && toolsSection !== 'hidden') {
panels.push({
header: (
<Chevron
count={groups.length}
@ -977,14 +998,14 @@ export const ToolTrail = memo(function ToolTrail({
setOpenTools(v => !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 => (
<Box flexDirection="column">
{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: (
<Chevron
count={spawnTotals.descendantCount}
@ -1042,20 +1063,20 @@ export const ToolTrail = memo(function ToolTrail({
setDeepSubagents(false)
}
}}
open={detailsMode === 'expanded' || openSubagents}
open={subagentsSection === 'expanded' || openSubagents}
suffix={suffix}
t={t}
title="Spawn tree"
/>
),
key: 'subagents',
open: detailsMode === 'expanded' || openSubagents,
open: subagentsSection === 'expanded' || openSubagents,
render: renderSubagentList
})
}
if (hasMeta) {
sections.push({
if (hasMeta && activitySection !== 'hidden') {
panels.push({
header: (
<Chevron
count={meta.length}
@ -1066,14 +1087,14 @@ export const ToolTrail = memo(function ToolTrail({
setOpenMeta(v => !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 => (
<Box flexDirection="column">
{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 (
<Box flexDirection="column">
{sections.map((section, index) => (
{panels.map((panel, index) => (
<TreeNode
branch={index === topCount - 1 ? 'last' : 'mid'}
header={section.header}
key={section.key}
open={section.open}
header={panel.header}
key={panel.key}
open={panel.open}
t={t}
>
{section.render}
{panel.render}
</TreeNode>
))}
{totalTokensLabel ? (

View file

@ -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<string, DetailsMode> = {
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<string, unknown>)) {
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]!

View file

@ -55,6 +55,7 @@ export interface ConfigDisplayConfig {
bell_on_complete?: boolean
details_mode?: string
inline_diffs?: boolean
sections?: Record<string, string>
show_cost?: boolean
show_reasoning?: boolean
streaming?: boolean

View file

@ -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<Record<SectionName, DetailsMode>>
export interface McpServerStatus {
connected: boolean
name: string

View file

@ -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 <section> [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