Troubleshooting
August 12, 2026 · View on GitHub
A guide to checking what Rook is doing, the most common problems, and how to report one that turns out to be a bug.
Where things live
Paths assume the defaults. When ROOK_STATE_DIR is set, the state files move under that directory instead of ~/Library/Application Support/rook.
- Keymap:
~/.config/rook/keymap.conf(or$ROOK_STATE_DIR/config/keymap.conf, or a custom directory set in Settings ▸ Key Mapping). - Ghostty config:
~/.config/rook/ghostty.conf(same directory as the keymap), a rook-scoped ghostty config that overrides the bundled defaults and your global~/.config/ghostty/config. - Settings:
~/Library/Application Support/rook/settings.json. - Window and session state:
~/Library/Application Support/rook/windows.jsonplus onewindows/<id>.jsonper window. - Control socket:
~/Library/Application Support/rook/rook.sock(or$ROOK_CONTROL_SOCKETwhen set). A spawned shell sees the bound path in$ROOK_SOCKET. - Logs: the macOS unified logging system, under the subsystem
com.rook.app.
Reading the logs
Rook logs to the unified logging system, so use log or Console:
# the last 30 minutes, all categories
log show --predicate 'subsystem == "com.rook.app"' --info --last 30m
# follow live while you reproduce the problem
log stream --predicate 'subsystem == "com.rook.app"' --info
# narrow to one area
log show --predicate 'subsystem == "com.rook.app" && category == "CustomCommandRunner"' --info --last 30m
The categories are rookApp, CustomCommandRunner, SettingsModel, GhosttyApp, GhosttyCallbacks, NotificationManager, and WatermarkRenderer. In Console.app, filter on the same subsystem.
Checking the keymap
After editing keymap.conf, nothing changes until you reload it.
- Settings ▸ Key Mapping shows a read-only list of parse problems (a malformed line, a dropped binding, a conflict). This is the first place to look when a binding does not behave.
- File ▸ Reload Keymap re-reads the file. A reload that found problems posts a banner with the count.
rookctl keymap reloaddoes the same from the command line and prints the diagnostic count (0means a clean reload).rookctl keymap listprints what the reload actually resolved to. Itsactions:section lists every built-in with its current chord (*marks one your file overrode,-an action with no key), then your custom commands, then the diagnostics with line numbers, then amenu:section holding the key equivalents the menu bar is really carrying right now, each marked(disabled)if the item is inert.
A chord that looks right in keymap.conf but does not fire. This is what the two halves of keymap list are for — compare them. If the action is missing or shows the wrong chord under actions:, the problem is in the file: a diagnostic in the same output usually names the line, or the chord lost a conflict to another binding. If actions: is correct but the menu: section shows that chord on a different item, on none at all, or on one marked (disabled), the model is fine and the menu is stale or hijacked. SwiftUI rebuilds the menu bar only on the app's next activation, so switch to another app and back, then re-run the command; if the chord has moved to some other menu item, that item is eating the key and the fix is to map the action to a free chord. A disabled item is inert in a third way: AppKit consumes the key and fires nothing, so the shortcut looks dead rather than wrong.
The keymap editor will not open
Edit Keymap (File ▸ Edit Keymap…, or the ⌃⇧P palette) opens keymap.conf in $VISUAL, else $EDITOR, else vi, inside a floating overlay over the active session. The overlay runs the editor through your login shell, so the editor resolves the same way it does in a normal terminal — whether your login shell is zsh/bash or fish.
Common causes when nothing usable appears:
-
A GUI editor without its blocking flag. Editors like VS Code, Sublime, Zed, and TextMate launch a detached window and return immediately, so the overlay opens and closes in a flash. Set the editor's wait flag so the launcher blocks until you close the file:
export EDITOR='code -w' # VS Code; also: 'subl -w', 'zed -w', 'mate -w', 'cursor -w' -
$EDITORunset. You getviinside the overlay. Pressito start typing, thenEscand:wqto save and quit; the keymap reloads when the editor exits. -
No active session, or an overlay is already open. Edit Keymap is a no-op with no session selected, or while another overlay or the quick terminal is up. Select a session and close any overlay first.
Set and export $EDITOR or $VISUAL in your shell startup file (export EDITOR=… in ~/.zshrc/~/.bashrc, or set -gx EDITOR … in ~/.config/fish/config.fish), not just in the current shell. The overlay reads the exported value from your login shell, so a value that lives in one terminal session only — or one set without export — is not seen and falls back to vi.
A custom action does nothing
Work down this list:
- Read the diagnostics. Open Settings ▸ Key Mapping. A malformed
commandline is listed there and skipped. - Chord conflict. If your chord collides with a built-in shortcut or with another custom command, the binding is dropped and the command becomes palette-only. It still runs from the action palette (
⌃⇧P), where it is listed with acustomtag. Pick a free chord, or run it from the palette. - Reserved chords.
ctrl+tab/ctrl+shift+tab(the session switcher) andctrl+1/ctrl+2(pane focus) are reserved and cannot be bound. - Modifier-less keys are rejected. A custom chord needs at least one modifier so it cannot shadow a plain terminal key.
command "x" g …is palette-only;command "x" cmd+g …binds. - Focus. A custom chord fires only while a terminal pane holds keyboard focus. When the sidebar, the inline rename field, a Settings field, or a palette has focus, the chord passes through. Click into the terminal first.
- The command runs in a plain
/bin/sh -c, not your login shell. It does not load~/.zshrcor~/.bashrc, so shell aliases and functions are not available.PATHis the app's GUI one — the launchd default, widened with the bundledrookctl,/usr/local/binand/opt/homebrew/bin— so a barerookctland a bare Homebrew binary both resolve. What is still missing is everything your shell profile adds: a~/binscript, a version-manager shim, or a Homebrew in a custom prefix fails with exit 127. Give those an absolute path, or wrap the body inzsh -lc '…'—zsh -ilc '…'when thatPATHis set in~/.zshrc, which-lcdoes not read. - Exit status. A non-zero exit posts a failure banner with the code. No banner and no effect usually means the chord never fired (causes above). A banner means it ran and failed, which points at the command itself, its
PATH, or its arguments. - Token quoting.
{AGT_SELECTION}and the other{AGT_*}tokens expand raw into the shell line. For content that may contain shell metacharacters, use the$AGT_SELECTIONenvironment form, which is already quoted. The token list is in the keymap section of the README.
Reload after every edit (File ▸ Reload Keymap, or rookctl keymap reload). Edits are not applied until you do.
Changing ghostty settings
Most terminal behavior comes from ghostty. The common knobs (font, theme, background opacity and blur, scroll speed) are in Rook's Settings, but any other ghostty key (macos-option-as-alt, keybind, window-padding-*, and so on) is set in a config file.
Rook reads four config sources, each overriding the one before it:
ghostty's bundled defaults → ~/.config/ghostty/config → <config dir>/ghostty.conf → Rook Settings
(lowest) (your global config) (rook-scoped) (UI wins)
<config dir>/ghostty.conf(default~/.config/rook/ghostty.conf, next tokeymap.conf) is scoped to Rook only; the standalone Ghostty.app never reads it. Use it for keys you want in Rook but not everywhere.~/.config/ghostty/configis your global ghostty config, shared with Ghostty.app, and already in the chain.- The keys Rook sets from its Settings window load last, so the Settings picker wins for what it manages. Put everything else in
ghostty.conf.
Edit ghostty.conf with File ▸ Edit ghostty.conf… (or the ⌃⇧P palette), which opens it in $EDITOR and reloads on exit, the same as Edit Keymap. After editing it elsewhere, apply it with File ▸ Reload Config, the action palette, or rookctl config reload. A malformed line is skipped while the good ones still apply. The diagnostic count (shown in a banner and printed by config.reload, where 0 means a clean reload) covers every ghostty config source, not just ghostty.conf, because the diagnostics do not record which file they came from. Check the Console log for the offending line.
A reload applies most keys to your open terminals right away — colors, theme, cursor-style, macos-option-as-alt, and the mouse and clipboard keys all take effect on the visible pane. Two kinds of key cannot change for a terminal that is already running, though:
- Layout keys —
window-padding-x,window-padding-y, and other size-affecting keys — do not re-apply to an open pane. libghostty re-derives a surface's padding only when it is first laid out, so a reload (and even resizing the window) leaves existing panes on their old padding. Open a new session or new window to pick up the change; the panes that were already open need a relaunch. - Spawn-time keys —
termandshell-integration-features— are read once when the shell starts, so a reload cannot change them for a shell that is already running. Open a new session, whose shell is spawned fresh, to apply them.
The full ghostty key reference is at https://ghostty.org/docs/config.
Copy/paste and shortcuts on a non-Latin or alternative layout
⌘C and ⌘V copy and paste on any keyboard layout, non-Latin ones (Russian, Greek, and so on) included, because Rook binds them to the physical key positions rather than to the character a layout prints. The physical C and V keys then work no matter what those keys produce in the active layout.
The reason is that ghostty's own copy/paste binds match the produced character: on a Russian layout the physical V key yields м, so the built-in super+v bind never fires. The bundled Rook defaults add physical-key binds (super+key_c, super+key_v) that match by position instead.
Those binds always consume the key, even when there is nothing to act on: ⌘C with no selection does nothing at all, rather than reaching the running program. That is deliberate. The Edit menu disables Copy without a selection and Paste without pasteable content — on every layout — so those presses fall to the terminal's own bind, and a bind that declined them would let the chord through to key encoding. A plain shell shows nothing either way, but under the kitty keyboard protocol, which Claude Code and other TUIs turn on, the program receives the chord as a key report and renders it as text — a stray с or ^[[1089;9u in the prompt. If you rebind copy or paste yourself, do not add ghostty's performable: prefix for the same reason.
The same distinction lets you remap any shortcut for your layout:
- A physical key name (
key_c,key_v,key_a, and so on) matches the key's position, whatever character it prints. - A bare letter (
c,v) matches the character the active layout produces at that key.
If you use a Latin alternative layout (Dvorak, Colemak, AZERTY) and want ⌘C/⌘V at your layout's own C and V letters instead of the QWERTY physical positions, override them in ~/.config/rook/ghostty.conf with character-based binds, and unbind the physical defaults so the QWERTY positions are freed:
keybind = super+key_c=unbind
keybind = super+key_v=unbind
keybind = super+c=copy_to_clipboard
keybind = super+v=paste_from_clipboard
Reload with File ▸ Reload Config or rookctl config reload. The keybind syntax is at https://ghostty.org/docs/config/keybind/reference.
My event watcher missed events
rookctl events stops with an error rather than quietly resuming from the tail when the cursor it sent cannot be honored: it prints the message and exits non-zero, and over the socket events.read answers ok:false with the same message in error. That is the design, not a malfunction — a watcher that lost its place has to learn it lost its place, instead of reading a silent re-anchor as "nothing happened". The failed reply still carries the ring's current anchor in result.events (run plus next, with an empty items), so a client speaking to the socket directly can rebaseline from that very reply. rookctl events does not: it prints one JSON object per event, never the response envelope, so from the CLI you re-anchor by re-running it without a cursor (below).
event run changed. The cursor is from a previous run of the app. The event ring lives in memory for one app run and is stamped with that run's UUID, so quitting, relaunching, or crashing Rook invalidates every cursor you were holding. Nothing is recoverable — the events went with the process — and nothing is broken; a restart is expected to reject an old cursor.event cursor expired. The ring holds 4096 entries and the events after your cursor have already been pushed out by newer ones. Your watcher was stopped, blocked, or slower than the app for long enough to fall more than a ringful behind. Read more often, and raise--limit(up to 1000) so each read drains more per round trip. Filtering with--kinddoes not help here — the ring stores every kind regardless of what you read — but it does not hurt either, because a filtered read still advances your cursor past the entries it skipped.event cursor is ahead of the current sequence. The sequence number you asked for does not exist in this run yet. This one is a caller bug: a hand-written cursor, or arithmetic done on anextvalue. Send back thenextyour last reply gave you, verbatim.
A feed that prints nothing at all and reports no error is a different situation, not a lost cursor: a watcher filtered to --kind notify has nothing to match, because that kind is accepted by the CLI but not emitted by the app yet. Drop the filter, or watch the session's unseen count in rookctl tree --json.
To resume after any of the three errors, drop --run and --after and run rookctl events again. With no cursor it subscribes from now, which is the explicit "I accept that the gap is lost" move — it replays no history, so whatever happened while you were away stays unseen. If you cannot afford that, poll rookctl tree --json once at the same moment to re-read the current state, then let the event feed carry you forward from there.
Other common issues
rookctl: command not found. Install it from Help ▸ Install Command Line Tool… (it symlinks into/usr/local/bin). You can also call it by its full path inside the app bundle:rook.app/Contents/MacOS/rookctl.- No desktop notifications. macOS must have granted permission (System Settings ▸ Notifications ▸ Rook), and Show notification banners in Settings ▸ Notifications must be on. The unseen-count badge still tracks even when banners are off.
notifysays ok but no notification appears.rookctl notifysucceeds as soon as the target session resolves, so a delivered banner and a suppressed one look the same from the call site. With Show notification banners off, the response carries a note in place ofok—badge updated, but "Show notification banners" is off, so no banner was posted— and the unseen-count badge ticks either way. Every posted banner and every suppression is logged at thenoticelevel under theNotificationManagercategory, so thelog showrecipe above covers the healthy case as well as the swallowed one — including a terminal-emitted OSC 9/777 notification dropped because you were typing in that very pane, whichrookctl notifyis never subject to.- Agent-status glyph does not update. Install the hooks from Help ▸ Install Agent Status Hooks…, then start a fresh shell so the
sourceline added to your shell rc takes effect. The hooks callrookctl session status, sorookctlmust resolve first (see above). - Agent-status glyph updates the wrong session. One session's glyph blinks while the work happens in another — typically when agents run inside tmux (or a tmux-backed session manager such as agent-deck). The working process inherited another session's
ROOK_SESSION_ID: the status hooks target whatever id is in their environment, and a long-lived daemon started from inside a Rook session (a tmux server is the usual carrier) captures that session'sROOK_*variables into its global environment and passes them to every child it ever creates. Checktmux show-environment -g | grep ROOK— if present, clear them withtmux set-environment -g -r ROOK_SESSION_IDand the same for every otherROOK_*name it lists (ROOK_ENABLED,ROOK_WINDOW_ID,ROOK_WORKSPACE_ID,ROOK_SOCKET,ROOK_PANE,ROOK_PANE_ID), then restart the affected panes. To avoid it, start such daemons with the variables scrubbed (env -u ROOK_ENABLED -u ROOK_SESSION_ID -u ROOK_WINDOW_ID -u ROOK_WORKSPACE_ID -u ROOK_SOCKET -u ROOK_PANE -u ROOK_PANE_ID <command>) or from a terminal outside rook. LeavingROOK_PANE_IDbehind is its own trap: it is a per-surface token, so a daemon that carries a stale one hands every child the identity of one long-dead pane. - Agent-status lands on the wrong pane of the right session. A split session where
blockedshows up on the pane that is not waiting. The cause is a stale pane role, and it needs a specific history: the session's main pane exits, the surviving split is promoted into the main slot while its shell still carries therightrole it was spawned with, and you then split the session again — now two shells both believe they are the right pane. Rook resolves this fromROOK_PANE_ID, a stable per-surface token the hooks forward assession status --pane-id, which is matched against the session's live surfaces when the status arrives, so the role is computed at report time instead of at spawn time. Two things can leave you without it. Installing the hooks copies them, so a copy installed before this landed still reports the role alone — re-run Help ▸ Install Agent Status Hooks… (idempotent) to refresh it. And the token is baked into a shell's environment at spawn, so shells that were already running do not have one; start a fresh session, or a fresh pane, after re-installing. Until both are true, the old role-only behavior is what you get — the fallback is deliberate, so nothing breaks in the meantime.
Claude Code's question or permission prompt stops responding after switching apps
While Claude Code shows an interactive prompt (a question menu or a permission dialog), switching to another app and back can leave that prompt unresponsive to the keyboard: the arrow keys and Return do nothing. The regular Claude Code prompt and the shell are unaffected, so you can still type there.
This is a Claude Code bug, not a Rook bug. When a window regains focus, Rook sends the standard terminal focus-in report (ESC[I, DEC private mode 1004), which any terminal does once an application turns focus reporting on. Claude Code's dialog input handler consumes that report instead of treating it as focus state, which wedges the prompt. It is tracked upstream as anthropics/claude-code#72188; the mouse-click variant is #72273.
Rook is behaving correctly: it emits paired focus-in and focus-out reports with nothing stray, and it follows the macOS focus-first convention, so a click that refocuses the window only focuses it and is not forwarded into the terminal. The trigger is the focus report itself, so any terminal with focus reporting on is affected the same way.
Workaround until the upstream fix: answer the prompt before switching away, or if you have already returned to a stuck prompt, press Esc to dismiss it and let Claude Code re-ask.
Reporting a problem
Collect this before filing:
- Rook version (Rook ▸ About Rook).
- macOS version.
- The exact steps, what you expected, and what happened instead.
- A log excerpt from the
log showcommand above, covering the moment you reproduced it. - The relevant
keymap.conflines, if it is keymap-related.
Scrub anything private (tokens, internal hostnames, usernames embedded in paths) before sharing.
If you run a coding agent inside Rook (Claude Code or Codex with the Rook skill installed), it can help you write and file the report: it drafts an issue for a bug, or a Discussion for a feature request or question, shows it to you first, and never posts without your go-ahead.
Otherwise open one directly:
- Bug: https://github.com/jokius/rook/issues/new
- Idea or question: https://github.com/jokius/rook/discussions/new