Skip to content

tdfacer/fsm.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fsm.nvim

Focus Set Manager for Neovim - manage named work contexts spanning i3 workspaces, tmux sessions, Neovim state, and browser windows.

Designed for DevOps workflows with heavy context switching. Start a focus, get a dedicated workspace with a tmux session and notes file. Suspend it to park browser windows and save state. Resume later exactly where you left off.

Features

  • Workspace Management: Automatically allocates i3 workspaces (10-19) for each focus
  • tmux Integration: One tmux session per focus, persists across suspend/resume
  • Session Persistence: Saves and restores Neovim sessions and working directory
  • Browser Parking: Parks browser windows to workspace 99 on suspend, restores on resume
  • Focus Files: Each focus gets notes.md and todo.md for context
  • Window Marking: Tracks windows via i3 marks for reliable state management
  • Telescope Integration: Quick focus switching with preview
  • Statusline Component: Show current focus in your statusline
  • Graceful Degradation: Works without i3 or tmux (with reduced functionality)

Requirements

Required:

  • Neovim 0.9+

Recommended:

  • i3 4.x (workspace management)
  • tmux 3.x (session management)
  • alacritty (default terminal)
  • firefox (default browser)

Optional:

Run :checkhealth fsm to verify your setup.

Installation

lazy.nvim

{
  "your-username/fsm.nvim",
  config = function()
    --- @module 'fsm'
    --- @type FocusConfigOpts
    require("fsm").setup({
      -- your configuration here
    })
  end,
  keys = {
    { "<leader>Fs", "<cmd>FocusStart<cr>", desc = "Start new focus" },
    { "<leader>Fw", "<cmd>FocusSwitch<cr>", desc = "Switch focus" },
    { "<leader>Fp", "<cmd>FocusSuspend<cr>", desc = "Suspend focus" },
    { "<leader>Fr", "<cmd>FocusResume<cr>", desc = "Resume focus" },
    { "<leader>Fl", "<cmd>FocusList<cr>", desc = "List focuses" },
    { "<leader>Fi", "<cmd>FocusStatus<cr>", desc = "Focus info/status" },
    { "<leader>Fh", "<cmd>FocusHealth<cr>", desc = "Focus health check" },
    { "<leader>FR", "<cmd>FocusRepair<cr>", desc = "Repair focuses" },
    { "<leader>Fn", "<cmd>FocusNotes<cr>", desc = "Open focus notes" },
    { "<leader>Ft", "<cmd>FocusTodo<cr>", desc = "Open focus todo" },
    { "<leader>FN", "<cmd>FocusQuickNote<cr>", desc = "Quick note" },
    { "<leader>Fu", "<cmd>FocusUrls<cr>", desc = "Open focus URLs" },
    { "<leader>FU", "<cmd>FocusAddUrl<cr>", desc = "Add URL to focus" },
    { "<leader>FL", "<cmd>FocusListUrls<cr>", desc = "List focus URLs" },
    { "<leader>Fe", "<cmd>FocusEditUrls<cr>", desc = "Edit focus URLs" },
    { "<leader>Fa", "<cmd>FocusArchive<cr>", desc = "Archive focus" },
    { "<leader>Fd", "<cmd>FocusDelete<cr>", desc = "Delete archived focus" },
  },
}

packer.nvim

use {
  "your-username/fsm.nvim",
  config = function()
    require("fsm").setup()
  end,
}

Configuration

