Skip to content

Commit 0ab5f93

Browse files
feat: upgrade to v1.6.6 and implement new modular UI architecture and task management components
1 parent 421e1c8 commit 0ab5f93

9 files changed

Lines changed: 374 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.6.6] (2026-05-13)
9+
10+
### Added
11+
- **Project Sidebar**: Added a dedicated project sidebar pane for intuitive project navigation.
12+
- **Fuzzy Find**: Quickly locate and switch between projects.
13+
- **Keyboard Navigation**: Use up/down arrow keys for seamless project switching.
14+
- **Customizable Order**: Configurable via `[projects] order = "alphabetical"|"recent"`.
15+
- **Toggle**: Use `ctrl+e` to show/hide the sidebar.
16+
- **Switch Panels**: Added shortcut keys to switch focus between the project sidebar and task panels.
17+
- **Documentation**: Updated README and help documentation to include new sidebar functionality.
18+
819
## [1.6.5] (2026-05-12)
920

1021
### Fixed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ Press `n` to create your first task. `ctrl+s` to save. That's it.
7979
### ⚡ Genuinely Fast
8080
Sub-millisecond fuzzy search. Vim bindings (`j/k/gg/G`). Natural language deadlines like `tomorrow 10am` or `next friday`. Full keyboard control — you never touch the mouse.
8181

82-
### 🗂 Nested Tasks & Hierarchy
83-
Organize work into deep hierarchies and separate projects. Nest tasks via the **Parent** field in the editor, switch projects with `ctrl+e` to focus your workspace, and export/import with full structure preserved — across JSON, CSV, Markdown, and plain text.
82+
### 🗂 Project Sidebar & Hierarchy
83+
Organize work into deep hierarchies and separate projects. Toggle the **Project Sidebar** with `ctrl+e` for quick navigation, fuzzy find projects by name, and switch between projects using arrow keys. Project ordering is customizable via `config.toml` (e.g., `[projects] order = "alphabetical"|"recent"`). Nest tasks via the **Parent** field in the editor, and export/import with full structure preserved — across JSON, CSV, Markdown, and plain text.
8484

8585
### 🔁 Recurring Tasks
8686
Tasks reappear automatically on a schedule. Weekly (`mon,wed,fri`) or monthly (`15`). When completed, Kairo generates the next instance immediately with a smart due-date preview.

VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.6.5
1+
1.6.6

internal/app/model.go

Lines changed: 114 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -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

16551688
func (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+
23902464
func (m *Model) pruneAndLoadTagsCmd() tea.Cmd {
23912465
return func() tea.Msg {
23922466
_ = m.svc.Prune(m.ctx)

internal/config/config.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type ProjectsConfig struct {
3737
Default string `toml:"default"`
3838
Shortcut string `toml:"shortcut"`
3939
ShowColumn string `toml:"show_column"` // "auto" | "always" | "never"
40+
Order string `toml:"order"` // "alphabetical" | "recent"
4041
}
4142

4243
type ListConfig struct {
@@ -88,18 +89,19 @@ func normalizeRightOrder(in []string) []string {
8889
}
8990

9091
type AppConfig struct {
91-
Theme string `toml:"theme"`
92-
ActiveProject string `toml:"active_project"`
93-
VimMode bool `toml:"vim_mode"`
94-
ShowHelp bool `toml:"show_help"`
95-
ShowID bool `toml:"show_id"`
96-
Rainbow bool `toml:"rainbow"`
97-
GeminiAPIKey string `toml:"gemini_api_key"`
98-
AIModel string `toml:"ai_model"`
99-
MCPEnabled bool `toml:"mcp_enabled"`
100-
MCPPort string `toml:"mcp_port"`
101-
Animations bool `toml:"animations"`
102-
OnboardingCompleted bool `toml:"onboarding_completed"`
92+
Theme string `toml:"theme"`
93+
ActiveProject string `toml:"active_project"`
94+
VimMode bool `toml:"vim_mode"`
95+
ShowHelp bool `toml:"show_help"`
96+
ShowID bool `toml:"show_id"`
97+
Rainbow bool `toml:"rainbow"`
98+
GeminiAPIKey string `toml:"gemini_api_key"`
99+
AIModel string `toml:"ai_model"`
100+
MCPEnabled bool `toml:"mcp_enabled"`
101+
MCPPort string `toml:"mcp_port"`
102+
Animations bool `toml:"animations"`
103+
OnboardingCompleted bool `toml:"onboarding_completed"`
104+
RecentProjects []string `toml:"recent_projects"`
103105
}
104106

105107
type StorageConfig struct {
@@ -166,6 +168,8 @@ type KeymapConfig struct {
166168
ProjectSwitcher string `toml:"project_switcher"`
167169
Undo string `toml:"undo"`
168170
Redo string `toml:"redo"`
171+
FocusSidebar string `toml:"focus_sidebar"`
172+
FocusList string `toml:"focus_list"`
169173
}
170174

171175
func Default() Config {
@@ -185,6 +189,7 @@ func Default() Config {
185189
Default: "default",
186190
Shortcut: "p",
187191
ShowColumn: "auto",
192+
Order: "alphabetical",
188193
},
189194
Edit: EditConfig{
190195
Preview: true,
@@ -261,6 +266,8 @@ func Default() Config {
261266
ProjectSwitcher: "ctrl+e",
262267
Undo: "ctrl+z",
263268
Redo: "ctrl+y",
269+
FocusSidebar: "[",
270+
FocusList: "]",
264271
},
265272
}
266273
}

internal/lua/engine.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func (e *Engine) SetupKairoAPI(L *lua.LState) {
6161
L.SetField(kairo, "notify", L.NewFunction(e.luaNotify))
6262

6363
// Meta
64-
L.SetField(kairo, "version", lua.LString("1.6.5"))
64+
L.SetField(kairo, "version", lua.LString("1.6.6"))
6565

6666
// Set as global
6767
L.SetGlobal("kairo", kairo)

0 commit comments

Comments
 (0)