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
This commit is contained in:
Brooklyn Nicholson
2026-04-24 02:42:03 -05:00
parent 728767e910
commit 005cc29e98
3 changed files with 88 additions and 124 deletions

View File

@@ -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 <section> [hidden|collapsed|expanded|reset]'
const DETAILS_SECTION_USAGE = 'usage: /details <section> [hidden|collapsed|expanded|reset]'
export const coreCommands: SlashCommand[] = [
{
@@ -150,9 +154,7 @@ export const coreCommands: SlashCommand[] = [
gateway
.rpc<ConfigGetValueResponse>('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 <section> <mode>`
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<ConfigSetResponse>('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 <section> [hidden|collapsed|expanded|reset]')
}
patchUiState({ sections: { ...ui.sections, [section]: sectionMode } })
patchUiState({ sections: mode ? { ...rest, [first]: mode } : rest })
gateway
.rpc<ConfigSetResponse>('config.set', { key: `details_mode.${section}`, value: sectionMode })
.rpc<ConfigSetResponse>('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 <section> [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<ConfigSetResponse>('config.set', { key: 'details_mode', value: next }).catch(() => {})
transcript.sys(`details: ${next}`)

View File

@@ -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) => (
<SubagentAccordion
branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
expanded={subagentsSection === 'expanded' || deepSubagents}
expanded={visible.subagents === 'expanded' || deepSubagents}
key={node.item.id}
node={node}
peak={spawnPeak}
@@ -938,7 +943,7 @@ export const ToolTrail = memo(function ToolTrail({
render: (rails: boolean[]) => ReactNode
}[] = []
if (hasThinking && thinkingSection !== 'hidden') {
if (hasThinking && visible.thinking !== 'hidden') {
panels.push({
header: (
<Box
@@ -951,7 +956,7 @@ export const ToolTrail = memo(function ToolTrail({
}}
>
<Text color={t.color.dim} dim={!thinkingLive}>
<Text color={t.color.amber}>{thinkingSection === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
<Text color={t.color.amber}>{visible.thinking === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
{thinkingLive ? (
<Text bold color={t.color.cornsilk}>
Thinking
@@ -971,7 +976,7 @@ export const ToolTrail = memo(function ToolTrail({
</Box>
),
key: 'thinking',
open: thinkingSection === 'expanded' || openThinking,
open: visible.thinking === 'expanded' || openThinking,
render: rails => (
<Thinking
active={reasoningActive}
@@ -986,7 +991,7 @@ export const ToolTrail = memo(function ToolTrail({
})
}
if (hasTools && toolsSection !== 'hidden') {
if (hasTools && visible.tools !== 'hidden') {
panels.push({
header: (
<Chevron
@@ -998,14 +1003,14 @@ export const ToolTrail = memo(function ToolTrail({
setOpenTools(v => !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 => (
<Box flexDirection="column">
{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: (
<Chevron
@@ -1087,14 +1092,14 @@ export const ToolTrail = memo(function ToolTrail({
setOpenMeta(v => !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 => (
<Box flexDirection="column">
{meta.map((row, index) => (

View File

@@ -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<string, DetailsMode> = {
collapsed: 'collapsed',
@@ -9,66 +17,36 @@ const THINKING_FALLBACK: Record<string, DetailsMode> = {
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<string, unknown>)
.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<string, unknown>)) {
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]!