@@ -40,6 +40,7 @@ import (
4040 "github.com/programmersd21/kairo/internal/ui/plugin_menu"
4141 "github.com/programmersd21/kairo/internal/ui/render"
4242 "github.com/programmersd21/kairo/internal/ui/settings"
43+ "github.com/programmersd21/kairo/internal/ui/sidebar"
4344 "github.com/programmersd21/kairo/internal/ui/stats"
4445 "github.com/programmersd21/kairo/internal/ui/styles"
4546 "github.com/programmersd21/kairo/internal/ui/tasklist"
@@ -102,6 +103,7 @@ const (
102103 ModeOnboarding
103104 ModeStats
104105 ModeProjectSwitcher
106+ ModeProjectSidebar
105107 ModeFocus
106108)
107109
@@ -118,17 +120,18 @@ type Model struct {
118120 width int
119121 height int
120122
121- mode Mode
123+ mode Mode
124+ sidebarVisible bool
122125
123126 activeProject string
124-
125127 views []core.View
126128 activeIdx int
127129 prevActiveIdx int
128130 tagFilter FilterState // Replaced plain tagParam with proper state management
129131 priParam * core.Priority
130132
131133 list tasklist.Model
134+ side sidebar.Model
132135 pal palette.Model
133136 det detail.Model
134137 edit * editor.Model
@@ -254,6 +257,9 @@ func New(ctx context.Context, cfg config.Config, svc service.TaskService) (tea.M
254257 m .list = tasklist .New (m .s , cfg .App .VimMode , cfg .App .Animations , m .km , cfg .List .Fields .Due .Minimal )
255258 m .list .SetTagsConfig (cfg .Tags .Highlight )
256259 m .list .SetRightOrder (cfg .List .Order .Right )
260+ m .side = sidebar .New (m .s )
261+ m .side .SetProjects (m .projects , cfg .Projects .Order , cfg .App .RecentProjects )
262+ m .side .SetActive (m .activeProject )
257263 m .pal = palette .New (m .s )
258264 m .det = detail .New (m .s )
259265 m .hlp = help .New (m .s , m .km )
@@ -498,6 +504,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
498504
499505 case projectsLoadedMsg :
500506 m .projects = x .Projects
507+ m .side .SetProjects (m .projects , m .cfg .Projects .Order , m .cfg .App .RecentProjects )
508+ m .side .SetActive (m .activeProject )
501509 m .rebuildProjectsIndex ()
502510 return m , nil
503511
@@ -749,18 +757,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
749757
750758 m .mode = ModeList
751759 switch x .Item .Kind {
752- case search .KindProject :
753- m .activeProject = x .Item .ID
754- m .cfg .App .ActiveProject = m .activeProject
755- _ = m .cfg .Save ()
756- var animCmd tea.Cmd
757- if m .cfg .App .Animations {
758- m .transitioning = m .cfg .App .Animations
759- m .transitionStarted = time .Now ()
760- m .animationGen ++
761- animCmd = m .viewTransitionTickCmd ()
762- }
763- return m , tea .Batch (m .loadTasksCmd (), animCmd )
764760 case search .KindTask :
765761 return m , m .fetchOpenTaskCmd (x .Item .ID )
766762 case search .KindTag :
@@ -776,6 +772,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
776772 }
777773 return m , nil
778774
775+ case sidebar.SelectMsg :
776+ m .activeProject = x .Project
777+ m .updateRecentProject (m .activeProject )
778+ m .cfg .App .ActiveProject = m .activeProject
779+ _ = m .cfg .Save ()
780+ m .side .SetProjects (m .projects , m .cfg .Projects .Order , m .cfg .App .RecentProjects )
781+ m .side .SetActive (m .activeProject )
782+ var animCmd tea.Cmd
783+ if m .cfg .App .Animations {
784+ m .transitioning = m .cfg .App .Animations
785+ m .transitionStarted = time .Now ()
786+ m .animationGen ++
787+ animCmd = m .viewTransitionTickCmd ()
788+ }
789+ return m , tea .Batch (m .loadTasksCmd (), animCmd )
790+
779791 case editor.CloseMsg :
780792 if m .mode == ModeEditor {
781793 m .mode = ModeList
@@ -869,10 +881,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
869881 return m , nil
870882
871883 case taskDeletedMsg :
872- // Deleting a task can orphan tags; prune & reload tags immediately so
873- // tag-related UI/palette stays accurate without requiring a restart.
884+ // Deleting a task can orphan tags/projects ; prune & reload so
885+ // UI stays accurate without requiring a restart.
874886 m .rebuildComponentSizes ()
875- return m , tea .Batch (m .pruneAndLoadTagsCmd (), m .refreshCmd (), m .syncIfEnabledCmd ())
887+ return m , tea .Batch (m .pruneAndLoadTagsCmd (), m .loadProjectsCmd (), m . refreshCmd (), m .syncIfEnabledCmd ())
876888
877889 case rainbowTickMsg :
878890 if ! m .cfg .App .Rainbow {
@@ -1147,26 +1159,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
11471159 }
11481160 }
11491161
1150- // Mode-specific toggles that should work even when the mode is active (and thus input is focused)
1151- if m .mode == ModeProjectSwitcher && keymapMatch (m .km .ProjectSwitcher , km ) {
1152- m .mode = ModeList
1153- return m , nil
1154- }
1155- if m .mode == ModePalette && keymapMatch (m .km .Palette , km ) {
1156- m .mode = ModeList
1162+ // Sidebar toggle
1163+ if keymapMatch (m .km .ProjectSwitcher , km ) {
1164+ m .sidebarVisible = ! m .sidebarVisible
1165+ if m .sidebarVisible {
1166+ m .mode = ModeProjectSidebar
1167+ m .side .Focus (true )
1168+ } else {
1169+ m .mode = ModeList
1170+ m .side .Focus (false )
1171+ }
1172+ m .rebuildComponentSizes ()
11571173 return m , nil
11581174 }
11591175
1160- // Explicit check for ctrl+e to prevent collision with enter
1161- if km .String () == "ctrl+e" {
1162- if m .mode == ModeProjectSwitcher {
1176+ // Focus switching
1177+ if m .sidebarVisible {
1178+ if keymapMatch (m .km .FocusSidebar , km ) {
1179+ m .mode = ModeProjectSidebar
1180+ m .side .Focus (true )
1181+ return m , nil
1182+ }
1183+ if keymapMatch (m .km .FocusList , km ) {
11631184 m .mode = ModeList
1185+ m .side .Focus (false )
1186+ return m , nil
1187+ }
1188+
1189+ if km .String () == "tab" {
1190+ if m .mode == ModeProjectSidebar {
1191+ m .mode = ModeList
1192+ m .side .Focus (false )
1193+ } else {
1194+ m .mode = ModeProjectSidebar
1195+ m .side .Focus (true )
1196+ }
11641197 return m , nil
11651198 }
1166- m .mode = ModeProjectSwitcher
1167- m .applyPaletteIndex ()
1168- m .pal .SetPlaceholder ("Switch project…" )
1169- return m , m .pal .Open ()
11701199 }
11711200
11721201 // Global key handling - only process keybindings if no input field is focused.
@@ -1565,6 +1594,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15651594 e , cmd := m .edit .Update (msg )
15661595 * m .edit = e
15671596 return m , cmd
1597+ case ModeProjectSidebar :
1598+ var cmd tea.Cmd
1599+ m .side , cmd = m .side .Update (msg )
1600+ return m , cmd
15681601 case ModeDetail :
15691602 var cmd tea.Cmd
15701603 return m , cmd
@@ -1653,7 +1686,7 @@ func (m *Model) View() string {
16531686}
16541687
16551688func (m * Model ) renderMainUI () string {
1656- // Calculate the width budget: when AI panel is visible, the main UI
1689+ // Calculate the width budget: when AI panel or sidebar is visible, the main UI
16571690 // shrinks to make room. Both halves must fit within m.width.
16581691 mainW := m .width
16591692 aiPanelW := 0
@@ -1662,15 +1695,17 @@ func (m *Model) renderMainUI() string {
16621695 if aiPanelW < 30 {
16631696 aiPanelW = 30
16641697 }
1665- mainW = m .width - aiPanelW
1666- if mainW < 40 {
1667- mainW = 40
1668- aiPanelW = m .width - mainW
1669- }
1698+ mainW -= aiPanelW
1699+ }
1700+
1701+ sidebarW := 0
1702+ if m .sidebarVisible {
1703+ sidebarW = 25
1704+ mainW -= sidebarW
16701705 }
16711706
1672- head := m .renderHeaderWithWidth (mainW )
1673- foot := m .renderFooterWithWidth (mainW )
1707+ head := m .renderHeaderWithWidth (mainW + sidebarW )
1708+ foot := m .renderFooterWithWidth (mainW + sidebarW )
16741709
16751710 hHeight := lipgloss .Height (head )
16761711 fHeight := lipgloss .Height (foot )
@@ -1679,7 +1714,10 @@ func (m *Model) renderMainUI() string {
16791714 availableHeight = 0
16801715 }
16811716
1682- // Update sizes dynamically — use mainW so components don't overflow
1717+ // Update sizes dynamically
1718+ if m .sidebarVisible {
1719+ m .side .SetSize (sidebarW , availableHeight )
1720+ }
16831721 m .list .SetSize (mainW , availableHeight )
16841722 m .det .ShowID = m .cfg .App .ShowID
16851723 m .det .SetSize (mainW , availableHeight )
@@ -1775,6 +1813,21 @@ func (m *Model) renderMainUI() string {
17751813
17761814 content := lipgloss .JoinVertical (lipgloss .Left , head , body , foot )
17771815
1816+ if m .sidebarVisible {
1817+ sidebarContent := m .side .View ()
1818+ sidebarContent = lipgloss .NewStyle ().
1819+ Width (sidebarW ).
1820+ Height (availableHeight ).
1821+ Background (m .s .Theme .Bg ).
1822+ Border (lipgloss .NormalBorder (), false , true , false , false ).
1823+ BorderForeground (m .s .Theme .Border ).
1824+ Render (sidebarContent )
1825+ // We join body instead of content because head/foot should span full width?
1826+ // Actually, the user's screenshot shows head/foot spanning only the main area.
1827+ // Let's re-join with head/foot separately if needed.
1828+ content = lipgloss .JoinVertical (lipgloss .Left , head , lipgloss .JoinHorizontal (lipgloss .Top , sidebarContent , body ), foot )
1829+ }
1830+
17781831 if m .mode == ModeOnboarding {
17791832 content = lipgloss .Place (m .width , m .height , lipgloss .Center , lipgloss .Center ,
17801833 m .onb .View (),
@@ -2387,6 +2440,27 @@ func (m *Model) refreshCmd() tea.Cmd {
23872440 )
23882441}
23892442
2443+ func (m * Model ) updateRecentProject (project string ) {
2444+ if project == "" {
2445+ return
2446+ }
2447+ recent := m .cfg .App .RecentProjects
2448+ // Remove if already exists
2449+ for i , p := range recent {
2450+ if p == project {
2451+ recent = append (recent [:i ], recent [i + 1 :]... )
2452+ break
2453+ }
2454+ }
2455+ // Prepend
2456+ recent = append ([]string {project }, recent ... )
2457+ // Limit to 50
2458+ if len (recent ) > 50 {
2459+ recent = recent [:50 ]
2460+ }
2461+ m .cfg .App .RecentProjects = recent
2462+ }
2463+
23902464func (m * Model ) pruneAndLoadTagsCmd () tea.Cmd {
23912465 return func () tea.Msg {
23922466 _ = m .svc .Prune (m .ctx )
0 commit comments