mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge pull request #14968 from NousResearch/bb/tui-section-visibility
feat(tui): per-section visibility for the details accordion
This commit is contained in:
commit
5dda4cab41
17 changed files with 575 additions and 104 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()
|
||||
|
||||
|
|
|
|||
109
ui-tui/src/__tests__/details.test.ts
Normal file
109
ui-tui/src/__tests__/details.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 { 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 <section> [hidden|collapsed|expanded|reset]'
|
||||
const DETAILS_SECTION_USAGE = 'usage: /details <section> [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 <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 +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<ConfigGetValueResponse>('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<ConfigSetResponse>('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<ConfigSetResponse>('config.set', { key: 'details_mode', value: next }).catch(() => {})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<ToolTrail detailsMode={detailsMode} t={t} trail={msg.tools} />
|
||||
<ToolTrail detailsMode={detailsMode} sections={sections} t={t} trail={msg.tools} />
|
||||
</Box>
|
||||
)
|
||||
) : 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<SubagentAccordion
|
||||
branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
|
||||
expanded={detailsMode === 'expanded' || deepSubagents}
|
||||
expanded={visible.subagents === 'expanded' || deepSubagents}
|
||||
key={node.item.id}
|
||||
node={node}
|
||||
peak={spawnPeak}
|
||||
|
|
@ -910,15 +940,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 && visible.thinking !== 'hidden') {
|
||||
panels.push({
|
||||
header: (
|
||||
<Box
|
||||
onClick={(e: any) => {
|
||||
|
|
@ -930,7 +960,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}>{visible.thinking === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
|
||||
{thinkingLive ? (
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
Thinking
|
||||
|
|
@ -950,7 +980,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
</Box>
|
||||
),
|
||||
key: 'thinking',
|
||||
open: detailsMode === 'expanded' || openThinking,
|
||||
open: visible.thinking === 'expanded' || openThinking,
|
||||
render: rails => (
|
||||
<Thinking
|
||||
active={reasoningActive}
|
||||
|
|
@ -965,8 +995,8 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
})
|
||||
}
|
||||
|
||||
if (hasTools) {
|
||||
sections.push({
|
||||
if (hasTools && visible.tools !== 'hidden') {
|
||||
panels.push({
|
||||
header: (
|
||||
<Chevron
|
||||
count={groups.length}
|
||||
|
|
@ -977,14 +1007,14 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
setOpenTools(v => !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 => (
|
||||
<Box flexDirection="column">
|
||||
{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: (
|
||||
<Chevron
|
||||
count={spawnTotals.descendantCount}
|
||||
|
|
@ -1042,20 +1072,20 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
setDeepSubagents(false)
|
||||
}
|
||||
}}
|
||||
open={detailsMode === 'expanded' || openSubagents}
|
||||
open={visible.subagents === 'expanded' || openSubagents}
|
||||
suffix={suffix}
|
||||
t={t}
|
||||
title="Spawn tree"
|
||||
/>
|
||||
),
|
||||
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: (
|
||||
<Chevron
|
||||
count={meta.length}
|
||||
|
|
@ -1066,14 +1096,14 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
setOpenMeta(v => !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 => (
|
||||
<Box flexDirection="column">
|
||||
{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 (
|
||||
<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,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.<name>` in config.yaml
|
||||
// or at runtime via `/details <name> collapsed|hidden`.
|
||||
const SECTION_DEFAULTS: SectionVisibility = {
|
||||
thinking: 'expanded',
|
||||
tools: 'expanded',
|
||||
activity: 'hidden'
|
||||
}
|
||||
|
||||
const THINKING_FALLBACK: Record<string, DetailsMode> = {
|
||||
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<string, unknown>)
|
||||
.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]!
|
||||
|
|
|
|||
|
|
@ -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 for the agent details accordion. Resolution order
|
||||
// at lookup time is: explicit `display.sections.<name>` → 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<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).
|
||||
|
|
@ -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 <section> [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 <section> <mode>` at runtime
|
||||
|
||||
Anything set explicitly in `display.sections` wins over the defaults, so
|
||||
existing configs keep working unchanged.
|
||||
|
||||
## Sessions
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue