跳到正文
GitHub·· 6 天前AI 评分50

claude-deck.nvim:在 Neovim 中并排管理多个 Claude Code 会话

tbsmcd/claude-deck.nvim

AI 导读

tbsmcd/claude-deck.nvim 是一个 Neovim 插件,可在终端中并排运行多个 Claude Code 会话,并在 winbar 中按状态着色显示每个会话的 id、状态和任务标题。

正文

claude-deck.nvim

English | 日本語

Run multiple Claude Code sessions side by side in Neovim terminals, and always know which one needs you. Think of it as a deck: one place where all your sessions are laid out in view.

  • Status at a glance: each terminal shows its id, status and task title in the winbar (colored per status), and its cwd and session id in the statusline
  • Desktop notifications when a session finishes or asks for permission (skipped for the terminal you are looking at)
  • Many sessions: split right / below, hide with :q (the session keeps running), bring back from a picker
  • Open in any directory from a directory picker
  • Fork a session into a new terminal (--resume <id> --fork-session)
  • Per-terminal model: start a terminal with --model <model> (new(where, { model = "haiku" }), :ClaudeDeck new --model haiku)
  • Focus mode: a new tab with file tree | editor | terminal in the terminal's cwd; toggle back to your previous layout
  • Point Claude at your code: one key inserts the current file and line (or selected lines) into Claude's prompt, e.g. app/models/user.rb:24-58
  • Cross-session awareness: Claude inside a terminal can run ct list (also ct list --json) / ct read <id> to see what the other sessions are doing, and ct spawn to start a new session in a hidden terminal (you approve each spawn)
  • Claude shows you the code: Claude can open a file (ct open) or the diff of the pull request (ct diff) in the editor next to its terminal

Four Claude Code sessions side by side, with status colors in the winbar

New here? Read the user guide: it walks through a typical session, from opening a terminal to notifications and focus mode.

Requirements

Required:

Optional (everything works without them):

  • fzf-lua: pickers with split keys; falls back to vim.ui.select
  • nvim-tree.lua: file tree in focus mode; falls back to netrw
  • zoxide: extra directories in pick_dir()
  • jq: pretty-prints the JSON of :ClaudeDeck settings
  • GitHub CLI (gh): the diff of the pull request for ct diff; without it (or without a pull request), the uncommitted changes are shown
  • terminal-notifier (macOS, brew install terminal-notifier): desktop notifications that bring back the terminal app when clicked
  • Desktop notifications otherwise: osascript on macOS (built in; lsappinfo is also used to check the frontmost app), notify-send on Linux, the terminal's own notifications (notify.method = "osc"), or your own notify.notifier

The hook script and ct are POSIX sh scripts that call nvim --server, so nvim must be on the PATH seen by Claude Code.

Installation

lazy.nvim:

{
    "tbsmcd/claude-deck.nvim",
    cmd = "ClaudeDeck",
    -- Optional: fzf-lua for the pickers, nvim-tree.lua for the focus mode tree
    -- Uncomment to install them together:
    -- dependencies = { "ibhagwan/fzf-lua", "nvim-tree/nvim-tree.lua" },
    opts = {},
    keys = {
        { "<leader>cc", function() require("claude-deck").toggle() end, desc = "Claude: open / focus / add" },
        { "<leader>cv", function() require("claude-deck").new("right") end, desc = "Claude: new to the right" },
        { "<leader>cs", function() require("claude-deck").new("below") end, desc = "Claude: new below" },
        { "<leader>cl", function() require("claude-deck").list() end, desc = "Claude: list terminals" },
        { "<leader>cd", function() require("claude-deck").pick_dir() end, desc = "Claude: new in directory" },
        { "<leader>cf", function() require("claude-deck").fork() end, desc = "Claude: fork session" },
        { "<leader>cr", function() require("claude-deck").rename() end, desc = "Claude: rename task" },
        { "<leader>co", function() require("claude-deck").focus() end, desc = "Claude: focus mode" },
        { "<leader>cR", function() require("claude-deck").redraw() end, desc = "Claude: redraw terminal" },
        { "<leader>cp", function() require("claude-deck").send_location() end, mode = { "n", "x" }, desc = "Claude: send file location" },
    },
}

No keymaps are created by default. (Instead of keys, send_location() can also be mapped with the keymaps.send_location option.)

Options go in opts (see Configuration), for example:

opts = {
    dir_roots = { "~/src" },
    labels = { waiting = "Your turn", attention = "Approve?" },
},

Usage

Lua API Command Description
toggle() :ClaudeDeck Outside a terminal: focus a visible one, or open a new one. Inside: add one to the right
new(where?, opts?) :ClaudeDeck new [right|below] [--model <model>] Open a new terminal by splitting the current window. opts.model: model for Claude Code (--model; an alias such as haiku or a full id)
list(opts?) :ClaudeDeck list Pick a terminal (including hidden ones) to show
pick_dir(opts?) :ClaudeDeck dir Pick a directory and open a new terminal there
fork(where?, opts?) :ClaudeDeck fork [right|below] [--model <model>] Fork the current session into a new terminal; it keeps the model of the current terminal unless opts.model is given
rename() :ClaudeDeck rename Rename the current task
redraw() :ClaudeDeck redraw Draw the current terminal again at the current width by resuming the same session
show_settings() :ClaudeDeck settings Show the Claude Code settings JSON (hooks, permissions)
focus() :ClaudeDeck focus Toggle focus mode
send_location() :ClaudeDeck location Insert the current file path and line (or selected lines) into Claude's prompt
open_file(path, line?, id?) :ClaudeDeck open <path>[:line] Open a file in the editor of a terminal's focus mode (see ct open)
open_diff(opts?) :ClaudeDeck diff [<path>[:line]] [--pr <pr>] [--base <ref>] Open the diff of the pull request (or the uncommitted changes) there (see ct diff)
spawn(opts) Start a new session in a hidden terminal without changing your screen and return its id (see ct spawn)
send(id, prompt) Type a task into an idle or waiting terminal and return the request id (see ct send)
show(id, where?) :ClaudeDeck show <id> Show a terminal by id

Where a new terminal opens (without where):

  • inside a terminal: split to the right
  • in an empty, unnamed buffer (e.g. right after starting Neovim): in place
  • otherwise: at the far right (width_ratio of the editor width)

In the fzf-lua pickers, enter opens with the rule above, ctrl-v splits right and ctrl-s splits below. The directory picker also has tab (go into the selected directory) and shift-tab (go up to the parent).

pick_dir() offers, in this order: the current directory, the directories directly under it, cwds of terminals, the directories directly under dir_roots, and zoxide directories. Hidden directories (names starting with .) are skipped when listing the directories directly under a directory, but not in terminal cwds or zoxide. With fzf-lua you can browse from there: tab lists the selected directory itself (to open there) and the directories directly under it, and shift-tab does the same for the parent (from the first list, the parent of the current directory). Pressing tab when nothing matches shows the same list again. With vim.ui.select only the first list is shown.

Terminal list in fzf-lua

Focus mode

focus() opens a new tab with file tree | editor | terminal in the terminal's cwd. Run it again in that tab to return to your previous layout.

Focus mode: file tree, editor and terminal

Send the file and line to Claude

This is made for focus mode: with the editor and the terminal side by side, run send_location() (:ClaudeDeck location) in the editor to point Claude at the code you are reading. (It also works outside focus mode when exactly one terminal is visible in the current tab, and in the diff buffer of ct diff, where it sends the file and line under the cursor.) It inserts the file path and line into Claude's prompt and moves to the terminal, so you can go on typing your instructions:

  • normal mode: the cursor line, e.g. app/services/example.rb:24
  • Visual mode (v, V or CTRL-V): the selected lines, e.g. app/services/example.rb:24-58

The path is relative to the terminal's cwd (outside it: an absolute path, with ~ for the home directory), followed by a space. A path containing spaces is inserted as it is, without escaping. Nothing is submitted: no Enter is sent, and only the path and line numbers go in, even when the file has unsaved changes. The location goes to the terminal of the focus mode in that tab; outside focus mode, to the terminal shown in the current tab when there is exactly one. Otherwise, and for buffers that are not files (unnamed, inside a terminal) or a terminal that has exited or is not shown in the tab, nothing is inserted and a message tells why.

Map it in normal and Visual mode (mode = { "n", "x" }, as in the installation example), or set keymaps.send_location = "<leader>cp" to let setup() create the mapping.

Closing a terminal window (:q) only hides it. The Claude session keeps running and still updates its status and sends notifications. Bring it back with list() (:ClaudeDeck list). Exit Claude (/exit) to end it.

This also holds when the terminal is the last window: :q (and ZZ, <C-w>q, :x) leaves Neovim open with an empty window, and a message tells how many terminals are still running in the background. Use :qa to quit Neovim. Running :q again in the empty window quits Neovim too, and with it every Claude session. This behavior can be turned off with keep_alive_on_quit.

Windows

Keys typed in a terminal go to Claude, so leave terminal mode first to use window commands:

Key Action
<C-q> Leave terminal mode (only in claude-deck terminals; keymaps.normal_mode)
<C-\><C-n> Leave terminal mode (built in)
<C-]> Send <Esc> to Claude (only in claude-deck terminals; keymaps.send_esc). Press twice for Esc Esc
<C-w>h / <C-w>j / <C-w>k / <C-w>l Move to another window (normal mode)
<C-w>< / <C-w>> / <C-w>- / <C-w>+ Resize (normal mode; e.g. 10<C-w>>)
<C-w>= Make windows equal size
mouse drag on a separator Resize

Moving back into a terminal window enters terminal mode automatically (auto_insert).

To run window commands without leaving terminal mode, set keymaps.window = "<C-w>" (Claude Code's Ctrl+W, delete word, is then unavailable).

Opening a terminal never resizes your other windows, even with 'equalalways': splits happen inside the terminal's area.

Example: <Esc> and <C-h/j/k/l> from terminal mode

For reference, the author uses global mappings like these, so that <Esc> leaves terminal mode and <C-h/j/k/l> moves between windows from any mode:

-- Leave terminal mode with <Esc> (send <Esc> to Claude with <C-]> instead)
vim.keymap.set("t", "<Esc>", [[<C-\><C-n>]])

-- Move between windows with <C-h/j/k/l> in normal and terminal mode
for _, key in ipairs({ "h", "j", "k", "l" }) do
    vim.keymap.set("n", "<C-" .. key .. ">", "<C-w>" .. key)
    vim.keymap.set("t", "<C-" .. key .. ">", [[<C-\><C-n><C-w>]] .. key)
end

These keys then no longer reach Claude Code: Esc (interrupt, Esc Esc to rewind), Ctrl+K (delete to end of line), Ctrl+L (redraw) and Ctrl+H (backspace in most terminals). They also apply to every terminal, not only claude-deck terminals.

Note

Neovim's terminal sends <Esc> to Claude (interrupt). If you map <Esc> in terminal mode to leave terminal mode yourself, press <C-]> to send <Esc> to Claude (twice for Esc Esc).

Scrolling back

Claude Code has two renderers, and claude-deck starts it with the classic one by default (renderer).

classic (default): the whole conversation stays in the terminal buffer. Leave terminal mode (<C-q>) and use Neovim as usual: j / k, <C-u> / <C-d>, gg / G, / to search, v and y to copy. Press i to go back to the prompt.

fullscreen: Claude Code keeps the history itself and the buffer only holds the current screen. Scroll inside Claude Code: PageUp / PageDown (Fn+↑ / Fn+↓ on a Mac), Ctrl+End to jump to the latest, Ctrl+O for the transcript view (j / k, / to search), or the mouse wheel.

classic fullscreen
Flicker May flicker while Claude is writing No flicker
Prompt input Follows the conversation: near the top until the conversation fills the window, then at the bottom Always at the bottom
History Terminal buffer (up to scrollback lines) Kept by Claude Code; the buffer holds one screen
Scroll, search, copy Neovim normal mode (j / k, /, y) PageUp / PageDown, Ctrl+O transcript, mouse wheel
Mouse clicks Handled by Neovim Handled by Claude Code
Long conversations Redraw leftovers may pile up as duplicate lines in the scrollback Stays fast; only the visible part is drawn
Window resize Stale output fragments may remain The whole screen is redrawn

To use the fullscreen renderer, or to leave the choice to your Claude Code settings (tui):

require("claude-deck").setup({ renderer = "fullscreen" }) -- or renderer = false

With renderer = false, claude-deck does not touch the environment variable: if CLAUDE_CODE_NO_FLICKER is set in Neovim's environment, Claude uses it; otherwise Claude Code's tui setting decides.

The renderer is passed to Claude with the CLAUDE_CODE_NO_FLICKER environment variable (0 for classic, 1 for fullscreen), which takes precedence over the tui setting. It applies to terminals opened after the change.

Status

Status Meaning Trigger Notification
New Started; no prompt sent yet started / SessionStart
Running Claude is working UserPromptSubmit, PostToolUse
Waiting Claude finished its turn and is waiting for your next prompt Stop yes
Needs you Claude is blocked in the middle of a task until you answer (permission prompt, question) Notification yes
Exited The Claude process has exited process exited

"Waiting" does not mean that Claude is queued or about to run: nothing happens until you send a prompt.

By default (when cli.enabled, cli.system_prompt and cli.auto_title are all on), Claude names its terminal with ct title once it understands the task (see below). Until then, the task title is the beginning of the first prompt (slash commands are ignored). A title you set with rename() takes precedence: Claude does not change it unless you ask. /clear resets the title, except one you set with rename().

Each terminal has a two-line header:

 #4 Needs you │ Run the migration after confirming   ← winbar
 ~/src/app                          session 317dbc5c   ← statusline

With statusline = false, or with 'laststatus' set to 3 (one global statusline), the cwd goes to the right of the winbar instead, and is shortened or dropped when the window is narrow. The terminal buffer is named claude:#<id> <title>, so tablines and other statuslines show something readable.

Notifications

Notifications are sent after a short delay (notify.delay, 2 seconds by default). If Claude resumes in the meantime, nothing is sent. This covers the case where Claude finishes its turn while waiting for a background process and continues by itself when the process ends.

A notification is skipped only when all of these hold:

  1. Neovim has focus (FocusGained / FocusLost)
  2. the current window shows that terminal
  3. on macOS, the terminal app running Neovim is the frontmost app (checked with lsappinfo, as a backup for terminals that do not report focus)

notify.method chooses how notifications are sent (notify.notifier, when set, takes precedence):

Method Notes
"auto" (default) "terminal-notifier" if it is installed, otherwise "osascript" on macOS; "notify-send" on Linux
"terminal-notifier" macOS. Clicking the notification brings back the terminal app running Neovim. A newer notification of the same terminal replaces the older one
"osc" The terminal emulator shows the notification (OSC 9 escape sequence, supported by iTerm2 among others). Clicking it brings back the terminal. Allow notifications in the terminal's settings (iTerm2: Profiles > Terminal > Notifications). Does nothing in a GUI or with --headless. Inside tmux, OSC 9 does not reach the outer terminal
"osascript" macOS, built in. Clicking the notification does not bring back the terminal app
"notify-send" Linux

On macOS, install terminal-notifier (brew install terminal-notifier) so that clicking a notification takes you back to Neovim; "auto" then uses it. If the command of the chosen method is not found, setup() warns once and uses "auto". :checkhealth claude-deck shows the method in use and whether clicking a notification brings back the terminal.

If terminal-notifier shows nothing on macOS, allow it under System Settings > Notifications (the permission prompt may not appear the first time). When a notification command fails, Neovim shows a warning with the reason, once per command per session.

ct command for Claude

Inside a terminal, ct is on PATH and Claude is told about it with --append-system-prompt:

ct list            # id, status, title, cwd, session_id, transcript path and model of every terminal
ct list --json     # the same as one JSON object
ct read 2 [count]  # recent messages of terminal #2 (default 20)
ct title "Fix login bug"   # set the task title of this terminal (without a title: show it)
ct title --force "…"       # also replace a title you set with rename()
ct open app/greeter.rb:24  # open a file (at a line) in the editor of focus mode
ct diff                    # open the diff of the pull request (or the uncommitted changes) there
ct diff app/greeter.rb:24  # … with the cursor on that file (and line)
ct diff --pr 5559 app/greeter.rb:24  # the diff of another pull request
ct spawn --title "Fix login" --prompt-file task.md  # start a new session in a hidden terminal
ct send 3 --prompt-file task.md  # hand a task to the idle or waiting terminal #3
ct wait 3 4 --timeout 300  # wait until the tasks sent to #3 and #4 have finished

All of them except ct spawn and ct send are allowed without a permission prompt. So you can ask, for example, "check that this doesn't conflict with what #1 is doing".

With cli.auto_title (default), Claude is told to name its terminal with ct title as soon as it understands the task, with a short title in the language of your prompt, and to rename it when the task changes. The title may be at most 40 cells wide (display width; a Japanese character takes 2); a longer one is refused rather than cut, so that Claude picks a shorter one. A title you set with rename() takes precedence: ct title refuses to change it, and Claude uses ct title --force only when you ask it to rename the terminal.

ct open <path>[:line] opens a file in the editor window of the terminal's focus mode and makes that window current (in normal mode), with the cursor on the line if one is given (past the end: the last line; path:line:col uses only the line). Without focus mode for the terminal, it opens focus mode first (showing the terminal if it is hidden). A relative path is relative to the current directory of Claude's shell; ~/ and absolute paths work too. A file whose whole name matches (e.g. foo:24) is opened as it is. Directories are refused. When the file has a swap file, it is opened read-only. Claude is told to use it when you ask it to open or show a file, or to show you the code it is explaining, so you can ask "open app/index.html" or "show me line 24 of greeter.rb". From Neovim, open_file(path, line?, id?) and :ClaudeDeck open <path>[:line] do the same for the current terminal (or the terminal of the focus mode tab); a relative path is relative to the terminal's cwd for open_file() and to Neovim's current directory for :ClaudeDeck open.

ct diff [<path>[:line]] [--pr <pr>] [--base <ref>] opens a diff in that editor window, as a read-only buffer with the diff filetype: the diff of the pull request of the current branch (gh pr diff; needs the GitHub CLI and a pull request), otherwise the uncommitted changes (git diff HEAD). With --pr <pr> (a number, URL or branch), that pull request is shown instead of the one of the current branch, e.g. one you are reviewing without checking it out. With --base <ref>, the working tree is compared with that ref instead (git diff <ref>), without looking for a pull request. The first line of the buffer names the source. With a path, the cursor goes to that file's section, and with a line to that line of the new side (a line outside the hunks: the nearest hunk header); a file without changes is refused. In the diff buffer, <CR> opens the file under the cursor at that line in a window right of the diff (the diff stays; ct open while a diff is shown opens the file beside it too), ]f / [f move between files, ]c / [c between hunks, and q closes the window; send_location() there sends the file and line under the cursor. Claude is told to use it when you ask to see a diff, the changes or the pull request, or when it wants to show you a change it is explaining, so you can ask "show me the diff of this PR" or "show me the change in greeter.rb". From Neovim, open_diff({ path?, line?, pr?, base?, id? }) and :ClaudeDeck diff [<path>[:line]] [--pr <pr>] [--base <ref>] do the same (paths relative to the terminal's cwd and to Neovim's current directory respectively).

ct list --json prints one JSON object, {"self": <id or null>, "terminals": [ … ]}, with one entry per terminal sorted by id: id, state (the internal name: idle, running, waiting, attention or exited, not the configurable label), title, title_source ("claude", "user", "auto" or null), cwd, session_id, transcript_path, model and spawned_by (null when not known). model is the model that was requested when the terminal was started (--model); Claude Code may use another one.

