返回 DeepSeek-Reasonix
elicit.go
根目录 / internal / cli / elicit.go
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
300 lines GO