RUF5K5CL542GK5UIIIBHPIMGGCXU72IWS5OFBVTI5DRX36OSPJDAC SB4FZEB6ZLUHQNM3M76OGNNJY6THOF55S6JO6Q7IGXWE7OA7INFAC 7J5R6ASXG23MYPW7HP4RHGQQQTMSCMHJZLYLCJCDRN4CVY5YVGLQC IZEEEQS5ES4AYNGZ7RE54QYUBREN6NA6PML242JR5N7EKVDCKT3AC IOGCFEJZXNXE2YP5F6Q2WXQ2EPBOA64JTQTSIZSCIF67WLF2QA6AC 22FIUBOFKBIV3WINZUSBKJO526K52HQV3V33B3DRS6CQWHU5HFPAC DBOROCRFD6A5SJBMFYFEJI5S5M77X4EFEK6KDQWA5QDMQJKIHRWQC ULWBYMSXYZEE7BJQ2B4HTX2JAABBNJWZTUFIQ56NF5RWXODXRTHQC YHDO5ELECDAMD4Q2LIFQQJA3QVEEMUP7VCUGHIH2UR6P5VKKMDYAC XLL6JFARO2VSNEOJQPXPKZLYYYF7XQGBCR2QJSIFF2RKYZKI25LAC EW7VBNMGWFBC73ZUDLB4LIK2HWFKA74ZUTUDG4J575ZQHEFHW4UQC U6JEEU5O477ZOJ5UMRMOJSGPEJEU6Q7KMPKUSDF56CYVUJWL7QBQC PNAXYHCI5CUWXLZPF5SCODY5X6OIMSHR45O2LJIF6ICSBANTUXZQC GQNMVJQBC6DRV5XGK3K5L7YWG2GJUXR7EQE3OHNW72XK6BFY3AHQC 2IURSWW3ZXRBH3DPJO437YE2FGMG54MPFL6W6UCEF5HODPVWVVTQC HHT7M27I3YKGGJOTVTMRVWXATDWUZKIVVLM7IVI7SJRB7FLT2DAQC 7SMHQHQGGCPR44NNBHAELM46EOREVGRF32YB66FYZALQSDRC3CJQC TKGASXMX2K7TH7H4PECN7LX2AYAGHYDHOVY7WFBEHFUTNKPX3MWAC TLLVARZXOP2M3B5VTLF4SYDGMIBPHABE6LJFG77IU53QTSYGTKWAC SPHUX2CTF2S2TXEO3TRHNY7NIJ42G5KEWZRHQPZWXP77SJS5WJUAC 5QRZQIBFN5KJZ4PZO22POEKLUVYYQ436NAVKGNQOMPIWKQX7CZFAC 2TDG53JBZHZA6ZPYONPINKVDV4UXLP4T4CI5C2MEZIIYO7DQE5RAC W3A2EECCD23SVHJZN6MXPH2PAVFHH5CNFD2XHPQRRW6M4GUTG3FAC }return m, nil}return m, nil}// handleGotoKey handles key presses in goto modefunc (m Model) handleGotoKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {key := msg.Key()// Enter: execute gotoif key.Code == tea.KeyEnter {if m.gotoInput == "" {m.gotoMode = falsereturn m, nil}fileNum := 0for _, ch := range m.gotoInput {if ch >= '0' && ch <= '9' {fileNum = fileNum*10 + int(ch-'0')}}if m.state.GotoFile(fileNum) {if m.state.Player != nil {m.state.Player.Stop()}m.gotoMode = falsereturn m, m.segmentChangeCmd()
// Backspace: remove last digitif key.Code == tea.KeyBackspace {if len(m.gotoInput) > 0 {m.gotoInput = m.gotoInput[:len(m.gotoInput)-1]}return m, nil}// Digits: append to inputs := msg.String()if len(s) == 1 && s[0] >= '0' && s[0] <= '9' {// Limit to reasonable number of digits (9 digits = 999,999,999 files)if len(m.gotoInput) < 9 {m.gotoInput += s}return m, nil}
b.WriteString(helpDarkStyle.Render(wrapText("[esc]quit [,]prev [.]next [space]comment [ctrl+s]clip [ctrl+g]goto [ctrl+d]bookmark [ctrl+,]prev-bk [ctrl+.]next-bk [enter]play [shift+enter]½speed", wrapWidth)))
b.WriteString(helpDarkStyle.Render(wrapText("[esc]quit [,]prev [.]next [space]comment [ctrl+s]clip [ctrl+d]bookmark [ctrl+,]prev-bk [ctrl+.]next-bk [enter]play [shift+enter]½speed", wrapWidth)))
state := &ClassifyState{Config: ClassifyConfig{Filter: "test-filter",Reviewer: "David",Bindings: bindings,Certainty: -1,},DataFiles: []*utils.DataFile{df},FileIdx: 0,SegmentIdx: 0,}
state := NewClassifyState(ClassifyConfig{Filter: "test-filter",Reviewer: "David",Bindings: bindings,Certainty: -1,}, []*utils.DataFile{df})
state := &ClassifyState{Config: ClassifyConfig{Filter: "test-filter",Reviewer: "David",Bindings: bindings,Certainty: -1,},DataFiles: []*utils.DataFile{df},FileIdx: 0,SegmentIdx: 0,}
state := NewClassifyState(ClassifyConfig{Filter: "test-filter",Reviewer: "David",Bindings: bindings,Certainty: -1,}, []*utils.DataFile{df})
filtered := state.getFilteredSegments(state.DataFiles[0])if len(filtered) != 2 {t.Errorf("getFilteredSegments should return 2 Kiwi segments, got %d", len(filtered))
// TotalSegments uses cached filtered segmentsif state.TotalSegments() != 2 {t.Errorf("TotalSegments should return 2 Kiwi segments, got %d", state.TotalSegments())
state1 := &ClassifyState{Config: ClassifyConfig{Certainty: -1},DataFiles: []*utils.DataFile{df1, df2},}
state1 := NewClassifyState(ClassifyConfig{Certainty: -1}, []*utils.DataFile{df1, df2})
state2 := &ClassifyState{Config: ClassifyConfig{Species: "Kiwi", Certainty: -1},DataFiles: []*utils.DataFile{df1, df2},}
state2 := NewClassifyState(ClassifyConfig{Species: "Kiwi", Certainty: -1}, []*utils.DataFile{df1, df2})
state3 := &ClassifyState{Config: ClassifyConfig{Species: "Tomtit", Certainty: -1},DataFiles: []*utils.DataFile{df1, df2},}
state3 := NewClassifyState(ClassifyConfig{Species: "Tomtit", Certainty: -1}, []*utils.DataFile{df1, df2})
state4 := &ClassifyState{Config: ClassifyConfig{Filter: "model-1.0", Certainty: -1},DataFiles: []*utils.DataFile{df1, df2},}
state4 := NewClassifyState(ClassifyConfig{Filter: "model-1.0", Certainty: -1}, []*utils.DataFile{df1, df2})
state5 := &ClassifyState{Config: ClassifyConfig{Species: "NonExistent", Certainty: -1},DataFiles: []*utils.DataFile{df1, df2},}
state5 := NewClassifyState(ClassifyConfig{Species: "NonExistent", Certainty: -1}, []*utils.DataFile{df1, df2})
state := &ClassifyState{Config: ClassifyConfig{Species: "Kiwi", Certainty: -1},DataFiles: []*utils.DataFile{df1, df2},FileIdx: 1, // at df2SegmentIdx: 0,}
state := NewClassifyState(ClassifyConfig{Species: "Kiwi", Certainty: -1}, []*utils.DataFile{df1, df2})state.FileIdx = 1 // at df2state.SegmentIdx = 0
state4 := &ClassifyState{Config: ClassifyConfig{Species: "Kiwi", Certainty: 70},DataFiles: []*utils.DataFile{df},}
state4 := NewClassifyState(ClassifyConfig{Species: "Kiwi", Certainty: 70}, []*utils.DataFile{df})
for _, df := range dataFiles {var segs []*utils.Segmentif !hasFilter {segs = df.Segments} else {for _, seg := range df.Segments {if seg.SegmentMatchesFilters(config.Filter, config.Species, config.CallType, config.Certainty) {segs = append(segs, seg)}}if len(segs) == 0 {continue // skip files with no matching segments}}kept = append(kept, df)cachedSegs = append(cachedSegs, segs)}
// When filtering, remove files with no matching segments// so navigation doesn't land on empty filesif config.Filter != "" || config.Species != "" || config.Certainty >= 0 {var matching []*utils.DataFilefor _, df := range dataFiles {if len(state.getFilteredSegments(df)) > 0 {matching = append(matching, df)
// Handle --goto: find file by basename and set initial positionif config.Goto != "" {found := falsefor i, df := range state.DataFiles {base := df.FilePath[strings.LastIndex(df.FilePath, "/")+1:]if base == config.Goto {state.FileIdx = ifound = truebreak
}// NewClassifyState creates a ClassifyState with pre-computed filtered segments.// Used by tests that construct state directly without LoadDataFiles.func NewClassifyState(config ClassifyConfig, dataFiles []*utils.DataFile) *ClassifyState {hasFilter := config.Filter != "" || config.Species != "" || config.Certainty >= 0cached := make([][]*utils.Segment, len(dataFiles))for i, df := range dataFiles {if !hasFilter {cached[i] = df.Segments} else {for _, seg := range df.Segments {if seg.SegmentMatchesFilters(config.Filter, config.Species, config.CallType, config.Certainty) {cached[i] = append(cached[i], seg)}}}}return &ClassifyState{Config: config,DataFiles: dataFiles,filteredSegs: cached,}
// GotoFile jumps to a specific file number (1-based), returns false if invalidfunc (s *ClassifyState) GotoFile(fileNum int) bool {idx := fileNum - 1if idx < 0 || idx >= len(s.DataFiles) {return false}s.FileIdx = idxs.SegmentIdx = 0return true}
// getFilteredSegments returns segments matching the active filters (filter, species, calltype, certainty)func (s *ClassifyState) getFilteredSegments(df *utils.DataFile) []*utils.Segment {if s.Config.Filter == "" && s.Config.Species == "" && s.Config.Certainty < 0 {return df.Segments}
var filtered []*utils.Segmentfor _, seg := range df.Segments {if s.segmentMatchesFilters(seg) {filtered = append(filtered, seg)}}return filtered}// segmentMatchesFilters returns true if the segment has any label matching all active filtersfunc (s *ClassifyState) segmentMatchesFilters(seg *utils.Segment) bool {return seg.SegmentMatchesFilters(s.Config.Filter, s.Config.Species, s.Config.CallType, s.Config.Certainty)}
## [2026-04-05] Simplify calls classify TUI**Static segment list:** Filtered segments are now computed once at startup and cached.Reclassifying a segment no longer removes it from the navigation list mid-session.This fixes instability/crashes when working fast with `--species` or other filters.**Replace goto dialog with `--goto` flag:**- Removed ctrl+g goto dialog from TUI (and all supporting code)- Added `--goto <filename>` CLI flag that opens on the first matching segment in the named file- Removed `GotoFile()` and `TotalFiles()` methods from `ClassifyState`**Internal:** Added `NewClassifyState()` constructor for tests. All `getFilteredSegments()` callsreplaced with pre-computed `filteredSegs` cache parallel to `DataFiles`.**Files changed:**- `tools/calls_classify.go` — cached segments, `--goto` support, removed dynamic filtering- `tui/classify.go` — removed goto dialog (model fields, handler, renderer, keybind)- `cmd/calls_classify.go` — added `--goto` flag parsing- `tools/calls_classify_*_test.go` — updated to use `NewClassifyState()`