Skip to content

Commit 800dacd

Browse files
authored
Merge pull request #1943 from dgageot/better-user-prompt
Improve user_prompt TUI dialog: title, free-form input, and navigation
2 parents b5748eb + 9a5c38a commit 800dacd

3 files changed

Lines changed: 143 additions & 43 deletions

File tree

pkg/tools/builtin/user_prompt.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ var (
2525

2626
type UserPromptArgs struct {
2727
Message string `json:"message" jsonschema:"The message/question to display to the user"`
28+
Title string `json:"title,omitempty" jsonschema:"Optional title for the dialog window (defaults to 'Question')"`
2829
Schema map[string]any `json:"schema,omitempty" jsonschema:"JSON Schema defining the expected response structure. Supports object schemas with properties or primitive type schemas."`
2930
}
3031

@@ -46,9 +47,15 @@ func (t *UserPromptTool) userPrompt(ctx context.Context, params UserPromptArgs)
4647
return tools.ResultError("user_prompt tool is not available in this context (no elicitation handler configured)"), nil
4748
}
4849

50+
var meta mcp.Meta
51+
if params.Title != "" {
52+
meta = mcp.Meta{"cagent/title": params.Title}
53+
}
54+
4955
req := &mcp.ElicitParams{
5056
Message: params.Message,
5157
RequestedSchema: params.Schema,
58+
Meta: meta,
5259
}
5360

