diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 9483de0f50..95a26bcc10 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 9b3285a369..fb4e9687eb 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 059dcdabff..752d44a75d 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]!