require("fsm").setup({
  -- Workspace range for focus allocation (default: 10-19)
  workspace_range = { 10, 19 },

  -- Workspace for parked windows during suspend
  parking_workspace = 99,

  -- How to handle windows on suspend
  suspend_policy = {
    terminals = "keep",   -- "keep" | "park" | "close"
    browsers = "park",    -- "keep" | "park" | "close"
  },

  -- Application launch commands
  -- %s placeholders: terminal gets (slug, tmux_cmd), browser gets (urls)
  apps = {
    terminal = 'alacritty --class "focus-%s,Alacritty" -e %s',
    browser = "firefox --new-window %s",  -- --new-window ensures proper workspace placement
  },

  -- tmux configuration
  tmux = {
    enabled = true,
    session_prefix = "focus/",  -- tmux sessions named "focus/<slug>"
    track_cwd = true,           -- Track and restore working directory
  },

  -- Notes configuration
  notes = {
    auto_open = true,  -- Start nvim with notes.md in tmux session
  },

  -- URL configuration
  urls = {
    auto_open_on_resume = true,  -- Open saved URLs when resuming focus
  },

  -- Environment variable patterns to redact from saved state
  redact_env_vars = { ".*SECRET.*", ".*TOKEN.*", ".*KEY.*", ".*PASSWORD.*" },

  -- Data storage location
  data_dir = "~/.local/share/focus",
})

Commands

All commands that require a focus will show a picker if called without arguments.

Command Description
:FocusStart [name] Create focus, allocate workspace, launch terminal with nvim+notes
:FocusSuspend [slug] Save state, park browser windows (defaults to current focus)
:FocusResume [slug] Restore workspace, unpark windows, open URLs (picker if no arg)
:FocusSwitch Picker to suspend current + resume selected
:FocusList List all focuses with state indicators
:FocusStatus Show current focus details and driver availability
:FocusArchive [slug] Archive focus, kill tmux session (picker if no arg)
:FocusDelete [slug] Permanently delete archived focus (picker if no arg)
:FocusNotes [slug] Open notes.md for focus
:FocusTodo [slug] Open todo.md for focus
:FocusQuickNote [msg] Append timestamped note without opening file
:FocusUrls [slug] Open all saved URLs in browser
:FocusAddUrl [url] Add URL to current focus (prompts if no arg)
:FocusListUrls [slug] List saved URLs for focus
:FocusEditUrls [slug] Edit urls.txt file directly
:FocusHealth Show health status of all focuses
:FocusRepair [--dry-run] Fix orphaned focuses after crash/reboot

Keymaps

Recommended keymaps using <leader>F prefix. All commands work without arguments - they'll prompt or show a picker as needed.

local opts = { noremap = true, silent = true }

-- Core workflow
vim.keymap.set("n", "<leader>Fs", "<cmd>FocusStart<cr>", vim.tbl_extend("force", opts, { desc = "Start new focus" }))
vim.keymap.set("n", "<leader>Fw", "<cmd>FocusSwitch<cr>", vim.tbl_extend("force", opts, { desc = "Switch focus" }))
vim.keymap.set("n", "<leader>Fp", "<cmd>FocusSuspend<cr>", vim.tbl_extend("force", opts, { desc = "Suspend focus" }))
vim.keymap.set("n", "<leader>Fr", "<cmd>FocusResume<cr>", vim.tbl_extend("force", opts, { desc = "Resume focus" }))

-- Info and lists
vim.keymap.set("n", "<leader>Fl", "<cmd>FocusList<cr>", vim.tbl_extend("force", opts, { desc = "List focuses" }))
vim.keymap.set("n", "<leader>Fi", "<cmd>FocusStatus<cr>", vim.tbl_extend("force", opts, { desc = "Focus info" }))
vim.keymap.set("n", "<leader>Fh", "<cmd>FocusHealth<cr>", vim.tbl_extend("force", opts, { desc = "Focus health" }))
vim.keymap.set("n", "<leader>FR", "<cmd>FocusRepair<cr>", vim.tbl_extend("force", opts, { desc = "Repair focuses" }))

