pomodoro.nvim

July 11, 2026 · View on GitHub

pomodoro.nvim

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.

Neovim Lua CI License: MIT Stars

Features · Focus mode · Install · Quickstart · Config · Commands · API · Recipes · FAQ


Starting a pomodoro, the pinned status window, a work→break transition, and the history float

Timer sped up for the demo. Reproduce it anytime: vhs scripts/demo.tape

✨ Features

  • 🍅 Classic Pomodoro cycles — 25 / 5 / 15 minute defaults, long break every 4th work block, all configurable
  • ⏱️ One-off durations:Pomodoro start 45 runs a single 45-minute block without touching your config
  • 🔔 Editor-native notificationsvim.notify (lights up nvim-notify / noice automatically) and/or a transient floating window
  • 📊 Per-day stats — completed work blocks, focused minutes, long-break count, persisted atomically as JSON
  • 📅 History & streaks:Pomodoro history floats the last N days; streaks track consecutive days hitting your goal
  • 🔉 Sound alerts (opt-in) — play any system command on phase end; sensible macOS default
  • 🪟 Toggleable status window — pinned, borderless card with phase-colored header, live progress bar, and today counter
  • 🛎️ Continue / stop prompt — when auto-start is off, each phase ends with a vim.ui.select asking whether to begin the next phase or stop
  • 🎯 Focus mode (opt-in) — block configured : commands during work; optionally mute diagnostics
  • 🧩 Renderer-agnostic statusline — drop-in component for lualine, heirline, or your own statusline
  • 🔭 Optional Telescope picker — last 30 days at a glance, only loaded if Telescope is present
  • 🪝 Hookson_work_start, on_break_start, on_cycle_complete, … wire your own behavior
  • 🪶 Zero required dependencies — pure Lua, stdlib only
  • Tested — 100+ plenary-busted specs, CI on stable + nightly Neovim

🎯 Focus mode

The 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.

Focus mode: :Lazy and :Mason blocked mid-work, diagnostics silenced, inactive window dimmed — then allowed again on the break

Timer sped up for the demo. Reproduce it anytime: vhs scripts/demo_focus.tape

While a work phase is running, focus mode can:

  • Block : commands you name — reach for :Lazy mid-flow and Neovim politely refuses
  • Silence diagnostics — no wall of red baiting you into a cleanup detour (signs stay, virtual text goes)
  • Dim inactive windows — attention stays on the buffer you're actually working in

The 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,
  },
})

📦 Requirements

  • Neovim ≥ 0.10
  • Optionaltelescope.nvim for the stats picker
  • Optionalnvim-notify for prettier toasts (any vim.notify replacement works)

📥 Installation

lazy.nvim
{
  "yal212/pomodoro.nvim",
  cmd = "Pomodoro",
  ---@type pomodoro.Config
  opts = {
    -- your config; see :help pomodoro-config
  },
}
packer.nvim
use({
  "yal212/pomodoro.nvim",
  config = function()
    require("pomodoro").setup({})
  end,
})
vim-plug
Plug 'yal212/pomodoro.nvim'

" In your init.lua, after plug#end():
lua require("pomodoro").setup({})

🚀 Quickstart

: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.

⚙️ Configuration

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.

Click to view all defaults
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,
  },
})

📋 Commands

Everything lives under a single :Pomodoro {subcommand} command. Subcommands are case-insensitive (:Pomodoro Start == :Pomodoro start) and <Tab>-completable.

SubcommandArgsDescription
: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 pausePause the active phase, preserving remaining time.
:Pomodoro resumeResume a paused phase.
:Pomodoro stopStop and reset to idle.
:Pomodoro skipEnd the current phase immediately and advance. Skipped work blocks are not counted in stats or hooks.
:Pomodoro restartRestart the current phase from the beginning.
:Pomodoro statusToggle the floating status window.
:Pomodoro statsShow today + last 7 days summary and current streak.
:Pomodoro history[{days}]Float the last N days (default 14). Close with q or <Esc>.
:Pomodoro resetWipe persisted stats (with confirm prompt).

🧰 Lua API

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

🍳 Recipes

Lualine — drop-in
require("lualine").setup({
  sections = {
    lualine_x = {
      function() return require("pomodoro.statusline").component() end,
      "encoding", "fileformat", "filetype",
    },
  },
})
Lualine — colored by phase
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" } },
})
Native statusline (no plugin)
vim.o.statusline = "%f %m %= %{v:lua.require('pomodoro').statusline()} "
System notification on break (macOS)
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,
  },
})
System notification on break (Linux)
require("pomodoro").setup({
  hooks = {
    on_break_start = function(p)
      vim.fn.jobstart({ "notify-send", "Pomodoro", "Break — " .. p.duration_min .. " min" })
    end,
  },
})
Lock yourself out of distractions while working
require("pomodoro").setup({
  focus = {
    enabled = true,
    blocked_commands   = { "Lazy", "Mason", "Telescope" },
    silent_diagnostics = true,
    dim_inactive       = true,  -- dim non-current windows during work
  },
})
Ding when a phase ends
require("pomodoro").setup({
  sound = { enabled = true },                              -- macOS: system sound via afplay
  -- sound = { enabled = true, cmd = { "paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga" } },  -- Linux
})

🔭 Telescope

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).

🎨 Highlights

All groups are defined with default = true links, so your colorscheme or config can override them freely:

GroupDefault linkUsed for
PomodoroWorkDiagnosticWarnWork phase header / statusline
PomodoroBreakDiagnosticOkBreak phase header / statusline
PomodoroPausedDiagnosticHintPaused state
PomodoroIdleCommentIdle state
PomodoroProgressDiagnosticInfoProgress bar fill
PomodoroProgressTrackNonTextProgress bar track
PomodoroDimCommentMuted text in the status window
PomodoroDimNCCommentInactive windows when focus.dim_inactive is on
PomodoroTitleFloatTitleFloat titles
vim.api.nvim_set_hl(0, "PomodoroWork", { fg = "#ff9e64", bold = true })

🩺 Health

:checkhealth pomodoro

Reports Neovim version, data-dir writability, sound-command availability (when enabled), and which optional integrations are available.

❓ FAQ

Does a running timer survive restarting Neovim?

No — only daily stats are persisted. Quitting Neovim mid-phase discards the running timer; the next launch starts idle, with your stats intact.

What happens when my laptop sleeps?

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".

Can I run multiple Neovim instances at once?

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.

How do I turn persistence off, or move the stats file?
require("pomodoro").setup({
  persistence = { enabled = false },  -- keep stats in memory only
  -- or: persistence = { path = vim.fn.expand("~/notes/pomodoro.json") },
})

🤝 Contributing

Issues and PRs welcome — see CONTRIBUTING.md for dev setup, test commands, and guidelines.

🙏 Acknowledgements

📄 License

MIT © yal212

If this plugin helps you ship, drop a ⭐ — it's the cheapest way to say thanks.