Skip to content

Commit 9fb0952

Browse files
[WOR-1243] tui: fix form->loading transition, stop intercepting Enter from huh, fix blank form on Esc (#396)
Stop hijacking Enter in FormWithAction so `huh`'s natural submit flow runs and clear the screen during the transition so leftover form chrome doesn't linger as the loading state takes over. Also, rebuild the underlying `huh` form on every Init() (via a factory) so navigating back to a previously-submitted form re-renders it instead of showing a blank screen. GitOrigin-RevId: 0598d47302f8a3e060f5dd01fb352712b25f166c
1 parent 47ce31e commit 9fb0952

8 files changed

Lines changed: 222 additions & 55 deletions

File tree

pkg/tui/formwithaction.go

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ import (
66
)
77

88
type FormAction[T any] struct {
9-
action func(T) tea.Cmd
10-
onSubmit TypedCmd[T]
11-
submitted bool
9+
action func(T) tea.Cmd
10+
onSubmit TypedCmd[T]
1211
}
1312

1413
func NewFormAction[T any](
@@ -21,39 +20,33 @@ func NewFormAction[T any](
2120
}
2221
}
2322

24-
func (fa *FormAction[T]) Init() tea.Cmd {
25-
return fa.onSubmit.Unwrap()
26-
}
27-
28-
func (fa *FormAction[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
29-
switch msg := msg.(type) {
30-
case LoadDataMsg[T]:
31-
cmd := fa.action(msg.Data)
32-
return fa, cmd
33-
}
34-
35-
return fa, nil
36-
}
37-
38-
func (fa *FormAction[T]) View() string {
39-
return "Loading..."
40-
}
41-
4223
type FormWithAction[T any] struct {
43-
done bool
4424
formAction FormAction[T]
25+
buildForm func() *huh.Form
4526
huhForm *huh.Form
4627
}
4728

48-
func NewFormWithAction[T any](action FormAction[T], form *huh.Form) *FormWithAction[T] {
29+
// NewFormWithAction wires the form's natural submit flow to the action's
30+
// onSubmit cmd via huh.Form.SubmitCmd. Once huh transitions to
31+
// StateCompleted, its View() returns "" (because f.quitting is true). We
32+
// also send tea.ClearScreen so the bubble tea renderer fully wipes the
33+
// previous (taller) form render, rather than relying on its diff logic to
34+
// erase every line.
35+
//
36+
// buildForm is a factory invoked on every Init(). huh provides no public
37+
// API to reset a completed form, so a fresh instance is required each
38+
// time the model is re-entered (e.g. when the user navigates back via
39+
// Esc after a successful submission).
40+
func NewFormWithAction[T any](action FormAction[T], buildForm func() *huh.Form) *FormWithAction[T] {
4941
return &FormWithAction[T]{
5042
formAction: action,
51-
huhForm: form,
43+
buildForm: buildForm,
5244
}
5345
}
5446

5547
func (df *FormWithAction[T]) Init() tea.Cmd {
56-
df.done = false
48+
df.huhForm = df.buildForm()
49+
df.huhForm.SubmitCmd = tea.Sequence(tea.ClearScreen, df.formAction.onSubmit.Unwrap())
5750
return df.huhForm.Init()
5851
}
5952

@@ -65,28 +58,21 @@ func (df *FormWithAction[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6558
// are unreachable.
6659
df.huhForm = df.huhForm.WithWidth(msg.Width).WithHeight(msg.Height)
6760
return df, nil
68-
case tea.KeyMsg:
69-
switch msg.Type {
70-
case tea.KeyEnter:
71-
df.done = true
72-
return df, df.formAction.Init()
73-
}
61+
case LoadDataMsg[T]:
62+
// Loading finished; dispatch the post-load action.
63+
return df, df.formAction.action(msg.Data)
7464
}
7565

76-
var cmd tea.Cmd
77-
if df.done {
78-
_, cmd = df.formAction.Update(msg)
79-
} else {
80-
_, cmd = df.huhForm.Update(msg)
66+
f, cmd := df.huhForm.Update(msg)
67+
if hf, ok := f.(*huh.Form); ok {
68+
df.huhForm = hf
8169
}
82-
8370
return df, cmd
8471
}
8572

8673
func (df *FormWithAction[T]) View() string {
87-
if df.done {
88-
return df.formAction.View()
74+
if df.huhForm == nil {
75+
return ""
8976
}
90-
9177
return df.huhForm.View()
9278
}

pkg/tui/formwithaction_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package tui_test
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
"time"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/charmbracelet/huh"
10+
"github.com/charmbracelet/x/ansi"
11+
"github.com/charmbracelet/x/exp/teatest"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/render-oss/cli/pkg/tui"
15+
"github.com/render-oss/cli/pkg/tui/testhelper"
16+
)
17+
18+
// waitOpts is the standard polling/timeout config used by these tests, matching
19+
// pkg/tui/views/logview_test.go.
20+
var waitOpts = []teatest.WaitForOption{
21+
teatest.WithCheckInterval(time.Millisecond * 10),
22+
teatest.WithDuration(time.Second * 3),
23+
}
24+
25+
// formHarness wires up a FormWithAction inside a real StackModel and returns
26+
// the pieces a test needs. The action pushes a sentinel model so the stack has
27+
// a frame to pop when we send Esc.
28+
type formHarness struct {
29+
stack *tui.StackModel
30+
tm *teatest.TestModel
31+
fieldPtr *string
32+
sentinel string
33+
}
34+
35+
// newFormHarness returns a harness whose form factory captures the
36+
// caller-provided field instance. Reusing the same field across rebuilds
37+
// matches the pattern in workflowcreate.go / jobcreate.go / taskrun.go, where
38+
// fields outlive the huh.Form they're wrapped in so user input persists across
39+
// re-entry.
40+
func newFormHarness(t *testing.T, field huh.Field, fieldPtr *string) *formHarness {
41+
t.Helper()
42+
stack := tui.NewStack()
43+
44+
const sentinel = "SENTINEL_VIEW"
45+
sentinelModel := &testhelper.SimpleModel{Str: sentinel}
46+
47+
action := tui.NewFormAction(
48+
func(string) tea.Cmd {
49+
return stack.Push(tui.ModelWithCmd{Model: sentinelModel, Breadcrumb: "Sentinel"})
50+
},
51+
tui.TypedCmd[string](func() tea.Msg {
52+
return tui.LoadDataMsg[string]{Data: "ok"}
53+
}),
54+
)
55+
56+
buildForm := func() *huh.Form {
57+
return huh.NewForm(huh.NewGroup(field))
58+
}
59+
60+
fwa := tui.NewFormWithAction(action, buildForm)
61+
stack.Push(tui.ModelWithCmd{Model: fwa, Breadcrumb: "Form"})
62+
63+
tm := teatest.NewTestModel(t, stack)
64+
tm.Send(tea.WindowSizeMsg{Width: 80, Height: 24})
65+
66+
return &formHarness{stack: stack, tm: tm, fieldPtr: fieldPtr, sentinel: sentinel}
67+
}
68+
69+
func (h *formHarness) cleanup(t *testing.T) {
70+
t.Helper()
71+
h.tm.Quit()
72+
h.tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3))
73+
}
74+
75+
// TestFormWithAction_RendersAfterReInit guards the regression where, after huh
76+
// transitions to StateCompleted on submit, navigating back to the form (e.g.
77+
// via Esc on a follow-up view) left the user on a blank screen because huh
78+
// exposes no API to reset f.quitting.
79+
//
80+
// Each teatest.WaitFor consumes the output stream as it reads, so the final
81+
// WaitFor sees only post-Esc output. If the form fails to re-render (the
82+
// original bug), "FieldHeading" never appears in that fresh window and the
83+
// WaitFor times out.
84+
func TestFormWithAction_RendersAfterReInit(t *testing.T) {
85+
var fieldValue string
86+
field := huh.NewInput().Title("FieldHeading").Value(&fieldValue)
87+
h := newFormHarness(t, field, &fieldValue)
88+
defer h.cleanup(t)
89+
90+
// 1. Initial render.
91+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
92+
return bytes.Contains(b, []byte("FieldHeading"))
93+
}, waitOpts...)
94+
95+
// 2. Submit. The action pushes the sentinel onto the stack.
96+
h.tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
97+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
98+
return bytes.Contains(b, []byte(h.sentinel))
99+
}, waitOpts...)
100+
101+
// 3. Esc pops the sentinel; StackModel re-Inits the form. With the factory
102+
// pattern, this rebuilds a fresh huh.Form. Without it, the reused form is
103+
// in StateCompleted and View() returns "", so "FieldHeading" never appears
104+
// in this fresh output window and the WaitFor times out.
105+
h.tm.Send(tea.KeyMsg{Type: tea.KeyEsc})
106+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
107+
return bytes.Contains(b, []byte("FieldHeading"))
108+
}, waitOpts...)
109+
}
110+
111+
// TestFormWithAction_PreservesValuesOnReInit guards the design choice behind
112+
// reusing huh.Field instances across factory rebuilds in production callers
113+
// (workflowcreate.go, jobcreate.go, taskrun.go). Because the field's value
114+
// pointer survives a rebuild, anything the user typed before submit should
115+
// still be visible when they navigate back to the form.
116+
func TestFormWithAction_PreservesValuesOnReInit(t *testing.T) {
117+
var fieldValue string
118+
field := huh.NewInput().Title("FieldHeading").Value(&fieldValue)
119+
h := newFormHarness(t, field, &fieldValue)
120+
defer h.cleanup(t)
121+
122+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
123+
return bytes.Contains(b, []byte("FieldHeading"))
124+
}, waitOpts...)
125+
126+
// Type a marker value that wouldn't appear anywhere else in the form
127+
// chrome, so we can unambiguously detect its survival across re-init.
128+
const typed = "rendywashere"
129+
h.tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(typed)})
130+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
131+
return bytes.Contains(b, []byte(typed))
132+
}, waitOpts...)
133+
134+
// Submit and navigate to the sentinel.
135+
h.tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
136+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
137+
return bytes.Contains(b, []byte(h.sentinel))
138+
}, waitOpts...)
139+
140+
// Esc back to the form. The rebuilt huh.Form wraps the same field instance,
141+
// whose Value pointer still references our typed string.
142+
h.tm.Send(tea.KeyMsg{Type: tea.KeyEsc})
143+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
144+
return bytes.Contains(b, []byte(typed))
145+
}, waitOpts...)
146+
147+
require.Equal(t, typed, *h.fieldPtr,
148+
"field's bound value should still hold the typed string after re-init")
149+
}
150+
151+
// TestFormWithAction_ClearsScreenOnSubmit guards the secondary fix that
152+
// prepends tea.ClearScreen to huh.Form.SubmitCmd, so the renderer wipes any
153+
// leftover form chrome (titles, labels) before the loading state takes over.
154+
// Without ClearScreen, leftover lines could persist briefly during the
155+
// form-to-loading transition.
156+
//
157+
// We verify by looking for the EraseEntireDisplay ANSI sequence emitted by the
158+
// bubble tea renderer when it processes a clearScreenMsg, which only happens
159+
// via tea.ClearScreen in this flow. Using the ansi package's constant (rather
160+
// than a hard-coded escape) means we follow the renderer's source of truth
161+
// across upstream version bumps.
162+
func TestFormWithAction_ClearsScreenOnSubmit(t *testing.T) {
163+
var fieldValue string
164+
field := huh.NewInput().Title("FieldHeading").Value(&fieldValue)
165+
h := newFormHarness(t, field, &fieldValue)
166+
defer h.cleanup(t)
167+
168+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
169+
return bytes.Contains(b, []byte("FieldHeading"))
170+
}, waitOpts...)
171+
172+
h.tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
173+
174+
teatest.WaitFor(t, h.tm.Output(), func(b []byte) bool {
175+
return bytes.Contains(b, []byte(ansi.EraseEntireDisplay))
176+
}, waitOpts...)
177+
}

