Download ui-tui/src/components/appLayout.tsx from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 21.6 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/components/appLayout.tsx
- Command line
-
hf download hf://SaylorTwift/hermes-agent/ui-tui/src/components/appLayout.tsx
-
curl -L -o appLayout.tsx https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/components/appLayout.tsx
21.6 kB
| // Importing the apps barrel registers the reference widget apps at startup. | |
| import '../sdk/apps/index.js' | |
| import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' | |
| import { useStore } from '@nanostores/react' | |
| import { Fragment, memo, type MutableRefObject, useEffect, useMemo, useRef } from 'react' | |
| import { useGateway } from '../app/gatewayContext.js' | |
| import type { AppLayoutProps } from '../app/interfaces.js' | |
| import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' | |
| import { $petBox } from '../app/petFlashStore.js' | |
| import { $uiState } from '../app/uiStore.js' | |
| import { usePet } from '../app/usePet.js' | |
| import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js' | |
| import { PLACEHOLDER } from '../content/placeholders.js' | |
| import { prevRenderedMsg } from '../domain/blockLayout.js' | |
| import { | |
| COMPOSER_PROMPT_GAP_WIDTH, | |
| composerPromptWidth, | |
| inputVisualHeight, | |
| stableComposerColumns | |
| } from '../lib/inputMetrics.js' | |
| import { PerfPane } from '../lib/perfPane.js' | |
| import { composerPromptText } from '../lib/prompt.js' | |
| import { ActiveWidgetSlot, AmbientDock, AmbientRail, useAmbientRailWidth } from '../sdk/host.js' | |
| import { AgentsOverlay } from './agentsOverlay.js' | |
| import { LiveAgentsPanel } from './agentsPanel.js' | |
| import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' | |
| import { FloatingOverlays, PromptZone } from './appOverlays.js' | |
| import { Banner, Panel, SessionPanel } from './branding.js' | |
| import { FpsOverlay } from './fpsOverlay.js' | |
| import { HelpHint } from './helpHint.js' | |
| import { Journey } from './journey.js' | |
| import { MessageLine } from './messageLine.js' | |
| import { PetKitty, PetSprite } from './petSprite.js' | |
| import { QueuedMessages } from './queuedMessages.js' | |
| import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' | |
| import { type InputCursorSnapshot, TextInput, type TextInputMouseApi } from './textInput.js' | |
| // Box geometry, kept here so the transcript's reservation math matches the | |
| // rendered overlay exactly. | |
| const PET_BOTTOM = 3 // rows the pet floats above the screen bottom (over the composer) | |
| const PET_PAD_LEFT = 2 | |
| const PET_RIGHT = 1 | |
| const PET_GUTTER_GAP = 1 | |
| const KITTY_PLACEHOLDER = '\u{10eeee}' | |
| // Below this many columns of remaining text width, the right gutter is too | |
| // cramped, so the transcript collapses to reserving bottom rows instead. | |
| const MIN_GUTTER_BODY_COLS = 72 | |
| // Petdex mascot — a small floating overlay riding the bottom-right corner just | |
| // above the status bar, with a little top/left breathing room. It reserves no | |
| // layout rows (the transcript scrolls underneath); instead it publishes its | |
| // footprint so the transcript can keep its text clear of it (right gutter on | |
| // wide terminals, reserved bottom rows on narrow ones). Renders nothing unless | |
| // a pet is installed + enabled. | |
| export const PetPane = memo(function PetPane() { | |
| const { enabled, grid, kitty } = usePet() | |
| // Footprint in cells. For kitty we count real placeholder cells (zero-width | |
| // diacritics make string length lie); for half-blocks it's the grid shape. | |
| const { width, height } = useMemo(() => { | |
| if (kitty) { | |
| return { | |
| height: kitty.placeholder.length, | |
| width: Math.max(0, ...kitty.placeholder.map(row => [...row].filter(ch => ch === KITTY_PLACEHOLDER).length)) | |
| } | |
| } | |
| if (grid) { | |
| return { height: grid.length, width: Math.max(0, ...grid.map(row => row.length)) } | |
| } | |
| return { height: 0, width: 0 } | |
| }, [grid, kitty]) | |
| const active = enabled && width > 0 && height > 0 | |
| useEffect(() => { | |
| $petBox.set( | |
| active | |
| ? { | |
| // Bottom PET_BOTTOM rows sit over the composer, so the transcript | |
| // only needs to clear the rest in the row-reservation (band) mode. | |
| height: Math.max(0, height - PET_BOTTOM), | |
| width: width + PET_PAD_LEFT + PET_RIGHT + PET_GUTTER_GAP | |
| } | |
| : null | |
| ) | |
| return () => $petBox.set(null) | |
| }, [active, height, width]) | |
| if (!active) { | |
| return null | |
| } | |
| return ( | |
| <NoSelect | |
| bottom={PET_BOTTOM} | |
| flexShrink={0} | |
| paddingLeft={PET_PAD_LEFT} | |
| paddingTop={1} | |
| position="absolute" | |
| right={PET_RIGHT} | |
| > | |
| {kitty ? <PetKitty color={kitty.color} placeholder={kitty.placeholder} /> : null} | |
| {!kitty && grid ? <PetSprite grid={grid} /> : null} | |
| </NoSelect> | |
| ) | |
| }) | |
| const PromptPrefix = memo(function PromptPrefix({ | |
| bold = false, | |
| color, | |
| promptText, | |
| width | |
| }: { | |
| bold?: boolean | |
| color: string | |
| promptText: string | |
| width: number | |
| }) { | |
| const glyphWidth = Math.max(1, width - COMPOSER_PROMPT_GAP_WIDTH) | |
| return ( | |
| <Box width={width}> | |
| <Box width={glyphWidth}> | |
| <Text bold={bold} color={color}> | |
| {promptText} | |
| </Text> | |
| </Box> | |
| <Box width={COMPOSER_PROMPT_GAP_WIDTH} /> | |
| </Box> | |
| ) | |
| }) | |
| const TranscriptPane = memo(function TranscriptPane({ | |
| actions, | |
| composer, | |
| progress, | |
| transcript | |
| }: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) { | |
| const ui = useStore($uiState) | |
| const petBox = useStore($petBox) | |
| const railCols = useAmbientRailWidth('left') + useAmbientRailWidth('right') | |
| // Keep transcript text clear of the floating pet, responsively: | |
| // - wide terminals: reserve a right gutter so lines wrap to the pet's left | |
| // (as long as enough width is left for comfortable reading); | |
| // - narrow terminals: keep full width and reserve bottom rows instead, so | |
| // the newest lines sit above the pet rather than getting cramped. | |
| const useGutter = !!petBox && composer.cols - railCols - petBox.width >= MIN_GUTTER_BODY_COLS | |
| const bodyCols = Math.max(28, (useGutter && petBox ? composer.cols - petBox.width : composer.cols) - railCols) | |
| const petBandRows = petBox && !useGutter ? petBox.height : 0 | |
| // LiveTodoPanel rides as a child of the latest user-message row so it | |
| // visually belongs to the prompt and follows it during scroll. -1 when | |
| // empty → row.index === -1 is always false → no render. | |
| const lastUserIdx = useMemo(() => { | |
| const items = transcript.historyItems | |
| for (let i = items.length - 1; i >= 0; i--) { | |
| if (items[i].role === 'user') { | |
| return i | |
| } | |
| } | |
| return -1 | |
| }, [transcript.historyItems]) | |
| // Index of the first user-role message; every later user message gets a | |
| // small dash above it so multi-turn transcripts visually segment by | |
| // turn. -1 when no user message has been sent yet → no separator ever | |
| // renders. | |
| const firstUserIdx = useMemo( | |
| () => transcript.historyItems.findIndex(m => m.role === 'user'), | |
| [transcript.historyItems] | |
| ) | |
| return ( | |
| <> | |
| <ScrollBox | |
| flexDirection="column" | |
| flexGrow={1} | |
| flexShrink={1} | |
| onClick={(e: { cellIsBlank?: boolean }) => { | |
| if (e.cellIsBlank) { | |
| actions.clearSelection() | |
| } | |
| }} | |
| ref={transcript.scrollRef} | |
| stickyScroll | |
| > | |
| <Box flexDirection="column" paddingX={1}> | |
| {transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null} | |
| {transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => ( | |
| <Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}> | |
| {row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && ( | |
| <Box marginTop={1}> | |
| <Text color={ui.theme.color.border}>───</Text> | |
| </Box> | |
| )} | |
| {row.msg.kind === 'intro' ? ( | |
| <Box flexDirection="column" paddingTop={1}> | |
| <Banner maxWidth={Math.max(1, composer.cols - 2)} t={ui.theme} /> | |
| {row.msg.info && ( | |
| <SessionPanel | |
| info={row.msg.info} | |
| maxWidth={Math.max(1, composer.cols - 2)} | |
| sid={ui.sid} | |
| t={ui.theme} | |
| /> | |
| )} | |
| </Box> | |
| ) : row.msg.kind === 'panel' && row.msg.panelData ? ( | |
| <Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} /> | |
| ) : ( | |
| <MessageLine | |
| cols={bodyCols} | |
| compact={ui.compact} | |
| detailsMode={ui.detailsMode} | |
| detailsModeCommandOverride={ui.detailsModeCommandOverride} | |
| msg={row.msg} | |
| prev={prevRenderedMsg(i => transcript.virtualRows[i]?.msg, row.index, { | |
| commandOverride: ui.detailsModeCommandOverride, | |
| detailsMode: ui.detailsMode, | |
| sections: ui.sections | |
| })} | |
| sections={ui.sections} | |
| t={ui.theme} | |
| timestamps={ui.timestamps} | |
| /> | |
| )} | |
| {row.index === lastUserIdx && <LiveTodoPanel />} | |
| </Box> | |
| ))} | |
| {transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null} | |
| <StreamingAssistant | |
| cols={bodyCols} | |
| compact={ui.compact} | |
| detailsMode={ui.detailsMode} | |
| detailsModeCommandOverride={ui.detailsModeCommandOverride} | |
| prevMsg={transcript.historyItems[transcript.historyItems.length - 1]} | |
| progress={progress} | |
| sections={ui.sections} | |
| /> | |
| {/* Narrow terminals: reserve rows so the newest lines sit above the pet. */} | |
| {petBandRows > 0 ? <Box height={petBandRows} /> : null} | |
| </Box> | |
| </ScrollBox> | |
| <NoSelect flexShrink={0} marginLeft={1}> | |
| <TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} /> | |
| </NoSelect> | |
| <StickyPromptTracker | |
| messages={transcript.historyItems} | |
| offsets={transcript.virtualHistory.offsets} | |
| onChange={actions.setStickyPrompt} | |
| scrollRef={transcript.scrollRef} | |
| /> | |
| </> | |
| ) | |
| }) | |
| const ComposerPane = memo(function ComposerPane({ | |
| actions, | |
| composer, | |
| cursorSnapshotRef, | |
| status | |
| }: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'> & { | |
| cursorSnapshotRef: MutableRefObject<InputCursorSnapshot | null> | |
| }) { | |
| const ui = useStore($uiState) | |
| const isBlocked = useStore($isBlocked) | |
| const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') | |
| const promptText = composerPromptText( | |
| ui.theme.brand.prompt, | |
| ui.info?.profile_name, | |
| sh, | |
| TERMUX_TUI_MODE, | |
| composer.cols | |
| ) | |
| const promptWidth = composerPromptWidth(promptText) | |
| const promptBlank = ' '.repeat(promptWidth) | |
| const inputColumns = stableComposerColumns(composer.cols, promptWidth, TERMUX_TUI_MODE) | |
| const inputHeight = inputVisualHeight(composer.input, inputColumns) | |
| const inputMouseRef = useRef<null | TextInputMouseApi>(null) | |
| const captureInputDrag = (e: GutterMouseEvent) => { | |
| if (e.button !== 0) { | |
| return | |
| } | |
| e.stopImmediatePropagation?.() | |
| inputMouseRef.current?.startAtBeginning() | |
| } | |
| // Drag origin matches the input box's top-left, so localRow / localCol | |
| // map directly into TextInput coords (after backing out the prompt cell). | |
| const dragFromPromptRow = (e: GutterMouseEvent) => { | |
| if (e.button !== 0) { | |
| return | |
| } | |
| e.stopImmediatePropagation?.() | |
| inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth) | |
| } | |
| // Spacer rows live on a different vertical origin; only the column is | |
| // parent-aligned with the input. Force row=0 so vertical drags can't | |
| // jump the cursor to the wrong wrapped line. | |
| const dragFromSpacer = (e: GutterMouseEvent) => { | |
| if (e.button !== 0) { | |
| return | |
| } | |
| e.stopImmediatePropagation?.() | |
| inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth) | |
| } | |
| const endInputDrag = () => inputMouseRef.current?.end() | |
| return ( | |
| <NoSelect | |
| flexDirection="column" | |
| flexShrink={0} | |
| fromLeftEdge | |
| onClick={(e: { cellIsBlank?: boolean }) => { | |
| if (e.cellIsBlank) { | |
| actions.clearSelection() | |
| } | |
| }} | |
| paddingX={1} | |
| > | |
| <QueuedMessages | |
| cols={composer.cols} | |
| queued={composer.queuedDisplay} | |
| queueEditIdx={composer.queueEditIdx} | |
| t={ui.theme} | |
| /> | |
| {ui.bgTasks.size > 0 && ( | |
| <Text color={ui.theme.color.muted}> | |
| {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running | |
| </Text> | |
| )} | |
| {status.showStickyPrompt ? ( | |
| <Text color={ui.theme.color.muted} wrap="truncate-end"> | |
| <Text color={ui.theme.color.label}>↳ </Text> | |
| {status.stickyPrompt} | |
| </Text> | |
| ) : ( | |
| <Box height={1} onMouseDown={captureInputDrag} onMouseDrag={dragFromSpacer} onMouseUp={endInputDrag} /> | |
| )} | |
| <LiveAgentsPanel cols={Math.max(1, composer.cols - 2)} /> | |
| <StatusRulePane at="top" composer={composer} status={status} /> | |
| <AmbientDock placement="dock-top" /> | |
| <Box flexDirection="column" marginTop={ui.statusBar === 'top' ? 0 : 1} position="relative"> | |
| <FloatingOverlays | |
| cols={composer.cols} | |
| compIdx={composer.compIdx} | |
| completions={composer.completions} | |
| onActiveSessionClose={actions.closeLiveSession} | |
| onActiveSessionSelect={actions.activateLiveSession} | |
| onModelSelect={actions.onModelSelect} | |
| onNewLiveSession={actions.newLiveSession} | |
| onNewPromptSession={actions.newPromptSession} | |
| onResumeSelect={actions.resumeById} | |
| pagerPageSize={composer.pagerPageSize} | |
| /> | |
| {composer.input === '?' && !composer.inputBuf.length && <HelpHint t={ui.theme} />} | |
| {!isBlocked && ( | |
| <> | |
| {composer.inputBuf.map((line, i) => ( | |
| <Box key={i}> | |
| <Box width={promptWidth}> | |
| {i === 0 ? ( | |
| <PromptPrefix color={ui.theme.color.muted} promptText={promptText} width={promptWidth} /> | |
| ) : ( | |
| <Text color={ui.theme.color.muted}>{promptBlank}</Text> | |
| )} | |
| </Box> | |
| <Text color={ui.theme.color.text}>{line || ' '}</Text> | |
| </Box> | |
| ))} | |
| <Box | |
| onMouseDown={captureInputDrag} | |
| onMouseDrag={dragFromPromptRow} | |
| onMouseUp={endInputDrag} | |
| position="relative" | |
| width={Math.max(1, composer.cols - 2)} | |
| > | |
| <Box width={promptWidth}> | |
| {sh ? ( | |
| <PromptPrefix color={ui.theme.color.shellDollar} promptText={promptText} width={promptWidth} /> | |
| ) : composer.inputBuf.length ? ( | |
| <Text color={ui.theme.color.prompt}>{promptBlank}</Text> | |
| ) : ( | |
| <PromptPrefix bold color={ui.theme.color.prompt} promptText={promptText} width={promptWidth} /> | |
| )} | |
| </Box> | |
| <Box flexGrow={0} flexShrink={0} height={inputHeight} width={inputColumns}> | |
| {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} | |
| <TextInput | |
| accentColor={ui.theme.color.accent} | |
| color={ui.theme.color.text} | |
| columns={inputColumns} | |
| cursorSnapshotRef={cursorSnapshotRef} | |
| mouseApiRef={inputMouseRef} | |
| onChange={composer.updateInput} | |
| onPaste={composer.handleTextPaste} | |
| onSubmit={composer.submit} | |
| placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} | |
| // Exactly the "(and N more toolsets…)" tone. `muted` is a | |
| // MID-luminance family tone, so it reads receded on both | |
| // poles even when polarity detection is wrong (transparent | |
| // terminals lie about their background); anything blended | |
| // toward the resolved surface inherits that wrong polarity. | |
| placeholderColor={ui.theme.color.muted} | |
| value={composer.input} | |
| voiceRecordKey={composer.voiceRecordKey} | |
| /> | |
| </Box> | |
| <Box position="absolute" right={0}> | |
| <GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} /> | |
| </Box> | |
| </Box> | |
| </> | |
| )} | |
| </Box> | |
| {!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}>☤ {ui.status}</Text>} | |
| <AmbientDock placement="dock-bottom" /> | |
| <StatusRulePane at="bottom" composer={composer} status={status} /> | |
| </NoSelect> | |
| ) | |
| }) | |
| const AgentsOverlayPane = memo(function AgentsOverlayPane() { | |
| const { gw } = useGateway() | |
| const ui = useStore($uiState) | |
| const overlay = useStore($overlayState) | |
| return ( | |
| <AgentsOverlay | |
| gw={gw} | |
| initialHistoryIndex={overlay.agentsInitialHistoryIndex} | |
| onClose={() => patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })} | |
| t={ui.theme} | |
| /> | |
| ) | |
| }) | |
| const JourneyPane = memo(function JourneyPane() { | |
| const { gw } = useGateway() | |
| const ui = useStore($uiState) | |
| return <Journey gw={gw} onClose={() => patchOverlayState({ journey: false })} t={ui.theme} /> | |
| }) | |
| const StatusRulePane = memo(function StatusRulePane({ | |
| at, | |
| composer, | |
| status | |
| }: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'top' }) { | |
| const ui = useStore($uiState) | |
| if (ui.statusBar !== at) { | |
| return null | |
| } | |
| return ( | |
| <Box marginTop={at === 'top' ? 1 : 0}> | |
| <StatusRule | |
| battery={ui.battery ? ui.batteryStatus : null} | |
| bgCount={ui.bgTasks.size} | |
| busy={ui.busy} | |
| cols={composer.cols} | |
| compacting={ui.compacting} | |
| cwdLabel={status.cwdLabel} | |
| focusView={ui.focusView} | |
| indicatorStyle={ui.indicatorStyle} | |
| lastTurnEndedAt={status.lastTurnEndedAt} | |
| liveSessionCount={ui.liveSessionCount} | |
| model={ui.info?.model ?? ''} | |
| modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'} | |
| modelReasoningEffort={ui.info?.reasoning_effort} | |
| notice={ui.notice} | |
| onSessionCountClick={() => patchOverlayState({ sessions: true })} | |
| sessionStartedAt={status.sessionStartedAt} | |
| sessionTitle={status.sessionTitle} | |
| status={ui.status} | |
| statusBarFields={ui.statusBarFields} | |
| statusColor={status.statusColor} | |
| t={ui.theme} | |
| turnStartedAt={status.turnStartedAt} | |
| usage={ui.usage} | |
| voiceLabel={status.voiceLabel} | |
| /> | |
| </Box> | |
| ) | |
| }) | |
| export const AppLayout = memo(function AppLayout({ | |
| actions, | |
| composer, | |
| mouseTracking, | |
| progress, | |
| status, | |
| transcript | |
| }: AppLayoutProps) { | |
| const overlay = useStore($overlayState) | |
| const ui = useStore($uiState) | |
| const cursorSnapshotRef = useRef<InputCursorSnapshot | null>(null) | |
| useEffect(() => { | |
| cursorSnapshotRef.current = null | |
| }, [ui.sid]) | |
| // Inline mode skips AlternateScreen so the host terminal's native | |
| // scrollback captures rows scrolled off the top; composer + progress | |
| // stay anchored via normal flex-column flow. | |
| const Shell = INLINE_MODE ? Fragment : AlternateScreen | |
| const shellProps = INLINE_MODE ? {} : { mouseTracking } | |
| return ( | |
| <Shell {...shellProps}> | |
| <Box flexDirection="column" flexGrow={1} position="relative"> | |
| <Box flexDirection="row" flexGrow={1}> | |
| {!overlay.agents && !overlay.journey && <AmbientRail side="left" />} | |
| {overlay.agents ? ( | |
| <PerfPane id="agents"> | |
| <AgentsOverlayPane /> | |
| </PerfPane> | |
| ) : overlay.journey ? ( | |
| <PerfPane id="journey"> | |
| <JourneyPane /> | |
| </PerfPane> | |
| ) : ( | |
| <PerfPane id="transcript"> | |
| <TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} /> | |
| </PerfPane> | |
| )} | |
| {!overlay.agents && !overlay.journey && <AmbientRail side="right" />} | |
| </Box> | |
| {!overlay.agents && !overlay.journey && ( | |
| <> | |
| <PerfPane id="prompt"> | |
| <PromptZone | |
| cols={composer.cols} | |
| onApprovalChoice={actions.answerApproval} | |
| onClarifyAnswer={actions.answerClarify} | |
| onClarifyQuestionAnswer={actions.answerClarifyQuestion} | |
| onSecretSubmit={actions.answerSecret} | |
| onSudoSubmit={actions.answerSudo} | |
| onVaultUnlockSubmit={actions.answerVaultUnlock} | |
| /> | |
| </PerfPane> | |
| <PerfPane id="composer"> | |
| <ComposerPane | |
| actions={actions} | |
| composer={composer} | |
| cursorSnapshotRef={cursorSnapshotRef} | |
| status={status} | |
| /> | |
| </PerfPane> | |
| {SHOW_FPS && ( | |
| <Box flexShrink={0} justifyContent="flex-end" paddingRight={1}> | |
| <FpsOverlay t={ui.theme} /> | |
| </Box> | |
| )} | |
| </> | |
| )} | |
| {!overlay.agents && <PetPane />} | |
| </Box> | |
| <ActiveWidgetSlot /> | |
| </Shell> | |
| ) | |
| }) | |
| type GutterMouseEvent = { | |
| button: number | |
| localCol?: number | |
| localRow?: number | |
| stopImmediatePropagation?: () => void | |
| } | |