feat(tmux): self-registering tmux hooks via managed block#48
Merged
Conversation
Introduces internal/tmuxhooks with DesiredHooks() (the canonical set of demux-owned tmux hook entries, excluding the client-attached bootstrap) and HooksHash() (a stable 12-hex-char SHA-256 digest used as a staleness key in @demux_hooks_version). Both the runtime reconciler and the upcoming `demux hooks install` command will share this single source of truth.
Introduces the Tmux interface and its real shell-out implementation. Run wraps exec.Command with stderr capture for richer error messages; Output does the same and returns stdout. Tests substitute a fake.
Add parseShowHooks to group raw `tmux show-hooks -g` output by event name, and isDemuxEntry to classify whether a hook command was registered by demux. Includes TDD test coverage for both functions.
Implements the core Reconcile function that idempotently registers demux's desired hook set, preserving user-owned hooks. Adds CurrentVersion, MarkVersion, and Sync as the fast-path entry point that skips reconciliation when the stored hash matches HooksHash().
… limit Remove the intermediate newEntries variable in Reconcile by inlining the append directly into the range clause. Add a test case to TestIsDemuxEntry documenting that user hooks whose text happens to contain "demux sidebar " or "demux event " are a known false-positive of the substring classifier.
Adds `demux event client_attached`, fired by the demux managed-block bootstrap hook on tmux client-attached. The command reconciles demux's tmux hook set under the event lock, auto-shows the sticky sidebar when cfg.Sidebar.Sticky.AutoShow is set, and warns when legacy pasted hook lines are detected in tmux.conf. Adds a registration test in cmd/event_test.go.
Add AutoShow bool field (toml:"auto_show") to SidebarStickyConfig. When true, the sticky sidebar appears automatically on tmux client attach (consumed by the client_attached command). Defaults to false via Go zero value. Update the config-init template (cmd/config.go) and README configuration reference to document the new field.
Implement conffile.go with functions to manage a marker-delimited "demux managed block" in ~/.tmux.conf. Provides LoadConf (symlink-resolving), WithManagedBlock (idempotent insert/replace), SaveConf (atomic write + backup), plus BootstrapHook and BootstrapCommand constants for the client-attached hook registration.
Add DetectLegacyHooks to identify old-style demux set-hook lines that linger outside the managed block, and RemoveLines to strip them by 1-based line number. Closes the cross-task build gap: cmd/event.go now has all its tmuxhooks dependencies and go build ./... succeeds.
Replace `hooks init` (print-and-paste snippet) with `hooks install`, which writes a one-line managed block to ~/.tmux.conf and registers demux's full hook set in the live tmux server via the internal/tmuxhooks package. `hooks init` is kept as a hidden deprecated alias. Updates README setup section and replaces test suite to cover promptYesNo and command registration.
Replace three stale references to `demux hooks init --tool tmux` and the old paste-snippet workflow with accurate descriptions of the new self-registration model (`demux hooks install`): - slots-mode paragraph: drop snippet reference, say "demux's tmux hooks" - ## Hooks > ### Tmux: replace generate/paste/CAUTION/snippet block with a short description pointing to the Enable-the-tmux-hooks setup section - command cheatsheet: swap hooks-init line for hooks-install
Register a no-op --tool flag on hooksInitCmd so that the old invocation `demux hooks init --tool tmux` continues to work instead of erroring with "unknown flag: --tool". Also restore the inline comment on the TMUX_PANE assignment in tmuxCurrentIDs explaining the %N pane-ID format.
Update the "Tmux hooks not clearing done states" section to reflect the self-registration model: demux now registers hooks automatically on every client-attached event via the bootstrap hook, not via a static .tmux.conf snippet. Lists all 6 registered events (was incorrectly stated as four), and replaces the old `tmux source ~/.tmux.conf` recovery step with `demux event client_attached`.
Add TestReconcileSkipsEventWhenClearFails to verify that when clearing one event's hook array fails (e.g. an unsupported hook name), Reconcile logs and continues rather than returning an error — and that subsequent events still reconcile successfully.
Lift the body of sidebarSlotsEnsureCmd.RunE into a standalone ensureSlots() function so the upcoming event new_window handler can call it directly. The command becomes a thin wrapper; behaviour is unchanged.
Implements three new event subcommands that absorb sidebar-follow and slots-ensure behavior: - window_focus: fired by after-select-window hook, clears done-states and follows sidebar - session_changed: fired by client-session-changed hook, clears done-states and follows sidebar - new_window: fired by after-new-window hook, follows sidebar and reconciles slots Both runWindowFocus and new_window handlers are best-effort; failures are logged, not returned.
Use cmd.Use instead of the hardcoded "window_focus" prefix in the shared runWindowFocus handler so the log message is accurate when the handler runs as session_changed. Also append "; auto-detected if omitted" to the --pane-id, --window-id, and --session-id flag descriptions for window_focus and session_changed, matching the existing pane_focus flag help text.
Replace the 9-entry DesiredHooks() set (which called sidebar follow / slots ensure directly) with 7 entries that each call a `demux event …` subcommand. window_focus and session_changed get dedicated event commands; after-new-window now calls `demux event new_window`. Update test expectations to match the new cardinality and command strings.
The tmux hooks now route through `demux event …` handlers instead of calling `demux sidebar follow` or `demux sidebar slots ensure` directly. Delete both hidden cobra commands and their tests; keep ensureSlots() which is still called by the event handler.
A non-runnable cobra parent command (event, sidebar, sidebar slots) printed its help to stdout with a zero exit when given an unknown subcommand. A tmux hook calling a since-removed subcommand (e.g. a stale `demux sidebar follow` left over before a reconcile) therefore dumped help text into the pane: the hook's `2>/dev/null` only silences stderr. rejectUnknownSubcommand gives these group commands a RunE that returns an error for an unknown subcommand (cobra prints it to stderr, exit 1) and still prints help when invoked bare. Stale hooks now fail silently under 2>/dev/null.
…vents The bare `tmux show-hooks -g` dump omits some events (e.g. pane-exited on tmux 3.x), causing those events' user hooks to be silently destroyed during reconcile. Query each event individually with `show-hooks -g <event>` so all user hooks are visible and preserved. Also add test infrastructure: scriptShowHooks helper and regression test TestReconcilePreservesUserHookForPerEventQuery.
Add a callout to the "Enable the tmux hooks" section for users on a previous demux: the multi-line snippet pasted from `demux hooks init` is obsolete, and `demux hooks install` detects and offers to remove those legacy lines in favor of the managed block.
0f5d97c to
1cb076f
Compare
Split the 63-line hooksInstallCmd.RunE into focused helpers: - reviewLegacyHooks: detects, reports, and removes old-style pasted lines - registerLiveHooks: registers bootstrap hook and reconciles live server RunE now reads top-down: validate tool → load config → review legacy → write managed block → register live hooks. Behavior unchanged.
Replace 3-deep if-err==nil pyramid in eventClientAttachedCmd.RunE with warnLegacyTmuxHooks() helper that uses guard-clause early returns. Behavior unchanged; legacy detection moved to separate concern.
Remove the BootstrapCommand() func that only wrapped a private const. Promote bootstrapCommand to exported const BootstrapCommand, eliminating one level of indirection. Update callsite in cmd/hooks.go. Behavior unchanged; const access is cleaner than a getter.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
demux's tmux integration (done-state clearing, the sticky sidebar follow/slot
hooks) is delivered through tmux hooks. Until now those hooks were installed by
hand:
demux hooks init --tool tmuxprinted a multi-line snippet the userpasted into
~/.tmux.confand re-sourced.That snippet is a maintenance trap. Every time demux's hook set changes — a new
event, a corrected flag, an added
run-shell -b— the pasted copy goes stale.A stale snippet silently misbehaves (wrong flags expand to empty values) or
stacks duplicate hooks on re-source, and nothing tells the user to re-paste.
The hook set and the user's config drift apart with no feedback loop.
This PR closes that gap: the user runs one command once, and demux keeps its
own hook set current in every running tmux server from then on.
Scope is tmux —
hooks install --toolonly acceptstmux.What does this PR do
Replaces the paste-a-snippet model with a self-registering managed block.
demux hooks install --tool tmuxwrites a marker-delimited managed blockto
~/.tmux.confcontaining a single bootstrap line:atomic (temp file + rename) and backs up the prior file to
~/.tmux.conf.demux-bak.demux event client_attached,which reconciles demux's full canonical hook set into the running server:
clears each event, re-adds the user's own (non-demux) entries, then re-adds
demux's — idempotent, never stacks duplicates, self-heals after a demux upgrade.
A version gate (
@demux_hooks_version, a hash of the desired set) makes thesteady-state attach a single tmux query.
demux hooks install --print-onlyprints the bootstrap line without touchingany file or server.
hooks installis idempotent (replaces the block in place). If anolder pasted multi-line snippet is detected outside the managed block, the user
is prompted to remove it.
demux eventsubcommands name the tmux event rather than the reaction:window_focus,session_changed,new_window,client_attached. The hookhandler decides the reaction (clear done-states, move the sidebar, reconcile
slot panes).
[sidebar.sticky] auto_show— whentrue, the sticky sidebar isshown automatically on client attach. Default
false.demux event, …) now reject an unknown subcommand with anerror on stderr and a non-zero exit, instead of cobra's default of printing
help to stdout with exit 0. Hooks run
demux … 2>/dev/null; a since-removedsubcommand now fails silently rather than dumping help text into a pane.
demux hooks initis kept as a hidden, deprecated alias forhooks install --print-only.Out of scope
hooks install --toolrejects anything buttmux;there is no shell/zsh integration in this iteration.
Manual QA
Prerequisites
1. tmux 3.x+ is installed.
Expect
tmux 3.xornext-3.x. The reconcile path was verified onnext-3.7.2. Go toolchain available to build the binary.
Expect
go1.2x. If missing, install Go or use a prebuiltdemuxbinary from this branch.Setup
Build the binary and stand up the isolated server + temp
$HOME. Run this once;later scenarios reuse
$D,$QAHOME,$QATMUX.Scenario 1 — `--print-only` prints the bootstrap line, touches nothing
ls "$QAHOME/.tmux.conf"No such file or directory.Scenario 2 — `hooks install` writes the managed block and registers live hooks
Created <QAHOME>/.tmux.confandRegistered demux hooks in the live tmux server., exit 0.cat "$QAHOME/.tmux.conf"set-hook -g client-attached …, end marker).tmux -L demuxqa show-hooks -g | grep demuxclient-attached(bootstrap) plusafter-kill-pane,after-new-window,after-select-pane,after-select-window,client-focus-in,client-session-changed.Scenario 3 — re-running `hooks install` is idempotent
Updated <QAHOME>/.tmux.conf (backup: …/.tmux.conf.demux-bak).1.Scenario 4 — legacy pasted snippet is detected and removed on confirm
$HOMEwith an old-style snippet plus user-owned config:yat the prompt:2 legacy demux hook line(s)atL3/L4, printsRemoved 2 legacy line(s).cat "$LH/.tmux.conf"# my own tmux config,set -g mouse on,bind r …all retained;the two
set-hook … demux …lines gone; managed block appended.rm -rf "$LH".Scenario 5 — end-to-end: a real client attach self-registers the full hook set
~/.tmux.conf(simulatinga fresh server start that loads just the managed block):
hook invokes on attach):
tmux -L demuxqa show-hooks -g | grep demux tmux -L demuxqa show-hooks -g pane-exited tmux -L demuxqa show -gv @demux_hooks_versionpane-exited, which the bareshow-hooks -gdump omits — query it explicitly), and@demux_hooks_versionset to a 12-hex-char hash.
demux event client_attached— the version gate makes it a no-op;show-hooks -gis unchanged, no duplicates.Scenario 6 — reconcile preserves the user's own hooks (incl. `pane-exited`)
pane-exitedand onafter-select-pane:[0], demux's at[1]:[0]= theecho USER-…hook,[1]= thedemux event …hook. (This is the case fixed by commit643f13e— the bareshow-hooks -gdump omitspane-exited, so reconcile queries each eventindividually to avoid wiping the user's hook.)
Scenario 7 — unknown subcommand fails on stderr, bare parent prints help
Error: unknown command "bogus" for "demux event"on stderr,exit=1, empty stdout.exit=0.Teardown
-L demuxqais a dedicated test server created in Setup — killing it does notaffect your real tmux server.