pkg/tui/views/deploycreate.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,14 +191,14 @@ func (v *DeployCreateView) setupForm() tea.Cmd {
191191
Value(v.input.CommitID))
192192
}
193193

194-
deployForm := huh.NewForm(huh.NewGroup(inputs...))
194+
buildForm := func() *huh.Form { return huh.NewForm(huh.NewGroup(inputs...)) }
195195

196196
action := tui.NewFormAction(
197197
v.logCmd,
198198
command.WrapInConfirm(command.LoadCmd(v.ctx, CreateDeploy, v.input), DeployCreateConfirm(v.ctx, v.input)),
199199
)
200200

201-
v.formAction = tui.NewFormWithAction(action, deployForm)
201+
v.formAction = tui.NewFormWithAction(action, buildForm)
202202

203203
return v.formAction.Init()
204204
}

pkg/tui/views/jobcreate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func NewJobCreateView(
7171
return command.LoadCmd(ctx, createJob, createJobInput)()
7272
},
7373
),
74-
huh.NewForm(huh.NewGroup(fields...)),
74+
func() *huh.Form { return huh.NewForm(huh.NewGroup(fields...)) },
7575
),
7676
}
7777
}

pkg/tui/views/jobcreate_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,13 @@ func TestJobCreate(t *testing.T) {
3737

3838
tm.Send(tea.WindowSizeMsg{Width: 80, Height: 80})
3939

40-
// Add start command
40+
// Add start command, then walk through the rest of the fields by hitting
41+
// Enter until huh submits naturally on the last field.
4142
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("echo 'hello world'")})
42-
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
43+
for i := 0; i < 5; i++ {
44+
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
45+
time.Sleep(20 * time.Millisecond)
46+
}
4347

