@@ -53,6 +53,10 @@ type FormModel struct {
5353 project string
5454 projectIdx int
5555 projects []string
56+ projectSearchMode bool // true when typing to search/filter projects
57+ projectSearchQuery string // current search query
58+ projectFiltered []string // filtered project list (fuzzy matched)
59+ projectFilteredIdx int // selected index in filtered list
5660 taskType string
5761 typeIdx int
5862 types []string
@@ -510,6 +514,61 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
510514 return m , nil
511515 }
512516
517+ // Handle project search mode when active
518+ if m .projectSearchMode && m .focused == FieldProject {
519+ switch msg .String () {
520+ case "esc" :
521+ m .exitProjectSearch ()
522+ return m , nil
523+ case "enter" , "tab" :
524+ m .selectProjectFromSearch ()
525+ if msg .String () == "tab" {
526+ m .focusNext ()
527+ }
528+ return m , nil
529+ case "up" , "ctrl+p" :
530+ if m .projectFilteredIdx > 0 {
531+ m .projectFilteredIdx --
532+ }
533+ return m , nil
534+ case "down" , "ctrl+n" :
535+ if m .projectFilteredIdx < len (m .projectFiltered )- 1 {
536+ m .projectFilteredIdx ++
537+ }
538+ return m , nil
539+ case "backspace" , "ctrl+h" :
540+ if len (m .projectSearchQuery ) > 0 {
541+ m .projectSearchQuery = m .projectSearchQuery [:len (m .projectSearchQuery )- 1 ]
542+ m .filterProjects ()
543+ } else {
544+ m .exitProjectSearch ()
545+ }
546+ return m , nil
547+ case "ctrl+c" :
548+ m .cancelled = true
549+ return m , nil
550+ case "ctrl+w" :
551+ // Delete word backward
552+ m .projectSearchQuery = ""
553+ m .filterProjects ()
554+ return m , nil
555+ case "ctrl+u" :
556+ // Clear line
557+ m .projectSearchQuery = ""
558+ m .filterProjects ()
559+ return m , nil
560+ default :
561+ key := msg .String ()
562+ // Only accept printable single characters
563+ if len (key ) == 1 && key [0 ] >= 32 && key [0 ] < 127 {
564+ m .projectSearchQuery += key
565+ m .filterProjects ()
566+ return m , nil
567+ }
568+ }
569+ return m , nil
570+ }
571+
513572 // Handle task reference autocomplete when active
514573 if m .showTaskRefAutocomplete && m .taskRefAutocomplete != nil && m .taskRefAutocomplete .HasResults () {
515574 switch msg .String () {
@@ -638,6 +697,11 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
638697 if m .focused == FieldBody {
639698 break
640699 }
700+ // On project field, enter opens search mode
701+ if m .focused == FieldProject {
702+ m .enterProjectSearch ()
703+ return m , nil
704+ }
641705 // On last visible field, submit
642706 if m .isLastVisibleField () {
643707 m .parseAttachments ()
@@ -724,12 +788,29 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
724788 return m , nil
725789 }
726790
791+ case "/" :
792+ // Slash to enter project search mode when on project field
793+ if m .focused == FieldProject {
794+ m .enterProjectSearch ()
795+ return m , nil
796+ }
797+
727798 default :
728799 if m .handleAttachmentRemovalKey (msg ) {
729800 return m , nil
730801 }
731- // Type-to-select for selector fields
732- if m .focused == FieldProject || m .focused == FieldType || m .focused == FieldExecutor {
802+ // Project field: any letter enters search mode
803+ if m .focused == FieldProject {
804+ key := msg .String ()
805+ if len (key ) == 1 && key [0 ] >= 32 && key [0 ] < 127 {
806+ m .enterProjectSearch ()
807+ m .projectSearchQuery = key
808+ m .filterProjects ()
809+ return m , nil
810+ }
811+ }
812+ // Type-to-select for other selector fields
813+ if m .focused == FieldType || m .focused == FieldExecutor {
733814 key := msg .String ()
734815 if len (key ) == 1 && unicode .IsLetter (rune (key [0 ])) {
735816 m .selectByPrefix (strings .ToLower (key ))
@@ -897,19 +978,85 @@ func (m *FormModel) acceptGhostText() {
897978 m .ghostFullText = ""
898979}
899980
900- func (m * FormModel ) selectByPrefix (prefix string ) {
901- switch m .focused {
902- case FieldProject :
903- for i , p := range m .projects {
904- if strings .HasPrefix (strings .ToLower (p ), prefix ) {
905- m .projectIdx = i
906- m .project = p
907- m .loadLastTaskTypeForProject ()
908- m .rebuildExecutorListForProject () // Re-sort by usage for new project
909- m .loadLastExecutorForProject ()
910- return
981+ // enterProjectSearch activates the project search mode.
982+ func (m * FormModel ) enterProjectSearch () {
983+ m .projectSearchMode = true
984+ m .projectSearchQuery = ""
985+ m .projectFilteredIdx = 0
986+ m .filterProjects ()
987+ }
988+
989+ // exitProjectSearch deactivates project search mode without selecting.
990+ func (m * FormModel ) exitProjectSearch () {
991+ m .projectSearchMode = false
992+ m .projectSearchQuery = ""
993+ m .projectFiltered = nil
994+ m .projectFilteredIdx = 0
995+ }
996+
997+ // selectProjectFromSearch selects the currently highlighted project in search results.
998+ func (m * FormModel ) selectProjectFromSearch () {
999+ if len (m .projectFiltered ) == 0 {
1000+ m .exitProjectSearch ()
1001+ return
1002+ }
1003+ if m .projectFilteredIdx >= len (m .projectFiltered ) {
1004+ m .projectFilteredIdx = 0
1005+ }
1006+ selected := m .projectFiltered [m .projectFilteredIdx ]
1007+ m .project = selected
1008+ // Update projectIdx to match
1009+ for i , p := range m .projects {
1010+ if p == selected {
1011+ m .projectIdx = i
1012+ break
1013+ }
1014+ }
1015+ m .exitProjectSearch ()
1016+ m .loadLastTaskTypeForProject ()
1017+ m .rebuildExecutorListForProject ()
1018+ m .loadLastExecutorForProject ()
1019+ }
1020+
1021+ // filterProjects updates the filtered project list based on the search query.
1022+ func (m * FormModel ) filterProjects () {
1023+ query := m .projectSearchQuery
1024+ if query == "" {
1025+ // Show all projects, with current project first
1026+ m .projectFiltered = make ([]string , len (m .projects ))
1027+ copy (m .projectFiltered , m .projects )
1028+ // Pre-select the current project
1029+ m .projectFilteredIdx = 0
1030+ for i , p := range m .projectFiltered {
1031+ if p == m .project {
1032+ m .projectFilteredIdx = i
1033+ break
9111034 }
9121035 }
1036+ return
1037+ }
1038+
1039+ type scored struct {
1040+ name string
1041+ score int
1042+ }
1043+ var results []scored
1044+ for _ , p := range m .projects {
1045+ s := fuzzyScore (p , query )
1046+ if s > 0 {
1047+ results = append (results , scored {p , s })
1048+ }
1049+ }
1050+ sort .Slice (results , func (i , j int ) bool { return results [i ].score > results [j ].score })
1051+ m .projectFiltered = make ([]string , len (results ))
1052+ for i , r := range results {
1053+ m .projectFiltered [i ] = r .name
1054+ }
1055+ m .projectFilteredIdx = 0
1056+ }
1057+
1058+ func (m * FormModel ) selectByPrefix (prefix string ) {
1059+ switch m .focused {
9131060 case FieldType :
9141061 for i , t := range m .types {
9151062 label := t
@@ -1075,6 +1222,9 @@ func (m *FormModel) blurAll() {
10751222 m .bodyInput .Blur ()
10761223 m .attachmentsInput .Blur ()
10771224 m .clearAttachmentSelection ()
1225+ if m .projectSearchMode {
1226+ m .exitProjectSearch ()
1227+ }
10781228}
10791229
10801230func (m * FormModel ) focusCurrent () {
@@ -1347,8 +1497,70 @@ func (m *FormModel) View() string {
13471497 if m .focused == FieldProject {
13481498 cursor = cursorStyle .Render ("▸" )
13491499 }
1350- b .WriteString (cursor + " " + labelStyle .Render ("Project" ) + m .renderSelector (m .projects , m .projectIdx , m .focused == FieldProject , selectedStyle , optionStyle , dimStyle ))
1351- b .WriteString ("\n \n " )
1500+ if m .projectSearchMode && m .focused == FieldProject {
1501+ // Search mode: show search input and filtered dropdown
1502+ searchInputStyle := lipgloss .NewStyle ().Foreground (ColorPrimary )
1503+ queryDisplay := m .projectSearchQuery
1504+ cursorChar := lipgloss .NewStyle ().Background (ColorPrimary ).Foreground (lipgloss .Color ("0" )).Render (" " )
1505+ b .WriteString (cursor + " " + labelStyle .Render ("Project" ) + searchInputStyle .Render (queryDisplay ) + cursorChar )
1506+ b .WriteString ("\n " )
1507+
1508+ // Show filtered results as a vertical dropdown
1509+ maxShow := 8
1510+ if len (m .projectFiltered ) < maxShow {
1511+ maxShow = len (m .projectFiltered )
1512+ }
1513+
1514+ // Calculate scroll window
1515+ scrollStart := 0
1516+ if m .projectFilteredIdx >= maxShow {
1517+ scrollStart = m .projectFilteredIdx - maxShow + 1
1518+ }
1519+ scrollEnd := scrollStart + maxShow
1520+ if scrollEnd > len (m .projectFiltered ) {
1521+ scrollEnd = len (m .projectFiltered )
1522+ scrollStart = scrollEnd - maxShow
1523+ if scrollStart < 0 {
1524+ scrollStart = 0
1525+ }
1526+ }
1527+
1528+ // Scroll-up indicator
1529+ if scrollStart > 0 {
1530+ b .WriteString (" " + dimStyle .Render ("↑ more" ) + "\n " )
1531+ }
1532+
1533+ for i := scrollStart ; i < scrollEnd ; i ++ {
1534+ p := m .projectFiltered [i ]
1535+ if i == m .projectFilteredIdx {
1536+ b .WriteString (" " + selectedStyle .Render (" " + p + " " ) + "\n " )
1537+ } else {
1538+ b .WriteString (" " + optionStyle .Render (" " + p ) + "\n " )
1539+ }
1540+ }
1541+
1542+ // Scroll-down indicator
1543+ if scrollEnd < len (m .projectFiltered ) {
1544+ b .WriteString (" " + dimStyle .Render ("↓ more" ) + "\n " )
1545+ }
1546+
1547+ if len (m .projectFiltered ) == 0 {
1548+ b .WriteString (" " + dimStyle .Render ("no matches" ) + "\n " )
1549+ }
1550+ b .WriteString ("\n " )
1551+ } else {
1552+ // Normal mode: show current project with hint
1553+ projectDisplay := m .project
1554+ if projectDisplay == "" {
1555+ projectDisplay = "none"
1556+ }
1557+ if m .focused == FieldProject {
1558+ b .WriteString (cursor + " " + labelStyle .Render ("Project" ) + selectedStyle .Render (" " + projectDisplay + " " ) + " " + dimStyle .Render ("type to search · " + IconArrowLeft ()+ "/" + IconArrowRight ()+ " cycle" ))
1559+ } else {
1560+ b .WriteString (cursor + " " + labelStyle .Render ("Project" ) + optionStyle .Bold (true ).Render (projectDisplay ))
1561+ }
1562+ b .WriteString ("\n \n " )
1563+ }
13521564 }
13531565
13541566 // Title
0 commit comments