Skip to content

Commit 3fe01f0

Browse files
cpcloudclaude
andcommitted
feat(ui): UX polish -- hints, required markers, actionable errors
- Tab-specific empty state hints guide new users (closes #232) - Undo hint shown after edits: "Saved. u to undo" (closes #233) - Required field markers with " *" suffix on all mandatory inputs (closes #235) - Onboarding nudge after first house setup with keybinding tips - Actionable LLM error messages: "start it with `ollama serve`", "pull it with `ollama pull <model>`" (closes #221, closes #222) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d9d4f4d commit 3fe01f0

File tree

6 files changed

+94
-26
lines changed

6 files changed

+94
-26
lines changed

internal/app/forms.go

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func (m *Model) startHouseForm() {
122122
form := huh.NewForm(
123123
huh.NewGroup(
124124
huh.NewInput().
125-
Title("Nickname").
125+
Title("Nickname *").
126126
Description("Ex: Primary Residence").
127127
Value(&values.Nickname).
128128
Validate(requiredText("nickname")),
@@ -210,7 +210,7 @@ func (m *Model) startProjectForm() {
210210
form := huh.NewForm(
211211
huh.NewGroup(
212212
huh.NewInput().
213-
Title("Title").
213+
Title("Title *").
214214
Value(&values.Title).
215215
Validate(requiredText("title")),
216216
huh.NewSelect[uint]().
@@ -242,7 +242,7 @@ func (m *Model) openProjectForm(values *projectFormData, options []huh.Option[ui
242242
form := huh.NewForm(
243243
huh.NewGroup(
244244
huh.NewInput().
245-
Title("Title").
245+
Title("Title *").
246246
Value(&values.Title).
247247
Validate(requiredText("title")),
248248
huh.NewSelect[uint]().
@@ -299,11 +299,11 @@ func (m *Model) startQuoteForm() error {
299299
Options(options...).
300300
Value(&values.ProjectID),
301301
huh.NewInput().
302-
Title("Vendor name").
302+
Title("Vendor name *").
303303
Value(&values.VendorName).
304304
Validate(requiredText("vendor name")),
305305
huh.NewInput().
306-
Title("Total").
306+
Title("Total *").
307307
Placeholder("3250.00").
308308
Value(&values.Total).
309309
Validate(requiredMoney("total")),
@@ -340,7 +340,7 @@ func (m *Model) openQuoteForm(values *quoteFormData, projectOpts []huh.Option[ui
340340
Options(projectOpts...).
341341
Value(&values.ProjectID),
342342
huh.NewInput().
343-
Title("Vendor name").
343+
Title("Vendor name *").
344344
Value(&values.VendorName).
345345
Validate(requiredText("vendor name")),
346346
huh.NewInput().Title("Contact name").Value(&values.ContactName),
@@ -350,7 +350,7 @@ func (m *Model) openQuoteForm(values *quoteFormData, projectOpts []huh.Option[ui
350350
).Title("Vendor"),
351351
huh.NewGroup(
352352
huh.NewInput().
353-
Title("Total").
353+
Title("Total *").
354354
Placeholder("3250.00").
355355
Value(&values.Total).
356356
Validate(requiredMoney("total")),
@@ -390,7 +390,7 @@ func (m *Model) startMaintenanceForm() {
390390
form := huh.NewForm(
391391
huh.NewGroup(
392392
huh.NewInput().
393-
Title("Item").
393+
Title("Item *").
394394
Value(&values.Name).
395395
Validate(requiredText("item")),
396396
huh.NewSelect[uint]().
@@ -433,7 +433,7 @@ func (m *Model) openMaintenanceForm(
433433
form := huh.NewForm(
434434
huh.NewGroup(
435435
huh.NewInput().
436-
Title("Item").
436+
Title("Item *").
437437
Value(&values.Name).
438438
Validate(requiredText("item")),
439439
huh.NewSelect[uint]().
@@ -473,7 +473,7 @@ func (m *Model) startApplianceForm() {
473473
form := huh.NewForm(
474474
huh.NewGroup(
475475
huh.NewInput().
476-
Title("Name").
476+
Title("Name *").
477477
Placeholder("Kitchen Refrigerator").
478478
Value(&values.Name).
479479
Validate(requiredText("name")),
@@ -497,7 +497,7 @@ func (m *Model) openApplianceForm(values *applianceFormData) {
497497
form := huh.NewForm(
498498
huh.NewGroup(
499499
huh.NewInput().
500-
Title("Name").
500+
Title("Name *").
501501
Placeholder("Kitchen Refrigerator").
502502
Value(&values.Name).
503503
Validate(requiredText("name")),
@@ -573,7 +573,7 @@ func (m *Model) startVendorForm() {
573573
form := huh.NewForm(
574574
huh.NewGroup(
575575
huh.NewInput().
576-
Title("Name").
576+
Title("Name *").
577577
Placeholder("Acme Plumbing").
578578
Value(&values.Name).
579579
Validate(requiredText("name")),
@@ -597,7 +597,7 @@ func (m *Model) openVendorForm(values *vendorFormData) {
597597
form := huh.NewForm(
598598
huh.NewGroup(
599599
huh.NewInput().
600-
Title("Name").
600+
Title("Name *").
601601
Placeholder("Acme Plumbing").
602602
Value(&values.Name).
603603
Validate(requiredText("name")),
@@ -908,7 +908,7 @@ func (m *Model) startServiceLogForm(maintenanceItemID uint) error {
908908
form := huh.NewForm(
909909
huh.NewGroup(
910910
huh.NewInput().
911-
Title("Date serviced (YYYY-MM-DD)").
911+
Title("Date serviced * (YYYY-MM-DD)").
912912
Value(&values.ServicedAt).
913913
Validate(requiredDate("date serviced")),
914914
huh.NewSelect[uint]().
@@ -940,7 +940,7 @@ func (m *Model) openServiceLogForm(
940940
form := huh.NewForm(
941941
huh.NewGroup(
942942
huh.NewInput().
943-
Title("Date serviced (YYYY-MM-DD)").
943+
Title("Date serviced * (YYYY-MM-DD)").
944944
Value(&values.ServicedAt).
945945
Validate(requiredDate("date serviced")),
946946
huh.NewSelect[uint]().
@@ -1106,7 +1106,7 @@ func (m *Model) openDatePicker(
11061106
if err := m.handleFormSubmit(); err != nil {
11071107
m.setStatusError(err.Error())
11081108
} else {
1109-
m.setStatusInfo("Saved.")
1109+
m.setStatusSaved(true) // calendar inline edits are always edits
11101110
m.reloadAfterFormSave(savedKind)
11111111
}
11121112
m.formKind = formNone

internal/app/model.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,6 +1300,8 @@ func (m *Model) statusLines() int {
13001300
}
13011301

13021302
func (m *Model) saveForm() tea.Cmd {
1303+
wasEdit := m.editID != nil
1304+
isFirstHouse := m.formKind == formHouse && !m.hasHouse
13031305
m.snapshotForUndo()
13041306
kind := m.formKind
13051307
err := m.handleFormSubmit()
@@ -1308,7 +1310,11 @@ func (m *Model) saveForm() tea.Cmd {
13081310
return nil
13091311
}
13101312
m.exitForm()
1311-
m.setStatusInfo("Saved.")
1313+
if isFirstHouse {
1314+
m.setStatusInfo("House set up. b/f to switch tabs, i to edit, ? for help")
1315+
} else {
1316+
m.setStatusSaved(wasEdit)
1317+
}
13121318
m.reloadAfterFormSave(kind)
13131319
return nil
13141320
}
@@ -1408,7 +1414,7 @@ func (m *Model) submitInlineInput() {
14081414
return
14091415
}
14101416
m.closeInlineInput()
1411-
m.setStatusInfo("Saved.")
1417+
m.setStatusSaved(true) // inline edits are always edits
14121418
m.reloadAfterFormSave(kind)
14131419
}
14141420

@@ -1439,6 +1445,16 @@ func (m *Model) setStatusInfo(text string) {
14391445
m.status = statusMsg{Text: text, Kind: statusInfo}
14401446
}
14411447

1448+
// setStatusSaved sets a "Saved." status message, appending an undo hint
1449+
// when the save was an edit (not a create).
1450+
func (m *Model) setStatusSaved(wasEdit bool) {
1451+
if wasEdit && len(m.undoStack) > 0 {
1452+
m.setStatusInfo("Saved. u to undo")
1453+
} else {
1454+
m.setStatusInfo("Saved.")
1455+
}
1456+
}
1457+
14421458
func (m *Model) setStatusError(text string) {
14431459
m.status = statusMsg{Text: text, Kind: statusError}
14441460
}

internal/app/view.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,7 @@ func (m *Model) tableView(tab *Tab) string {
900900
if tab.FilterActive && hasPins(tab) {
901901
bodyParts = append(bodyParts, m.styles.Empty.Render("No matches."))
902902
} else {
903-
bodyParts = append(bodyParts, m.styles.Empty.Render("No entries yet."))
903+
bodyParts = append(bodyParts, m.styles.Empty.Render(emptyHint(tab.Kind)))
904904
}
905905
} else {
906906
bodyParts = append(bodyParts, strings.Join(rows, "\n"))
@@ -1074,6 +1074,24 @@ func truncateLeft(s string, maxW int) string {
10741074
return ansi.TruncateLeft(s, sw-maxW+1, "…")
10751075
}
10761076

1077+
// emptyHint returns a context-aware empty state message for the given tab.
1078+
func emptyHint(kind TabKind) string {
1079+
switch kind {
1080+
case tabProjects:
1081+
return "No projects yet. Press i then a to add one."
1082+
case tabQuotes:
1083+
return "No quotes yet. Add a project first, then press i then a."
1084+
case tabMaintenance:
1085+
return "No maintenance items yet. Press i then a to add one."
1086+
case tabAppliances:
1087+
return "No appliances yet. Press i then a to add one."
1088+
case tabVendors:
1089+
return "No vendors yet. Press i then a to add one."
1090+
default:
1091+
return "No entries yet."
1092+
}
1093+
}
1094+
10771095
// wordWrap breaks text into lines of at most maxW visible columns, splitting
10781096
// on word boundaries when possible.
10791097
func wordWrap(text string, maxW int) string {

internal/app/view_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,3 +729,35 @@ func TestDeletedHintProminentWhenShowDeleted(t *testing.T) {
729729
status := m.statusView()
730730
assert.Contains(t, status, "deleted")
731731
}
732+
733+
func TestEmptyHintPerTab(t *testing.T) {
734+
tests := []struct {
735+
kind TabKind
736+
want string
737+
}{
738+
{tabProjects, "No projects yet"},
739+
{tabQuotes, "No quotes yet"},
740+
{tabMaintenance, "No maintenance items yet"},
741+
{tabAppliances, "No appliances yet"},
742+
{tabVendors, "No vendors yet"},
743+
}
744+
for _, tt := range tests {
745+
hint := emptyHint(tt.kind)
746+
assert.Contains(t, hint, tt.want)
747+
assert.Contains(t, hint, "i then a", "should contain add instruction")
748+
}
749+
}
750+
751+
func TestSetStatusSavedWithUndo(t *testing.T) {
752+
m := newTestModel()
753+
m.undoStack = append(m.undoStack, undoEntry{Description: "test"})
754+
m.setStatusSaved(true)
755+
assert.Contains(t, m.status.Text, "u to undo")
756+
}
757+
758+
func TestSetStatusSavedNoUndo(t *testing.T) {
759+
m := newTestModel()
760+
m.setStatusSaved(false)
761+
assert.Equal(t, "Saved.", m.status.Text)
762+
assert.NotContains(t, m.status.Text, "undo")
763+
}

internal/llm/client.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func (c *Client) ListModels(ctx context.Context) ([]string, error) {
112112
resp, err := c.http.Do(req)
113113
if err != nil {
114114
return nil, fmt.Errorf(
115-
"cannot reach %s -- is the inference server running?",
115+
"cannot reach %s -- start it with `ollama serve`",
116116
c.baseURL,
117117
)
118118
}
@@ -197,7 +197,7 @@ func (c *Client) PullModel(ctx context.Context, model string) (*PullScanner, err
197197
resp, err := c.http.Do(req)
198198
if err != nil {
199199
return nil, fmt.Errorf(
200-
"cannot reach %s -- is the inference server running?",
200+
"cannot reach %s -- start it with `ollama serve`",
201201
ollamaBase,
202202
)
203203
}
@@ -229,7 +229,7 @@ func (c *Client) Ping(ctx context.Context) error {
229229
resp, err := c.http.Do(req)
230230
if err != nil {
231231
return fmt.Errorf(
232-
"cannot reach %s -- is the inference server running?",
232+
"cannot reach %s -- start it with `ollama serve`",
233233
c.baseURL,
234234
)
235235
}
@@ -252,8 +252,8 @@ func (c *Client) Ping(ctx context.Context) error {
252252
}
253253
}
254254
return fmt.Errorf(
255-
"model %q not found at %s -- is it pulled/loaded?",
256-
c.model, c.baseURL,
255+
"model %q not found -- pull it with `ollama pull %s`",
256+
c.model, c.model,
257257
)
258258
}
259259

@@ -288,7 +288,7 @@ func (c *Client) ChatComplete(
288288
resp, err := c.http.Do(req)
289289
if err != nil {
290290
return "", fmt.Errorf(
291-
"cannot reach %s -- is the inference server running?",
291+
"cannot reach %s -- start it with `ollama serve`",
292292
c.baseURL,
293293
)
294294
}
@@ -340,7 +340,7 @@ func (c *Client) ChatStream(
340340
resp, err := c.http.Do(req)
341341
if err != nil {
342342
return nil, fmt.Errorf(
343-
"cannot reach %s -- is the inference server running?",
343+
"cannot reach %s -- start it with `ollama serve`",
344344
c.baseURL,
345345
)
346346
}

internal/llm/client_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ func TestPingModelNotFound(t *testing.T) {
3636
err := client.Ping(context.Background())
3737
assert.Error(t, err)
3838
assert.Contains(t, err.Error(), "not found")
39+
assert.Contains(t, err.Error(), "ollama pull", "should include actionable remediation")
3940
}
4041

4142
func TestPingServerDown(t *testing.T) {
4243
client := NewClient("http://127.0.0.1:1", "qwen3")
4344
err := client.Ping(context.Background())
4445
assert.Error(t, err)
4546
assert.Contains(t, err.Error(), "cannot reach")
47+
assert.Contains(t, err.Error(), "ollama serve", "should include actionable remediation")
4648
}
4749

4850
func TestChatStreamSuccess(t *testing.T) {

0 commit comments

Comments
 (0)