4448
require.Eventually(t, func() bool {
4549
return createJobInput.StartCommand != nil && *createJobInput.StartCommand == "echo 'hello world'"

pkg/tui/views/workflows/taskrun.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func NewTaskRunView(
3838
return command.LoadCmd(ctx, workflowLoader.CreateTaskRun, createTaskRunInput)()
3939
},
4040
),
41-
huh.NewForm(huh.NewGroup(fields...)),
41+
func() *huh.Form { return huh.NewForm(huh.NewGroup(fields...)) },
4242
),
4343
}
4444
}

pkg/tui/views/workflows/versionrelease.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,24 @@ func NewVersionReleaseView(ctx context.Context, workflowLoader *WorkflowLoader,
3535
}
3636

3737
func (v *VersionReleaseView) setupForm() tea.Cmd {
38-
var inputs []huh.Field
3938
if v.input.CommitID == nil {
4039
v.input.CommitID = pointers.From("")
4140
}
4241

43-
inputs = append(inputs, huh.NewInput().
44-
Title("Commit ID").
45-
Placeholder("Enter commit ID (Optional)").
46-
Value(v.input.CommitID))
47-
48-
versionForm := huh.NewForm(huh.NewGroup(inputs...))
42+
buildForm := func() *huh.Form {
43+
input := huh.NewInput().
44+
Title("Commit ID").
45+
Placeholder("Enter commit ID (Optional)").
46+
Value(v.input.CommitID)
47+
return huh.NewForm(huh.NewGroup(input))
48+
}
4949

5050
action := tui.NewFormAction(
5151
v.logCmd,
5252
command.WrapInConfirm(command.LoadCmd(v.ctx, v.workflowLoader.ReleaseVersion, *v.input), v.workflowLoader.VersionReleaseConfirm(v.ctx, *v.input)),
5353
)
5454

55-
v.formAction = tui.NewFormWithAction(action, versionForm)
55+
v.formAction = tui.NewFormWithAction(action, buildForm)
5656

5757
return v.formAction.Init()
5858
}

pkg/tui/views/workflows/workflowcreate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func NewWorkflowCreateView(
121121
return command.LoadCmd(ctx, createWorkflow, createInput)()
122122
},
123123
),
124-
huh.NewForm(huh.NewGroup(fields...)),
124+
func() *huh.Form { return huh.NewForm(huh.NewGroup(fields...)) },
125125
),
126126
}
127127
}

0 commit comments

Comments
 (0)