5461
result, err := t.elicitationHandler(ctx, req)
@@ -81,7 +88,10 @@ func (t *UserPromptTool) Instructions() string {
8188
8289
Use user_prompt to ask the user a question or gather input when you need clarification, specific information, or a decision.
8390
91+
Optionally provide a "title" to label the dialog (defaults to "Question").
92+
8493
Optionally provide a JSON schema to structure the expected response (object, primitive, or enum types).
94+
If no schema is provided, the user can type a free-form response.
8595
8696
Example schema for multiple choice:
8797
{"type": "string", "enum": ["option1", "option2"], "title": "Select an option"}

pkg/tui/dialog/elicitation.go

Lines changed: 101 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -37,46 +37,83 @@ type ElicitationField struct {
3737
}
3838

3939
// ElicitationDialog implements Dialog for MCP elicitation requests.
40+
//
41+
// When a schema is provided, fields are rendered as a form.
42+
// When no schema is provided, a single free-form text input (responseInput)
43+
// is shown so the user can type an answer.
4044
type ElicitationDialog struct {
4145
BaseDialog
42-
message string
43-
fields []ElicitationField
44-
inputs []textinput.Model
45-
boolValues map[int]bool
46-
enumIndexes map[int]int // selected index for enum fields
47-
currentField int
48-
keyMap elicitationKeyMap
49-
fieldErrors map[int]string // validation error messages per field
46+
title string
47+
message string
48+
fields []ElicitationField
49+
inputs []textinput.Model
50+
boolValues map[int]bool
51+
enumIndexes map[int]int // selected index for enum fields
52+
currentField int
53+
keyMap elicitationKeyMap
54+
fieldErrors map[int]string // validation error messages per field
55+
responseInput textinput.Model // free-form text input used when len(fields) == 0
5056
}
5157

5258
type elicitationKeyMap struct {
53-
Up, Down, Enter, Escape, Space key.Binding
59+
Up, Down, Tab, ShiftTab, Enter, Escape, Space key.Binding
60+
}
61+
62+
// hasFreeFormInput returns true when no schema fields exist and the dialog
63+
// shows a single free-form text input instead.
64+
func (d *ElicitationDialog) hasFreeFormInput() bool {
65+
return len(d.fields) == 0
5466
}
5567

5668
// NewElicitationDialog creates a new elicitation dialog.
57-
func NewElicitationDialog(message string, schema any, _ map[string]any) Dialog {
69+
func NewElicitationDialog(message string, schema any, meta map[string]any) Dialog {
5870
fields := parseElicitationSchema(schema)
71+
72+
// Determine dialog title from meta, defaulting to "Question"
73+
title := "Question"
74+
if meta != nil {
75+
if t, ok := meta["cagent/title"].(string); ok && t != "" {
76+
title = t
77+
}
78+
}
79+
5980
d := &ElicitationDialog{
81+
title: title,
6082
message: message,
6183
fields: fields,
6284
inputs: make([]textinput.Model, len(fields)),
6385
boolValues: make(map[int]bool),
6486
enumIndexes: make(map[int]int),
6587
fieldErrors: make(map[int]string),
6688
keyMap: elicitationKeyMap{
67-
Up: key.NewBinding(key.WithKeys("up", "shift+tab")),
68-
Down: key.NewBinding(key.WithKeys("down", "tab")),
69-
Enter: key.NewBinding(key.WithKeys("enter")),
70-
Escape: key.NewBinding(key.WithKeys("esc")),
71-
Space: key.NewBinding(key.WithKeys("space")),
89+
Up: key.NewBinding(key.WithKeys("up")),
90+
Down: key.NewBinding(key.WithKeys("down")),
91+
Tab: key.NewBinding(key.WithKeys("tab")),
92+
ShiftTab: key.NewBinding(key.WithKeys("shift+tab")),
93+
Enter: key.NewBinding(key.WithKeys("enter")),
94+
Escape: key.NewBinding(key.WithKeys("esc")),
95+
Space: key.NewBinding(key.WithKeys("space")),
7296
},
7397
}
98+
99+
// If no schema fields, add a free-form text input for the response
100+
if len(fields) == 0 {
101+
ti := textinput.New()
102+
ti.SetStyles(styles.DialogInputStyle)
103+
ti.SetWidth(defaultWidth)
104+
ti.Prompt = ""
105+
ti.Placeholder = "Type your response"
106+
ti.CharLimit = defaultCharLimit
107+
ti.Focus()
108+
d.responseInput = ti
109+
}
110+
74111
d.initInputs()
75112
return d
76113
}
77114

78115
func (d *ElicitationDialog) Init() tea.Cmd {
79-
if len(d.inputs) > 0 {
116+
if d.hasFreeFormInput() || len(d.inputs) > 0 {
80117
return textinput.Blink
81118
}
82119
return nil
@@ -88,7 +125,12 @@ func (d *ElicitationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
88125
cmd := d.SetSize(msg.Width, msg.Height)
89126
return d, cmd
90127
case tea.PasteMsg:
91-
// Forward paste to text input if current field uses one
128+
// Forward paste to the active text input
129+
if d.hasFreeFormInput() {
130+
var cmd tea.Cmd
131+
d.responseInput, cmd = d.responseInput.Update(msg)
132+
return d, cmd
133+
}
92134
if d.isTextInputField() {
93135
var cmd tea.Cmd
94136
d.inputs[d.currentField], cmd = d.inputs[d.currentField].Update(msg)
@@ -112,17 +154,24 @@ func (d *ElicitationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
112154

113155
func (d *ElicitationDialog) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) {
114156
switch {
115-
case key.Matches(msg, d.keyMap.Space) && !d.isTextInputField():
116-
// Only handle space for boolean/enum fields; let it pass through to text input otherwise
117-
d.toggleCurrentSelection()
157+
case key.Matches(msg, d.keyMap.Space) && !d.isTextInputField() && !d.hasFreeFormInput():
158+
// Space cycles forward through options, same as down arrow
159+
d.moveSelection(1)
118160
return d, nil
119161
case key.Matches(msg, d.keyMap.Escape):
120162
cmd := d.close(tools.ElicitationActionCancel, nil)
121163
return d, cmd
122164
case key.Matches(msg, d.keyMap.Up):
123-
d.moveFocus(-1)
165+
// Up/down navigate within selection fields (enum/boolean)
166+
d.moveSelection(-1)
124167
return d, nil
125168
case key.Matches(msg, d.keyMap.Down):
169+
d.moveSelection(1)
170+
return d, nil
171+
case key.Matches(msg, d.keyMap.ShiftTab):
172+
d.moveFocus(-1)
173+
return d, nil
174+
case key.Matches(msg, d.keyMap.Tab):
126175
d.moveFocus(1)
127176
return d, nil
128177
case key.Matches(msg, d.keyMap.Enter):
@@ -132,17 +181,21 @@ func (d *ElicitationDialog) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, t
132181
}
133182
}
134183

135-
// toggleCurrentSelection toggles boolean or cycles enum for the current field.
136-
func (d *ElicitationDialog) toggleCurrentSelection() {
137-
// Clear error when user interacts with the field
184+
// moveSelection moves the selection up/down within a boolean or enum field.
185+
func (d *ElicitationDialog) moveSelection(delta int) {
138186
delete(d.fieldErrors, d.currentField)
139187

140188
switch d.currentFieldType() {
141189
case "boolean":
190+
// Boolean only has two options: toggle
142191
d.boolValues[d.currentField] = !d.boolValues[d.currentField]
143192
case "enum":
144193
field := d.fields[d.currentField]
145-
d.enumIndexes[d.currentField] = (d.enumIndexes[d.currentField] + 1) % len(field.EnumValues)
194+
n := len(field.EnumValues)
195+
if n == 0 {
196+
return
197+
}
198+
d.enumIndexes[d.currentField] = (d.enumIndexes[d.currentField] + delta + n) % n
146199
}
147200
}
148201

@@ -154,17 +207,22 @@ func (d *ElicitationDialog) currentFieldType() string {
154207
}
155208

156209
func (d *ElicitationDialog) submit() (layout.Model, tea.Cmd) {
157-
if len(d.fields) == 0 {
158-
cmd := d.close(tools.ElicitationActionAccept, nil)
210+
// Free-form response: no schema fields, just a text input
211+
if d.hasFreeFormInput() {
212+
val := strings.TrimSpace(d.responseInput.Value())
213+
var content map[string]any
214+
if val != "" {
215+
content = map[string]any{"response": val}
216+
}
217+
cmd := d.close(tools.ElicitationActionAccept, content)
159218
return d, cmd
160219
}
161220

162-
// Clear previous errors and validate
221+
// Schema-based form: validate all fields
163222
d.fieldErrors = make(map[int]string)
164223
content, firstErrorIdx := d.collectAndValidate()
165224

166225
if firstErrorIdx >= 0 {
167-
// Focus the first field with an error
168226
d.focusField(firstErrorIdx)
169227
return d, nil
170228
}
@@ -174,9 +232,12 @@ func (d *ElicitationDialog) submit() (layout.Model, tea.Cmd) {
174232
}
175233

176234
func (d *ElicitationDialog) updateCurrentInput(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) {
177-
// Only text-based fields (not boolean/enum) use the text input
235+
if d.hasFreeFormInput() {
236+
var cmd tea.Cmd
237+
d.responseInput, cmd = d.responseInput.Update(msg)
238+
return d, cmd
239+
}
178240
if d.isTextInputField() {
179-
// Clear error for current field when user types
180241
delete(d.fieldErrors, d.currentField)
181242
var cmd tea.Cmd
182243
d.inputs[d.currentField], cmd = d.inputs[d.currentField].Update(msg)
@@ -314,7 +375,7 @@ func (d *ElicitationDialog) View() string {
314375
contentWidth := d.ContentWidth(dialogWidth, 2)
315376

316377
content := NewContent(contentWidth)
317-
content.AddTitle("MCP Server Request")
378+
content.AddTitle(d.title)
318379
content.AddSeparator()
319380
content.AddContent(styles.DialogContentStyle.Width(contentWidth).Render(d.message))
320381

@@ -326,17 +387,21 @@ func (d *ElicitationDialog) View() string {
326387
content.AddSpace()
327388
}
328389
}
390+
} else if d.hasFreeFormInput() {
391+
content.AddSeparator()
392+
d.responseInput.SetWidth(contentWidth)
393+
content.AddContent(d.responseInput.View())
329394
}
330395

331396
content.AddSpace()
332397
if len(d.fields) > 0 {
333398
if d.hasSelectionFields() {
334-
content.AddHelpKeys("↑/↓", "navigate", "space", "change", "enter", "submit", "esc", "cancel")
399+
content.AddHelpKeys("↑/↓", "select", "tab", "next field", "enter", "submit", "esc", "cancel")
335400
} else {
336-
content.AddHelpKeys("↑/↓", "navigate", "enter", "submit", "esc", "cancel")
401+
content.AddHelpKeys("tab", "next field", "enter", "submit", "esc", "cancel")
337402
}
338403
} else {
339-
content.AddHelpKeys("enter", "confirm", "esc", "cancel")
404+
content.AddHelpKeys("enter", "submit", "esc", "cancel")
340405
}
341406

342407
return styles.DialogStyle.Width(dialogWidth).Render(content.Build())
@@ -441,7 +506,7 @@ func (d *ElicitationDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Mode
441506

442507
// Compute the Y offset where fields start by measuring the rendered header.
443508
header := lipgloss.JoinVertical(lipgloss.Left,
444-
styles.DialogTitleStyle.Width(contentWidth).Render("MCP Server Request"),
509+
styles.DialogTitleStyle.Width(contentWidth).Render(d.title),
445510
RenderSeparator(contentWidth),
446511
styles.DialogContentStyle.Width(contentWidth).Render(d.message),
447512
RenderSeparator(contentWidth),

pkg/tui/dialog/elicitation_test.go

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -287,14 +287,19 @@ func TestNewElicitationDialog(t *testing.T) {
287287
t.Parallel()
288288

289289
tests := []struct {
290-
name string
291-
message string
292-
schema any
290+
name string
291+
message string
292+
schema any
293+
meta map[string]any
294+
expectedTitle string
295+
hasFreeFormInput bool
293296
}{
294297
{
295-
name: "simple dialog without fields",
296-
message: "Please confirm this action",
297-
schema: nil,
298+
name: "simple dialog without fields has response input",
299+
message: "Please confirm this action",
300+
schema: nil,
301+
expectedTitle: "Question",
302+
hasFreeFormInput: true,
298303
},
299304
{
300305
name: "dialog with form fields",
@@ -307,18 +312,38 @@ func TestNewElicitationDialog(t *testing.T) {
307312
},
308313
"required": []any{"username", "password"},
309314
},
315+
expectedTitle: "Question",
316+
hasFreeFormInput: false,
317+
},
318+
{
319+
name: "dialog with custom title from meta",
320+
message: "Choose wisely",
321+
schema: nil,
322+
meta: map[string]any{"cagent/title": "Custom Title"},
323+
expectedTitle: "Custom Title",
324+
hasFreeFormInput: true,
325+
},
326+
{
327+
name: "dialog with empty meta defaults to Question",
328+
message: "What?",
329+
schema: nil,
330+
meta: map[string]any{},
331+
expectedTitle: "Question",
332+
hasFreeFormInput: true,
310333
},
311334
}
312335

313336
for _, tt := range tests {
314337
t.Run(tt.name, func(t *testing.T) {
315338
t.Parallel()
316-
dialog := NewElicitationDialog(tt.message, tt.schema, nil)
339+
dialog := NewElicitationDialog(tt.message, tt.schema, tt.meta)
317340
require.NotNil(t, dialog)
318341

319342
ed, ok := dialog.(*ElicitationDialog)
320343
require.True(t, ok)
321344
assert.Equal(t, tt.message, ed.message)
345+
assert.Equal(t, tt.expectedTitle, ed.title)
346+
assert.Equal(t, tt.hasFreeFormInput, ed.hasFreeFormInput())
322347
})
323348
}
324349
}

0 commit comments

Comments
 (0)