From 3eca8f5376dce3796c24580b30f000800e20b619 Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Sun, 9 Aug 2026 02:09:42 +0200 Subject: [PATCH] Advertise the rule screens in the key help The bottom help still described 4 as "rules" from before the rules list existed, and never mentioned 5 at all, so the new screen was unreachable unless you already knew about it. The transactions line had also outgrown the window: at 137 characters it ran past the edge of a normal terminal and "q quit" was simply gone. Help now wraps to the window width instead of being cut off, and that line is shorter. Co-Authored-By: Claude Opus 5 --- cmd/money/main.go | 2 +- internal/tui/tui.go | 17 ++++++++++---- internal/tui/tui_test.go | 49 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/cmd/money/main.go b/cmd/money/main.go index 44b79d3..d077ba0 100644 --- a/cmd/money/main.go +++ b/cmd/money/main.go @@ -27,7 +27,7 @@ const usage = `money - statement-driven personal finance tracker usage: money [--root DIR] [flags] commands: - tui browse and tag transactions (default) + tui browse and tag transactions, build and prune rules (default) import extract transactions from every statement into the index retag re-apply rules.toml to everything already imported ls list transactions diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0e6962a..abdf119 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1111,7 +1111,14 @@ func (m *Model) View() string { b.WriteString(statusStyle.Render(m.status)) } b.WriteString("\n") - b.WriteString(helpStyle.Render(m.help())) + // Wrap the key list rather than letting the terminal cut it off: the keys + // at the end are as important as the ones at the start, and "q quit" was + // the first casualty on a narrow window. + help := helpStyle + if m.width > 0 { + help = help.Width(m.width) + } + b.WriteString(help.Render(m.help())) return b.String() } @@ -1252,17 +1259,17 @@ func (m *Model) help() string { } switch m.view { case viewAccounts: - return "enter open · 2 transactions · 3 report · 4 rules · i import · r retag · q quit" + return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · i import · r retag · q quit" case viewRules: return "tab/↑↓ field · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit" case viewRuleList: if m.confirm != confirmNone { return "y confirm · any other key cancels" } - return "d delete rule · p prune all unused · r refresh counts · 4 rule builder · esc back · q quit" + return "d delete rule · p prune all unused · r refresh counts · 4 new rule · 1 accounts · esc back · q quit" case viewReport: - return "1 accounts · 2 transactions · 4 rules · u untagged · a all accounts · q quit" + return "1 accounts · 2 transactions · 4 new rule · 5 rules · u untagged · a all accounts · q quit" default: - return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 rules · 1 accounts · 3 report · q quit" + return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 new rule · 5 rules · q quit" } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 08eff12..2724227 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -840,6 +840,55 @@ func TestRuleListPruneWithNothingToDo(t *testing.T) { } } +// Every screen must advertise how to reach the others and how to quit. These +// lines drift as screens are added, and a line that outgrows the terminal +// loses its tail silently. +func TestHelpLinesMentionEveryScreen(t *testing.T) { + m, _ := newTestModel(t) + + for _, tc := range []struct { + name string + key string + want []string + }{ + {"accounts", "1", []string{"4 new rule", "5 rules", "q quit"}}, + {"transactions", "2", []string{"4 new rule", "5 rules", "q quit"}}, + {"report", "3", []string{"4 new rule", "5 rules", "q quit"}}, + {"rules", "5", []string{"d delete rule", "p prune all unused", "4 new rule", "q quit"}}, + } { + key(t, m, tc.key) + help := m.help() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Errorf("%s help = %q, missing %q", tc.name, help, want) + } + } + } + + // The rule builder is a form, so it advertises its own keys instead. + key(t, m, "4") + if help := m.help(); !strings.Contains(help, "enter save rule") || !strings.Contains(help, "esc back") { + t.Errorf("rule builder help = %q", help) + } +} + +// A help line longer than the window must wrap, not be cut off. +func TestHelpWrapsOnNarrowTerminals(t *testing.T) { + m, _ := newTestModel(t) + key(t, m, "2") + m.Update(tea.WindowSizeMsg{Width: 90, Height: 30}) + + view := m.View() + if !strings.Contains(view, "q quit") { + t.Errorf("the end of the help line was lost at 90 columns:\n%s", view) + } + for _, line := range strings.Split(view, "\n") { + if len([]rune(line)) > 90 { + t.Errorf("line is %d columns wide, wider than the window:\n%q", len([]rune(line)), line) + } + } +} + // newEmptyModel builds a model over an index with nothing in it. func newEmptyModel(t *testing.T, accounts []*config.Account) *Model { t.Helper()