diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 2c50065b2c..107d238977 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -160,6 +160,71 @@ def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): assert saved["display"]["tui_statusbar"] == "bottom" +def test_config_set_section_writes_per_section_override(tmp_path, monkeypatch): + import yaml + + cfg_path = tmp_path / "config.yaml" + monkeypatch.setattr(server, "_hermes_home", tmp_path) + + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "details_mode.activity", "value": "hidden"}, + } + ) + + assert resp["result"] == {"key": "details_mode.activity", "value": "hidden"} + saved = yaml.safe_load(cfg_path.read_text()) + assert saved["display"]["sections"] == {"activity": "hidden"} + + +def test_config_set_section_clears_override_on_empty_value(tmp_path, monkeypatch): + import yaml + + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text( + yaml.safe_dump( + {"display": {"sections": {"activity": "hidden", "tools": "expanded"}}} + ) + ) + monkeypatch.setattr(server, "_hermes_home", tmp_path) + + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "details_mode.activity", "value": ""}, + } + ) + + assert resp["result"] == {"key": "details_mode.activity", "value": ""} + saved = yaml.safe_load(cfg_path.read_text()) + assert saved["display"]["sections"] == {"tools": "expanded"} + + +def test_config_set_section_rejects_unknown_section_or_mode(tmp_path, monkeypatch): + monkeypatch.setattr(server, "_hermes_home", tmp_path) + + bad_section = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "details_mode.bogus", "value": "hidden"}, + } + ) + assert bad_section["error"]["code"] == 4002 + + bad_mode = server.handle_request( + { + "id": "2", + "method": "config.set", + "params": {"key": "details_mode.tools", "value": "maximised"}, + } + ) + assert bad_mode["error"]["code"] == 4002 + + def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f0a870b6e1..0727ed0e0c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2642,6 +2642,41 @@ def _(rid, params: dict) -> dict: _write_config_key("display.details_mode", nv) return _ok(rid, {"key": key, "value": nv}) + if key.startswith("details_mode."): + # Per-section override: `details_mode.
` writes to + # `display.sections.
`. Empty value clears the override + # and lets the section fall back to the global details_mode. + section = key.split(".", 1)[1] + allowed_sections = frozenset({"thinking", "tools", "subagents", "activity"}) + if section not in allowed_sections: + return _err(rid, 4002, f"unknown section: {section}") + + cfg = _load_cfg() + display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} + sections_cfg = ( + display.get("sections") + if isinstance(display.get("sections"), dict) + else {} + ) + + nv = str(value or "").strip().lower() + if not nv: + sections_cfg.pop(section, None) + display["sections"] = sections_cfg + cfg["display"] = display + _save_cfg(cfg) + return _ok(rid, {"key": key, "value": ""}) + + allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) + if nv not in allowed_dm: + return _err(rid, 4002, f"unknown details_mode: {value}") + + sections_cfg[section] = nv + display["sections"] = sections_cfg + cfg["display"] = display + _save_cfg(cfg) + return _ok(rid, {"key": key, "value": nv}) + if key == "thinking_mode": nv = str(value or "").strip().lower() allowed_tm = frozenset({"collapsed", "truncated", "full"}) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 901564f732..4bb8b6bda1 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -88,6 +88,41 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded') }) + it('sets a per-section override and persists it under details_mode.
', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/details activity hidden')).toBe(true) + expect(getUiState().sections.activity).toBe('hidden') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { + key: 'details_mode.activity', + value: 'hidden' + }) + expect(ctx.transcript.sys).toHaveBeenCalledWith('details activity: hidden') + }) + + it('clears a per-section override on /details
reset', () => { + const ctx = buildCtx() + createSlashHandler(ctx)('/details tools expanded') + expect(getUiState().sections.tools).toBe('expanded') + + createSlashHandler(ctx)('/details tools reset') + expect(getUiState().sections.tools).toBeUndefined() + expect(ctx.gateway.rpc).toHaveBeenLastCalledWith('config.set', { + key: 'details_mode.tools', + value: '' + }) + expect(ctx.transcript.sys).toHaveBeenCalledWith('details tools: reset') + }) + + it('rejects unknown section modes with a usage hint', () => { + const ctx = buildCtx() + createSlashHandler(ctx)('/details tools blink') + expect(getUiState().sections.tools).toBeUndefined() + expect(ctx.transcript.sys).toHaveBeenCalledWith( + 'usage: /details
[hidden|collapsed|expanded|reset]' + ) + }) + it('shows tool enable usage when names are missing', () => { const ctx = buildCtx() diff --git a/ui-tui/src/__tests__/details.test.ts b/ui-tui/src/__tests__/details.test.ts new file mode 100644 index 0000000000..b6c28329b6 --- /dev/null +++ b/ui-tui/src/__tests__/details.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' + +import { isSectionName, parseDetailsMode, resolveSections, sectionMode, SECTION_NAMES } from '../domain/details.js' + +describe('parseDetailsMode', () => { + it('accepts the canonical modes case-insensitively', () => { + expect(parseDetailsMode('hidden')).toBe('hidden') + expect(parseDetailsMode(' COLLAPSED ')).toBe('collapsed') + expect(parseDetailsMode('Expanded')).toBe('expanded') + }) + + it('rejects junk', () => { + expect(parseDetailsMode('truncated')).toBeNull() + expect(parseDetailsMode('')).toBeNull() + expect(parseDetailsMode(undefined)).toBeNull() + expect(parseDetailsMode(42)).toBeNull() + }) +}) + +describe('isSectionName', () => { + it('only lets the four canonical sections through', () => { + expect(isSectionName('thinking')).toBe(true) + expect(isSectionName('tools')).toBe(true) + expect(isSectionName('subagents')).toBe(true) + expect(isSectionName('activity')).toBe(true) + + expect(isSectionName('Thinking')).toBe(false) // case-sensitive on purpose + expect(isSectionName('bogus')).toBe(false) + expect(isSectionName('')).toBe(false) + expect(isSectionName(7)).toBe(false) + }) + + it('SECTION_NAMES exposes them all', () => { + expect([...SECTION_NAMES].sort()).toEqual(['activity', 'subagents', 'thinking', 'tools']) + }) +}) + +describe('resolveSections', () => { + it('parses a well-formed sections object', () => { + expect( + resolveSections({ + thinking: 'expanded', + tools: 'expanded', + subagents: 'collapsed', + activity: 'hidden' + }) + ).toEqual({ + thinking: 'expanded', + tools: 'expanded', + subagents: 'collapsed', + activity: 'hidden' + }) + }) + + it('drops unknown section names and unknown modes', () => { + expect( + resolveSections({ + thinking: 'expanded', + tools: 'maximised', + bogus: 'hidden', + activity: 'hidden' + }) + ).toEqual({ thinking: 'expanded', activity: 'hidden' }) + }) + + it('treats nullish/non-objects as empty overrides', () => { + expect(resolveSections(undefined)).toEqual({}) + expect(resolveSections(null)).toEqual({}) + expect(resolveSections('hidden')).toEqual({}) + expect(resolveSections([])).toEqual({}) + }) +}) + +describe('sectionMode', () => { + it('falls back to the global mode when no override is set', () => { + expect(sectionMode('tools', 'collapsed', {})).toBe('collapsed') + expect(sectionMode('tools', 'expanded', undefined)).toBe('expanded') + }) + + it('honours per-section overrides over the global mode', () => { + expect(sectionMode('activity', 'expanded', { activity: 'hidden' })).toBe('hidden') + expect(sectionMode('tools', 'collapsed', { tools: 'expanded' })).toBe('expanded') + }) +}) diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index c5a0a97dc1..5682517441 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -62,6 +62,53 @@ describe('applyDisplay', () => { expect(s.showReasoning).toBe(false) expect(s.statusBar).toBe('top') expect(s.streaming).toBe(true) + expect(s.sections).toEqual({}) + }) + + it('parses display.sections into per-section overrides', () => { + const setBell = vi.fn() + + applyDisplay( + { + config: { + display: { + details_mode: 'collapsed', + sections: { + activity: 'hidden', + tools: 'expanded', + thinking: 'expanded', + bogus: 'expanded' + } + } + } + }, + setBell + ) + + const s = $uiState.get() + expect(s.detailsMode).toBe('collapsed') + expect(s.sections).toEqual({ + activity: 'hidden', + tools: 'expanded', + thinking: 'expanded' + }) + }) + + it('drops invalid section modes', () => { + const setBell = vi.fn() + + applyDisplay( + { + config: { + display: { + sections: { tools: 'maximised' as unknown as string, activity: 'hidden' } + } + } + }, + setBell + ) + + expect($uiState.get().sections).toEqual({ activity: 'hidden' }) }) it('treats a null config like an empty display block', () => { diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 81def036cd..92529ca791 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -16,6 +16,7 @@ import type { Msg, PanelSection, SecretReq, + SectionVisibility, SessionInfo, SlashCatalog, SubagentProgress, @@ -87,6 +88,7 @@ export interface UiState { detailsMode: DetailsMode info: null | SessionInfo inlineDiffs: boolean + sections: SectionVisibility showCost: boolean showReasoning: boolean sid: null | string diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 904882c217..9483de0f50 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,7 +1,7 @@ import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' -import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' +import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' import type { ConfigGetValueResponse, ConfigSetResponse, @@ -10,7 +10,7 @@ import type { } from '../../../gatewayTypes.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js' -import type { DetailsMode, Msg, PanelSection } from '../../../types.js' +import type { DetailsMode, Msg, PanelSection, SectionName } from '../../../types.js' import type { StatusBarMode } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' @@ -57,7 +57,8 @@ export const coreCommands: SlashCommand[] = [ sections.push( { rows: [ - ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/details [hidden|collapsed|expanded|cycle]', 'set global agent detail visibility mode'], + ['/details
[hidden|collapsed|expanded|reset]', 'override one section (thinking/tools/subagents/activity)'], ['/fortune [random|daily]', 'show a random or daily local fortune'] ], title: 'TUI' @@ -140,7 +141,7 @@ export const coreCommands: SlashCommand[] = [ { aliases: ['detail'], - help: 'control agent detail visibility', + help: 'control agent detail visibility (global or per-section)', name: 'details', run: (arg, ctx) => { const { gateway, transcript, ui } = ctx @@ -154,9 +155,14 @@ export const coreCommands: SlashCommand[] = [ } const mode = parseDetailsMode(r?.value) ?? ui.detailsMode - patchUiState({ detailsMode: mode }) - transcript.sys(`details: ${mode}`) + + const overrides = SECTION_NAMES + .filter(s => ui.sections[s]) + .map(s => `${s}=${ui.sections[s]}`) + .join(' ') + + transcript.sys(`details: ${mode}${overrides ? ` (${overrides})` : ''}`) }) .catch(() => { if (!ctx.stale()) { @@ -167,10 +173,46 @@ export const coreCommands: SlashCommand[] = [ return } - const mode = arg.trim().toLowerCase() + const tokens = arg.trim().toLowerCase().split(/\s+/) + + // Per-section override: `/details
` + if (tokens.length >= 2 && isSectionName(tokens[0])) { + const section = tokens[0] as SectionName + const action = tokens[1] ?? '' + + if (action === 'reset' || action === 'clear' || action === 'default') { + const { [section]: _drop, ...rest } = ui.sections + patchUiState({ sections: rest }) + gateway + .rpc('config.set', { key: `details_mode.${section}`, value: '' }) + .catch(() => {}) + transcript.sys(`details ${section}: reset`) + + return + } + + const sectionMode = parseDetailsMode(action) + + if (!sectionMode) { + return transcript.sys('usage: /details
[hidden|collapsed|expanded|reset]') + } + + patchUiState({ sections: { ...ui.sections, [section]: sectionMode } }) + gateway + .rpc('config.set', { key: `details_mode.${section}`, value: sectionMode }) + .catch(() => {}) + transcript.sys(`details ${section}: ${sectionMode}`) + + return + } + + // Global mode (existing behavior). + const mode = tokens[0] ?? '' if (!DETAIL_MODES.has(mode)) { - return transcript.sys('usage: /details [hidden|collapsed|expanded|cycle]') + return transcript.sys( + 'usage: /details [hidden|collapsed|expanded|cycle] or /details
[hidden|collapsed|expanded|reset]' + ) } const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index fcf2e5d88c..0b3fd97402 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -12,6 +12,7 @@ const buildUiState = (): UiState => ({ detailsMode: 'collapsed', info: null, inlineDiffs: true, + sections: {}, showCost: false, showReasoning: false, sid: null, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 9e7c93ce99..cb98eed819 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import { resolveDetailsMode } from '../domain/details.js' +import { resolveDetailsMode, resolveSections } from '../domain/details.js' import type { GatewayClient } from '../gatewayClient.js' import type { ConfigFullResponse, @@ -46,6 +46,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea compact: !!d.tui_compact, detailsMode: resolveDetailsMode(d), inlineDiffs: d.inline_diffs !== false, + sections: resolveSections(d.sections), showCost: !!d.show_cost, showReasoning: !!d.show_reasoning, statusBar: normalizeStatusBar(d.tui_statusbar), diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 19bc7736b7..d856451751 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -8,7 +8,7 @@ import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStor import { $uiState } from '../app/uiStore.js' import { PLACEHOLDER } from '../content/placeholders.js' import type { Theme } from '../theme.js' -import type { DetailsMode } from '../types.js' +import type { DetailsMode, SectionVisibility } from '../types.js' import { AgentsOverlay } from './agentsOverlay.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' @@ -25,6 +25,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ compact, detailsMode, progress, + sections, t }: StreamingAssistantProps) { if (!progress.showProgressArea && !progress.showStreamingArea) { @@ -34,7 +35,15 @@ const StreamingAssistant = memo(function StreamingAssistant({ return ( <> {progress.streamSegments.map((msg, i) => ( - + ))} {progress.showProgressArea && ( @@ -48,6 +57,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ reasoningActive={progress.reasoningActive} reasoningStreaming={progress.reasoningStreaming} reasoningTokens={progress.reasoningTokens} + sections={sections} subagents={progress.subagents} t={t} tools={progress.tools} @@ -68,6 +78,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ text: progress.streaming, ...(progress.streamPendingTools.length && { tools: progress.streamPendingTools }) }} + sections={sections} t={t} /> )} @@ -78,6 +89,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ compact={compact} detailsMode={detailsMode} msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }} + sections={sections} t={t} /> )} @@ -115,6 +127,7 @@ const TranscriptPane = memo(function TranscriptPane({ compact={ui.compact} detailsMode={ui.detailsMode} msg={row.msg} + sections={ui.sections} t={ui.theme} /> )} @@ -129,6 +142,7 @@ const TranscriptPane = memo(function TranscriptPane({ compact={ui.compact} detailsMode={ui.detailsMode} progress={progress} + sections={ui.sections} t={ui.theme} /> @@ -337,5 +351,6 @@ interface StreamingAssistantProps { compact?: boolean detailsMode: DetailsMode progress: AppLayoutProgressProps + sections?: SectionVisibility t: Theme } diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 635c119a04..524c0f572a 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -6,7 +6,7 @@ import { userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { DetailsMode, Msg } from '../types.js' +import type { DetailsMode, Msg, SectionVisibility } from '../types.js' import { Md } from './markdown.js' import { ToolTrail } from './thinking.js' @@ -17,12 +17,13 @@ export const MessageLine = memo(function MessageLine({ detailsMode = 'collapsed', isStreaming = false, msg, + sections, t }: MessageLineProps) { if (msg.kind === 'trail' && msg.tools?.length) { return detailsMode === 'hidden' ? null : ( - + ) } @@ -98,6 +99,7 @@ export const MessageLine = memo(function MessageLine({ detailsMode={detailsMode} reasoning={thinking} reasoningTokens={msg.thinkingTokens} + sections={sections} t={t} toolTokens={msg.toolTokens} trail={msg.tools} @@ -124,5 +126,6 @@ interface MessageLineProps { detailsMode?: DetailsMode isStreaming?: boolean msg: Msg + sections?: SectionVisibility t: Theme } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index a59cdc41d2..9b3285a369 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -3,6 +3,7 @@ import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' +import { sectionMode } from '../domain/details.js' import { buildSubagentTree, fmtCost, @@ -25,7 +26,15 @@ import { toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool, ActivityItem, DetailsMode, SubagentNode, SubagentProgress, ThinkingMode } from '../types.js' +import type { + ActiveTool, + ActivityItem, + DetailsMode, + SectionVisibility, + SubagentNode, + SubagentProgress, + ThinkingMode +} from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] @@ -675,6 +684,7 @@ export const ToolTrail = memo(function ToolTrail({ reasoning = '', reasoningTokens, reasoningStreaming = false, + sections, subagents = [], t, tools = [], @@ -689,6 +699,7 @@ export const ToolTrail = memo(function ToolTrail({ reasoning?: string reasoningTokens?: number reasoningStreaming?: boolean + sections?: SectionVisibility subagents?: SubagentProgress[] t: Theme tools?: ActiveTool[] @@ -696,38 +707,34 @@ export const ToolTrail = memo(function ToolTrail({ trail?: string[] activity?: ActivityItem[] }) { + const thinkingSection = sectionMode('thinking', detailsMode, sections) + const toolsSection = sectionMode('tools', detailsMode, sections) + const subagentsSection = sectionMode('subagents', detailsMode, sections) + const activitySection = sectionMode('activity', detailsMode, sections) + const [now, setNow] = useState(() => Date.now()) - const [openThinking, setOpenThinking] = useState(false) - const [openTools, setOpenTools] = useState(false) - const [openSubagents, setOpenSubagents] = useState(false) - const [deepSubagents, setDeepSubagents] = useState(false) - const [openMeta, setOpenMeta] = useState(false) + const [openThinking, setOpenThinking] = useState(thinkingSection === 'expanded') + const [openTools, setOpenTools] = useState(toolsSection === 'expanded') + const [openSubagents, setOpenSubagents] = useState(subagentsSection === 'expanded') + const [deepSubagents, setDeepSubagents] = useState(subagentsSection === 'expanded') + const [openMeta, setOpenMeta] = useState(activitySection === 'expanded') useEffect(() => { - if (!tools.length || (detailsMode === 'collapsed' && !openTools)) { + if (!tools.length || (toolsSection !== 'expanded' && !openTools)) { return } const id = setInterval(() => setNow(Date.now()), 500) return () => clearInterval(id) - }, [detailsMode, openTools, tools.length]) + }, [toolsSection, openTools, tools.length]) useEffect(() => { - if (detailsMode === 'expanded') { - setOpenThinking(true) - setOpenTools(true) - setOpenSubagents(true) - setOpenMeta(true) - } - - if (detailsMode === 'hidden') { - setOpenThinking(false) - setOpenTools(false) - setOpenSubagents(false) - setOpenMeta(false) - } - }, [detailsMode]) + setOpenThinking(thinkingSection === 'expanded') + setOpenTools(toolsSection === 'expanded') + setOpenSubagents(subagentsSection === 'expanded') + setOpenMeta(activitySection === 'expanded') + }, [thinkingSection, toolsSection, subagentsSection, activitySection]) const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) @@ -863,8 +870,17 @@ export const ToolTrail = memo(function ToolTrail({ const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null // ── Hidden: errors/warnings only ────────────────────────────── + // + // When the global details_mode is 'hidden' (or all sections are individually + // hidden), the accordion collapses entirely. Errors/warnings still float + // as inline alerts UNLESS the activity section is explicitly hidden — that + // override means "I don't want to see meta at all", so respect it. if (detailsMode === 'hidden') { + if (activitySection === 'hidden') { + return null + } + const alerts = activity.filter(i => i.tone !== 'info').slice(-2) return alerts.length ? ( @@ -879,13 +895,18 @@ export const ToolTrail = memo(function ToolTrail({ } // ── Tree render fragments ────────────────────────────────────── + // + // Shift+click on any chevron expands every NON-hidden section at once — + // hidden sections stay hidden so the override is honoured. const expandAll = () => { - setOpenThinking(true) - setOpenTools(true) - setOpenSubagents(true) - setDeepSubagents(true) - setOpenMeta(true) + if (thinkingSection !== 'hidden') setOpenThinking(true) + if (toolsSection !== 'hidden') setOpenTools(true) + if (subagentsSection !== 'hidden') { + setOpenSubagents(true) + setDeepSubagents(true) + } + if (activitySection !== 'hidden') setOpenMeta(true) } const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') @@ -899,7 +920,7 @@ export const ToolTrail = memo(function ToolTrail({ {spawnTree.map((node, index) => ( ) - const sections: { + const panels: { header: ReactNode key: string open: boolean render: (rails: boolean[]) => ReactNode }[] = [] - if (hasThinking) { - sections.push({ + if (hasThinking && thinkingSection !== 'hidden') { + panels.push({ header: ( { @@ -930,7 +951,7 @@ export const ToolTrail = memo(function ToolTrail({ }} > - {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} + {thinkingSection === 'expanded' || openThinking ? '▾ ' : '▸ '} {thinkingLive ? ( Thinking @@ -950,7 +971,7 @@ export const ToolTrail = memo(function ToolTrail({ ), key: 'thinking', - open: detailsMode === 'expanded' || openThinking, + open: thinkingSection === 'expanded' || openThinking, render: rails => ( !v) } }} - open={detailsMode === 'expanded' || openTools} + open={toolsSection === 'expanded' || openTools} suffix={toolTokensLabel} t={t} title="Tool calls" /> ), key: 'tools', - open: detailsMode === 'expanded' || openTools, + open: toolsSection === 'expanded' || openTools, render: rails => ( {groups.map((group, index) => { @@ -1024,12 +1045,12 @@ export const ToolTrail = memo(function ToolTrail({ }) } - if (hasSubagents && !inlineDelegateKey) { + if (hasSubagents && !inlineDelegateKey && subagentsSection !== 'hidden') { // Spark + summary give a one-line read on the branch shape before // opening the subtree. `/agents` opens the full-screen audit overlay. const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)` - sections.push({ + panels.push({ header: ( ), key: 'subagents', - open: detailsMode === 'expanded' || openSubagents, + open: subagentsSection === 'expanded' || openSubagents, render: renderSubagentList }) } - if (hasMeta) { - sections.push({ + if (hasMeta && activitySection !== 'hidden') { + panels.push({ header: ( !v) } }} - open={detailsMode === 'expanded' || openMeta} + open={activitySection === 'expanded' || openMeta} t={t} title="Activity" tone={metaTone} /> ), key: 'meta', - open: detailsMode === 'expanded' || openMeta, + open: activitySection === 'expanded' || openMeta, render: rails => ( {meta.map((row, index) => ( @@ -1092,19 +1113,19 @@ export const ToolTrail = memo(function ToolTrail({ }) } - const topCount = sections.length + (totalTokensLabel ? 1 : 0) + const topCount = panels.length + (totalTokensLabel ? 1 : 0) return ( - {sections.map((section, index) => ( + {panels.map((panel, index) => ( - {section.render} + {panel.render} ))} {totalTokensLabel ? ( diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts index fa01092f5d..4ac2e6f801 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -1,6 +1,7 @@ -import type { DetailsMode } from '../types.js' +import type { DetailsMode, SectionName, SectionVisibility } from '../types.js' const MODES = ['hidden', 'collapsed', 'expanded'] as const +export const SECTION_NAMES: readonly SectionName[] = ['thinking', 'tools', 'subagents', 'activity'] const THINKING_FALLBACK: Record = { collapsed: 'collapsed', @@ -14,6 +15,9 @@ export const parseDetailsMode = (v: unknown): DetailsMode | null => { return MODES.find(m => m === s) ?? null } +export const isSectionName = (v: unknown): v is SectionName => + typeof v === 'string' && (SECTION_NAMES as readonly string[]).includes(v) + export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode => parseDetailsMode(d?.details_mode) ?? THINKING_FALLBACK[ @@ -23,4 +27,35 @@ export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: ] ?? 'collapsed' +// Build a SectionVisibility from a free-form `display.sections` config blob. +// Skips keys that aren't recognized section names or don't parse to a valid +// mode — partial overrides are intentional, missing keys fall through to the +// global details_mode at render time. +export const resolveSections = (raw: unknown): SectionVisibility => { + const out: SectionVisibility = {} + + if (!raw || typeof raw !== 'object') { + return out + } + + for (const [k, v] of Object.entries(raw as Record)) { + const mode = parseDetailsMode(v) + + if (mode && isSectionName(k)) { + out[k] = mode + } + } + + return out +} + +// Resolve the effective mode for one section: explicit override wins, +// otherwise the global details_mode. Single source of truth — every render +// site that needs to know "is this section open by default" calls this. +export const sectionMode = ( + name: SectionName, + global: DetailsMode, + sections?: SectionVisibility +): DetailsMode => sections?.[name] ?? global + export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]! diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 05f8d9a41c..a38e068049 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -55,6 +55,7 @@ export interface ConfigDisplayConfig { bell_on_complete?: boolean details_mode?: string inline_diffs?: boolean + sections?: Record show_cost?: boolean show_reasoning?: boolean streaming?: boolean diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 191e639009..eb80536105 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -116,6 +116,14 @@ export type Role = 'assistant' | 'system' | 'tool' | 'user' export type DetailsMode = 'hidden' | 'collapsed' | 'expanded' export type ThinkingMode = 'collapsed' | 'truncated' | 'full' +// Per-section overrides on top of the global DetailsMode. Each missing key +// falls back to the global mode; an explicit value overrides for that one +// section only — so users can keep the accordion collapsed by default while +// auto-expanding tools, or hide the activity panel entirely without touching +// thinking/tools/subagents. +export type SectionName = 'thinking' | 'tools' | 'subagents' | 'activity' +export type SectionVisibility = Partial> + export interface McpServerStatus { connected: boolean name: string diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 72c0a47123..fe032cf518 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -87,7 +87,7 @@ All slash commands work unchanged. A few are TUI-owned — they produce richer o | `/sessions` | Modal session picker — preview, title, token totals, resume inline | | `/model` | Modal model picker grouped by provider, with cost hints | | `/skin` | Live preview — theme change applies as you browse | -| `/details` | Toggle verbose tool-call details in the transcript | +| `/details` | Toggle verbose tool-call details (global or per-section) | | `/usage` | Rich token / cost / context panel | Every other slash command (including installed skills, quick commands, and personality toggles) works identically to the classic CLI. See [Slash Commands Reference](../reference/slash-commands.md). @@ -114,13 +114,26 @@ A handful of keys tune the TUI surface specifically: ```yaml display: - skin: default # any built-in or custom skin + skin: default # any built-in or custom skin personality: helpful - details_mode: compact # or "verbose" — default tool-call detail level - mouse_tracking: true # disable if your terminal conflicts with mouse reporting + details_mode: collapsed # hidden | collapsed | expanded — global accordion default + sections: # optional: per-section overrides (any subset) + thinking: expanded # always open + tools: expanded # always open + activity: hidden # never show errors/warnings/info panel + mouse_tracking: true # disable if your terminal conflicts with mouse reporting ``` -`/details on` / `/details off` / `/details cycle` toggle this at runtime. +Runtime toggles: + +- `/details [hidden|collapsed|expanded|cycle]` — set the global mode +- `/details
[hidden|collapsed|expanded|reset]` — override one section + (sections: `thinking`, `tools`, `subagents`, `activity`) + +Per-section overrides take precedence over the global `details_mode`. With +`activity: hidden`, errors/warnings are suppressed entirely (the floating-alert +fallback that normally surfaces under `details_mode: hidden` is also silenced +when activity is explicitly hidden). ## Sessions