A focus-first Pomodoro timer for developers who live in Neovim.
Work / break cycles, editor-native notifications, per-day stats, an opt-in focus mode that mutes distractions while you ship.
Features · Focus mode · Install · Quickstart · Config · Commands · API · Recipes · FAQ

Timer sped up for the demo. Reproduce it anytime: vhs scripts/demo.tape
:Pomodoro start 45 runs a single 45-minute block without touching your configvim.notify (lights up nvim-notify / noice automatically) and/or a transient floating window:Pomodoro history floats the last N days; streaks track consecutive days hitting your goalvim.ui.select asking whether to begin the next phase or stop: commands during work; optionally mute diagnosticslualine, heirline, or your own statuslineon_work_start, on_break_start, on_cycle_complete, … wire your own behaviorThe feature this plugin exists for. A timer in your menu bar is easy to ignore — and it does nothing about the distraction that actually gets you, which is the editor itself. Focus mode makes a work block mean something.

Timer sped up for the demo. Reproduce it anytime: vhs scripts/demo_focus.tape
While a work phase is running, focus mode can:
: commands you name — reach for :Lazy mid-flow and Neovim politely refusesThe moment the break starts, everything comes back exactly as it was. It's opt-in and off by default:
require("pomodoro").setup({
focus = {
enabled = true,
blocked_commands = { "Lazy", "Mason", "Telescope" }, -- your rabbit holes
silent_diagnostics = true,
dim_inactive = true,
},
})
telescope.nvim for the stats pickernvim-notify for prettier toasts (any vim.notify replacement works){
"yal212/pomodoro.nvim",
cmd = "Pomodoro",
---@type pomodoro.Config
opts = {
-- your config; see :help pomodoro-config
},
}
use({
"yal212/pomodoro.nvim",
config = function()
require("pomodoro").setup({})
end,
})
Plug 'yal212/pomodoro.nvim'
" In your init.lua, after plug#end():
lua require("pomodoro").setup({})
:Pomodoro start " 25-minute work block
:Pomodoro start 45 " one-off 45-minute work block
:Pomodoro skip " end current phase, advance to next (doesn't count it)
:Pomodoro restart " restart current phase from the beginning
:Pomodoro status " toggle floating status window
:Pomodoro pause " pause; remaining time preserved
:Pomodoro resume
:Pomodoro stop
:Pomodoro stats " today + last 7 days + streak
:Pomodoro history " last 14 days in a float (q/<Esc> closes)
Subcommands are case-insensitive — :Pomodoro Start works too — and <Tab> completes them.
No keys are bound by default. Every action ships a <Plug> mapping — <Plug>(PomodoroStart), (PomodoroPause), (PomodoroResume), (PomodoroStop), (PomodoroSkip), (PomodoroRestart), (PomodoroStatus), (PomodoroStats), (PomodoroHistory) — so suggested keymaps look like:
local map = vim.keymap.set
map("n", "<leader>ps", "<Plug>(PomodoroStart)", { desc = "Pomodoro: start" })
map("n", "<leader>pp", "<Plug>(PomodoroPause)", { desc = "Pomodoro: pause" })
map("n", "<leader>pr", "<Plug>(PomodoroResume)", { desc = "Pomodoro: resume" })
map("n", "<leader>px", "<Plug>(PomodoroStop)", { desc = "Pomodoro: stop" })
map("n", "<leader>pw", "<Plug>(PomodoroStatus)", { desc = "Pomodoro: window" })
map("n", "<leader>pS", "<Plug>(PomodoroStats)", { desc = "Pomodoro: stats" })
With lazy.nvim, putting them in keys also lazy-loads the plugin on first press:
{
"yal212/pomodoro.nvim",
keys = {
{ "<leader>ps", "<Plug>(PomodoroStart)", desc = "Pomodoro: start" },
{ "<leader>pw", "<Plug>(PomodoroStatus)", desc = "Pomodoro: window" },
},
}
Plain <cmd>Pomodoro start<cr> mappings work just as well if you prefer them.
setup() is not required — :Pomodoro initializes itself with defaults on first use. Pass any subset of the table below to override. Options are validated: an invalid value (say status_window.anchor = "TOP", or a non-numeric width) makes setup() fail immediately with a clear pomodoro:-prefixed error instead of surfacing later as a raw runtime error.
require("pomodoro").setup({
-- Phase durations (minutes)
durations = {
work = 25,
short_break = 5,
long_break = 15,
},
-- Long break every Nth completed work block
cycles_per_long_break = 4,
-- Target work blocks per day (0 = disabled)
daily_goal = 0,
-- Phase transition behavior
auto_start_break = true, -- break begins immediately; if false a Continue/Stop prompt appears
auto_start_work = false, -- next work block requires :Pomodoro start (or Continue from prompt)
-- Notification channels (any subset, in display order)
notify_styles = { "vim_notify", "float" },
notify = {
float_duration_ms = 4000,
},
-- Opt-in sound on phase end (not played on skip)
sound = {
enabled = false,
cmd = nil, -- string (run via sh -c) or argv table, e.g. { "paplay", "/path/ding.wav" }
-- nil → afplay with a system sound on macOS
},
-- Statusline component appearance
statusline = {
icon = "",
show_when_idle = false,
format = "%s %s", -- icon, body
refresh_ms = 250, -- live tick while a phase is running
condition = nil, -- function(ctx) return boolean end
},
-- Toggleable pinned status window (borderless card)
status_window = {
border = "none",
width = 36,
height = 5,
anchor = "NE",
row = 1,
col_offset = 2,
refresh_ms = 250,
show_progress_bar = true,
show_today = true,
title = nil, -- optional float title, e.g. " pomodoro "
title_pos = "center", -- "left" | "center" | "right"
icons = {
work = "▶",
short_break = "•",
long_break = "★",
paused = "❚❚",
idle = "○",
},
},
-- Opt-in focus enforcement
focus = {
enabled = false,
blocked_commands = {}, -- e.g. { "Lazy", "Mason", "Telescope" }
silent_diagnostics = false,
dim_inactive = false,
},
-- JSON stats on disk
persistence = {
enabled = true,
path = nil, -- nil → vim.fn.stdpath('data') .. '/pomodoro/stats.json'
},
-- Lifecycle hooks
hooks = {
on_work_start = nil, -- function(payload) end
on_work_end = nil,
on_break_start = nil,
on_break_end = nil,
on_cycle_complete = nil,
},
})
Everything lives under a single :Pomodoro {subcommand} command. Subcommands are case-insensitive (:Pomodoro Start == :Pomodoro start) and <Tab>-completable.
| Subcommand | Args | Description |
|---|---|---|
:Pomodoro start |
[work|short|long|{minutes}] |
Start a phase. Defaults to next in cycle, or resumes if paused. A number starts a one-off work block of that length. |
:Pomodoro pause |
— | Pause the active phase, preserving remaining time. |
:Pomodoro resume |
— | Resume a paused phase. |
:Pomodoro stop |
— | Stop and reset to idle. |
:Pomodoro skip |
— | End the current phase immediately and advance. Skipped work blocks are not counted in stats or hooks. |
:Pomodoro restart |
— | Restart the current phase from the beginning. |
:Pomodoro status |
— | Toggle the floating status window. |
:Pomodoro stats |
— | Show today + last 7 days summary and current streak. |
:Pomodoro history |
[{days}] |
Float the last N days (default 14). Close with q or <Esc>. |
:Pomodoro reset |
— | Wipe persisted stats (with confirm prompt). |
local pomo = require("pomodoro")
pomo.setup({}) -- merge config (idempotent)
pomo.start("work" | "short" | "long" | nil)
pomo.start(45) -- one-off 45-minute work block
pomo.pause()
pomo.resume()
pomo.stop()
pomo.skip()
pomo.restart()
pomo.status() -- toggle status window
pomo.stats_summary() -- print today + week + streak via vim.notify
pomo.history(14) -- float the last 14 days
pomo.reset_stats()
pomo.statusline() -- string for your statusline
-- Lower level
require("pomodoro.statusline").component() -- string
require("pomodoro.statusline").component_lualine() -- { text, hl }
require("pomodoro.stats").today() -- table
require("pomodoro.stats").last_n_days(7) -- table[]
require("pomodoro.stats").streak(goal) -- consecutive days meeting goal
require("lualine").setup({
sections = {
lualine_x = {
function() return require("pomodoro.statusline").component() end,
"encoding", "fileformat", "filetype",
},
},
})
local function pomo()
local s = require("pomodoro.statusline").component_lualine()
if s.text == "" then return "" end
return "%#" .. s.hl .. "#" .. s.text
end
require("lualine").setup({
sections = { lualine_x = { pomo, "filetype" } },
})
vim.o.statusline = "%f %m %= %{v:lua.require('pomodoro').statusline()} "
require("pomodoro").setup({
hooks = {
on_break_start = function(p)
vim.fn.jobstart({
"terminal-notifier",
"-title", "Pomodoro",
"-message", "Break time — " .. p.duration_min .. " min",
"-sound", "Glass",
})
end,
},
})
require("pomodoro").setup({
hooks = {
on_break_start = function(p)
vim.fn.jobstart({ "notify-send", "Pomodoro", "Break — " .. p.duration_min .. " min" })
end,
},
})
require("pomodoro").setup({
focus = {
enabled = true,
blocked_commands = { "Lazy", "Mason", "Telescope" },
silent_diagnostics = true,
dim_inactive = true, -- dim non-current windows during work
},
})
require("pomodoro").setup({
sound = { enabled = true }, -- macOS: system sound via afplay
-- sound = { enabled = true, cmd = { "paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga" } }, -- Linux
})
If nvim-telescope/telescope.nvim is installed, an extension is registered automatically:
:Telescope pomodoro stats
Last 30 days; preview pane shows that day's breakdown (work blocks, long breaks, minutes focused).
All groups are defined with default = true links, so your colorscheme or config can override them freely:
| Group | Default link | Used for |
|---|---|---|
PomodoroWork |
DiagnosticWarn |
Work phase header / statusline |
PomodoroBreak |
DiagnosticOk |
Break phase header / statusline |
PomodoroPaused |
DiagnosticHint |
Paused state |
PomodoroIdle |
Comment |
Idle state |
PomodoroProgress |
DiagnosticInfo |
Progress bar fill |
PomodoroProgressTrack |
NonText |
Progress bar track |
PomodoroDim |
Comment |
Muted text in the status window |
PomodoroDimNC |
Comment |
Inactive windows when focus.dim_inactive is on |
PomodoroTitle |
FloatTitle |
Float titles |
vim.api.nvim_set_hl(0, "PomodoroWork", { fg = "#ff9e64", bold = true })
:checkhealth pomodoro
Reports Neovim version, data-dir writability, sound-command availability (when enabled), and which optional integrations are available.
No — only daily stats are persisted. Quitting Neovim mid-phase discards the running timer; the next launch starts idle, with your stats intact.
Timers are driven by libuv, which stalls while the system is suspended, so a phase is effectively extended by however long the machine slept. Treat a work block as "time Neovim was actually awake".
Yes. They share one stats file, and every save is atomic (temp file + rename), so nothing corrupts. Each save also re-reads the file and merges that instance's new counts on top, so same-day counts from parallel instances add up — completing blocks in two Neovims at once records all of them.
require("pomodoro").setup({
persistence = { enabled = false }, -- keep stats in memory only
-- or: persistence = { path = vim.fn.expand("~/notes/pomodoro.json") },
})
Issues and PRs welcome — see CONTRIBUTING.md for dev setup, test commands, and guidelines.
vim.uv, vim.notify, vim.json, and :checkhealthnvim-lua/plenary.nvim for the test harnessMIT © yal212