Add a rule builder screen with a live glob preview

Writing a glob by hand meant guessing what it would catch, then running retag
to find out. The new screen, on 4, puts the glob, account and tag fields on
the left and every still-untagged description on the right, sorted
alphabetically and grouped by description with an occurrence count.

Matches are marked as the glob is typed, along with a count, so the effect of
a rule is visible before it is written. Enter appends it to rules.toml via
config.AppendRule, reloads the engine from disk and retags, so the rows it
caught leave the list immediately.

Rules are appended rather than prepended, keeping the precedence of anything
already in the file. Since the preview only lists transactions no existing
rule has tagged, it reflects that precedence for free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 01:41:24 +02:00
co-authored by Claude Opus 5
parent 3ba26cadae
commit aeca203855
6 changed files with 803 additions and 5 deletions
+342 -4
View File
@@ -4,6 +4,7 @@ package tui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/spinner"
@@ -13,6 +14,7 @@ import (
"github.com/charmbracelet/lipgloss"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/glob"
"git.petrovv.com/nikola/money/internal/importer"
"git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/report"
@@ -26,6 +28,9 @@ const (
viewAccounts view = iota
viewTxns
viewReport
viewRules
viewCount = 4
)
// input is the modal state: the transaction list is read-only until the user
@@ -60,6 +65,17 @@ type Model struct {
txns []model.Transaction // rows currently shown in txnTable
// Rule builder: three inputs on the left, and a live preview on the right
// of which untagged descriptions the glob would catch.
ruleGlob textinput.Model
ruleAccount textinput.Model
ruleTag textinput.Model
ruleFocus int // which of the three inputs has the cursor
ruleTable table.Model
ruleReturn view // the view to go back to on esc
untagged []descGroup
ruleMatches int // untagged descriptions the current glob matches
filter store.Filter
onlyUntagged bool
@@ -75,6 +91,19 @@ var (
errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1)
helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
emptyStyle = lipgloss.NewStyle().Padding(1, 2)
labelStyle = lipgloss.NewStyle().Faint(true)
focusedLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62"))
// Both boxes are pinned to the same width: a focused input renders one
// extra cell for the cursor, which would otherwise shift the border as
// the cursor moves between fields.
boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")).Padding(0, 1).Width(ruleInputWidth + 2)
focusedBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).Padding(0, 1).Width(ruleInputWidth + 2)
hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2)
matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2)
ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth)
)
// Run starts the interface.
@@ -93,6 +122,15 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
ti.Prompt = ""
ti.CharLimit = 64
newInput := func(placeholder string) textinput.Model {
in := textinput.New()
in.Prompt = ""
in.CharLimit = 80
in.Width = ruleInputWidth
in.Placeholder = placeholder
return in
}
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
@@ -128,6 +166,14 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
{Title: "T", Width: 1},
{Title: "Description", Width: 40},
}),
ruleGlob: newInput("*LIDL*"),
ruleAccount: newInput("blank = every account"),
ruleTag: newInput("groceries"),
ruleTable: newTable([]table.Column{
{Title: " ", Width: 1},
{Title: "Untagged description", Width: 44},
{Title: "N", Width: 4},
}),
reportTable: newTable([]table.Column{
{Title: "Tag", Width: 20},
{Title: "Cur", Width: 4},
@@ -236,6 +282,196 @@ func (m *Model) reloadReport(txns []model.Transaction) {
m.reportTable.SetRows(out)
}
// ruleFormWidth is the width of the rule builder's left-hand form, and
// ruleInputWidth the editable area inside its boxes. The form must stay wider
// than a box plus its border, padding and the block cursor, or lipgloss wraps
// the border and the panel falls apart.
const (
ruleInputWidth = 26
ruleFormWidth = ruleInputWidth + 12
)
// descGroup is one distinct untagged description and how often it occurs.
// Grouping matters: a statement holds the same payee dozens of times, and a
// rule is written against the description, not against individual rows.
type descGroup struct {
Description string
Accounts map[string]bool
Count int
}
// reloadUntagged rebuilds the alphabetical list of untagged descriptions that
// the rule builder previews against.
func (m *Model) reloadUntagged() error {
txns, err := m.db.Transactions(store.Filter{Untagged: true})
if err != nil {
return err
}
byDesc := map[string]*descGroup{}
for _, t := range txns {
key := model.NormalizeDescription(t.Description)
g, ok := byDesc[key]
if !ok {
g = &descGroup{Description: t.Description, Accounts: map[string]bool{}}
byDesc[key] = g
}
g.Accounts[t.AccountSlug] = true
g.Count++
}
m.untagged = make([]descGroup, 0, len(byDesc))
for _, g := range byDesc {
m.untagged = append(m.untagged, *g)
}
sort.Slice(m.untagged, func(i, j int) bool {
a := model.NormalizeDescription(m.untagged[i].Description)
b := model.NormalizeDescription(m.untagged[j].Description)
if a != b {
return a < b
}
return m.untagged[i].Description < m.untagged[j].Description
})
m.refreshRulePreview()
return nil
}
// refreshRulePreview re-marks the preview against whatever is typed right now.
// It runs on every keystroke, so the glob is checked against descriptions only,
// never against the database.
func (m *Model) refreshRulePreview() {
var (
pattern = strings.TrimSpace(m.ruleGlob.Value())
account = strings.TrimSpace(m.ruleAccount.Value())
rows = make([]table.Row, 0, len(m.untagged))
)
m.ruleMatches = 0
for _, g := range m.untagged {
// An account filter narrows the preview the same way the saved rule
// will narrow its matching.
if account != "" && !g.Accounts[account] {
continue
}
marker := " "
if pattern != "" && glob.Match(pattern, model.NormalizeDescription(g.Description)) {
marker = "▸"
m.ruleMatches++
}
rows = append(rows, table.Row{marker, g.Description, fmt.Sprintf("%d", g.Count)})
}
cursor := m.ruleTable.Cursor()
m.ruleTable.SetRows(rows)
if cursor >= len(rows) {
cursor = len(rows) - 1
}
if cursor < 0 {
cursor = 0
}
m.ruleTable.SetCursor(cursor)
}
// saveRule appends the composed rule to rules.toml, reloads the engine and
// retags, so the effect is visible immediately.
func (m *Model) saveRule() error {
r := config.Rule{
Match: strings.TrimSpace(m.ruleGlob.Value()),
Account: strings.TrimSpace(m.ruleAccount.Value()),
Tag: strings.TrimSpace(m.ruleTag.Value()),
}
if r.Match == "" {
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
}
if r.Tag == "" {
return fmt.Errorf("enter a tag to apply")
}
if r.Account != "" && !m.knownAccount(r.Account) {
return fmt.Errorf("no account called %q; leave it blank to apply to every account", r.Account)
}
if err := config.AppendRule(m.root, r); err != nil {
return err
}
// Re-read the file rather than appending to the in-memory engine, so what
// runs is exactly what is now on disk.
loaded, err := config.LoadRules(m.root)
if err != nil {
return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err)
}
m.engine = rules.New(loaded)
n, err := m.engine.Retag(m.db)
if err != nil {
return err
}
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
m.ruleGlob.SetValue("")
m.ruleTag.SetValue("")
m.setRuleFocus(0)
return m.reload()
}
func (m *Model) knownAccount(slug string) bool {
for _, a := range m.accounts {
if a.Slug == slug {
return true
}
}
// Fall back to the index, so accounts work even when the caller passed no
// config (as the tests do).
accounts, err := m.db.Accounts()
if err != nil {
return false
}
for _, a := range accounts {
if a.Slug == slug {
return true
}
}
return false
}
// openRuleBuilder switches to the rule builder, remembering where to return to
// and seeding the account field from whatever account is being browsed.
func (m *Model) openRuleBuilder() tea.Cmd {
if m.view != viewRules {
m.ruleReturn = m.view
}
m.view = viewRules
if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" {
m.ruleAccount.SetValue(m.filter.AccountSlug)
}
m.setRuleFocus(0)
if err := m.reloadUntagged(); err != nil {
m.err = err
}
return textinput.Blink
}
// ruleInputs lists the form fields in tab order.
func (m *Model) ruleInputs() []*textinput.Model {
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag}
}
// setRuleFocus moves the cursor between the form fields, wrapping around.
func (m *Model) setRuleFocus(i int) {
inputs := m.ruleInputs()
n := len(inputs)
m.ruleFocus = ((i % n) + n) % n
for j, in := range inputs {
if j == m.ruleFocus {
in.Focus()
in.CursorEnd()
continue
}
in.Blur()
}
}
// selected returns the transaction under the cursor, if any.
func (m *Model) selected() (model.Transaction, bool) {
i := m.txnTable.Cursor()
@@ -284,11 +520,55 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.input != inputNone {
return m.updateInput(msg)
}
// The rule builder is a form: every printable key belongs to the
// focused input, so the global single-letter keymap cannot apply.
if m.view == viewRules {
return m.updateRules(msg)
}
return m.updateNormal(msg)
}
return m, nil
}
// updateRules drives the rule builder form.
func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.view = m.ruleReturn
return m, nil
case "tab", "down":
m.setRuleFocus(m.ruleFocus + 1)
return m, nil
case "shift+tab", "up":
m.setRuleFocus(m.ruleFocus - 1)
return m, nil
case "pgdown":
m.ruleTable.MoveDown(10)
return m, nil
case "pgup":
m.ruleTable.MoveUp(10)
return m, nil
case "enter":
m.err = nil
if err := m.saveRule(); err != nil {
m.err = err
return m, nil
}
if err := m.reloadUntagged(); err != nil {
m.err = err
}
return m, nil
}
var cmd tea.Cmd
inputs := m.ruleInputs()
*inputs[m.ruleFocus], cmd = inputs[m.ruleFocus].Update(msg)
m.refreshRulePreview()
return m, cmd
}
func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) {
m.importing = false
if msg.err != nil {
@@ -339,6 +619,18 @@ func (m *Model) resize() {
m.accountTable.SetHeight(h)
m.txnTable.SetHeight(h)
m.reportTable.SetHeight(h)
m.ruleTable.SetHeight(h)
// The preview list gets whatever the form does not use.
if m.width > 0 {
cols := m.ruleTable.Columns()
desc := m.width - ruleFormWidth - cols[0].Width - cols[2].Width - 10
if desc < 20 {
desc = 20
}
cols[1].Width = desc
m.ruleTable.SetColumns(cols)
}
// Give the description column whatever is left over.
if m.width > 0 {
@@ -417,8 +709,13 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "3":
m.view = viewReport
return m, nil
case "4":
return m, m.openRuleBuilder()
case "tab":
m.view = (m.view + 1) % 3
m.view = (m.view + 1) % viewCount
if m.view == viewRules {
return m, m.openRuleBuilder()
}
return m, nil
case "enter":
@@ -572,6 +869,8 @@ func (m *Model) View() string {
b.WriteString(m.txnTable.View())
case viewReport:
b.WriteString(m.reportTable.View())
case viewRules:
b.WriteString(m.rulesView())
}
}
b.WriteString("\n")
@@ -593,6 +892,41 @@ func (m *Model) View() string {
return b.String()
}
// rulesView puts the form on the left and the live preview on the right.
func (m *Model) rulesView() string {
return lipgloss.JoinHorizontal(lipgloss.Top, m.ruleFormView(), m.ruleTable.View())
}
func (m *Model) ruleFormView() string {
field := func(i int, label, help string) string {
name := labelStyle.Render(" " + label)
box := boxStyle.Render(m.ruleInputs()[i].View())
if i == m.ruleFocus {
name = focusedLabelStyle.Render("▸ " + label)
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
}
return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n"
}
var b strings.Builder
b.WriteString(field(0, "glob", "vs. the description"))
b.WriteString("\n")
b.WriteString(field(1, "account", "blank = all accounts"))
b.WriteString("\n")
b.WriteString(field(2, "tag", "applied to matches"))
b.WriteString("\n")
// The count is the whole point of the preview: it says what the rule will
// do before it is written to disk.
summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, len(m.ruleTable.Rows()))
if strings.TrimSpace(m.ruleGlob.Value()) == "" {
summary = fmt.Sprintf("%d untagged descriptions", len(m.ruleTable.Rows()))
}
b.WriteString(matchCountStyle.Render(summary))
return ruleFormStyle.Render(b.String())
}
// emptyMessage explains an empty view and names the key that fixes it, or
// returns "" when there is something to show.
func (m *Model) emptyMessage() string {
@@ -656,6 +990,8 @@ func (m *Model) title() string {
switch m.view {
case viewAccounts:
return "money · accounts"
case viewRules:
return "money · rule builder · writes to rules.toml"
case viewReport:
return "money · report · " + scope + " · transfers excluded"
default:
@@ -681,10 +1017,12 @@ func (m *Model) help() string {
}
switch m.view {
case viewAccounts:
return "enter open · 2 transactions · 3 report · i import · r retag · q quit"
return "enter open · 2 transactions · 3 report · 4 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 viewReport:
return "1 accounts · 2 transactions · u untagged · a all accounts · q quit"
return "1 accounts · 2 transactions · 4 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 · 1 accounts · 3 report · q quit"
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 rules · 1 accounts · 3 report · q quit"
}
}
+274
View File
@@ -2,6 +2,8 @@ package tui
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -380,6 +382,278 @@ func TestImportWarningsSurface(t *testing.T) {
}
}
// newRuleModel builds a model whose data root is a real directory, so the rule
// builder can actually write rules.toml.
func newRuleModel(t *testing.T) (*Model, *store.DB, string) {
t.Helper()
root := t.TempDir()
db, err := store.Open(filepath.Join(root, ".money", "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
accountID, err := db.UpsertAccount(model.Account{
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
})
if err != nil {
t.Fatal(err)
}
savingsID, err := db.UpsertAccount(model.Account{
Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2,
})
if err != nil {
t.Fatal(err)
}
sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
// Deliberately unsorted, with LIDL appearing twice so the preview has
// something to group.
seed := []struct {
account int64
desc string
}{
{accountID, "ZZZ LAST ALPHABETICALLY"},
{accountID, "LIDL SOFIA 4412"},
{accountID, "LIDL SOFIA 4412"},
{accountID, "AAA FIRST ALPHABETICALLY"},
{savingsID, "LIDL VARNA 9911"},
}
for i, s := range seed {
if _, err := db.InsertTransaction(model.Transaction{
AccountID: s.account,
SourceFileID: sourceID,
Fingerprint: fmt.Sprintf("fp-%d", i),
Date: "2026-01-01",
Description: s.desc,
AmountMinor: -1000,
}); err != nil {
t.Fatal(err)
}
}
m := New(root, db, []*config.Account{
{Slug: "checking", Currency: "EUR", Parser: "csv"},
{Slug: "savings", Currency: "EUR", Parser: "csv"},
}, rules.New(&config.Rules{}))
if err := m.reload(); err != nil {
t.Fatal(err)
}
m.Update(tea.WindowSizeMsg{Width: 140, Height: 30})
return m, db, root
}
// descriptions returns the preview list as it is rendered, in order.
func previewRows(m *Model) (all []string, matched []string) {
for _, row := range m.ruleTable.Rows() {
all = append(all, row[1])
if row[0] == "▸" {
matched = append(matched, row[1])
}
}
return all, matched
}
func TestRuleBuilderListsUntaggedAlphabetically(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
if m.view != viewRules {
t.Fatal("expected 4 to open the rule builder")
}
all, _ := previewRows(m)
want := []string{
"AAA FIRST ALPHABETICALLY",
"LIDL SOFIA 4412",
"LIDL VARNA 9911",
"ZZZ LAST ALPHABETICALLY",
}
if len(all) != len(want) {
t.Fatalf("preview = %v, want %v (identical descriptions must be grouped)", all, want)
}
for i := range want {
if all[i] != want[i] {
t.Errorf("row %d = %q, want %q", i, all[i], want[i])
}
}
// The repeated LIDL row carries its occurrence count.
for _, row := range m.ruleTable.Rows() {
if row[1] == "LIDL SOFIA 4412" && row[2] != "2" {
t.Errorf("count for the repeated description = %q, want 2", row[2])
}
}
}
func TestRuleBuilderPreviewsGlobAsYouType(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
if _, matched := previewRows(m); len(matched) != 0 {
t.Errorf("nothing should match before a glob is typed, got %v", matched)
}
typeText(t, m, "*LIDL*")
_, matched := previewRows(m)
if len(matched) != 2 || m.ruleMatches != 2 {
t.Fatalf("matched = %v (count %d), want both LIDL rows", matched, m.ruleMatches)
}
// Narrowing the glob narrows the preview.
typeText(t, m, "\b") // not a backspace key; replace the value instead
m.ruleGlob.SetValue("*SOFIA*")
m.refreshRulePreview()
if _, matched := previewRows(m); len(matched) != 1 || matched[0] != "LIDL SOFIA 4412" {
t.Errorf("matched = %v, want only the Sofia row", matched)
}
}
// The account field scopes the preview the same way it will scope the rule.
func TestRuleBuilderAccountFiltersPreview(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
m.ruleGlob.SetValue("*LIDL*")
m.ruleAccount.SetValue("savings")
m.refreshRulePreview()
all, matched := previewRows(m)
if len(all) != 1 || all[0] != "LIDL VARNA 9911" {
t.Errorf("preview = %v, want only the savings row", all)
}
if len(matched) != 1 {
t.Errorf("matched = %v, want the savings row to match", matched)
}
}
// Typing must never reach the global keymap: q, i and 1 are ordinary text here.
func TestRuleBuilderSwallowsGlobalKeys(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
typeText(t, m, "q1i")
if m.view != viewRules {
t.Fatal("typing must not switch views")
}
if got := m.ruleGlob.Value(); got != "q1i" {
t.Errorf("glob = %q, want the typed characters", got)
}
if m.importing {
t.Error("typing i must not start an import")
}
}
func TestRuleBuilderSavesRuleAndRetags(t *testing.T) {
m, db, root := newRuleModel(t)
key(t, m, "4")
m.ruleGlob.SetValue("*LIDL*")
m.setRuleFocus(2)
m.ruleTag.SetValue("groceries")
key(t, m, "enter")
if m.err != nil {
t.Fatalf("saving failed: %v", m.err)
}
// It landed in rules.toml...
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" {
t.Fatalf("rules.toml holds %+v", loaded.Rule)
}
// ...and was applied straight away.
txns, err := db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
tagged := 0
for _, txn := range txns {
if txn.Tag() == "groceries" {
tagged++
}
}
if tagged != 3 {
t.Errorf("%d transactions tagged, want 3", tagged)
}
if !strings.Contains(m.status, "3 transactions retagged") {
t.Errorf("status = %q, want the retag count", m.status)
}
// Those rows are no longer untagged, so the preview drops them.
all, _ := previewRows(m)
for _, d := range all {
if strings.Contains(d, "LIDL") {
t.Errorf("preview still lists %q after it was tagged", d)
}
}
// The glob and tag are cleared, ready for the next rule.
if m.ruleGlob.Value() != "" || m.ruleTag.Value() != "" {
t.Errorf("form not reset: glob=%q tag=%q", m.ruleGlob.Value(), m.ruleTag.Value())
}
}
func TestRuleBuilderRejectsIncompleteRule(t *testing.T) {
m, _, root := newRuleModel(t)
key(t, m, "4")
// No glob, no tag.
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if m.err == nil {
t.Error("expected an error when saving an empty form")
}
// Glob but no tag.
m.err = nil
m.ruleGlob.SetValue("*LIDL*")
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if m.err == nil || !strings.Contains(m.err.Error(), "tag") {
t.Errorf("err = %v, want a complaint about the missing tag", m.err)
}
// An account that does not exist is caught before anything is written.
m.err = nil
m.ruleTag.SetValue("groceries")
m.ruleAccount.SetValue("nosuchaccount")
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") {
t.Errorf("err = %v, want a complaint about the unknown account", m.err)
}
if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) {
t.Error("a rejected rule must not write rules.toml")
}
}
func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "2") // come from the transactions view
key(t, m, "4")
for i, want := range []int{1, 2, 0} {
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleFocus != want {
t.Errorf("tab %d moved focus to %d, want %d", i+1, m.ruleFocus, want)
}
}
if !m.ruleGlob.Focused() {
t.Error("expected the glob input to be focused after wrapping around")
}
m.Update(tea.KeyMsg{Type: tea.KeyEsc})
if m.view != viewTxns {
t.Errorf("esc returned to view %d, want the transactions view it came from", m.view)
}
}
// newEmptyModel builds a model over an index with nothing in it.
func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
t.Helper()