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
+144 -17
View File
@@ -4,6 +4,7 @@ package tui
import (
"fmt"
"slices"
"sort"
"strings"
@@ -149,6 +150,14 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
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 = spinner.Dot
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},
}),
ruleGlob: newInput("*LIDL*"),
ruleAccount: newInput("blank = every account"),
ruleTag: newInput("groceries"),
ruleAccount: completing(newInput("blank = every account")),
ruleTag: completing(newInput("groceries")),
ruleTable: newTable([]table.Column{
{Title: " ", Width: 1},
{Title: "Untagged description", Width: 44},
@@ -439,27 +448,106 @@ func (m *Model) saveRule() error {
m.ruleGlob.SetValue("")
m.ruleTag.SetValue("")
m.setRuleFocus(0)
if err := m.reloadSuggestions(); err != nil {
return err
}
return m.reload()
}
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 {
if a.Slug == slug {
return true
configured = append(configured, a.Slug)
}
var imported []string
if accounts, err := m.db.Accounts(); err == nil {
for _, a := range accounts {
imported = append(imported, a.Slug)
}
}
// Fall back to the index, so accounts work even when the caller passed no
// config (as the tests do).
accounts, err := m.db.Accounts()
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
}
for _, a := range accounts {
if a.Slug == slug {
return true
}
}
return false
in.SetValue(s)
in.CursorEnd()
in.SetSuggestions(in.AvailableSuggestions())
return true
}
// reloadRuleList counts, for every rule, how many transactions it actually
@@ -657,6 +745,9 @@ func (m *Model) openRuleBuilder() tea.Cmd {
m.ruleAccount.SetValue(m.filter.AccountSlug)
}
m.setRuleFocus(0)
if err := m.reloadSuggestions(); err != nil {
m.err = err
}
if err := m.reloadUntagged(); err != nil {
m.err = err
}
@@ -752,12 +843,31 @@ func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "esc":
m.view = m.ruleReturn
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)
return m, nil
case "shift+tab", "up":
m.setRuleFocus(m.ruleFocus - 1)
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":
m.ruleTable.MoveDown(10)
return m, nil
@@ -1141,9 +1251,9 @@ func (m *Model) ruleFormView() string {
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(field(1, "account", m.completionHint(1, "blank = all accounts")))
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")
// 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())
}
// 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
// returns "" when there is something to show.
func (m *Model) emptyMessage() string {
@@ -1261,7 +1388,7 @@ func (m *Model) help() string {
case viewAccounts:
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"
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:
if m.confirm != confirmNone {
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,
// one shadowed rule and one that matches nothing.
func newRuleListModel(t *testing.T) (*Model, string) {