diff --git a/README.md b/README.md index 548b6c6..87ac626 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ terminal size. A runnable version of the whole flow lives in - `github.com/joestump-agent/a2tea` — public entry point: `Contains`, `Scan(reply) ([]Part, error)`, `Render(msgs) (tea.Model, error)`, and `Standalone`. - `github.com/joestump-agent/a2tea/render` — walks an A2UI surface (components referencing children by ID) into an embeddable `render.Model`. -- `github.com/joestump-agent/a2tea/event` — outbound `tea.Msg` types a host can consume for interaction results (`ButtonClicked`, `InputSubmitted`, `ChoiceSelected`, `FormSubmitted`), each carrying `Source`. `ButtonClicked` is emitted when a focused button is activated; the rest are defined but not emitted yet. +- `github.com/joestump-agent/a2tea/event` — outbound `tea.Msg` types a host can consume for interaction results (`ButtonClicked`, `InputSubmitted`, `ChoiceSelected`), each carrying `Source`. `ButtonClicked` is emitted when a focused button is activated; `InputSubmitted` when Enter confirms a focused `TextField`/`DateTimeInput` value; `ChoiceSelected` (carrying the full selection as `Values []string`) when a focused `ChoicePicker`'s selection changes. `FormSubmitted` is deprecated and never emitted. A2UI message and component types come from `github.com/tmc/a2ui`. @@ -103,9 +103,6 @@ What is **not** yet implemented: active one. - **Editing beyond `TextField`.** `CheckBox`/`ChoicePicker`/`Slider`/ `DateTimeInput` remain read-only visuals. -- **The remaining host-facing events.** `InputSubmitted`/`ChoiceSelected` are - defined but never dispatched; `event.ButtonClicked` is the only host-facing - event emitted. ## Versioning diff --git a/docs/wire-format.md b/docs/wire-format.md index f8f38c3..a5b0d3c 100644 --- a/docs/wire-format.md +++ b/docs/wire-format.md @@ -87,5 +87,3 @@ Also implemented since earlier revisions of this doc: - Tab switching: tabs are not focusable, so the first tab is always active. - Editing beyond `TextField`: `CheckBox`, `ChoicePicker`, `Slider`, and `DateTimeInput` remain read-only visuals. -- The remaining host-facing event types: `InputSubmitted`/`ChoiceSelected` - are defined but never dispatched. diff --git a/event/event.go b/event/event.go index e721ba1..edd35eb 100644 --- a/event/event.go +++ b/event/event.go @@ -3,21 +3,21 @@ // through the standard bubbletea Update loop, and the consuming agent picks // them up from there. // -// Status. ButtonClicked is emitted by the surface renderer and now carries the -// button's resolved *a2ui.EventAction (nil for buttons with no server event). -// Alongside it the renderer emits a native a2ui.ClientMessage whose -// ActionEvent.Context is populated from the surface's input component values -// (TextField/DateTimeInput → string, ChoicePicker → []string, CheckBox → -// bool, Slider → float64, keyed by component ID) merged with the action's -// own declared context bindings. +// Status. ButtonClicked, InputSubmitted, and ChoiceSelected are all emitted +// by the surface renderer. ButtonClicked carries the button's resolved +// *a2ui.EventAction (nil for buttons with no server event); alongside it the +// renderer emits a native a2ui.ClientMessage whose ActionEvent.Context is +// populated from the surface's input component values (TextField/DateTimeInput +// → string, ChoicePicker → []string, CheckBox → bool, Slider → float64, keyed +// by component ID) merged with the action's own declared context bindings. +// InputSubmitted carries the confirmed text of a TextField (or DateTimeInput, +// which shares the TextField edit path); ChoiceSelected carries a +// ChoicePicker's full selection as a string list, matching the component's +// natively multi-value shape. // // FormSubmitted is deprecated: A2UI v0.9 has no Form component, so a "form // submit" is just a Button Action whose ActionEvent.Context carries the -// gathered field values — the host reads Context directly. InputSubmitted and -// ChoiceSelected are not emitted yet; they are a provisional host-facing -// vocabulary that will be re-grounded in A2UI catalog terms (TextField, not -// "input"; ChoicePicker, not "choice") when wired. Treat those shapes as not -// yet stable. +// gathered field values — the host reads Context directly. // // Source context. Every event embeds Source, which carries the IDs a consumer // needs to tell interactions apart when more than one component (or the same @@ -58,32 +58,37 @@ type ButtonClicked struct { Action *a2ui.EventAction } -// InputSubmitted is emitted when the user confirms an A2UI TextField value. -// -// TODO(a2tea): dispatch this from the renderer with Source set. +// InputSubmitted is emitted when the user confirms an A2UI TextField value: +// with the surface focused, Enter on a focused TextField (or DateTimeInput, +// which shares the TextField edit path) dispatches this event with Source +// set. Value is the field's value at the moment of submission — the edited +// text when the user has typed, else the field's literal or resolved +// data-model value ("" when the value is absent or unresolved; display +// placeholders never leak into events). type InputSubmitted struct { Source - // ID is the a2ui.Component ID of the TextField. + // ID is the a2ui.Component ID of the TextField (or DateTimeInput). ID string // Value is the final string value the user submitted. Value string } -// ChoiceSelected is emitted when the user picks from an A2UI ChoicePicker. -// -// A2UI's ChoicePicker is natively multi-value (its value is a string list), so -// when this event is wired it will likely carry a []string rather than a single -// Value — one of the re-grounding tasks noted in the package doc. It is kept -// single-valued here only until then. +// ChoiceSelected is emitted when the selection set of an A2UI ChoicePicker +// changes: with the surface focused, Space toggles the picker's highlighted +// option and dispatches this event with Source set. A Space that leaves the +// selection unchanged (radio semantics: re-selecting a single-select picker's +// already selected option) emits nothing. // -// TODO(a2tea): dispatch this from the renderer with Source set; reconcile the -// value shape with ChoicePicker's list value. +// A2UI's ChoicePicker is natively multi-value (its value is a string list), +// so the event carries the full selection as Values; a single-select picker's +// list simply never has more than one element. type ChoiceSelected struct { Source // ID is the a2ui.Component ID of the ChoicePicker. ID string - // Value is the selected option's value (NOT its label). - Value string + // Values is the full selected option value list (option values, NOT + // labels), in option-declaration order. + Values []string } // Deprecated: A2UI v0.9 has no Form component. A "form submit" is a Button diff --git a/event/event_test.go b/event/event_test.go index 983abab..e3dd7fd 100644 --- a/event/event_test.go +++ b/event/event_test.go @@ -1,6 +1,7 @@ package event_test import ( + "slices" "testing" "github.com/joestump-agent/a2tea/event" @@ -19,8 +20,8 @@ func TestSourceContext(t *testing.T) { t.Fatalf("InputSubmitted shape unexpected: %#v", i) } - c := event.ChoiceSelected{Source: event.Source{ComponentID: "form1"}, ID: "color", Value: "red"} - if c.Value != "red" { + c := event.ChoiceSelected{Source: event.Source{ComponentID: "form1"}, ID: "color", Values: []string{"red", "blue"}} + if c.ComponentID != "form1" || c.ID != "color" || !slices.Equal(c.Values, []string{"red", "blue"}) { t.Fatalf("ChoiceSelected shape unexpected: %#v", c) } } diff --git a/render/inputs.go b/render/inputs.go index 22025f2..5f96620 100644 --- a/render/inputs.go +++ b/render/inputs.go @@ -1,6 +1,14 @@ package render -import a2ui "github.com/tmc/a2ui" +import ( + "slices" + + tea "charm.land/bubbletea/v2" + + a2ui "github.com/tmc/a2ui" + + "github.com/joestump-agent/a2tea/event" +) // This file holds the edit paths for the non-text input components: CheckBox // (Space/Enter toggles), ChoicePicker (Up/Down moves the highlight, Space @@ -100,15 +108,29 @@ func (s *Surface) movePickerCursor(delta int) { // selected option keeps it selected). The stored value is normalized to // option-declaration order, so literal values that name no declared option // are dropped once the user edits. -func (s *Surface) togglePickerOption() { +// +// When the toggle changes the selection set it returns a command dispatching +// event.ChoiceSelected with the picker's full post-toggle selection; a toggle +// that leaves the (normalized) selection unchanged — re-selecting a +// single-select picker's already selected option — returns nil. +func (s *Surface) togglePickerOption() tea.Cmd { id := s.focusables[s.focusIdx] c, ok := s.byID[id] if !ok || c.ChoicePicker == nil || len(c.ChoicePicker.Options) == 0 { - return + return nil } cp := c.ChoicePicker optVal := cp.Options[s.pickerCursor(id, cp)].Value sel := s.pickerSelection(id, cp) + // Normalize the pre-toggle selection to option-declaration order so the + // changed check compares like with like (a literal naming no declared + // option is dropped by normalization, not by the user's toggle). + before := make([]string, 0, len(sel)) + for _, opt := range cp.Options { + if sel[opt.Value] { + before = append(before, opt.Value) + } + } if cp.Variant == a2ui.ChoicePickerVariantMultipleSelection { sel[optVal] = !sel[optVal] } else { @@ -124,6 +146,15 @@ func (s *Surface) togglePickerOption() { s.choiceValues = make(map[string][]string) } s.choiceValues[id] = out + if slices.Equal(before, out) { + return nil + } + selected := event.ChoiceSelected{ + Source: event.Source{ComponentID: id, SurfaceID: s.id}, + ID: id, + Values: out, + } + return func() tea.Msg { return selected } } // sliderStep is the per-keypress increment for a slider spanning span. The diff --git a/render/inputs_test.go b/render/inputs_test.go index 4ef262c..e073f17 100644 --- a/render/inputs_test.go +++ b/render/inputs_test.go @@ -11,6 +11,7 @@ import ( a2ui "github.com/tmc/a2ui" + "github.com/joestump-agent/a2tea/event" "github.com/joestump-agent/a2tea/render" ) @@ -493,6 +494,90 @@ func TestButtonContextCarriesEditedInputValues(t *testing.T) { } } +// TestEnterOnDateTimeInputEmitsInputSubmitted verifies Enter on a focused +// DateTimeInput — which shares the TextField edit path — emits +// event.InputSubmitted with Source set and the edited value. +func TestEnterOnDateTimeInputEmitsInputSubmitted(t *testing.T) { + s := inputSurface(t, dateTimeComp("dt", "2026-07-1")) + + s.Update(typeKey('8')) + _, cmd := s.Update(pressKey(tea.KeyEnter)) + + sub := findMsg[event.InputSubmitted](t, collectMsgs(t, cmd)) + if sub.ComponentID != "dt" || sub.SurfaceID != "s" { + t.Fatalf("InputSubmitted.Source = %#v, want ComponentID 'dt', SurfaceID 's'", sub.Source) + } + if sub.ID != "dt" || sub.Value != "2026-07-18" { + t.Fatalf("InputSubmitted = %#v, want ID 'dt', Value '2026-07-18'", sub) + } +} + +// TestChoicePickerSpaceEmitsChoiceSelected verifies Space on a single-select +// picker emits event.ChoiceSelected with Source set and the new selection — +// and that re-selecting the already selected option (radio semantics: no +// change) emits nothing. +func TestChoicePickerSpaceEmitsChoiceSelected(t *testing.T) { + s := inputSurface(t, pickerComp("cp", "", []string{"a"})) + + // Move the highlight to Beta and select it: selection changes a → b. + s.Update(pressKey(tea.KeyDown)) + _, cmd := s.Update(pressSpace()) + + sel := findMsg[event.ChoiceSelected](t, collectMsgs(t, cmd)) + if sel.ComponentID != "cp" || sel.SurfaceID != "s" { + t.Fatalf("ChoiceSelected.Source = %#v, want ComponentID 'cp', SurfaceID 's'", sel.Source) + } + if sel.ID != "cp" || !reflect.DeepEqual(sel.Values, []string{"b"}) { + t.Fatalf("ChoiceSelected = %#v, want ID 'cp', Values [b]", sel) + } + + // Space again on the selected option keeps it selected — no change, no + // event. + _, cmd = s.Update(pressSpace()) + if cmd != nil { + t.Fatalf("re-selecting the selected option should emit nothing, got %#v", cmd()) + } +} + +// TestChoicePickerMultiSelectEmitsFullSelection verifies each toggle on a +// multipleSelection picker emits the full post-toggle selection in +// option-declaration order, including the empty selection when the last +// option is untoggled. +func TestChoicePickerMultiSelectEmitsFullSelection(t *testing.T) { + s := inputSurface(t, pickerComp("cp", a2ui.ChoicePickerVariantMultipleSelection, nil)) + + // Select Gamma, then Alpha: the second event carries [a c], option order. + s.Update(pressKey(tea.KeyDown)) + s.Update(pressKey(tea.KeyDown)) + _, cmd := s.Update(pressSpace()) + sel := findMsg[event.ChoiceSelected](t, collectMsgs(t, cmd)) + if !reflect.DeepEqual(sel.Values, []string{"c"}) { + t.Fatalf("first toggle Values = %v, want [c]", sel.Values) + } + + s.Update(pressKey(tea.KeyUp)) + s.Update(pressKey(tea.KeyUp)) + _, cmd = s.Update(pressSpace()) + sel = findMsg[event.ChoiceSelected](t, collectMsgs(t, cmd)) + if !reflect.DeepEqual(sel.Values, []string{"a", "c"}) { + t.Fatalf("second toggle Values = %v, want [a c] in option order", sel.Values) + } + + // Untoggle Alpha, then Gamma: the last event carries the empty selection. + _, cmd = s.Update(pressSpace()) + sel = findMsg[event.ChoiceSelected](t, collectMsgs(t, cmd)) + if !reflect.DeepEqual(sel.Values, []string{"c"}) { + t.Fatalf("untoggle Values = %v, want [c]", sel.Values) + } + s.Update(pressKey(tea.KeyDown)) + s.Update(pressKey(tea.KeyDown)) + _, cmd = s.Update(pressSpace()) + sel = findMsg[event.ChoiceSelected](t, collectMsgs(t, cmd)) + if len(sel.Values) != 0 { + t.Fatalf("clearing the selection should emit empty Values, got %v", sel.Values) + } +} + // TestInputEditsSurviveApplyMerge verifies edited input values survive an // updateComponents merge that touches a sibling (edit state is keyed by ID // and merges do not clear it). diff --git a/render/render.go b/render/render.go index 96f6973..e7f2d64 100644 --- a/render/render.go +++ b/render/render.go @@ -18,9 +18,11 @@ // // Input components are editable and join Buttons in the focus ring. A focused // TextField (and DateTimeInput, which shares the same rune-edit path against -// its string value) accepts printable keys and backspace. A focused CheckBox +// its string value) accepts printable keys and backspace, and Enter emits +// event.InputSubmitted with the field's current value. A focused CheckBox // toggles with Space or Enter. A focused ChoicePicker moves its highlight with -// Up/Down and toggles the highlighted option with Space. A focused Slider +// Up/Down and toggles the highlighted option with Space, emitting +// event.ChoiceSelected whenever the selection set changes. A focused Slider // steps with Left/Right within its min/max bounds. Edited values are read back // via FieldValues and flow into a button's ActionEvent Context. // @@ -237,11 +239,12 @@ func (s *Surface) Init() tea.Cmd { return nil } // most recently opened modal (and is otherwise ignored, so the host keeps its // Esc semantics when no modal is open — see HasOpenModal). When a // text-editable component (TextField or DateTimeInput) holds focus, rune key -// presses append to its value and backspace deletes the last rune; Enter -// there is a no-op (there is no form-submit concept). A focused CheckBox -// toggles on Space or Enter; a focused ChoicePicker moves its highlight with -// Up/Down and toggles the highlighted option with Space; a focused Slider -// steps with Left/Right. +// presses append to its value, backspace deletes the last rune, and Enter +// emits event.InputSubmitted carrying the field's current value. A focused +// CheckBox toggles on Space or Enter; a focused ChoicePicker moves its +// highlight with Up/Down and toggles the highlighted option with Space, +// emitting event.ChoiceSelected when the selection set changes; a focused +// Slider steps with Left/Right. // // Button activation emits two messages via tea.Batch: // - event.ButtonClicked — the host-facing convenience event, carrying @@ -273,8 +276,8 @@ func (s *Surface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.focusIdx = (s.focusIdx - 1 + len(s.focusables)) % len(s.focusables) case "enter": // Enter toggles a focused modal open/closed, activates a focused - // button, and toggles a focused checkbox; on a text-editable - // component it is a no-op. + // button, submits a focused text-editable component's value, and + // toggles a focused checkbox. if s.focusedIsModal() { s.toggleModal(s.focusables[s.focusIdx]) return s, nil @@ -282,6 +285,9 @@ func (s *Surface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if s.focusedIsButton() { return s, s.activate() } + if s.focusedIsTextEditable() { + return s, s.submitInput() + } if s.focusedIsCheckBox() { s.toggleCheckBox() } @@ -294,7 +300,7 @@ func (s *Surface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case s.focusedIsCheckBox(): s.toggleCheckBox() case s.focusedIsChoicePicker(): - s.togglePickerOption() + return s, s.togglePickerOption() case s.focusedIsTextEditable(): s.appendText(" ") } @@ -404,6 +410,26 @@ func (s *Surface) activate() tea.Cmd { ) } +// submitInput dispatches event.InputSubmitted for the focused text-editable +// component (TextField or DateTimeInput). Value follows the same shadowing +// rule as rendering and FieldValues: the edited value when the user has +// typed, else the field's literal or resolved data-model seed (editSeed) — so +// an unedited field submits its pre-filled content and an unresolved binding +// submits "" rather than leaking a display placeholder. +func (s *Surface) submitInput() tea.Cmd { + id := s.focusables[s.focusIdx] + v, ok := s.fieldValues[id] + if !ok { + v = s.editSeed(id) + } + submitted := event.InputSubmitted{ + Source: event.Source{ComponentID: id, SurfaceID: s.id}, + ID: id, + Value: v, + } + return func() tea.Msg { return submitted } +} + // View implements tea.Model. func (s *Surface) View() tea.View { if s.rootID == "" { diff --git a/render/textfield_test.go b/render/textfield_test.go index 01ba8df..449e28c 100644 --- a/render/textfield_test.go +++ b/render/textfield_test.go @@ -240,22 +240,62 @@ func TestFieldValuesReturnsLiteralWhenNotEdited(t *testing.T) { } } -// TestEnterOnTextFieldIsNoOp verifies that pressing Enter while a TextField is -// focused does NOT emit a ButtonClicked event. -func TestEnterOnTextFieldIsNoOp(t *testing.T) { +// TestEnterOnTextFieldEmitsInputSubmitted verifies that pressing Enter while +// a TextField is focused emits event.InputSubmitted with Source set and the +// field's current value — and does NOT emit ButtonClicked (Enter still only +// activates buttons). +func TestEnterOnTextFieldEmitsInputSubmitted(t *testing.T) { s := fieldSurface(t) - // Focus is on the text field (index 0). Press Enter. + // Focus is on the text field (index 0). Press Enter without editing: the + // submitted value is the field's literal. + _, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + msgs := collectMsgs(t, cmd) + + if hasMsg[event.ButtonClicked](msgs) { + t.Fatal("Enter on a TextField should not emit ButtonClicked") + } + sub := findMsg[event.InputSubmitted](t, msgs) + if sub.ComponentID != "field" || sub.SurfaceID != "s" { + t.Fatalf("InputSubmitted.Source = %#v, want ComponentID 'field', SurfaceID 's'", sub.Source) + } + if sub.ID != "field" || sub.Value != "initial" { + t.Fatalf("InputSubmitted = %#v, want ID 'field', Value 'initial'", sub) + } +} + +// TestEnterSubmitsEditedValue verifies InputSubmitted carries the edited text, +// not the stale literal, after the user types into the field. +func TestEnterSubmitsEditedValue(t *testing.T) { + s := fieldSurface(t) + + for _, r := range "!!" { + s.Update(typeKey(r)) + } _, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - // cmd should be nil — no activation. - if cmd != nil { - msgs := collectMsgs(t, cmd) - for _, m := range msgs { - if _, ok := m.(event.ButtonClicked); ok { - t.Fatal("Enter on a TextField should not emit ButtonClicked") - } - } + sub := findMsg[event.InputSubmitted](t, collectMsgs(t, cmd)) + if sub.Value != "initial!!" { + t.Fatalf("InputSubmitted.Value = %q, want %q", sub.Value, "initial!!") + } +} + +// TestEnterOnUnresolvedBoundFieldSubmitsEmpty verifies submitting an unedited +// TextField whose value is an unresolved binding carries "" — the "{binding}" +// display placeholder must not leak into the event. +func TestEnterOnUnresolvedBoundFieldSubmitsEmpty(t *testing.T) { + bound := a2ui.StringBinding("/name") + comps := []a2ui.Component{ + {ID: "root", Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{IDs: []string{"f"}}}}, + {ID: "f", TextField: &a2ui.TextFieldComponent{Value: &bound}}, + } + s := render.NewSurface("s", comps) + s.Focus() + + _, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + sub := findMsg[event.InputSubmitted](t, collectMsgs(t, cmd)) + if sub.Value != "" { + t.Fatalf("InputSubmitted.Value = %q, want empty (placeholder leaked)", sub.Value) } }