diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 2c50065b2..107d23897 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 f0a870b6e..0727ed0e0 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 901564f73..4bb8b6bda 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 000000000..15ef681dc --- /dev/null +++ b/ui-tui/src/__tests__/details.test.ts @@ -0,0 +1,109 @@ +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 for sections without a built-in default', () => { + expect(sectionMode('subagents', 'collapsed', {})).toBe('collapsed') + expect(sectionMode('subagents', 'expanded', undefined)).toBe('expanded') + expect(sectionMode('subagents', 'hidden', {})).toBe('hidden') + }) + + it('streams thinking + tools expanded by default regardless of global mode', () => { + expect(sectionMode('thinking', 'collapsed', {})).toBe('expanded') + expect(sectionMode('thinking', 'hidden', undefined)).toBe('expanded') + expect(sectionMode('tools', 'collapsed', {})).toBe('expanded') + expect(sectionMode('tools', 'hidden', undefined)).toBe('expanded') + }) + + it('hides the activity panel by default regardless of global mode', () => { + expect(sectionMode('activity', 'collapsed', {})).toBe('hidden') + expect(sectionMode('activity', 'expanded', undefined)).toBe('hidden') + expect(sectionMode('activity', 'hidden', {})).toBe('hidden') + }) + + it('honours per-section overrides over both the section default and global mode', () => { + expect(sectionMode('thinking', 'collapsed', { thinking: 'collapsed' })).toBe('collapsed') + expect(sectionMode('tools', 'collapsed', { tools: 'hidden' })).toBe('hidden') + expect(sectionMode('activity', 'collapsed', { activity: 'expanded' })).toBe('expanded') + expect(sectionMode('activity', 'expanded', { activity: 'collapsed' })).toBe('collapsed') + }) + + it('lets per-section overrides escape the global hidden mode', () => { + // Regression for the case where global details_mode: hidden used to + // short-circuit the entire accordion and prevent overrides from + // surfacing — `sections.tools: expanded` must still resolve to expanded. + expect(sectionMode('subagents', 'hidden', { subagents: 'expanded' })).toBe('expanded') + expect(sectionMode('thinking', 'hidden', { thinking: 'collapsed' })).toBe('collapsed') + expect(sectionMode('activity', 'hidden', { activity: 'expanded' })).toBe('expanded') + }) +}) diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index c5a0a97dc..568251744 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 81def036c..92529ca79 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 904882c21..efea1c112 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 { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } 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 { 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[] = [ { @@ -57,7 +61,11 @@ 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 +148,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 @@ -149,31 +157,48 @@ 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 }) - transcript.sys(`details: ${mode}`) - }) - .catch(() => { - if (!ctx.stale()) { - transcript.sys(`details: ${ui.detailsMode}`) - } + + const overrides = SECTION_NAMES.filter(s => ui.sections[s]) + .map(s => `${s}=${ui.sections[s]}`) + .join(' ') + + transcript.sys(`details: ${mode}${overrides ? ` (${overrides})` : ''}`) }) + .catch(() => !ctx.stale() && transcript.sys(`details: ${ui.detailsMode}`)) return } - const mode = arg.trim().toLowerCase() + const [first, second] = arg.trim().toLowerCase().split(/\s+/) - if (!DETAIL_MODES.has(mode)) { - return transcript.sys('usage: /details [hidden|collapsed|expanded|cycle]') + if (second && isSectionName(first)) { + const reset = RESET_WORDS.has(second) + const mode = reset ? null : parseDetailsMode(second) + + if (!reset && !mode) { + return transcript.sys(DETAILS_SECTION_USAGE) + } + + const { [first]: _drop, ...rest } = ui.sections + + patchUiState({ sections: mode ? { ...rest, [first]: mode } : rest }) + gateway + .rpc('config.set', { key: `details_mode.${first}`, value: mode ?? '' }) + .catch(() => {}) + transcript.sys(`details ${first}: ${mode ?? 'reset'}`) + + return } - const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) + const next = CYCLE_WORDS.has(first ?? '') ? nextDetailsMode(ui.detailsMode) : parseDetailsMode(first) + + if (!next) { + return transcript.sys(DETAILS_USAGE) + } patchUiState({ detailsMode: next }) gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index fcf2e5d88..0b3fd9740 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 9e7c93ce9..cb98eed81 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/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 7b742478e..d2e5494a9 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' +import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { fmtCwdBranch, shortCwd } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' @@ -630,11 +631,15 @@ export function useMainApp(gw: GatewayClient) { const hasReasoning = Boolean(turn.reasoning.trim()) - const showProgressArea = - ui.detailsMode === 'hidden' - ? turn.activity.some(item => item.tone !== 'info') - : Boolean( - ui.busy || + // Per-section overrides win over the global mode — when every section is + // resolved to hidden, the only thing ToolTrail will surface is the + // floating-alert backstop (errors/warnings). Mirror that so we don't + // render an empty wrapper Box above the streaming area in quiet mode. + const anyPanelVisible = SECTION_NAMES.some(s => sectionMode(s, ui.detailsMode, ui.sections) !== 'hidden') + + const showProgressArea = anyPanelVisible + ? Boolean( + ui.busy || turn.outcome || turn.streamPendingTools.length || turn.streamSegments.length || @@ -643,7 +648,8 @@ export function useMainApp(gw: GatewayClient) { turn.turnTrail.length || hasReasoning || turn.activity.length - ) + ) + : turn.activity.some(item => item.tone !== 'info') const appActions = useMemo( () => ({ diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 19bc7736b..d85645175 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 635c119a0..a73a4f01d 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,12 +1,13 @@ import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' +import { sectionMode } from '../domain/details.js' import { LONG_MSG } from '../config/limits.js' 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,14 +18,26 @@ export const MessageLine = memo(function MessageLine({ detailsMode = 'collapsed', isStreaming = false, msg, + sections, t }: MessageLineProps) { + // Per-section overrides win over the global mode, so resolve each section + // we might consume here once and gate visibility on the *content-bearing* + // sections only — never on the global mode. A `trail` message feeds Tool + // calls + Activity; an assistant message with thinking/tools metadata + // feeds Thinking + Tool calls. Gating on every section would let + // `thinking` (expanded by default) keep an empty wrapper alive when only + // `tools` is hidden — exactly the empty-Box bug Copilot caught. + const thinkingMode = sectionMode('thinking', detailsMode, sections) + const toolsMode = sectionMode('tools', detailsMode, sections) + const activityMode = sectionMode('activity', detailsMode, sections) + if (msg.kind === 'trail' && msg.tools?.length) { - return detailsMode === 'hidden' ? null : ( + return toolsMode !== 'hidden' || activityMode !== 'hidden' ? ( - + - ) + ) : null } if (msg.role === 'tool') { @@ -49,7 +62,10 @@ export const MessageLine = memo(function MessageLine({ const { body, glyph, prefix } = ROLE[msg.role](t) const thinking = msg.thinking?.trim() ?? '' - const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking)) + + const showDetails = + (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || + (thinkingMode !== 'hidden' && Boolean(thinking)) const content = (() => { if (msg.kind === 'slash') { @@ -98,6 +114,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 +141,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 a59cdc41d..da8b3d396 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,8 +1,9 @@ import { Box, NoSelect, Text } from '@hermes/ink' -import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' +import { memo, useEffect, useMemo, useState, type ReactNode } 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,39 @@ export const ToolTrail = memo(function ToolTrail({ trail?: string[] activity?: ActivityItem[] }) { + 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(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(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 || (detailsMode === 'collapsed' && !openTools)) { + if (!tools.length || (visible.tools !== 'expanded' && !openTools)) { return } const id = setInterval(() => setNow(Date.now()), 500) return () => clearInterval(id) - }, [detailsMode, openTools, tools.length]) + }, [openTools, tools.length, visible.tools]) 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(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]) @@ -862,9 +874,22 @@ export const ToolTrail = memo(function ToolTrail({ const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task')) const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null - // ── Hidden: errors/warnings only ────────────────────────────── + // ── Backstop: floating alerts when every panel is hidden ───────── + // + // Per-section overrides win over the global details_mode (they're computed + // by sectionMode), so we only collapse to nothing when EVERY section is + // resolved to hidden — that way `details_mode: hidden` + `sections.tools: + // expanded` still renders the tools panel. When all panels are hidden + // AND ambient errors/warnings exist, surface them as a compact inline + // backstop so quiet-mode users aren't blind to failures. - if (detailsMode === 'hidden') { + const allHidden = + visible.thinking === 'hidden' && + visible.tools === 'hidden' && + visible.subagents === 'hidden' && + visible.activity === 'hidden' + + if (allHidden) { const alerts = activity.filter(i => i.tone !== 'info').slice(-2) return alerts.length ? ( @@ -879,13 +904,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 (visible.thinking !== 'hidden') setOpenThinking(true) + if (visible.tools !== 'hidden') setOpenTools(true) + if (visible.subagents !== 'hidden') { + setOpenSubagents(true) + setDeepSubagents(true) + } + if (visible.activity !== 'hidden') setOpenMeta(true) } const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') @@ -899,7 +929,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 && visible.thinking !== 'hidden') { + panels.push({ header: ( { @@ -930,7 +960,7 @@ export const ToolTrail = memo(function ToolTrail({ }} > - {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} + {visible.thinking === 'expanded' || openThinking ? '▾ ' : '▸ '} {thinkingLive ? ( Thinking @@ -950,7 +980,7 @@ export const ToolTrail = memo(function ToolTrail({ ), key: 'thinking', - open: detailsMode === 'expanded' || openThinking, + open: visible.thinking === 'expanded' || openThinking, render: rails => ( !v) } }} - open={detailsMode === 'expanded' || openTools} + open={visible.tools === 'expanded' || openTools} suffix={toolTokensLabel} t={t} title="Tool calls" /> ), key: 'tools', - open: detailsMode === 'expanded' || openTools, + open: visible.tools === 'expanded' || openTools, render: rails => ( {groups.map((group, index) => { @@ -1024,12 +1054,12 @@ export const ToolTrail = memo(function ToolTrail({ }) } - if (hasSubagents && !inlineDelegateKey) { + 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)` - sections.push({ + panels.push({ header: ( ), key: 'subagents', - open: detailsMode === 'expanded' || openSubagents, + open: visible.subagents === 'expanded' || openSubagents, render: renderSubagentList }) } - if (hasMeta) { - sections.push({ + if (hasMeta && visible.activity !== 'hidden') { + panels.push({ header: ( !v) } }} - open={detailsMode === 'expanded' || openMeta} + open={visible.activity === 'expanded' || openMeta} t={t} title="Activity" tone={metaTone} /> ), key: 'meta', - open: detailsMode === 'expanded' || openMeta, + open: visible.activity === 'expanded' || openMeta, render: rails => ( {meta.map((row, index) => ( @@ -1092,19 +1122,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 fa01092f5..079b08ea7 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -1,26 +1,65 @@ -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 = ['thinking', 'tools', 'subagents', 'activity'] as const + +// Out-of-the-box per-section defaults — applied when the user hasn't pinned +// an explicit override and layered ABOVE the global details_mode: +// +// - thinking / tools: expanded — stream open so the turn reads like a +// live transcript (reasoning + tool calls side by side) instead of a +// wall of chevrons the user has to click every turn. +// - activity: hidden — ambient meta (gateway hints, terminal-parity +// nudges, background notifications) is noise for typical use. Tool +// failures still render inline on the failing tool row, and ambient +// errors/warnings surface via the floating-alert backstop when every +// panel resolves to hidden. +// - subagents: not set — falls through to the global details_mode so +// Spawn trees stay under a chevron until a delegation actually happens. +// +// Opt out of any of these with `display.sections.` in config.yaml +// or at runtime via `/details collapsed|hidden`. +const SECTION_DEFAULTS: SectionVisibility = { + thinking: 'expanded', + tools: 'expanded', + activity: 'hidden' +} + const THINKING_FALLBACK: Record = { collapsed: 'collapsed', full: 'expanded', 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 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) + : {} + +// 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]! diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 05f8d9a41..a38e06804 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 191e63900..3fdb39b82 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 for the agent details accordion. Resolution order +// at lookup time is: explicit `display.sections.` → built-in +// SECTION_DEFAULTS → global `details_mode`. Today the built-in defaults +// expand `thinking`/`tools` and hide `activity`; `subagents` falls through +// to the global mode. Any explicit value still wins for that one section. +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 72c0a4712..8c1b179b6 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,47 @@ 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: collapsed # opt back IN to the activity panel (hidden by default) + 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`) + +**Default visibility** + +The TUI ships with opinionated per-section defaults that stream the turn as +a live transcript instead of a wall of chevrons: + +- `thinking` — **expanded**. Reasoning streams inline as the model emits it. +- `tools` — **expanded**. Tool calls and their results render open. +- `subagents` — falls through to the global `details_mode` (collapsed under + chevron by default — stays quiet until a delegation actually happens). +- `activity` — **hidden**. Ambient meta (gateway hints, terminal-parity + nudges, background notifications) is noise for most day-to-day use. Tool + failures still render inline on the failing tool row; ambient + errors/warnings surface via a floating-alert backstop when every panel + is hidden. + +Per-section overrides take precedence over both the section default and the +global `details_mode`. To reshape the layout: + +- `display.sections.thinking: collapsed` — put thinking back under a chevron +- `display.sections.tools: collapsed` — put tool calls back under a chevron +- `display.sections.activity: collapsed` — opt the activity panel back in +- `/details
` at runtime + +Anything set explicitly in `display.sections` wins over the defaults, so +existing configs keep working unchanged. ## Sessions