| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | |
| 8 | tea "charm.land/bubbletea/v2" |
| 9 | |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/i18n" |
| 12 | ) |
| 13 | |
| 14 | // elicitCard is the CLI's MCP elicitation prompt: a flat form (string/number/ |
| 15 | // boolean/enum fields) or a URL confirmation, raised by a server mid-call. |
| 16 | // It mirrors the chooser's modality: keys drive it while set, free-typed |
| 17 | // answers borrow the composer, and Submit/Decline/Cancel map to the |
| 18 | // accept/decline/cancel actions. |
| 19 | type elicitCard struct { |
| 20 | id string |
| 21 | server string |
| 22 | mode string |
| 23 | message string |
| 24 | url string |
| 25 | |
| 26 | fields []elicitField |
| 27 | values []string |
| 28 | bools []bool |
| 29 | cursor int // 0..len-1: a field; len: the Submit row |
| 30 | typing bool |
| 31 | } |
| 32 | |
| 33 | type elicitField struct { |
| 34 | Key string `json:"-"` |
| 35 | Label string `json:"title"` |
| 36 | Type string `json:"type"` |
| 37 | Enum []string `json:"enum"` |
| 38 | Required bool `json:"-"` |
| 39 | Default any `json:"default"` |
| 40 | } |
| 41 | |
| 42 | func parseElicitFields(schema json.RawMessage) []elicitField { |
| 43 | if len(schema) == 0 { |
| 44 | return nil |
| 45 | } |
| 46 | var parsed struct { |
| 47 | Properties map[string]json.RawMessage `json:"properties"` |
| 48 | Required []string `json:"required"` |
| 49 | } |
| 50 | if err := json.Unmarshal(schema, &parsed); err != nil { |
| 51 | return nil |
| 52 | } |
| 53 | required := map[string]bool{} |
| 54 | for _, r := range parsed.Required { |
| 55 | required[r] = true |
| 56 | } |
| 57 | fields := make([]elicitField, 0, len(parsed.Properties)) |
| 58 | for key, raw := range parsed.Properties { |
| 59 | var f elicitField |
| 60 | f.Key = key |
| 61 | f.Label = key |
| 62 | f.Required = required[key] |
| 63 | if err := json.Unmarshal(raw, &f); err != nil { |
| 64 | continue |
| 65 | } |
| 66 | if f.Label == "" { |
| 67 | f.Label = key |
| 68 | } |
| 69 | fields = append(fields, f) |
| 70 | } |
| 71 | return fields |
| 72 | } |
| 73 | |
| 74 | func newElicitCard(interaction event.MCPInteraction) *elicitCard { |
| 75 | card := &elicitCard{ |
| 76 | id: interaction.ID, |
| 77 | server: interaction.Server, |
| 78 | mode: interaction.Mode, |
| 79 | message: interaction.Message, |
| 80 | url: interaction.URL, |
| 81 | fields: parseElicitFields(interaction.RequestedSchema), |
| 82 | } |
| 83 | card.values = make([]string, len(card.fields)) |
| 84 | card.bools = make([]bool, len(card.fields)) |
| 85 | for i, f := range card.fields { |
| 86 | switch v := f.Default.(type) { |
| 87 | case string: |
| 88 | card.values[i] = v |
| 89 | if f.Type == "boolean" { |
| 90 | card.bools[i] = v == "true" |
| 91 | } |
| 92 | case bool: |
| 93 | card.bools[i] = v |
| 94 | if v { |
| 95 | card.values[i] = "true" |
| 96 | } |
| 97 | case float64: |
| 98 | card.values[i] = fmt.Sprintf("%v", v) |
| 99 | } |
| 100 | } |
| 101 | return card |
| 102 | } |
| 103 | |
| 104 | func (c *elicitCard) onSubmitRow() bool { return c.cursor >= len(c.fields) } |
| 105 | |
| 106 | // content coerces the typed strings into JSON-typed values. |
| 107 | func (c *elicitCard) content() map[string]any { |
| 108 | out := map[string]any{} |
| 109 | for i, f := range c.fields { |
| 110 | raw := strings.TrimSpace(c.values[i]) |
| 111 | switch f.Type { |
| 112 | case "boolean": |
| 113 | out[f.Key] = c.bools[i] |
| 114 | case "number", "integer": |
| 115 | var num float64 |
| 116 | if _, err := fmt.Sscanf(raw, "%g", &num); err == nil && raw != "" { |
| 117 | out[f.Key] = num |
| 118 | } |
| 119 | default: |
| 120 | if raw != "" { |
| 121 | out[f.Key] = raw |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | return out |
| 126 | } |
| 127 | |
| 128 | // startElicit opens the card for a server-initiated elicitation. |
| 129 | func (m *chatTUI) startElicit(interaction event.MCPInteraction) { |
| 130 | m.finalizeStreamed() |
| 131 | m.elicit = newElicitCard(interaction) |
| 132 | } |
| 133 | |
| 134 | // clearElicitCard drops the card when its turn settles (timeout or cancel). |
| 135 | func (m *chatTUI) clearElicitCard() { |
| 136 | if m.elicit != nil { |
| 137 | m.elicit = nil |
| 138 | m.refreshInputPlaceholder() |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // elicitKey handles one keystroke for the active card, including free-text |
| 143 | // typing (Enter confirms, Esc backs out). handled=false leaves the keystroke |
| 144 | // with the composer, matching the chooser's typing mode. |
| 145 | func (m chatTUI) elicitKey(msg tea.KeyPressMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd, bool) { |
| 146 | c := m.elicit |
| 147 | if c == nil { |
| 148 | return m, nil, false |
| 149 | } |
| 150 | if c.typing { |
| 151 | switch msg.String() { |
| 152 | case "enter": |
| 153 | val := strings.TrimSpace(m.input.Value()) |
| 154 | m.resetComposerInput() |
| 155 | c.typing = false |
| 156 | m.refreshInputPlaceholder() |
| 157 | if val != "" { |
| 158 | c.values[c.cursor] = val |
| 159 | c.cursor++ |
| 160 | } |
| 161 | return m, finalize(m, cmds), true |
| 162 | case "esc": |
| 163 | c.typing = false |
| 164 | m.resetComposerInput() |
| 165 | m.refreshInputPlaceholder() |
| 166 | return m, finalize(m, cmds), true |
| 167 | } |
| 168 | return m, nil, false |
| 169 | } |
| 170 | model, cmd := m.handleElicitKey(msg) |
| 171 | return model, cmd, true |
| 172 | } |
| 173 | |
| 174 | // handleElicitKey routes a non-typing keystroke to the active card. |
| 175 | func (m chatTUI) handleElicitKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
| 176 | c := m.elicit |
| 177 | key := msg.String() |
| 178 | switch key { |
| 179 | case "ctrl+c", "esc": |
| 180 | return m.elicitAnswer("cancel", c.id) |
| 181 | case "o": |
| 182 | if c.mode == "url" { |
| 183 | openElicitURL(c.url) |
| 184 | } |
| 185 | return m, nil |
| 186 | case "enter": |
| 187 | if c.mode == "url" || c.onSubmitRow() { |
| 188 | return m.elicitAnswer("accept", c.id) |
| 189 | } |
| 190 | return m.elicitFieldKey(key) |
| 191 | case "d": |
| 192 | if c.mode == "url" || c.onSubmitRow() { |
| 193 | return m.elicitAnswer("decline", c.id) |
| 194 | } |
| 195 | return m, nil |
| 196 | case "tab", "right", "l", "down", "j": |
| 197 | if c.cursor < len(c.fields) { |
| 198 | c.cursor++ |
| 199 | } |
| 200 | return m, nil |
| 201 | case "shift+tab", "left", "h", "up", "k": |
| 202 | if c.cursor > 0 { |
| 203 | c.cursor-- |
| 204 | } |
| 205 | return m, nil |
| 206 | } |
| 207 | return m.elicitFieldKey(key) |
| 208 | } |
| 209 | |
| 210 | // elicitFieldKey handles a key inside one field (boolean toggle, enum pick, |
| 211 | // open free-text entry). |
| 212 | func (m chatTUI) elicitFieldKey(key string) (tea.Model, tea.Cmd) { |
| 213 | c := m.elicit |
| 214 | f := c.fields[c.cursor] |
| 215 | switch { |
| 216 | case f.Type == "boolean" && (key == " " || key == "space" || key == "enter" || key == "y" || key == "n"): |
| 217 | c.bools[c.cursor] = !c.bools[c.cursor] |
| 218 | c.values[c.cursor] = fmt.Sprintf("%v", c.bools[c.cursor]) |
| 219 | case len(f.Enum) > 0 && len(key) == 1 && key[0] >= '1' && key[0] <= '9': |
| 220 | if idx := int(key[0] - '1'); idx < len(f.Enum) { |
| 221 | c.values[c.cursor] = f.Enum[idx] |
| 222 | } |
| 223 | case len(f.Enum) == 0 && f.Type != "boolean" && key == "enter": |
| 224 | c.typing = true |
| 225 | m.resetComposerInput() |
| 226 | m.input.SetHeight(1) |
| 227 | m.refreshInputPlaceholder() |
| 228 | } |
| 229 | return m, nil |
| 230 | } |
| 231 | |
| 232 | func (m chatTUI) elicitAnswer(action, id string) (tea.Model, tea.Cmd) { |
| 233 | content := map[string]any(nil) |
| 234 | if action == "accept" && m.elicit != nil && m.elicit.mode != "url" { |
| 235 | content = m.elicit.content() |
| 236 | } |
| 237 | m.ctrl.AnswerMCPInteraction(id, action, content) |
| 238 | m.elicit = nil |
| 239 | m.refreshInputPlaceholder() |
| 240 | return m, nil |
| 241 | } |
| 242 | |
| 243 | // openElicitURL opens a url-mode target in the system browser; the card shows |
| 244 | // the server and host before this ever runs. |
| 245 | func openElicitURL(raw string) { |
| 246 | if cmd, err := mcpOpenCommand(raw); err == nil { |
| 247 | _ = cmd.Start() |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // renderElicit draws the pinned elicitation card. |
| 252 | func (m chatTUI) renderElicit() string { |
| 253 | c := m.elicit |
| 254 | if c == nil { |
| 255 | return "" |
| 256 | } |
| 257 | w := max(m.width, 10) |
| 258 | var b strings.Builder |
| 259 | fmt.Fprintf(&b, "%s %s\n", accent("MCP"), dim("· "+c.server)) |
| 260 | b.WriteString(wrapForViewport(c.message, w, activeCLITheme.info) + "\n") |
| 261 | if c.mode == "url" { |
| 262 | host := c.url |
| 263 | if u := strings.SplitN(strings.TrimPrefix(strings.TrimPrefix(c.url, "https://"), "http://"), "/", 2); len(u) > 0 { |
| 264 | host = u[0] |
| 265 | } |
| 266 | fmt.Fprintf(&b, " %s\n", host) |
| 267 | b.WriteString(dim(i18n.M.ElicitURLHint)) |
| 268 | return choicePanelStyle.Width(w).Render(b.String()) |
| 269 | } |
| 270 | if len(c.fields) == 0 { |
| 271 | b.WriteString(dim(i18n.M.ElicitConfirmOnly)) |
| 272 | return choicePanelStyle.Width(w).Render(b.String()) |
| 273 | } |
| 274 | for i, f := range c.fields { |
| 275 | marker := " " |
| 276 | label := f.Label |
| 277 | if f.Required { |
| 278 | label += " *" |
| 279 | } |
| 280 | value := strings.TrimSpace(c.values[i]) |
| 281 | if f.Type == "boolean" { |
| 282 | value = fmt.Sprintf("%v", c.bools[i]) |
| 283 | } else if value == "" { |
| 284 | value = dim(i18n.M.ElicitUnanswered) |
| 285 | } |
| 286 | if i == c.cursor { |
| 287 | marker = accent("› ") |
| 288 | label = accent(label) |
| 289 | } |
| 290 | fmt.Fprintf(&b, "%s%s: %s\n", marker, label, value) |
| 291 | } |
| 292 | if c.onSubmitRow() { |
| 293 | fmt.Fprintf(&b, "%s\n", accent(" ["+i18n.M.ElicitSubmit+"]")) |
| 294 | b.WriteString(dim(i18n.M.ElicitSubmitHint)) |
| 295 | } else { |
| 296 | fmt.Fprintf(&b, "%s\n", dim(" ["+i18n.M.ElicitSubmit+"]")) |
| 297 | } |
| 298 | return choicePanelStyle.Width(w).Render(b.String()) |
| 299 | } |
| 300 |