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" 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): def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_SESSION", 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) _write_config_key("display.details_mode", nv)
return _ok(rid, {"key": key, "value": 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": if key == "thinking_mode":
nv = str(value or "").strip().lower() nv = str(value or "").strip().lower()
allowed_tm = frozenset({"collapsed", "truncated", "full"}) allowed_tm = frozenset({"collapsed", "truncated", "full"})

View file

@ -88,6 +88,41 @@ describe('createSlashHandler', () => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded') 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', () => { it('shows tool enable usage when names are missing', () => {
const ctx = buildCtx() 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.showReasoning).toBe(false)
expect(s.statusBar).toBe('top') expect(s.statusBar).toBe('top')
expect(s.streaming).toBe(true) 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', () => { it('treats a null config like an empty display block', () => {

View file

@ -16,6 +16,7 @@ import type {
Msg, Msg,
PanelSection, PanelSection,
SecretReq, SecretReq,
SectionVisibility,
SessionInfo, SessionInfo,
SlashCatalog, SlashCatalog,
SubagentProgress, SubagentProgress,
@ -87,6 +88,7 @@ export interface UiState {
detailsMode: DetailsMode detailsMode: DetailsMode
info: null | SessionInfo info: null | SessionInfo
inlineDiffs: boolean inlineDiffs: boolean
sections: SectionVisibility
showCost: boolean showCost: boolean
showReasoning: boolean showReasoning: boolean
sid: null | string sid: null | string

View file

@ -1,7 +1,7 @@
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.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 { import type {
ConfigGetValueResponse, ConfigGetValueResponse,
ConfigSetResponse, ConfigSetResponse,
@ -10,7 +10,7 @@ import type {
} from '../../../gatewayTypes.js' } from '../../../gatewayTypes.js'
import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.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 type { StatusBarMode } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js' import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js' import { patchUiState } from '../../uiStore.js'
@ -57,7 +57,8 @@ export const coreCommands: SlashCommand[] = [
sections.push( sections.push(
{ {
rows: [ 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'] ['/fortune [random|daily]', 'show a random or daily local fortune']
], ],
title: 'TUI' title: 'TUI'
@ -140,7 +141,7 @@ export const coreCommands: SlashCommand[] = [
{ {
aliases: ['detail'], aliases: ['detail'],
help: 'control agent detail visibility', help: 'control agent detail visibility (global or per-section)',
name: 'details', name: 'details',
run: (arg, ctx) => { run: (arg, ctx) => {
const { gateway, transcript, ui } = ctx const { gateway, transcript, ui } = ctx
@ -154,9 +155,14 @@ export const coreCommands: SlashCommand[] = [
} }
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
patchUiState({ detailsMode: mode }) 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(() => { .catch(() => {
if (!ctx.stale()) { if (!ctx.stale()) {
@ -167,10 +173,46 @@ export const coreCommands: SlashCommand[] = [
return 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)) { 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) const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode)

View file

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

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react' 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 { GatewayClient } from '../gatewayClient.js'
import type { import type {
ConfigFullResponse, ConfigFullResponse,
@ -46,6 +46,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
compact: !!d.tui_compact, compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d), detailsMode: resolveDetailsMode(d),
inlineDiffs: d.inline_diffs !== false, inlineDiffs: d.inline_diffs !== false,
sections: resolveSections(d.sections),
showCost: !!d.show_cost, showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning, showReasoning: !!d.show_reasoning,
statusBar: normalizeStatusBar(d.tui_statusbar), 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 { $uiState } from '../app/uiStore.js'
import { PLACEHOLDER } from '../content/placeholders.js' import { PLACEHOLDER } from '../content/placeholders.js'
import type { Theme } from '../theme.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 { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
@ -25,6 +25,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
compact, compact,
detailsMode, detailsMode,
progress, progress,
sections,
t t
}: StreamingAssistantProps) { }: StreamingAssistantProps) {
if (!progress.showProgressArea && !progress.showStreamingArea) { if (!progress.showProgressArea && !progress.showStreamingArea) {
@ -34,7 +35,15 @@ const StreamingAssistant = memo(function StreamingAssistant({
return ( return (
<> <>
{progress.streamSegments.map((msg, i) => ( {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 && ( {progress.showProgressArea && (
@ -48,6 +57,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
reasoningActive={progress.reasoningActive} reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming} reasoningStreaming={progress.reasoningStreaming}
reasoningTokens={progress.reasoningTokens} reasoningTokens={progress.reasoningTokens}
sections={sections}
subagents={progress.subagents} subagents={progress.subagents}
t={t} t={t}
tools={progress.tools} tools={progress.tools}
@ -68,6 +78,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
text: progress.streaming, text: progress.streaming,
...(progress.streamPendingTools.length && { tools: progress.streamPendingTools }) ...(progress.streamPendingTools.length && { tools: progress.streamPendingTools })
}} }}
sections={sections}
t={t} t={t}
/> />
)} )}
@ -78,6 +89,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
compact={compact} compact={compact}
detailsMode={detailsMode} detailsMode={detailsMode}
msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }} msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }}
sections={sections}
t={t} t={t}
/> />
)} )}
@ -115,6 +127,7 @@ const TranscriptPane = memo(function TranscriptPane({
compact={ui.compact} compact={ui.compact}
detailsMode={ui.detailsMode} detailsMode={ui.detailsMode}
msg={row.msg} msg={row.msg}
sections={ui.sections}
t={ui.theme} t={ui.theme}
/> />
)} )}
@ -129,6 +142,7 @@ const TranscriptPane = memo(function TranscriptPane({
compact={ui.compact} compact={ui.compact}
detailsMode={ui.detailsMode} detailsMode={ui.detailsMode}
progress={progress} progress={progress}
sections={ui.sections}
t={ui.theme} t={ui.theme}
/> />
</Box> </Box>
@ -337,5 +351,6 @@ interface StreamingAssistantProps {
compact?: boolean compact?: boolean
detailsMode: DetailsMode detailsMode: DetailsMode
progress: AppLayoutProgressProps progress: AppLayoutProgressProps
sections?: SectionVisibility
t: Theme t: Theme
} }

View file

@ -6,7 +6,7 @@ import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js' import { ROLE } from '../domain/roles.js'
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js' import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
import type { Theme } from '../theme.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 { Md } from './markdown.js'
import { ToolTrail } from './thinking.js' import { ToolTrail } from './thinking.js'
@ -17,12 +17,13 @@ export const MessageLine = memo(function MessageLine({
detailsMode = 'collapsed', detailsMode = 'collapsed',
isStreaming = false, isStreaming = false,
msg, msg,
sections,
t t
}: MessageLineProps) { }: MessageLineProps) {
if (msg.kind === 'trail' && msg.tools?.length) { if (msg.kind === 'trail' && msg.tools?.length) {
return detailsMode === 'hidden' ? null : ( return detailsMode === 'hidden' ? null : (
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
<ToolTrail detailsMode={detailsMode} t={t} trail={msg.tools} /> <ToolTrail detailsMode={detailsMode} sections={sections} t={t} trail={msg.tools} />
</Box> </Box>
) )
} }
@ -98,6 +99,7 @@ export const MessageLine = memo(function MessageLine({
detailsMode={detailsMode} detailsMode={detailsMode}
reasoning={thinking} reasoning={thinking}
reasoningTokens={msg.thinkingTokens} reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t} t={t}
toolTokens={msg.toolTokens} toolTokens={msg.toolTokens}
trail={msg.tools} trail={msg.tools}
@ -124,5 +126,6 @@ interface MessageLineProps {
detailsMode?: DetailsMode detailsMode?: DetailsMode
isStreaming?: boolean isStreaming?: boolean
msg: Msg msg: Msg
sections?: SectionVisibility
t: Theme 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 spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { THINKING_COT_MAX } from '../config/limits.js' import { THINKING_COT_MAX } from '../config/limits.js'
import { sectionMode } from '../domain/details.js'
import { import {
buildSubagentTree, buildSubagentTree,
fmtCost, fmtCost,
@ -25,7 +26,15 @@ import {
toolTrailLabel toolTrailLabel
} from '../lib/text.js' } from '../lib/text.js'
import type { Theme } from '../theme.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 THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
@ -675,6 +684,7 @@ export const ToolTrail = memo(function ToolTrail({
reasoning = '', reasoning = '',
reasoningTokens, reasoningTokens,
reasoningStreaming = false, reasoningStreaming = false,
sections,
subagents = [], subagents = [],
t, t,
tools = [], tools = [],
@ -689,6 +699,7 @@ export const ToolTrail = memo(function ToolTrail({
reasoning?: string reasoning?: string
reasoningTokens?: number reasoningTokens?: number
reasoningStreaming?: boolean reasoningStreaming?: boolean
sections?: SectionVisibility
subagents?: SubagentProgress[] subagents?: SubagentProgress[]
t: Theme t: Theme
tools?: ActiveTool[] tools?: ActiveTool[]
@ -696,38 +707,34 @@ export const ToolTrail = memo(function ToolTrail({
trail?: string[] trail?: string[]
activity?: ActivityItem[] 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 [now, setNow] = useState(() => Date.now())
const [openThinking, setOpenThinking] = useState(false) const [openThinking, setOpenThinking] = useState(thinkingSection === 'expanded')
const [openTools, setOpenTools] = useState(false) const [openTools, setOpenTools] = useState(toolsSection === 'expanded')
const [openSubagents, setOpenSubagents] = useState(false) const [openSubagents, setOpenSubagents] = useState(subagentsSection === 'expanded')
const [deepSubagents, setDeepSubagents] = useState(false) const [deepSubagents, setDeepSubagents] = useState(subagentsSection === 'expanded')
const [openMeta, setOpenMeta] = useState(false) const [openMeta, setOpenMeta] = useState(activitySection === 'expanded')
useEffect(() => { useEffect(() => {
if (!tools.length || (detailsMode === 'collapsed' && !openTools)) { if (!tools.length || (toolsSection !== 'expanded' && !openTools)) {
return return
} }
const id = setInterval(() => setNow(Date.now()), 500) const id = setInterval(() => setNow(Date.now()), 500)
return () => clearInterval(id) return () => clearInterval(id)
}, [detailsMode, openTools, tools.length]) }, [toolsSection, openTools, tools.length])
useEffect(() => { useEffect(() => {
if (detailsMode === 'expanded') { setOpenThinking(thinkingSection === 'expanded')
setOpenThinking(true) setOpenTools(toolsSection === 'expanded')
setOpenTools(true) setOpenSubagents(subagentsSection === 'expanded')
setOpenSubagents(true) setOpenMeta(activitySection === 'expanded')
setOpenMeta(true) }, [thinkingSection, toolsSection, subagentsSection, activitySection])
}
if (detailsMode === 'hidden') {
setOpenThinking(false)
setOpenTools(false)
setOpenSubagents(false)
setOpenMeta(false)
}
}, [detailsMode])
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) 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 const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null
// ── Hidden: errors/warnings only ────────────────────────────── // ── 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 (detailsMode === 'hidden') {
if (activitySection === 'hidden') {
return null
}
const alerts = activity.filter(i => i.tone !== 'info').slice(-2) const alerts = activity.filter(i => i.tone !== 'info').slice(-2)
return alerts.length ? ( return alerts.length ? (
@ -879,13 +895,18 @@ export const ToolTrail = memo(function ToolTrail({
} }
// ── Tree render fragments ────────────────────────────────────── // ── 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 = () => { const expandAll = () => {
setOpenThinking(true) if (thinkingSection !== 'hidden') setOpenThinking(true)
setOpenTools(true) if (toolsSection !== 'hidden') setOpenTools(true)
setOpenSubagents(true) if (subagentsSection !== 'hidden') {
setDeepSubagents(true) setOpenSubagents(true)
setOpenMeta(true) setDeepSubagents(true)
}
if (activitySection !== 'hidden') setOpenMeta(true)
} }
const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') 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) => ( {spawnTree.map((node, index) => (
<SubagentAccordion <SubagentAccordion
branch={index === spawnTree.length - 1 ? 'last' : 'mid'} branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
expanded={detailsMode === 'expanded' || deepSubagents} expanded={subagentsSection === 'expanded' || deepSubagents}
key={node.item.id} key={node.item.id}
node={node} node={node}
peak={spawnPeak} peak={spawnPeak}
@ -910,15 +931,15 @@ export const ToolTrail = memo(function ToolTrail({
</Box> </Box>
) )
const sections: { const panels: {
header: ReactNode header: ReactNode
key: string key: string
open: boolean open: boolean
render: (rails: boolean[]) => ReactNode render: (rails: boolean[]) => ReactNode
}[] = [] }[] = []
if (hasThinking) { if (hasThinking && thinkingSection !== 'hidden') {
sections.push({ panels.push({
header: ( header: (
<Box <Box
onClick={(e: any) => { onClick={(e: any) => {
@ -930,7 +951,7 @@ export const ToolTrail = memo(function ToolTrail({
}} }}
> >
<Text color={t.color.dim} dim={!thinkingLive}> <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 ? ( {thinkingLive ? (
<Text bold color={t.color.cornsilk}> <Text bold color={t.color.cornsilk}>
Thinking Thinking
@ -950,7 +971,7 @@ export const ToolTrail = memo(function ToolTrail({
</Box> </Box>
), ),
key: 'thinking', key: 'thinking',
open: detailsMode === 'expanded' || openThinking, open: thinkingSection === 'expanded' || openThinking,
render: rails => ( render: rails => (
<Thinking <Thinking
active={reasoningActive} active={reasoningActive}
@ -965,8 +986,8 @@ export const ToolTrail = memo(function ToolTrail({
}) })
} }
if (hasTools) { if (hasTools && toolsSection !== 'hidden') {
sections.push({ panels.push({
header: ( header: (
<Chevron <Chevron
count={groups.length} count={groups.length}
@ -977,14 +998,14 @@ export const ToolTrail = memo(function ToolTrail({
setOpenTools(v => !v) setOpenTools(v => !v)
} }
}} }}
open={detailsMode === 'expanded' || openTools} open={toolsSection === 'expanded' || openTools}
suffix={toolTokensLabel} suffix={toolTokensLabel}
t={t} t={t}
title="Tool calls" title="Tool calls"
/> />
), ),
key: 'tools', key: 'tools',
open: detailsMode === 'expanded' || openTools, open: toolsSection === 'expanded' || openTools,
render: rails => ( render: rails => (
<Box flexDirection="column"> <Box flexDirection="column">
{groups.map((group, index) => { {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 // Spark + summary give a one-line read on the branch shape before
// opening the subtree. `/agents` opens the full-screen audit overlay. // opening the subtree. `/agents` opens the full-screen audit overlay.
const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)` const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)`
sections.push({ panels.push({
header: ( header: (
<Chevron <Chevron
count={spawnTotals.descendantCount} count={spawnTotals.descendantCount}
@ -1042,20 +1063,20 @@ export const ToolTrail = memo(function ToolTrail({
setDeepSubagents(false) setDeepSubagents(false)
} }
}} }}
open={detailsMode === 'expanded' || openSubagents} open={subagentsSection === 'expanded' || openSubagents}
suffix={suffix} suffix={suffix}
t={t} t={t}
title="Spawn tree" title="Spawn tree"
/> />
), ),
key: 'subagents', key: 'subagents',
open: detailsMode === 'expanded' || openSubagents, open: subagentsSection === 'expanded' || openSubagents,
render: renderSubagentList render: renderSubagentList
}) })
} }
if (hasMeta) { if (hasMeta && activitySection !== 'hidden') {
sections.push({ panels.push({
header: ( header: (
<Chevron <Chevron
count={meta.length} count={meta.length}
@ -1066,14 +1087,14 @@ export const ToolTrail = memo(function ToolTrail({
setOpenMeta(v => !v) setOpenMeta(v => !v)
} }
}} }}
open={detailsMode === 'expanded' || openMeta} open={activitySection === 'expanded' || openMeta}
t={t} t={t}
title="Activity" title="Activity"
tone={metaTone} tone={metaTone}
/> />
), ),
key: 'meta', key: 'meta',
open: detailsMode === 'expanded' || openMeta, open: activitySection === 'expanded' || openMeta,
render: rails => ( render: rails => (
<Box flexDirection="column"> <Box flexDirection="column">
{meta.map((row, index) => ( {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 ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{sections.map((section, index) => ( {panels.map((panel, index) => (
<TreeNode <TreeNode
branch={index === topCount - 1 ? 'last' : 'mid'} branch={index === topCount - 1 ? 'last' : 'mid'}
header={section.header} header={panel.header}
key={section.key} key={panel.key}
open={section.open} open={panel.open}
t={t} t={t}
> >
{section.render} {panel.render}
</TreeNode> </TreeNode>
))} ))}
{totalTokensLabel ? ( {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 const MODES = ['hidden', 'collapsed', 'expanded'] as const
export const SECTION_NAMES: readonly SectionName[] = ['thinking', 'tools', 'subagents', 'activity']
const THINKING_FALLBACK: Record<string, DetailsMode> = { const THINKING_FALLBACK: Record<string, DetailsMode> = {
collapsed: 'collapsed', collapsed: 'collapsed',
@ -14,6 +15,9 @@ export const parseDetailsMode = (v: unknown): DetailsMode | null => {
return MODES.find(m => m === s) ?? 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 => export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode =>
parseDetailsMode(d?.details_mode) ?? parseDetailsMode(d?.details_mode) ??
THINKING_FALLBACK[ THINKING_FALLBACK[
@ -23,4 +27,35 @@ export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?:
] ?? ] ??
'collapsed' '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]! 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 bell_on_complete?: boolean
details_mode?: string details_mode?: string
inline_diffs?: boolean inline_diffs?: boolean
sections?: Record<string, string>
show_cost?: boolean show_cost?: boolean
show_reasoning?: boolean show_reasoning?: boolean
streaming?: boolean streaming?: boolean

View file

@ -116,6 +116,14 @@ export type Role = 'assistant' | 'system' | 'tool' | 'user'
export type DetailsMode = 'hidden' | 'collapsed' | 'expanded' export type DetailsMode = 'hidden' | 'collapsed' | 'expanded'
export type ThinkingMode = 'collapsed' | 'truncated' | 'full' 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 { export interface McpServerStatus {
connected: boolean connected: boolean
name: string 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 | | `/sessions` | Modal session picker — preview, title, token totals, resume inline |
| `/model` | Modal model picker grouped by provider, with cost hints | | `/model` | Modal model picker grouped by provider, with cost hints |
| `/skin` | Live preview — theme change applies as you browse | | `/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 | | `/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). 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 ```yaml
display: display:
skin: default # any built-in or custom skin skin: default # any built-in or custom skin
personality: helpful personality: helpful
details_mode: compact # or "verbose" — default tool-call detail level details_mode: collapsed # hidden | collapsed | expanded — global accordion default
mouse_tracking: true # disable if your terminal conflicts with mouse reporting 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 ## Sessions