-- Focus files
vim.keymap.set("n", "<leader>Fn", "<cmd>FocusNotes<cr>", vim.tbl_extend("force", opts, { desc = "Focus notes" }))
vim.keymap.set("n", "<leader>Ft", "<cmd>FocusTodo<cr>", vim.tbl_extend("force", opts, { desc = "Focus todo" }))
vim.keymap.set("n", "<leader>FN", "<cmd>FocusQuickNote<cr>", vim.tbl_extend("force", opts, { desc = "Quick note" }))
vim.keymap.set("n", "<leader>Fu", "<cmd>FocusUrls<cr>", vim.tbl_extend("force", opts, { desc = "Open focus URLs" }))
vim.keymap.set("n", "<leader>FU", "<cmd>FocusAddUrl<cr>", vim.tbl_extend("force", opts, { desc = "Add URL to focus" }))
vim.keymap.set("n", "<leader>FL", "<cmd>FocusListUrls<cr>", vim.tbl_extend("force", opts, { desc = "List focus URLs" }))
vim.keymap.set("n", "<leader>Fe", "<cmd>FocusEditUrls<cr>", vim.tbl_extend("force", opts, { desc = "Edit focus URLs" }))

-- Lifecycle
vim.keymap.set("n", "<leader>Fa", "<cmd>FocusArchive<cr>", vim.tbl_extend("force", opts, { desc = "Archive focus" }))
vim.keymap.set("n", "<leader>Fd", "<cmd>FocusDelete<cr>", vim.tbl_extend("force", opts, { desc = "Delete focus" }))

which-key.nvim

require("which-key").register({
  ["<leader>F"] = {
    name = "+focus",
    s = { "<cmd>FocusStart<cr>", "Start new focus" },
    w = { "<cmd>FocusSwitch<cr>", "Switch focus" },
    p = { "<cmd>FocusSuspend<cr>", "Suspend focus" },
    r = { "<cmd>FocusResume<cr>", "Resume focus" },
    l = { "<cmd>FocusList<cr>", "List focuses" },
    i = { "<cmd>FocusStatus<cr>", "Focus info" },
    h = { "<cmd>FocusHealth<cr>", "Focus health" },
    R = { "<cmd>FocusRepair<cr>", "Repair focuses" },
    n = { "<cmd>FocusNotes<cr>", "Focus notes" },
    t = { "<cmd>FocusTodo<cr>", "Focus todo" },
    N = { "<cmd>FocusQuickNote<cr>", "Quick note" },
    u = { "<cmd>FocusUrls<cr>", "Open URLs" },
    U = { "<cmd>FocusAddUrl<cr>", "Add URL" },
    L = { "<cmd>FocusListUrls<cr>", "List URLs" },
    e = { "<cmd>FocusEditUrls<cr>", "Edit URLs" },
    a = { "<cmd>FocusArchive<cr>", "Archive focus" },
    d = { "<cmd>FocusDelete<cr>", "Delete focus" },
  },
})

Statusline

lualine.nvim

require("lualine").setup({
  sections = {
    lualine_x = {
      require("fsm.ui.status").lualine_component(),
      "encoding",
      "fileformat",
      "filetype",
    },
  },
})

Manual Integration

local status = require("fsm.ui.status")

-- Returns "● Focus Name" when active, "" otherwise
status.statusline()

-- Returns "● Focus Name [10]" with workspace number
status.statusline_full()

-- Individual components
status.focus_name()   -- "my-focus" or ""
status.focus_icon()   -- "●" (active), "○" (suspended), "◌" (archived)
status.has_focus()    -- boolean

Lua API

local fsm = require("fsm")

-- Start a new focus
fsm.start("incident-rds-outage", { urls = { "https://console.aws.amazon.com" } })

-- Suspend current or specific focus
fsm.suspend()           -- current
fsm.suspend("my-focus") -- specific

-- Resume a focus
fsm.resume("my-focus")

-- Switch (suspend current + resume target)
fsm.switch("other-focus")

-- Archive a focus (also kills tmux session)
fsm.archive("old-focus")

-- Delete a focus permanently (must be archived first)
fsm.delete("old-focus")
fsm.delete("active-focus", { force = true })  -- skip archive check

