codev / src /components /Settings /Settings.tsx
chenbhao's picture
feat: voice stt+tts just first have voice
6db367d
Raw
History Blame Contribute Delete
5.1 kB
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import * as React from 'react'
import { Suspense, useState } from 'react'
import { useKeybinding } from '../../keybindings/useKeybinding.js'
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import {
useIsInsideModal,
useModalOrTerminalSize,
} from '../../context/modalContext.js'
import { Pane } from '../design-system/Pane.js'
import { Tabs, Tab } from '../design-system/Tabs.js'
import { Status, buildDiagnostics } from './Status.js'
import { Config } from './Config.js'
import { Usage } from './Usage.js'
import type {
LocalJSXCommandContext,
CommandResultDisplay,
} from '../../commands.js'
type Props = {
onClose: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
context: LocalJSXCommandContext
defaultTab: 'Status' | 'Config' | 'Usage' | 'Gates'
}
export function Settings({
onClose,
context,
defaultTab,
}: Props): React.ReactNode {
const [selectedTab, setSelectedTab] = useState<string>(defaultTab)
const [tabsHidden, setTabsHidden] = useState(false)
// True while Config's own Esc handler is active (search mode with content
// focused). Settings must cede Esc so search can clear/exit first.
const [configOwnsEsc, setConfigOwnsEsc] = useState(false)
const [gatesOwnsEsc, setGatesOwnsEsc] = useState(false)
// Fixed content height so switching tabs doesn't shift the pane height.
// Outside modals cap at min(80% viewport, 30). Inside a Modal the modal's
// innerSize.rows IS the ScrollBox viewport — the 0.8 multiplier over-
// shrinks, leaving empty rows while Config shows "↓ N more below".
//
// Inside-modal math: Config's paneCap-10 chrome estimate was tuned for
// marginY={1} (2 rows) which is stripped inside modals → +2 to recover.
// Then -2 for Tabs' header row + its marginTop=1. Plus +1 observed gap
// from the paneCap-10 estimate being slightly generous. Net: rows + 1.
const insideModal = useIsInsideModal()
const { rows } = useModalOrTerminalSize(useTerminalSize())
const contentHeight = insideModal
? rows + 1
: Math.max(15, Math.min(Math.floor(rows * 0.8), 30))
// Kick off diagnostics once when the pane opens. Status use()s this so
// it resolves once per /config invocation — no re-fetch flash when
// tabbing back to Status (Tab unmounts children when not selected).
const [diagnosticsPromise] = useState(() =>
buildDiagnostics().catch(() => []),
)
useExitOnCtrlCDWithKeybindings()
// Handle escape via keybinding - only when not in submenu
const handleEscape = () => {
// Don't handle escape when a submenu is showing (tabsHidden means submenu is open)
// Let the submenu handle escape to return to the main menu
if (tabsHidden) {
return
}
// TODO: Update to "Settings" dialog once we define '/settings'.
onClose('Status dialog dismissed', { display: 'system' })
}
// Disable when submenu is open so the submenu's Dialog can handle ESC,
// and when Config's search mode is active so its useInput handler
// (clear query → exit search) processes Escape first.
useKeybinding('confirm:no', handleEscape, {
context: 'Settings',
isActive:
!tabsHidden &&
!(selectedTab === 'Config' && configOwnsEsc) &&
!(selectedTab === 'Gates' && gatesOwnsEsc),
})
const tabs = [
<Tab key="status" title="Status">
<Status context={context} diagnosticsPromise={diagnosticsPromise} />
</Tab>,
<Tab key="config" title="Config">
<Suspense fallback={null}>
<Config
context={context}
onClose={onClose}
setTabsHidden={setTabsHidden}
onIsSearchModeChange={setConfigOwnsEsc}
contentHeight={contentHeight}
/>
</Suspense>
</Tab>,
<Tab key="usage" title="Usage">
<Usage />
</Tab>,
...("external" === 'ant'
? [
<Tab key="gates" title="Gates">
<Gates
onOwnsEscChange={setGatesOwnsEsc}
contentHeight={contentHeight}
/>
</Tab>,
]
: []),
]
return (
<Pane color="permission">
<Tabs
color="permission"
selectedTab={selectedTab}
onTabChange={setSelectedTab}
hidden={tabsHidden}
// Config has interactive content — start with header unfocused so
// left/right/tab cycle option values instead of switching tabs.
initialHeaderFocused={defaultTab !== 'Config' && defaultTab !== 'Gates'}
// Inside a Modal, skip the Tabs-level cap so tall tabs (Status's
// MCP list) flow to their natural height for the Modal's ScrollBox
// to scroll. Config/Gates still get contentHeight above — they
// paginate internally so this only affects Status/Usage.
contentHeight={tabsHidden || insideModal ? undefined : contentHeight}
>
{tabs}
</Tabs>
</Pane>
)
}