Skip to content

Commit b64060c

Browse files
authored
Merge pull request #12 from dAppCore/feat/tui
feat(tui): lem TUI on .ctml — compositor-rendered screens
2 parents c65fa35 + b67f287 commit b64060c

49 files changed

Lines changed: 4132 additions & 565 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cli/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ require (
3333
charm.land/log/v2 v2.0.0 // indirect
3434
charm.land/wish/v2 v2.0.0 // indirect
3535
dappco.re/go/cgo v0.11.2 // indirect
36-
dappco.re/go/html v0.11.0 // direct: cli/tui renders .ctml (tabs.go)
36+
dappco.re/go/html v0.15.0 // direct: cli/tui renders .ctml (tabs.go)
3737
dappco.re/go/i18n v0.12.1 // indirect
3838
dappco.re/go/log v0.13.1 // indirect
3939
dappco.re/go/ratelimit v0.12.1 // indirect

cli/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ dappco.re/go/container v0.11.0 h1:XbZS3VdMoxHIn1EQTnU44kJSB/H95rVrZ6ceujAUPSE=
2222
dappco.re/go/container v0.11.0/go.mod h1:DTA3lcbMG3bfve8hlDls1UNxRgGAZELCGuuM5XKSpgg=
2323
dappco.re/go/html v0.11.0 h1:Qau6MinQATThPmMamnzyaSV06mr10QzDdAcvkIqwlBk=
2424
dappco.re/go/html v0.11.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4=
25+
dappco.re/go/html v0.12.0 h1:/5hDG/AcOrFagkGk/7BeRnIichJ6+2qqRAbEdBgYrfE=
26+
dappco.re/go/html v0.12.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4=
27+
dappco.re/go/html v0.13.0 h1:EgMdKzZ2mX++2tlNSVk2VrexKM74SmwAAtlwSuG0cSA=
28+
dappco.re/go/html v0.13.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4=
29+
dappco.re/go/html v0.13.1 h1:ILgEjxaiof8eOqp44791Gtij+t5PfvT6u7E2K0Wbw2s=
30+
dappco.re/go/html v0.13.1/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4=
31+
dappco.re/go/html v0.14.0 h1:vUDiFe4b5IXF2xFCFPSPwwlXwWW5Cf+OhDQq0gnt/iY=
32+
dappco.re/go/html v0.14.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4=
33+
dappco.re/go/html v0.15.0 h1:XMFA7fKY07F3gskoF/9+NYo1WlLaNmoGEFmOofWvnRw=
34+
dappco.re/go/html v0.15.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4=
2535
dappco.re/go/i18n v0.12.1 h1:Pm1DF9I0O8bckTr815OrnR+G+7EvbQhP2Vk4ItC0mB4=
2636
dappco.re/go/i18n v0.12.1/go.mod h1:AYx1QkPfW+qvzLCfJnuOlc4J+grwlgFzoHnoke4TAYg=
2737
dappco.re/go/inference v0.14.0 h1:3Lddj4W8T7/Mg8iTmvS+hCUkUqYYhRPYGe6i/ILF1+o=

cli/tui/README.md

Lines changed: 317 additions & 12 deletions
Large diffs are not rendered by default.

cli/tui/agentanswer.ctml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!-- SPDX-Licence-Identifier: EUPL-1.2 -->
2+
<!--
3+
agentanswer.ctml : the answer-agent-question overlay — one textarea
4+
feeding a native run's pending question.
5+
6+
Idiom: <layout variant="HF"> via ctml.ParseLayout — the live textarea
7+
emits pre-styled ANSI, which cannot ride a .ctml document, so the
8+
layout renders the header and footer bands and the host composes the
9+
textarea between them (renderOverlayFrame splits at the H box height).
10+
11+
Host seams (agentoverlay.go):
12+
- Sequence question : zero-or-one row {text} — the question line
13+
(a lone dynamic value rides a one-row sequence).
14+
- Classes overlay-keys : mapped onto the existing uiStyles palette
15+
by overlayFrameTheme.
16+
-->
17+
<layout variant="HF">
18+
<h><h2>Answer agent question</h2><each items="question" as="row"><p>{{row.text}}</p></each></h>
19+
<f><p class="overlay-keys">enter submits · esc cancels</p></f>
20+
</layout>

cli/tui/agentoverlay.go

Lines changed: 156 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
package tui
44

55
import (
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+
100132
func (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

115144
type 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+
163210
func (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

171219
type 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+
211295
func (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

227305
func 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+
314453
func (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

334474
type agentActionMsg struct {

0 commit comments

Comments
 (0)