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