Skip to content

Commit bab9198

Browse files
maksim-zuev-dbIsaac
andauthored
Show a blank prompt glyph until invalid input is edited (#7314)
## Changes The `cmdio` prompt glyph is now blank until the first edit or Enter. After that it shows a cross while the input is invalid and a checkmark once it is valid. A prompt without `Validate` still shows the checkmark. An empty bracketed paste, which some terminals send when the clipboard is empty, is now ignored. It no longer counts as an edit and no longer clears the error line. ## Why The glyph ran `Validate` on every render, so a prompt that rejects empty input, such as the `databricks configure` host prompt, opened with a red cross before anything was typed. This change was part of #7195 and was split out because it affects every prompt that uses `Validate`, including the `databricks auth login` profile name prompt when the default name is already taken. ## Tests - The golden for an empty validated prompt now shows the blank glyph. - New prompt tests cover Enter before any edit, an empty input that is already valid, navigation keys and Backspace on an empty prompt, and an empty paste. - `libs/cmdio/...` and `cmd/configure` unit tests pass. This pull request and its description were written by Isaac. --------- Co-authored-by: Isaac <no-reply@databricks.com>
1 parent 64e4c2d commit bab9198

6 files changed

Lines changed: 118 additions & 7 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Prompts that validate their input, such as the `databricks configure` host prompt, no longer open with a red cross before anything is typed. ([#7314](https://github.com/databricks/cli/pull/7314))

‎libs/cmdio/cmdiotest/prompt_validate_baseline_test.go‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,90 @@ func TestPromptBaseline_Validate(t *testing.T) {
4545
require.NoError(t, err, "raw output: %q", tm.Raw())
4646
assert.Equal(t, "https://example.com", v, "snapshot:\n%s", tm.Snapshot())
4747
}
48+
49+
// TestPromptBaseline_ValidateEnterBeforeEdit pins that Enter on an untouched
50+
// prompt flips the glyph to "✗" along with the error line.
51+
func TestPromptBaseline_ValidateEnterBeforeEdit(t *testing.T) {
52+
t.Parallel()
53+
tm := termtest.NewPrompt(t, cmdio.PromptOptions{
54+
Label: "Workspace host",
55+
Validate: func(s string) error {
56+
if s == "" {
57+
return errors.New("host is required")
58+
}
59+
return nil
60+
},
61+
})
62+
tm.WaitFor("Workspace host")
63+
tm.Type(termtest.KeyEnter)
64+
tm.Golden("01-enter")
65+
66+
tm.Type(termtest.KeyCtrlC)
67+
_, err := tm.Result()
68+
require.Error(t, err)
69+
assert.EqualError(t, err, "^C")
70+
}
71+
72+
// TestPromptBaseline_ValidateValidBeforeEdit pins that an untouched prompt
73+
// whose input already passes Validate shows "✔".
74+
func TestPromptBaseline_ValidateValidBeforeEdit(t *testing.T) {
75+
t.Parallel()
76+
tm := termtest.NewPrompt(t, cmdio.PromptOptions{
77+
Label: "Workspace host",
78+
Validate: func(s string) error {
79+
if strings.Contains(s, " ") {
80+
return errors.New("must not contain spaces")
81+
}
82+
return nil
83+
},
84+
})
85+
tm.WaitFor("Workspace host")
86+
tm.Golden("01-initial")
87+
88+
tm.Type(termtest.KeyEnter)
89+
v, err := tm.Result()
90+
require.NoError(t, err, "raw output: %q", tm.Raw())
91+
assert.Empty(t, v)
92+
}
93+
94+
// TestPromptBaseline_ValidateNonEditKeysKeepBlankGlyph pins that keys which
95+
// leave the buffer unchanged do not flip the blank glyph to "✗".
96+
func TestPromptBaseline_ValidateNonEditKeysKeepBlankGlyph(t *testing.T) {
97+
t.Parallel()
98+
tm := termtest.NewPrompt(t, cmdio.PromptOptions{
99+
Label: "Workspace host",
100+
Validate: func(s string) error {
101+
if s == "" {
102+
return errors.New("host is required")
103+
}
104+
return nil
105+
},
106+
})
107+
tm.WaitFor("Workspace host")
108+
tm.Type(termtest.KeyLeft + termtest.KeyRight + termtest.KeyBackspace + termtest.KeyCtrlC)
109+
_, err := tm.Result()
110+
assert.EqualError(t, err, "^C")
111+
// The prompt line is erased on exit, so check every rendered frame.
112+
assert.NotContains(t, tm.Raw(), "✗")
113+
}
114+
115+
// TestPromptBaseline_ValidateEmptyPasteKeepsBlankGlyph pins that an empty
116+
// bracketed paste, which some terminals send for an empty clipboard, is not
117+
// treated as an edit.
118+
func TestPromptBaseline_ValidateEmptyPasteKeepsBlankGlyph(t *testing.T) {
119+
t.Parallel()
120+
tm := termtest.NewPrompt(t, cmdio.PromptOptions{
121+
Label: "Workspace host",
122+
Validate: func(s string) error {
123+
if s == "" {
124+
return errors.New("host is required")
125+
}
126+
return nil
127+
},
128+
})
129+
tm.WaitFor("Workspace host")
130+
tm.Type("\x1b[200~\x1b[201~" + termtest.KeyCtrlC)
131+
_, err := tm.Result()
132+
assert.EqualError(t, err, "^C")
133+
assert.NotContains(t, tm.Raw(), "✗")
134+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
✗ Workspace host: █
1+
Workspace host: █
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
✗ Workspace host: █
2+
>> host is required
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
✔ Workspace host: █

‎libs/cmdio/prompt.go‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import (
1010
)
1111

1212
// Glyphs drawn into the prompt's rendered output. cursorBlock stands in for
13-
// the (hidden) OS cursor at the current edit position.
13+
// the (hidden) OS cursor at the current edit position. The glyphs must all be
14+
// one column wide so the label does not shift when the glyph changes.
1415
const (
1516
cursorBlock = "█"
1617
glyphValid = "✔"
1718
glyphInvalid = "✗"
19+
glyphBlank = " "
1820
)
1921

2022
// PromptOptions configures a single-line text prompt shown by [RunPrompt].
@@ -33,7 +35,9 @@ type PromptOptions struct {
3335
// Validate, when set, is called on every keystroke. While it returns a
3436
// non-nil error the leading glyph flips from "✔" to "✗" and Enter is
3537
// inert; pressing Enter while invalid surfaces the returned error
36-
// below the prompt until the next edit.
38+
// below the prompt until the next edit. Until the first edit or Enter,
39+
// an invalid input shows a blank glyph instead, so an empty prompt does
40+
// not open as an error.
3741
Validate func(input string) error
3842
}
3943

@@ -73,6 +77,10 @@ type promptModel struct {
7377
deleted bool
7478
submitted bool
7579

80+
// touched is set by the first edit or Enter. Until then an invalid input
81+
// shows a blank glyph, so a required field does not open as an error.
82+
touched bool
83+
7684
// submitErr is the error from the last failed Enter attempt. Rendered
7785
// below the prompt and cleared on the next edit.
7886
submitErr error
@@ -92,12 +100,16 @@ func (m *promptModel) value() string {
92100
}
93101

94102
// glyph returns the leading status indicator: bold-green ✔ when valid,
95-
// bold-red ✗ when validate rejects the buffer.
103+
// bold-red ✗ when validate rejects the buffer after an edit or Enter, and a
104+
// blank before that.
96105
func (m *promptModel) glyph() string {
97-
if m.validate != nil && m.validate(m.value()) != nil {
98-
return ansiBold + ansiRed + glyphInvalid + ansiReset
106+
if m.validate == nil || m.validate(m.value()) == nil {
107+
return ansiBold + ansiGreen + glyphValid + ansiReset
108+
}
109+
if !m.touched {
110+
return glyphBlank
99111
}
100-
return ansiBold + ansiGreen + glyphValid + ansiReset
112+
return ansiBold + ansiRed + glyphInvalid + ansiReset
101113
}
102114

103115
func (m *promptModel) Init() tea.Cmd { return nil }
@@ -115,6 +127,7 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115127

116128
case tea.KeyEnter, tea.KeyCtrlJ:
117129
// Enter sends CR, Ctrl+J sends LF; treat both as submit.
130+
m.touched = true
118131
if m.validate != nil {
119132
if err := m.validate(m.value()); err != nil {
120133
m.submitErr = err
@@ -168,10 +181,17 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
168181
if key.Type == tea.KeySpace {
169182
typed = []rune{' '}
170183
}
184+
// Some terminals send an empty bracketed paste when the clipboard is
185+
// empty. Nothing changes, so it must not count as an edit or clear the
186+
// error line.
187+
if len(typed) == 0 {
188+
return m, nil
189+
}
171190
tail := append([]rune{}, m.runes[m.cursor:]...)
172191
m.runes = append(m.runes[:m.cursor], typed...)
173192
m.runes = append(m.runes, tail...)
174193
m.cursor += len(typed)
194+
m.touched = true
175195
m.submitErr = nil
176196

177197
default:

0 commit comments

Comments
 (0)