返回 DeepSeek-Reasonix
extension_surface.go
根目录 / internal / cli / extension_surface.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/control"
8 "reasonix/internal/event"
9 "reasonix/internal/extension/uihub"
10 "reasonix/internal/i18n"
11 )
12
13 // Extension structured-UI rendering (Extension Protocol v1, stage 8b1). The
14 // sidecar's publish calls arrive as ExtensionSurface / ExtensionStatus events
15 // carrying an already-redacted ExtensionSurfacePayload; these helpers map the
16 // payload onto the transcript idioms the rest of the TUI uses — notice-style
17 // severity lines for status/notification, and a compaction-style gutter card
18 // for card/form. Blocking prompts never reach here: the hub routes those
19 // through the ordinary Ask machinery, which the chooser already renders.
20
21 // extensionStatusLine renders one extension status contribution as a
22 // severity-aware notice line: "[plugin] label: detail". Empty payload → "".
23 func extensionStatusLine(p *event.ExtensionSurfacePayload) string {
24 if p == nil || p.Status == nil {
25 return ""
26 }
27 st := p.Status
28 text := "[" + p.PluginID + "] " + st.Label
29 if st.Detail != "" {
30 text += ": " + st.Detail
31 }
32 if st.Progress != nil {
33 text += fmt.Sprintf(" (%.0f%%)", *st.Progress*100)
34 }
35 return extensionSeverityLine(st.Severity, text)
36 }
37
38 // extensionNotificationLine renders one extension notification as a
39 // severity-aware notice line: "[plugin] title — body".
40 func extensionNotificationLine(p *event.ExtensionSurfacePayload) string {
41 if p == nil || p.Notification == nil {
42 return ""
43 }
44 n := p.Notification
45 text := "[" + p.PluginID + "] " + n.Title
46 if n.Body != "" {
47 text += " — " + n.Body
48 }
49 return extensionSeverityLine(n.Severity, text)
50 }
51
52 // extensionSeverityLine paints a notice line with the glyph and colour of its
53 // severity, mirroring the event.Notice glyphs (· info, ! warn) plus ✗ error.
54 func extensionSeverityLine(severity, text string) string {
55 switch severity {
56 case "warn":
57 return themeStyle(activeCLITheme.warn).Render(" ! " + text)
58 case "error":
59 return themeStyle(activeCLITheme.danger).Render(" ✗ " + text)
60 default:
61 return dim(" · " + text)
62 }
63 }
64
65 // extensionSurfaceLines renders a published card/form surface as a transcript
66 // card. Unknown kinds and mismatched payloads yield nothing (the event stays
67 // silent rather than drawing a broken card).
68 func extensionSurfaceLines(p *event.ExtensionSurfacePayload, width int) []string {
69 if p == nil {
70 return nil
71 }
72 switch {
73 case p.Card != nil:
74 return extensionCardLines(p.PluginID, p.Card, width)
75 case p.Form != nil:
76 return extensionFormLines(p.PluginID, p.Form)
77 }
78 return nil
79 }
80
81 // extensionCardLines renders a card surface like the compaction card: an
82 // accent ◆ header, the body under a dim " │ " gutter (markdown through the
83 // same renderer assistant answers use), then key/value rows, progress, and
84 // action hints. The TUI has no button primitive, so actions render as their
85 // /<plugin>:<action> slash invocation.
86 func extensionCardLines(pluginID string, c *event.ExtensionCardView, width int) []string {
87 title := c.Title
88 if title == "" {
89 title = pluginID
90 }
91 lines := []string{accent("◆ " + title)}
92 body := c.Text
93 if c.Markdown != "" {
94 bodyWidth := max(width-visibleWidth(" │ "), 1)
95 if rendered := newMarkdownRenderer(bodyWidth).Render(c.Markdown); rendered != "" {
96 body = rendered
97 } else {
98 body = c.Markdown
99 }
100 }
101 for _, ln := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
102 if ln == "" {
103 lines = append(lines, "")
104 continue
105 }
106 lines = append(lines, " │ "+ln)
107 }
108 for _, f := range c.Fields {
109 lines = append(lines, dim(" │ "+f.Key+": "+f.Value))
110 }
111 if c.Progress != nil {
112 lines = append(lines, dim(fmt.Sprintf(" │ %.0f%%", *c.Progress*100)))
113 }
114 for _, a := range c.Actions {
115 hint := fmt.Sprintf(i18n.M.ExtRunActionFmt, uihub.SlashName(pluginID, a.ActionID))
116 if a.Label != "" {
117 hint = a.Label + " — " + hint
118 }
119 lines = append(lines, dim(" │ → "+hint))
120 }
121 return lines
122 }
123
124 // extensionFormLines renders a published form surface as a descriptive card.
125 // The field values themselves arrive through the Ask machinery (the hub turns
126 // the form into ordinary question prompts), so the card only announces the
127 // form — no dialog side is needed here.
128 func extensionFormLines(pluginID string, f *event.ExtensionFormView) []string {
129 title := f.Title
130 if title == "" {
131 title = pluginID
132 }
133 lines := []string{accent("◆ " + title)}
134 for _, ln := range strings.Split(strings.TrimRight(f.Message, "\n"), "\n") {
135 if ln == "" {
136 continue
137 }
138 lines = append(lines, dim(" │ "+ln))
139 }
140 lines = append(lines, dim(" │ "+i18n.M.ExtFormFieldsHint))
141 return lines
142 }
143
144 // parseExtensionActionArgs maps the trailing fields of a "/<plugin>:<action>
145 // args…" line onto the action's string map; the convention lives in the
146 // control package so every frontend parses identically.
147 func parseExtensionActionArgs(fields []string) map[string]string {
148 return control.ParseExtensionActionArgs(fields)
149 }
150
151 // matchExtensionAction resolves a typed "/<plugin>:<action>" command against
152 // the declared extension actions, returning the matching view. Anything that
153 // does not parse as a well-formed action name, or parses but is undeclared,
154 // is not an extension action — the slash dispatch falls through to
155 // SlashUnknown exactly as before.
156 func matchExtensionAction(ctrl control.SessionAPI, typedCmd string) (control.ExtensionActionView, bool) {
157 if ctrl == nil {
158 return control.ExtensionActionView{}, false
159 }
160 pluginID, actionID, ok := uihub.ParseSlashName(typedCmd)
161 if !ok {
162 return control.ExtensionActionView{}, false
163 }
164 for _, action := range ctrl.ExtensionActions() {
165 if action.PluginID == pluginID && action.ActionID == actionID {
166 return action, true
167 }
168 }
169 return control.ExtensionActionView{}, false
170 }
171
172 // extensionActionHint marks an extension action in the slash menu the way
173 // plugin commands are marked: "plugin <id> · <label>".
174 func extensionActionHint(a control.ExtensionActionView) string {
175 source := "plugin " + a.PluginID
176 if a.Label == "" {
177 return source
178 }
179 return source + " · " + a.Label
180 }
181
181 lines GO