Skip to content

Commit bc0e8e3

Browse files
feat: implement focus mode with integrated timer and task tracking engine
1 parent 9f85110 commit bc0e8e3

13 files changed

Lines changed: 499 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ 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+
## v1.6.0 (2026-05-09)
9+
10+
### Added
11+
* **Integrated Focus Engine (Pomodoro)**: A native deep-work timer to bridge the gap between planning and execution.
12+
* `f` → Open Focus Engine. If a task is selected in the list, it's automatically tracked.
13+
* **Minimalist "Liquid Glass" UI**: A dedicated focus mode with countdown timer and state management (Focus/Break).
14+
* **"DEEP WORK" Pulse**: Live visual feedback in the footer when a session is active.
15+
* **Automatic Persistence**: Completed focus sessions are logged to the database and linked to specific tasks for future analytics.
16+
* **Onboarding Update**: Added Focus Engine training to the Welcome Tour.
17+
18+
### Changed
19+
* **Keybinding Refinement**: To accommodate the Focus Engine, the "Filter by Tag" shortcut has been moved from `f` to `ctrl+f`.
20+
821
## v1.5.6 (2026-05-08)
922

1023
### Added

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ Tasks reappear automatically on a schedule. Weekly (`mon,wed,fri`) or monthly (`
8585
### 🔒 Your Data, Locally
8686
SQLite with WAL mode. Fully offline. Optional Git-backed sync — no backend, no account, no lock-in. Export to JSON, CSV, Markdown, or plain text on demand. Project organization is preserved in your database.
8787

88-
### 🧭 Interactive Stats Dashboard
89-
Press `s` to open a next-gen "Command Center". Visualize your **Productivity DNA**, track real-time momentum, and get behavioral insights like "You complete 73% more tasks at night". Fully animated, keyboard-driven, and deeply insightful.
88+
### 🧭 Interactive Stats Dashboard & Focus Engine
89+
Press `s` to open a next-gen "Command Center". Visualize your **Productivity DNA**, track real-time momentum, and get behavioral insights.
90+
91+
**Focus Engine**: Press `f` to launch the native Pomodoro timer. Track deep work sessions directly against your active tasks. When a session is active, Kairo displays a "DEEP WORK" pulse in the footer.
9092

9193
### 🤖 AI — Optional, Never Intrusive
9294
Gemini integration (`gemini-3.1-flash-lite-preview` / `gemini-2.5-flash-lite` / `gemini-2.0-flash-lite`). Toggle with `ctrl+a`. Create and manage complex recurring tasks with natural language, including assigning to specific projects. Invisible until you need it.
@@ -123,7 +125,8 @@ diy = "bg=accent"
123125
| `ctrl+d` | Duplicate task |
124126
| `Space` | Select task / Collapse subtasks |
125127
| `s` | Stats dashboard |
126-
| `f` | Filter by tag |
128+
| `f` | Focus engine |
129+
| `ctrl+f` | Filter by tag |
127130
| `ctrl+e` | Switch project |
128131
| `p` | Manage plugins |
129132
| `t` | Switch theme |
@@ -144,6 +147,7 @@ diy = "bg=accent"
144147
<img src="screenshots/theme_menu.png" width="30%" />
145148
<img src="screenshots/dashboard.png" width="30%" />
146149
<img src="screenshots/welcome_tour.png" width="30%" />
150+
<img src="screenshots/focus_mode.png" width="30%" />
147151
</div>
148152

149153
---

VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.5.7
1+
1.6.0

internal/app/model.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/programmersd21/kairo/internal/ui/ai_panel"
3232
"github.com/programmersd21/kairo/internal/ui/detail"
3333
"github.com/programmersd21/kairo/internal/ui/editor"
34+
"github.com/programmersd21/kairo/internal/ui/focus"
3435
"github.com/programmersd21/kairo/internal/ui/help"
3536
"github.com/programmersd21/kairo/internal/ui/import_export_menu"
3637
"github.com/programmersd21/kairo/internal/ui/keymap"
@@ -101,6 +102,7 @@ const (
101102
ModeOnboarding
102103
ModeStats
103104
ModeProjectSwitcher
105+
ModeFocus
104106
)
105107

106108
type Model struct {
@@ -137,6 +139,7 @@ type Model struct {
137139
set settings.Model
138140
iem import_export_menu.Model
139141
stats stats.Model
142+
foc focus.Model
140143
aiPanel ai_panel.Model
141144
aiClient *ai.Client
142145
aiKey string
@@ -260,6 +263,7 @@ func New(ctx context.Context, cfg config.Config, svc service.TaskService) (tea.M
260263
m.set = settings.New(m.s, cfg)
261264
m.iem = import_export_menu.New(m.s)
262265
m.stats = stats.New(m.s)
266+
m.foc = focus.New(m.s)
263267
m.aiPanel = ai_panel.New(m.s)
264268
m.aiChan = make(chan ai_panel.AIChunkMsg, 100)
265269
m.aiKey = cfg.App.GeminiAPIKey
@@ -813,6 +817,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
813817
}(),
814818
)
815819

820+
case focus.SessionDoneMsg:
821+
return m, m.createFocusSessionCmd(x.Session)
822+
823+
case focus.TickMsg:
824+
var cmd tea.Cmd
825+
m.foc, cmd = m.foc.Update(msg)
826+
// If rainbow is off, we still want the pulse to animate,
827+
// so we can increment the offset here too.
828+
if !m.cfg.App.Rainbow {
829+
m.RainbowAnimationOffset = (m.RainbowAnimationOffset + 1) % 7
830+
}
831+
return m, cmd
832+
816833
case taskUpdatedMsg:
817834
m.rebuildComponentSizes()
818835
return m, m.refreshCmd()
@@ -1343,11 +1360,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
13431360
}
13441361

13451362
switch {
1346-
case km.String() == "f":
1363+
case keymapMatch(m.km.ViewTag, km):
13471364
m.tagFilterInput.SetValue(m.tagFilter.Value())
13481365
m.tagFilterInput.Focus()
13491366
m.mode = ModeTagFilter
13501367
return m, nil
1368+
case keymapMatch(m.km.Focus, km):
1369+
if item, ok := m.list.Selected(); ok {
1370+
m.foc.Task = &item.Task
1371+
} else {
1372+
m.foc.Task = nil
1373+
}
1374+
m.mode = ModeFocus
1375+
return m, nil
13511376
case km.String() == " ":
13521377
// Handle space key for toggle collapse
13531378
if item, ok := m.list.Selected(); ok && item.HasChildren {
@@ -1546,6 +1571,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15461571
var cmd tea.Cmd
15471572
m.pm, cmd = m.pm.Update(msg)
15481573
return m, cmd
1574+
case ModeFocus:
1575+
if km, ok := msg.(tea.KeyMsg); ok {
1576+
if keymapMatch(m.km.Back, km) {
1577+
m.mode = ModeList
1578+
return m, nil
1579+
}
1580+
}
1581+
var cmd tea.Cmd
1582+
m.foc, cmd = m.foc.Update(msg)
1583+
return m, cmd
15491584
case ModeSettings:
15501585
var cmd tea.Cmd
15511586
m.set, cmd = m.set.Update(msg)
@@ -1647,6 +1682,7 @@ func (m *Model) renderMainUI() string {
16471682
m.tm.SetSize(mainW, availableHeight)
16481683
m.iem.SetSize(mainW, availableHeight)
16491684
m.stats.SetSize(mainW, availableHeight)
1685+
m.foc.SetSize(mainW, availableHeight)
16501686
if m.edit != nil {
16511687
m.edit.SetSize(mainW, availableHeight)
16521688
}
@@ -1688,6 +1724,8 @@ func (m *Model) renderMainUI() string {
16881724
}
16891725
case ModeImportExport:
16901726
body = m.iem.View()
1727+
case ModeFocus:
1728+
body = m.foc.View()
16911729
case ModeStats:
16921730
body = m.stats.View()
16931731
case ModeOnboarding:
@@ -2143,11 +2181,36 @@ func (m *Model) renderFooter() string {
21432181
}
21442182
versionText = fmt.Sprintf("Update: %s → %s", cur, lat)
21452183
}
2184+
2185+
focusPill := ""
2186+
if m.foc.Active && (m.mode == ModeList || m.mode == ModeFocus) {
2187+
pulseStyle := m.s.BadgeDoing
2188+
if m.foc.State == focus.StateShortBreak || m.foc.State == focus.StateLongBreak {
2189+
pulseStyle = m.s.BadgeGood
2190+
}
2191+
2192+
text := "DEEP WORK"
2193+
if m.foc.State != focus.StateFocus {
2194+
text = "BREAK"
2195+
}
2196+
2197+
// Pulse effect using RainbowAnimationOffset
2198+
if m.RainbowAnimationOffset%2 == 0 {
2199+
text = "• " + text + " •"
2200+
} else {
2201+
text = " " + text + " "
2202+
}
2203+
2204+
focusPill = m.s.TagLeft.Foreground(pulseStyle.GetBackground()).Render() +
2205+
pulseStyle.Render(text) +
2206+
m.s.TagRight.Foreground(pulseStyle.GetBackground()).Render() + " "
2207+
}
2208+
21462209
mcpStatus := ""
21472210
if m.mcpRunning {
21482211
mcpStatus = makePill("MCP "+styles.IconSuccess) + " "
21492212
}
2150-
right = mcpStatus + makePill(syncLogo+versionText) + " "
2213+
right = focusPill + mcpStatus + makePill(syncLogo+versionText) + " "
21512214
}
21522215

21532216
return render.BarLine(left, right, m.width, m.s.Theme.Bg)
@@ -2357,6 +2420,15 @@ func (m *Model) updateTaskCmd(id string, p core.TaskPatch) tea.Cmd {
23572420
}
23582421
}
23592422

2423+
func (m *Model) createFocusSessionCmd(s core.FocusSession) tea.Cmd {
2424+
return func() tea.Msg {
2425+
if err := m.svc.Repo().CreateFocusSession(m.ctx, s); err != nil {
2426+
return errMsg{Err: err}
2427+
}
2428+
return nil
2429+
}
2430+
}
2431+
23602432
func (m *Model) deleteTaskCmd(id string) tea.Cmd {
23612433
return func() tea.Msg {
23622434
before, err := m.svc.GetByID(m.ctx, id)

internal/core/stats.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ type Event struct {
1717
Metadata string
1818
}
1919

20+
type FocusSession struct {
21+
ID string
22+
TaskID string
23+
StartTime time.Time
24+
EndTime *time.Time
25+
Duration time.Duration
26+
}
27+
2028
const (
2129
EventTypeTaskCreated = "task_created"
2230
EventTypeTaskCompleted = "task_completed"

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.5.7"))
64+
L.SetField(kairo, "version", lua.LString("1.6.0"))
6565

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

internal/storage/migrations.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ func migrate(ctx context.Context, db *sql.DB) error {
8686
ALTER TABLE tasks ADD COLUMN project TEXT;
8787
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project);
8888
`},
89+
{6, `
90+
CREATE TABLE IF NOT EXISTS focus_sessions (
91+
id TEXT PRIMARY KEY,
92+
task_id TEXT NULL,
93+
start_time_ms INTEGER NOT NULL,
94+
end_time_ms INTEGER NULL,
95+
duration_ms INTEGER NOT NULL DEFAULT 0,
96+
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE SET NULL
97+
);
98+
CREATE INDEX IF NOT EXISTS idx_focus_sessions_start ON focus_sessions(start_time_ms);
99+
CREATE INDEX IF NOT EXISTS idx_focus_sessions_task ON focus_sessions(task_id);
100+
`},
89101
}
90102