-- List focuses
fsm.list()                        -- all
fsm.list({ state = "active" })    -- filtered
fsm.list({ state = "suspended" })
fsm.list({ state = "archived" })

-- Get current focus
fsm.current()        -- returns slug or nil
fsm.current_focus()  -- returns full metadata or nil

-- Status info
fsm.status()  -- { has_focus, focus, i3_available, tmux_available, ... }

-- Focus files (works on archived focuses too)
fsm.notes("my-focus")
fsm.todo("my-focus")

-- URL management
fsm.add_url("https://example.com")              -- add to current focus
fsm.add_url("https://example.com", "my-focus")  -- add to specific focus
fsm.add_urls({ "https://a.com", "https://b.com" }, "my-focus")
fsm.open_urls()           -- open URLs for current focus in browser
fsm.open_urls("my-focus") -- open URLs for specific focus
fsm.list_urls("my-focus") -- returns array of URLs

Data Storage

Focus data is stored at ~/.local/share/focus/foci/<slug>/:

~/.local/share/focus/foci/incident-rds/
├── meta.json         # Focus metadata (name, state, workspace, etc.)
├── nvim_session.vim  # Neovim session (:mksession output)
├── nvim_state.json   # Buffer list, cwd
├── i3_layout.json    # Workspace layout (if captured)
├── i3_marks.json     # Window marks
├── urls.txt          # Canonical URLs (one per line)
├── notes.md          # Focus notes
└── todo.md           # Focus todos

Focus Lifecycle

  ┌─────────┐
  │  Start  │  :FocusStart "name"
  └────┬────┘
       │
       ▼
  ┌─────────┐     :FocusSuspend
  │ Active  │◄─────────────────┐
  └────┬────┘                  │
       │                       │
       │ :FocusSuspend         │ :FocusResume
       ▼                       │
  ┌─────────┐                  │
  │Suspended│──────────────────┘
  └────┬────┘
       │
       │ :FocusArchive
       ▼
  ┌─────────┐
  │Archived │  tmux session killed, notes still readable
  └────┬────┘
       │
       │ :FocusDelete
       ▼
     (gone)   data directory removed

Archived Focuses

When you archive a focus:

  • The tmux session is killed (no more terminal)
  • The workspace number is freed for reuse
  • Notes and todos are preserved and still readable via :FocusNotes/:FocusTodo
  • The focus cannot be resumed

Archived focuses are kept for reference. When you're done with them, use :FocusDelete to permanently remove the data.

Workflow Example

# Start working on an incident
:FocusStart RDS Outage Investigation

# You now have:
# - Workspace 10:FOCUS-rds-outage-investigation
# - tmux session focus/rds-outage-investigation
# - Neovim open with notes.md in the tmux session
# - Alacritty terminal attached to tmux

# Open relevant URLs, work on the issue...
# Get interrupted by something urgent

:FocusSuspend
# Browser windows parked to workspace 99
# Neovim session saved

:FocusStart Urgent Deploy Fix
# New workspace, new tmux session, fresh context

# Fix the urgent issue, then switch back
:FocusSwitch
# Telescope picker shows your focuses
# Select "rds-outage-investigation"
# Suspends current, resumes previous
# Browser windows restored, back where you left off

# Done with the incident
:FocusArchive rds-outage-investigation
# tmux session killed, notes preserved

# Later, clean up old focuses
:FocusDelete
# Picker shows archived focuses, select to delete

Focus States

State Icon Description
active Currently active, has allocated workspace
suspended Saved state, windows parked, can resume
archived Read-only, workspace freed, notes preserved

tmux Configuration

Session Name Truncation

By default, tmux may truncate long session names in the status bar. Add to your ~/.tmux.conf:

# Show full session name (adjust width as needed)
set -g status-left-length 40
set -g status-left '[#{session_name}] '

Shorter Session Names

