@@ -470,6 +470,20 @@ export function activeOverlayItemId(
470470}
471471
472472export function paintOverlayList ( shell : AppShell ) : void {
473+ const list = shell . overlayList ;
474+ if ( ! list ) return ;
475+ // Geometry's assigned host rows, not overlayHost.height: OpenTUI still
476+ // reports the dummy height 1 until the next layout pass, and fitting to
477+ // that dummy drops the decision header on a host that is actually tall.
478+ const hostH = Math . max ( 0 , shell . layout . overlayHeight ) ;
479+ if ( hostH > 0 ) {
480+ fitOverlayListToHost ( shell , hostH ) ;
481+ return ;
482+ }
483+ paintOverlayListContents ( shell ) ;
484+ }
485+
486+ function paintOverlayListContents ( shell : AppShell ) : void {
473487 const list = shell . overlayList ;
474488 if ( ! list ) return ;
475489 const bag = shellInternals ( shell ) ;
@@ -624,6 +638,57 @@ export function paintPromptBorder(shell: AppShell): void {
624638 shell . promptBottomRule . content = new StyledText ( ruleChunks ( shell , bottom ) ) ;
625639}
626640
641+ /**
642+ * Shrink overlay body and list so they fit the assigned host. A short
643+ * terminal can leave fewer rows than chrome plus a full-height choice;
644+ * dropping context first keeps one choice row inside the box so the
645+ * operator can still answer.
646+ */
647+ function fitOverlayListToHost ( shell : AppShell , hostH : number ) : void {
648+ const list = shell . overlayList ;
649+ if ( ! list || hostH <= 0 ) return ;
650+ const bag = shellInternals ( shell ) ;
651+ const hasDesc = ! ! bag ?. primaryBindings . describe ;
652+ const hasAnswer = overlayAnswerState ( shell ) !== null ;
653+ const perItem = overlayRowsPerItem ( shell . overlayKind ) ;
654+ const hasItems = shell . overlayItems . length > 0 ;
655+ const choiceWant = hasItems ? perItem : 0 ;
656+ const choiceMin = hasItems ? 1 : 0 ;
657+ let bodyCount = shell . overlayBodyLines . length ;
658+ const chromeOf = ( n : number ) : number =>
659+ overlayChromeRows ( shell . overlayKind , n , hasDesc , hasAnswer ) ;
660+ let chrome = chromeOf ( bodyCount ) ;
661+ while ( bodyCount > 0 && hostH - chrome < choiceMin ) {
662+ bodyCount -= 1 ;
663+ chrome = chromeOf ( bodyCount ) ;
664+ }
665+ while ( bodyCount > 0 && hostH - chrome < choiceWant ) {
666+ bodyCount -= 1 ;
667+ chrome = chromeOf ( bodyCount ) ;
668+ }
669+ const bodyH = Math . max ( 0 , hostH - chrome ) ;
670+ if ( bodyH >= perItem ) {
671+ list . setHeight (
672+ Math . max ( 1 , Math . floor ( bodyH / perItem ) ) ,
673+ isDecisionOverlay ( shell . overlayKind ) ? DECISION_CHOICE_ROWS : 1 ,
674+ ) ;
675+ } else if ( bodyH >= 1 && hasItems ) {
676+ list . setHeight ( 1 , 1 ) ;
677+ }
678+ const savedLines = shell . overlayBodyLines ;
679+ const savedFgs = shell . overlayBodyFgs ;
680+ if ( bodyCount < savedLines . length ) {
681+ shell . overlayBodyLines = savedLines . slice ( 0 , bodyCount ) ;
682+ shell . overlayBodyFgs = savedFgs . slice ( 0 , bodyCount ) ;
683+ }
684+ try {
685+ paintOverlayListContents ( shell ) ;
686+ } finally {
687+ shell . overlayBodyLines = savedLines ;
688+ shell . overlayBodyFgs = savedFgs ;
689+ }
690+ }
691+
627692export function applyLayout ( shell : AppShell , layout : GeometryLayout ) : void {
628693 // Rows lay themselves out against the column budget (right-aligned bubbles,
629694 // pre-wrapped reasoning blocks), so a width change invalidates every painted
@@ -718,11 +783,16 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void {
718783 shell . notice . height = noticeH > 0 ? noticeH : 1 ;
719784 shell . notice . visible = noticeH > 0 ;
720785
721- const promptH = Math . max ( 1 , h . prompt ) ;
722- shell . promptBox . height = promptH ;
786+ const promptH = Math . max ( 0 , h . prompt ) ;
787+ shell . promptBox . height = promptH > 0 ? promptH : 1 ;
723788 shell . promptBox . visible = promptH > 0 ;
789+ const showPromptRules = promptH >= 2 ;
790+ const showPromptField = promptH >= 3 ;
791+ shell . promptTopRule . visible = showPromptRules || promptH === 1 ;
792+ shell . promptBottomRule . visible = showPromptRules ;
793+ shell . promptField . visible = showPromptField ;
724794 // The field takes whatever the box has left once both labelled rules are paid.
725- const promptInnerH = Math . max ( 1 , promptH - 2 ) ;
795+ const promptInnerH = showPromptField ? Math . max ( 1 , promptH - 2 ) : 1 ;
726796 shell . promptField . height = promptInnerH ;
727797 // Sized explicitly rather than left to grow with its content: past the cap the
728798 // input has to scroll inside a fixed window instead of pushing the frame open.
@@ -745,21 +815,7 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void {
745815 shell . overlayHost . height = hostH > 0 ? hostH : 1 ;
746816 shell . overlayHost . visible = hostH > 0 ;
747817 if ( hostH > 0 && shell . overlayList ) {
748- const chrome = overlayChromeRows (
749- shell . overlayKind ,
750- shell . overlayBodyLines . length ,
751- ! ! bag ?. primaryBindings . describe ,
752- overlayAnswerState ( shell ) !== null ,
753- ) ;
754- const bodyH = Math . max ( 1 , hostH - chrome ) ;
755- // The viewport counts items, not rows; a decision overlay spends several
756- // rows per item, so the row budget has to be divided back down.
757- const perItem = overlayRowsPerItem ( shell . overlayKind ) ;
758- shell . overlayList . setHeight (
759- Math . max ( 1 , Math . floor ( bodyH / perItem ) ) ,
760- isDecisionOverlay ( shell . overlayKind ) ? DECISION_CHOICE_ROWS : 1 ,
761- ) ;
762- paintOverlayList ( shell ) ;
818+ fitOverlayListToHost ( shell , hostH ) ;
763819 }
764820
765821 paintPromptBorder ( shell ) ;
0 commit comments