Skip to content

Commit d84a8a6

Browse files
authored
Merge pull request #2509 from dgageot/board/fix-docker-agent-github-issue-2495-35d25358
fix(tui): make user_prompt elicitation dialog scrollable
2 parents aabb4c2 + ec5ce4b commit d84a8a6

2 files changed

Lines changed: 291 additions & 87 deletions

File tree

‎pkg/tui/dialog/elicitation.go‎

Lines changed: 202 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
4557
type 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

6079
type 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

124146
func (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.
187215
func (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

211235
func (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.
500646
func (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

567681
func (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

Comments
 (0)