Alternatively, use a shorter prefix in your FSM config:

require("fsm").setup({
  tmux = {
    session_prefix = "f/",  -- "f/my-focus" instead of "focus/my-focus"
  },
})

Working Directory Tracking

When track_cwd is enabled (default), FSM will:

  • Capture the current tmux pane's working directory when suspending a focus
  • Restore that directory when resuming the focus
  • Use the current Neovim working directory when starting a new focus

This ensures you return to the exact location you were working in:

require("fsm").setup({
  tmux = {
    track_cwd = true,  -- Enable directory tracking (default)
  },
})

Example workflow:

  1. Start a focus: cd ~/projects/api && nvim:FocusStart API Work
  2. Navigate somewhere: cd src/handlers
  3. Suspend the focus: :FocusSuspend
  4. Resume later: :FocusResume api-work
  5. Your tmux session opens in ~/projects/api/src/handlers

To disable this feature:

tmux = { track_cwd = false }

Tips

  • Naming: Use descriptive names like "incident-rds-outage" or "feature-user-auth" - they become slugs and tmux session names
  • URLs: Add canonical URLs to urls.txt for quick reference
  • Notes: Use notes.md for context that helps you resume - what you were doing, next steps
  • Workspaces: Keep workspaces 1-9 for non-focus work; FSM uses 10-19 by default
  • Parking: Workspace 99 holds parked browser windows - don't put other stuff there
  • Cleanup: Periodically run :FocusDelete to clean up old archived focuses

Health Check

Run :checkhealth fsm to verify:

  • Neovim version
  • i3 availability and connection
  • tmux installation
  • Terminal (alacritty) availability
  • Browser (firefox) availability
  • Data directory status
  • Optional dependencies (telescope)

Troubleshooting

Browser Opens in Wrong Window

By default, FSM uses firefox --new-window to ensure URLs open in a new window on the correct workspace. If you're still experiencing issues or want different behavior:

require("fsm").setup({
  apps = {
    -- Force new window (default)
    browser = "firefox --new-window %s",

    -- Or open in new tab of existing window
    browser = "firefox --new-tab %s",

    -- Or use a specific profile
    browser = "firefox --new-window --profile work %s",
  },
})

Focus Picker Not Working

If keyboard shortcuts like <leader>fs (suspend) aren't showing the picker, ensure you're not passing an argument. The picker only appears when no argument is provided:

  • :FocusSuspend - Shows picker
  • :FocusSuspend my-focus - Suspends specific focus directly

After Crash/Reboot

After a crash or reboot, your focuses may be in an inconsistent state - marked as "active" but with missing workspaces and tmux sessions. Use these commands to diagnose and fix:

" Check health of all focuses
:FocusHealth

" Preview what would be fixed (dry run)
:FocusRepair --dry-run

" Fix orphaned focuses (resets them to suspended state)
:FocusRepair

After repair, you can resume your focuses normally with :FocusResume.

Cannot Create Focus (Name Conflict)

If you get "Focus already exists" but can't find it, the focus directory may exist without proper metadata. Check:

ls ~/.local/share/focus/foci/

You can manually delete orphaned directories or use :FocusDelete with force = true via the Lua API.

Utility Scripts

FSM includes bash scripts for managing workspaces and focuses from outside Neovim. See bin/README.md for details:

  • fsm-workspace-switch - Quick workspace switching with dmenu/rofi
  • fsm-control - Full FSM control panel via dmenu/rofi
  • fsm-ws - Terminal-based workspace switcher using fzf

Example i3 keybindings:

# Quick workspace switch
bindsym $mod+Tab exec --no-startup-id ~/code/fsm.nvim/bin/fsm-workspace-switch rofi

# FSM control panel
bindsym $mod+Shift+f exec --no-startup-id ~/code/fsm.nvim/bin/fsm-control rofi

License

MIT

About

Focus set manager for neovim

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published