fix(tui): apply details mode live

This commit is contained in:
Brooklyn Nicholson 2026-04-26 13:30:08 -05:00
parent 6814646b36
commit a8fcd1c742
12 changed files with 56 additions and 19 deletions

View file

@ -62,10 +62,10 @@ import {
getSelectedText,
hasSelection,
moveFocus,
selectionBounds,
type SelectionState,
selectLineAt,
selectWordAt,
selectionBounds,
shiftAnchor,
shiftSelection,
shiftSelectionForFollow,

View file

@ -119,6 +119,7 @@ describe('createSlashHandler', () => {
expect(getUiState().detailsMode).toBe('collapsed')
expect(createSlashHandler(ctx)('/details toggle')).toBe(true)
expect(getUiState().detailsMode).toBe('expanded')
expect(getUiState().detailsModeCommandOverride).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'details_mode',
value: 'expanded'

View file

@ -78,19 +78,25 @@ describe('sectionMode', () => {
expect(sectionMode('subagents', 'hidden', {})).toBe('hidden')
})
it('streams thinking + tools expanded by default regardless of global mode', () => {
it('streams thinking + tools expanded by default for persisted config values', () => {
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', () => {
it('hides the activity panel by default for persisted config values', () => {
expect(sectionMode('activity', 'collapsed', {})).toBe('hidden')
expect(sectionMode('activity', 'expanded', undefined)).toBe('hidden')
expect(sectionMode('activity', 'hidden', {})).toBe('hidden')
})
it('applies in-session /details mode globally over built-in defaults', () => {
expect(sectionMode('thinking', 'collapsed', {}, true)).toBe('collapsed')
expect(sectionMode('tools', 'hidden', {}, true)).toBe('hidden')
expect(sectionMode('activity', 'expanded', undefined, true)).toBe('expanded')
})
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')

View file

@ -90,6 +90,7 @@ export interface UiState {
busy: boolean
compact: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
info: null | SessionInfo
inlineDiffs: boolean
mouseTracking: boolean

View file

@ -184,7 +184,7 @@ export const coreCommands: SlashCommand[] = [
}
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
patchUiState({ detailsMode: mode })
patchUiState({ detailsMode: mode, detailsModeCommandOverride: false })
const overrides = SECTION_NAMES.filter(s => ui.sections[s])
.map(s => `${s}=${ui.sections[s]}`)
@ -224,7 +224,7 @@ export const coreCommands: SlashCommand[] = [
return transcript.sys(DETAILS_USAGE)
}
patchUiState({ detailsMode: next })
patchUiState({ detailsMode: next, detailsModeCommandOverride: true })
gateway.rpc<ConfigSetResponse>('config.set', { key: 'details_mode', value: next }).catch(() => {})
transcript.sys(`details: ${next}`)
}

View file

@ -11,6 +11,7 @@ const buildUiState = (): UiState => ({
busy: false,
compact: false,
detailsMode: 'collapsed',
detailsModeCommandOverride: false,
info: null,
inlineDiffs: true,
mouseTracking: MOUSE_TRACKING,

View file

@ -45,6 +45,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
patchUiState({
compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d),
detailsModeCommandOverride: false,
inlineDiffs: d.inline_diffs !== false,
mouseTracking: d.tui_mouse !== false,
sections: resolveSections(d.sections),

View file

@ -26,6 +26,7 @@ import { createGatewayEventHandler } from './createGatewayEventHandler.js'
import { createSlashHandler } from './createSlashHandler.js'
import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
import { $overlayState, patchOverlayState } from './overlayStore.js'
import { scrollWithSelectionBy } from './scroll.js'
import { turnController } from './turnController.js'
import { $turnState, patchTurnState } from './turnStore.js'
import { $uiState, getUiState, patchUiState } from './uiStore.js'
@ -33,7 +34,6 @@ import { useComposerState } from './useComposerState.js'
import { useConfigSync } from './useConfigSync.js'
import { useInputHandlers } from './useInputHandlers.js'
import { useLongRunToolCharms } from './useLongRunToolCharms.js'
import { scrollWithSelectionBy } from './scroll.js'
import { useSessionLifecycle } from './useSessionLifecycle.js'
import { useSubmission } from './useSubmission.js'
@ -593,7 +593,9 @@ export function useMainApp(gw: GatewayClient) {
// 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 anyPanelVisible = SECTION_NAMES.some(
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
)
const showProgressArea = anyPanelVisible
? Boolean(

View file

@ -25,6 +25,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
cols,
compact,
detailsMode,
detailsModeCommandOverride,
progress,
sections,
t
@ -40,6 +41,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
key={`seg:${i}`}
msg={msg}
sections={sections}
@ -52,6 +54,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
<ToolTrail
activity={progress.activity}
busy={busy}
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
outcome={progress.outcome}
reasoning={progress.reasoning}
@ -73,6 +76,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
isStreaming
msg={{
role: 'assistant',
@ -89,6 +93,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }}
sections={sections}
t={t}
@ -127,6 +132,7 @@ const TranscriptPane = memo(function TranscriptPane({
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
msg={row.msg}
sections={ui.sections}
t={ui.theme}
@ -142,6 +148,7 @@ const TranscriptPane = memo(function TranscriptPane({
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
progress={progress}
sections={ui.sections}
t={ui.theme}
@ -353,6 +360,7 @@ interface StreamingAssistantProps {
cols: number
compact?: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
progress: AppLayoutProgressProps
sections?: SectionVisibility
t: Theme

View file

@ -16,6 +16,7 @@ export const MessageLine = memo(function MessageLine({
cols,
compact,
detailsMode = 'collapsed',
detailsModeCommandOverride = false,
isStreaming = false,
msg,
sections,
@ -28,15 +29,16 @@ export const MessageLine = memo(function MessageLine({
// 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)
const thinkingMode = sectionMode('thinking', detailsMode, sections, detailsModeCommandOverride)
const toolsMode = sectionMode('tools', detailsMode, sections, detailsModeCommandOverride)
const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride)
const thinking = msg.thinking?.trim() ?? ''
if (msg.kind === 'trail' && (msg.tools?.length || thinking)) {
return thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden' ? (
<Box flexDirection="column">
<ToolTrail
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
@ -118,6 +120,7 @@ export const MessageLine = memo(function MessageLine({
{showDetails && (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
@ -146,6 +149,7 @@ interface MessageLineProps {
cols: number
compact?: boolean
detailsMode?: DetailsMode
detailsModeCommandOverride?: boolean
isStreaming?: boolean
msg: Msg
sections?: SectionVisibility

View file

@ -681,6 +681,7 @@ interface Group {
export const ToolTrail = memo(function ToolTrail({
busy = false,
commandOverride = false,
detailsMode = 'collapsed',
outcome = '',
reasoningActive = false,
@ -696,6 +697,7 @@ export const ToolTrail = memo(function ToolTrail({
activity = []
}: {
busy?: boolean
commandOverride?: boolean
detailsMode?: DetailsMode
outcome?: string
reasoningActive?: boolean
@ -712,12 +714,12 @@ export const ToolTrail = memo(function ToolTrail({
}) {
const visible = useMemo(
() => ({
thinking: sectionMode('thinking', detailsMode, sections),
tools: sectionMode('tools', detailsMode, sections),
subagents: sectionMode('subagents', detailsMode, sections),
activity: sectionMode('activity', detailsMode, sections)
thinking: sectionMode('thinking', detailsMode, sections, commandOverride),
tools: sectionMode('tools', detailsMode, sections, commandOverride),
subagents: sectionMode('subagents', detailsMode, sections, commandOverride),
activity: sectionMode('activity', detailsMode, sections, commandOverride)
}),
[detailsMode, sections]
[commandOverride, detailsMode, sections]
)
const [now, setNow] = useState(() => Date.now())

View file

@ -57,9 +57,20 @@ export const resolveSections = (raw: unknown): SectionVisibility =>
) 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
// Effective mode for one section: explicit override → global command mode →
// built-in live-stream defaults → global config mode.
//
// The `commandOverride` flag is set for in-session `/details <mode>` changes.
// That command should immediately apply to every section, including sections
// with built-in defaults like thinking/tools=expanded and activity=hidden. On
// startup/config sync we keep those defaults layered above the persisted global
// config so the TUI still opens live reasoning/tools by default unless the user
// pins explicit per-section overrides.
export const sectionMode = (
name: SectionName,
global: DetailsMode,
sections?: SectionVisibility,
commandOverride = false
): DetailsMode => sections?.[name] ?? (commandOverride ? global : (SECTION_DEFAULTS[name] ?? global))
export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]!