Complete account and tag names in the rule builder

Both fields are free text, and a slug or tag that is slightly wrong produces
a rule that silently catches nothing — or a second, near-identical tag. They
now complete against what already exists: accounts from the folders on disk
and the index, tags from every tag in use plus any named in rules.toml, so a
tag is completable from the moment a rule mentions it.

The completion is ghosted after the cursor and never committed until it is
accepted, so inventing a new tag still works. tab takes it and moves focus
only when there is nothing left to complete; ctrl+n/ctrl+p cycle an ambiguous
prefix, and the hint under the box says how many candidates are left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 23:06:31 +02:00
co-authored by Claude Opus 5
parent 3eca8f5376
commit 73fefea96d
4 changed files with 305 additions and 18 deletions
+9
View File
@@ -55,6 +55,15 @@ rule builder cannot shadow a rule the user wrote by hand.
`viewRules`, or typing `q` would quit and `i` would start an import. Any new `viewRules`, or typing `q` would quit and `i` would start an import. Any new
full-screen input needs the same treatment. full-screen input needs the same treatment.
**In the rule builder, `tab` completes first and moves focus second.** The
account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion`
key is `tab` and whose `NextSuggestion`/`PrevSuggestion` are `up`/`down` — all
three already meant something here. So `updateRules` intercepts `tab` and calls
`acceptCompletion` before falling back to `setRuleFocus`, keeps `up`/`down` on
field navigation, and lets `ctrl+n` / `ctrl+p` through to the input for cycling.
`SetValue` does not re-match the suggestion list, so `acceptCompletion` re-sets
it afterwards or `ctrl+n` would offer candidates that no longer fit the value.
**A rule's usage count is how many transactions it wins, not how many its glob **A rule's usage count is how many transactions it wins, not how many its glob
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by an could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by an
earlier one correctly reports zero. That is what makes the rules screen able to earlier one correctly reports zero. That is what makes the rules screen able to
+18 -1
View File
@@ -88,19 +88,36 @@ a `▸` against each one the glob currently matches and a running
"*n* of *m* descriptions match" count. "*n* of *m* descriptions match" count.
``` ```
▸ glob Untagged description N glob Untagged description N
╭────────────────────────────╮ ACME PAYROLL JAN 1 ╭────────────────────────────╮ ACME PAYROLL JAN 1
│ *LIDL* │ BOLT RIDE 1 │ *LIDL* │ BOLT RIDE 1
╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2 ╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2
vs. the description ▸ LIDL VARNA 9911 1 vs. the description ▸ LIDL VARNA 9911 1
ZARA MLADOST 1 ZARA MLADOST 1
▸ tag
╭────────────────────────────╮
│ groceries │
╰────────────────────────────╯
tab completes · ctrl+n: 1 more
2 of 7 descriptions match 2 of 7 descriptions match
``` ```
Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
`tab` / `↑↓` move between the glob, account and tag fields, `pgup` / `pgdn` `tab` / `↑↓` move between the glob, account and tag fields, `pgup` / `pgdn`
scroll the list, and `enter` appends the rule to `rules.toml` and retags scroll the list, and `enter` appends the rule to `rules.toml` and retags
immediately, so the rows it caught disappear from the list. `esc` goes back. immediately, so the rows it caught disappear from the list. `esc` goes back.
The account and tag fields complete as you type: the rest of the match is
ghosted in grey after the cursor, and `tab` (or `→` at the end of the line)
takes it. When several candidates share the prefix, the hint under the box says
how many, and `ctrl+n` / `ctrl+p` cycle through them. Nothing is committed until
you accept it, so a new tag is still just typed out in full. Accounts come from
the folders on disk and the index; tags from every tag in use plus any named in
`rules.toml`, so a tag is completable from the moment a rule mentions it —
which is what stops `groceries` acquiring a `grocery` twin.
Leaving the account blank applies the rule everywhere; filling it in also Leaving the account blank applies the rule everywhere; filling it in also
narrows the preview to that account. Rules are appended, so anything already in narrows the preview to that account. Rules are appended, so anything already in
`rules.toml` keeps precedence — the preview accounts for that automatically, `rules.toml` keeps precedence — the preview accounts for that automatically,
+144 -17
View File
@@ -4,6 +4,7 @@ package tui
import ( import (
"fmt" "fmt"
"slices"
"sort" "sort"
"strings" "strings"
@@ -149,6 +150,14 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
return in return in
} }
// Account slugs and tags are typed over and over, and a typo in either
// silently produces a rule that catches nothing, so both fields complete
// against what already exists.
completing := func(in textinput.Model) textinput.Model {
in.ShowSuggestions = true
return in
}
sp := spinner.New() sp := spinner.New()
sp.Spinner = spinner.Dot sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62")) sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
@@ -185,8 +194,8 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
{Title: "Description", Width: 40}, {Title: "Description", Width: 40},
}), }),
ruleGlob: newInput("*LIDL*"), ruleGlob: newInput("*LIDL*"),
ruleAccount: newInput("blank = every account"), ruleAccount: completing(newInput("blank = every account")),
ruleTag: newInput("groceries"), ruleTag: completing(newInput("groceries")),
ruleTable: newTable([]table.Column{ ruleTable: newTable([]table.Column{
{Title: " ", Width: 1}, {Title: " ", Width: 1},
{Title: "Untagged description", Width: 44}, {Title: "Untagged description", Width: 44},
@@ -439,28 +448,107 @@ func (m *Model) saveRule() error {
m.ruleGlob.SetValue("") m.ruleGlob.SetValue("")
m.ruleTag.SetValue("") m.ruleTag.SetValue("")
m.setRuleFocus(0) m.setRuleFocus(0)
if err := m.reloadSuggestions(); err != nil {
return err
}
return m.reload() return m.reload()
} }
func (m *Model) knownAccount(slug string) bool { func (m *Model) knownAccount(slug string) bool {
return slices.Contains(m.accountSlugs(), slug)
}
// accountSlugs lists every account slug worth completing: those configured on
// disk, plus those the index already holds so accounts still resolve when the
// caller passed no config (as the tests do).
func (m *Model) accountSlugs() []string {
configured := make([]string, 0, len(m.accounts))
for _, a := range m.accounts { for _, a := range m.accounts {
if a.Slug == slug { configured = append(configured, a.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
} }
var imported []string
if accounts, err := m.db.Accounts(); err == nil {
for _, a := range accounts { for _, a := range accounts {
if a.Slug == slug { imported = append(imported, a.Slug)
return true
} }
} }
return sortedSet(configured, imported)
}
// knownTags lists the tags in use in the index plus those only named by a rule.
// A rule that has not caught anything yet is exactly where a tag lives before
// it appears on a transaction, and those are the ones most likely to be
// mistyped into a second, near-identical tag.
func (m *Model) knownTags() ([]string, error) {
tagged, err := m.db.Tags()
if err != nil {
return nil, err
}
rs := m.engine.Rules()
fromRules := make([]string, 0, len(rs))
for _, r := range rs {
fromRules = append(fromRules, r.Tag)
}
return sortedSet(tagged, fromRules), nil
}
// sortedSet merges the groups into one sorted list with blanks and duplicates
// removed. Completion cycles in this order, so it has to be stable.
func sortedSet(groups ...[]string) []string {
seen := map[string]bool{}
var out []string
for _, g := range groups {
for _, v := range g {
if v == "" || seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
}
sort.Strings(out)
return out
}
// reloadSuggestions refreshes what the account and tag fields complete against.
// It runs whenever the builder is opened and after every save, since saving a
// rule can introduce a tag that did not exist a moment ago.
func (m *Model) reloadSuggestions() error {
m.ruleAccount.SetSuggestions(m.accountSlugs())
tags, err := m.knownTags()
if err != nil {
return err
}
m.ruleTag.SetSuggestions(tags)
return nil
}
// pendingCompletion returns the suggestion an input is currently offering, or
// "" when there is nothing left to accept. Matching is case-insensitive, so a
// suggestion differing only in case is not worth rewriting what was typed.
func pendingCompletion(in *textinput.Model) string {
s := in.CurrentSuggestion()
if s == "" || strings.EqualFold(s, in.Value()) {
return ""
}
return s
}
// acceptCompletion takes the offered completion into the focused field,
// reporting whether there was one. The suggestion list is re-set afterwards
// because SetValue does not re-match it, which would otherwise leave ctrl+n
// cycling through candidates that no longer share the new prefix.
func (m *Model) acceptCompletion() bool {
in := m.ruleInputs()[m.ruleFocus]
s := pendingCompletion(in)
if s == "" {
return false return false
} }
in.SetValue(s)
in.CursorEnd()
in.SetSuggestions(in.AvailableSuggestions())
return true
}
// reloadRuleList counts, for every rule, how many transactions it actually // reloadRuleList counts, for every rule, how many transactions it actually
// claims. A rule can match nothing because no description fits it, or because // claims. A rule can match nothing because no description fits it, or because
@@ -657,6 +745,9 @@ func (m *Model) openRuleBuilder() tea.Cmd {
m.ruleAccount.SetValue(m.filter.AccountSlug) m.ruleAccount.SetValue(m.filter.AccountSlug)
} }
m.setRuleFocus(0) m.setRuleFocus(0)
if err := m.reloadSuggestions(); err != nil {
m.err = err
}
if err := m.reloadUntagged(); err != nil { if err := m.reloadUntagged(); err != nil {
m.err = err m.err = err
} }
@@ -752,12 +843,31 @@ func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "esc": case "esc":
m.view = m.ruleReturn m.view = m.ruleReturn
return m, nil return m, nil
case "tab", "down": case "tab":
// Tab is overloaded on purpose: with a completion on offer it takes
// it, and moving on is then one more tab. Without one it does what it
// always did and moves to the next field.
if m.acceptCompletion() {
m.refreshRulePreview()
return m, nil
}
m.setRuleFocus(m.ruleFocus + 1)
return m, nil
case "down":
m.setRuleFocus(m.ruleFocus + 1) m.setRuleFocus(m.ruleFocus + 1)
return m, nil return m, nil
case "shift+tab", "up": case "shift+tab", "up":
m.setRuleFocus(m.ruleFocus - 1) m.setRuleFocus(m.ruleFocus - 1)
return m, nil return m, nil
case "right":
// At the end of the line → has nothing else to do, so it accepts the
// completion the way a shell does. Anywhere else it falls through and
// moves the cursor.
in := m.ruleInputs()[m.ruleFocus]
if in.Position() == len([]rune(in.Value())) && m.acceptCompletion() {
m.refreshRulePreview()
return m, nil
}
case "pgdown": case "pgdown":
m.ruleTable.MoveDown(10) m.ruleTable.MoveDown(10)
return m, nil return m, nil
@@ -1141,9 +1251,9 @@ func (m *Model) ruleFormView() string {
var b strings.Builder var b strings.Builder
b.WriteString(field(0, "glob", "vs. the description")) b.WriteString(field(0, "glob", "vs. the description"))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(field(1, "account", "blank = all accounts")) b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts")))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(field(2, "tag", "applied to matches")) b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches")))
b.WriteString("\n") b.WriteString("\n")
// The count is the whole point of the preview: it says what the rule will // The count is the whole point of the preview: it says what the rule will
@@ -1157,6 +1267,23 @@ func (m *Model) ruleFormView() string {
return ruleFormStyle.Render(b.String()) return ruleFormStyle.Render(b.String())
} }
// completionHint names the key that accepts the ghosted completion, replacing
// the field's usual hint while one is on offer. The hint sits directly under
// the box the ghost text appears in, which is where the question is asked.
func (m *Model) completionHint(i int, fallback string) string {
if i != m.ruleFocus {
return fallback
}
in := m.ruleInputs()[i]
if pendingCompletion(in) == "" {
return fallback
}
if n := len(in.MatchedSuggestions()); n > 1 {
return fmt.Sprintf("tab completes · ctrl+n: %d more", n-1)
}
return "tab completes"
}
// emptyMessage explains an empty view and names the key that fixes it, or // emptyMessage explains an empty view and names the key that fixes it, or
// returns "" when there is something to show. // returns "" when there is something to show.
func (m *Model) emptyMessage() string { func (m *Model) emptyMessage() string {
@@ -1261,7 +1388,7 @@ func (m *Model) help() string {
case viewAccounts: case viewAccounts:
return "enter open · 2 transactions · 3 report · 4 new rule · 5 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: case viewRules:
return "tab/↑↓ field · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit" return "tab complete/next field · ↑↓ field · ctrl+n/p other completions · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit"
case viewRuleList: case viewRuleList:
if m.confirm != confirmNone { if m.confirm != confirmNone {
return "y confirm · any other key cancels" return "y confirm · any other key cancels"
+134
View File
@@ -654,6 +654,140 @@ func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) {
} }
} }
// The account field completes against the accounts that exist, because a slug
// typed by hand only has to be slightly wrong to produce a rule that silently
// catches nothing.
func TestRuleBuilderCompletesAccount(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
m.Update(tea.KeyMsg{Type: tea.KeyTab}) // onto the account field
typeText(t, m, "sa")
// Nothing is committed until it is accepted: the value is still what was
// typed, with the rest offered as a ghost.
if m.ruleAccount.Value() != "sa" {
t.Fatalf("account = %q, want the typed text to stand until accepted", m.ruleAccount.Value())
}
if got := m.ruleAccount.CurrentSuggestion(); got != "savings" {
t.Fatalf("suggestion = %q, want savings", got)
}
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleAccount.Value() != "savings" {
t.Fatalf("account = %q after tab, want savings", m.ruleAccount.Value())
}
// Tab took the completion rather than the focus; the next one moves on.
if m.ruleFocus != 1 {
t.Errorf("focus = %d, want the account field to keep it while completing", m.ruleFocus)
}
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleFocus != 2 {
t.Errorf("focus = %d, want the tag field once there is nothing left to complete", m.ruleFocus)
}
// Accepting an account narrows the preview, exactly as typing it in full does.
if all, _ := previewRows(m); len(all) != 1 || all[0] != "LIDL VARNA 9911" {
t.Errorf("preview = %v, want only the savings row", all)
}
}
// → accepts too, at the end of the line where it has nothing else to do.
func TestRuleBuilderRightArrowAcceptsCompletion(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
m.Update(tea.KeyMsg{Type: tea.KeyTab})
typeText(t, m, "che")
m.Update(tea.KeyMsg{Type: tea.KeyRight})
if m.ruleAccount.Value() != "checking" {
t.Errorf("account = %q after →, want checking", m.ruleAccount.Value())
}
// With no completion pending it is an ordinary cursor key again.
m.ruleAccount.SetCursor(0)
m.Update(tea.KeyMsg{Type: tea.KeyRight})
if m.ruleAccount.Position() != 1 {
t.Errorf("cursor = %d, want → to move within the text once there is nothing to accept",
m.ruleAccount.Position())
}
}
// Tags come from the index and from rules.toml, so a tag is completable from
// the moment it is named in a rule, not only once something matches it.
func TestRuleBuilderCompletesTag(t *testing.T) {
m, _, _ := newRuleModel(t)
m.engine = rules.New(&config.Rules{Rule: []config.Rule{
{Match: "*NOTHING*", Tag: "groceries"},
{Match: "*ALSO NOTHING*", Tag: "grooming"},
}})
key(t, m, "4")
m.setRuleFocus(2)
typeText(t, m, "gro")
if got := m.ruleTag.CurrentSuggestion(); got != "groceries" {
t.Fatalf("suggestion = %q, want the first of the two alphabetically", got)
}
if n := len(m.ruleTag.MatchedSuggestions()); n != 2 {
t.Fatalf("%d matched suggestions, want both gro* tags", n)
}
// An ambiguous prefix is cycled, not guessed.
m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
if got := m.ruleTag.CurrentSuggestion(); got != "grooming" {
t.Fatalf("suggestion = %q after ctrl+n, want grooming", got)
}
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleTag.Value() != "grooming" {
t.Errorf("tag = %q, want the cycled-to suggestion", m.ruleTag.Value())
}
// The hint under the box names the key that accepts, and says how many
// other candidates there are.
m.ruleTag.SetValue("gro")
m.ruleTag.SetSuggestions(m.ruleTag.AvailableSuggestions())
if hint := m.completionHint(2, "applied to matches"); !strings.Contains(hint, "tab") ||
!strings.Contains(hint, "1 more") {
t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint)
}
}
// A tag invented in the builder is completable next time round.
func TestSavingARuleAddsItsTagToTheSuggestions(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
m.ruleGlob.SetValue("*LIDL*")
m.setRuleFocus(2)
m.ruleTag.SetValue("groceries")
key(t, m, "enter")
m.setRuleFocus(2) // saving resets the form to the glob field
typeText(t, m, "gro")
if got := m.ruleTag.CurrentSuggestion(); got != "groceries" {
t.Errorf("suggestion = %q, want the tag just saved", got)
}
}
// A prefix nothing matches must stay exactly as typed: the completion never
// invents a tag, and tab goes back to moving between fields.
func TestRuleBuilderKeepsUnmatchedInput(t *testing.T) {
m, _, _ := newRuleModel(t)
m.engine = rules.New(&config.Rules{Rule: []config.Rule{{Match: "*X*", Tag: "groceries"}}})
key(t, m, "4")
m.setRuleFocus(2)
typeText(t, m, "zzz")
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleTag.Value() != "zzz" {
t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value())
}
if m.ruleFocus != 0 {
t.Errorf("focus = %d, want tab to wrap round to the glob field", m.ruleFocus)
}
}
// newRuleListModel seeds a data root whose rules.toml holds one useful rule, // newRuleListModel seeds a data root whose rules.toml holds one useful rule,
// one shadowed rule and one that matches nothing. // one shadowed rule and one that matches nothing.
func newRuleListModel(t *testing.T) (*Model, string) { func newRuleListModel(t *testing.T) (*Model, string) {