91103
for _, s := range steps {

internal/storage/repo.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,3 +890,58 @@ func (r *Repository) ListEvents(ctx context.Context) ([]core.Event, error) {
890890
}
891891
return out, rows.Err()
892892
}
893+
894+
func (r *Repository) CreateFocusSession(ctx context.Context, s core.FocusSession) error {
895+
var taskID any
896+
if s.TaskID != "" {
897+
taskID = s.TaskID
898+
}
899+
var endTime any
900+
if s.EndTime != nil {
901+
endTime = s.EndTime.UTC().UnixMilli()
902+
}
903+
_, err := r.db.ExecContext(ctx, `
904+
INSERT INTO focus_sessions (id, task_id, start_time_ms, end_time_ms, duration_ms)
905+
VALUES (?, ?, ?, ?, ?)
906+
`, s.ID, taskID, s.StartTime.UTC().UnixMilli(), endTime, int64(s.Duration/time.Millisecond))
907+
return err
908+
}
909+
910+
func (r *Repository) ListFocusSessions(ctx context.Context) ([]core.FocusSession, error) {
911+
rows, err := r.db.QueryContext(ctx, `
912+
SELECT id, task_id, start_time_ms, end_time_ms, duration_ms
913+
FROM focus_sessions
914+
ORDER BY start_time_ms DESC
915+
`)
916+
if err != nil {
917+
return nil, err
918+
}
919+
defer func() {
920+
if err := rows.Close(); err != nil {
921+
_ = err
922+
}
923+
}()
924+
var out []core.FocusSession
925+
for rows.Next() {
926+
var id string
927+
var taskID sql.NullString
928+
var startMs int64
929+
var endMs sql.NullInt64
930+
var durMs int64
931+
if err := rows.Scan(&id, &taskID, &startMs, &endMs, &durMs); err != nil {
932+
return nil, err
933+
}
934+
s := core.FocusSession{
935+
ID: id,
936+
TaskID: taskID.String,
937+
StartTime: time.UnixMilli(startMs).UTC(),
938+
Duration: time.Duration(durMs) * time.Millisecond,
939+
}
940+
if endMs.Valid {
941+
t := time.UnixMilli(endMs.Int64).UTC()
942+
s.EndTime = &t
943+
}
944+
out = append(out, s)
945+
}
946+
return out, rows.Err()
947+
}

0 commit comments

Comments
 (0)