ct spawn [--title <title>] [--cwd <dir>] [--model <model>] (--prompt <text> | --prompt-file <path>) [--show] starts a new Claude Code session in a new terminal and prints {"id": 4, "cwd": "…", "model": null, "title": "…"} on one line. The prompt (exactly one of --prompt and --prompt-file; the file is read by Neovim, a relative path is relative to Claude's shell) is passed to Claude Code as its first argument (after --), not typed into it, and is registered as the terminal's first request (see ct send), so ct wait can follow it. --cwd defaults to the cwd of the calling terminal and must be an existing directory. The title follows the rules of ct title (at most 40 cells). The terminal is created hidden: your tab, window, cursor and mode do not change, and you bring it up with show() / list() like any hidden terminal. With --show it opens to the right of the calling terminal instead (at the far right of the tab when the caller is not shown in it; other windows may be resized), and the focus stays where it is. If Claude Code cannot be started (for example claude is not on the PATH), nothing is left behind and the error is printed. At most cli.spawn_limit (3) spawned terminals that have not exited can exist at a time. Unlike the other ct commands, ct spawn is not allowed in advance: Claude Code asks you for permission each time, and Claude is told to use it only when you asked it to delegate work to other sessions. From Neovim, spawn({ prompt, cwd?, model?, title?, show? }) does the same and returns the new id (or nil and a message).

ct send <id> (--prompt <text> | --prompt-file <path>) hands a task to an existing terminal that is idle or waiting (not the calling terminal itself) and prints {"id": 3, "request": "r1"} on one line. The prompt (exactly one of the two forms; a relative --prompt-file is relative to Claude's shell) is typed into the target's input as one bracketed paste, so a multi-line prompt stays one message, and submitted with a carriage return shortly after. It is refused (ct: …, exit status 1) when the target is a running, attention or exited terminal, does not exist or has no live process, is still starting (ct: terminal #3 is still starting: no SessionStart yet), still has a request that Claude Code has not taken up (ct: terminal #3 has a pending request r2 (not received yet), so two pastes never land in one input), or the prompt is missing, empty, unreadable or contains a NUL byte. Success means only that the input was written, not that Claude took it up. Each send gets a request id (r1, r2, …), recorded on the target: the request counts as received when Claude Code reports a submitted prompt (hook UserPromptSubmit) with the same text, or that contains it (you may have had a draft in the input); CRLF is read as LF and the <pasted_content …> tags that Claude Code puts around a long paste are ignored. It counts as completed at the next Stop (or when the terminal exits). If a Stop arrives while the request is not received, the turn ended without it: the request is marked lost, and sending is allowed again. ct list --json shows the latest one as "last_request": {"id", "from", "received", "completed", "lost", "sent_at"} (null when none was sent). Keep prompts short: Claude Code treats a long pasted prompt as pasted content, and the worker may weigh it as data rather than as your instruction. So prompts given with ct send / ct spawn --prompt-file should be one or two lines that point to a task file, for example Read /abs/task.md and do what it says. Write your report to /abs/result.md., not the whole task. Like ct spawn, ct send is not allowed in advance: Claude Code asks you each time. From Neovim, send(id, prompt) does the same and returns the request id (or nil and a message).

ct wait <id>... [--timeout <seconds>] (default 600) waits until, for every id, the last request is completed, or the terminal needs you (attention), has exited or does not exist; a terminal that never got a request counts as finished at once unless it is running (then it is pending). A ct spawn terminal has its first prompt as a request, so waiting right after a spawn works. waiting alone is never enough: the request must have been received and then stopped. The loop runs in the ct script, polling Neovim once a second, so Neovim is not blocked. It prints one line, {"timed_out": false, "terminals": {"3": {"status": "done", "request": "r1", "state": "waiting"}}}, where status is done, attention, exited, missing, none (no request was sent and the terminal is not running), lost (a turn ended without the request being received) or pending (only after a timeout). Exit status 0 only means that the wait finished (2 on timeout, 1 on a usage error): the caller must read each terminal's status, because attention, exited, missing, none and lost also exit with 0. ct wait only reads, so it is allowed without a prompt. A finished request means the turn ended, not that the work succeeded: Claude is told to read the result the worker wrote.

Claude in terminal #5 listing the terminals and reading the conversation of terminal #4

Warning

ct read parses Claude Code's transcript files, whose format is internal and may change.

Configuration

Defaults:

require("claude-deck").setup({
    cmd = { "claude" },
    claude_settings = true, -- pass hooks and `ct` permissions with `claude --settings`
    title_width = 60, -- characters kept for the task title; the winbar cuts it to fit
    statusline = true, -- cwd and session id in the window's statusline (false: cwd in the winbar)
    width_ratio = 0.4,
    dir_roots = {}, -- e.g. { "~/src" }: direct children are offered by pick_dir()
    zoxide = true, -- also offer `zoxide query --list` in pick_dir()
    labels = {
        idle = "New",
        running = "Running",
        waiting = "Waiting",
        attention = "Needs you",
        exited = "Exited",
        untitled = "(new task)",
    },
    notify = {
        enabled = true,
        states = { "waiting", "attention" },
        skip_when_watching = true,
        delay = 2000, -- milliseconds to wait before sending; 0 to send at once
        method = "auto", -- "terminal-notifier", "osc", "osascript" or "notify-send"
        notifier = nil, -- function({ title, subtitle, body, state, terminal })
    },
    keymaps = { -- false to disable
        -- terminal-mode keys in claude-deck terminals
        normal_mode = "<C-q>",
        window = false, -- e.g. "<C-w>"
        send_esc = "<C-]>", -- send <Esc> to Claude
        -- global normal / Visual mode key for send_location()
        send_location = false, -- e.g. "<leader>cp"
    },
    renderer = "classic", -- "classic" | "fullscreen" | false (keep CLAUDE_CODE_NO_FLICKER from Neovim's environment, else Claude Code's `tui`)
    scrollback = 100000, -- 'scrollback' of terminal buffers (max 100000); false to leave it
    auto_insert = true, -- enter terminal mode when moving into a terminal window
    keep_alive_on_quit = true, -- `:q` on the last window hides a running terminal instead of quitting Neovim
    picker = "auto", -- "auto" | "fzf-lua" | "select"
    focus = {
        tree = "auto", -- "auto" | false | function(cwd)
    },
    cli = {
        enabled = true, -- `ct` on PATH and allowed without a prompt (except `ct spawn` and `ct send`)
        system_prompt = true, -- tell Claude about `ct`
        auto_title = true, -- tell Claude to name its terminal with `ct title` (`ct title` works either way)
        spawn_limit = 3, -- max number of terminals started by `ct spawn` that have not exited
    },
})

Highlights

The default status colors are based on the Okabe-Ito palette and differ in lightness as well as hue, so they stay distinguishable with color vision deficiencies.

Group Default
ClaudeDeckIdle gray
ClaudeDeckRunning sky blue
ClaudeDeckWaiting yellow
ClaudeDeckAttention vermillion
ClaudeDeckExited dark gray
ClaudeDeckCwd links to Directory

How it works

  • Each terminal runs claude --settings <json>. The JSON registers hooks that call bin/claude-deck-hook, which forwards the event to Neovim over $NVIM (nvim --server $NVIM --remote-expr). Your ~/.claude/settings.json is not modified, and claude started elsewhere is unaffected.
  • Terminals are identified by $CLAUDE_DECK_ID.

Without --settings

Set claude_settings = false to start claude without --settings. Without the hooks, status, notifications, task titles and fork() do not work, and ct asks for permission.

To keep them, add the hooks to your own Claude Code settings (e.g. ~/.claude/settings.json). :ClaudeDeck settings shows the JSON. The hook script does nothing outside claude-deck, so registering it globally is safe.

Limitations

  • Interrupting Claude does not fire the Stop hook, so the status stays "Running" until the next prompt.
  • On macOS, switching tabs or panes inside the terminal app is not detected as losing focus.
  • Terminals live in one Neovim instance and are lost when Neovim exits (sessions can be resumed with claude --resume). Quitting Neovim with :qa (or :q on a window that is not a running terminal) ends every Claude session without asking.
  • :q! and ZQ discard the terminal buffer, which ends that Claude session even when Neovim stays open.
  • With the classic renderer, when the window gets narrower (for example when focus mode opens), Claude Code may fail to erase part of its earlier output, so fragments of stale output (such as the tail of a line) can remain in the scrollback. This is how the classic renderer behaves in any terminal when it is resized, the plugin cannot prevent it, and Ctrl+L does not remove the fragments; new output and the prompt are drawn correctly. If you resize windows often, use renderer = "fullscreen"; otherwise return to the main tab after focus mode and let Claude print something, and drawing is normal from then on. To clean up the fragments, run redraw() (:ClaudeDeck redraw) while Claude is waiting for input: it stops Claude Code and resumes the same session with claude --resume in the same window, so the whole conversation is drawn again at the current width. It takes a few seconds.

Help

The same documentation is available inside Neovim:

:help claude-deck

A Japanese version is also available with :help claude-deck@ja.

Health check

:checkhealth claude-deck

Development

sh tests/smoke.sh

License

MIT

来源:GitHub · github.com