返回 DeepSeek-Reasonix
hub.go
1 // Package uihub implements the host side of the Extension Protocol v1
2 // structured UI surface (stage 8a). One Hub serves host/ui/publish and
3 // host/ui/request for every sidecar client of a runtime generation:
4 // publications are strict-decoded, credential-redacted, and emitted as
5 // frontend events; blocking prompts are translated onto the host's Ask
6 // machinery; and handshake-declared actions are registered for
7 // /<plugin>:<action> invocation and form submission routing.
8 //
9 // Stability contract: a publication or request whose generation or session
10 // does not match the hub's current binding is dropped with a debug log (late
11 // results after a reload must never overwrite the new generation's state);
12 // calls from unknown or crashed clients are rejected. All sidecar-sourced
13 // user-visible text passes secrets.RedactCredentials before surfacing.
14 package uihub
15
16 import (
17 "context"
18 "errors"
19 "fmt"
20 "log/slog"
21 "regexp"
22 "sort"
23 "strings"
24 "sync"
25
26 "reasonix/internal/event"
27 "reasonix/internal/extension/protocol"
28 "reasonix/internal/extension/sidecar"
29 "reasonix/internal/secrets"
30 )
31
32 // UIHandler is the sidecar package's Extension → Host UI call surface,
33 // re-exported so hub bindings and the sidecar manager's UIBinder contract
34 // share ONE interface type (Go interface satisfaction is signature-exact —
35 // a same-shaped local interface would silently fail the manager's
36 // per-plugin binding path).
37 type UIHandler = sidecar.UIHandler
38
39 // ActionClient is the subset of a sidecar client the hub needs for
40 // host-initiated UI calls. *sidecar.Client satisfies it.
41 type ActionClient interface {
42 UIAction(ctx context.Context, p protocol.UIActionParams) (protocol.UIActionResult, error)
43 UISubmit(ctx context.Context, p protocol.UISubmitParams) (protocol.UISubmitResult, error)
44 }
45
46 // ClientResolver resolves a plugin ID to its live sidecar client, or nil when
47 // the plugin has no running sidecar in this generation.
48 type ClientResolver func(pluginID string) ActionClient
49
50 // HubRequest is one blocking prompt translated from a host/ui/request call,
51 // ready for the host's Ask machinery. Every user-visible string is already
52 // credential-redacted.
53 type HubRequest struct {
54 PluginID string
55 SurfaceID string
56 SessionID string
57 Kind protocol.UIRequestKind
58 Title string
59 Message string
60 Fields []protocol.UIFormField
61 }
62
63 // RequestFunc answers one blocking prompt. The returned values are keyed by
64 // form field key; cancelled reports a dismissal (distinct from an empty value
65 // set); err reports a channel failure.
66 type RequestFunc func(ctx context.Context, req HubRequest) (values map[string]any, cancelled bool, err error)
67
68 // Options configures a Hub. Emit and Request are the frontend seams: Emit
69 // receives the extension surface/status events, Request answers blocking
70 // prompts through the host's Ask machinery. Resolve is optional at
71 // construction because the sidecar manager only exists after StartPackages —
72 // bind it with SetResolver once clients are live.
73 type Options struct {
74 SessionID string
75 Generation uint64
76 Emit func(event.Event)
77 Request RequestFunc
78 Warn func(string)
79 Resolve ClientResolver
80 }
81
82 // ActionView is one registered extension action for frontend enumeration.
83 // Slash is the public invocation name, "/<plugin>:<action>".
84 type ActionView struct {
85 PluginID string
86 ActionID string
87 Label string
88 Slash string
89 }
90
91 // Hub is the per-generation host UI hub. It is constructed at build time,
92 // bound to the build's session ID and snapshot generation, and retired with
93 // its controller; BindGeneration re-binds it across a reload. The mutex makes
94 // every method safe for concurrent sidecar traffic.
95 type Hub struct {
96 mu sync.Mutex
97 sessionID string
98 generation uint64
99 emit func(event.Event)
100 requestFn RequestFunc
101 warn func(string)
102 resolve ClientResolver
103 known map[string]bool
104 crashed map[string]bool
105 actions map[string]map[string]protocol.UIActionDecl
106 }
107
108 // New builds a Hub bound to one session ID and generation.
109 func New(opts Options) *Hub {
110 return &Hub{
111 sessionID: strings.TrimSpace(opts.SessionID),
112 generation: opts.Generation,
113 emit: opts.Emit,
114 requestFn: opts.Request,
115 warn: opts.Warn,
116 resolve: opts.Resolve,
117 known: map[string]bool{},
118 crashed: map[string]bool{},
119 actions: map[string]map[string]protocol.UIActionDecl{},
120 }
121 }
122
123 // SessionID returns the session the hub is bound to.
124 func (h *Hub) SessionID() string {
125 h.mu.Lock()
126 defer h.mu.Unlock()
127 return h.sessionID
128 }
129
130 // Generation returns the generation the hub is bound to.
131 func (h *Hub) Generation() uint64 {
132 h.mu.Lock()
133 defer h.mu.Unlock()
134 return h.generation
135 }
136
137 // BindGeneration re-binds the hub to a new session and generation after a
138 // reload. Later calls carrying the previous generation fail the staleness
139 // gate; bindings already marked crashed stay crashed until a fresh
140 // HandlerFor marks the replacement client live.
141 func (h *Hub) BindGeneration(sessionID string, gen uint64) {
142 h.mu.Lock()
143 defer h.mu.Unlock()
144 h.sessionID = strings.TrimSpace(sessionID)
145 h.generation = gen
146 }
147
148 // SetResolver installs the client resolver once the sidecar manager exists.
149 func (h *Hub) SetResolver(r ClientResolver) {
150 h.mu.Lock()
151 defer h.mu.Unlock()
152 h.resolve = r
153 }
154
155 // HandlerFor returns the UIHandler binding the sidecar manager installs on
156 // one client's connection. Binding marks the plugin known and live: a publish
157 // or request is attributed to the plugin through the returned value.
158 func (h *Hub) HandlerFor(pluginID string) UIHandler {
159 pluginID = strings.TrimSpace(pluginID)
160 h.mu.Lock()
161 h.known[pluginID] = true
162 delete(h.crashed, pluginID)
163 h.mu.Unlock()
164 return binding{pluginID: pluginID, hub: h}
165 }
166
167 // ClientCrashed marks a bound plugin's sidecar dead. Its later UI calls are
168 // rejected until a fresh HandlerFor binding marks the replacement live.
169 func (h *Hub) ClientCrashed(pluginID string) {
170 h.mu.Lock()
171 defer h.mu.Unlock()
172 if h.known[pluginID] {
173 h.crashed[pluginID] = true
174 }
175 }
176
177 // Publish implements the bare UIHandler. Without a per-plugin binding the hub
178 // cannot attribute the publication, so the unbound method rejects.
179 func (h *Hub) Publish(context.Context, protocol.UIPublishParams) (protocol.UIPublishResult, error) {
180 return protocol.UIPublishResult{}, unknownClientError("")
181 }
182
183 // Request implements the bare UIHandler; like Publish it requires a binding.
184 func (h *Hub) Request(context.Context, protocol.UIRequestParams) (protocol.UIRequestResult, error) {
185 return protocol.UIRequestResult{}, unknownClientError("")
186 }
187
188 // binding is the per-plugin UIHandler the sidecar manager installs.
189 type binding struct {
190 pluginID string
191 hub *Hub
192 }
193
194 func (b binding) Publish(ctx context.Context, p protocol.UIPublishParams) (protocol.UIPublishResult, error) {
195 return b.hub.publish(b.pluginID, ctx, p)
196 }
197
198 func (b binding) Request(ctx context.Context, p protocol.UIRequestParams) (protocol.UIRequestResult, error) {
199 return b.hub.request(b.pluginID, ctx, p)
200 }
201
202 // gate enforces the binding and staleness rules: unknown or crashed clients
203 // are rejected; a generation or session mismatch is a silent drop (stale) —
204 // late results after a reload must never overwrite the new generation's
205 // state, and a wedged old sidecar must not fail either.
206 func (h *Hub) gate(pluginID, sessionID string, generation uint64) (stale bool, err error) {
207 h.mu.Lock()
208 defer h.mu.Unlock()
209 if !h.known[pluginID] {
210 return false, unknownClientError(pluginID)
211 }
212 if h.crashed[pluginID] {
213 return false, &protocol.ProtocolError{
214 Reason: protocol.ErrProviderInterrupted,
215 Message: "extension sidecar " + pluginID + " crashed",
216 }
217 }
218 if generation != h.generation {
219 slog.Debug("uihub: dropping stale-generation UI call", "plugin", pluginID, "got", generation, "current", h.generation)
220 return true, nil
221 }
222 if sessionID != h.sessionID {
223 slog.Debug("uihub: dropping UI call for an unbound session", "plugin", pluginID, "session", sessionID, "bound", h.sessionID)
224 return true, nil
225 }
226 return false, nil
227 }
228
229 // publish serves one host/ui/publish call: gate, strict-decode by kind,
230 // redact, emit the matching extension event, acknowledge.
231 func (h *Hub) publish(pluginID string, _ context.Context, p protocol.UIPublishParams) (protocol.UIPublishResult, error) {
232 stale, err := h.gate(pluginID, p.SessionID, p.Generation)
233 if err != nil {
234 return protocol.UIPublishResult{}, err
235 }
236 if stale {
237 return protocol.UIPublishResult{Accepted: false}, nil
238 }
239 payload, err := decodePublishEvent(pluginID, p)
240 if err != nil {
241 return protocol.UIPublishResult{}, err
242 }
243 kind := event.ExtensionSurface
244 if p.Kind == protocol.UISurfaceStatus {
245 kind = event.ExtensionStatus
246 }
247 h.emitEvent(event.Event{Kind: kind, Extension: payload})
248 return protocol.UIPublishResult{Accepted: true}, nil
249 }
250
251 // request serves one host/ui/request call: gate, strict-decode the form,
252 // redact, block on the host's Ask channel, and map the answers back to the
253 // protocol result. A stale request is answered cancelled immediately so the
254 // old sidecar never wedges on a prompt nobody will see.
255 func (h *Hub) request(pluginID string, ctx context.Context, p protocol.UIRequestParams) (protocol.UIRequestResult, error) {
256 stale, err := h.gate(pluginID, p.SessionID, p.Generation)
257 if err != nil {
258 return protocol.UIRequestResult{}, err
259 }
260 if stale {
261 return protocol.UIRequestResult{Cancelled: true}, nil
262 }
263 decoded, err := protocol.DecodeUIRequestPayload(p.Kind, p.Payload)
264 if err != nil {
265 return protocol.UIRequestResult{}, &protocol.ProtocolError{
266 Reason: protocol.ErrInvalidParams,
267 Message: "invalid " + string(p.Kind) + " request payload: " + err.Error(),
268 }
269 }
270 form := decoded.(protocol.UIFormPayload)
271 h.mu.Lock()
272 request := h.requestFn
273 h.mu.Unlock()
274 if request == nil {
275 return protocol.UIRequestResult{}, &protocol.ProtocolError{
276 Reason: protocol.ErrUnknownMethod,
277 Message: "extension UI is not available on this host",
278 }
279 }
280 values, cancelled, err := request(ctx, HubRequest{
281 PluginID: pluginID,
282 SurfaceID: p.SurfaceID,
283 SessionID: p.SessionID,
284 Kind: p.Kind,
285 Title: secrets.RedactCredentials(form.Title),
286 Message: secrets.RedactCredentials(form.Message),
287 Fields: redactFormFields(form.Fields),
288 })
289 if err != nil {
290 return protocol.UIRequestResult{}, err
291 }
292 return protocol.UIRequestResult{Cancelled: cancelled, Values: values}, nil
293 }
294
295 // RegisterActions records the UI actions one plugin declared in its
296 // handshake, replacing any previously registered set for that plugin. An
297 // invalid action ID rejects the whole batch: the registry is the allow-list
298 // for later invocations, so a malformed declaration must not slip through.
299 func (h *Hub) RegisterActions(pluginID string, actions []protocol.UIActionDecl) error {
300 pluginID = strings.TrimSpace(pluginID)
301 if pluginID == "" {
302 return errors.New("uihub: plugin id is required")
303 }
304 for _, action := range actions {
305 if !ValidActionID(action.ActionID) {
306 return fmt.Errorf("uihub: extension %s declared invalid action id %q", pluginID, action.ActionID)
307 }
308 }
309 h.mu.Lock()
310 defer h.mu.Unlock()
311 decls := make(map[string]protocol.UIActionDecl, len(actions))
312 for _, action := range actions {
313 if _, dup := decls[action.ActionID]; dup {
314 h.warnLocked("extension " + pluginID + " re-declared action " + action.ActionID + "; keeping the first declaration")
315 continue
316 }
317 decls[action.ActionID] = protocol.UIActionDecl{
318 ActionID: action.ActionID,
319 Label: secrets.RedactCredentials(action.Label),
320 }
321 }
322 h.actions[pluginID] = decls
323 h.known[pluginID] = true
324 return nil
325 }
326
327 // Actions returns every registered action ordered by its public slash name.
328 func (h *Hub) Actions() []ActionView {
329 h.mu.Lock()
330 defer h.mu.Unlock()
331 var out []ActionView
332 for pluginID, decls := range h.actions {
333 for actionID, decl := range decls {
334 out = append(out, ActionView{
335 PluginID: pluginID,
336 ActionID: actionID,
337 Label: decl.Label,
338 Slash: SlashName(pluginID, actionID),
339 })
340 }
341 }
342 sort.Slice(out, func(i, j int) bool { return out[i].Slash < out[j].Slash })
343 return out
344 }
345
346 // InvokeAction routes one /<plugin>:<action> invocation to the owning sidecar
347 // as extension/ui/action. The action must be handshake-declared, the plugin's
348 // sidecar live, and the session the hub's current one; the result message is
349 // credential-redacted before it travels back to the frontend.
350 func (h *Hub) InvokeAction(ctx context.Context, pluginID, actionID, sessionID string, args map[string]string) (protocol.UIActionResult, error) {
351 if !ValidActionID(actionID) {
352 return protocol.UIActionResult{}, &protocol.ProtocolError{
353 Reason: protocol.ErrInvalidParams,
354 Message: fmt.Sprintf("invalid action id %q", actionID),
355 }
356 }
357 client, generation, err := h.actionTarget(pluginID, sessionID)
358 if err != nil {
359 return protocol.UIActionResult{}, err
360 }
361 h.mu.Lock()
362 _, declared := h.actions[pluginID][actionID]
363 h.mu.Unlock()
364 if !declared {
365 return protocol.UIActionResult{}, &protocol.ProtocolError{
366 Reason: protocol.ErrInvalidParams,
367 Message: "extension " + pluginID + " declared no action " + actionID,
368 }
369 }
370 result, err := client.UIAction(ctx, protocol.UIActionParams{
371 ActionID: actionID,
372 SessionID: sessionID,
373 Generation: generation,
374 Args: args,
375 })
376 if err != nil {
377 return protocol.UIActionResult{}, err
378 }
379 result.Message = secrets.RedactCredentials(result.Message)
380 return result, nil
381 }
382
383 // Submit routes one form surface's values back to the owning sidecar as
384 // extension/ui/submit.
385 func (h *Hub) Submit(ctx context.Context, pluginID, surfaceID, sessionID string, values map[string]any) (protocol.UISubmitResult, error) {
386 if strings.TrimSpace(surfaceID) == "" {
387 return protocol.UISubmitResult{}, &protocol.ProtocolError{
388 Reason: protocol.ErrInvalidParams,
389 Message: "surface id is required",
390 }
391 }
392 client, generation, err := h.actionTarget(pluginID, sessionID)
393 if err != nil {
394 return protocol.UISubmitResult{}, err
395 }
396 return client.UISubmit(ctx, protocol.UISubmitParams{
397 SurfaceID: surfaceID,
398 SessionID: sessionID,
399 Generation: generation,
400 Values: values,
401 })
402 }
403
404 // actionTarget resolves the live client for one plugin after enforcing the
405 // binding and session rules shared by InvokeAction and Submit.
406 func (h *Hub) actionTarget(pluginID, sessionID string) (ActionClient, uint64, error) {
407 h.mu.Lock()
408 defer h.mu.Unlock()
409 if !h.known[pluginID] {
410 return nil, 0, unknownClientError(pluginID)
411 }
412 if h.crashed[pluginID] {
413 return nil, 0, &protocol.ProtocolError{
414 Reason: protocol.ErrProviderInterrupted,
415 Message: "extension sidecar " + pluginID + " crashed",
416 }
417 }
418 if sessionID != h.sessionID {
419 return nil, 0, &protocol.ProtocolError{
420 Reason: protocol.ErrInvalidParams,
421 Message: "extension UI call for a stale session",
422 }
423 }
424 if h.resolve == nil {
425 return nil, 0, &protocol.ProtocolError{
426 Reason: protocol.ErrUnknownMethod,
427 Message: "extension UI is not available on this host",
428 }
429 }
430 client := h.resolve(pluginID)
431 if client == nil {
432 return nil, 0, &protocol.ProtocolError{
433 Reason: protocol.ErrProviderInterrupted,
434 Message: "extension " + pluginID + " has no live sidecar",
435 }
436 }
437 return client, h.generation, nil
438 }
439
440 // emitEvent fans one extension event out to the frontend sink.
441 func (h *Hub) emitEvent(ev event.Event) {
442 h.mu.Lock()
443 emit := h.emit
444 h.mu.Unlock()
445 if emit == nil {
446 slog.Debug("uihub: dropping extension event (no emit sink)", "kind", ev.Kind)
447 return
448 }
449 emit(ev)
450 }
451
452 // warnLocked reports a non-fatal anomaly; the caller holds h.mu.
453 func (h *Hub) warnLocked(msg string) {
454 if h.warn != nil {
455 h.warn(msg)
456 return
457 }
458 slog.Warn("uihub: " + msg)
459 }
460
461 // decodePublishEvent strict-decodes one publish payload by kind, redacts
462 // every user-visible string, and builds the event payload.
463 func decodePublishEvent(pluginID string, p protocol.UIPublishParams) (*event.ExtensionSurfacePayload, error) {
464 decoded, err := protocol.DecodeUIPublishPayload(p.Kind, p.Payload)
465 if err != nil {
466 return nil, &protocol.ProtocolError{
467 Reason: protocol.ErrInvalidParams,
468 Message: "invalid " + string(p.Kind) + " surface payload: " + err.Error(),
469 }
470 }
471 out := &event.ExtensionSurfacePayload{
472 PluginID: pluginID,
473 SurfaceID: p.SurfaceID,
474 SessionID: p.SessionID,
475 Generation: p.Generation,
476 Kind: string(p.Kind),
477 }
478 switch payload := decoded.(type) {
479 case protocol.UIStatusPayload:
480 out.Status = &event.ExtensionStatusView{
481 Label: secrets.RedactCredentials(payload.Label),
482 Detail: secrets.RedactCredentials(payload.Detail),
483 Severity: string(payload.Severity),
484 Progress: payload.Progress,
485 }
486 case protocol.UICardPayload:
487 card := &event.ExtensionCardView{
488 Title: secrets.RedactCredentials(payload.Title),
489 Markdown: secrets.RedactCredentials(payload.Markdown),
490 Text: secrets.RedactCredentials(payload.Text),
491 Progress: payload.Progress,
492 }
493 for _, field := range payload.Fields {
494 card.Fields = append(card.Fields, event.ExtensionKeyValue{
495 Key: secrets.RedactCredentials(field.Key),
496 Value: secrets.RedactCredentials(field.Value),
497 })
498 }
499 for _, action := range payload.Actions {
500 card.Actions = append(card.Actions, event.ExtensionActionRef{
501 ActionID: action.ActionID,
502 Label: secrets.RedactCredentials(action.Label),
503 })
504 }
505 out.Card = card
506 case protocol.UIFormPayload:
507 out.Form = &event.ExtensionFormView{
508 Title: secrets.RedactCredentials(payload.Title),
509 Message: secrets.RedactCredentials(payload.Message),
510 Fields: redactEventFormFields(payload.Fields),
511 }
512 case protocol.UINotificationPayload:
513 out.Notification = &event.ExtensionNotificationView{
514 Title: secrets.RedactCredentials(payload.Title),
515 Body: secrets.RedactCredentials(payload.Body),
516 Severity: string(payload.Severity),
517 }
518 }
519 return out, nil
520 }
521
522 // redactFormFields redacts the user-visible strings of protocol form fields
523 // (labels and options; keys stay intact — they correlate answers back).
524 func redactFormFields(fields []protocol.UIFormField) []protocol.UIFormField {
525 out := make([]protocol.UIFormField, len(fields))
526 for i, field := range fields {
527 out[i] = protocol.UIFormField{
528 Key: field.Key,
529 Label: secrets.RedactCredentials(field.Label),
530 Kind: field.Kind,
531 Options: redactStrings(field.Options),
532 Default: redactDefault(field.Default),
533 Required: field.Required,
534 }
535 }
536 return out
537 }
538
539 // redactEventFormFields redacts protocol form fields into their event view.
540 func redactEventFormFields(fields []protocol.UIFormField) []event.ExtensionFormField {
541 out := make([]event.ExtensionFormField, 0, len(fields))
542 for _, field := range fields {
543 out = append(out, event.ExtensionFormField{
544 Key: field.Key,
545 Label: secrets.RedactCredentials(field.Label),
546 Kind: string(field.Kind),
547 Options: redactStrings(field.Options),
548 Default: redactDefault(field.Default),
549 Required: field.Required,
550 })
551 }
552 return out
553 }
554
555 func redactStrings(in []string) []string {
556 if len(in) == 0 {
557 return nil
558 }
559 out := make([]string, len(in))
560 for i, s := range in {
561 out[i] = secrets.RedactCredentials(s)
562 }
563 return out
564 }
565
566 // redactDefault redacts a string default value; non-string defaults carry no
567 // user-visible text.
568 func redactDefault(v any) any {
569 if s, ok := v.(string); ok {
570 return secrets.RedactCredentials(s)
571 }
572 return v
573 }
574
575 func unknownClientError(pluginID string) error {
576 return &protocol.ProtocolError{
577 Reason: protocol.ErrInternal,
578 Message: "host/ui call from an unknown extension client " + fmt.Sprintf("%q", pluginID),
579 }
580 }
581
582 // actionIDPattern freezes the public action ID contract: lowercase, digits,
583 // and dashes only, so actions compose cleanly into /<plugin>:<action> names.
584 var actionIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
585
586 // ValidActionID reports whether id is a well-formed extension action ID.
587 func ValidActionID(id string) bool {
588 return actionIDPattern.MatchString(id)
589 }
590
591 // SlashName renders the public invocation name of one action,
592 // "/<plugin>:<action>".
593 func SlashName(pluginID, actionID string) string {
594 return "/" + pluginID + ":" + actionID
595 }
596
597 // ParseSlashName splits a public invocation name back into plugin and action
598 // IDs. ok is false when the name is not a well-formed "/<plugin>:<action>".
599 func ParseSlashName(name string) (pluginID, actionID string, ok bool) {
600 rest, found := strings.CutPrefix(strings.TrimSpace(name), "/")
601 if !found {
602 return "", "", false
603 }
604 plugin, action, found := strings.Cut(rest, ":")
605 if !found || strings.TrimSpace(plugin) == "" || !ValidActionID(action) {
606 return "", "", false
607 }
608 return plugin, action, true
609 }
610
611 // Confirmation option labels. They are display strings; the boolean answer
612 // maps back from the picked label.
613 const (
614 confirmYes = "Yes"
615 confirmNo = "No"
616 )
617
618 // AskRequestFunc adapts the host's Ask channel (agent.Asker-shaped, e.g.
619 // control.Controller.Ask) to the hub's RequestFunc. Form fields translate
620 // one-to-one into AskQuestions; answers map back to values keyed by field
621 // key — a string for input/select/confirm-free text, a bool for confirm, a
622 // string slice for multiselect. A prompt dismissed with no selections at all
623 // reports cancelled, mirroring the controller's own skip semantics.
624 func AskRequestFunc(ask func(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error)) RequestFunc {
625 return func(ctx context.Context, req HubRequest) (map[string]any, bool, error) {
626 if ask == nil {
627 return nil, false, &protocol.ProtocolError{
628 Reason: protocol.ErrUnknownMethod,
629 Message: "extension UI is not available on this host",
630 }
631 }
632 fields := req.Fields
633 if len(fields) == 0 {
634 // A field-less request (the common confirm shape) asks one
635 // question of the request kind's matching field kind.
636 fields = []protocol.UIFormField{{
637 Key: "value",
638 Label: req.Message,
639 Kind: protocol.UIFieldKind(req.Kind),
640 }}
641 }
642 questions := make([]event.AskQuestion, 0, len(fields))
643 for _, field := range fields {
644 question := event.AskQuestion{ID: field.Key, Header: field.Label, Prompt: field.Label}
645 if question.Prompt == "" {
646 question.Prompt = req.Message
647 }
648 if question.Header == "" {
649 question.Header = req.Title
650 }
651 switch field.Kind {
652 case protocol.UIFieldConfirm:
653 question.Options = []event.AskOption{{Label: confirmYes}, {Label: confirmNo}}
654 case protocol.UIFieldSelect:
655 question.Options = askOptions(field.Options)
656 case protocol.UIFieldMultiselect:
657 question.Options = askOptions(field.Options)
658 question.Multi = true
659 default:
660 // input (and anything unrecognised) is a free-text question.
661 }
662 questions = append(questions, question)
663 }
664 answers, err := ask(ctx, questions)
665 if err != nil {
666 return nil, false, err
667 }
668 byID := make(map[string]event.AskAnswer, len(answers))
669 for _, answer := range answers {
670 byID[answer.QuestionID] = answer
671 }
672 values := map[string]any{}
673 selections := 0
674 for _, field := range fields {
675 answer, ok := byID[field.Key]
676 if !ok || len(answer.Selected) == 0 {
677 continue
678 }
679 selections += len(answer.Selected)
680 switch field.Kind {
681 case protocol.UIFieldConfirm:
682 values[field.Key] = strings.EqualFold(answer.Selected[0], confirmYes)
683 case protocol.UIFieldMultiselect:
684 values[field.Key] = append([]string(nil), answer.Selected...)
685 default:
686 values[field.Key] = answer.Selected[0]
687 }
688 }
689 if selections == 0 {
690 return nil, true, nil
691 }
692 return values, false, nil
693 }
694 }
695
696 func askOptions(options []string) []event.AskOption {
697 out := make([]event.AskOption, len(options))
698 for i, option := range options {
699 out[i] = event.AskOption{Label: option}
700 }
701 return out
702 }
703
703 lines GO