33package tui
44
55import (
6+ _ "embed"
7+
68 "github.com/charmbracelet/bubbles/textarea"
79 "github.com/charmbracelet/bubbles/textinput"
810 "github.com/charmbracelet/bubbles/viewport"
911 tea "github.com/charmbracelet/bubbletea"
1012
1113 core "dappco.re/go"
14+ "dappco.re/go/html/ctml"
1215)
1316
1417// workEditor keeps Work creation and editing local to the TUI. It deliberately
@@ -97,6 +100,35 @@ func (editor *workEditor) values() (string, string, string) {
97100 return core .Trim (editor .title .Value ()), core .Trim (editor .task .Value ()), core .Trim (editor .repository .Value ())
98101}
99102
103+ // workEditorCTML is the Work editor overlay's markup — see workeditor.ctml
104+ // for the seams it exposes (the create/edit title split, the conditional
105+ // validation line, class tokens).
106+ //
107+ //go:embed workeditor.ctml
108+ var workEditorCTML []byte
109+
110+ // workEditorBindings selects the create-vs-edit title — both texts are
111+ // static markup, so the mode is carried by which zero-or-one-row sequence
112+ // holds the row, the selection-as-sequence-split idiom — and binds the
113+ // save-validation error (zero-or-one row; an empty sequence renders
114+ // nothing).
115+ func workEditorBindings (editor * workEditor ) ctml.Bindings {
116+ sequences := map [string ][]map [string ]any {
117+ "createTitle" : {},
118+ "editTitle" : {},
119+ "validation" : {},
120+ }
121+ if editor != nil && editor .editingID != "" {
122+ sequences ["editTitle" ] = append (sequences ["editTitle" ], map [string ]any {})
123+ } else {
124+ sequences ["createTitle" ] = append (sequences ["createTitle" ], map [string ]any {})
125+ }
126+ if editor != nil && editor .validation != "" {
127+ sequences ["validation" ] = append (sequences ["validation" ], map [string ]any {"text" : editor .validation })
128+ }
129+ return ctml.Bindings {Sequences : sequences }
130+ }
131+
100132func (editor * workEditor ) View (width , height int , styles uiStyles ) string {
101133 if editor == nil {
102134 return ""
@@ -105,11 +137,8 @@ func (editor *workEditor) View(width, height int, styles uiStyles) string {
105137 editor .title .Width = fieldWidth
106138 editor .repository .Width = fieldWidth
107139 editor .task .SetWidth (fieldWidth )
108- title := "Create Work"
109- if editor .editingID != "" {
110- title = "Edit Work"
111- }
112- return fitPane (core .Join ("\n " , title , "" , "Work title" , editor .title .View (), "" , "Full task" , editor .task .View (), "" , "Repository" , editor .repository .View (), "" , "tab changes field · ctrl+s saves · esc cancels" , editor .validation ), width , height , styles .panel )
140+ head , foot := renderOverlayFrame (workEditorCTML , width , styles , workEditorBindings (editor ))
141+ return fitPane (core .Join ("\n " , head , editor .title .View (), "" , "Full task" , editor .task .View (), "" , "Repository" , editor .repository .View (), foot ), width , height , styles .panel )
113142}
114143
115144type launchReviewOverlay struct {
@@ -160,12 +189,31 @@ func (overlay *agentAnswerOverlay) answer() string {
160189 return core .Trim (overlay .input .Value ())
161190}
162191
192+ // agentAnswerCTML is the answer overlay's markup — see agentanswer.ctml
193+ // for the seams it exposes (the question row, class tokens).
194+ //
195+ //go:embed agentanswer.ctml
196+ var agentAnswerCTML []byte
197+
198+ // agentAnswerBindings binds the pending question — a lone dynamic value
199+ // riding a one-row sequence.
200+ func agentAnswerBindings (overlay * agentAnswerOverlay ) ctml.Bindings {
201+ question := ""
202+ if overlay != nil {
203+ question = overlay .question
204+ }
205+ return ctml.Bindings {Sequences : map [string ][]map [string ]any {
206+ "question" : {{"text" : question }},
207+ }}
208+ }
209+
163210func (overlay * agentAnswerOverlay ) View (width , height int , styles uiStyles ) string {
164211 if overlay == nil {
165212 return ""
166213 }
167214 overlay .input .SetWidth (max (12 , width - 6 ))
168- return fitPane (core .Join ("\n " , "Answer agent question" , "" , overlay .question , "" , overlay .input .View (), "" , "enter submits · esc cancels" ), width , height , styles .panel )
215+ head , foot := renderOverlayFrame (agentAnswerCTML , width , styles , agentAnswerBindings (overlay ))
216+ return fitPane (core .Join ("\n " , head , "" , overlay .input .View (), foot ), width , height , styles .panel )
169217}
170218
171219type changeAcceptanceOverlay struct {
@@ -208,20 +256,50 @@ func (overlay *changeAcceptanceOverlay) Update(message tea.KeyMsg) bool {
208256 return true
209257}
210258
259+ // changeReviewCTML is the change-acceptance overlay's markup — see
260+ // changereview.ctml for the seams it exposes (the title row, the
261+ // conditional warning, the three-way prompt split, class tokens).
262+ //
263+ //go:embed changereview.ctml
264+ var changeReviewCTML []byte
265+
266+ // changeAcceptanceBindings binds the review title and warning, and selects
267+ // the gate prompt: all three prompt texts are static markup, so the gate
268+ // stage is carried by which zero-or-one-row sequence holds the row, the
269+ // selection-as-sequence-split idiom.
270+ func changeAcceptanceBindings (overlay * changeAcceptanceOverlay ) ctml.Bindings {
271+ sequences := map [string ][]map [string ]any {
272+ "review" : {},
273+ "warn" : {},
274+ "promptContinue" : {},
275+ "promptAcknowledge" : {},
276+ "promptApply" : {},
277+ }
278+ if overlay != nil {
279+ sequences ["review" ] = append (sequences ["review" ], map [string ]any {"title" : overlay .review .Title })
280+ if overlay .review .Warning != "" {
281+ sequences ["warn" ] = append (sequences ["warn" ], map [string ]any {"text" : overlay .review .Warning })
282+ }
283+ switch {
284+ case overlay .final :
285+ sequences ["promptApply" ] = append (sequences ["promptApply" ], map [string ]any {})
286+ case overlay .review .NeedsAcknowledgement && ! overlay .acknowledged :
287+ sequences ["promptAcknowledge" ] = append (sequences ["promptAcknowledge" ], map [string ]any {})
288+ default :
289+ sequences ["promptContinue" ] = append (sequences ["promptContinue" ], map [string ]any {})
290+ }
291+ }
292+ return ctml.Bindings {Sequences : sequences }
293+ }
294+
211295func (overlay * changeAcceptanceOverlay ) View (width , height int , styles uiStyles ) string {
212296 if overlay == nil {
213297 return ""
214298 }
215- prompt := "enter continues · esc cancels"
216- if overlay .review .NeedsAcknowledgement && ! overlay .acknowledged {
217- prompt = "a acknowledges no validation · enter continues · esc cancels"
218- }
219- if overlay .final {
220- prompt = "enter applies this exact reviewed receipt · esc cancels"
221- }
222299 overlay .viewport .Width , overlay .viewport .Height = max (1 , width - 4 ), max (1 , height - 6 )
223300 overlay .viewport .SetContent (overlay .review .Body )
224- return fitPane (core .Join ("\n " , overlay .review .Title , overlay .review .Warning , "" , overlay .viewport .View (), "" , prompt ), width , height , styles .panel )
301+ head , foot := renderOverlayFrame (changeReviewCTML , width , styles , changeAcceptanceBindings (overlay ))
302+ return fitPane (core .Join ("\n " , head , overlay .viewport .View (), foot ), width , height , styles .panel )
225303}
226304
227305func newLaunchReviewOverlay (review agentReview , provider , model string ) * launchReviewOverlay {
@@ -311,6 +389,67 @@ func (overlay *launchReviewOverlay) selection() (string, string) {
311389 return core .Trim (overlay .providerInput .Value ()), core .Trim (overlay .modelInput .Value ())
312390}
313391
392+ // launchReviewCTML and agentSelectCTML are the launch-review overlay's two
393+ // markup shapes — launchreview.ctml (read-only receipt review, full HCF)
394+ // and agentselect.ctml (editable provider/model selection, HF around the
395+ // inputs); see each file for the seams it exposes.
396+ //
397+ //go:embed launchreview.ctml
398+ var launchReviewCTML []byte
399+
400+ //go:embed agentselect.ctml
401+ var agentSelectCTML []byte
402+
403+ // reviewBodyRows splits a multi-line receipt body into one row per line —
404+ // a bound value cannot carry a line break through an inline run, so each
405+ // row closes with <br> in the markup; empty lines ride as empty rows.
406+ func reviewBodyRows (body string ) []map [string ]any {
407+ rows := []map [string ]any {}
408+ for _ , line := range core .Split (body , "\n " ) {
409+ rows = append (rows , map [string ]any {"line" : line })
410+ }
411+ return rows
412+ }
413+
414+ // launchReviewBindings binds the read-only shape: the title with the
415+ // defaulted provider/model pair (one row), the conditional warning, and
416+ // the receipt body lines.
417+ func launchReviewBindings (overlay * launchReviewOverlay , provider , model string ) ctml.Bindings {
418+ sequences := map [string ][]map [string ]any {
419+ "review" : {},
420+ "warn" : {},
421+ "body" : {},
422+ }
423+ if overlay != nil {
424+ sequences ["review" ] = append (sequences ["review" ], map [string ]any {
425+ "title" : overlay .review .Title , "provider" : provider , "model" : model ,
426+ })
427+ if overlay .review .Warning != "" {
428+ sequences ["warn" ] = append (sequences ["warn" ], map [string ]any {"text" : overlay .review .Warning })
429+ }
430+ sequences ["body" ] = reviewBodyRows (overlay .review .Body )
431+ }
432+ return ctml.Bindings {Sequences : sequences }
433+ }
434+
435+ // agentSelectionBindings binds the editable shape: the title (one row),
436+ // the receipt body lines, and the conditional warning.
437+ func agentSelectionBindings (overlay * launchReviewOverlay ) ctml.Bindings {
438+ sequences := map [string ][]map [string ]any {
439+ "review" : {},
440+ "body" : {},
441+ "warn" : {},
442+ }
443+ if overlay != nil {
444+ sequences ["review" ] = append (sequences ["review" ], map [string ]any {"title" : overlay .review .Title })
445+ sequences ["body" ] = reviewBodyRows (overlay .review .Body )
446+ if overlay .review .Warning != "" {
447+ sequences ["warn" ] = append (sequences ["warn" ], map [string ]any {"text" : overlay .review .Warning })
448+ }
449+ }
450+ return ctml.Bindings {Sequences : sequences }
451+ }
452+
314453func (overlay * launchReviewOverlay ) View (width , height int , styles uiStyles ) string {
315454 if overlay == nil {
316455 return ""
@@ -323,12 +462,13 @@ func (overlay *launchReviewOverlay) View(width, height int, styles uiStyles) str
323462 model = "default model"
324463 }
325464 if ! overlay .editable {
326- return fitPane (core . Join ( " \n " , overlay . review . Title , "" , "Provider: " + provider , "Model: " + model , "" , overlay . review . Warning , "" , overlay . review . Body , "" , "enter confirms · esc cancels" ), width , height , styles .panel )
465+ return fitPane (renderOverlayLayout ( launchReviewCTML , width , styles , launchReviewBindings ( overlay , provider , model ) ), width , height , styles .panel )
327466 }
328467 fieldWidth := max (12 , width - 6 )
329468 overlay .providerInput .Width = fieldWidth
330469 overlay .modelInput .Width = fieldWidth
331- return fitPane (core .Join ("\n " , overlay .review .Title , "" , "Provider" , overlay .providerInput .View (), "Model" , overlay .modelInput .View (), "" , overlay .review .Body , "" , overlay .review .Warning , "" , "tab selects provider/model · enter confirms · esc cancels" ), width , height , styles .panel )
470+ head , foot := renderOverlayFrame (agentSelectCTML , width , styles , agentSelectionBindings (overlay ))
471+ return fitPane (core .Join ("\n " , head , overlay .providerInput .View (), "Model" , overlay .modelInput .View (), foot ), width , height , styles .panel )
332472}
333473
334474type agentActionMsg struct {
0 commit comments