@@ -15,6 +15,7 @@ import (
1515
1616 "github.com/docker/docker-agent/pkg/tools"
1717 "github.com/docker/docker-agent/pkg/tui/components/markdown"
18+ "github.com/docker/docker-agent/pkg/tui/components/scrollview"
1819 "github.com/docker/docker-agent/pkg/tui/core/layout"
1920 "github.com/docker/docker-agent/pkg/tui/styles"
2021)
@@ -23,6 +24,13 @@ const (
2324 defaultCharLimit = 500
2425 numberCharLimit = 50
2526 defaultWidth = 50
27+
28+ // elicitationHeaderLines is the count of fixed header lines above the
29+ // scrollable body (title + separator).
30+ elicitationHeaderLines = 2
31+ // elicitationOverhead is the dialog height not available to the body:
32+ // header (2) + footer blank+help (2) + frame border+padding (4).
33+ elicitationOverhead = 8
2634)
2735
2836// ElicitationField represents a form field extracted from a JSON schema.
@@ -42,6 +50,10 @@ type ElicitationField struct {
4250// When a schema is provided, fields are rendered as a form.
4351// When no schema is provided, a single free-form text input (responseInput)
4452// is shown so the user can type an answer.
53+ //
54+ // The body region (message + fields, or message + free-form input) is
55+ // rendered inside a scrollview so long content remains accessible when it
56+ // would otherwise overflow the terminal.
4557type ElicitationDialog struct {
4658 BaseDialog
4759
@@ -55,6 +67,13 @@ type ElicitationDialog struct {
5567 keyMap elicitationKeyMap
5668 fieldErrors map [int ]string // validation error messages per field
5769 responseInput textinput.Model // free-form text input used when len(fields) == 0
70+
71+ scrollview * scrollview.Model
72+ // fieldStarts[i] is the line offset of field i's label inside the
73+ // scrollable body. Populated by View() / Position().
74+ fieldStarts []int
75+ // scrollableRow is the absolute screen row of the first scrollable line.
76+ scrollableRow int
5877}
5978
6079type elicitationKeyMap struct {
@@ -96,6 +115,9 @@ func NewElicitationDialog(message string, schema any, meta map[string]any) Dialo
96115 Escape : key .NewBinding (key .WithKeys ("esc" )),
97116 Space : key .NewBinding (key .WithKeys ("space" )),
98117 },
118+ // Up/Down stay reserved for selection inside enum/boolean fields;
119+ // the scrollview consumes mouse wheel/scrollbar plus PgUp/PgDn/Home/End.
120+ scrollview : scrollview .New (scrollview .WithReserveScrollbarSpace (true )),
99121 }
100122
101123 // If no schema fields, add a free-form text input for the response
@@ -122,6 +144,12 @@ func (d *ElicitationDialog) Init() tea.Cmd {
122144}
123145
124146func (d * ElicitationDialog ) Update (msg tea.Msg ) (layout.Model , tea.Cmd ) {
147+ // Let the scrollview consume mouse wheel/scrollbar drag and the
148+ // PgUp/PgDn/Home/End keys before falling through to dialog handling.
149+ if handled , cmd := d .scrollview .Update (msg ); handled {
150+ return d , cmd
151+ }
152+
125153 switch msg := msg .(type ) {
126154 case tea.WindowSizeMsg :
127155 cmd := d .SetSize (msg .Width , msg .Height )
@@ -185,27 +213,23 @@ func (d *ElicitationDialog) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, t
185213
186214// moveSelection moves the selection up/down within a boolean or enum field.
187215func (d * ElicitationDialog ) moveSelection (delta int ) {
216+ if d .currentField >= len (d .fields ) {
217+ return
218+ }
188219 delete (d .fieldErrors , d .currentField )
189220
190- switch d . currentFieldType () {
221+ switch field := d . fields [ d . currentField ]; field . Type {
191222 case "boolean" :
192223 // Boolean only has two options: toggle
193224 d .boolValues [d .currentField ] = ! d .boolValues [d .currentField ]
194225 case "enum" :
195- field := d .fields [d .currentField ]
196226 n := len (field .EnumValues )
197227 if n == 0 {
198228 return
199229 }
200230 d .enumIndexes [d .currentField ] = (d .enumIndexes [d .currentField ] + delta + n ) % n
201231 }
202- }
203-
204- func (d * ElicitationDialog ) currentFieldType () string {
205- if d .currentField < len (d .fields ) {
206- return d .fields [d .currentField ].Type
207- }
208- return ""
232+ d .ensureFocusVisible ()
209233}
210234
211235func (d * ElicitationDialog ) submit () (layout.Model , tea.Cmd ) {
@@ -269,6 +293,38 @@ func (d *ElicitationDialog) focusField(idx int) {
269293 if d .isTextInputField () {
270294 d .inputs [d .currentField ].Focus ()
271295 }
296+ d .ensureFocusVisible ()
297+ }
298+
299+ // ensureFocusVisible scrolls so that the focused field's active line stays
300+ // in view. No-op before the first View() populates fieldStarts.
301+ func (d * ElicitationDialog ) ensureFocusVisible () {
302+ if line := d .focusLine (); line >= 0 {
303+ d .scrollview .EnsureLineVisible (line )
304+ }
305+ }
306+
307+ // focusLine returns the line offset (within the scrollable body) of the
308+ // focused field's active line — the selected option for enums/booleans, the
309+ // input line for text fields. Returns -1 if no field is focused or layouts
310+ // haven't been computed yet.
311+ func (d * ElicitationDialog ) focusLine () int {
312+ if d .currentField < 0 || d .currentField >= len (d .fieldStarts ) {
313+ return - 1
314+ }
315+ start := d .fieldStarts [d .currentField ]
316+ switch f := d .fields [d .currentField ]; f .Type {
317+ case "boolean" :
318+ if d .boolValues [d .currentField ] {
319+ return start + 1 // "Yes"
320+ }
321+ return start + 2 // "No"
322+ case "enum" :
323+ idx := max (0 , min (d .enumIndexes [d .currentField ], len (f .EnumValues )- 1 ))
324+ return start + 1 + idx
325+ default :
326+ return start + 1 // input line
327+ }
272328}
273329
274330// isTextInputField returns true if the current field uses a text input (not boolean/enum).
@@ -372,41 +428,131 @@ func (d *ElicitationDialog) parseAndValidateField(val string, field ElicitationF
372428 }
373429}
374430
375- func (d * ElicitationDialog ) View () string {
431+ // elicitationLayout captures the geometry computed once per render. View()
432+ // and Position() share it so layout math lives in exactly one place.
433+ type elicitationLayout struct {
434+ dialogWidth int
435+ contentWidth int // inside dialog frame
436+ viewport int // height of the scrollable region in lines
437+ bodyLines []string // pre-rendered body, one entry per line
438+ fieldStarts []int // line offset of each field's label
439+ }
440+
441+ // dialogHeight is the total rendered height of the dialog, including frame.
442+ func (l elicitationLayout ) dialogHeight () int { return l .viewport + elicitationOverhead }
443+
444+ func (d * ElicitationDialog ) layout () elicitationLayout {
376445 dialogWidth := d .ComputeDialogWidth (70 , 60 , 90 )
377446 contentWidth := d .ContentWidth (dialogWidth , 2 )
447+ innerWidth := max (1 , contentWidth - d .scrollview .ReservedCols ())
378448
379- content := NewContent (contentWidth )
380- content .AddTitle (d .title )
381- content .AddSeparator ()
382- content .AddContent (renderMarkdownMessage (d .message , contentWidth ))
449+ bodyLines , fieldStarts := d .buildBody (innerWidth )
450+ maxViewport := max (1 , min (d .Height ()* 80 / 100 , 40 )- elicitationOverhead )
451+ viewport := max (1 , min (len (bodyLines ), maxViewport ))
383452
384- if len (d .fields ) > 0 {
385- content .AddSeparator ()
453+ return elicitationLayout {
454+ dialogWidth : dialogWidth ,
455+ contentWidth : contentWidth ,
456+ viewport : viewport ,
457+ bodyLines : bodyLines ,
458+ fieldStarts : fieldStarts ,
459+ }
460+ }
461+
462+ // buildBody renders the scrollable body using the existing Content-based
463+ // helpers and records the line offset of every field's label. Tracks line
464+ // count incrementally to keep buildBody O(N) in the number of fields.
465+ func (d * ElicitationDialog ) buildBody (width int ) (lines []string , fieldStarts []int ) {
466+ body := NewContent (width )
467+ lineCount := 0
468+
469+ if d .message != "" {
470+ msgRendered := renderMarkdownMessage (d .message , width )
471+ body .AddContent (msgRendered )
472+ lineCount += lipgloss .Height (msgRendered )
473+ }
474+
475+ switch {
476+ case len (d .fields ) > 0 :
477+ body .AddSeparator ()
478+ lineCount ++ // separator adds 1 line
479+
480+ fieldStarts = make ([]int , len (d .fields ))
386481 for i , field := range d .fields {
387- d .renderField (content , i , field , contentWidth )
482+ // Record the current line count as this field's start position.
483+ // This avoids O(N²) by tracking line count incrementally instead
484+ // of calling body.Build() in the loop.
485+ fieldStarts [i ] = lineCount
486+
487+ // Render the field into a temporary Content to measure its height
488+ // without rebuilding the entire body.
489+ tempContent := NewContent (width )
490+ d .renderField (tempContent , i , field , width )
491+ fieldRendered := tempContent .Build ()
492+ fieldHeight := lipgloss .Height (fieldRendered )
493+
494+ // Add the pre-rendered field to the main body
495+ body .AddContent (fieldRendered )
496+ lineCount += fieldHeight
497+
388498 if i < len (d .fields )- 1 {
389- content .AddSpace ()
499+ body .AddSpace ()
500+ lineCount ++ // blank line separator
390501 }
391502 }
392- } else if d .hasFreeFormInput () {
393- content .AddSeparator ()
394- d .responseInput .SetWidth (contentWidth )
395- content .AddContent (d .responseInput .View ())
503+
504+ case d .hasFreeFormInput ():
505+ body .AddSeparator ()
506+ d .responseInput .SetWidth (width )
507+ body .AddContent (d .responseInput .View ())
396508 }
397509
398- content .AddSpace ()
510+ return strings .Split (body .Build (), "\n " ), fieldStarts
511+ }
512+
513+ func (d * ElicitationDialog ) View () string {
514+ l := d .layout ()
515+ d .fieldStarts = l .fieldStarts
516+
517+ // Configure the scrollview viewport, give it the body, and scroll so the
518+ // focused field stays visible.
519+ d .scrollview .SetSize (l .contentWidth , l .viewport )
520+ d .scrollview .SetContent (l .bodyLines , len (l .bodyLines ))
521+ d .ensureFocusVisible ()
522+
523+ // Tell the scrollview where it lives on screen (for scrollbar drag) and
524+ // remember the body's top row for our own mouse click hit-testing.
525+ row , col := CenterPosition (d .Width (), d .Height (), l .dialogWidth , l .dialogHeight ())
526+ frameTop := styles .DialogStyle .GetBorderTopSize () + styles .DialogStyle .GetPaddingTop ()
527+ frameLeft := styles .DialogStyle .GetBorderLeftSize () + styles .DialogStyle .GetPaddingLeft ()
528+ d .scrollableRow = row + frameTop + elicitationHeaderLines
529+ d .scrollview .SetPosition (col + frameLeft , d .scrollableRow )
530+
531+ parts := []string {
532+ RenderTitle (d .title , l .contentWidth , styles .DialogTitleStyle ),
533+ RenderSeparator (l .contentWidth ),
534+ }
535+ parts = append (parts , strings .Split (d .scrollview .View (), "\n " )... )
536+ parts = append (parts , "" , RenderHelpKeys (l .contentWidth , d .helpPairs ()... ))
537+
538+ return styles .DialogStyle .Width (l .dialogWidth ).Render (lipgloss .JoinVertical (lipgloss .Left , parts ... ))
539+ }
540+
541+ // helpPairs returns key/description pairs for the dialog's bottom help line,
542+ // in left-to-right display order.
543+ func (d * ElicitationDialog ) helpPairs () []string {
544+ var pairs []string
545+ if d .hasSelectionFields () {
546+ pairs = append (pairs , "↑/↓" , "select" )
547+ }
399548 if len (d .fields ) > 0 {
400- if d .hasSelectionFields () {
401- content .AddHelpKeys ("↑/↓" , "select" , "tab" , "next field" , "enter" , "submit" , "esc" , "cancel" )
402- } else {
403- content .AddHelpKeys ("tab" , "next field" , "enter" , "submit" , "esc" , "cancel" )
404- }
405- } else {
406- content .AddHelpKeys ("enter" , "submit" , "esc" , "cancel" )
549+ pairs = append (pairs , "tab" , "next field" )
407550 }
408-
409- return styles .DialogStyle .Width (dialogWidth ).Render (content .Build ())
551+ pairs = append (pairs , "enter" , "submit" , "esc" , "cancel" )
552+ if d .scrollview .NeedsScrollbar () {
553+ pairs = append (pairs , "pgup/pgdn" , "scroll" )
554+ }
555+ return pairs
410556}
411557
412558// hasSelectionFields returns true if any field uses selection-based input (boolean or enum).
@@ -498,74 +644,43 @@ func capitalizeFirst(s string) string {
498644
499645// handleMouseClick handles mouse click events for field focus and selection toggling.
500646func (d * ElicitationDialog ) handleMouseClick (msg tea.MouseClickMsg ) (layout.Model , tea.Cmd ) {
501- if len (d .fields ) == 0 {
647+ if len (d .fieldStarts ) == 0 || d . scrollableRow == 0 {
502648 return d , nil
503649 }
650+ relY := msg .Y - d .scrollableRow
651+ if relY < 0 || relY >= d .scrollview .VisibleHeight () {
652+ return d , nil
653+ }
654+ line := d .scrollview .ScrollOffset () + relY
504655
505- dialogRow , _ := d .Position ()
506- dialogWidth := d .ComputeDialogWidth (70 , 60 , 90 )
507- contentWidth := d .ContentWidth (dialogWidth , 2 )
508-
509- // Compute the Y offset where fields start by measuring the rendered header.
510- header := lipgloss .JoinVertical (lipgloss .Left ,
511- styles .DialogTitleStyle .Width (contentWidth ).Render (d .title ),
512- RenderSeparator (contentWidth ),
513- renderMarkdownMessage (d .message , contentWidth ),
514- RenderSeparator (contentWidth ),
515- )
516- y := ContentStartRow (dialogRow , header )
517-
518- // Now iterate through fields to find which field/option was clicked.
519- clickY := msg .Y
520- for i , field := range d .fields {
521- labelY := y
522- y ++ // label line
523-
524- switch field .Type {
656+ // Walk backwards: the field whose start is just at or above `line` owns it.
657+ // Clicks on the blank separator after a field still focus that field.
658+ for i := len (d .fieldStarts ) - 1 ; i >= 0 ; i -- {
659+ start := d .fieldStarts [i ]
660+ if line < start {
661+ continue
662+ }
663+ offset := line - start
664+ d .focusField (i )
665+ delete (d .fieldErrors , i )
666+ switch f := d .fields [i ]; f .Type {
525667 case "boolean" :
526- if clickY >= y && clickY < y + 2 {
527- d .focusField (i )
528- d .boolValues [i ] = clickY == y // first option = Yes
529- delete (d .fieldErrors , i )
530- return d , nil
668+ if offset == 1 || offset == 2 {
669+ d .boolValues [i ] = offset == 1
531670 }
532- y += 2
533671 case "enum" :
534- numOptions := len (field .EnumValues )
535- if clickY >= y && clickY < y + numOptions {
536- d .focusField (i )
537- d .enumIndexes [i ] = clickY - y
538- delete (d .fieldErrors , i )
539- return d , nil
540- }
541- y += numOptions
542- default :
543- if clickY == y {
544- d .focusField (i )
545- return d , nil
672+ if offset >= 1 && offset <= len (f .EnumValues ) {
673+ d .enumIndexes [i ] = offset - 1
546674 }
547- y ++
548- }
549-
550- // Click on the label line focuses the field
551- if clickY == labelY {
552- d .focusField (i )
553- return d , nil
554- }
555-
556- if d .fieldErrors [i ] != "" {
557- y ++
558- }
559- if i < len (d .fields )- 1 {
560- y ++
561675 }
676+ return d , nil
562677 }
563-
564678 return d , nil
565679}
566680
567681func (d * ElicitationDialog ) Position () (row , col int ) {
568- return d .CenterDialog (d .View ())
682+ l := d .layout ()
683+ return CenterPosition (d .Width (), d .Height (), l .dialogWidth , l .dialogHeight ())
569684}
570685
571686// --- Input initialization ---
0 commit comments