Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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

Expand Down
2 changes: 0 additions & 2 deletions docs/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
57 changes: 31 additions & 26 deletions event/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions event/event_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package event_test

import (
"slices"
"testing"

"github.com/joestump-agent/a2tea/event"
Expand All @@ -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)
}
}
Expand Down
37 changes: 34 additions & 3 deletions render/inputs.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
85 changes: 85 additions & 0 deletions render/inputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

a2ui "github.com/tmc/a2ui"

"github.com/joestump-agent/a2tea/event"
"github.com/joestump-agent/a2tea/render"
)

Expand Down Expand Up @@ -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).
Expand Down
46 changes: 36 additions & 10 deletions render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -273,15 +276,18 @@ 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
}
if s.focusedIsButton() {
return s, s.activate()
}
if s.focusedIsTextEditable() {
return s, s.submitInput()
}
if s.focusedIsCheckBox() {
s.toggleCheckBox()
}
Expand All @@ -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(" ")
}
Expand Down Expand Up @@ -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 == "" {
Expand Down
Loading
Loading