import { Box, stringWidth, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import type { ReactNode } from 'react' import { useGateway } from '../app/gatewayContext.js' import type { AppOverlaysProps } from '../app/interfaces.js' import { $overlayState, hasFloatingPanel, patchOverlayState } from '../app/overlayStore.js' import { $uiSessionId, $uiTheme } from '../app/uiStore.js' import { ActiveSessionSwitcher } from './activeSessionSwitcher.js' import { FloatBox } from './appChrome.js' import { BillingOverlay } from './billingOverlay.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' import { OverlayHint } from './overlayControls.js' import { listRowStyle } from './overlayPrimitives.js' import { PetPicker } from './petPicker.js' import { PluginsHub } from './pluginsHub.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SkillsHub } from './skillsHub.js' import { SubscriptionOverlay } from './subscriptionOverlay.js' import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js' const COMPLETION_WINDOW = 16 /** * A prompt hosted in a single-cell WidgetGrid with the classic 1-cell padding. * The inner full-width column restores the horizontal stretch the old plain * padded Box gave its child, so rendering is identical; routing through the * grid makes the prompt zone a layout-engine surface like the desktop app's * pane shell. */ function PromptCell({ children, cols, id }: { children: ReactNode; cols: number; id: string }) { return ( {children} ), id } ]} /> ) } export function PromptZone({ cols, onApprovalChoice, onClarifyAnswer, onClarifyQuestionAnswer, onSecretSubmit, onSudoSubmit }: Pick< AppOverlaysProps, 'cols' | 'onApprovalChoice' | 'onClarifyAnswer' | 'onClarifyQuestionAnswer' | 'onSecretSubmit' | 'onSudoSubmit' >) { const overlay = useStore($overlayState) const theme = useStore($uiTheme) if (overlay.approval) { return ( ) } if (overlay.billing) { const current = overlay.billing const onPatch = (next: Partial) => patchOverlayState(prev => (prev.billing ? { ...prev, billing: { ...prev.billing, ...next } } : prev)) const onClose = () => patchOverlayState({ billing: null }) return ( ) } if (overlay.subscription) { const current = overlay.subscription const onPatch = (next: Partial) => patchOverlayState(prev => prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev ) const onClose = () => patchOverlayState({ subscription: null }) return ( ) } if (overlay.confirm) { const req = overlay.confirm const onConfirm = () => { patchOverlayState({ confirm: null }) req.onConfirm() } const onCancel = () => patchOverlayState({ confirm: null }) return ( ) } if (overlay.clarify) { return ( onClarifyAnswer('')} onQuestionAnswer={onClarifyQuestionAnswer} req={overlay.clarify} t={theme} /> ) } if (overlay.sudo) { return ( ) } if (overlay.secret) { return ( ) } return null } export function FloatingOverlays({ cols, compIdx, completions, onActiveSessionSelect, onActiveSessionClose, onModelSelect, onNewLiveSession, onNewPromptSession, onResumeSelect, pagerPageSize }: Pick< AppOverlaysProps, | 'cols' | 'compIdx' | 'completions' | 'onActiveSessionSelect' | 'onActiveSessionClose' | 'onModelSelect' | 'onNewLiveSession' | 'onNewPromptSession' | 'onResumeSelect' | 'pagerPageSize' >) { const { gw } = useGateway() const overlay = useStore($overlayState) const sid = useStore($uiSessionId) const theme = useStore($uiTheme) const hasAny = hasFloatingPanel(overlay) || completions.length if (!hasAny) { return null } // Fixed viewport centered on compIdx — previously the slice end was // compIdx + 8 so the dropdown grew from 8 rows to 16 as the user scrolled // down, bouncing the height on every keystroke. const viewportSize = Math.min(COMPLETION_WINDOW, completions.length) const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize)) // Every floating panel is a widget in a single-column grid. Panels keep // their intrinsic (content-hugging) widths inside full-width cells today; // multi-column tiling on wide terminals is a `columns`/track change here, // not a rewrite. `maxWidth` hands each panel its cell budget — with one // column it never binds, so rendering is identical to the pre-grid layout. const widgets: WidgetGridWidget[] = [] if (overlay.sessions) { widgets.push({ id: 'sessions', render: width => ( patchOverlayState({ sessions: false })} onClose={onActiveSessionClose} onNew={onNewLiveSession} onNewPrompt={onNewPromptSession} onResume={onResumeSelect} onSelect={onActiveSessionSelect} t={theme} /> ) }) } if (overlay.modelPicker) { const initialRefresh = typeof overlay.modelPicker === 'object' && overlay.modelPicker.refresh === true widgets.push({ id: 'model-picker', render: width => ( patchOverlayState({ modelPicker: false })} onSelect={onModelSelect} sessionId={sid} t={theme} /> ) }) } if (overlay.petPicker) { widgets.push({ id: 'pet-picker', render: width => ( patchOverlayState({ petPicker: false })} t={theme} /> ) }) } if (overlay.skillsHub) { widgets.push({ id: 'skills-hub', render: width => ( patchOverlayState({ skillsHub: false })} t={theme} /> ) }) } if (overlay.pluginsHub) { widgets.push({ id: 'plugins-hub', render: width => ( patchOverlayState({ pluginsHub: false })} t={theme} /> ) }) } const pager = overlay.pager if (pager) { widgets.push({ id: 'pager', render: () => ( {pager.title && ( {pager.title} )} {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( {line} ))} {pager.offset + pagerPageSize < pager.lines.length ? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` : `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${pager.lines.length} lines)`} ) }) } if (completions.length) { widgets.push({ id: 'completions', render: () => ( {/* No painted panel fill: FloatBox is `opaque`, so rows sit on the terminal's own background — the one color that is always right on a canvas we don't own (a full completionBg fill was the lone surface painting its own background, which is why it could disagree with every other overlay). Only the ACTIVE row carries a selection chip, mirroring the session switcher. */} {(() => { const visible = completions.slice(start, start + viewportSize) // Two-column grid: the name track auto-sizes to the widest // visible command, so descriptions align — and wrapped // description lines stay inside their own column instead of // running under the names. const nameW = Math.max(...visible.map(item => stringWidth(item.display))) + 2 return visible.map((item, i) => { const active = start + i === compIdx const row = listRowStyle(theme, active) return ( {' '} {item.display} {item.meta ? ( // Descriptions in the neutral gray, NOT a gold-family // tone — label vs muted are near-twins on some skins, // which made command and description read as one run. // Active row: meta rides the chip, so it uses row ink. {item.meta} ) : null} ) }) })()} ) }) } return ( ) }