| 1 | package protocol |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "reflect" |
| 7 | ) |
| 8 | |
| 9 | // UI DTOs: extension-contributed actions, host-rendered surfaces, and |
| 10 | // blocking prompts. |
| 11 | // |
| 12 | // The extension UI surface is structured-only: payloads are the typed |
| 13 | // documents below and nothing else. HTML, CSS, JavaScript, and URLs are |
| 14 | // never accepted or rendered — hosts map these structures onto their own |
| 15 | // native widgets (TUI, desktop, ACP client, or headless sink). |
| 16 | |
| 17 | // UIActionDecl declares one invocable action in the handshake. |
| 18 | type UIActionDecl struct { |
| 19 | ActionID string `json:"actionId" validate:"nonempty"` |
| 20 | Label string `json:"label,omitempty"` |
| 21 | } |
| 22 | |
| 23 | // UIActionParams invokes one declared action. Generation pins the session |
| 24 | // generation the action applies to; a stale generation is rejected host-side. |
| 25 | type UIActionParams struct { |
| 26 | ActionID string `json:"actionId" validate:"nonempty"` |
| 27 | SessionID string `json:"sessionId" validate:"nonempty"` |
| 28 | Generation uint64 `json:"generation"` |
| 29 | Args map[string]string `json:"args,omitempty"` |
| 30 | } |
| 31 | |
| 32 | // UIActionResult reports whether the extension accepted the invocation. |
| 33 | type UIActionResult struct { |
| 34 | Accepted bool `json:"accepted"` |
| 35 | Message string `json:"message,omitempty"` |
| 36 | } |
| 37 | |
| 38 | // UISubmitParams delivers the values of a previously published form surface |
| 39 | // back to the extension. |
| 40 | type UISubmitParams struct { |
| 41 | SurfaceID string `json:"surfaceId" validate:"nonempty"` |
| 42 | SessionID string `json:"sessionId" validate:"nonempty"` |
| 43 | Generation uint64 `json:"generation"` |
| 44 | Values map[string]any `json:"values"` |
| 45 | } |
| 46 | |
| 47 | // UISubmitResult acknowledges the submission. |
| 48 | type UISubmitResult struct { |
| 49 | Accepted bool `json:"accepted"` |
| 50 | } |
| 51 | |
| 52 | // UIPublishParams publishes or replaces one structured surface on the host. |
| 53 | // Payload must decode to the UISurfaceKind's payload type below. |
| 54 | type UIPublishParams struct { |
| 55 | SurfaceID string `json:"surfaceId" validate:"nonempty"` |
| 56 | SessionID string `json:"sessionId" validate:"nonempty"` |
| 57 | Generation uint64 `json:"generation"` |
| 58 | Kind UISurfaceKind `json:"kind"` |
| 59 | Payload json.RawMessage `json:"payload"` |
| 60 | } |
| 61 | |
| 62 | // UIPublishResult acknowledges the publish. |
| 63 | type UIPublishResult struct { |
| 64 | Accepted bool `json:"accepted"` |
| 65 | } |
| 66 | |
| 67 | // UIRequestParams asks the host to block on one structured prompt. Payload |
| 68 | // must decode to the UIRequestKind's payload shape (a UIFormPayload-shaped |
| 69 | // document for input/select/multiselect). |
| 70 | type UIRequestParams struct { |
| 71 | SurfaceID string `json:"surfaceId" validate:"nonempty"` |
| 72 | SessionID string `json:"sessionId" validate:"nonempty"` |
| 73 | Generation uint64 `json:"generation"` |
| 74 | Kind UIRequestKind `json:"kind"` |
| 75 | Payload json.RawMessage `json:"payload"` |
| 76 | } |
| 77 | |
| 78 | // UIRequestResult carries the user's answer; Cancelled distinguishes |
| 79 | // dismissal from an empty value set. |
| 80 | type UIRequestResult struct { |
| 81 | Cancelled bool `json:"cancelled"` |
| 82 | Values map[string]any `json:"values,omitempty"` |
| 83 | } |
| 84 | |
| 85 | // UIStatusPayload is a one-line status contribution. |
| 86 | type UIStatusPayload struct { |
| 87 | Label string `json:"label" validate:"nonempty"` |
| 88 | Detail string `json:"detail,omitempty"` |
| 89 | Severity UISeverity `json:"severity,omitempty"` |
| 90 | Progress *float64 `json:"progress,omitempty"` |
| 91 | } |
| 92 | |
| 93 | // UIKeyValue is one labelled value row in a card. |
| 94 | type UIKeyValue struct { |
| 95 | Key string `json:"key" validate:"nonempty"` |
| 96 | Value string `json:"value"` |
| 97 | } |
| 98 | |
| 99 | // UIActionRef renders a button that invokes a declared action. |
| 100 | type UIActionRef struct { |
| 101 | ActionID string `json:"actionId" validate:"nonempty"` |
| 102 | Label string `json:"label" validate:"nonempty"` |
| 103 | } |
| 104 | |
| 105 | // UICardPayload is a rich read-only surface: Markdown body, key/value rows, |
| 106 | // optional progress, and action buttons. |
| 107 | type UICardPayload struct { |
| 108 | Title string `json:"title,omitempty"` |
| 109 | Markdown string `json:"markdown,omitempty"` |
| 110 | Text string `json:"text,omitempty"` |
| 111 | Fields []UIKeyValue `json:"fields,omitempty"` |
| 112 | Progress *float64 `json:"progress,omitempty"` |
| 113 | Actions []UIActionRef `json:"actions,omitempty"` |
| 114 | } |
| 115 | |
| 116 | // UIFormField is one input row of a form surface. |
| 117 | type UIFormField struct { |
| 118 | Key string `json:"key" validate:"nonempty"` |
| 119 | Label string `json:"label,omitempty"` |
| 120 | Kind UIFieldKind `json:"kind"` |
| 121 | Options []string `json:"options,omitempty"` |
| 122 | Default any `json:"default,omitempty"` |
| 123 | Required bool `json:"required,omitempty"` |
| 124 | } |
| 125 | |
| 126 | // UIFormPayload is an editable surface; submissions return through |
| 127 | // extension/ui/submit. |
| 128 | type UIFormPayload struct { |
| 129 | Title string `json:"title,omitempty"` |
| 130 | Message string `json:"message,omitempty"` |
| 131 | Fields []UIFormField `json:"fields"` |
| 132 | } |
| 133 | |
| 134 | // UINotificationPayload is a transient toast-style message. |
| 135 | type UINotificationPayload struct { |
| 136 | Title string `json:"title" validate:"nonempty"` |
| 137 | Body string `json:"body,omitempty"` |
| 138 | Severity UISeverity `json:"severity,omitempty"` |
| 139 | } |
| 140 | |
| 141 | // DecodeUIPublishPayload strict-decodes the payload document of one |
| 142 | // host/ui/publish call into the kind's payload struct (UIStatusPayload, |
| 143 | // UICardPayload, UIFormPayload, or UINotificationPayload). It applies the |
| 144 | // same strictness as the method DTO decoders: unknown fields, enum values, |
| 145 | // and required-field violations are rejected. |
| 146 | func DecodeUIPublishPayload(kind UISurfaceKind, raw json.RawMessage) (any, error) { |
| 147 | var typ reflect.Type |
| 148 | switch kind { |
| 149 | case UISurfaceStatus: |
| 150 | typ = reflect.TypeOf(UIStatusPayload{}) |
| 151 | case UISurfaceCard: |
| 152 | typ = reflect.TypeOf(UICardPayload{}) |
| 153 | case UISurfaceForm: |
| 154 | typ = reflect.TypeOf(UIFormPayload{}) |
| 155 | case UISurfaceNotification: |
| 156 | typ = reflect.TypeOf(UINotificationPayload{}) |
| 157 | default: |
| 158 | return nil, fmt.Errorf("protocol: unknown UI surface kind %q", kind) |
| 159 | } |
| 160 | return decodeAndValidate(raw, typ) |
| 161 | } |
| 162 | |
| 163 | // DecodeUIRequestPayload strict-decodes the payload document of one |
| 164 | // host/ui/request call. Every request kind carries a UIFormPayload-shaped |
| 165 | // document: a confirm is a form with one confirm field (or a bare message), |
| 166 | // input/select/multiselect compose the matching field kinds. |
| 167 | func DecodeUIRequestPayload(kind UIRequestKind, raw json.RawMessage) (any, error) { |
| 168 | switch kind { |
| 169 | case UIRequestConfirm, UIRequestInput, UIRequestSelect, UIRequestMultiselect: |
| 170 | return decodeAndValidate(raw, reflect.TypeOf(UIFormPayload{})) |
| 171 | default: |
| 172 | return nil, fmt.Errorf("protocol: unknown UI request kind %q", kind) |
| 173 | } |
| 174 | } |
| 175 |