File size: 5,096 Bytes
064bfd6 6db367d 064bfd6 6db367d 064bfd6 6db367d 064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | // 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>
)
}
|