mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
6051fba9dc
commit
78481ac124
16 changed files with 478 additions and 70 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.<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":
|
||||
nv = str(value or "").strip().lower()
|
||||
allowed_tm = frozenset({"collapsed", "truncated", "full"})
|
||||
|
|
|
|||
|
|
@ -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.<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', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
|
|
|
|||
84
ui-tui/src/__tests__/details.test.ts
Normal file
84
ui-tui/src/__tests__/details.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <section> [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 <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)) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const buildUiState = (): UiState => ({
|
|||
detailsMode: 'collapsed',
|
||||
info: null,
|
||||
inlineDiffs: true,
|
||||
sections: {},
|
||||
showCost: false,
|
||||
showReasoning: false,
|
||||
sid: null,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<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 && (
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</Box>
|
||||
|
|
@ -337,5 +351,6 @@ interface StreamingAssistantProps {
|
|||
compact?: boolean
|
||||
detailsMode: DetailsMode
|
||||
progress: AppLayoutProgressProps
|
||||
sections?: SectionVisibility
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<ToolTrail detailsMode={detailsMode} t={t} trail={msg.tools} />
|
||||
<ToolTrail detailsMode={detailsMode} sections={sections} t={t} trail={msg.tools} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
if (thinkingSection !== 'hidden') setOpenThinking(true)
|
||||
if (toolsSection !== 'hidden') setOpenTools(true)
|
||||
if (subagentsSection !== 'hidden') {
|
||||
setOpenSubagents(true)
|
||||
setDeepSubagents(true)
|
||||
setOpenMeta(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) => (
|
||||
<SubagentAccordion
|
||||
branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
|
||||
expanded={detailsMode === 'expanded' || deepSubagents}
|
||||
expanded={subagentsSection === 'expanded' || deepSubagents}
|
||||
key={node.item.id}
|
||||
node={node}
|
||||
peak={spawnPeak}
|
||||
|
|
@ -910,15 +931,15 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
</Box>
|
||||
)
|
||||
|
||||
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: (
|
||||
<Box
|
||||
onClick={(e: any) => {
|
||||
|
|
@ -930,7 +951,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
}}
|
||||
>
|
||||
<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 ? (
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
Thinking
|
||||
|
|
@ -950,7 +971,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
</Box>
|
||||
),
|
||||
key: 'thinking',
|
||||
open: detailsMode === 'expanded' || openThinking,
|
||||
open: thinkingSection === 'expanded' || openThinking,
|
||||
render: rails => (
|
||||
<Thinking
|
||||
active={reasoningActive}
|
||||
|
|
@ -965,8 +986,8 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
})
|
||||
}
|
||||
|
||||
if (hasTools) {
|
||||
sections.push({
|
||||
if (hasTools && toolsSection !== 'hidden') {
|
||||
panels.push({
|
||||
header: (
|
||||
<Chevron
|
||||
count={groups.length}
|
||||
|
|
@ -977,14 +998,14 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
setOpenTools(v => !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 => (
|
||||
<Box flexDirection="column">
|
||||
{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: (
|
||||
<Chevron
|
||||
count={spawnTotals.descendantCount}
|
||||
|
|
@ -1042,20 +1063,20 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
setDeepSubagents(false)
|
||||
}
|
||||
}}
|
||||
open={detailsMode === 'expanded' || openSubagents}
|
||||
open={subagentsSection === 'expanded' || openSubagents}
|
||||
suffix={suffix}
|
||||
t={t}
|
||||
title="Spawn tree"
|
||||
/>
|
||||
),
|
||||
key: 'subagents',
|
||||
open: detailsMode === 'expanded' || openSubagents,
|
||||
open: subagentsSection === 'expanded' || openSubagents,
|
||||
render: renderSubagentList
|
||||
})
|
||||
}
|
||||
|
||||
if (hasMeta) {
|
||||
sections.push({
|
||||
if (hasMeta && activitySection !== 'hidden') {
|
||||
panels.push({
|
||||
header: (
|
||||
<Chevron
|
||||
count={meta.length}
|
||||
|
|
@ -1066,14 +1087,14 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
setOpenMeta(v => !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 => (
|
||||
<Box flexDirection="column">
|
||||
{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 (
|
||||
<Box flexDirection="column">
|
||||
{sections.map((section, index) => (
|
||||
{panels.map((panel, index) => (
|
||||
<TreeNode
|
||||
branch={index === topCount - 1 ? 'last' : 'mid'}
|
||||
header={section.header}
|
||||
key={section.key}
|
||||
open={section.open}
|
||||
header={panel.header}
|
||||
key={panel.key}
|
||||
open={panel.open}
|
||||
t={t}
|
||||
>
|
||||
{section.render}
|
||||
{panel.render}
|
||||
</TreeNode>
|
||||
))}
|
||||
{totalTokensLabel ? (
|
||||
|
|
|
|||
|
|
@ -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<string, DetailsMode> = {
|
||||
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<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]!
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export interface ConfigDisplayConfig {
|
|||
bell_on_complete?: boolean
|
||||
details_mode?: string
|
||||
inline_diffs?: boolean
|
||||
sections?: Record<string, string>
|
||||
show_cost?: boolean
|
||||
show_reasoning?: boolean
|
||||
streaming?: boolean
|
||||
|
|
|
|||
|
|
@ -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<Record<SectionName, DetailsMode>>
|
||||
|
||||
export interface McpServerStatus {
|
||||
connected: boolean
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -116,11 +116,24 @@ A handful of keys tune the TUI surface specifically:
|
|||
display:
|
||||
skin: default # any built-in or custom skin
|
||||
personality: helpful
|
||||
details_mode: compact # or "verbose" — default tool-call detail level
|
||||
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 <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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue