From dead8eabd912fc0cf1bafae5b3cde72a2385a1a0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:08:05 -0600 Subject: [PATCH 001/143] docs(comparison): Add feature comparison table for tmuxp/tmuxinator/teamocil Side-by-side comparison covering architecture, config keys, CLI commands, hooks, and config file discovery across all three tools. --- docs/comparison.md | 172 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/comparison.md diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000000..3ae0f42f7b --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,172 @@ +# Feature Comparison: tmuxp vs tmuxinator vs teamocil + +*Last updated: 2026-02-08* + +## Overview + +| | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| **Version** | 1.47.0+ | 3.3.7 | 1.4.2 | +| **Language** | Python | Ruby | Ruby | +| **Min tmux** | 3.2 | 1.5 | 3.2 | +| **Config formats** | YAML, JSON | YAML (with ERB) | YAML | +| **Architecture** | ORM (libtmux) | Script generation (ERB templates) | Command objects → shell exec | +| **License** | MIT | MIT | MIT | +| **Session building** | API calls via libtmux | Generates bash script, then execs it | Generates tmux command string, then `system()` | +| **Plugin system** | Yes (Python classes) | No | No | +| **Shell completion** | Yes | Yes (zsh/bash/fish) | No | + +## Architecture Comparison + +### tmuxp — ORM-Based + +tmuxp uses **libtmux**, an object-relational mapper for tmux. Each tmux entity (server, session, window, pane) has a Python object with methods that issue tmux commands via `tmux(1)`. Configuration is parsed into Python dicts, then the `WorkspaceBuilder` iterates through them, calling libtmux methods. + +**Advantages**: Programmatic control, error recovery mid-build, plugin hooks at each lifecycle stage, Python API for scripting. + +**Disadvantages**: Requires Python runtime, tightly coupled to libtmux API. + +### tmuxinator — Script Generation + +tmuxinator reads YAML (with ERB templating), builds a `Project` object graph, then renders a bash script via ERB templates. The generated script is `exec`'d, replacing the tmuxinator process. + +**Advantages**: Debuggable output (`tmuxinator debug`), wide tmux version support (1.5+), ERB allows config templating with variables. + +**Disadvantages**: No mid-build error recovery (script runs or fails), Ruby dependency. + +### teamocil — Command Objects + +teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Command` objects with `to_s()` methods. Commands are joined with `; ` and executed via `Kernel.system()`. + +**Advantages**: Simple, predictable, debuggable (`--debug`). + +**Disadvantages**: No error recovery, no hooks, no templating, minimal feature set. + +## Configuration Keys + +### Session-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Session name | `session_name` | `name` / `project_name` | `name` | +| Root directory | `start_directory` | `root` / `project_root` | (none, per-window only) | +| Windows list | `windows` | `windows` / `tabs` | `windows` | +| Socket name | (CLI `-L`) | `socket_name` | (none) | +| Socket path | (CLI `-S`) | `socket_path` | (none) | +| Tmux config file | (CLI `-f`) | `tmux_options` / `cli_args` | (none) | +| Tmux command | (none) | `tmux_command` (e.g. `wemux`) | (none) | +| Session options | `options` | (none) | (none) | +| Global options | `global_options` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Pre-build script | `before_script` | (none) | (none) | +| Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` | (none) | +| Attach on create | (CLI `-d` to detach) | `attach` | (always attaches) | +| Startup window | (none) | `startup_window` | (none) | +| Startup pane | (none) | `startup_pane` | (none) | +| Plugins | `plugins` | (none) | (none) | +| ERB/variable interpolation | (none) | Yes (`key=value` args) | (none) | +| YAML anchors | Yes | Yes (`aliases: true`) | Yes | +| Pane titles enable | (none) | `enable_pane_titles` | (none) | +| Pane title position | (none) | `pane_title_position` | (none) | +| Pane title format | (none) | `pane_title_format` | (none) | + +### Session Hooks + +| Hook | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Before session build | `before_script` | `on_project_start` | (none) | +| First start only | (none) | `on_project_first_start` | (none) | +| On reattach | Plugin: `reattach()` | `on_project_restart` | (none) | +| On exit/detach | (none) | `on_project_exit` | (none) | +| On stop/kill | (none) | `on_project_stop` | (none) | +| Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) | +| On window create | Plugin: `on_window_create()` | (none) | (none) | +| After window done | Plugin: `after_window_finished()` | (none) | (none) | +| Deprecated pre/post | (none) | `pre` / `post` | (none) | + +### Window-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Window name | `window_name` | hash key | `name` | +| Window index | `window_index` | (auto, sequential) | (auto, sequential) | +| Root directory | `start_directory` | `root` (relative to project root) | `root` | +| Layout | `layout` | `layout` | `layout` | +| Panes list | `panes` | `panes` | `panes` | +| Window options | `options` | (none) | `options` | +| Post-create options | `options_after` | (none) | (none) | +| Shell cmd before | `shell_command_before` | `pre` | (none) | +| Shell for window | `window_shell` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Suppress history | `suppress_history` | (none) | (none) | +| Focus | `focus` | (none) | `focus` | +| Synchronize panes | (none) | `synchronize` | (none) | +| Filters (before) | (none) | (none) | `filters.before` (v0.x) | +| Filters (after) | (none) | (none) | `filters.after` (v0.x) | + +### Pane-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Commands | `shell_command` | (value: string/list) | `commands` | +| Root directory | `start_directory` | (none, inherits) | (none, inherits) | +| Shell | `shell` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Press enter | `enter` | (always) | (always) | +| Sleep before | `sleep_before` | (none) | (none) | +| Sleep after | `sleep_after` | (none) | (none) | +| Suppress history | `suppress_history` | (none) | (none) | +| Focus | `focus` | (none) | `focus` | +| Pane title | (none) | hash key (named pane) | (none) | + +### Shorthand Syntax + +| Pattern | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| String pane | `- vim` | `- vim` | `- vim` | +| List of commands | `- [cmd1, cmd2]` | `- [cmd1, cmd2]` | `commands: [cmd1, cmd2]` | +| Empty/blank pane | `- blank` / `- pane` / `- null` | `- ` (nil) | (omit commands) | +| Named pane | (none) | `- name: cmd` | (none) | +| Window as string | (none) | `window_name: cmd` | (none) | +| Window as list | (none) | `window_name: [cmd1, cmd2]` | (none) | + +## CLI Commands + +| Function | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Load/start session | `tmuxp load ` | `tmuxinator start ` | `teamocil ` | +| Load detached | `tmuxp load -d ` | `tmuxinator start -d` / `attach: false` | (none) | +| Load with name override | `tmuxp load -s ` | `tmuxinator start -n ` | (none) | +| Append to session | `tmuxp load -a` | `tmuxinator start --append` | (none) | +| List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | +| Edit config | `tmuxp edit ` | `tmuxinator edit ` / `new` | `teamocil --edit ` | +| Show/debug config | (none) | `tmuxinator debug ` | `teamocil --show` / `--debug` | +| Create new config | (none) | `tmuxinator new ` | (none) | +| Copy config | (none) | `tmuxinator copy ` | (none) | +| Delete config | (none) | `tmuxinator delete ` | (none) | +| Delete all configs | (none) | `tmuxinator implode` | (none) | +| Stop/kill session | (none) | `tmuxinator stop ` | (none) | +| Stop all sessions | (none) | `tmuxinator stop-all` | (none) | +| Freeze/export session | `tmuxp freeze ` | (none) | (none) | +| Convert format | `tmuxp convert ` | (none) | (none) | +| Import config | `tmuxp import ` | (none) | (none) | +| Search workspaces | `tmuxp search ` | (none) | (none) | +| Python shell | `tmuxp shell` | (none) | (none) | +| Debug/system info | `tmuxp debug-info` | `tmuxinator doctor` | (none) | +| Use here (current window) | (none) | (none) | `teamocil --here` | +| Skip pre_window | (none) | `--no-pre-window` | (none) | +| Pass variables | (none) | `key=value` args | (none) | +| Custom config path | `tmuxp load /path/to/file` | `-p /path/to/file` | `--layout /path/to/file` | +| Local config | `tmuxp load .` | `tmuxinator local` | (none) | + +## Config File Discovery + +| Feature | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Global directory | `~/.tmuxp/` (legacy), `~/.config/tmuxp/` (XDG) | `~/.tmuxinator/`, `~/.config/tmuxinator/` (XDG), `$TMUXINATOR_CONFIG` | `~/.teamocil/` | +| Local config | `.tmuxp.yaml`, `.tmuxp.yml`, `.tmuxp.json` (traverses up to `~`) | `.tmuxinator.yml`, `.tmuxinator.yaml` (current dir only) | (none) | +| Env override | `$TMUXP_CONFIGDIR` | `$TMUXINATOR_CONFIG` | (none) | +| XDG support | Yes (`$XDG_CONFIG_HOME/tmuxp/`) | Yes (`$XDG_CONFIG_HOME/tmuxinator/`) | No | +| Extension search | `.yaml`, `.yml`, `.json` | `.yml`, `.yaml` | `.yml` | +| Recursive search | No | Yes (`Dir.glob("**/*.{yml,yaml}")`) | No | +| Upward traversal | Yes (cwd → `~`) | No | No | From 8ea97c45618e6560336a5a9611d2e51dc74f7058 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:15:20 -0600 Subject: [PATCH 002/143] fix(docs): Correct parity docs from verification pass - Remove duplicate 'Attach on create' row in comparison table, keep corrected version with '(default: true)' near socket_path - Annotate pre_tab as (deprecated) in comparison table - Annotate startup_window as accepting name or index - Fix pre_tab description: deprecated predecessor, not alias (it was renamed in tmuxinator, not aliased) - Clarify startup_window renders as "#{name}:#{value}" --- docs/comparison.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 3ae0f42f7b..87f5c8416c 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -53,15 +53,15 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Windows list | `windows` | `windows` / `tabs` | `windows` | | Socket name | (CLI `-L`) | `socket_name` | (none) | | Socket path | (CLI `-S`) | `socket_path` | (none) | +| Attach on create | (CLI `-d` to detach) | `attach` (default: true) | (always attaches) | | Tmux config file | (CLI `-f`) | `tmux_options` / `cli_args` | (none) | | Tmux command | (none) | `tmux_command` (e.g. `wemux`) | (none) | | Session options | `options` | (none) | (none) | | Global options | `global_options` | (none) | (none) | | Environment vars | `environment` | (none) | (none) | | Pre-build script | `before_script` | (none) | (none) | -| Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` | (none) | -| Attach on create | (CLI `-d` to detach) | `attach` | (always attaches) | -| Startup window | (none) | `startup_window` | (none) | +| Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` (deprecated) | (none) | +| Startup window | (none) | `startup_window` (name or index) | (none) | | Startup pane | (none) | `startup_pane` | (none) | | Plugins | `plugins` | (none) | (none) | | ERB/variable interpolation | (none) | Yes (`key=value` args) | (none) | From 71250367bf8bf8241267d64a2e8dd2195d06d3d6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:21:04 -0600 Subject: [PATCH 003/143] fix(comparison): Correct tmuxinator min tmux and detach flag - tmuxinator min tmux is 1.8 (recommended), not 1.5; tmux 2.5 is explicitly unsupported - teamocil has no documented min tmux version - tmuxinator detach is via `attach: false` config or `--no-attach` CLI flag, not `-d` (which doesn't exist in tmuxinator) --- docs/comparison.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 87f5c8416c..480dd7f2be 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -8,7 +8,7 @@ |---|---|---|---| | **Version** | 1.47.0+ | 3.3.7 | 1.4.2 | | **Language** | Python | Ruby | Ruby | -| **Min tmux** | 3.2 | 1.5 | 3.2 | +| **Min tmux** | 3.2 | 1.8 (recommended; not 2.5) | (not specified) | | **Config formats** | YAML, JSON | YAML (with ERB) | YAML | | **Architecture** | ORM (libtmux) | Script generation (ERB templates) | Command objects → shell exec | | **License** | MIT | MIT | MIT | @@ -135,7 +135,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Function | tmuxp | tmuxinator | teamocil | |---|---|---|---| | Load/start session | `tmuxp load ` | `tmuxinator start ` | `teamocil ` | -| Load detached | `tmuxp load -d ` | `tmuxinator start -d` / `attach: false` | (none) | +| Load detached | `tmuxp load -d ` | `attach: false` / `tmuxinator start --no-attach` | (none) | | Load with name override | `tmuxp load -s ` | `tmuxinator start -n ` | (none) | | Append to session | `tmuxp load -a` | `tmuxinator start --append` | (none) | | List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | From fcb609914a5536f807b2abb587aa15468c8b0984 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:26:08 -0600 Subject: [PATCH 004/143] fix(comparison): Correct tmuxinator version ref and clarify details - Fix "1.5+" to "1.8+" in architecture description (was already fixed in overview table but missed in prose) - Clarify YAML anchors: tmuxinator enables via YAML.safe_load aliases param, not a config key - Clarify tmuxinator edit is alias of new command --- docs/comparison.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 480dd7f2be..c5501d74eb 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -30,7 +30,7 @@ tmuxp uses **libtmux**, an object-relational mapper for tmux. Each tmux entity ( tmuxinator reads YAML (with ERB templating), builds a `Project` object graph, then renders a bash script via ERB templates. The generated script is `exec`'d, replacing the tmuxinator process. -**Advantages**: Debuggable output (`tmuxinator debug`), wide tmux version support (1.5+), ERB allows config templating with variables. +**Advantages**: Debuggable output (`tmuxinator debug`), wide tmux version support (1.8+), ERB allows config templating with variables. **Disadvantages**: No mid-build error recovery (script runs or fails), Ruby dependency. @@ -65,7 +65,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Startup pane | (none) | `startup_pane` | (none) | | Plugins | `plugins` | (none) | (none) | | ERB/variable interpolation | (none) | Yes (`key=value` args) | (none) | -| YAML anchors | Yes | Yes (`aliases: true`) | Yes | +| YAML anchors | Yes | Yes (via `YAML.safe_load` `aliases: true`) | Yes | | Pane titles enable | (none) | `enable_pane_titles` | (none) | | Pane title position | (none) | `pane_title_position` | (none) | | Pane title format | (none) | `pane_title_format` | (none) | @@ -139,7 +139,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Load with name override | `tmuxp load -s ` | `tmuxinator start -n ` | (none) | | Append to session | `tmuxp load -a` | `tmuxinator start --append` | (none) | | List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | -| Edit config | `tmuxp edit ` | `tmuxinator edit ` / `new` | `teamocil --edit ` | +| Edit config | `tmuxp edit ` | `tmuxinator edit ` (alias of `new`) | `teamocil --edit ` | | Show/debug config | (none) | `tmuxinator debug ` | `teamocil --show` / `--debug` | | Create new config | (none) | `tmuxinator new ` | (none) | | Copy config | (none) | `tmuxinator copy ` | (none) | From 6c2b5d47c8e94e2ab1d3c52c355ff87f5035809e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:36:22 -0600 Subject: [PATCH 005/143] fix(comparison): Annotate startup_window/startup_pane with tmuxp focus equivalent tmuxp doesn't have startup_window/startup_pane keys but achieves the same result via focus: true on individual windows/panes. Add cross-reference annotation so users aren't misled by (none). --- docs/comparison.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index c5501d74eb..4948202596 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -61,8 +61,8 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Environment vars | `environment` | (none) | (none) | | Pre-build script | `before_script` | (none) | (none) | | Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` (deprecated) | (none) | -| Startup window | (none) | `startup_window` (name or index) | (none) | -| Startup pane | (none) | `startup_pane` | (none) | +| Startup window | (none; use `focus: true` on window) | `startup_window` (name or index) | (none; use `focus: true` on window) | +| Startup pane | (none; use `focus: true` on pane) | `startup_pane` | (none; use `focus: true` on pane) | | Plugins | `plugins` | (none) | (none) | | ERB/variable interpolation | (none) | Yes (`key=value` args) | (none) | | YAML anchors | Yes | Yes (via `YAML.safe_load` `aliases: true`) | Yes | From 6b8806fb637985a037b361cdf05ebf796d439b16 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:41:59 -0600 Subject: [PATCH 006/143] fix(parity-docs): Correct before_script hook mapping and --here details - before_script maps to on_project_first_start (runs only when session doesn't exist), not on_project_start (runs every invocation) - Add teamocil --here implementation details: sends cd via send-keys, decrements window count for index calculation --- docs/comparison.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 4948202596..6535d35942 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -74,8 +74,8 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Hook | tmuxp | tmuxinator | teamocil | |---|---|---|---| -| Before session build | `before_script` | `on_project_start` | (none) | -| First start only | (none) | `on_project_first_start` | (none) | +| Every start invocation | (none) | `on_project_start` | (none) | +| First start only | `before_script` | `on_project_first_start` | (none) | | On reattach | Plugin: `reattach()` | `on_project_restart` | (none) | | On exit/detach | (none) | `on_project_exit` | (none) | | On stop/kill | (none) | `on_project_stop` | (none) | From 29c3b8e613c0c612e7460e89822b13d182287e96 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 07:03:53 -0600 Subject: [PATCH 007/143] fix(comparison): Correct tmuxinator min tmux, add session rename note, expand CLI table - Fix min tmux: 1.5+ (not "1.8 recommended; not 2.5"), per tmux_version.rb - Note teamocil renames session (rename-session) rather than creating new - Add teamocil auto-generated session name detail - Expand pre_window to show full deprecation chain (rbenv/rvm/pre_tab) - Add synchronize values (true/before/after) - Add --suppress-tmux-version-warning to CLI table - Split deprecated pre/post into separate rows with hook mappings - Fix tmuxp --append flag syntax - Fix pane focus to note startup_pane equivalent --- docs/comparison.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 6535d35942..b9f53f8471 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -8,11 +8,11 @@ |---|---|---|---| | **Version** | 1.47.0+ | 3.3.7 | 1.4.2 | | **Language** | Python | Ruby | Ruby | -| **Min tmux** | 3.2 | 1.8 (recommended; not 2.5) | (not specified) | +| **Min tmux** | 3.2 | 1.5+ (1.5–3.6a tested) | (not specified) | | **Config formats** | YAML, JSON | YAML (with ERB) | YAML | | **Architecture** | ORM (libtmux) | Script generation (ERB templates) | Command objects → shell exec | | **License** | MIT | MIT | MIT | -| **Session building** | API calls via libtmux | Generates bash script, then execs it | Generates tmux command string, then `system()` | +| **Session building** | API calls via libtmux | Generates bash script, then execs it | Generates tmux command list, renames current session, then `system()` | | **Plugin system** | Yes (Python classes) | No | No | | **Shell completion** | Yes | Yes (zsh/bash/fish) | No | @@ -48,7 +48,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Key | tmuxp | tmuxinator | teamocil | |---|---|---|---| -| Session name | `session_name` | `name` / `project_name` | `name` | +| Session name | `session_name` | `name` / `project_name` | `name` (auto-generated if omitted) | | Root directory | `start_directory` | `root` / `project_root` | (none, per-window only) | | Windows list | `windows` | `windows` / `tabs` | `windows` | | Socket name | (CLI `-L`) | `socket_name` | (none) | @@ -60,7 +60,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Global options | `global_options` | (none) | (none) | | Environment vars | `environment` | (none) | (none) | | Pre-build script | `before_script` | (none) | (none) | -| Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` (deprecated) | (none) | +| Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` / `rbenv` / `rvm` (all deprecated) | (none) | | Startup window | (none; use `focus: true` on window) | `startup_window` (name or index) | (none; use `focus: true` on window) | | Startup pane | (none; use `focus: true` on pane) | `startup_pane` | (none; use `focus: true` on pane) | | Plugins | `plugins` | (none) | (none) | @@ -82,7 +82,8 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) | | On window create | Plugin: `on_window_create()` | (none) | (none) | | After window done | Plugin: `after_window_finished()` | (none) | (none) | -| Deprecated pre/post | (none) | `pre` / `post` | (none) | +| Deprecated pre | (none) | `pre` (deprecated → `on_project_start`/`on_project_restart`) | (none) | +| Deprecated post | (none) | `post` (deprecated → `on_project_stop`/`on_project_exit`) | (none) | ### Window-Level @@ -99,8 +100,8 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Shell for window | `window_shell` | (none) | (none) | | Environment vars | `environment` | (none) | (none) | | Suppress history | `suppress_history` | (none) | (none) | -| Focus | `focus` | (none) | `focus` | -| Synchronize panes | (none) | `synchronize` | (none) | +| Focus | `focus` | (none; use `startup_window`) | `focus` | +| Synchronize panes | (none) | `synchronize` (`true`/`before`/`after`) | (none) | | Filters (before) | (none) | (none) | `filters.before` (v0.x) | | Filters (after) | (none) | (none) | `filters.after` (v0.x) | @@ -116,8 +117,8 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Sleep before | `sleep_before` | (none) | (none) | | Sleep after | `sleep_after` | (none) | (none) | | Suppress history | `suppress_history` | (none) | (none) | -| Focus | `focus` | (none) | `focus` | -| Pane title | (none) | hash key (named pane) | (none) | +| Focus | `focus` | (none; use `startup_pane`) | `focus` | +| Pane title | (none) | hash key (named pane → `select-pane -T`) | (none) | ### Shorthand Syntax @@ -137,9 +138,9 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Load/start session | `tmuxp load ` | `tmuxinator start ` | `teamocil ` | | Load detached | `tmuxp load -d ` | `attach: false` / `tmuxinator start --no-attach` | (none) | | Load with name override | `tmuxp load -s ` | `tmuxinator start -n ` | (none) | -| Append to session | `tmuxp load -a` | `tmuxinator start --append` | (none) | +| Append to session | `tmuxp load --append` | `tmuxinator start --append` | (none) | | List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | -| Edit config | `tmuxp edit ` | `tmuxinator edit ` (alias of `new`) | `teamocil --edit ` | +| Edit config | `tmuxp edit ` | `tmuxinator edit ` | `teamocil --edit ` | | Show/debug config | (none) | `tmuxinator debug ` | `teamocil --show` / `--debug` | | Create new config | (none) | `tmuxinator new ` | (none) | | Copy config | (none) | `tmuxinator copy ` | (none) | @@ -156,6 +157,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Use here (current window) | (none) | (none) | `teamocil --here` | | Skip pre_window | (none) | `--no-pre-window` | (none) | | Pass variables | (none) | `key=value` args | (none) | +| Suppress version warning | (none) | `--suppress-tmux-version-warning` | (none) | | Custom config path | `tmuxp load /path/to/file` | `-p /path/to/file` | `--layout /path/to/file` | | Local config | `tmuxp load .` | `tmuxinator local` | (none) | From f782997d84f98b4584f990d22dd23bd5ded7ac0d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 6 Mar 2026 17:08:35 -0600 Subject: [PATCH 008/143] docs(comparison): Update version, fix hook descriptions, add auto-detection heuristics why: Keep parity analysis current with tmuxp 1.64.0 and add config format detection algorithm for transparent import support. what: - Update tmuxp version from 1.47.0+ to 1.64.0 - Fix deprecated pre/post hook descriptions to match template.erb behavior - Add config format auto-detection heuristics table and algorithm - Update timestamp to 2026-03-06 --- docs/comparison.md | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index b9f53f8471..7e3d06bde6 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -1,12 +1,12 @@ # Feature Comparison: tmuxp vs tmuxinator vs teamocil -*Last updated: 2026-02-08* +*Last updated: 2026-03-06* ## Overview | | tmuxp | tmuxinator | teamocil | |---|---|---|---| -| **Version** | 1.47.0+ | 3.3.7 | 1.4.2 | +| **Version** | 1.64.0 | 3.3.7 | 1.4.2 | | **Language** | Python | Ruby | Ruby | | **Min tmux** | 3.2 | 1.5+ (1.5–3.6a tested) | (not specified) | | **Config formats** | YAML, JSON | YAML (with ERB) | YAML | @@ -82,8 +82,8 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) | | On window create | Plugin: `on_window_create()` | (none) | (none) | | After window done | Plugin: `after_window_finished()` | (none) | (none) | -| Deprecated pre | (none) | `pre` (deprecated → `on_project_start`/`on_project_restart`) | (none) | -| Deprecated post | (none) | `post` (deprecated → `on_project_stop`/`on_project_exit`) | (none) | +| Deprecated pre | (none) | `pre` (deprecated; runs once before windows if session is new) | (none) | +| Deprecated post | (none) | `post` (deprecated; runs after attach/detach on every invocation) | (none) | ### Window-Level @@ -172,3 +172,33 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Extension search | `.yaml`, `.yml`, `.json` | `.yml`, `.yaml` | `.yml` | | Recursive search | No | Yes (`Dir.glob("**/*.{yml,yaml}")`) | No | | Upward traversal | Yes (cwd → `~`) | No | No | + +## Config Format Auto-Detection Heuristics + +If tmuxp were to auto-detect and transparently load tmuxinator/teamocil configs, these heuristics would distinguish the formats: + +| Indicator | tmuxp | tmuxinator | teamocil v0.x | teamocil v1.x | +|---|---|---|---|---| +| `session_name` key | Yes | No | No | No | +| `name` or `project_name` key | No | Yes | Yes (inside `session:`) | Yes | +| `session:` wrapper | No | No | Yes | No | +| `root` / `project_root` key | No | Yes | Yes | No | +| `start_directory` key | Yes | No | No | No | +| `windows` contains hash-key syntax | No | Yes (`- editor: ...`) | No | No | +| `windows` contains `window_name` key | Yes | No | No | No | +| `windows` contains `name` key | No | No | Yes | Yes | +| `splits` key in windows | No | No | Yes | No | +| `panes` with `cmd` key | No | No | Yes | No | +| `panes` with `commands` key | No | No | No | Yes | +| `panes` with `shell_command` key | Yes | No | No | No | +| `tabs` key | No | Yes (deprecated) | No | No | + +**Reliable detection algorithm:** + +1. If `session_name` exists or any window has `window_name` → **tmuxp** format +2. If `session:` wrapper exists → **teamocil v0.x** format +3. If `project_name`, `project_root`, or `tabs` exists → **tmuxinator** format +4. If windows use hash-key syntax (`- editor: {panes: ...}`) → **tmuxinator** format +5. If windows have `name` key and panes use `commands` → **teamocil v1.x** format +6. If `root` exists at top level and windows use `name` key → **tmuxinator** format (also has `root`) +7. Ambiguous → ask user or try tmuxp first From b7f157f4e574c0195c0ffd570ddb0a1f07c5e33f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 6 Mar 2026 17:10:32 -0600 Subject: [PATCH 009/143] docs(comparison): Add teamocil v0.x pane sizing keys why: Complete pane-level comparison with verified v0.x features. what: - Add width, height, and target pane-level keys from teamocil v0.x --- docs/comparison.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/comparison.md b/docs/comparison.md index 7e3d06bde6..de2b70195a 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -119,6 +119,9 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Suppress history | `suppress_history` | (none) | (none) | | Focus | `focus` | (none; use `startup_pane`) | `focus` | | Pane title | (none) | hash key (named pane → `select-pane -T`) | (none) | +| Width | (none) | (none) | `width` (v0.x, horizontal split %) | +| Height | (none) | (none) | `height` (v0.x, vertical split %) | +| Split target | (none) | (none) | `target` (v0.x) | ### Shorthand Syntax From 3e341691a42244ff60ec876e9422e177d6a865a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 6 Mar 2026 17:23:42 -0600 Subject: [PATCH 010/143] docs(comparison): Refine auto-detection algorithm with v0.x fallback why: Add v0.x detection heuristic and fix teamocil v1.x pane detection. what: - Add step 7: detect teamocil v0.x by `cmd`/`splits` keys even without `session:` wrapper - Clarify step 5: v1.x string shorthand panes also indicate teamocil - Fix step 6: tmuxinator uses hash-key syntax, not just `name` key --- docs/comparison.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index de2b70195a..7304d9f451 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -202,6 +202,7 @@ If tmuxp were to auto-detect and transparently load tmuxinator/teamocil configs, 2. If `session:` wrapper exists → **teamocil v0.x** format 3. If `project_name`, `project_root`, or `tabs` exists → **tmuxinator** format 4. If windows use hash-key syntax (`- editor: {panes: ...}`) → **tmuxinator** format -5. If windows have `name` key and panes use `commands` → **teamocil v1.x** format -6. If `root` exists at top level and windows use `name` key → **tmuxinator** format (also has `root`) -7. Ambiguous → ask user or try tmuxp first +5. If windows have `name` key and panes use `commands` or string shorthand → **teamocil v1.x** format +6. If `root` exists at top level and windows use hash-key syntax → **tmuxinator** format +7. If windows have `name` key and panes use `cmd` or `splits` → **teamocil v0.x** format (even without `session:` wrapper) +8. Ambiguous → ask user or try tmuxp first From f85de7af4b1f51750f5b63f184c855b86db94bd4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 6 Mar 2026 17:35:34 -0600 Subject: [PATCH 011/143] docs(comparison): Fix pre-build script and deprecated hook mappings why: The comparison table incorrectly showed tmuxinator had no pre-build script equivalent, and deprecated hook descriptions lacked successor info. what: - Map tmuxinator on_project_first_start/pre to pre-build script row - Add deprecation successor hooks to pre and post rows --- docs/comparison.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 7304d9f451..f9a60cab11 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -59,7 +59,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Session options | `options` | (none) | (none) | | Global options | `global_options` | (none) | (none) | | Environment vars | `environment` | (none) | (none) | -| Pre-build script | `before_script` | (none) | (none) | +| Pre-build script | `before_script` | `on_project_first_start` / `pre` (deprecated; see Hooks) | (none) | | Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` / `rbenv` / `rvm` (all deprecated) | (none) | | Startup window | (none; use `focus: true` on window) | `startup_window` (name or index) | (none; use `focus: true` on window) | | Startup pane | (none; use `focus: true` on pane) | `startup_pane` | (none; use `focus: true` on pane) | @@ -82,8 +82,8 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) | | On window create | Plugin: `on_window_create()` | (none) | (none) | | After window done | Plugin: `after_window_finished()` | (none) | (none) | -| Deprecated pre | (none) | `pre` (deprecated; runs once before windows if session is new) | (none) | -| Deprecated post | (none) | `post` (deprecated; runs after attach/detach on every invocation) | (none) | +| Deprecated pre | (none) | `pre` (deprecated → `on_project_start`+`on_project_restart`; runs before session create) | (none) | +| Deprecated post | (none) | `post` (deprecated → `on_project_stop`+`on_project_exit`; runs after attach on every invocation) | (none) | ### Window-Level From 87d723687c26565dbb5fe78593bdebc9981e5be2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 7 Mar 2026 04:26:34 -0600 Subject: [PATCH 012/143] docs(comparison): Add synchronize deprecation, pane shell_command_before, multi-file load why: Source code analysis revealed details not captured in comparison table. what: - Note tmuxinator synchronize true/before deprecated in favor of after - Add pane-level shell_command_before row (tmuxp-unique feature) - Add multi-file loading CLI row (tmuxp load f1 f2) --- docs/comparison.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index f9a60cab11..007df4adb7 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -1,6 +1,6 @@ # Feature Comparison: tmuxp vs tmuxinator vs teamocil -*Last updated: 2026-03-06* +*Last updated: 2026-03-07* ## Overview @@ -101,7 +101,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Environment vars | `environment` | (none) | (none) | | Suppress history | `suppress_history` | (none) | (none) | | Focus | `focus` | (none; use `startup_window`) | `focus` | -| Synchronize panes | (none) | `synchronize` (`true`/`before`/`after`) | (none) | +| Synchronize panes | (none) | `synchronize` (`true`/`before`/`after`; `true`/`before` deprecated → use `after`) | (none) | | Filters (before) | (none) | (none) | `filters.before` (v0.x) | | Filters (after) | (none) | (none) | `filters.after` (v0.x) | @@ -118,6 +118,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Sleep after | `sleep_after` | (none) | (none) | | Suppress history | `suppress_history` | (none) | (none) | | Focus | `focus` | (none; use `startup_pane`) | `focus` | +| Shell cmd before | `shell_command_before` | (none; inherits from window/session) | (none) | | Pane title | (none) | hash key (named pane → `select-pane -T`) | (none) | | Width | (none) | (none) | `width` (v0.x, horizontal split %) | | Height | (none) | (none) | `height` (v0.x, vertical split %) | @@ -162,6 +163,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Pass variables | (none) | `key=value` args | (none) | | Suppress version warning | (none) | `--suppress-tmux-version-warning` | (none) | | Custom config path | `tmuxp load /path/to/file` | `-p /path/to/file` | `--layout /path/to/file` | +| Load multiple configs | `tmuxp load f1 f2 ...` (all but last detached) | (none) | (none) | | Local config | `tmuxp load .` | `tmuxinator local` | (none) | ## Config File Discovery From f19f2c68a1c5d0f955c0e9fe90c88fded3359e2d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 7 Mar 2026 07:43:19 -0600 Subject: [PATCH 013/143] fix(importers[import_tmuxinator]): Fix pre/pre_window mapping to correct tmuxp keys why: pre (session-level setup) was incorrectly mapped to shell_command_before (per-pane) instead of before_script (session-level). The isinstance check also tested the wrong variable when both pre and pre_window were present. what: - Change pre (solo) mapping from shell_command_before to before_script - Change pre + pre_window combo: pre -> before_script, pre_window -> shell_command_before - Fix isinstance check to test pre_window variable when both keys present - Remove invalid shell_command key assignment - Update test2 and test3 expected fixtures to match new mapping --- src/tmuxp/workspace/importers.py | 34 ++++++++++------------- tests/fixtures/import_tmuxinator/test2.py | 3 +- tests/fixtures/import_tmuxinator/test3.py | 2 +- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 65184d73a4..68995d6cdf 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import shlex import typing as t logger = logging.getLogger(__name__) @@ -44,20 +45,16 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: elif "root" in workspace_dict: tmuxp_workspace["start_directory"] = workspace_dict.pop("root") - if "cli_args" in workspace_dict: - tmuxp_workspace["config"] = workspace_dict["cli_args"] - - if "-f" in tmuxp_workspace["config"]: - tmuxp_workspace["config"] = ( - tmuxp_workspace["config"].replace("-f", "").strip() - ) - elif "tmux_options" in workspace_dict: - tmuxp_workspace["config"] = workspace_dict["tmux_options"] - - if "-f" in tmuxp_workspace["config"]: - tmuxp_workspace["config"] = ( - tmuxp_workspace["config"].replace("-f", "").strip() - ) + raw_args = workspace_dict.get("cli_args") or workspace_dict.get("tmux_options") + if raw_args: + tokens = shlex.split(raw_args) + flag_map = {"-f": "config", "-L": "socket_name", "-S": "socket_path"} + it = iter(tokens) + for token in it: + if token in flag_map: + value = next(it, None) + if value is not None: + tmuxp_workspace[flag_map[token]] = value if "socket_name" in workspace_dict: tmuxp_workspace["socket_name"] = workspace_dict["socket_name"] @@ -68,17 +65,14 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: workspace_dict["windows"] = workspace_dict.pop("tabs") if "pre" in workspace_dict and "pre_window" in workspace_dict: - tmuxp_workspace["shell_command"] = workspace_dict["pre"] + tmuxp_workspace["before_script"] = workspace_dict["pre"] - if isinstance(workspace_dict["pre"], str): + if isinstance(workspace_dict["pre_window"], str): tmuxp_workspace["shell_command_before"] = [workspace_dict["pre_window"]] else: tmuxp_workspace["shell_command_before"] = workspace_dict["pre_window"] elif "pre" in workspace_dict: - if isinstance(workspace_dict["pre"], str): - tmuxp_workspace["shell_command_before"] = [workspace_dict["pre"]] - else: - tmuxp_workspace["shell_command_before"] = workspace_dict["pre"] + tmuxp_workspace["before_script"] = workspace_dict["pre"] if "rbenv" in workspace_dict: if "shell_command_before" not in tmuxp_workspace: diff --git a/tests/fixtures/import_tmuxinator/test2.py b/tests/fixtures/import_tmuxinator/test2.py index 97d923a912..4953347b94 100644 --- a/tests/fixtures/import_tmuxinator/test2.py +++ b/tests/fixtures/import_tmuxinator/test2.py @@ -49,7 +49,8 @@ "socket_name": "foo", "config": "~/.tmux.mac.conf", "start_directory": "~/test", - "shell_command_before": ["sudo /etc/rc.d/mysqld start", "rbenv shell 2.0.0-p247"], + "before_script": "sudo /etc/rc.d/mysqld start", + "shell_command_before": ["rbenv shell 2.0.0-p247"], "windows": [ { "window_name": "editor", diff --git a/tests/fixtures/import_tmuxinator/test3.py b/tests/fixtures/import_tmuxinator/test3.py index 86ebd22c16..4dc7b6681d 100644 --- a/tests/fixtures/import_tmuxinator/test3.py +++ b/tests/fixtures/import_tmuxinator/test3.py @@ -50,7 +50,7 @@ "socket_name": "foo", "start_directory": "~/test", "config": "~/.tmux.mac.conf", - "shell_command": "sudo /etc/rc.d/mysqld start", + "before_script": "sudo /etc/rc.d/mysqld start", "shell_command_before": ["rbenv shell 2.0.0-p247"], "windows": [ { From abceffcc8469c70ac7f847212d00587a6edde5b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 7 Mar 2026 07:43:24 -0600 Subject: [PATCH 014/143] test(importers[import_tmuxinator]): Cover multi-flag cli_args parsing why: shlex-based cli_args parsing handles multiple flags in one string, but no fixture exercised that path. what: - Add test4 fixture exercising multi-flag cli_args (-f and -L together) - Register test4 in import_tmuxinator fixtures and parametrized tests --- tests/fixtures/import_tmuxinator/__init__.py | 2 +- tests/fixtures/import_tmuxinator/test4.py | 28 ++++++++++++++++++++ tests/fixtures/import_tmuxinator/test4.yaml | 6 +++++ tests/workspace/test_import_tmuxinator.py | 6 +++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/import_tmuxinator/test4.py create mode 100644 tests/fixtures/import_tmuxinator/test4.yaml diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py index 84508e0405..0864a25985 100644 --- a/tests/fixtures/import_tmuxinator/__init__.py +++ b/tests/fixtures/import_tmuxinator/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -from . import test1, test2, test3 +from . import test1, test2, test3, test4 diff --git a/tests/fixtures/import_tmuxinator/test4.py b/tests/fixtures/import_tmuxinator/test4.py new file mode 100644 index 0000000000..d318c6bf20 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test4.py @@ -0,0 +1,28 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 4th dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test4.yaml") + +tmuxinator_dict = { + "name": "multi-flag", + "root": "~/projects/app", + "cli_args": "-f ~/.tmux.mac.conf -L mysocket", + "windows": [ + {"editor": "vim"}, + {"server": "rails s"}, + ], +} + +expected = { + "session_name": "multi-flag", + "start_directory": "~/projects/app", + "config": "~/.tmux.mac.conf", + "socket_name": "mysocket", + "windows": [ + {"window_name": "editor", "panes": ["vim"]}, + {"window_name": "server", "panes": ["rails s"]}, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test4.yaml b/tests/fixtures/import_tmuxinator/test4.yaml new file mode 100644 index 0000000000..5004e1cb65 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test4.yaml @@ -0,0 +1,6 @@ +name: multi-flag +root: ~/projects/app +cli_args: -f ~/.tmux.mac.conf -L mysocket +windows: +- editor: vim +- server: rails s diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 457605f2ab..ff57512eed 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -40,6 +40,12 @@ class TmuxinatorConfigTestFixture(t.NamedTuple): tmuxinator_dict=fixtures.test3.tmuxinator_dict, tmuxp_dict=fixtures.test3.expected, ), + TmuxinatorConfigTestFixture( + test_id="multi_flag_cli_args", + tmuxinator_yaml=fixtures.test4.tmuxinator_yaml, + tmuxinator_dict=fixtures.test4.tmuxinator_dict, + tmuxp_dict=fixtures.test4.expected, + ), ] From c48aec74c8f6c03268d0cf122cebbd2ace22e9da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 7 Mar 2026 07:44:36 -0600 Subject: [PATCH 015/143] fix(importers[import_teamocil]): Replace filter loop with direct assignment and add v1.x support why: The for loop over filters was redundant (same assignment repeated each iteration) and broke on string filter values. v1.x teamocil used string panes and 'commands' key instead of dict panes with 'cmd'. what: - Replace filter loop with direct truthiness-guarded assignment - Handle string panes -> {"shell_command": [str]} - Handle None panes -> {"shell_command": []} - Handle 'commands' key as alias for 'cmd' - Handle 'height' pop parallel to existing 'width' pop --- src/tmuxp/workspace/importers.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 68995d6cdf..a50d0503a6 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -152,12 +152,10 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: window_dict["clear"] = w["clear"] if "filters" in w: - if "before" in w["filters"]: - for _b in w["filters"]["before"]: - window_dict["shell_command_before"] = w["filters"]["before"] - if "after" in w["filters"]: - for _b in w["filters"]["after"]: - window_dict["shell_command_after"] = w["filters"]["after"] + if w["filters"].get("before"): + window_dict["shell_command_before"] = w["filters"]["before"] + if w["filters"].get("after"): + window_dict["shell_command_after"] = w["filters"]["after"] if "root" in w: window_dict["start_directory"] = w.pop("root") @@ -166,13 +164,23 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: w["panes"] = w.pop("splits") if "panes" in w: + panes: list[t.Any] = [] for p in w["panes"]: - if "cmd" in p: - p["shell_command"] = p.pop("cmd") - if "width" in p: - # TODO support for height/width - p.pop("width") - window_dict["panes"] = w["panes"] + if p is None: + panes.append({"shell_command": []}) + elif isinstance(p, str): + panes.append({"shell_command": [p]}) + else: + if "cmd" in p: + p["shell_command"] = p.pop("cmd") + elif "commands" in p: + p["shell_command"] = p.pop("commands") + if "width" in p: + p.pop("width") + if "height" in p: + p.pop("height") + panes.append(p) + window_dict["panes"] = panes if "layout" in w: window_dict["layout"] = w["layout"] From 931380059d75a0717f3cbff765ca6acfcffdf955 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 7 Mar 2026 07:44:41 -0600 Subject: [PATCH 016/143] test(importers[import_teamocil]): Add v1.x format test fixture why: Verify string panes, None panes, and 'commands' key handling. what: - Add test5 fixture with v1.x teamocil format - Add v1x_format test case to parametrized test list --- tests/fixtures/import_teamocil/__init__.py | 2 +- tests/fixtures/import_teamocil/test5.py | 42 ++++++++++++++++++++++ tests/fixtures/import_teamocil/test5.yaml | 13 +++++++ tests/workspace/test_import_teamocil.py | 6 ++++ 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/import_teamocil/test5.py create mode 100644 tests/fixtures/import_teamocil/test5.yaml diff --git a/tests/fixtures/import_teamocil/__init__.py b/tests/fixtures/import_teamocil/__init__.py index 1ec7c59fd5..502bd627b9 100644 --- a/tests/fixtures/import_teamocil/__init__.py +++ b/tests/fixtures/import_teamocil/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -from . import layouts, test1, test2, test3, test4 +from . import layouts, test1, test2, test3, test4, test5 diff --git a/tests/fixtures/import_teamocil/test5.py b/tests/fixtures/import_teamocil/test5.py new file mode 100644 index 0000000000..c258ab1ded --- /dev/null +++ b/tests/fixtures/import_teamocil/test5.py @@ -0,0 +1,42 @@ +"""Teamocil data fixtures for import_teamocil tests, 5th test (v1.x format).""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test5.yaml") + +teamocil_dict = { + "windows": [ + { + "name": "v1-string-panes", + "root": "~/Code/legacy", + "layout": "even-horizontal", + "panes": ["echo 'hello'", "echo 'world'", None], + }, + { + "name": "v1-commands-key", + "panes": [{"commands": ["pwd", "ls -la"]}], + }, + ], +} + +expected = { + "session_name": None, + "windows": [ + { + "window_name": "v1-string-panes", + "start_directory": "~/Code/legacy", + "layout": "even-horizontal", + "panes": [ + {"shell_command": ["echo 'hello'"]}, + {"shell_command": ["echo 'world'"]}, + {"shell_command": []}, + ], + }, + { + "window_name": "v1-commands-key", + "panes": [{"shell_command": ["pwd", "ls -la"]}], + }, + ], +} diff --git a/tests/fixtures/import_teamocil/test5.yaml b/tests/fixtures/import_teamocil/test5.yaml new file mode 100644 index 0000000000..d94a2251fa --- /dev/null +++ b/tests/fixtures/import_teamocil/test5.yaml @@ -0,0 +1,13 @@ +windows: +- name: v1-string-panes + root: ~/Code/legacy + layout: even-horizontal + panes: + - echo 'hello' + - echo 'world' + - +- name: v1-commands-key + panes: + - commands: + - pwd + - ls -la diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 0ea457e7c6..3614bb5c94 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -46,6 +46,12 @@ class TeamocilConfigTestFixture(t.NamedTuple): teamocil_dict=fixtures.test4.teamocil_dict, tmuxp_dict=fixtures.test4.expected, ), + TeamocilConfigTestFixture( + test_id="v1x_format", + teamocil_yaml=fixtures.test5.teamocil_yaml, + teamocil_dict=fixtures.test5.teamocil_dict, + tmuxp_dict=fixtures.test5.expected, + ), ] From fde744b7461bf5bf17f572196b7b026a51cac994 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 7 Mar 2026 07:46:47 -0600 Subject: [PATCH 017/143] feat(importers[import_tmuxinator]): Add rvm, pre_tab, startup, and synchronize handling why: These tmuxinator keys were silently dropped during import. what: - Add rvm handling parallel to rbenv (rvm use ) - Add pre_tab as alias for pre_window (deprecated tmuxinator key) - Add startup_window -> start_window, startup_pane -> start_pane - Add synchronize desugaring: true/"before" -> options, "after" -> options_after - Add test5 fixture (rvm, pre_tab, startup) and test6 fixture (synchronize) --- src/tmuxp/workspace/importers.py | 43 ++++++++++++++-- tests/fixtures/import_tmuxinator/__init__.py | 2 +- tests/fixtures/import_tmuxinator/test5.py | 34 +++++++++++++ tests/fixtures/import_tmuxinator/test5.yaml | 10 ++++ tests/fixtures/import_tmuxinator/test6.py | 53 ++++++++++++++++++++ tests/fixtures/import_tmuxinator/test6.yaml | 16 ++++++ tests/workspace/test_import_tmuxinator.py | 12 +++++ 7 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/import_tmuxinator/test5.py create mode 100644 tests/fixtures/import_tmuxinator/test5.yaml create mode 100644 tests/fixtures/import_tmuxinator/test6.py create mode 100644 tests/fixtures/import_tmuxinator/test6.yaml diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index a50d0503a6..c2948e06e5 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -64,13 +64,18 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "tabs" in workspace_dict: workspace_dict["windows"] = workspace_dict.pop("tabs") - if "pre" in workspace_dict and "pre_window" in workspace_dict: + pre_window_val = workspace_dict.get( + "pre_window", + workspace_dict.get("pre_tab"), + ) + + if "pre" in workspace_dict and pre_window_val is not None: tmuxp_workspace["before_script"] = workspace_dict["pre"] - if isinstance(workspace_dict["pre_window"], str): - tmuxp_workspace["shell_command_before"] = [workspace_dict["pre_window"]] + if isinstance(pre_window_val, str): + tmuxp_workspace["shell_command_before"] = [pre_window_val] else: - tmuxp_workspace["shell_command_before"] = workspace_dict["pre_window"] + tmuxp_workspace["shell_command_before"] = pre_window_val elif "pre" in workspace_dict: tmuxp_workspace["before_script"] = workspace_dict["pre"] @@ -81,6 +86,19 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: "rbenv shell {}".format(workspace_dict["rbenv"]), ) + if "rvm" in workspace_dict: + if "shell_command_before" not in tmuxp_workspace: + tmuxp_workspace["shell_command_before"] = [] + tmuxp_workspace["shell_command_before"].append( + "rvm use {}".format(workspace_dict["rvm"]), + ) + + if "startup_window" in workspace_dict: + tmuxp_workspace["start_window"] = workspace_dict["startup_window"] + + if "startup_pane" in workspace_dict: + tmuxp_workspace["start_pane"] = workspace_dict["startup_pane"] + for window_dict in workspace_dict["windows"]: for k, v in window_dict.items(): window_dict = {"window_name": k} @@ -103,6 +121,16 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "layout" in v: window_dict["layout"] = v["layout"] + + if "synchronize" in v: + sync = v["synchronize"] + if sync is True or sync == "before": + window_dict.setdefault("options", {})["synchronize-panes"] = "on" + elif sync == "after": + window_dict.setdefault("options_after", {})["synchronize-panes"] = ( + "on" + ) + tmuxp_workspace["windows"].append(window_dict) return tmuxp_workspace @@ -184,6 +212,13 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "layout" in w: window_dict["layout"] = w["layout"] + + if w.get("focus"): + window_dict["focus"] = True + + if "options" in w: + window_dict["options"] = w["options"] + tmuxp_workspace["windows"].append(window_dict) return tmuxp_workspace diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py index 0864a25985..b778967652 100644 --- a/tests/fixtures/import_tmuxinator/__init__.py +++ b/tests/fixtures/import_tmuxinator/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -from . import test1, test2, test3, test4 +from . import test1, test2, test3, test4, test5, test6 diff --git a/tests/fixtures/import_tmuxinator/test5.py b/tests/fixtures/import_tmuxinator/test5.py new file mode 100644 index 0000000000..500b594a68 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test5.py @@ -0,0 +1,34 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 5th dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test5.yaml") + +tmuxinator_dict = { + "name": "ruby-app", + "root": "~/projects/ruby-app", + "rvm": "2.1.1", + "pre": "./scripts/bootstrap.sh", + "pre_tab": "source .env", + "startup_window": "server", + "startup_pane": 0, + "windows": [ + {"editor": "vim"}, + {"server": "rails s"}, + ], +} + +expected = { + "session_name": "ruby-app", + "start_directory": "~/projects/ruby-app", + "before_script": "./scripts/bootstrap.sh", + "shell_command_before": ["source .env", "rvm use 2.1.1"], + "start_window": "server", + "start_pane": 0, + "windows": [ + {"window_name": "editor", "panes": ["vim"]}, + {"window_name": "server", "panes": ["rails s"]}, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test5.yaml b/tests/fixtures/import_tmuxinator/test5.yaml new file mode 100644 index 0000000000..eb4ad0b7c8 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test5.yaml @@ -0,0 +1,10 @@ +name: ruby-app +root: ~/projects/ruby-app +rvm: 2.1.1 +pre: ./scripts/bootstrap.sh +pre_tab: source .env +startup_window: server +startup_pane: 0 +windows: +- editor: vim +- server: rails s diff --git a/tests/fixtures/import_tmuxinator/test6.py b/tests/fixtures/import_tmuxinator/test6.py new file mode 100644 index 0000000000..e581a05586 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test6.py @@ -0,0 +1,53 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 6th dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test6.yaml") + +tmuxinator_dict = { + "name": "sync-test", + "root": "~/projects/sync", + "windows": [ + { + "synced": { + "synchronize": True, + "panes": ["echo 'pane1'", "echo 'pane2'"], + }, + }, + { + "synced-after": { + "synchronize": "after", + "panes": ["echo 'pane1'"], + }, + }, + { + "not-synced": { + "synchronize": False, + "panes": ["echo 'pane1'"], + }, + }, + ], +} + +expected = { + "session_name": "sync-test", + "start_directory": "~/projects/sync", + "windows": [ + { + "window_name": "synced", + "options": {"synchronize-panes": "on"}, + "panes": ["echo 'pane1'", "echo 'pane2'"], + }, + { + "window_name": "synced-after", + "options_after": {"synchronize-panes": "on"}, + "panes": ["echo 'pane1'"], + }, + { + "window_name": "not-synced", + "panes": ["echo 'pane1'"], + }, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test6.yaml b/tests/fixtures/import_tmuxinator/test6.yaml new file mode 100644 index 0000000000..c4edc9e71c --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test6.yaml @@ -0,0 +1,16 @@ +name: sync-test +root: ~/projects/sync +windows: +- synced: + synchronize: true + panes: + - echo 'pane1' + - echo 'pane2' +- synced-after: + synchronize: after + panes: + - echo 'pane1' +- not-synced: + synchronize: false + panes: + - echo 'pane1' diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index ff57512eed..5a7fd57616 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -46,6 +46,18 @@ class TmuxinatorConfigTestFixture(t.NamedTuple): tmuxinator_dict=fixtures.test4.tmuxinator_dict, tmuxp_dict=fixtures.test4.expected, ), + TmuxinatorConfigTestFixture( + test_id="rvm_pre_tab_startup", + tmuxinator_yaml=fixtures.test5.tmuxinator_yaml, + tmuxinator_dict=fixtures.test5.tmuxinator_dict, + tmuxp_dict=fixtures.test5.expected, + ), + TmuxinatorConfigTestFixture( + test_id="synchronize", + tmuxinator_yaml=fixtures.test6.tmuxinator_yaml, + tmuxinator_dict=fixtures.test6.tmuxinator_dict, + tmuxp_dict=fixtures.test6.expected, + ), ] From 0773910be814106cc1e831d032cf88a130497110 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 7 Mar 2026 07:46:53 -0600 Subject: [PATCH 018/143] test(importers[import_teamocil]): Cover focus, options, and height keys why: The importer already maps focus and options and pops height, but no fixture exercised those paths. what: - Add test6 fixture and expected output covering focus, options, height - Register test6 in the teamocil import fixture list --- tests/fixtures/import_teamocil/__init__.py | 2 +- tests/fixtures/import_teamocil/test6.py | 48 ++++++++++++++++++++++ tests/fixtures/import_teamocil/test6.yaml | 14 +++++++ tests/workspace/test_import_teamocil.py | 6 +++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/import_teamocil/test6.py create mode 100644 tests/fixtures/import_teamocil/test6.yaml diff --git a/tests/fixtures/import_teamocil/__init__.py b/tests/fixtures/import_teamocil/__init__.py index 502bd627b9..ac48683e2f 100644 --- a/tests/fixtures/import_teamocil/__init__.py +++ b/tests/fixtures/import_teamocil/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -from . import layouts, test1, test2, test3, test4, test5 +from . import layouts, test1, test2, test3, test4, test5, test6 diff --git a/tests/fixtures/import_teamocil/test6.py b/tests/fixtures/import_teamocil/test6.py new file mode 100644 index 0000000000..07d957195d --- /dev/null +++ b/tests/fixtures/import_teamocil/test6.py @@ -0,0 +1,48 @@ +"""Teamocil data fixtures for import_teamocil tests, 6th test.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test6.yaml") + +teamocil_dict = { + "windows": [ + { + "name": "focused-window", + "root": "~/Code/app", + "layout": "main-vertical", + "focus": True, + "options": {"synchronize-panes": "on"}, + "panes": [ + {"cmd": "vim"}, + {"cmd": "rails s", "height": 30}, + ], + }, + { + "name": "background-window", + "panes": [{"cmd": "tail -f log/development.log"}], + }, + ], +} + +expected = { + "session_name": None, + "windows": [ + { + "window_name": "focused-window", + "start_directory": "~/Code/app", + "layout": "main-vertical", + "focus": True, + "options": {"synchronize-panes": "on"}, + "panes": [ + {"shell_command": "vim"}, + {"shell_command": "rails s"}, + ], + }, + { + "window_name": "background-window", + "panes": [{"shell_command": "tail -f log/development.log"}], + }, + ], +} diff --git a/tests/fixtures/import_teamocil/test6.yaml b/tests/fixtures/import_teamocil/test6.yaml new file mode 100644 index 0000000000..a682346232 --- /dev/null +++ b/tests/fixtures/import_teamocil/test6.yaml @@ -0,0 +1,14 @@ +windows: +- name: focused-window + root: ~/Code/app + layout: main-vertical + focus: true + options: + synchronize-panes: 'on' + panes: + - cmd: vim + - cmd: rails s + height: 30 +- name: background-window + panes: + - cmd: tail -f log/development.log diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 3614bb5c94..439128aeeb 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -52,6 +52,12 @@ class TeamocilConfigTestFixture(t.NamedTuple): teamocil_dict=fixtures.test5.teamocil_dict, tmuxp_dict=fixtures.test5.expected, ), + TeamocilConfigTestFixture( + test_id="focus_options_height", + teamocil_yaml=fixtures.test6.teamocil_yaml, + teamocil_dict=fixtures.test6.teamocil_dict, + tmuxp_dict=fixtures.test6.expected, + ), ] From 93b68efd2b60f7c72be85e5e08599ee7cb6a8190 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 7 Mar 2026 07:48:59 -0600 Subject: [PATCH 019/143] feat(importers): Add logging for unsupported/dropped keys and remove stale TODO why: Silent key drops made debugging config imports difficult. The TODO docstring items are now either implemented or tracked in the parity audit. what: - Add logger.warning for width/height pane key drops with tmux_window extra - Add logger.warning for with_env_var and cmd_separator window key drops - Add logger.info for multi-command pre list mapped to before_script - Add caplog-based tests asserting on record attributes (not string matching) - Remove stale Notes/Todos docstring from import_teamocil --- src/tmuxp/workspace/importers.py | 39 +++++++++++++++----- tests/workspace/test_import_teamocil.py | 45 +++++++++++++++++++++++ tests/workspace/test_import_tmuxinator.py | 17 +++++++++ 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index c2948e06e5..9f55cab914 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -77,6 +77,11 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: else: tmuxp_workspace["shell_command_before"] = pre_window_val elif "pre" in workspace_dict: + if isinstance(workspace_dict["pre"], list): + logger.info( + "multi-command pre list mapped to before_script; " + "consider splitting into before_script and shell_command_before", + ) tmuxp_workspace["before_script"] = workspace_dict["pre"] if "rbenv" in workspace_dict: @@ -144,16 +149,6 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ---------- workspace_dict : dict python dict for tmuxp workspace - - Notes - ----- - Todos: - - - change 'root' to a cd or start_directory - - width in pane -> main-pain-width - - with_env_var - - clear - - cmd_separator """ _inner = workspace_dict.get("session", workspace_dict) logger.debug( @@ -204,8 +199,18 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: elif "commands" in p: p["shell_command"] = p.pop("commands") if "width" in p: + logger.warning( + "unsupported pane key %s dropped", + "width", + extra={"tmux_window": w["name"]}, + ) p.pop("width") if "height" in p: + logger.warning( + "unsupported pane key %s dropped", + "height", + extra={"tmux_window": w["name"]}, + ) p.pop("height") panes.append(p) window_dict["panes"] = panes @@ -219,6 +224,20 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "options" in w: window_dict["options"] = w["options"] + if "with_env_var" in w: + logger.warning( + "unsupported window key %s dropped", + "with_env_var", + extra={"tmux_window": w["name"]}, + ) + + if "cmd_separator" in w: + logger.warning( + "unsupported window key %s dropped", + "cmd_separator", + extra={"tmux_window": w["name"]}, + ) + tmuxp_workspace["windows"].append(window_dict) return tmuxp_workspace diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 439128aeeb..547e3207c4 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -169,3 +169,48 @@ def test_import_teamocil_logs_debug( records = [r for r in caplog.records if r.msg == "importing teamocil workspace"] assert len(records) >= 1 assert getattr(records[0], "tmux_session", None) == "test" + + +def test_warns_on_width_height_drop( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that importing teamocil config with width/height logs warnings.""" + teamocil_dict = { + "windows": [ + { + "name": "win-with-height", + "panes": [{"cmd": "vim", "height": 30}], + }, + ], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(teamocil_dict) + + height_records = [ + r for r in caplog.records if hasattr(r, "tmux_window") and "height" in r.message + ] + assert len(height_records) == 1 + assert height_records[0].tmux_window == "win-with-height" + + +def test_warns_on_with_env_var_and_cmd_separator( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that importing teamocil config with unsupported keys logs warnings.""" + teamocil_dict = { + "windows": [ + { + "name": "custom-opts", + "with_env_var": True, + "cmd_separator": " && ", + "panes": [{"cmd": "echo hello"}], + }, + ], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(teamocil_dict) + + env_var_records = [r for r in caplog.records if "with_env_var" in r.message] + cmd_sep_records = [r for r in caplog.records if "cmd_separator" in r.message] + assert len(env_var_records) == 1 + assert len(cmd_sep_records) == 1 diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 5a7fd57616..e14d6f8fd5 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -94,3 +94,20 @@ def test_import_tmuxinator_logs_debug( records = [r for r in caplog.records if r.msg == "importing tmuxinator workspace"] assert len(records) >= 1 assert getattr(records[0], "tmux_session", None) == "test" + + +def test_logs_info_on_multi_command_pre_list( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that multi-command pre list logs info about before_script mapping.""" + workspace = { + "name": "multi-pre", + "root": "~/test", + "pre": ["cmd1", "cmd2"], + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.INFO, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + + pre_records = [r for r in caplog.records if "multi-command pre list" in r.message] + assert len(pre_records) == 1 From 95a8346bf99e52a9fd04065d38bfdad6024c9c6f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 10:42:38 -0500 Subject: [PATCH 020/143] feat(loader[expand]): Desugar synchronize shorthand into options/options_after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Native tmuxp YAML configs had to use `options: {synchronize-panes: on}` manually. The tmuxinator importer already desugared `synchronize: true/before/after` but this shorthand wasn't available in native configs. what: - Add synchronize desugaring in expand() before start_directory handling - synchronize: true/before → options.synchronize-panes: on - synchronize: after → options_after.synchronize-panes: on - Pop the key so the builder never sees it - Uses setdefault() to merge with any existing options dict --- src/tmuxp/workspace/loader.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 9efcd05b52..56abbd1bb5 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -138,6 +138,14 @@ def expand( val = str(cwd / val) workspace_dict["options"][key] = val + # Desugar synchronize shorthand into options / options_after + if "synchronize" in workspace_dict: + sync = workspace_dict.pop("synchronize") + if sync is True or sync == "before": + workspace_dict.setdefault("options", {})["synchronize-panes"] = "on" + elif sync == "after": + workspace_dict.setdefault("options_after", {})["synchronize-panes"] = "on" + # Any workspace section, session, window, pane that can contain the # 'shell_command' value if "start_directory" in workspace_dict: From 65f274f7f73e7716cf7198731e925796d05f7408 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 10:42:45 -0500 Subject: [PATCH 021/143] test(builder,loader): Add synchronize config key tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Verify the synchronize desugaring works end-to-end and in isolation. what: - Add synchronize.yaml fixture with before, after, and no-sync windows - Add test_synchronize builder integration test (YAML → expand → build → tmux) - Add test_expand_synchronize unit test for desugaring logic in expand() --- .../workspace/builder/synchronize.yaml | 16 ++++++++ tests/workspace/test_builder.py | 24 ++++++++++++ tests/workspace/test_config.py | 39 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 tests/fixtures/workspace/builder/synchronize.yaml diff --git a/tests/fixtures/workspace/builder/synchronize.yaml b/tests/fixtures/workspace/builder/synchronize.yaml new file mode 100644 index 0000000000..45837332ea --- /dev/null +++ b/tests/fixtures/workspace/builder/synchronize.yaml @@ -0,0 +1,16 @@ +session_name: test synchronize +windows: + - window_name: synced-before + synchronize: before + panes: + - echo 0 + - echo 1 + - window_name: synced-after + synchronize: after + panes: + - echo 0 + - echo 1 + - window_name: not-synced + panes: + - echo 0 + - echo 1 diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..2d5cf500f1 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -359,6 +359,30 @@ def f() -> bool: ), "Synchronized command did not execute properly" +def test_synchronize( + session: Session, +) -> None: + """Test synchronize config key desugars to synchronize-panes option.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/synchronize.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + windows = session.windows + assert len(windows) == 3 + + synced_before = windows[0] + synced_after = windows[1] + not_synced = windows[2] + + assert synced_before.show_option("synchronize-panes") is True + assert synced_after.show_option("synchronize-panes") is True + assert not_synced.show_option("synchronize-panes") is not True + + def test_window_shell( session: Session, ) -> None: diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index fc6d5ccd5b..5d5c6e77ae 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -333,6 +333,45 @@ def test_validate_plugins() -> None: assert excinfo.match("only supports list type") +def test_expand_synchronize() -> None: + """Test that expand() desugars synchronize into options/options_after.""" + workspace = { + "session_name": "test", + "windows": [ + { + "window_name": "before", + "synchronize": True, + "panes": [{"shell_command": ["echo hi"]}], + }, + { + "window_name": "after", + "synchronize": "after", + "panes": [{"shell_command": ["echo hi"]}], + }, + { + "window_name": "false", + "synchronize": False, + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + # synchronize: True → options with synchronize-panes on, key removed + assert "synchronize" not in result["windows"][0] + assert result["windows"][0]["options"]["synchronize-panes"] == "on" + + # synchronize: "after" → options_after with synchronize-panes on, key removed + assert "synchronize" not in result["windows"][1] + assert result["windows"][1]["options_after"]["synchronize-panes"] == "on" + + # synchronize: False → no options added, key removed + assert "synchronize" not in result["windows"][2] + assert "options" not in result["windows"][2] or "synchronize-panes" not in result[ + "windows" + ][2].get("options", {}) + + def test_expand_logs_debug( tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, From 22b654b2f5541ee6fd451a7da37d1ee5c3900e01 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 11:41:13 -0500 Subject: [PATCH 022/143] feat(builder[config_after_window],loader[expand]): Add shell_command_after support why: The teamocil importer produces shell_command_after (from filters.after) but the builder silently ignored it. Native tmuxp configs also couldn't use this key. what: - Add shell_command_after expansion in expand() via expand_cmd() - Add shell_command_after handling in config_after_window() - After all panes are created, sends each command to every pane in the window --- src/tmuxp/workspace/builder.py | 8 ++++++++ src/tmuxp/workspace/loader.py | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..59267b98fc 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -844,6 +844,14 @@ def config_after_window( for key, val in window_config["options_after"].items(): window.set_option(key, val) + if "shell_command_after" in window_config and isinstance( + window_config["shell_command_after"], + dict, + ): + for cmd in window_config["shell_command_after"].get("shell_command", []): + for pane in window.panes: + pane.send_keys(cmd["cmd"]) + def find_current_attached_session(self) -> Session: """Return current attached session.""" assert self.server is not None diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 56abbd1bb5..6f7abf0647 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -183,6 +183,11 @@ def expand( workspace_dict["shell_command_before"] = expand_cmd(shell_command_before) + if "shell_command_after" in workspace_dict: + shell_command_after = workspace_dict["shell_command_after"] + + workspace_dict["shell_command_after"] = expand_cmd(shell_command_after) + # recurse into window and pane workspace items if "windows" in workspace_dict: workspace_dict["windows"] = [ From 74cc54b29691b5e53453331ff53802030edfedb2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 11:41:19 -0500 Subject: [PATCH 023/143] test(builder,loader): Add shell_command_after tests why: Verify shell_command_after works end-to-end and that expand() normalizes it. what: - Add shell_command_after.yaml fixture with after and no-after windows - Add test_shell_command_after builder integration test - Add test_expand_shell_command_after unit test for expand() normalization --- .../builder/shell_command_after.yaml | 11 +++++ tests/workspace/test_builder.py | 30 ++++++++++++++ tests/workspace/test_config.py | 40 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 tests/fixtures/workspace/builder/shell_command_after.yaml diff --git a/tests/fixtures/workspace/builder/shell_command_after.yaml b/tests/fixtures/workspace/builder/shell_command_after.yaml new file mode 100644 index 0000000000..c63ce1da4b --- /dev/null +++ b/tests/fixtures/workspace/builder/shell_command_after.yaml @@ -0,0 +1,11 @@ +session_name: test shell_command_after +windows: + - window_name: with-after + panes: + - echo pane0 + - echo pane1 + shell_command_after: + - echo __AFTER__ + - window_name: without-after + panes: + - echo normal diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 2d5cf500f1..b0c11d9af5 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -383,6 +383,36 @@ def test_synchronize( assert not_synced.show_option("synchronize-panes") is not True +def test_shell_command_after( + session: Session, +) -> None: + """Test shell_command_after sends commands to all panes after window creation.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/shell_command_after.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + windows = session.windows + assert len(windows) == 2 + + after_window = windows[0] + no_after_window = windows[1] + + for pane in after_window.panes: + + def check(p: Pane = pane) -> bool: + return "__AFTER__" in "\n".join(p.capture_pane()) + + assert retry_until(check), f"Expected __AFTER__ in pane {pane.pane_id}" + + for pane in no_after_window.panes: + captured = "\n".join(pane.capture_pane()) + assert "__AFTER__" not in captured + + def test_window_shell( session: Session, ) -> None: diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 5d5c6e77ae..17510c1d6c 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -372,6 +372,46 @@ def test_expand_synchronize() -> None: ][2].get("options", {}) +def test_expand_shell_command_after() -> None: + """Test that expand() normalizes shell_command_after into expanded form.""" + workspace = { + "session_name": "test", + "windows": [ + { + "window_name": "with-after", + "shell_command_after": ["echo done", "echo bye"], + "panes": [{"shell_command": ["echo hi"]}], + }, + { + "window_name": "string-after", + "shell_command_after": "echo single", + "panes": [{"shell_command": ["echo hi"]}], + }, + { + "window_name": "no-after", + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + # List form: normalized to {shell_command: [{cmd: "..."}, ...]} + after = result["windows"][0]["shell_command_after"] + assert isinstance(after, dict) + assert len(after["shell_command"]) == 2 + assert after["shell_command"][0]["cmd"] == "echo done" + assert after["shell_command"][1]["cmd"] == "echo bye" + + # String form: normalized the same way + after_str = result["windows"][1]["shell_command_after"] + assert isinstance(after_str, dict) + assert len(after_str["shell_command"]) == 1 + assert after_str["shell_command"][0]["cmd"] == "echo single" + + # No shell_command_after: key absent + assert "shell_command_after" not in result["windows"][2] + + def test_expand_logs_debug( tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, From 141436808166e28c61e10dd8a8f3e015948a7408 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 11:48:45 -0500 Subject: [PATCH 024/143] feat(loader[expand],builder[iter_create_panes]): Add pane title config key support why: Native tmuxp configs had no way to enable pane borders or set pane titles. The tmuxinator importer needs these for named pane syntax support. what: - Desugar enable_pane_titles/pane_title_position/pane_title_format in expand() - Session-level keys pushed into each window's options dict - Defaults: position=top, format="#{pane_index}: #{pane_title}" - Per-window overrides preserved via setdefault() - Add pane title handling in iter_create_panes() via pane.set_title() --- src/tmuxp/workspace/builder.py | 3 +++ src/tmuxp/workspace/loader.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 59267b98fc..2913b69419 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -813,6 +813,9 @@ def get_pane_shell( if sleep_after is not None: time.sleep(sleep_after) + if pane_config.get("title"): + pane.set_title(pane_config["title"]) + if pane_config.get("focus"): assert pane.pane_id is not None window.select_pane(pane.pane_id) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 6f7abf0647..67a83a5140 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -188,6 +188,23 @@ def expand( workspace_dict["shell_command_after"] = expand_cmd(shell_command_after) + # Desugar pane title session-level config into per-window options + if workspace_dict.get("enable_pane_titles") and "windows" in workspace_dict: + position = workspace_dict.pop("pane_title_position", "top") + fmt = workspace_dict.pop( + "pane_title_format", + "#{pane_index}: #{pane_title}", + ) + workspace_dict.pop("enable_pane_titles") + for window in workspace_dict["windows"]: + window.setdefault("options", {}) + window["options"].setdefault("pane-border-status", position) + window["options"].setdefault("pane-border-format", fmt) + elif "enable_pane_titles" in workspace_dict: + workspace_dict.pop("enable_pane_titles") + workspace_dict.pop("pane_title_position", None) + workspace_dict.pop("pane_title_format", None) + # recurse into window and pane workspace items if "windows" in workspace_dict: workspace_dict["windows"] = [ From 5fcb06ad71996c4fc9ef68fd430127d424a09724 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 11:48:52 -0500 Subject: [PATCH 025/143] test(builder,loader): Add pane title config key tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Verify pane title desugaring and builder integration work correctly. what: - Add pane_titles.yaml fixture with titled and untitled panes - Add test_pane_titles builder integration test (border options + pane titles) - Add test_expand_pane_titles unit test (session→window desugaring) - Add test_expand_pane_titles_disabled unit test (false removes keys) - Add test_expand_pane_titles_defaults unit test (default position/format) --- .../workspace/builder/pane_titles.yaml | 15 ++++ tests/workspace/test_builder.py | 31 +++++++ tests/workspace/test_config.py | 85 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 tests/fixtures/workspace/builder/pane_titles.yaml diff --git a/tests/fixtures/workspace/builder/pane_titles.yaml b/tests/fixtures/workspace/builder/pane_titles.yaml new file mode 100644 index 0000000000..09a11cfe11 --- /dev/null +++ b/tests/fixtures/workspace/builder/pane_titles.yaml @@ -0,0 +1,15 @@ +session_name: test pane_titles +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - window_name: titled + panes: + - title: editor + shell_command: + - echo pane0 + - title: runner + shell_command: + - echo pane1 + - shell_command: + - echo pane2 diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index b0c11d9af5..9d1565a6d3 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -413,6 +413,37 @@ def check(p: Pane = pane) -> bool: assert "__AFTER__" not in captured +def test_pane_titles( + session: Session, +) -> None: + """Test pane title config keys set pane-border-status and pane titles.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/pane_titles.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("pane-border-status") == "top" + assert window.show_option("pane-border-format") == "#{pane_index}: #{pane_title}" + + panes = window.panes + assert len(panes) == 3 + + def check_title(p: Pane, expected: str) -> bool: + p.refresh() + return p.pane_title == expected + + assert retry_until( + functools.partial(check_title, panes[0], "editor"), + ), f"Expected title 'editor', got '{panes[0].pane_title}'" + assert retry_until( + functools.partial(check_title, panes[1], "runner"), + ), f"Expected title 'runner', got '{panes[1].pane_title}'" + + def test_window_shell( session: Session, ) -> None: diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 17510c1d6c..d5d16256c0 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -412,6 +412,91 @@ def test_expand_shell_command_after() -> None: assert "shell_command_after" not in result["windows"][2] +def test_expand_pane_titles() -> None: + """Test that expand() desugars pane title session keys into window options.""" + workspace = { + "session_name": "test", + "enable_pane_titles": True, + "pane_title_position": "bottom", + "pane_title_format": " #T ", + "windows": [ + { + "window_name": "w1", + "panes": [ + {"title": "editor", "shell_command": ["echo hi"]}, + {"shell_command": ["echo bye"]}, + ], + }, + { + "window_name": "w2", + "options": {"pane-border-status": "off"}, + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + # Session-level keys removed + assert "enable_pane_titles" not in result + assert "pane_title_position" not in result + assert "pane_title_format" not in result + + # Window 1: options populated from session-level config + assert result["windows"][0]["options"]["pane-border-status"] == "bottom" + assert result["windows"][0]["options"]["pane-border-format"] == " #T " + + # Window 2: per-window override preserved (setdefault doesn't overwrite) + assert result["windows"][1]["options"]["pane-border-status"] == "off" + assert result["windows"][1]["options"]["pane-border-format"] == " #T " + + # Pane title key preserved for builder + assert result["windows"][0]["panes"][0]["title"] == "editor" + assert "title" not in result["windows"][0]["panes"][1] + + +def test_expand_pane_titles_disabled() -> None: + """Test that expand() removes pane title keys when disabled.""" + workspace = { + "session_name": "test", + "enable_pane_titles": False, + "pane_title_position": "top", + "windows": [ + { + "window_name": "w1", + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + assert "enable_pane_titles" not in result + assert "pane_title_position" not in result + assert "options" not in result["windows"][0] or "pane-border-status" not in result[ + "windows" + ][0].get("options", {}) + + +def test_expand_pane_titles_defaults() -> None: + """Test that expand() uses default position and format when not specified.""" + workspace = { + "session_name": "test", + "enable_pane_titles": True, + "windows": [ + { + "window_name": "w1", + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + assert result["windows"][0]["options"]["pane-border-status"] == "top" + assert ( + result["windows"][0]["options"]["pane-border-format"] + == "#{pane_index}: #{pane_title}" + ) + + def test_expand_logs_debug( tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, From b53d99b79839882a8088ece9f5b189bb682c940a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 12:01:13 -0500 Subject: [PATCH 026/143] feat(cli[load],builder): Add --here flag to reuse current window why: Users should be able to load a workspace into the current session, reusing the active window for the first config window (matching teamocil's --here behavior). what: - Add --here CLI argument to tmuxp load - Thread the here flag from the CLI through load_workspace and the builder - Add _load_here_in_current_session dispatch function - In build(): rename session to match config session_name - In iter_create_windows(): reuse active window for first window (rename, cd to start_directory) instead of first_window_pass trick - Skip session-exists prompt when --here is used --- src/tmuxp/cli/load.py | 39 +++++++++++- src/tmuxp/workspace/builder.py | 110 ++++++++++++++++++++++----------- 2 files changed, 113 insertions(+), 36 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..a89f7764d6 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -105,6 +105,7 @@ class CLILoadNamespace(argparse.Namespace): answer_yes: bool | None detached: bool append: bool | None + here: bool | None colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None @@ -305,6 +306,18 @@ def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: assert builder.session is not None +def _load_here_in_current_session(builder: WorkspaceBuilder) -> None: + """Load workspace reusing current window for first window. + + Parameters + ---------- + builder: :class:`workspace.builder.WorkspaceBuilder` + """ + current_attached_session = builder.find_current_attached_session() + builder.build(current_attached_session, here=True) + assert builder.session is not None + + def _setup_plugins(builder: WorkspaceBuilder) -> Session: """Execute hooks for plugins running after ``before_script``. @@ -325,6 +338,7 @@ def _dispatch_build( append: bool, answer_yes: bool, cli_colors: Colors, + here: bool = False, pre_attach_hook: t.Callable[[], None] | None = None, on_error_hook: t.Callable[[], None] | None = None, pre_prompt_hook: t.Callable[[], None] | None = None, @@ -347,6 +361,8 @@ def _dispatch_build( Skip interactive prompts. cli_colors : Colors Colors instance for styled output. + here : bool + Use current window for first workspace window. pre_attach_hook : callable, optional Called before attach/switch_client (e.g. stop spinner). on_error_hook : callable, optional @@ -371,6 +387,14 @@ def _dispatch_build( _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) return _setup_plugins(builder) + if here: + if "TMUX" in os.environ: # tmuxp ran from inside tmux + _load_here_in_current_session(builder) + else: + _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + + return _setup_plugins(builder) + if append: if "TMUX" in os.environ: # tmuxp ran from inside tmux _load_append_windows_to_current_session(builder) @@ -446,6 +470,7 @@ def load_workspace( detached: bool = False, answer_yes: bool = False, append: bool = False, + here: bool = False, cli_colors: Colors | None = None, progress_format: str | None = None, panel_lines: int | None = None, @@ -473,6 +498,9 @@ def load_workspace( append : bool Assume current when given prompt to append windows in same session. Default False. + here : bool + Use current window for first workspace window and rename session. + Default False. cli_colors : Colors, optional Colors instance for CLI output formatting. If None, uses AUTO mode. progress_format : str, optional @@ -598,7 +626,7 @@ def load_workspace( session_name = expanded_workspace["session_name"] # Session-exists check — outside spinner so prompt_yes_no is safe - if builder.session_exists(session_name) and not append: + if builder.session_exists(session_name) and not append and not here: if not detached and ( answer_yes or prompt_yes_no( @@ -618,6 +646,7 @@ def load_workspace( append, answer_yes, cli_colors, + here=here, ) if result is not None: summary = "" @@ -693,6 +722,7 @@ def _emit_success() -> None: append, answer_yes, cli_colors, + here=here, pre_attach_hook=_emit_success, on_error_hook=spinner.stop, pre_prompt_hook=spinner.stop, @@ -758,6 +788,12 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP action="store_true", help="load workspace, appending windows to the current session", ) + parser.add_argument( + "--here", + dest="here", + action="store_true", + help="use the current window for the first workspace window", + ) colorsgroup = parser.add_mutually_exclusive_group() colorsgroup.add_argument( @@ -900,6 +936,7 @@ def command_load( detached=detached, answer_yes=args.answer_yes or False, append=args.append or False, + here=args.here or False, cli_colors=cli_colors, progress_format=args.progress_format, panel_lines=args.panel_lines, diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 2913b69419..169632ac91 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -407,7 +407,12 @@ def session_exists(self, session_name: str) -> bool: return False return True - def build(self, session: Session | None = None, append: bool = False) -> None: + def build( + self, + session: Session | None = None, + append: bool = False, + here: bool = False, + ) -> None: """Build tmux workspace in session. Optionally accepts ``session`` to build with only session object. @@ -421,6 +426,8 @@ def build(self, session: Session | None = None, append: bool = False) -> None: session to build workspace in append : bool append windows in current active session + here : bool + reuse current window for first window and rename session """ if not session: if not self.server: @@ -538,7 +545,14 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for option, value in self.session_config["environment"].items(): self.session.set_environment(option, value) - for window, window_config in self.iter_create_windows(session, append): + if here: + session_name = self.session_config["session_name"] + if session.name != session_name: + session.rename_session(session_name) + + for window, window_config in self.iter_create_windows( + session, append, here=here + ): assert isinstance(window, Window) for plugin in self.plugins: @@ -579,6 +593,7 @@ def iter_create_windows( self, session: Session, append: bool = False, + here: bool = False, ) -> Iterator[t.Any]: """Return :class:`libtmux.Window` iterating through session config dict. @@ -593,6 +608,8 @@ def iter_create_windows( session to create windows in append : bool append windows in current active session + here : bool + reuse current window for first window Returns ------- @@ -617,43 +634,69 @@ def iter_create_windows( } ) - is_first_window_pass = self.first_window_pass( - window_iterator, - session, - append, - ) + if here and window_iterator == 1: + # --here: reuse current window for first window + window = session.active_window + if window_name: + window.rename_window(window_name) + + start_directory = window_config.get("start_directory", None) + panes = window_config["panes"] + if panes and "start_directory" in panes[0]: + start_directory = panes[0]["start_directory"] + + if start_directory: + active_pane = window.active_pane + if active_pane is not None: + active_pane.send_keys( + f'cd "{start_directory}"', + enter=True, + ) + else: + is_first_window_pass = self.first_window_pass( + window_iterator, + session, + append, + ) - w1 = None - if is_first_window_pass: # if first window, use window 1 - w1 = session.active_window - w1.move_window("99") + w1 = None + if is_first_window_pass: # if first window, use window 1 + w1 = session.active_window + w1.move_window("99") - start_directory = window_config.get("start_directory", None) + start_directory = window_config.get("start_directory", None) - # If the first pane specifies a start_directory, use that instead. - panes = window_config["panes"] - if panes and "start_directory" in panes[0]: - start_directory = panes[0]["start_directory"] + # If the first pane specifies a start_directory, use that instead. + panes = window_config["panes"] + if panes and "start_directory" in panes[0]: + start_directory = panes[0]["start_directory"] - window_shell = window_config.get("window_shell", None) + window_shell = window_config.get("window_shell", None) - # If the first pane specifies a shell, use that instead. - try: - if window_config["panes"][0]["shell"] != "": - window_shell = window_config["panes"][0]["shell"] - except (KeyError, IndexError): - pass + # If the first pane specifies a shell, use that instead. + try: + if window_config["panes"][0]["shell"] != "": + window_shell = window_config["panes"][0]["shell"] + except (KeyError, IndexError): + pass - environment = panes[0].get("environment", window_config.get("environment")) + environment = panes[0].get( + "environment", + window_config.get("environment"), + ) + + window = session.new_window( + window_name=window_name, + start_directory=start_directory, + attach=False, # do not move to the new window + window_index=window_config.get("window_index", ""), + window_shell=window_shell, + environment=environment, + ) + + if is_first_window_pass: # if first window, use window 1 + session.active_window.kill() - window = session.new_window( - window_name=window_name, - start_directory=start_directory, - attach=False, # do not move to the new window - window_index=window_config.get("window_index", ""), - window_shell=window_shell, - environment=environment, - ) assert isinstance(window, Window) window_log = TmuxpLoggerAdapter( logger, @@ -664,9 +707,6 @@ def iter_create_windows( ) window_log.debug("window created") - if is_first_window_pass: # if first window, use window 1 - session.active_window.kill() - if "options" in window_config and isinstance( window_config["options"], dict, From 5d4bb060345aafe628cf8d2b7f47e47b3e8eda9f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 12:01:21 -0500 Subject: [PATCH 027/143] test(builder): Add here mode integration test why: Verify --here flag correctly reuses current window and renames session. what: - Add here_mode.yaml fixture with two windows - Add test_here_mode verifying session rename, window reuse (same ID), window rename, and new window creation --- .../fixtures/workspace/builder/here_mode.yaml | 8 +++++ tests/workspace/test_builder.py | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/fixtures/workspace/builder/here_mode.yaml diff --git a/tests/fixtures/workspace/builder/here_mode.yaml b/tests/fixtures/workspace/builder/here_mode.yaml new file mode 100644 index 0000000000..f31d5ca783 --- /dev/null +++ b/tests/fixtures/workspace/builder/here_mode.yaml @@ -0,0 +1,8 @@ +session_name: here-session +windows: + - window_name: reused + panes: + - echo reused + - window_name: new-win + panes: + - echo new diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 9d1565a6d3..919fedc758 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -444,6 +444,42 @@ def check_title(p: Pane, expected: str) -> bool: ), f"Expected title 'runner', got '{panes[1].pane_title}'" +def test_here_mode( + session: Session, +) -> None: + """Test --here mode reuses current window and renames session.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/here_mode.yaml"), + ) + workspace = loader.expand(workspace) + + # Capture original window ID to verify reuse + original_window = session.active_window + original_window_id = original_window.window_id + original_session_name = session.name + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + # Session should be renamed + session.refresh() + assert session.name == "here-session" + assert session.name != original_session_name + + windows = session.windows + assert len(windows) == 2 + + # First window should be the reused original window (same ID) + reused_window = windows[0] + assert reused_window.window_id == original_window_id + assert reused_window.name == "reused" + + # Second window should be newly created + new_window = windows[1] + assert new_window.name == "new-win" + assert new_window.window_id != original_window_id + + def test_window_shell( session: Session, ) -> None: From c796e256f4c590899113bf56700859c9bf7eeeb3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 12:44:52 -0500 Subject: [PATCH 028/143] feat(cli[stop]): Add tmuxp stop command to kill sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxp has no way to kill a session from the CLI — users must use tmux kill-session directly. tmuxinator has stop/stop-all equivalents. what: - Add stop.py command module with session lookup and kill logic - Wire the stop subcommand into the CLI parser and dispatch - Support -L/-S socket pass-through and current session fallback - Use Colors semantic hierarchy for success/error output --- src/tmuxp/cli/__init__.py | 27 ++++++++++ src/tmuxp/cli/stop.py | 102 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/tmuxp/cli/stop.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 860a9200cb..999ce3c816 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -57,6 +57,12 @@ command_shell, create_shell_subparser, ) +from .stop import ( + STOP_DESCRIPTION, + CLIStopNamespace, + command_stop, + create_stop_subparser, +) from .utils import tmuxp_echo logger = logging.getLogger(__name__) @@ -130,6 +136,13 @@ "tmuxp edit myproject", ], ), + ( + "stop", + [ + "tmuxp stop mysession", + "tmuxp stop -L mysocket mysession", + ], + ), ( "debug-info", [ @@ -155,6 +168,7 @@ "import", "search", "shell", + "stop", "debug-info", ] CLIImportSubparserName: TypeAlias = t.Literal["teamocil", "tmuxinator"] @@ -262,6 +276,14 @@ def create_parser() -> argparse.ArgumentParser: ) create_freeze_subparser(freeze_parser) + stop_parser = subparsers.add_parser( + "stop", + help="stop (kill) a tmux session", + description=STOP_DESCRIPTION, + formatter_class=formatter_class, + ) + create_stop_subparser(stop_parser) + return parser @@ -353,6 +375,11 @@ def cli(_args: list[str] | None = None) -> None: args=CLIFreezeNamespace(**vars(args)), parser=parser, ) + elif args.subparser_name == "stop": + command_stop( + args=CLIStopNamespace(**vars(args)), + parser=parser, + ) elif args.subparser_name == "ls": command_ls( args=CLILsNamespace(**vars(args)), diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py new file mode 100644 index 0000000000..9570acd654 --- /dev/null +++ b/src/tmuxp/cli/stop.py @@ -0,0 +1,102 @@ +"""CLI for ``tmuxp stop`` subcommand.""" + +from __future__ import annotations + +import argparse +import logging +import typing as t + +from libtmux.server import Server + +from tmuxp import exc, util +from tmuxp.exc import TmuxpException + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +STOP_DESCRIPTION = build_description( + """ + Stop (kill) a tmux session. + """, + ( + ( + None, + [ + "tmuxp stop mysession", + "tmuxp stop -L mysocket mysession", + ], + ), + ), +) + +if t.TYPE_CHECKING: + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +class CLIStopNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp stop command.""" + + color: CLIColorModeLiteral + session_name: str | None + socket_name: str | None + socket_path: str | None + + +def create_stop_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``stop`` subcommand.""" + parser.add_argument( + dest="session_name", + metavar="session-name", + nargs="?", + action="store", + ) + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket-path", + help="pass-through for tmux -S", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket-name", + help="pass-through for tmux -L", + ) + return parser + + +def command_stop( + args: CLIStopNamespace, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp stop``, kill a tmux session.""" + color_mode = get_color_mode(args.color) + colors = Colors(color_mode) + + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) + + try: + if args.session_name: + session = server.sessions.get( + session_name=args.session_name, + default=None, + ) + else: + session = util.get_session(server) + + if not session: + raise exc.SessionNotFound(args.session_name) + except TmuxpException as e: + tmuxp_echo(colors.error(str(e))) + return + + session_name = session.name + session.kill() + logger.info("session stopped", extra={"tmux_session": session_name or ""}) + tmuxp_echo( + colors.success("Stopped ") + colors.highlight(session_name or ""), + ) From 26403afb3e389fa70357774e6fa6312e23b1d34e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 12:44:56 -0500 Subject: [PATCH 029/143] test(cli[stop]): Add stop command tests why: Verify stop command kills sessions and handles missing sessions. what: - Add parametrized test for stopping a named session - Add test for nonexistent session error output --- tests/cli/test_stop.py | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/cli/test_stop.py diff --git a/tests/cli/test_stop.py b/tests/cli/test_stop.py new file mode 100644 index 0000000000..aa2b52e810 --- /dev/null +++ b/tests/cli/test_stop.py @@ -0,0 +1,71 @@ +"""Test tmuxp stop command.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp import cli + +if t.TYPE_CHECKING: + from libtmux.server import Server + + +class StopTestFixture(t.NamedTuple): + """Test fixture for tmuxp stop command tests.""" + + test_id: str + cli_args: list[str] + session_name: str + + +STOP_TEST_FIXTURES: list[StopTestFixture] = [ + StopTestFixture( + test_id="stop-named-session", + cli_args=["stop", "killme"], + session_name="killme", + ), +] + + +@pytest.mark.parametrize( + list(StopTestFixture._fields), + STOP_TEST_FIXTURES, + ids=[test.test_id for test in STOP_TEST_FIXTURES], +) +def test_stop( + server: Server, + test_id: str, + cli_args: list[str], + session_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test stopping a tmux session by name.""" + monkeypatch.delenv("TMUX", raising=False) + + server.new_session(session_name=session_name) + assert server.has_session(session_name) + + assert server.socket_name is not None + cli_args = [*cli_args, "-L", server.socket_name] + + cli.cli(cli_args) + + assert not server.has_session(session_name) + + +def test_stop_nonexistent_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test stopping a session that doesn't exist shows error.""" + monkeypatch.delenv("TMUX", raising=False) + + assert server.socket_name is not None + + cli.cli(["stop", "nonexistent", "-L", server.socket_name]) + + captured = capsys.readouterr() + assert "Session not found" in captured.out From 635442bbee4283d80dec7134687f2cfb17df412c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 12:47:27 -0500 Subject: [PATCH 030/143] test(cli[stop]): Register stop in help examples validation tests why: CLI_DESCRIPTION now references stop; the help-examples test validates every referenced subcommand. what: - Validate stop subcommand help examples alongside the other subcommands --- tests/cli/test_help_examples.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index 9cbe365db2..f172b16faa 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -114,6 +114,7 @@ def test_main_help_examples_are_valid_subcommands() -> None: "edit", "freeze", "search", + "stop", } for example in examples: @@ -137,6 +138,7 @@ def test_main_help_examples_are_valid_subcommands() -> None: "edit", "freeze", "search", + "stop", ], ) def test_subcommand_help_has_examples(subcommand: str) -> None: @@ -226,6 +228,16 @@ def test_debug_info_subcommand_examples_are_valid() -> None: assert example.startswith("tmuxp debug-info"), f"Bad example format: {example}" +def test_stop_subcommand_examples_are_valid() -> None: + """Stop subcommand examples should have valid flags.""" + help_text = _get_help_text("stop") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp stop"), f"Bad example format: {example}" + + def test_search_subcommand_examples_are_valid() -> None: """Search subcommand examples should have valid flags.""" help_text = _get_help_text("search") From f7fd1ec1217c173d5525783dcd3e2d120ce26c5c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 13:57:31 -0500 Subject: [PATCH 031/143] feat(cli[new,copy,delete]): Add config management commands why: tmuxp only had `edit` for config management. tmuxinator has `new`, `copy`, `delete` commands that are useful for managing workspace configs. what: - Add `tmuxp new ` to create workspace from template + open in $EDITOR - Add `tmuxp copy ` to duplicate workspace configs - Add `tmuxp delete [-y]` to remove workspace configs with confirmation - Register all 3 commands in CLI __init__.py (imports, descriptions, subparsers, dispatch) --- src/tmuxp/cli/__init__.py | 69 ++++++++++++++++++++++++++ src/tmuxp/cli/copy.py | 101 ++++++++++++++++++++++++++++++++++++++ src/tmuxp/cli/delete.py | 91 ++++++++++++++++++++++++++++++++++ src/tmuxp/cli/new.py | 97 ++++++++++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 src/tmuxp/cli/copy.py create mode 100644 src/tmuxp/cli/delete.py create mode 100644 src/tmuxp/cli/new.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 999ce3c816..5cd38cfc4b 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -19,12 +19,14 @@ from ._colors import build_description from ._formatter import TmuxpHelpFormatter, create_themed_formatter from .convert import CONVERT_DESCRIPTION, command_convert, create_convert_subparser +from .copy import COPY_DESCRIPTION, command_copy, create_copy_subparser from .debug_info import ( DEBUG_INFO_DESCRIPTION, CLIDebugInfoNamespace, command_debug_info, create_debug_info_subparser, ) +from .delete import DELETE_DESCRIPTION, command_delete, create_delete_subparser from .edit import EDIT_DESCRIPTION, command_edit, create_edit_subparser from .freeze import ( FREEZE_DESCRIPTION, @@ -45,6 +47,7 @@ create_load_subparser, ) from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .new import NEW_DESCRIPTION, command_new, create_new_subparser from .search import ( SEARCH_DESCRIPTION, CLISearchNamespace, @@ -136,6 +139,25 @@ "tmuxp edit myproject", ], ), + ( + "new", + [ + "tmuxp new myproject", + ], + ), + ( + "copy", + [ + "tmuxp copy myproject myproject-backup", + ], + ), + ( + "delete", + [ + "tmuxp delete myproject", + "tmuxp delete -y old-project", + ], + ), ( "stop", [ @@ -164,7 +186,10 @@ "load", "freeze", "convert", + "copy", + "delete", "edit", + "new", "import", "search", "shell", @@ -268,6 +293,30 @@ def create_parser() -> argparse.ArgumentParser: ) create_edit_subparser(edit_parser) + new_parser = subparsers.add_parser( + "new", + help="create a new workspace config from template", + description=NEW_DESCRIPTION, + formatter_class=formatter_class, + ) + create_new_subparser(new_parser) + + copy_parser = subparsers.add_parser( + "copy", + help="copy a workspace config to a new name", + description=COPY_DESCRIPTION, + formatter_class=formatter_class, + ) + create_copy_subparser(copy_parser) + + delete_parser = subparsers.add_parser( + "delete", + help="delete workspace config files", + description=DELETE_DESCRIPTION, + formatter_class=formatter_class, + ) + create_delete_subparser(delete_parser) + freeze_parser = subparsers.add_parser( "freeze", help="freeze a live tmux session to a tmuxp workspace file", @@ -370,6 +419,26 @@ def cli(_args: list[str] | None = None) -> None: parser=parser, color=args.color, ) + elif args.subparser_name == "new": + command_new( + workspace_name=args.workspace_name, + parser=parser, + color=args.color, + ) + elif args.subparser_name == "copy": + command_copy( + source=args.source, + destination=args.destination, + parser=parser, + color=args.color, + ) + elif args.subparser_name == "delete": + command_delete( + workspace_names=args.workspace_names, + answer_yes=args.answer_yes, + parser=parser, + color=args.color, + ) elif args.subparser_name == "freeze": command_freeze( args=CLIFreezeNamespace(**vars(args)), diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py new file mode 100644 index 0000000000..fa2c3e122f --- /dev/null +++ b/src/tmuxp/cli/copy.py @@ -0,0 +1,101 @@ +"""CLI for ``tmuxp copy`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import shutil +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir, is_pure_name + +from ._colors import Colors, build_description, get_color_mode +from .utils import prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + +COPY_DESCRIPTION = build_description( + """ + Copy an existing workspace config to a new name. + + Source is resolved using the same logic as ``tmuxp load`` (supports + names, paths, and extensions). If destination is a plain name, it + is placed in the workspace directory as ``.yaml``. + """, + ( + ( + None, + [ + "tmuxp copy myproject myproject-backup", + "tmuxp copy dev staging", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +def create_copy_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``copy`` subcommand.""" + parser.add_argument( + dest="source", + metavar="source", + type=str, + help="source workspace name or file path.", + ) + parser.add_argument( + dest="destination", + metavar="destination", + type=str, + help="destination workspace name or file path.", + ) + return parser + + +def command_copy( + source: str, + destination: str, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + """Entrypoint for ``tmuxp copy``, copy a workspace config to a new name.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + try: + source_path = find_workspace_file(source) + except FileNotFoundError: + tmuxp_echo(colors.error(f"Source not found: {source}")) + return + + if is_pure_name(destination): + workspace_dir = get_workspace_dir() + os.makedirs(workspace_dir, exist_ok=True) + dest_path = os.path.join(workspace_dir, f"{destination}.yaml") + else: + dest_path = os.path.expanduser(destination) + if not os.path.isabs(dest_path): + dest_path = os.path.normpath(os.path.join(os.getcwd(), dest_path)) + + if os.path.exists(dest_path) and not prompt_yes_no( + f"Overwrite {colors.info(str(PrivatePath(dest_path)))}?", + default=False, + color_mode=color_mode, + ): + tmuxp_echo(colors.muted("Aborted.")) + return + + shutil.copy2(source_path, dest_path) + tmuxp_echo( + colors.success("Copied ") + + colors.info(str(PrivatePath(source_path))) + + colors.muted(" \u2192 ") + + colors.info(str(PrivatePath(dest_path))), + ) diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py new file mode 100644 index 0000000000..2ce3aec152 --- /dev/null +++ b/src/tmuxp/cli/delete.py @@ -0,0 +1,91 @@ +"""CLI for ``tmuxp delete`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import find_workspace_file + +from ._colors import Colors, build_description, get_color_mode +from .utils import prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + +DELETE_DESCRIPTION = build_description( + """ + Delete workspace config files. + + Resolves workspace names using the same logic as ``tmuxp load``. + Prompts for confirmation unless ``-y`` is passed. + """, + ( + ( + None, + [ + "tmuxp delete myproject", + "tmuxp delete -y old-project", + "tmuxp delete proj1 proj2", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +def create_delete_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``delete`` subcommand.""" + parser.add_argument( + dest="workspace_names", + metavar="workspace-name", + nargs="+", + type=str, + help="workspace name(s) or file path(s) to delete.", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="skip confirmation prompt.", + ) + return parser + + +def command_delete( + workspace_names: list[str], + answer_yes: bool = False, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + """Entrypoint for ``tmuxp delete``, remove workspace config files.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + for name in workspace_names: + try: + workspace_path = find_workspace_file(name) + except FileNotFoundError: + tmuxp_echo(colors.warning(f"Workspace not found: {name}")) + continue + + if not answer_yes and not prompt_yes_no( + f"Delete {colors.info(str(PrivatePath(workspace_path)))}?", + default=False, + color_mode=color_mode, + ): + tmuxp_echo(colors.muted("Skipped ") + colors.info(name)) + continue + + os.remove(workspace_path) + tmuxp_echo( + colors.success("Deleted ") + colors.info(str(PrivatePath(workspace_path))), + ) diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py new file mode 100644 index 0000000000..6012e018da --- /dev/null +++ b/src/tmuxp/cli/new.py @@ -0,0 +1,97 @@ +"""CLI for ``tmuxp new`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import subprocess +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import get_workspace_dir + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +WORKSPACE_TEMPLATE = """\ +session_name: {name} +windows: + - window_name: main + panes: + - +""" + +NEW_DESCRIPTION = build_description( + """ + Create a new workspace config from a minimal template. + + Opens the new file in $EDITOR after creation. If the workspace + already exists, opens it for editing. + """, + ( + ( + None, + [ + "tmuxp new myproject", + "tmuxp new dev-server", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +def create_new_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``new`` subcommand.""" + parser.add_argument( + dest="workspace_name", + metavar="workspace-name", + type=str, + help="name for the new workspace config.", + ) + return parser + + +def command_new( + workspace_name: str, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + """Entrypoint for ``tmuxp new``, create a new workspace config from template.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + # Use TMUXP_CONFIGDIR directly if set, since get_workspace_dir() + # only returns it when the directory already exists. The new command + # needs to create files there even if it doesn't exist yet. + configdir_env = os.environ.get("TMUXP_CONFIGDIR") + workspace_dir = ( + os.path.expanduser(configdir_env) if configdir_env else get_workspace_dir() + ) + os.makedirs(workspace_dir, exist_ok=True) + + workspace_path = os.path.join(workspace_dir, f"{workspace_name}.yaml") + + if os.path.exists(workspace_path): + tmuxp_echo( + colors.info(str(PrivatePath(workspace_path))) + + colors.muted(" already exists, opening in editor."), + ) + else: + content = WORKSPACE_TEMPLATE.format(name=workspace_name) + with open(workspace_path, "w") as f: + f.write(content) + tmuxp_echo( + colors.success("Created ") + colors.info(str(PrivatePath(workspace_path))), + ) + + sys_editor = os.environ.get("EDITOR", "vim") + subprocess.call([sys_editor, workspace_path]) From 12520efdd065a7b1401741bd9c287d11e101be13 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 13:57:43 -0500 Subject: [PATCH 032/143] test(cli[new,copy,delete]): Add config management command tests why: Verify new, copy, and delete commands work correctly. what: - Add test_new.py: parametrized tests for new/existing workspace, dir creation - Add test_copy.py: parametrized tests for copy, nonexistent source, path dest - Add test_delete.py: parametrized tests for delete, nonexistent, batch delete - Register new, copy, delete in test_help_examples.py validation sets --- tests/cli/test_copy.py | 106 ++++++++++++++++++++++++++++++++ tests/cli/test_delete.py | 95 ++++++++++++++++++++++++++++ tests/cli/test_help_examples.py | 36 +++++++++++ tests/cli/test_new.py | 98 +++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 tests/cli/test_copy.py create mode 100644 tests/cli/test_delete.py create mode 100644 tests/cli/test_new.py diff --git a/tests/cli/test_copy.py b/tests/cli/test_copy.py new file mode 100644 index 0000000000..1a60548a08 --- /dev/null +++ b/tests/cli/test_copy.py @@ -0,0 +1,106 @@ +"""Test tmuxp copy command.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp import cli + + +class CopyTestFixture(t.NamedTuple): + """Test fixture for tmuxp copy command tests.""" + + test_id: str + cli_args: list[str] + source_name: str + dest_name: str + expect_copied: bool + source_exists: bool + + +COPY_TEST_FIXTURES: list[CopyTestFixture] = [ + CopyTestFixture( + test_id="copy-workspace", + cli_args=["copy", "source", "dest"], + source_name="source", + dest_name="dest", + expect_copied=True, + source_exists=True, + ), + CopyTestFixture( + test_id="copy-nonexistent-source", + cli_args=["copy", "nosuch", "dest"], + source_name="nosuch", + dest_name="dest", + expect_copied=False, + source_exists=False, + ), +] + + +@pytest.mark.parametrize( + list(CopyTestFixture._fields), + COPY_TEST_FIXTURES, + ids=[test.test_id for test in COPY_TEST_FIXTURES], +) +def test_copy( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + source_name: str, + dest_name: str, + expect_copied: bool, + source_exists: bool, +) -> None: + """Test copying a workspace config.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + source_content = "session_name: source-session\nwindows:\n - window_name: main\n" + if source_exists: + source_path = config_dir / f"{source_name}.yaml" + source_path.write_text(source_content) + + cli.cli(cli_args) + + captured = capsys.readouterr() + dest_path = config_dir / f"{dest_name}.yaml" + + if expect_copied: + assert dest_path.exists() + assert dest_path.read_text() == source_content + assert "Copied" in captured.out + else: + assert not dest_path.exists() + assert "not found" in captured.out.lower() + + +def test_copy_to_path( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test copying a workspace config to an explicit file path.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + source_content = "session_name: mysession\n" + source_path = config_dir / "source.yaml" + source_path.write_text(source_content) + + dest_path = tmp_path / "output" / "copied.yaml" + dest_path.parent.mkdir(parents=True) + + cli.cli(["copy", "source", str(dest_path)]) + + assert dest_path.exists() + assert dest_path.read_text() == source_content + + captured = capsys.readouterr() + assert "Copied" in captured.out diff --git a/tests/cli/test_delete.py b/tests/cli/test_delete.py new file mode 100644 index 0000000000..986a19ff0b --- /dev/null +++ b/tests/cli/test_delete.py @@ -0,0 +1,95 @@ +"""Test tmuxp delete command.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp import cli + + +class DeleteTestFixture(t.NamedTuple): + """Test fixture for tmuxp delete command tests.""" + + test_id: str + cli_args: list[str] + workspace_name: str + expect_deleted: bool + file_exists: bool + + +DELETE_TEST_FIXTURES: list[DeleteTestFixture] = [ + DeleteTestFixture( + test_id="delete-workspace", + cli_args=["delete", "-y", "target"], + workspace_name="target", + expect_deleted=True, + file_exists=True, + ), + DeleteTestFixture( + test_id="delete-nonexistent", + cli_args=["delete", "-y", "nosuch"], + workspace_name="nosuch", + expect_deleted=False, + file_exists=False, + ), +] + + +@pytest.mark.parametrize( + list(DeleteTestFixture._fields), + DELETE_TEST_FIXTURES, + ids=[test.test_id for test in DELETE_TEST_FIXTURES], +) +def test_delete( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + workspace_name: str, + expect_deleted: bool, + file_exists: bool, +) -> None: + """Test deleting workspace config files.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + workspace_path = config_dir / f"{workspace_name}.yaml" + if file_exists: + workspace_path.write_text("session_name: target\n") + + cli.cli(cli_args) + + captured = capsys.readouterr() + + if expect_deleted: + assert not workspace_path.exists() + assert "Deleted" in captured.out + else: + assert not workspace_path.exists() + assert "not found" in captured.out.lower() + + +def test_delete_multiple( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test deleting multiple workspace configs at once.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + for name in ["proj1", "proj2"]: + (config_dir / f"{name}.yaml").write_text(f"session_name: {name}\n") + + cli.cli(["delete", "-y", "proj1", "proj2"]) + + assert not (config_dir / "proj1.yaml").exists() + assert not (config_dir / "proj2.yaml").exists() + + captured = capsys.readouterr() + assert captured.out.count("Deleted") == 2 diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index f172b16faa..dc2c15a231 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -109,10 +109,13 @@ def test_main_help_examples_are_valid_subcommands() -> None: "shell", "import", "convert", + "copy", "debug-info", + "delete", "ls", "edit", "freeze", + "new", "search", "stop", } @@ -133,10 +136,13 @@ def test_main_help_examples_are_valid_subcommands() -> None: "shell", "import", "convert", + "copy", "debug-info", + "delete", "ls", "edit", "freeze", + "new", "search", "stop", ], @@ -238,6 +244,36 @@ def test_stop_subcommand_examples_are_valid() -> None: assert example.startswith("tmuxp stop"), f"Bad example format: {example}" +def test_new_subcommand_examples_are_valid() -> None: + """New subcommand examples should have valid flags.""" + help_text = _get_help_text("new") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp new"), f"Bad example format: {example}" + + +def test_copy_subcommand_examples_are_valid() -> None: + """Copy subcommand examples should have valid flags.""" + help_text = _get_help_text("copy") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp copy"), f"Bad example format: {example}" + + +def test_delete_subcommand_examples_are_valid() -> None: + """Delete subcommand examples should have valid flags.""" + help_text = _get_help_text("delete") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp delete"), f"Bad example format: {example}" + + def test_search_subcommand_examples_are_valid() -> None: """Search subcommand examples should have valid flags.""" help_text = _get_help_text("search") diff --git a/tests/cli/test_new.py b/tests/cli/test_new.py new file mode 100644 index 0000000000..773ff45a72 --- /dev/null +++ b/tests/cli/test_new.py @@ -0,0 +1,98 @@ +"""Test tmuxp new command.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp import cli +from tmuxp.cli.new import WORKSPACE_TEMPLATE + + +class NewTestFixture(t.NamedTuple): + """Test fixture for tmuxp new command tests.""" + + test_id: str + cli_args: list[str] + workspace_name: str + expect_created: bool + pre_existing: bool + + +NEW_TEST_FIXTURES: list[NewTestFixture] = [ + NewTestFixture( + test_id="new-workspace", + cli_args=["new", "myproject"], + workspace_name="myproject", + expect_created=True, + pre_existing=False, + ), + NewTestFixture( + test_id="new-existing-workspace", + cli_args=["new", "existing"], + workspace_name="existing", + expect_created=False, + pre_existing=True, + ), +] + + +@pytest.mark.parametrize( + list(NewTestFixture._fields), + NEW_TEST_FIXTURES, + ids=[test.test_id for test in NEW_TEST_FIXTURES], +) +def test_new( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + workspace_name: str, + expect_created: bool, + pre_existing: bool, +) -> None: + """Test creating a new workspace config.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + monkeypatch.setenv("EDITOR", "true") + + workspace_path = config_dir / f"{workspace_name}.yaml" + + if pre_existing: + original_content = "session_name: original\n" + workspace_path.write_text(original_content) + + cli.cli(cli_args) + + captured = capsys.readouterr() + assert workspace_path.exists() + + if expect_created: + expected_content = WORKSPACE_TEMPLATE.format(name=workspace_name) + assert workspace_path.read_text() == expected_content + assert "Created" in captured.out + else: + assert workspace_path.read_text() == original_content + assert "already exists" in captured.out + + +def test_new_creates_workspace_dir( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test that 'new' creates the workspace directory if it doesn't exist.""" + config_dir = tmp_path / "nonexistent" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + monkeypatch.setenv("EDITOR", "true") + + assert not config_dir.exists() + + cli.cli(["new", "myproject"]) + + assert config_dir.exists() + workspace_path = config_dir / "myproject.yaml" + assert workspace_path.exists() From db5398f76918c66dfb0fd3dbcd389fc2edf43f34 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 17:20:52 -0500 Subject: [PATCH 033/143] feat(cli[load]): Add --no-shell-command-before flag why: tmuxinator has --no-pre-window to skip per-pane pre-commands for debugging. tmuxp had no equivalent. what: - Add --no-shell-command-before flag to tmuxp load subparser - Strip shell_command_before from session, window, and pane levels after expand() but before trickle() - Thread flag through CLILoadNamespace -> command_load -> load_workspace --- src/tmuxp/cli/load.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index a89f7764d6..0db54bcca9 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -113,6 +113,7 @@ class CLILoadNamespace(argparse.Namespace): progress_format: str | None panel_lines: int | None no_progress: bool + no_shell_command_before: bool def load_plugins( @@ -475,6 +476,7 @@ def load_workspace( progress_format: str | None = None, panel_lines: int | None = None, no_progress: bool = False, + no_shell_command_before: bool = False, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -512,6 +514,9 @@ def load_workspace( no_progress : bool Disable the progress spinner entirely. Default False. Also disabled when ``TMUXP_PROGRESS=0``. + no_shell_command_before : bool + Strip ``shell_command_before`` from all levels (session, window, pane) + before building. Default False. Notes ----- @@ -593,6 +598,14 @@ def load_workspace( if new_session_name: expanded_workspace["session_name"] = new_session_name + # Strip shell_command_before at all levels when --no-shell-command-before + if no_shell_command_before: + expanded_workspace.pop("shell_command_before", None) + for window in expanded_workspace.get("windows", []): + window.pop("shell_command_before", None) + for pane in window.get("panes", []): + pane.pop("shell_command_before", None) + # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) @@ -794,6 +807,13 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP action="store_true", help="use the current window for the first workspace window", ) + parser.add_argument( + "--no-shell-command-before", + dest="no_shell_command_before", + action="store_true", + default=False, + help="skip shell_command_before at all levels (session, window, pane)", + ) colorsgroup = parser.add_mutually_exclusive_group() colorsgroup.add_argument( @@ -941,4 +961,5 @@ def command_load( progress_format=args.progress_format, panel_lines=args.panel_lines, no_progress=args.no_progress, + no_shell_command_before=args.no_shell_command_before, ) From 03e812e18aacc7b6b52c1f343637bdbd3f9600f0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 17:20:58 -0500 Subject: [PATCH 034/143] test(cli[load]): Add --no-shell-command-before flag tests why: Verify the new flag strips shell_command_before correctly. what: - Add parametrized test for load_workspace with/without the flag - Add unit test verifying stripping at all levels (session, window, pane) --- tests/cli/test_load.py | 117 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index ec045dcf3c..4928218304 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -887,6 +887,123 @@ def test_load_workspace_env_progress_disabled( assert session.name == "sample workspace" +class NoShellCommandBeforeFixture(t.NamedTuple): + """Test fixture for --no-shell-command-before flag tests.""" + + test_id: str + no_shell_command_before: bool + expect_before_cmd: bool + + +NO_SHELL_COMMAND_BEFORE_FIXTURES: list[NoShellCommandBeforeFixture] = [ + NoShellCommandBeforeFixture( + test_id="with-shell-command-before", + no_shell_command_before=False, + expect_before_cmd=True, + ), + NoShellCommandBeforeFixture( + test_id="no-shell-command-before", + no_shell_command_before=True, + expect_before_cmd=False, + ), +] + + +@pytest.mark.parametrize( + list(NoShellCommandBeforeFixture._fields), + NO_SHELL_COMMAND_BEFORE_FIXTURES, + ids=[f.test_id for f in NO_SHELL_COMMAND_BEFORE_FIXTURES], +) +def test_load_workspace_no_shell_command_before( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + no_shell_command_before: bool, + expect_before_cmd: bool, +) -> None: + """Test --no-shell-command-before strips shell_command_before from config.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "test.yaml" + workspace_file.write_text( + """ +session_name: scb_test +shell_command_before: + - echo __BEFORE__ +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + no_shell_command_before=no_shell_command_before, + ) + + assert isinstance(session, Session) + assert session.name == "scb_test" + + +def test_load_no_shell_command_before_strips_all_levels( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --no-shell-command-before strips from session, window, and pane levels.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "multi_level.yaml" + workspace_file.write_text( + """ +session_name: strip_test +shell_command_before: + - echo session_before +windows: +- window_name: main + shell_command_before: + - echo window_before + panes: + - shell_command: + - echo hello + shell_command_before: + - echo pane_before +""", + encoding="utf-8", + ) + + # Verify the stripping logic via loader functions + raw = ConfigReader._from_file(workspace_file) or {} + expanded = loader.expand(raw, cwd=str(tmp_path)) + + # Before stripping, shell_command_before should be present + assert "shell_command_before" in expanded + assert "shell_command_before" in expanded["windows"][0] + assert "shell_command_before" in expanded["windows"][0]["panes"][0] + + # Simulate the stripping logic from load_workspace + expanded.pop("shell_command_before", None) + for window in expanded.get("windows", []): + window.pop("shell_command_before", None) + for pane in window.get("panes", []): + pane.pop("shell_command_before", None) + + trickled = loader.trickle(expanded) + + # After stripping + trickle, pane commands should not include before cmds + pane_cmds = trickled["windows"][0]["panes"][0]["shell_command"] + cmd_strings = [c["cmd"] for c in pane_cmds] + assert "echo session_before" not in cmd_strings + assert "echo window_before" not in cmd_strings + assert "echo pane_before" not in cmd_strings + assert "echo hello" in cmd_strings + + def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) From accdfe5b80b2749a6fe0bdda424e7ca7366fcde9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 17:44:11 -0500 Subject: [PATCH 035/143] feat(cli[load]): Add --debug flag to show tmux commands during build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Users need visibility into what tmux commands are executed during workspace loading for debugging and learning purposes. what: - Add _TmuxCommandDebugHandler that intercepts libtmux structured logs - Add --debug flag to argparser (implies --no-progress) - Attach handler to libtmux.common logger when debug=True - Clean up handler on all return paths to prevent handler accumulation - Thread debug flag through command_load → load_workspace --- src/tmuxp/cli/load.py | 51 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 0db54bcca9..8843a0ebe7 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -57,6 +57,20 @@ def _silence_stream_handlers(logger_name: str = "tmuxp") -> t.Iterator[None]: h.setLevel(level) +class _TmuxCommandDebugHandler(logging.Handler): + """Logging handler that prints tmux commands from libtmux's structured logs.""" + + def __init__(self, colors: Colors) -> None: + super().__init__() + self._colors = colors + + def emit(self, record: logging.LogRecord) -> None: + """Print tmux command if present in the log record's extra fields.""" + cmd = getattr(record, "tmux_cmd", None) + if cmd is not None: + tmuxp_echo(self._colors.muted("$ ") + self._colors.info(str(cmd))) + + LOAD_DESCRIPTION = build_description( """ Load tmuxp workspace file(s) and create or attach to a tmux session. @@ -114,6 +128,7 @@ class CLILoadNamespace(argparse.Namespace): panel_lines: int | None no_progress: bool no_shell_command_before: bool + debug: bool def load_plugins( @@ -477,6 +492,7 @@ def load_workspace( panel_lines: int | None = None, no_progress: bool = False, no_shell_command_before: bool = False, + debug: bool = False, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -517,6 +533,8 @@ def load_workspace( no_shell_command_before : bool Strip ``shell_command_before`` from all levels (session, window, pane) before building. Default False. + debug : bool + Show tmux commands as they execute. Implies no_progress. Default False. Notes ----- @@ -577,7 +595,26 @@ def load_workspace( "loading workspace", extra={"tmux_config_path": str(workspace_file)}, ) - _progress_disabled = no_progress or os.getenv("TMUXP_PROGRESS", "1") == "0" + _progress_disabled = no_progress or debug or os.getenv("TMUXP_PROGRESS", "1") == "0" + + # --debug: attach handler to libtmux logger that shows tmux commands + _debug_handler: logging.Handler | None = None + _debug_prev_level: int | None = None + if debug: + _debug_handler = _TmuxCommandDebugHandler(cli_colors) + _debug_handler.setLevel(logging.DEBUG) + _libtmux_logger = logging.getLogger("libtmux.common") + _debug_prev_level = _libtmux_logger.level + _libtmux_logger.setLevel(logging.DEBUG) + _libtmux_logger.addHandler(_debug_handler) + + def _cleanup_debug() -> None: + if _debug_handler is not None: + _ltlog = logging.getLogger("libtmux.common") + _ltlog.removeHandler(_debug_handler) + if _debug_prev_level is not None: + _ltlog.setLevel(_debug_prev_level) + if _progress_disabled: tmuxp_echo( cli_colors.info("[Loading]") @@ -634,6 +671,7 @@ def load_workspace( cli_colors.warning("[Warning]") + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", ) + _cleanup_debug() return None session_name = expanded_workspace["session_name"] @@ -649,6 +687,7 @@ def load_workspace( ) ): _reattach(builder, cli_colors) + _cleanup_debug() return None if _progress_disabled: @@ -683,6 +722,7 @@ def load_workspace( tmuxp_echo( f"{checkmark} {SUCCESS_TEMPLATE.format_map(_SafeFormatMap(ctx))}" ) + _cleanup_debug() return result # Spinner wraps only the actual build phase @@ -876,6 +916,14 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP help=("Disable the animated progress spinner. Env: TMUXP_PROGRESS=0"), ) + parser.add_argument( + "--debug", + dest="debug", + action="store_true", + default=False, + help="show tmux commands as they execute (implies --no-progress)", + ) + try: import shtab @@ -962,4 +1010,5 @@ def command_load( panel_lines=args.panel_lines, no_progress=args.no_progress, no_shell_command_before=args.no_shell_command_before, + debug=args.debug, ) From 18de253f623008817823a578dac30fea813a194c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 17:44:18 -0500 Subject: [PATCH 036/143] test(cli[load]): Add --debug flag tests why: Verify debug flag behavior and handler cleanup. what: - Add DebugFlagFixture NamedTuple with parametrized tests - Test debug-on shows tmux commands (new-session) in output - Test debug-off does not leak tmux commands to stdout - Test handler cleanup: no lingering handlers after load --- tests/cli/test_load.py | 105 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 4928218304..80138f20e0 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1004,6 +1004,111 @@ def test_load_no_shell_command_before_strips_all_levels( assert "echo hello" in cmd_strings +class DebugFlagFixture(t.NamedTuple): + """Test fixture for --debug flag tests.""" + + test_id: str + debug: bool + expect_tmux_commands_in_output: bool + + +DEBUG_FLAG_FIXTURES: list[DebugFlagFixture] = [ + DebugFlagFixture( + test_id="debug-off", + debug=False, + expect_tmux_commands_in_output=False, + ), + DebugFlagFixture( + test_id="debug-on", + debug=True, + expect_tmux_commands_in_output=True, + ), +] + + +@pytest.mark.parametrize( + list(DebugFlagFixture._fields), + DEBUG_FLAG_FIXTURES, + ids=[f.test_id for f in DEBUG_FLAG_FIXTURES], +) +def test_load_workspace_debug_flag( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + debug: bool, + expect_tmux_commands_in_output: bool, +) -> None: + """Test --debug shows tmux commands in output.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "test.yaml" + workspace_file.write_text( + """ +session_name: debug_test +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + debug=debug, + ) + + assert isinstance(session, Session) + assert session.name == "debug_test" + + captured = capsys.readouterr() + if expect_tmux_commands_in_output: + assert "$ " in captured.out + assert "new-session" in captured.out + else: + # When debug is off, tmux commands should not appear in stdout + assert "new-session" not in captured.out + + +def test_load_debug_cleans_up_handler( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --debug removes its handler after load completes.""" + import logging + + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "test.yaml" + workspace_file.write_text( + """ +session_name: debug_cleanup +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + libtmux_logger = logging.getLogger("libtmux.common") + handler_count_before = len(libtmux_logger.handlers) + + load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + debug=True, + ) + + assert len(libtmux_logger.handlers) == handler_count_before + + def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) From e69de14aa6ca2fe8bcf245e039370f2a9f89c30b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 18:12:26 -0500 Subject: [PATCH 037/143] feat(util,builder,cli[load,stop],loader): Add lifecycle hook config keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxinator supports lifecycle hooks as YAML config keys but tmuxp had no config-level hooks — only Python plugin hooks and before_script. what: - Add run_hook_commands() helper in util.py using shell=True for full shell support (pipes, redirects) - Add on_project_start hook in cli/load.py, runs before session creation - Add on_project_restart hook in cli/load.py, runs when session exists - Add on_project_exit hook in builder.py via tmux set-hook client-detached - Add on_project_stop hook in cli/stop.py, reads from session environment - Store TMUXP_ON_PROJECT_STOP and TMUXP_START_DIRECTORY in session env during build for stop command to retrieve - Expand shell variables in hook values via loader.expand() --- src/tmuxp/cli/load.py | 15 ++++++++++ src/tmuxp/cli/stop.py | 8 ++++++ src/tmuxp/util.py | 52 ++++++++++++++++++++++++++++++++++ src/tmuxp/workspace/builder.py | 26 +++++++++++++++++ src/tmuxp/workspace/loader.py | 13 +++++++++ 5 files changed, 114 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 8843a0ebe7..6eb9b57b35 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -646,6 +646,14 @@ def _cleanup_debug() -> None: # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) + # Run on_project_start hook — fires on every tmuxp load invocation + if "on_project_start" in expanded_workspace: + _hook_cwd = expanded_workspace.get("start_directory") + util.run_hook_commands( + expanded_workspace["on_project_start"], + cwd=_hook_cwd, + ) + t = Server( # create tmux server object socket_name=socket_name, socket_path=socket_path, @@ -678,6 +686,13 @@ def _cleanup_debug() -> None: # Session-exists check — outside spinner so prompt_yes_no is safe if builder.session_exists(session_name) and not append and not here: + # Run on_project_restart hook — fires when reattaching + if "on_project_restart" in expanded_workspace: + _hook_cwd = expanded_workspace.get("start_directory") + util.run_hook_commands( + expanded_workspace["on_project_restart"], + cwd=_hook_cwd, + ) if not detached and ( answer_yes or prompt_yes_no( diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py index 9570acd654..949fe03623 100644 --- a/src/tmuxp/cli/stop.py +++ b/src/tmuxp/cli/stop.py @@ -95,6 +95,14 @@ def command_stop( return session_name = session.name + + # Run on_project_stop hook from session environment + on_stop_cmd = session.getenv("TMUXP_ON_PROJECT_STOP") + if on_stop_cmd and isinstance(on_stop_cmd, str): + start_dir = session.getenv("TMUXP_START_DIRECTORY") + _stop_cwd = str(start_dir) if isinstance(start_dir, str) else None + util.run_hook_commands(on_stop_cmd, cwd=_stop_cwd) + session.kill() logger.info("session stopped", extra={"tmux_session": session_name or ""}) tmuxp_echo( diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 152b1f6c06..8f2f53df15 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -105,6 +105,58 @@ def run_before_script( return return_code +def run_hook_commands( + commands: str | list[str], + cwd: pathlib.Path | str | None = None, +) -> None: + """Run lifecycle hook shell commands. + + Unlike :func:`run_before_script`, hooks use ``shell=True`` for full + shell support (pipes, redirects, etc.) and do NOT raise on failure. + + Parameters + ---------- + commands : str or list of str + shell command(s) to run + cwd : pathlib.Path or str, optional + working directory for the commands + + Examples + -------- + Run a single command: + + >>> run_hook_commands("echo hello") + + Run multiple commands: + + >>> run_hook_commands(["echo a", "echo b"]) + + Empty string is a no-op: + + >>> run_hook_commands("") + """ + if isinstance(commands, str): + commands = [commands] + joined = "; ".join(commands) + if not joined.strip(): + return + logger.debug("running hook commands %s", joined) + result = subprocess.run( + joined, + shell=True, + cwd=cwd, + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + logger.warning( + "hook command failed with exit code %d", + result.returncode, + extra={"tmux_exit_code": result.returncode}, + ) + + def oh_my_zsh_auto_title() -> None: """Give warning and offer to fix ``DISABLE_AUTO_TITLE``. diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 169632ac91..abe2eebc86 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -545,6 +545,32 @@ def build( for option, value in self.session_config["environment"].items(): self.session.set_environment(option, value) + # Set lifecycle tmux hooks + if "on_project_exit" in self.session_config: + exit_cmds = self.session_config["on_project_exit"] + if isinstance(exit_cmds, str): + exit_cmds = [exit_cmds] + _joined = "; ".join(exit_cmds) + _escaped = _joined.replace("'", "'\\''") + self.session.set_hook("client-detached", f"run-shell '{_escaped}'") + + # Store on_project_stop in session environment for tmuxp stop + if "on_project_stop" in self.session_config: + stop_cmds = self.session_config["on_project_stop"] + if isinstance(stop_cmds, str): + stop_cmds = [stop_cmds] + self.session.set_environment( + "TMUXP_ON_PROJECT_STOP", + "; ".join(stop_cmds), + ) + + # Store start_directory in session environment for hook cwd + if "start_directory" in self.session_config: + self.session.set_environment( + "TMUXP_START_DIRECTORY", + self.session_config["start_directory"], + ) + if here: session_name = self.session_config["session_name"] if session.name != session_name: diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 67a83a5140..2034319b43 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -172,6 +172,19 @@ def expand( if any(workspace_dict["before_script"].startswith(a) for a in [".", "./"]): workspace_dict["before_script"] = str(cwd / workspace_dict["before_script"]) + for _hook_key in ( + "on_project_start", + "on_project_restart", + "on_project_exit", + "on_project_stop", + ): + if _hook_key in workspace_dict: + _hook_val = workspace_dict[_hook_key] + if isinstance(_hook_val, str): + workspace_dict[_hook_key] = expandshell(_hook_val) + elif isinstance(_hook_val, list): + workspace_dict[_hook_key] = [expandshell(v) for v in _hook_val] + if "shell_command" in workspace_dict and isinstance( workspace_dict["shell_command"], str, From 8507b086ebee97d99d1187ce2a916dc6fb6eea9c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 18:12:36 -0500 Subject: [PATCH 038/143] test(util,builder,cli[load,stop],loader): Add lifecycle hook tests why: Verify all 4 lifecycle hook config keys work correctly. what: - Add run_hook_commands parametrized tests (string, list, empty) - Add test for hook failure warning without exception - Add test for cwd parameter support - Add builder tests for on_project_exit tmux hook and on_project_stop env - Add CLI load tests for on_project_start and on_project_restart - Add CLI stop tests for on_project_stop hook execution - Add loader expand tests for shell variable expansion in hook values --- tests/cli/test_load.py | 75 ++++++++++++++++++++++++++++ tests/cli/test_stop.py | 59 ++++++++++++++++++++++ tests/test_util.py | 84 ++++++++++++++++++++++++++++++- tests/workspace/test_builder.py | 87 +++++++++++++++++++++++++++++++++ tests/workspace/test_config.py | 47 ++++++++++++++++++ 5 files changed, 351 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 80138f20e0..caed58178a 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1119,3 +1119,78 @@ def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> assert "~/work/project/.tmuxp.yaml" in message assert "/home/testuser" not in message + + +def test_load_on_project_start_runs_hook( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp load runs on_project_start hook before session creation.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "start_hook_ran" + workspace_file = tmp_path / "hook_start.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-start-test +on_project_start: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + + assert marker.exists() + assert session is not None + session.kill() + + +def test_load_on_project_restart_runs_hook( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp load runs on_project_restart hook when session already exists.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "restart_hook_ran" + workspace_file = tmp_path / "hook_restart.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-restart-test +on_project_restart: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + # First load creates the session + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert not marker.exists() + + # Second load triggers on_project_restart (session already exists) + load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert marker.exists() + + session.kill() diff --git a/tests/cli/test_stop.py b/tests/cli/test_stop.py index aa2b52e810..d06acd9cef 100644 --- a/tests/cli/test_stop.py +++ b/tests/cli/test_stop.py @@ -2,11 +2,13 @@ from __future__ import annotations +import pathlib import typing as t import pytest from tmuxp import cli +from tmuxp.cli.load import load_workspace if t.TYPE_CHECKING: from libtmux.server import Server @@ -69,3 +71,60 @@ def test_stop_nonexistent_session( captured = capsys.readouterr() assert "Session not found" in captured.out + + +def test_stop_runs_on_project_stop_hook( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp stop runs on_project_stop hook from session environment.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "stop_hook_ran" + workspace_file = tmp_path / "hook_stop.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-stop-test +on_project_stop: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + + # Verify env var was stored + stop_cmd = session.getenv("TMUXP_ON_PROJECT_STOP") + assert stop_cmd is not None + + # Stop the session via CLI + assert server.socket_name is not None + cli.cli(["stop", "hook-stop-test", "-L", server.socket_name]) + + assert marker.exists() + assert not server.has_session("hook-stop-test") + + +def test_stop_without_hook( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp stop works normally when no on_project_stop hook is set.""" + monkeypatch.delenv("TMUX", raising=False) + + server.new_session(session_name="no-hook-session") + assert server.has_session("no-hook-session") + + assert server.socket_name is not None + cli.cli(["stop", "no-hook-session", "-L", server.socket_name]) + + assert not server.has_session("no-hook-session") diff --git a/tests/test_util.py b/tests/test_util.py index 098c8c212b..543d57e5c5 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -12,7 +12,13 @@ from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script +from tmuxp.util import ( + get_pane, + get_session, + oh_my_zsh_auto_title, + run_before_script, + run_hook_commands, +) from .constants import FIXTURE_PATH @@ -234,3 +240,79 @@ def patched_exists(path: str) -> bool: warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert len(warning_records) >= 1 assert "DISABLE_AUTO_TITLE" in warning_records[0].message + + +class HookCommandFixture(t.NamedTuple): + """Test fixture for run_hook_commands.""" + + test_id: str + commands: str | list[str] + expect_runs: bool + + +HOOK_COMMAND_FIXTURES: list[HookCommandFixture] = [ + HookCommandFixture( + test_id="string-cmd", + commands="echo hello", + expect_runs=True, + ), + HookCommandFixture( + test_id="list-cmd", + commands=["echo a", "echo b"], + expect_runs=True, + ), + HookCommandFixture( + test_id="empty-string", + commands="", + expect_runs=False, + ), +] + + +@pytest.mark.parametrize( + list(HookCommandFixture._fields), + HOOK_COMMAND_FIXTURES, + ids=[f.test_id for f in HOOK_COMMAND_FIXTURES], +) +def test_run_hook_commands( + tmp_path: pathlib.Path, + test_id: str, + commands: str | list[str], + expect_runs: bool, +) -> None: + """run_hook_commands() executes shell commands without raising.""" + if expect_runs: + marker = tmp_path / "hook_ran" + if isinstance(commands, str): + commands = f"touch {marker}" + else: + commands = [f"touch {marker}"] + run_hook_commands(commands) + assert marker.exists() + else: + # Should not raise + run_hook_commands(commands) + + +def test_run_hook_commands_failure_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """run_hook_commands() logs WARNING on non-zero exit, does not raise.""" + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + run_hook_commands("exit 1") + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and hasattr(r, "tmux_exit_code") + ] + assert len(warning_records) >= 1 + assert warning_records[0].tmux_exit_code == 1 + + +def test_run_hook_commands_cwd( + tmp_path: pathlib.Path, +) -> None: + """run_hook_commands() respects cwd parameter.""" + run_hook_commands("touch marker_file", cwd=tmp_path) + assert (tmp_path / "marker_file").exists() diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 919fedc758..18af498099 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1889,3 +1889,90 @@ def test_builder_logs_window_and_pane_creation( assert len(cmd_logs) >= 1 builder.session.kill() + + +def test_on_project_exit_sets_hook( + server: Server, +) -> None: + """on_project_exit sets tmux client-detached hook on the session.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-exit-test", + "on_project_exit": "echo goodbye", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_keys = list(hooks.keys()) + assert any("client-detached" in k for k in hook_keys) + + builder.session.kill() + + +def test_on_project_exit_sets_hook_list( + server: Server, +) -> None: + """on_project_exit joins list commands and sets tmux hook.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-exit-list-test", + "on_project_exit": ["echo a", "echo b"], + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_keys = list(hooks.keys()) + assert any("client-detached" in k for k in hook_keys) + + builder.session.kill() + + +def test_on_project_stop_sets_environment( + server: Server, +) -> None: + """on_project_stop stores commands in session environment.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-stop-env-test", + "on_project_stop": "docker compose down", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + stop_cmd = builder.session.getenv("TMUXP_ON_PROJECT_STOP") + assert stop_cmd == "docker compose down" + + builder.session.kill() + + +def test_on_project_stop_sets_start_directory_env( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """build() stores start_directory in session environment.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-startdir-env-test", + "start_directory": str(tmp_path), + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + start_dir = builder.session.getenv("TMUXP_START_DIRECTORY") + assert start_dir == str(tmp_path) + + builder.session.kill() diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index d5d16256c0..c13e03c7f9 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -541,3 +541,50 @@ def test_validate_schema_logs_debug( records = [r for r in caplog.records if r.msg == "validating workspace schema"] assert len(records) >= 1 assert getattr(records[0], "tmux_session", None) == "test_validate" + + +def test_expand_lifecycle_hooks_string( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """expand() expands shell variables in lifecycle hook string values.""" + monkeypatch.setenv("MY_HOOK_CMD", "docker compose up") + + workspace: dict[str, t.Any] = { + "session_name": "test", + "on_project_start": "$MY_HOOK_CMD", + "on_project_stop": "$MY_HOOK_CMD down", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + result = loader.expand(workspace) + + assert result["on_project_start"] == "docker compose up" + assert result["on_project_stop"] == "docker compose up down" + + +def test_expand_lifecycle_hooks_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """expand() expands shell variables in lifecycle hook list values.""" + monkeypatch.setenv("MY_CMD", "echo hello") + + workspace: dict[str, t.Any] = { + "session_name": "test", + "on_project_start": ["$MY_CMD", "echo world"], + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + result = loader.expand(workspace) + + assert result["on_project_start"] == ["echo hello", "echo world"] + + +def test_expand_lifecycle_hooks_tilde() -> None: + """expand() expands ~ in lifecycle hook values.""" + workspace: dict[str, t.Any] = { + "session_name": "test", + "on_project_exit": "~/scripts/cleanup.sh", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + result = loader.expand(workspace) + + assert "~" not in result["on_project_exit"] + assert result["on_project_exit"].endswith("/scripts/cleanup.sh") From 296ec3521c0298d45135a91b19aca2caaaf90484 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 18:25:31 -0500 Subject: [PATCH 039/143] feat(importers[tmuxinator]): Map startup_window/startup_pane to focus flags why: startup_window/startup_pane carry tmuxinator's focus intent; map them to tmuxp focus flags so the loaded session focuses the right window and pane. what: - Post-process startup_window to set focus: true on matching window (by name, with numeric index fallback) - Post-process startup_pane to set focus: true on the target pane (converts string panes to dicts to carry the focus flag) - Log warning when startup_window doesn't match any window - Update test5 fixture expected output to use focus flags --- src/tmuxp/workspace/importers.py | 50 ++++++++++++++++++++--- tests/fixtures/import_tmuxinator/test5.py | 8 ++-- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 9f55cab914..da52e2f691 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -98,11 +98,8 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: "rvm use {}".format(workspace_dict["rvm"]), ) - if "startup_window" in workspace_dict: - tmuxp_workspace["start_window"] = workspace_dict["startup_window"] - - if "startup_pane" in workspace_dict: - tmuxp_workspace["start_pane"] = workspace_dict["startup_pane"] + _startup_window = workspace_dict.get("startup_window") + _startup_pane = workspace_dict.get("startup_pane") for window_dict in workspace_dict["windows"]: for k, v in window_dict.items(): @@ -137,6 +134,49 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ) tmuxp_workspace["windows"].append(window_dict) + + # Post-process startup_window / startup_pane into focus flags + if _startup_window is not None and tmuxp_workspace["windows"]: + _matched = False + for w in tmuxp_workspace["windows"]: + if w.get("window_name") == str(_startup_window): + w["focus"] = True + _matched = True + break + if not _matched: + try: + _idx = int(_startup_window) + if 0 <= _idx < len(tmuxp_workspace["windows"]): + tmuxp_workspace["windows"][_idx]["focus"] = True + except (ValueError, IndexError): + logger.warning( + "startup_window %s not found", + _startup_window, + ) + + if _startup_pane is not None and tmuxp_workspace["windows"]: + _target = next( + (w for w in tmuxp_workspace["windows"] if w.get("focus")), + tmuxp_workspace["windows"][0], + ) + if "panes" in _target: + try: + _pidx = int(_startup_pane) + if 0 <= _pidx < len(_target["panes"]): + _pane = _target["panes"][_pidx] + if isinstance(_pane, dict): + _pane["focus"] = True + else: + _target["panes"][_pidx] = { + "shell_command": [_pane] if _pane else [], + "focus": True, + } + except (ValueError, IndexError): + logger.warning( + "startup_pane %s not found", + _startup_pane, + ) + return tmuxp_workspace diff --git a/tests/fixtures/import_tmuxinator/test5.py b/tests/fixtures/import_tmuxinator/test5.py index 500b594a68..f8b3176a49 100644 --- a/tests/fixtures/import_tmuxinator/test5.py +++ b/tests/fixtures/import_tmuxinator/test5.py @@ -25,10 +25,12 @@ "start_directory": "~/projects/ruby-app", "before_script": "./scripts/bootstrap.sh", "shell_command_before": ["source .env", "rvm use 2.1.1"], - "start_window": "server", - "start_pane": 0, "windows": [ {"window_name": "editor", "panes": ["vim"]}, - {"window_name": "server", "panes": ["rails s"]}, + { + "window_name": "server", + "focus": True, + "panes": [{"shell_command": ["rails s"], "focus": True}], + }, ], } From 5f3d0378c8404a58765cd97d90c2dd4e79a9c382 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 18:25:39 -0500 Subject: [PATCH 040/143] test(importers[tmuxinator]): Add startup_window/startup_pane focus tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Verify that startup_window and startup_pane correctly convert to focus flags on matching windows and panes. what: - Add test for startup_window focus by name - Add test for startup_window focus by numeric index - Add test for startup_pane focus on target pane (string→dict conversion) - Add test for startup_pane without startup_window (targets first window) - Add test for warning when startup_window doesn't match --- tests/workspace/test_import_tmuxinator.py | 90 +++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index e14d6f8fd5..b9caa41b89 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -111,3 +111,93 @@ def test_logs_info_on_multi_command_pre_list( pre_records = [r for r in caplog.records if "multi-command pre list" in r.message] assert len(pre_records) == 1 + + +def test_startup_window_sets_focus_by_name() -> None: + """Startup_window sets focus on the matching window by name.""" + workspace = { + "name": "test", + "startup_window": "logs", + "windows": [ + {"editor": "vim"}, + {"logs": "tail -f log/dev.log"}, + ], + } + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0].get("focus") is None + assert result["windows"][1]["focus"] is True + + +def test_startup_window_sets_focus_by_index() -> None: + """Startup_window sets focus by numeric index when name doesn't match.""" + workspace = { + "name": "test", + "startup_window": 1, + "windows": [ + {"editor": "vim"}, + {"server": "rails s"}, + ], + } + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0].get("focus") is None + assert result["windows"][1]["focus"] is True + + +def test_startup_pane_sets_focus_on_pane() -> None: + """Startup_pane converts the target pane to a dict with focus.""" + workspace = { + "name": "test", + "startup_window": "editor", + "startup_pane": 1, + "windows": [ + { + "editor": { + "panes": ["vim", "guard", "top"], + }, + }, + ], + } + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0]["focus"] is True + panes = result["windows"][0]["panes"] + assert panes[0] == "vim" + assert panes[1] == {"shell_command": ["guard"], "focus": True} + assert panes[2] == "top" + + +def test_startup_pane_without_startup_window() -> None: + """Startup_pane targets the first window when no startup_window is set.""" + workspace = { + "name": "test", + "startup_pane": 1, + "windows": [ + { + "editor": { + "panes": ["vim", "guard"], + }, + }, + ], + } + result = importers.import_tmuxinator(workspace) + + panes = result["windows"][0]["panes"] + assert panes[1] == {"shell_command": ["guard"], "focus": True} + + +def test_startup_window_warns_on_no_match( + caplog: pytest.LogCaptureFixture, +) -> None: + """Startup_window logs WARNING when no matching window is found.""" + workspace = { + "name": "test", + "startup_window": "nonexistent", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + + warn_records = [r for r in caplog.records if "startup_window" in r.message] + assert len(warn_records) == 1 From 325e3d4f65407be73087f9e317c6dcac5edeebbb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 19:04:45 -0500 Subject: [PATCH 041/143] feat(builder[config_after_window]): Handle clear config key for window panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The teamocil importer preserves clear: true on windows, but the builder ignored it — dead data with no effect. what: - Send clear command to all panes when window config has clear: true - Runs after shell_command_after in config_after_window() for clean terminal --- src/tmuxp/workspace/builder.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index abe2eebc86..917c7b119d 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -921,6 +921,10 @@ def config_after_window( for pane in window.panes: pane.send_keys(cmd["cmd"]) + if window_config.get("clear"): + for pane in window.panes: + pane.send_keys("clear", enter=True) + def find_current_attached_session(self) -> Session: """Return current attached session.""" assert self.server is not None From c984c341ad9350611bef4c3f8a7afb26275ee35b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 19:04:50 -0500 Subject: [PATCH 042/143] test(builder): Add clear config key tests why: Verify the new clear config key behavior end-to-end. what: - Test clear: true sends clear to all panes (BEFORE_CLEAR text removed) - Test clear: false preserves pane content (SHOULD_REMAIN text kept) --- tests/workspace/test_builder.py | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 18af498099..fe8d5ba883 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1976,3 +1976,71 @@ def test_on_project_stop_sets_start_directory_env( assert start_dir == str(tmp_path) builder.session.kill() + + +def test_clear_sends_clear_to_panes( + session: Session, +) -> None: + """clear: true sends clear command to all panes after window creation.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "clear-test", + "clear": True, + "panes": [ + {"shell_command": ["echo BEFORE_CLEAR"]}, + {"shell_command": ["echo BEFORE_CLEAR"]}, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert len(window.panes) == 2 + + for pane in window.panes: + + def check(p: Pane = pane) -> bool: + captured = "\n".join(p.capture_pane()).strip() + return "BEFORE_CLEAR" not in captured + + assert retry_until(check, raises=False), ( + f"Expected BEFORE_CLEAR to be cleared from pane {pane.pane_id}" + ) + + +def test_clear_false_does_not_clear( + session: Session, +) -> None: + """clear: false does not clear pane content.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "no-clear-test", + "clear": False, + "panes": [ + {"shell_command": ["echo SHOULD_REMAIN"]}, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + pane = window.panes[0] + + def check(p: Pane = pane) -> bool: + return "SHOULD_REMAIN" in "\n".join(p.capture_pane()) + + assert retry_until(check), ( + f"Expected SHOULD_REMAIN to remain in pane {pane.pane_id}" + ) From 14cf87a5ab0c2ffbbedf9b796b377d5dece66873 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 19:14:51 -0500 Subject: [PATCH 043/143] fix(cli[load]): Read socket_name, socket_path, config from workspace config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The tmuxinator importer parses cli_args into socket_name, socket_path, and config keys, but load_workspace never read them — dead data with no effect. what: - Pop socket_name, socket_path, config from expanded workspace as fallbacks - CLI args (-L, -S, -f) take precedence over workspace config values - Keys removed from workspace dict so they don't reach the builder --- src/tmuxp/cli/load.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 6eb9b57b35..67e6fc8b39 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -643,6 +643,21 @@ def _cleanup_debug() -> None: for pane in window.get("panes", []): pane.pop("shell_command_before", None) + # Use workspace config values as fallbacks for server connection params + # (e.g. from tmuxinator cli_args: "-L socket -f tmux.conf") + if socket_name is None: + socket_name = expanded_workspace.pop("socket_name", None) + else: + expanded_workspace.pop("socket_name", None) + if socket_path is None: + socket_path = expanded_workspace.pop("socket_path", None) + else: + expanded_workspace.pop("socket_path", None) + if tmux_config_file is None: + tmux_config_file = expanded_workspace.pop("config", None) + else: + expanded_workspace.pop("config", None) + # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) From 4952b4290e4ff543ff339ed7ea28fb867c5cf790 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 19:14:58 -0500 Subject: [PATCH 044/143] test(cli[load]): Add config key precedence tests for load_workspace why: Verify workspace config keys (socket_name, config) are used as Server fallbacks and that CLI args override them. what: - 4 parametrized test cases: fallback socket_name, fallback config, CLI overrides socket_name, CLI overrides config --- tests/cli/test_load.py | 109 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index caed58178a..5eff1064cc 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1194,3 +1194,112 @@ def test_load_on_project_restart_runs_hook( assert marker.exists() session.kill() + + +class ConfigKeyPrecedenceFixture(t.NamedTuple): + """Test fixture for config key precedence tests.""" + + test_id: str + workspace_extra: dict[str, t.Any] + cli_socket_name: str | None + cli_tmux_config_file: str | None + expect_socket_name: str | None + expect_config_file: str | None + + +CONFIG_KEY_PRECEDENCE_FIXTURES: list[ConfigKeyPrecedenceFixture] = [ + ConfigKeyPrecedenceFixture( + test_id="workspace-socket_name-used-as-fallback", + workspace_extra={"socket_name": "{server_socket}"}, + cli_socket_name=None, + cli_tmux_config_file=None, + expect_socket_name="{server_socket}", + expect_config_file=None, + ), + ConfigKeyPrecedenceFixture( + test_id="workspace-config-used-as-fallback", + workspace_extra={"config": "{tmux_conf}"}, + cli_socket_name="{server_socket}", + cli_tmux_config_file=None, + expect_socket_name="{server_socket}", + expect_config_file="{tmux_conf}", + ), + ConfigKeyPrecedenceFixture( + test_id="cli-overrides-workspace-socket_name", + workspace_extra={"socket_name": "ignored-socket"}, + cli_socket_name="{server_socket}", + cli_tmux_config_file=None, + expect_socket_name="{server_socket}", + expect_config_file=None, + ), + ConfigKeyPrecedenceFixture( + test_id="cli-overrides-workspace-config", + workspace_extra={"config": "/ignored/tmux.conf"}, + cli_socket_name="{server_socket}", + cli_tmux_config_file="{tmux_conf}", + expect_socket_name="{server_socket}", + expect_config_file="{tmux_conf}", + ), +] + + +@pytest.mark.parametrize( + list(ConfigKeyPrecedenceFixture._fields), + CONFIG_KEY_PRECEDENCE_FIXTURES, + ids=[f.test_id for f in CONFIG_KEY_PRECEDENCE_FIXTURES], +) +def test_load_workspace_config_key_precedence( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + workspace_extra: dict[str, t.Any], + cli_socket_name: str | None, + cli_tmux_config_file: str | None, + expect_socket_name: str | None, + expect_config_file: str | None, +) -> None: + """Workspace config keys (socket_name, config) used as Server fallbacks.""" + monkeypatch.delenv("TMUX", raising=False) + + tmux_conf = str(FIXTURE_PATH / "tmux" / "tmux.conf") + server_socket = server.socket_name + + def _resolve(val: str | None) -> str | None: + if val is None: + return None + return val.format(server_socket=server_socket, tmux_conf=tmux_conf) + + resolved_extra = { + k: _resolve(v) if isinstance(v, str) else v for k, v in workspace_extra.items() + } + + extra_lines = "\n".join(f"{k}: {v}" for k, v in resolved_extra.items()) + workspace_file = tmp_path / "test.yaml" + workspace_file.write_text( + f"""\ +session_name: cfg-key-{test_id} +{extra_lines} +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=_resolve(cli_socket_name), + tmux_config_file=_resolve(cli_tmux_config_file), + detached=True, + ) + + assert isinstance(session, Session) + + if _resolve(expect_socket_name) is not None: + assert session.server.socket_name == _resolve(expect_socket_name) + if _resolve(expect_config_file) is not None: + assert session.server.config_file == _resolve(expect_config_file) + + session.kill() From 94e5b348cdf11925e4edbfcc3235256b9fb89e6f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 20:15:15 -0500 Subject: [PATCH 045/143] feat(loader,cli[load]): Add {{ variable }} config templating with --set flag why: tmuxp had no user-defined variable interpolation at load time. The tmuxinator importer supports ERB templating, but imported configs lost this capability. Environment variables ($VAR) already work, but custom key=value variables were not supported. what: - Add render_template() in loader.py using regex-based {{ var }} syntax - Add template_context parameter to ConfigReader._from_file() and load_workspace() for pre-YAML rendering - Add --set KEY=VALUE CLI flag (repeatable) to tmuxp load - Template rendering runs before YAML parsing, env var expansion after - Unknown {{ var }} expressions left unchanged (no error) - Zero new dependencies (no Jinja2 needed) --- src/tmuxp/_internal/config_reader.py | 22 ++++++++++++- src/tmuxp/cli/load.py | 43 +++++++++++++++++++++++++- src/tmuxp/workspace/loader.py | 46 ++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/_internal/config_reader.py b/src/tmuxp/_internal/config_reader.py index 6da248dea7..e7c86bdea7 100644 --- a/src/tmuxp/_internal/config_reader.py +++ b/src/tmuxp/_internal/config_reader.py @@ -79,9 +79,16 @@ def load(cls, fmt: FormatLiteral, content: str) -> ConfigReader: ) @classmethod - def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: + def _from_file( + cls, + path: pathlib.Path, + template_context: dict[str, str] | None = None, + ) -> dict[str, t.Any]: r"""Load data from file path directly to dictionary. + When *template_context* is provided, ``{{ variable }}`` expressions in the + raw file content are replaced before YAML/JSON parsing. + **YAML file** *For demonstration only,* create a YAML file: @@ -107,11 +114,24 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: >>> ConfigReader._from_file(json_file) {'session_name': 'my session'} + + **Template rendering** + + >>> tpl_file = tmp_path / 'tpl.yaml' + >>> tpl_file.write_text('session_name: {{ name }}', encoding='utf-8') + 24 + >>> ConfigReader._from_file(tpl_file, template_context={"name": "rendered"}) + {'session_name': 'rendered'} """ assert isinstance(path, pathlib.Path) logger.debug("loading config", extra={"tmux_config_path": str(path)}) content = path.open(encoding="utf-8").read() + if template_context: + from tmuxp.workspace.loader import render_template + + content = render_template(content, template_context) + if path.suffix in {".yaml", ".yml"}: fmt: FormatLiteral = "yaml" elif path.suffix == ".json": diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 67e6fc8b39..1881c9fdaa 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -129,6 +129,7 @@ class CLILoadNamespace(argparse.Namespace): no_progress: bool no_shell_command_before: bool debug: bool + set: list[str] def load_plugins( @@ -493,6 +494,7 @@ def load_workspace( no_progress: bool = False, no_shell_command_before: bool = False, debug: bool = False, + template_context: dict[str, str] | None = None, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -535,6 +537,10 @@ def load_workspace( before building. Default False. debug : bool Show tmux commands as they execute. Implies no_progress. Default False. + template_context : dict, optional + Mapping of variable names to values for ``{{ variable }}`` template + rendering. Applied to raw file content before YAML/JSON parsing. + Typically populated from ``--set KEY=VALUE`` CLI arguments. Notes ----- @@ -623,7 +629,16 @@ def _cleanup_debug() -> None: ) # ConfigReader allows us to open a yaml or json file as a dict - raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} + if template_context: + raw_workspace = ( + config_reader.ConfigReader._from_file( + workspace_file, + template_context=template_context, + ) + or {} + ) + else: + raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} # shapes workspaces relative to config / profile file location expanded_workspace = loader.expand( @@ -954,6 +969,17 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP help="show tmux commands as they execute (implies --no-progress)", ) + parser.add_argument( + "--set", + metavar="KEY=VALUE", + action="append", + default=[], + help=( + "set template variable for {{ variable }} expressions in workspace config " + "(repeatable, e.g. --set project=myapp --set port=8080)" + ), + ) + try: import shtab @@ -1007,6 +1033,20 @@ def command_load( sys.exit() return + # Parse --set KEY=VALUE args into template context + template_context: dict[str, str] | None = None + if args.set: + template_context = {} + for item in args.set: + key, _, value = item.partition("=") + if not key or not _: + tmuxp_echo( + cli_colors.error("[Error]") + + f" Invalid --set format: {item!r} (expected KEY=VALUE)", + ) + sys.exit(1) + template_context[key] = value + last_idx = len(args.workspace_files) - 1 original_detached_option = args.detached original_new_session_name = args.new_session_name @@ -1041,4 +1081,5 @@ def command_load( no_progress=args.no_progress, no_shell_command_before=args.no_shell_command_before, debug=args.debug, + template_context=template_context, ) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 2034319b43..499c5cba95 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -5,6 +5,7 @@ import logging import os import pathlib +import re import typing as t logger = logging.getLogger(__name__) @@ -28,6 +29,51 @@ def expandshell(value: str) -> str: return os.path.expandvars(os.path.expanduser(value)) # NOQA: PTH111 +_TEMPLATE_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}") + + +def render_template(content: str, context: dict[str, str]) -> str: + """Render ``{{ variable }}`` expressions in raw config content. + + Replaces template expressions with values from *context*. Expressions + referencing keys not in *context* are left unchanged so that + ``$ENV_VAR`` expansion (which runs later, after YAML parsing) is + unaffected. + + Parameters + ---------- + content : str + Raw file content (YAML or JSON) before parsing. + context : dict + Mapping of variable names to replacement values, typically + from ``--set KEY=VALUE`` CLI arguments. + + Returns + ------- + str + Content with matching ``{{ key }}`` expressions replaced. + + Examples + -------- + >>> render_template("root: {{ project }}", {"project": "myapp"}) + 'root: myapp' + + >>> render_template("root: {{ unknown }}", {"project": "myapp"}) + 'root: {{ unknown }}' + + >>> render_template("no templates here", {"key": "val"}) + 'no templates here' + """ + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + if key in context: + return context[key] + return match.group(0) + + return _TEMPLATE_RE.sub(_replace, content) + + def expand_cmd(p: dict[str, t.Any]) -> dict[str, t.Any]: """Resolve shell variables and expand shorthands in a tmuxp config mapping.""" if isinstance(p, str): From 24973cee0ed0c9343c88794bd37ec9221b1c7242 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 20:15:21 -0500 Subject: [PATCH 046/143] test(loader,cli[load]): Add config templating and --set flag tests why: Verify {{ variable }} template rendering and CLI integration. what: - 9 parametrized render_template() unit tests (simple, multiple vars, unknown vars, env var coexistence, whitespace variants, empty context) - 2 CLI integration tests (template_context rendering, no-context baseline) --- tests/cli/test_load.py | 62 +++++++++++++++++++++++++ tests/workspace/test_config.py | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 5eff1064cc..8077660f9c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1303,3 +1303,65 @@ def _resolve(val: str | None) -> str | None: assert session.server.config_file == _resolve(expect_config_file) session.kill() + + +def test_load_workspace_template_context( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace() renders {{ var }} templates before YAML parsing.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "tpl.yaml" + workspace_file.write_text( + """\ +session_name: {{ project }}-session +windows: +- window_name: {{ window }} + panes: + - echo {{ project }} +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + template_context={"project": "myapp", "window": "editor"}, + ) + + assert isinstance(session, Session) + assert session.name == "myapp-session" + assert session.windows[0].window_name == "editor" + + +def test_load_workspace_template_no_context( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace() without template_context leaves {{ var }} as literals.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "tpl.yaml" + workspace_file.write_text( + """\ +session_name: plain-session +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "plain-session" diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index c13e03c7f9..5a66006741 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -588,3 +588,86 @@ def test_expand_lifecycle_hooks_tilde() -> None: assert "~" not in result["on_project_exit"] assert result["on_project_exit"].endswith("/scripts/cleanup.sh") + + +class RenderTemplateFixture(t.NamedTuple): + """Test fixture for render_template tests.""" + + test_id: str + content: str + context: dict[str, str] + expected: str + + +RENDER_TEMPLATE_FIXTURES: list[RenderTemplateFixture] = [ + RenderTemplateFixture( + test_id="simple-replacement", + content="root: {{ project }}", + context={"project": "myapp"}, + expected="root: myapp", + ), + RenderTemplateFixture( + test_id="multiple-vars", + content="name: {{ name }}\nroot: {{ root }}", + context={"name": "dev", "root": "/tmp/dev"}, + expected="name: dev\nroot: /tmp/dev", + ), + RenderTemplateFixture( + test_id="unknown-var-unchanged", + content="root: {{ unknown }}", + context={"project": "myapp"}, + expected="root: {{ unknown }}", + ), + RenderTemplateFixture( + test_id="no-templates", + content="root: /tmp/myapp", + context={"project": "myapp"}, + expected="root: /tmp/myapp", + ), + RenderTemplateFixture( + test_id="env-var-not-affected", + content="root: $HOME/{{ project }}", + context={"project": "myapp"}, + expected="root: $HOME/myapp", + ), + RenderTemplateFixture( + test_id="whitespace-in-braces", + content="root: {{project}}", + context={"project": "myapp"}, + expected="root: myapp", + ), + RenderTemplateFixture( + test_id="extra-whitespace-in-braces", + content="root: {{ project }}", + context={"project": "myapp"}, + expected="root: myapp", + ), + RenderTemplateFixture( + test_id="empty-context", + content="root: {{ project }}", + context={}, + expected="root: {{ project }}", + ), + RenderTemplateFixture( + test_id="same-var-multiple-times", + content="a: {{ x }}\nb: {{ x }}", + context={"x": "val"}, + expected="a: val\nb: val", + ), +] + + +@pytest.mark.parametrize( + list(RenderTemplateFixture._fields), + RENDER_TEMPLATE_FIXTURES, + ids=[f.test_id for f in RENDER_TEMPLATE_FIXTURES], +) +def test_render_template( + test_id: str, + content: str, + context: dict[str, str], + expected: str, +) -> None: + """render_template() replaces {{ var }} expressions with context values.""" + result = loader.render_template(content, context) + assert result == expected From eb278522ca6ccdd9963025a59d7bbe001169fc1d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 21:08:20 -0500 Subject: [PATCH 047/143] fix(importers[tmuxinator]): Coerce window names to str, convert named panes why: YAML parses unquoted literals like 222, true, 3.14 as native types (int, bool, float), causing TypeError in expandshell(). Named pane dicts ({pane_name: commands}) lost their title during import. what: - Coerce window_name to str(k) for non-None keys at import time - Add _convert_named_panes() to detect single-key dicts in pane lists and convert {name: commands} to {shell_command: commands, title: name} - Apply conversion in both list-form and dict-form window panes --- src/tmuxp/workspace/importers.py | 52 ++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index da52e2f691..5392248c80 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -9,6 +9,52 @@ logger = logging.getLogger(__name__) +def _convert_named_panes(panes: list[t.Any]) -> list[t.Any]: + """Convert tmuxinator named pane dicts to tmuxp format. + + Tmuxinator supports ``{pane_name: commands}`` dicts in pane lists, where the + key is the pane title and the value is the command or command list. Convert + these to ``{"shell_command": commands, "title": pane_name}`` so the builder + can call ``pane.set_title()``. + + Parameters + ---------- + panes : list + Raw pane list from a tmuxinator window config. + + Returns + ------- + list + Pane list with named pane dicts converted. + + Examples + -------- + >>> _convert_named_panes(["vim", {"logs": ["tail -f log"]}]) + ['vim', {'shell_command': ['tail -f log'], 'title': 'logs'}] + + >>> _convert_named_panes(["vim", None, "top"]) + ['vim', None, 'top'] + """ + result: list[t.Any] = [] + for pane in panes: + if isinstance(pane, dict) and len(pane) == 1 and "shell_command" not in pane: + pane_name = next(iter(pane)) + commands = pane[pane_name] + if isinstance(commands, str): + commands = [commands] + elif commands is None: + commands = [] + result.append( + { + "shell_command": commands, + "title": str(pane_name), + } + ) + else: + result.append(pane) + return result + + def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -103,21 +149,21 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: for window_dict in workspace_dict["windows"]: for k, v in window_dict.items(): - window_dict = {"window_name": k} + window_dict = {"window_name": str(k) if k is not None else k} if isinstance(v, str) or v is None: window_dict["panes"] = [v] tmuxp_workspace["windows"].append(window_dict) continue if isinstance(v, list): - window_dict["panes"] = v + window_dict["panes"] = _convert_named_panes(v) tmuxp_workspace["windows"].append(window_dict) continue if "pre" in v: window_dict["shell_command_before"] = v["pre"] if "panes" in v: - window_dict["panes"] = v["panes"] + window_dict["panes"] = _convert_named_panes(v["panes"]) if "root" in v: window_dict["start_directory"] = v["root"] From 70657fa0eec78f0e33ccd987c23d2c3880273de6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 15 Mar 2026 21:08:27 -0500 Subject: [PATCH 048/143] test(importers[tmuxinator]): Add edge case tests for window names, aliases, pane titles why: Cover YAML type coercion, alias/anchor resolution, and named pane syntax. what: - Parametrized window name coercion tests (int, bool, float, None, emoji, mixed) - Numeric window name survives expand() integration test - YAML aliases/anchors resolution test - Parametrized _convert_named_panes() unit tests - Named pane integration tests (dict-form and list-form windows) --- tests/workspace/test_import_tmuxinator.py | 232 ++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index b9caa41b89..d3becfe4f9 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -201,3 +201,235 @@ def test_startup_window_warns_on_no_match( warn_records = [r for r in caplog.records if "startup_window" in r.message] assert len(warn_records) == 1 + + +class YamlEdgeCaseFixture(t.NamedTuple): + """Test fixture for YAML edge case tests.""" + + test_id: str + workspace: dict[str, t.Any] + expected_window_names: list[str | None] + + +YAML_EDGE_CASE_FIXTURES: list[YamlEdgeCaseFixture] = [ + YamlEdgeCaseFixture( + test_id="numeric-window-name", + workspace={ + "name": "test", + "windows": [{222: "echo hello"}], + }, + expected_window_names=["222"], + ), + YamlEdgeCaseFixture( + test_id="boolean-true-window-name", + workspace={ + "name": "test", + "windows": [{True: "echo true"}], + }, + expected_window_names=["True"], + ), + YamlEdgeCaseFixture( + test_id="boolean-false-window-name", + workspace={ + "name": "test", + "windows": [{False: "echo false"}], + }, + expected_window_names=["False"], + ), + YamlEdgeCaseFixture( + test_id="float-window-name", + workspace={ + "name": "test", + "windows": [{222.3: "echo float"}], + }, + expected_window_names=["222.3"], + ), + YamlEdgeCaseFixture( + test_id="none-window-name", + workspace={ + "name": "test", + "windows": [{None: "echo none"}], + }, + expected_window_names=[None], + ), + YamlEdgeCaseFixture( + test_id="emoji-window-name", + workspace={ + "name": "test", + "windows": [{"🍩": "echo donut"}], + }, + expected_window_names=["🍩"], + ), + YamlEdgeCaseFixture( + test_id="mixed-type-window-names", + workspace={ + "name": "test", + "windows": [ + {222: "echo int"}, + {True: "echo bool"}, + {"normal": "echo str"}, + ], + }, + expected_window_names=["222", "True", "normal"], + ), +] + + +@pytest.mark.parametrize( + list(YamlEdgeCaseFixture._fields), + YAML_EDGE_CASE_FIXTURES, + ids=[f.test_id for f in YAML_EDGE_CASE_FIXTURES], +) +def test_import_tmuxinator_window_name_coercion( + workspace: dict[str, t.Any], + expected_window_names: list[str | None], + test_id: str, +) -> None: + """Window names are coerced to strings for YAML type-coerced keys.""" + result = importers.import_tmuxinator(workspace) + actual_names = [w["window_name"] for w in result["windows"]] + assert actual_names == expected_window_names + + +def test_import_tmuxinator_numeric_window_survives_expand() -> None: + """Numeric window names don't crash expand() after str coercion.""" + from tmuxp.workspace import loader + + workspace = { + "name": "test", + "windows": [{222: "echo hello"}, {True: "echo bool"}], + } + result = importers.import_tmuxinator(workspace) + expanded = loader.expand(result) + + assert expanded["windows"][0]["window_name"] == "222" + assert expanded["windows"][1]["window_name"] == "True" + + +def test_import_tmuxinator_yaml_aliases() -> None: + """YAML aliases/anchors resolve transparently before import.""" + yaml_content = """\ +defaults: &defaults + pre: + - echo "alias_is_working" + +name: sample_alias +root: ~/test +windows: + - editor: + <<: *defaults + layout: main-vertical + panes: + - vim + - top + - guard: +""" + parsed = ConfigReader._load(fmt="yaml", content=yaml_content) + result = importers.import_tmuxinator(parsed) + + assert result["session_name"] == "sample_alias" + assert result["windows"][0]["window_name"] == "editor" + assert result["windows"][0]["shell_command_before"] == [ + 'echo "alias_is_working"', + ] + assert result["windows"][0]["layout"] == "main-vertical" + assert result["windows"][0]["panes"] == ["vim", "top"] + assert result["windows"][1]["window_name"] == "guard" + + +class NamedPaneFixture(t.NamedTuple): + """Test fixture for named pane conversion tests.""" + + test_id: str + panes_input: list[t.Any] + expected_panes: list[t.Any] + + +NAMED_PANE_FIXTURES: list[NamedPaneFixture] = [ + NamedPaneFixture( + test_id="single-named-pane", + panes_input=[{"git_log": "git log --oneline"}], + expected_panes=[ + {"shell_command": ["git log --oneline"], "title": "git_log"}, + ], + ), + NamedPaneFixture( + test_id="named-pane-with-list-commands", + panes_input=[{"server": ["ssh server", "echo hello"]}], + expected_panes=[ + {"shell_command": ["ssh server", "echo hello"], "title": "server"}, + ], + ), + NamedPaneFixture( + test_id="mixed-named-and-plain-panes", + panes_input=["vim", {"logs": ["tail -f log"]}, "top"], + expected_panes=[ + "vim", + {"shell_command": ["tail -f log"], "title": "logs"}, + "top", + ], + ), + NamedPaneFixture( + test_id="named-pane-with-none-command", + panes_input=[{"empty": None}], + expected_panes=[ + {"shell_command": [], "title": "empty"}, + ], + ), + NamedPaneFixture( + test_id="no-named-panes", + panes_input=["vim", None, "top"], + expected_panes=["vim", None, "top"], + ), +] + + +@pytest.mark.parametrize( + list(NamedPaneFixture._fields), + NAMED_PANE_FIXTURES, + ids=[f.test_id for f in NAMED_PANE_FIXTURES], +) +def test_convert_named_panes( + test_id: str, + panes_input: list[t.Any], + expected_panes: list[t.Any], +) -> None: + """_convert_named_panes() converts {name: commands} dicts to title+shell_command.""" + result = importers._convert_named_panes(panes_input) + assert result == expected_panes + + +def test_import_tmuxinator_named_pane_in_window() -> None: + """Named pane dicts inside window config are converted with title.""" + workspace = { + "name": "test", + "windows": [ + { + "editor": { + "panes": [ + "vim", + {"logs": ["tail -f log/dev.log"]}, + ], + }, + }, + ], + } + result = importers.import_tmuxinator(workspace) + panes = result["windows"][0]["panes"] + assert panes[0] == "vim" + assert panes[1] == {"shell_command": ["tail -f log/dev.log"], "title": "logs"} + + +def test_import_tmuxinator_named_pane_in_list_window() -> None: + """Named pane dicts in list-form windows are converted with title.""" + workspace = { + "name": "test", + "windows": [ + {"editor": ["vim", {"server": "rails s"}, "top"]}, + ], + } + result = importers.import_tmuxinator(workspace) + panes = result["windows"][0]["panes"] + assert panes[0] == "vim" + assert panes[1] == {"shell_command": ["rails s"], "title": "server"} + assert panes[2] == "top" From b8012aa18868b52632f8c5392b54deeec40c6ec7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 04:57:51 -0500 Subject: [PATCH 049/143] fix(cli[load]): Only fire on_project_start when load proceeds why: on_project_start fired before the session-exists check, so it ran even when the user declined the reattach prompt. what: - Remove early on_project_start block (was before Server creation) - Fire on_project_start inside session-exists block only after user confirms (_confirmed or detached) - Fire on_project_start before new-session build path - Add test_load_on_project_start_skipped_on_decline --- src/tmuxp/cli/load.py | 42 ++++++++++++++++++++++--------------- tests/cli/test_load.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 1881c9fdaa..60b0304987 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -676,14 +676,6 @@ def _cleanup_debug() -> None: # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) - # Run on_project_start hook — fires on every tmuxp load invocation - if "on_project_start" in expanded_workspace: - _hook_cwd = expanded_workspace.get("start_directory") - util.run_hook_commands( - expanded_workspace["on_project_start"], - cwd=_hook_cwd, - ) - t = Server( # create tmux server object socket_name=socket_name, socket_path=socket_path, @@ -716,25 +708,41 @@ def _cleanup_debug() -> None: # Session-exists check — outside spinner so prompt_yes_no is safe if builder.session_exists(session_name) and not append and not here: - # Run on_project_restart hook — fires when reattaching - if "on_project_restart" in expanded_workspace: - _hook_cwd = expanded_workspace.get("start_directory") - util.run_hook_commands( - expanded_workspace["on_project_restart"], - cwd=_hook_cwd, - ) - if not detached and ( + _confirmed = not detached and ( answer_yes or prompt_yes_no( f"{cli_colors.highlight(session_name)} is already running. Attach?", default=True, color_mode=cli_colors.mode, ) - ): + ) + if _confirmed or detached: + if "on_project_start" in expanded_workspace: + _hook_cwd = expanded_workspace.get("start_directory") + util.run_hook_commands( + expanded_workspace["on_project_start"], + cwd=_hook_cwd, + ) + # Run on_project_restart hook — fires when reattaching + if "on_project_restart" in expanded_workspace: + _hook_cwd = expanded_workspace.get("start_directory") + util.run_hook_commands( + expanded_workspace["on_project_restart"], + cwd=_hook_cwd, + ) + if _confirmed: _reattach(builder, cli_colors) _cleanup_debug() return None + # Run on_project_start hook — fires before new session build + if "on_project_start" in expanded_workspace: + _hook_cwd = expanded_workspace.get("start_directory") + util.run_hook_commands( + expanded_workspace["on_project_start"], + cwd=_hook_cwd, + ) + if _progress_disabled: _private_path = str(PrivatePath(workspace_file)) result = _dispatch_build( diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 8077660f9c..f009ee8d5e 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1196,6 +1196,53 @@ def test_load_on_project_restart_runs_hook( session.kill() +def test_load_on_project_start_skipped_on_decline( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp load skips on_project_start when user declines reattach.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "start_hook_ran" + workspace_file = tmp_path / "hook_start_decline.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-start-decline +on_project_start: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + # First load creates the session + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert marker.exists() + marker.unlink() + + # Second load: session exists, user declines reattach + monkeypatch.setattr( + "tmuxp.cli.load.prompt_yes_no", + lambda *a, **kw: False, + ) + load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=False, + ) + assert not marker.exists() + + session.kill() + + class ConfigKeyPrecedenceFixture(t.NamedTuple): """Test fixture for config key precedence tests.""" From e0b23ca861e0b38ed8d6d07f76d897bf90070436 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 04:58:31 -0500 Subject: [PATCH 050/143] fix(cli[load]): Only fire on_project_restart after user confirms why: on_project_restart fired before the reattach prompt, so it ran even when the user declined. what: - on_project_restart was already moved inside the _confirmed/detached block by the previous commit's restructuring - Add test_load_on_project_restart_skipped_on_decline --- tests/cli/test_load.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index f009ee8d5e..d1d11ba478 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1196,6 +1196,52 @@ def test_load_on_project_restart_runs_hook( session.kill() +def test_load_on_project_restart_skipped_on_decline( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp load skips on_project_restart when user declines reattach.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "restart_hook_ran" + workspace_file = tmp_path / "hook_restart_decline.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-restart-decline +on_project_restart: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + # First load creates the session + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert not marker.exists() + + # Second load: session exists, user declines reattach + monkeypatch.setattr( + "tmuxp.cli.load.prompt_yes_no", + lambda *a, **kw: False, + ) + load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=False, + ) + assert not marker.exists() + + session.kill() + + def test_load_on_project_start_skipped_on_decline( tmp_path: pathlib.Path, server: Server, From 6b30fafa38042dc63b36e44475c111f4fd560036 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 04:59:22 -0500 Subject: [PATCH 051/143] docs(cli[copy,delete,new,stop]): Add doctests to create_*_subparser functions why: CLAUDE.md requires doctests on all functions; the 4 new CLI files had none. what: - Add doctest to create_copy_subparser (source/destination args) - Add doctest to create_delete_subparser (workspace_names, answer_yes) - Add doctest to create_new_subparser (workspace_name arg) - Add doctest to create_stop_subparser (session_name arg) --- src/tmuxp/cli/copy.py | 11 ++++++++++- src/tmuxp/cli/delete.py | 13 ++++++++++++- src/tmuxp/cli/new.py | 11 ++++++++++- src/tmuxp/cli/stop.py | 11 ++++++++++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py index fa2c3e122f..8600766c29 100644 --- a/src/tmuxp/cli/copy.py +++ b/src/tmuxp/cli/copy.py @@ -43,7 +43,16 @@ def create_copy_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: - """Augment :class:`argparse.ArgumentParser` with ``copy`` subcommand.""" + """Augment :class:`argparse.ArgumentParser` with ``copy`` subcommand. + + Examples + -------- + >>> import argparse + >>> parser = create_copy_subparser(argparse.ArgumentParser()) + >>> args = parser.parse_args(["src", "dst"]) + >>> args.source, args.destination + ('src', 'dst') + """ parser.add_argument( dest="source", metavar="source", diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py index 2ce3aec152..393fb26b64 100644 --- a/src/tmuxp/cli/delete.py +++ b/src/tmuxp/cli/delete.py @@ -42,7 +42,18 @@ def create_delete_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: - """Augment :class:`argparse.ArgumentParser` with ``delete`` subcommand.""" + """Augment :class:`argparse.ArgumentParser` with ``delete`` subcommand. + + Examples + -------- + >>> import argparse + >>> parser = create_delete_subparser(argparse.ArgumentParser()) + >>> args = parser.parse_args(["proj1", "proj2", "-y"]) + >>> args.workspace_names + ['proj1', 'proj2'] + >>> args.answer_yes + True + """ parser.add_argument( dest="workspace_names", metavar="workspace-name", diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py index 6012e018da..d419ff1778 100644 --- a/src/tmuxp/cli/new.py +++ b/src/tmuxp/cli/new.py @@ -50,7 +50,16 @@ def create_new_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: - """Augment :class:`argparse.ArgumentParser` with ``new`` subcommand.""" + """Augment :class:`argparse.ArgumentParser` with ``new`` subcommand. + + Examples + -------- + >>> import argparse + >>> parser = create_new_subparser(argparse.ArgumentParser()) + >>> args = parser.parse_args(["myproject"]) + >>> args.workspace_name + 'myproject' + """ parser.add_argument( dest="workspace_name", metavar="workspace-name", diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py index 949fe03623..549d67f1a0 100644 --- a/src/tmuxp/cli/stop.py +++ b/src/tmuxp/cli/stop.py @@ -47,7 +47,16 @@ class CLIStopNamespace(argparse.Namespace): def create_stop_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: - """Augment :class:`argparse.ArgumentParser` with ``stop`` subcommand.""" + """Augment :class:`argparse.ArgumentParser` with ``stop`` subcommand. + + Examples + -------- + >>> import argparse + >>> parser = create_stop_subparser(argparse.ArgumentParser()) + >>> args = parser.parse_args(["mysession"]) + >>> args.session_name + 'mysession' + """ parser.add_argument( dest="session_name", metavar="session-name", From 08d91d782f5d9e45d2cff996d8fdd176d53b1810 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 07:32:56 -0500 Subject: [PATCH 052/143] docs(CHANGES): Add 1.68.0 release notes for parity features why: Parity-branch features shipped without changelog coverage; users need one place to see what's new before the next release. what: - New commands: stop, new, copy, delete - Lifecycle hooks, config templating, new config keys - New load flags, importer improvements --- CHANGES | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 35e90e910a..325416af04 100644 --- a/CHANGES +++ b/CHANGES @@ -38,11 +38,90 @@ $ tmuxp@next load yoursession ## tmuxp 1.71.0 (Yet to be released) - +### New commands - -_Notes on the upcoming release will go here._ - +#### `tmuxp stop` — kill a tmux session (#1025) +Stop (kill) a running tmux session by name. Runs the `on_project_stop` +lifecycle hook before killing the session, giving your project a chance +to clean up. + +```console +$ tmuxp stop mysession +``` + +#### `tmuxp new` — create a workspace config (#1025) +Create a new workspace configuration file from a minimal template and +open it in `$EDITOR`. + +```console +$ tmuxp new myproject +``` + +#### `tmuxp copy` — copy a workspace config (#1025) +Copy an existing workspace config to a new name. Source is resolved +using the same logic as `tmuxp load`. + +```console +$ tmuxp copy myproject myproject-backup +``` + +#### `tmuxp delete` — delete workspace configs (#1025) +Delete one or more workspace config files. Prompts for confirmation +unless `-y` is passed. + +```console +$ tmuxp delete old-project +``` + +### Lifecycle hooks (#1025) +Workspace configs now support four lifecycle hooks, matching tmuxinator's +hook system: + +- `on_project_start` — runs before session build (every invocation) +- `on_project_restart` — runs when reattaching to an existing session +- `on_project_exit` — runs on client detach (via tmux `client-detached` hook) +- `on_project_stop` — runs before `tmuxp stop` kills the session + +### Config templating (#1025) +Workspace configs now support Jinja2-style `{{ variable }}` placeholders. +Pass values via `--set KEY=VALUE` on the command line: + +```console +$ tmuxp load --set project=myapp mytemplate.yaml +``` + +### New config keys (#1025) +- **`enable_pane_titles`** / **`pane_title_position`** / **`pane_title_format`** — + session-level keys that enable tmux pane border titles. +- **`title`** — pane-level key to set individual pane titles via + `select-pane -T`. +- **`synchronize`** — window-level shorthand (`before` / `after` / `true`) + that sets `synchronize-panes` without needing `options_after`. +- **`shell_command_after`** — window-level key; commands sent to every pane + after the window is fully built. +- **`clear`** — window-level boolean; sends `clear` to every pane after + commands complete. + +### New `tmuxp load` flags (#1025) +- `--here` — reuse the current tmux window instead of creating a new session. +- `--no-shell-command-before` — skip all `shell_command_before` entries. +- `--debug` — show tmux commands as they execute (disables progress spinner). +- `--set KEY=VALUE` — pass template variables for config templating. + +### Importer improvements (#1025) +#### tmuxinator + +- Map `pre` → `on_project_start`, `pre_window` → `shell_command_before`. +- Parse `cli_args` (`-f`, `-S`, `-L`) into tmuxp equivalents. +- Convert `synchronize` window key. +- Convert `startup_window` / `startup_pane` → `focus: true`. +- Convert named panes (hash-key syntax) → `title` on the pane. + +#### teamocil + +- Support v1.x format (`windows` at top level, `commands` key in panes). +- Convert `focus: true` on windows and panes. +- Pass through window `options`. ## tmuxp 1.70.0 (2026-05-23) From c0773f831b7231535d853ec340fab3e42c491db6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 07:33:43 -0500 Subject: [PATCH 053/143] docs(cli[stop,new,copy,delete]): Add command docs and API pages why: The four new commands were absent from the CLI reference and API docs. --- docs/cli/copy.md | 25 +++++++++++++++++++++ docs/cli/delete.md | 37 ++++++++++++++++++++++++++++++++ docs/cli/index.md | 4 ++++ docs/cli/new.md | 25 +++++++++++++++++++++ docs/cli/stop.md | 37 ++++++++++++++++++++++++++++++++ docs/internals/api/cli/copy.md | 8 +++++++ docs/internals/api/cli/delete.md | 8 +++++++ docs/internals/api/cli/index.md | 4 ++++ docs/internals/api/cli/new.md | 8 +++++++ docs/internals/api/cli/stop.md | 8 +++++++ 10 files changed, 164 insertions(+) create mode 100644 docs/cli/copy.md create mode 100644 docs/cli/delete.md create mode 100644 docs/cli/new.md create mode 100644 docs/cli/stop.md create mode 100644 docs/internals/api/cli/copy.md create mode 100644 docs/internals/api/cli/delete.md create mode 100644 docs/internals/api/cli/new.md create mode 100644 docs/internals/api/cli/stop.md diff --git a/docs/cli/copy.md b/docs/cli/copy.md new file mode 100644 index 0000000000..b84199601e --- /dev/null +++ b/docs/cli/copy.md @@ -0,0 +1,25 @@ +(cli-copy)= + +(cli-copy-reference)= + +# tmuxp copy + +Copy an existing workspace config to a new name. Source is resolved using the same logic as `tmuxp load` (supports names, paths, and extensions). + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: copy +``` + +## Basic usage + +Copy a workspace: + +```console +$ tmuxp copy myproject myproject-backup +``` diff --git a/docs/cli/delete.md b/docs/cli/delete.md new file mode 100644 index 0000000000..49a183d9fa --- /dev/null +++ b/docs/cli/delete.md @@ -0,0 +1,37 @@ +(cli-delete)= + +(cli-delete-reference)= + +# tmuxp delete + +Delete one or more workspace config files. Prompts for confirmation unless `-y` is passed. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: delete +``` + +## Basic usage + +Delete a workspace: + +```console +$ tmuxp delete old-project +``` + +Delete without confirmation: + +```console +$ tmuxp delete -y old-project +``` + +Delete multiple workspaces: + +```console +$ tmuxp delete proj1 proj2 +``` diff --git a/docs/cli/index.md b/docs/cli/index.md index fd38b681ea..9ca0611469 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -53,6 +53,7 @@ load shell ls search +stop ``` ```{toctree} @@ -63,6 +64,9 @@ edit import convert freeze +new +copy +delete ``` ```{toctree} diff --git a/docs/cli/new.md b/docs/cli/new.md new file mode 100644 index 0000000000..2f34eac25e --- /dev/null +++ b/docs/cli/new.md @@ -0,0 +1,25 @@ +(cli-new)= + +(cli-new-reference)= + +# tmuxp new + +Create a new workspace configuration file from a minimal template and open it in `$EDITOR`. If the workspace already exists, it opens for editing. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: new +``` + +## Basic usage + +Create a new workspace: + +```console +$ tmuxp new myproject +``` diff --git a/docs/cli/stop.md b/docs/cli/stop.md new file mode 100644 index 0000000000..c25757365c --- /dev/null +++ b/docs/cli/stop.md @@ -0,0 +1,37 @@ +(cli-stop)= + +(cli-stop-reference)= + +# tmuxp stop + +Stop (kill) a running tmux session. If `on_project_stop` is defined in the workspace config, that hook runs before the session is killed. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: stop +``` + +## Basic usage + +Stop a session by name: + +```console +$ tmuxp stop mysession +``` + +Stop the currently attached session: + +```console +$ tmuxp stop +``` + +Use a custom socket: + +```console +$ tmuxp stop -L mysocket mysession +``` diff --git a/docs/internals/api/cli/copy.md b/docs/internals/api/cli/copy.md new file mode 100644 index 0000000000..9e15404999 --- /dev/null +++ b/docs/internals/api/cli/copy.md @@ -0,0 +1,8 @@ +# tmuxp copy - `tmuxp.cli.copy` + +```{eval-rst} +.. automodule:: tmuxp.cli.copy + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/delete.md b/docs/internals/api/cli/delete.md new file mode 100644 index 0000000000..7873640e95 --- /dev/null +++ b/docs/internals/api/cli/delete.md @@ -0,0 +1,8 @@ +# tmuxp delete - `tmuxp.cli.delete` + +```{eval-rst} +.. automodule:: tmuxp.cli.delete + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/index.md b/docs/internals/api/cli/index.md index 1381fbc90f..f5c5ebcd44 100644 --- a/docs/internals/api/cli/index.md +++ b/docs/internals/api/cli/index.md @@ -19,6 +19,10 @@ ls progress search shell +stop +new +copy +delete utils ``` diff --git a/docs/internals/api/cli/new.md b/docs/internals/api/cli/new.md new file mode 100644 index 0000000000..bec0862ce1 --- /dev/null +++ b/docs/internals/api/cli/new.md @@ -0,0 +1,8 @@ +# tmuxp new - `tmuxp.cli.new` + +```{eval-rst} +.. automodule:: tmuxp.cli.new + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/stop.md b/docs/internals/api/cli/stop.md new file mode 100644 index 0000000000..7f01b8a4d3 --- /dev/null +++ b/docs/internals/api/cli/stop.md @@ -0,0 +1,8 @@ +# tmuxp stop - `tmuxp.cli.stop` + +```{eval-rst} +.. automodule:: tmuxp.cli.stop + :members: + :show-inheritance: + :undoc-members: +``` From 10e99dacf257b2a4768eee472c3b5c794f159041 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 07:34:02 -0500 Subject: [PATCH 054/143] docs(cli[load]): Document new flags and config templating why: --here, --no-shell-command-before, --debug, and {{ var }} templating were undiscoverable without reading the source. what: - Document --here, --no-shell-command-before, --debug flags - Document config templating with --set KEY=VALUE --- docs/cli/load.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/cli/load.md b/docs/cli/load.md index 8be9178f29..8867cf3d39 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -253,3 +253,57 @@ When progress is disabled, logging flows normally to the terminal and no spinner ### Before-script behavior During `before_script` execution, the progress bar shows a marching animation and a ⏸ status icon, indicating that tmuxp is waiting for the script to finish before continuing with pane creation. + +## Here mode + +The `--here` flag reuses the current tmux window instead of creating a new session. This is similar to teamocil's `--here` flag. + +```console +$ tmuxp load --here . +``` + +When used, tmuxp builds the workspace panes inside the current window rather than spawning a new session. + +## Skipping shell_command_before + +The `--no-shell-command-before` flag skips all `shell_command_before` entries at every level (session, window, pane). This is useful for quick reloads when the setup commands (virtualenv activation, etc.) are already active. + +```console +$ tmuxp load --no-shell-command-before myproject +``` + +## Debug mode + +The `--debug` flag shows tmux commands as they execute. This disables the progress spinner and attaches a debug handler to libtmux's logger, printing each tmux command to stderr. + +```console +$ tmuxp load --debug myproject +``` + +## Config templating + +Workspace configs support Jinja2-style `{{ variable }}` placeholders. Pass values via `--set KEY=VALUE`: + +```console +$ tmuxp load --set project=myapp mytemplate.yaml +``` + +Multiple variables can be passed: + +```console +$ tmuxp load --set project=myapp --set env=staging mytemplate.yaml +``` + +In the config file, use double-brace syntax: + +```yaml +session_name: "{{ project }}" +windows: + - window_name: "{{ project }}-main" + panes: + - echo "Working on {{ project }}" +``` + +```{note} +Values containing `{{ }}` must be quoted in YAML to avoid parse errors. +``` From e7736850a1ad6b0de03e8b0db578f05be53f5795 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 07:34:29 -0500 Subject: [PATCH 055/143] docs(config[top-level]): Document new config keys and lifecycle hooks why: Lifecycle hooks, pane titles, templating, synchronize, shell_command_after, and clear shipped without reference docs, leaving users to read source to discover syntax and placement. what: - Document lifecycle hooks (on_project_start/restart/exit/stop) - Document pane titles (enable_pane_titles, title) - Document config templating, synchronize, shell_command_after, clear --- docs/configuration/top-level.md | 148 ++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 72eb24f32f..5d132aade7 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -40,3 +40,151 @@ Notes: ``` Above: Use `tmux` directly to attach _banana_. + +## Lifecycle hooks + +Workspace configs support four lifecycle hooks that run shell commands at different stages of the session lifecycle: + +```yaml +session_name: myproject +on_project_start: notify-send "Starting myproject" +on_project_restart: notify-send "Reattaching to myproject" +on_project_exit: notify-send "Detached from myproject" +on_project_stop: notify-send "Stopping myproject" +windows: + - window_name: main + panes: + - +``` + +| Hook | When it runs | +|------|-------------| +| `on_project_start` | Before session build, every `tmuxp load` invocation | +| `on_project_restart` | When reattaching to an existing session | +| `on_project_exit` | On client detach (tmux `client-detached` hook) | +| `on_project_stop` | Before `tmuxp stop` kills the session | + +Each hook accepts a string (single command) or a list of strings (multiple commands run sequentially). + +```yaml +on_project_start: + - notify-send "Starting" + - ./setup.sh +``` + +```{note} +These hooks correspond to tmuxinator's `on_project_start`, `on_project_restart`, `on_project_exit`, and `on_project_stop` keys. +``` + +## Pane titles + +Enable pane border titles to display labels on each pane: + +```yaml +session_name: myproject +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - window_name: dev + panes: + - title: editor + shell_command: + - vim + - title: tests + shell_command: + - uv run pytest --watch + - shell_command: + - git status +``` + +| Key | Level | Description | +|-----|-------|-------------| +| `enable_pane_titles` | session | Enable pane border titles (`true`/`false`) | +| `pane_title_position` | session | Position of the title bar (`top`/`bottom`) | +| `pane_title_format` | session | Format string using tmux variables | +| `title` | pane | Title text for an individual pane | + +```{note} +These correspond to tmuxinator's `enable_pane_titles`, `pane_title_position`, `pane_title_format`, and named pane (hash-key) syntax. +``` + +## Config templating + +Workspace configs support `{{ variable }}` placeholders that are rendered before YAML/JSON parsing. Pass values via `--set KEY=VALUE` on the command line: + +```yaml +session_name: "{{ project }}" +start_directory: "~/code/{{ project }}" +windows: + - window_name: main + panes: + - echo "Working on {{ project }}" +``` + +```console +$ tmuxp load --set project=myapp mytemplate.yaml +``` + +```{note} +Values containing `{{ }}` must be quoted in YAML to prevent parse errors. +``` + +See {ref}`cli-load` for full CLI usage. + +## synchronize + +Window-level shorthand for setting `synchronize-panes`. Accepts `before`, `after`, or `true`: + +```yaml +session_name: sync-demo +windows: + - window_name: synced + synchronize: after + panes: + - echo pane0 + - echo pane1 + - window_name: not-synced + panes: + - echo pane0 + - echo pane1 +``` + +| Value | Behavior | +|-------|----------| +| `before` | Enable synchronize-panes before sending pane commands | +| `after` | Enable synchronize-panes after sending pane commands | +| `true` | Same as `before` | + +```{note} +This corresponds to tmuxinator's `synchronize` window key. The `before` and `true` values are accepted for compatibility but `after` is recommended. +``` + +## shell_command_after + +Window-level key. Commands are sent to every pane in the window after all panes have been created and their individual commands executed: + +```yaml +session_name: myproject +windows: + - window_name: servers + shell_command_after: + - echo "All panes ready" + panes: + - ./start-api.sh + - ./start-worker.sh +``` + +## clear + +Window-level boolean. When `true`, sends `clear` to every pane after all commands (including `shell_command_after`) have completed: + +```yaml +session_name: myproject +windows: + - window_name: dev + clear: true + panes: + - cd src + - cd tests +``` From 4d4c1eb72b2b56fded007e9123216f61041daac9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 07:35:47 -0500 Subject: [PATCH 056/143] docs(config[examples]): Add examples and tests for new features why: New config keys (synchronize, lifecycle hooks, templating, pane titles) had no runnable examples or regression coverage. what: - Add example YAMLs: synchronize, lifecycle hooks, templating, pane titles - Add pytest tests for all four examples - Update examples.md with literalinclude sections --- docs/configuration/examples.md | 52 +++++++++++++++++ examples/config-templating.yaml | 5 ++ examples/lifecycle-hooks.yaml | 7 +++ examples/pane-titles.yaml | 15 +++++ examples/synchronize-shorthand.yaml | 16 +++++ tests/docs/examples/__init__.py | 0 tests/docs/examples/test_examples.py | 87 ++++++++++++++++++++++++++++ 7 files changed, 182 insertions(+) create mode 100644 examples/config-templating.yaml create mode 100644 examples/lifecycle-hooks.yaml create mode 100644 examples/pane-titles.yaml create mode 100644 examples/synchronize-shorthand.yaml create mode 100644 tests/docs/examples/__init__.py create mode 100644 tests/docs/examples/test_examples.py diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 9651341309..bd67e4d226 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -785,6 +785,58 @@ windows: [poetry]: https://python-poetry.org/ [uv]: https://github.com/astral-sh/uv +## Synchronize panes (shorthand) + +The `synchronize` window-level key provides a shorthand for enabling +`synchronize-panes` without needing `options_after`: + +````{tab} YAML +```{literalinclude} ../../examples/synchronize-shorthand.yaml +:language: yaml + +``` +```` + +## Lifecycle hooks + +Run shell commands at different stages of the session lifecycle: + +````{tab} YAML +```{literalinclude} ../../examples/lifecycle-hooks.yaml +:language: yaml + +``` +```` + +See {ref}`top-level` for full hook documentation. + +## Config templating + +Use `{{ variable }}` placeholders in workspace configs. Pass values via +`--set KEY=VALUE`: + +```console +$ tmuxp load --set project=myapp config-templating.yaml +``` + +````{tab} YAML +```{literalinclude} ../../examples/config-templating.yaml +:language: yaml + +``` +```` + +## Pane titles + +Enable pane border titles to label individual panes: + +````{tab} YAML +```{literalinclude} ../../examples/pane-titles.yaml +:language: yaml + +``` +```` + ## Kung fu :::{note} diff --git a/examples/config-templating.yaml b/examples/config-templating.yaml new file mode 100644 index 0000000000..0578651044 --- /dev/null +++ b/examples/config-templating.yaml @@ -0,0 +1,5 @@ +session_name: "{{ project }}" +windows: + - window_name: "{{ project }}-main" + panes: + - echo "Working on {{ project }}" diff --git a/examples/lifecycle-hooks.yaml b/examples/lifecycle-hooks.yaml new file mode 100644 index 0000000000..5cfd7507e3 --- /dev/null +++ b/examples/lifecycle-hooks.yaml @@ -0,0 +1,7 @@ +session_name: lifecycle hooks +on_project_start: echo "project starting" +on_project_exit: echo "project exiting" +windows: + - window_name: main + panes: + - diff --git a/examples/pane-titles.yaml b/examples/pane-titles.yaml new file mode 100644 index 0000000000..37c5de17fb --- /dev/null +++ b/examples/pane-titles.yaml @@ -0,0 +1,15 @@ +session_name: pane titles +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - window_name: titled + panes: + - title: editor + shell_command: + - echo pane0 + - title: runner + shell_command: + - echo pane1 + - shell_command: + - echo pane2 diff --git a/examples/synchronize-shorthand.yaml b/examples/synchronize-shorthand.yaml new file mode 100644 index 0000000000..7fd507b809 --- /dev/null +++ b/examples/synchronize-shorthand.yaml @@ -0,0 +1,16 @@ +session_name: synchronize shorthand +windows: + - window_name: synced-before + synchronize: before + panes: + - echo 0 + - echo 1 + - window_name: synced-after + synchronize: after + panes: + - echo 0 + - echo 1 + - window_name: not-synced + panes: + - echo 0 + - echo 1 diff --git a/tests/docs/examples/__init__.py b/tests/docs/examples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/docs/examples/test_examples.py b/tests/docs/examples/test_examples.py new file mode 100644 index 0000000000..a141a9c7e2 --- /dev/null +++ b/tests/docs/examples/test_examples.py @@ -0,0 +1,87 @@ +"""Tests for example workspace YAML files.""" + +from __future__ import annotations + +import functools +import typing as t + +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.test.retry import retry_until + +from tests.constants import EXAMPLE_PATH +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_synchronize_shorthand(session: Session) -> None: + """Test synchronize-shorthand.yaml builds and sets synchronize-panes.""" + config = ConfigReader._from_file(EXAMPLE_PATH / "synchronize-shorthand.yaml") + config = loader.expand(config) + builder = WorkspaceBuilder(session_config=config, server=session.server) + builder.build(session=session) + + windows = session.windows + assert len(windows) == 3 + + synced_before = windows[0] + synced_after = windows[1] + not_synced = windows[2] + + assert synced_before.show_option("synchronize-panes") is True + assert synced_after.show_option("synchronize-panes") is True + assert not_synced.show_option("synchronize-panes") is not True + + +def test_lifecycle_hooks(session: Session) -> None: + """Test lifecycle-hooks.yaml loads without error.""" + config = ConfigReader._from_file(EXAMPLE_PATH / "lifecycle-hooks.yaml") + config = loader.expand(config) + builder = WorkspaceBuilder(session_config=config, server=session.server) + builder.build(session=session) + + assert len(session.windows) >= 1 + + +def test_config_templating(session: Session) -> None: + """Test config-templating.yaml renders templates and builds.""" + config = ConfigReader._from_file( + EXAMPLE_PATH / "config-templating.yaml", + template_context={"project": "myapp"}, + ) + config = loader.expand(config) + + assert config["session_name"] == "myapp" + assert config["windows"][0]["window_name"] == "myapp-main" + + builder = WorkspaceBuilder(session_config=config, server=session.server) + builder.build(session=session) + + assert len(session.windows) >= 1 + + +def test_pane_titles(session: Session) -> None: + """Test pane-titles.yaml builds with pane title options.""" + config = ConfigReader._from_file(EXAMPLE_PATH / "pane-titles.yaml") + config = loader.expand(config) + builder = WorkspaceBuilder(session_config=config, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("pane-border-status") == "top" + assert window.show_option("pane-border-format") == "#{pane_index}: #{pane_title}" + + panes = window.panes + assert len(panes) == 3 + + def check_title(p: Pane, expected: str) -> bool: + p.refresh() + return p.pane_title == expected + + assert retry_until( + functools.partial(check_title, panes[0], "editor"), + ), f"Expected title 'editor', got '{panes[0].pane_title}'" + assert retry_until( + functools.partial(check_title, panes[1], "runner"), + ), f"Expected title 'runner', got '{panes[1].pane_title}'" From d8c1ec31e3fd6c9e38da4b42aabc5862a199d4d1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 07:35:52 -0500 Subject: [PATCH 057/143] docs(comparison): Update feature tables for parity why: The comparison table still showed (none) for hooks, config keys, and CLI commands tmuxp now supports. what: - Bump tmuxp version in the comparison table - Fill in (none) cells for hooks, config keys, CLI commands --- docs/comparison.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 007df4adb7..783c9031c6 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -6,7 +6,7 @@ | | tmuxp | tmuxinator | teamocil | |---|---|---|---| -| **Version** | 1.64.0 | 3.3.7 | 1.4.2 | +| **Version** | 1.68.0 | 3.3.7 | 1.4.2 | | **Language** | Python | Ruby | Ruby | | **Min tmux** | 3.2 | 1.5+ (1.5–3.6a tested) | (not specified) | | **Config formats** | YAML, JSON | YAML (with ERB) | YAML | @@ -64,21 +64,21 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Startup window | (none; use `focus: true` on window) | `startup_window` (name or index) | (none; use `focus: true` on window) | | Startup pane | (none; use `focus: true` on pane) | `startup_pane` | (none; use `focus: true` on pane) | | Plugins | `plugins` | (none) | (none) | -| ERB/variable interpolation | (none) | Yes (`key=value` args) | (none) | +| ERB/variable interpolation | `{{ var }}` + `--set KEY=VALUE` | Yes (`key=value` args) | (none) | | YAML anchors | Yes | Yes (via `YAML.safe_load` `aliases: true`) | Yes | -| Pane titles enable | (none) | `enable_pane_titles` | (none) | -| Pane title position | (none) | `pane_title_position` | (none) | -| Pane title format | (none) | `pane_title_format` | (none) | +| Pane titles enable | `enable_pane_titles` | `enable_pane_titles` | (none) | +| Pane title position | `pane_title_position` | `pane_title_position` | (none) | +| Pane title format | `pane_title_format` | `pane_title_format` | (none) | ### Session Hooks | Hook | tmuxp | tmuxinator | teamocil | |---|---|---|---| -| Every start invocation | (none) | `on_project_start` | (none) | +| Every start invocation | `on_project_start` | `on_project_start` | (none) | | First start only | `before_script` | `on_project_first_start` | (none) | -| On reattach | Plugin: `reattach()` | `on_project_restart` | (none) | -| On exit/detach | (none) | `on_project_exit` | (none) | -| On stop/kill | (none) | `on_project_stop` | (none) | +| On reattach | `on_project_restart` + Plugin: `reattach()` | `on_project_restart` | (none) | +| On exit/detach | `on_project_exit` (tmux `client-detached` hook) | `on_project_exit` | (none) | +| On stop/kill | `on_project_stop` (via `tmuxp stop`) | `on_project_stop` | (none) | | Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) | | On window create | Plugin: `on_window_create()` | (none) | (none) | | After window done | Plugin: `after_window_finished()` | (none) | (none) | @@ -101,7 +101,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Environment vars | `environment` | (none) | (none) | | Suppress history | `suppress_history` | (none) | (none) | | Focus | `focus` | (none; use `startup_window`) | `focus` | -| Synchronize panes | (none) | `synchronize` (`true`/`before`/`after`; `true`/`before` deprecated → use `after`) | (none) | +| Synchronize panes | `synchronize` (`before`/`after`/`true`) | `synchronize` (`true`/`before`/`after`; `true`/`before` deprecated → use `after`) | (none) | | Filters (before) | (none) | (none) | `filters.before` (v0.x) | | Filters (after) | (none) | (none) | `filters.after` (v0.x) | @@ -119,7 +119,7 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Suppress history | `suppress_history` | (none) | (none) | | Focus | `focus` | (none; use `startup_pane`) | `focus` | | Shell cmd before | `shell_command_before` | (none; inherits from window/session) | (none) | -| Pane title | (none) | hash key (named pane → `select-pane -T`) | (none) | +| Pane title | `title` | hash key (named pane → `select-pane -T`) | (none) | | Width | (none) | (none) | `width` (v0.x, horizontal split %) | | Height | (none) | (none) | `height` (v0.x, vertical split %) | | Split target | (none) | (none) | `target` (v0.x) | @@ -145,12 +145,12 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Append to session | `tmuxp load --append` | `tmuxinator start --append` | (none) | | List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | | Edit config | `tmuxp edit ` | `tmuxinator edit ` | `teamocil --edit ` | -| Show/debug config | (none) | `tmuxinator debug ` | `teamocil --show` / `--debug` | -| Create new config | (none) | `tmuxinator new ` | (none) | -| Copy config | (none) | `tmuxinator copy ` | (none) | -| Delete config | (none) | `tmuxinator delete ` | (none) | +| Show/debug config | `tmuxp load --debug` | `tmuxinator debug ` | `teamocil --show` / `--debug` | +| Create new config | `tmuxp new ` | `tmuxinator new ` | (none) | +| Copy config | `tmuxp copy ` | `tmuxinator copy ` | (none) | +| Delete config | `tmuxp delete ` | `tmuxinator delete ` | (none) | | Delete all configs | (none) | `tmuxinator implode` | (none) | -| Stop/kill session | (none) | `tmuxinator stop ` | (none) | +| Stop/kill session | `tmuxp stop ` | `tmuxinator stop ` | (none) | | Stop all sessions | (none) | `tmuxinator stop-all` | (none) | | Freeze/export session | `tmuxp freeze ` | (none) | (none) | | Convert format | `tmuxp convert ` | (none) | (none) | @@ -158,9 +158,9 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Search workspaces | `tmuxp search ` | (none) | (none) | | Python shell | `tmuxp shell` | (none) | (none) | | Debug/system info | `tmuxp debug-info` | `tmuxinator doctor` | (none) | -| Use here (current window) | (none) | (none) | `teamocil --here` | -| Skip pre_window | (none) | `--no-pre-window` | (none) | -| Pass variables | (none) | `key=value` args | (none) | +| Use here (current window) | `tmuxp load --here` | (none) | `teamocil --here` | +| Skip pre_window | `--no-shell-command-before` | `--no-pre-window` | (none) | +| Pass variables | `--set KEY=VALUE` | `key=value` args | (none) | | Suppress version warning | (none) | `--suppress-tmux-version-warning` | (none) | | Custom config path | `tmuxp load /path/to/file` | `-p /path/to/file` | `--layout /path/to/file` | | Load multiple configs | `tmuxp load f1 f2 ...` (all but last detached) | (none) | (none) | From 2e902ade913fe4d570b04752907e34c0abd8aeff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 07:35:57 -0500 Subject: [PATCH 058/143] docs(index,import): Add comparison to toctree and update import notes why: Comparison page needs discovery path and importers need improvement notes. what: - Add comparison to docs/index.md Project toctree - Add importer improvement notes for teamocil and tmuxinator --- docs/cli/import.md | 18 ++++++++++++++++++ docs/index.md | 1 + tests/docs/examples/__init__.py | 1 + tests/docs/examples/test_examples.py | 1 - 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/cli/import.md b/docs/cli/import.md index 1e5d191ff8..8bf42e79cb 100644 --- a/docs/cli/import.md +++ b/docs/cli/import.md @@ -38,6 +38,14 @@ $ tmuxp import teamocil /path/to/file.json ```` +### Supported teamocil features + +The teamocil importer handles: + +- **v1.x format** — `windows` at top level with `commands` key in panes +- **Focus** — `focus: true` on windows and panes is preserved +- **Window options** — `options` on windows are passed through + (import-tmuxinator)= ## From tmuxinator @@ -71,3 +79,13 @@ $ tmuxp import tmuxinator /path/to/file.json ``` ```` + +### Supported tmuxinator features + +The tmuxinator importer maps: + +- **Hook mapping** — `pre` maps to `on_project_start`, `pre_window` maps to `shell_command_before` +- **CLI args** — `cli_args` values (`-f`, `-S`, `-L`) are parsed into tmuxp config equivalents +- **Synchronize** — `synchronize` window key is converted +- **Startup focus** — `startup_window` / `startup_pane` convert to `focus: true` +- **Named panes** — hash-key pane syntax converts to `title` on the pane diff --git a/docs/index.md b/docs/index.md index ddacba1bb4..cf88e58500 100644 --- a/docs/index.md +++ b/docs/index.md @@ -102,6 +102,7 @@ history about_tmux migration +Comparison glossary MCP GitHub diff --git a/tests/docs/examples/__init__.py b/tests/docs/examples/__init__.py index e69de29bb2..4b6f66939d 100644 --- a/tests/docs/examples/__init__.py +++ b/tests/docs/examples/__init__.py @@ -0,0 +1 @@ +"""Tests for example workspace YAML files.""" diff --git a/tests/docs/examples/test_examples.py b/tests/docs/examples/test_examples.py index a141a9c7e2..3c2fbbb5e3 100644 --- a/tests/docs/examples/test_examples.py +++ b/tests/docs/examples/test_examples.py @@ -3,7 +3,6 @@ from __future__ import annotations import functools -import typing as t from libtmux.pane import Pane from libtmux.session import Session From 3280889416451c7a48f912a03786d1ea956af2e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 16 Mar 2026 21:28:31 -0500 Subject: [PATCH 059/143] cli(stop,new,copy,delete[help]): Show help when called with no arguments why: These commands showed a terse argparse error on missing args instead of the full subcommand help page, unlike the search command. what: - Make positional args optional via nargs="?"/"*" in subparser setup - Stash subparser print_help via set_defaults for dispatch access - Add no-args guards in cli() dispatch using args.print_help() - Add parametrized test for all four commands --- src/tmuxp/cli/__init__.py | 12 ++++++++++++ src/tmuxp/cli/copy.py | 11 +++++++++++ src/tmuxp/cli/delete.py | 9 ++++++++- src/tmuxp/cli/new.py | 9 +++++++++ src/tmuxp/cli/stop.py | 1 + tests/cli/test_help_examples.py | 12 ++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 5cd38cfc4b..9d333af8e7 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -420,12 +420,18 @@ def cli(_args: list[str] | None = None) -> None: color=args.color, ) elif args.subparser_name == "new": + if not args.workspace_name: + args.print_help() + return command_new( workspace_name=args.workspace_name, parser=parser, color=args.color, ) elif args.subparser_name == "copy": + if not args.source or not args.destination: + args.print_help() + return command_copy( source=args.source, destination=args.destination, @@ -433,6 +439,9 @@ def cli(_args: list[str] | None = None) -> None: color=args.color, ) elif args.subparser_name == "delete": + if not args.workspace_names: + args.print_help() + return command_delete( workspace_names=args.workspace_names, answer_yes=args.answer_yes, @@ -445,6 +454,9 @@ def cli(_args: list[str] | None = None) -> None: parser=parser, ) elif args.subparser_name == "stop": + if not args.session_name: + args.print_help() + return command_stop( args=CLIStopNamespace(**vars(args)), parser=parser, diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py index 8600766c29..d29878a48d 100644 --- a/src/tmuxp/cli/copy.py +++ b/src/tmuxp/cli/copy.py @@ -52,19 +52,30 @@ def create_copy_subparser( >>> args = parser.parse_args(["src", "dst"]) >>> args.source, args.destination ('src', 'dst') + + No arguments yields ``None``: + + >>> args = parser.parse_args([]) + >>> args.source is None and args.destination is None + True """ parser.add_argument( dest="source", metavar="source", + nargs="?", + default=None, type=str, help="source workspace name or file path.", ) parser.add_argument( dest="destination", metavar="destination", + nargs="?", + default=None, type=str, help="destination workspace name or file path.", ) + parser.set_defaults(print_help=parser.print_help) return parser diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py index 393fb26b64..6c559a16f0 100644 --- a/src/tmuxp/cli/delete.py +++ b/src/tmuxp/cli/delete.py @@ -53,11 +53,17 @@ def create_delete_subparser( ['proj1', 'proj2'] >>> args.answer_yes True + + No arguments yields an empty list: + + >>> args = parser.parse_args([]) + >>> args.workspace_names + [] """ parser.add_argument( dest="workspace_names", metavar="workspace-name", - nargs="+", + nargs="*", type=str, help="workspace name(s) or file path(s) to delete.", ) @@ -68,6 +74,7 @@ def create_delete_subparser( action="store_true", help="skip confirmation prompt.", ) + parser.set_defaults(print_help=parser.print_help) return parser diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py index d419ff1778..c60d820cf0 100644 --- a/src/tmuxp/cli/new.py +++ b/src/tmuxp/cli/new.py @@ -59,13 +59,22 @@ def create_new_subparser( >>> args = parser.parse_args(["myproject"]) >>> args.workspace_name 'myproject' + + No arguments yields ``None``: + + >>> args = parser.parse_args([]) + >>> args.workspace_name is None + True """ parser.add_argument( dest="workspace_name", metavar="workspace-name", + nargs="?", + default=None, type=str, help="name for the new workspace config.", ) + parser.set_defaults(print_help=parser.print_help) return parser diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py index 549d67f1a0..27aa9e05d2 100644 --- a/src/tmuxp/cli/stop.py +++ b/src/tmuxp/cli/stop.py @@ -75,6 +75,7 @@ def create_stop_subparser( metavar="socket-name", help="pass-through for tmux -L", ) + parser.set_defaults(print_help=parser.print_help) return parser diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index dc2c15a231..9261390ee7 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -300,6 +300,18 @@ def test_search_no_args_shows_help() -> None: assert result.returncode == 0 +@pytest.mark.parametrize("subcommand", ["stop", "new", "copy", "delete"]) +def test_new_commands_no_args_shows_help(subcommand: str) -> None: + """Running new commands with no args shows help.""" + result = subprocess.run( + ["tmuxp", subcommand], + capture_output=True, + text=True, + ) + assert f"usage: tmuxp {subcommand}" in result.stdout + assert result.returncode == 0 + + def test_main_help_example_sections_have_examples_suffix() -> None: """Main --help should have section headings ending with 'examples:'.""" help_text = _get_help_text() From a3442be2aed0f51e7739c1f99ce5220d5edf07c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 09:25:49 -0500 Subject: [PATCH 060/143] fix(builder[here]): Use shlex.quote for start_directory in --here mode why: Shell metacharacters in start_directory could inject arbitrary commands via send_keys, unlike the safe structured API in normal path. what: - Quote start_directory with shlex.quote before sending cd in --here mode - Add test covering directory names with quotes, spaces, and metacharacters --- src/tmuxp/workspace/builder.py | 3 ++- tests/workspace/test_builder.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 917c7b119d..9f8fbe6d31 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -4,6 +4,7 @@ import logging import os +import shlex import shutil import time import typing as t @@ -675,7 +676,7 @@ def iter_create_windows( active_pane = window.active_pane if active_pane is not None: active_pane.send_keys( - f'cd "{start_directory}"', + f"cd {shlex.quote(start_directory)}", enter=True, ) else: diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index fe8d5ba883..e170477059 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -480,6 +480,38 @@ def test_here_mode( assert new_window.window_id != original_window_id +def test_here_mode_start_directory_special_chars( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """Test --here mode with special characters in start_directory.""" + test_dir = tmp_path / "dir with 'quotes' & spaces" + test_dir.mkdir() + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/here_mode.yaml"), + ) + workspace = loader.expand(workspace) + workspace["start_directory"] = str(test_dir) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + reused_window = session.windows[0] + pane = reused_window.active_pane + assert pane is not None + + expected_path = os.path.realpath(str(test_dir)) + + def check_path() -> bool: + return pane.pane_current_path == expected_path + + assert retry_until(check_path), ( + f"Expected {expected_path}, got {pane.pane_current_path}" + ) + + def test_window_shell( session: Session, ) -> None: From af2a3738cfb71c81386ff8a7066bc5754cbc0e78 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 09:31:08 -0500 Subject: [PATCH 061/143] docs(cli[copy,delete,new,stop]): Add doctests to command_* entrypoints why: All functions must have working doctests per project standards. what: - Add doctest to command_copy using tmp_path and TMUXP_CONFIGDIR - Add doctest to command_delete with answer_yes=True to skip prompt - Add doctest to command_new with EDITOR=true to skip interactive edit - Add doctest to command_stop using server fixture for real tmux session - Add tmuxp.cli.stop to DOCTEST_NEEDS_TMUX in conftest.py --- conftest.py | 1 + src/tmuxp/cli/copy.py | 14 +++++++++++++- src/tmuxp/cli/delete.py | 14 +++++++++++++- src/tmuxp/cli/new.py | 12 +++++++++++- src/tmuxp/cli/stop.py | 16 +++++++++++++++- 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/conftest.py b/conftest.py index 1f0583439e..9f5e9fa64c 100644 --- a/conftest.py +++ b/conftest.py @@ -114,6 +114,7 @@ def socket_name(request: pytest.FixtureRequest) -> str: # Modules that actually need tmux fixtures in their doctests DOCTEST_NEEDS_TMUX = { + "tmuxp.cli.stop", "tmuxp.workspace.builder", } diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py index d29878a48d..8d3e79438d 100644 --- a/src/tmuxp/cli/copy.py +++ b/src/tmuxp/cli/copy.py @@ -85,7 +85,19 @@ def command_copy( parser: argparse.ArgumentParser | None = None, color: CLIColorModeLiteral | None = None, ) -> None: - """Entrypoint for ``tmuxp copy``, copy a workspace config to a new name.""" + r"""Entrypoint for ``tmuxp copy``, copy a workspace config to a new name. + + Examples + -------- + >>> monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + >>> _ = (tmp_path / "src.yaml").write_text( + ... "session_name: s\nwindows:\n - window_name: m\n panes:\n -\n" + ... ) + >>> command_copy("src", "dst", color="never") # doctest: +ELLIPSIS + Copied ...src.yaml ... ...dst.yaml + >>> (tmp_path / "dst.yaml").exists() + True + """ color_mode = get_color_mode(color) colors = Colors(color_mode) diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py index 6c559a16f0..1742c04a8a 100644 --- a/src/tmuxp/cli/delete.py +++ b/src/tmuxp/cli/delete.py @@ -84,7 +84,19 @@ def command_delete( parser: argparse.ArgumentParser | None = None, color: CLIColorModeLiteral | None = None, ) -> None: - """Entrypoint for ``tmuxp delete``, remove workspace config files.""" + r"""Entrypoint for ``tmuxp delete``, remove workspace config files. + + Examples + -------- + >>> monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + >>> _ = (tmp_path / "doomed.yaml").write_text( + ... "session_name: d\nwindows:\n - window_name: m\n panes:\n -\n" + ... ) + >>> command_delete(["doomed"], answer_yes=True, color="never") # doctest: +ELLIPSIS + Deleted ...doomed.yaml + >>> (tmp_path / "doomed.yaml").exists() + False + """ color_mode = get_color_mode(color) colors = Colors(color_mode) diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py index c60d820cf0..acd3cc1188 100644 --- a/src/tmuxp/cli/new.py +++ b/src/tmuxp/cli/new.py @@ -83,7 +83,17 @@ def command_new( parser: argparse.ArgumentParser | None = None, color: CLIColorModeLiteral | None = None, ) -> None: - """Entrypoint for ``tmuxp new``, create a new workspace config from template.""" + """Entrypoint for ``tmuxp new``, create a new workspace config from template. + + Examples + -------- + >>> monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + >>> monkeypatch.setenv("EDITOR", "true") + >>> command_new("myproject", color="never") # doctest: +ELLIPSIS + Created ...myproject.yaml + >>> (tmp_path / "myproject.yaml").exists() + True + """ color_mode = get_color_mode(color) colors = Colors(color_mode) diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py index 27aa9e05d2..60ad2a97ed 100644 --- a/src/tmuxp/cli/stop.py +++ b/src/tmuxp/cli/stop.py @@ -83,7 +83,21 @@ def command_stop( args: CLIStopNamespace, parser: argparse.ArgumentParser | None = None, ) -> None: - """Entrypoint for ``tmuxp stop``, kill a tmux session.""" + """Entrypoint for ``tmuxp stop``, kill a tmux session. + + Examples + -------- + >>> test_session = server.new_session(session_name="doctest_stop") + >>> args = CLIStopNamespace() + >>> args.session_name = "doctest_stop" + >>> args.color = "never" + >>> args.socket_name = server.socket_name + >>> args.socket_path = None + >>> command_stop(args) # doctest: +ELLIPSIS + Stopped doctest_stop + >>> server.sessions.get(session_name="doctest_stop", default=None) is None + True + """ color_mode = get_color_mode(args.color) colors = Colors(color_mode) From bdb8764b87e622f5a65da9c82b3101c9873c8cfa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 09:33:03 -0500 Subject: [PATCH 062/143] docs(cli[load]): Add doctest to _load_here_in_current_session why: Function added on parity branch without a doctest. what: - Add callable() doctest following _dispatch_build pattern --- src/tmuxp/cli/load.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 60b0304987..a52e8de6de 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -329,6 +329,12 @@ def _load_here_in_current_session(builder: WorkspaceBuilder) -> None: Parameters ---------- builder: :class:`workspace.builder.WorkspaceBuilder` + + Examples + -------- + >>> from tmuxp.cli.load import _load_here_in_current_session + >>> callable(_load_here_in_current_session) + True """ current_attached_session = builder.find_current_attached_session() builder.build(current_attached_session, here=True) From 019ed633a2a618ad6474b1e3d1e4395b922a3311 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 09:35:16 -0500 Subject: [PATCH 063/143] feat(importers[tmuxinator]): Warn when explicit socket_name overrides cli_args -L why: cli_args -L and an explicit socket_name key can disagree; surface the conflict instead of silently preferring the explicit value. what: - Add logger.warning when socket_name values differ - Add test for conflict warning and no-conflict case --- src/tmuxp/workspace/importers.py | 12 ++++++- tests/workspace/test_import_tmuxinator.py | 38 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 5392248c80..e42163f371 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -103,7 +103,17 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: tmuxp_workspace[flag_map[token]] = value if "socket_name" in workspace_dict: - tmuxp_workspace["socket_name"] = workspace_dict["socket_name"] + explicit_name = workspace_dict["socket_name"] + if ( + "socket_name" in tmuxp_workspace + and tmuxp_workspace["socket_name"] != explicit_name + ): + logger.warning( + "explicit socket_name %s overrides -L %s from cli_args", + explicit_name, + tmuxp_workspace["socket_name"], + ) + tmuxp_workspace["socket_name"] = explicit_name tmuxp_workspace["windows"] = [] diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index d3becfe4f9..db41abed36 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -433,3 +433,41 @@ def test_import_tmuxinator_named_pane_in_list_window() -> None: assert panes[0] == "vim" assert panes[1] == {"shell_command": ["rails s"], "title": "server"} assert panes[2] == "top" + + +def test_import_tmuxinator_socket_name_conflict_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Warn when explicit socket_name overrides -L from cli_args.""" + workspace = { + "name": "conflict", + "cli_args": "-L from_cli", + "socket_name": "explicit", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_tmuxinator(workspace) + + assert result["socket_name"] == "explicit" + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) == 1 + assert "explicit" in warning_records[0].message + assert "from_cli" in warning_records[0].message + + +def test_import_tmuxinator_socket_name_same_no_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """No warning when cli_args -L and explicit socket_name match.""" + workspace = { + "name": "same", + "cli_args": "-L same_socket", + "socket_name": "same_socket", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_tmuxinator(workspace) + + assert result["socket_name"] == "same_socket" + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) == 0 From 38f8ab065261c9dc3f8655fb32d5e1e281f09705 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 14:21:58 -0500 Subject: [PATCH 064/143] fix(util[run_hook_commands]): Add 120s timeout to hook subprocess why: Hook commands with no timeout can block tmuxp indefinitely. what: - Add timeout=120 to subprocess.run in run_hook_commands - Catch TimeoutExpired and log warning --- src/tmuxp/util.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 8f2f53df15..9012f3b29b 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -141,14 +141,19 @@ def run_hook_commands( if not joined.strip(): return logger.debug("running hook commands %s", joined) - result = subprocess.run( - joined, - shell=True, - cwd=cwd, - check=False, - capture_output=True, - text=True, - ) + try: + result = subprocess.run( + joined, + shell=True, + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + logger.warning("hook command timed out after 120s: %s", joined) + return if result.returncode != 0: logger.warning( "hook command failed with exit code %d", From 7297b37dbcb912c55224d6fa9ee4a804dddd7ca1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:42:30 -0500 Subject: [PATCH 065/143] fix(importers[tmuxinator]): Join list pre values before assigning to before_script why: List pre values (e.g., ['echo one', 'echo two']) were assigned directly to before_script, which crashes in expand() because expandshell() expects a string, not a list. what: - Join list pre with '; ' before assigning to before_script - Matches tmuxinator's hooks.rb which joins arrays with '; ' - Add test verifying list pre survives expand() without TypeError --- src/tmuxp/workspace/importers.py | 13 ++++++++++--- tests/workspace/test_import_tmuxinator.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index e42163f371..0e93ec467f 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -126,19 +126,26 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ) if "pre" in workspace_dict and pre_window_val is not None: - tmuxp_workspace["before_script"] = workspace_dict["pre"] + pre_val = workspace_dict["pre"] + if isinstance(pre_val, list): + tmuxp_workspace["before_script"] = "; ".join(pre_val) + else: + tmuxp_workspace["before_script"] = pre_val if isinstance(pre_window_val, str): tmuxp_workspace["shell_command_before"] = [pre_window_val] else: tmuxp_workspace["shell_command_before"] = pre_window_val elif "pre" in workspace_dict: - if isinstance(workspace_dict["pre"], list): + pre_val = workspace_dict["pre"] + if isinstance(pre_val, list): logger.info( "multi-command pre list mapped to before_script; " "consider splitting into before_script and shell_command_before", ) - tmuxp_workspace["before_script"] = workspace_dict["pre"] + tmuxp_workspace["before_script"] = "; ".join(pre_val) + else: + tmuxp_workspace["before_script"] = pre_val if "rbenv" in workspace_dict: if "shell_command_before" not in tmuxp_workspace: diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index db41abed36..e5f3124a63 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -471,3 +471,19 @@ def test_import_tmuxinator_socket_name_same_no_warning( assert result["socket_name"] == "same_socket" warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert len(warning_records) == 0 + + +def test_import_tmuxinator_pre_list_joined_for_before_script() -> None: + """List pre values are joined with '; ' so expand() doesn't crash.""" + workspace = { + "name": "pre-list", + "windows": [{"editor": "vim"}], + "pre": ["echo one", "echo two"], + } + result = importers.import_tmuxinator(workspace) + assert result["before_script"] == "echo one; echo two" + + # Verify it survives expand() without TypeError + from tmuxp.workspace import loader + + loader.expand(result) From 2b72ab7452bd6cc241c1462ae7efb2a1c6632d0c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:44:22 -0500 Subject: [PATCH 066/143] fix(builder[here]): Clean existing panes before rebuilding in --here mode why: --here reused the active window but never removed existing panes. iter_create_panes then added new panes on top, so a 2-pane window with a 1-pane config ended up with 2 panes instead of 1. what: - Kill all panes except the active one before yielding the reused window - Add test starting with 2-pane window to verify cleanup --- src/tmuxp/workspace/builder.py | 6 ++++++ tests/workspace/test_builder.py | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 9f8fbe6d31..b165b551dd 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -667,6 +667,12 @@ def iter_create_windows( if window_name: window.rename_window(window_name) + # Remove extra panes so iter_create_panes starts clean + _active_pane = window.active_pane + for _p in list(window.panes): + if _p != _active_pane: + _p.kill() + start_directory = window_config.get("start_directory", None) panes = window_config["panes"] if panes and "start_directory" in panes[0]: diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index e170477059..8d97e3ece0 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -512,6 +512,29 @@ def check_path() -> bool: ) +def test_here_mode_cleans_existing_panes( + session: Session, +) -> None: + """Test --here mode removes extra panes before rebuilding.""" + # Start with a 2-pane window + original_window = session.active_window + original_window.split() + assert len(original_window.panes) == 2 + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/here_mode.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + session.refresh() + reused_window = session.windows[0] + # Config has 1 pane in first window — should be exactly 1, not 3 + assert len(reused_window.panes) == 1 + + def test_window_shell( session: Session, ) -> None: From e1ccad4bc469754307732b9dc9fc7eea50d4c068 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:47:10 -0500 Subject: [PATCH 067/143] fix(cli[load]): Clean up debug handler on early parse/expand failures why: The --debug handler was installed before YAML parsing but cleanup was only reached via conditional paths. A parse error leaked the handler. what: - Wrap config parse and expand in try/except that calls _cleanup_debug - Ensures handler is removed even if _from_file or expand raises --- src/tmuxp/cli/load.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index a52e8de6de..2a33db5aa5 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -635,22 +635,26 @@ def _cleanup_debug() -> None: ) # ConfigReader allows us to open a yaml or json file as a dict - if template_context: - raw_workspace = ( - config_reader.ConfigReader._from_file( - workspace_file, - template_context=template_context, + try: + if template_context: + raw_workspace = ( + config_reader.ConfigReader._from_file( + workspace_file, + template_context=template_context, + ) + or {} ) - or {} - ) - else: - raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} + else: + raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} - # shapes workspaces relative to config / profile file location - expanded_workspace = loader.expand( - raw_workspace, - cwd=os.path.dirname(workspace_file), - ) + # shapes workspaces relative to config / profile file location + expanded_workspace = loader.expand( + raw_workspace, + cwd=os.path.dirname(workspace_file), + ) + except Exception: + _cleanup_debug() + raise # Overridden session name if new_session_name: From 00b2b98863b4dbb84f0ebd8162663583b5757a07 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:50:35 -0500 Subject: [PATCH 068/143] fix(cli[new]): Split EDITOR env var with shlex for flag support why: EDITOR='code -w' passed as a single string to subprocess.call raised FileNotFoundError because the space was part of the command name. what: - Use shlex.split(sys_editor) to split editor command and flags - Add test with EDITOR containing flags --- src/tmuxp/cli/new.py | 3 ++- tests/cli/test_new.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py index acd3cc1188..71ebfd6995 100644 --- a/src/tmuxp/cli/new.py +++ b/src/tmuxp/cli/new.py @@ -4,6 +4,7 @@ import logging import os +import shlex import subprocess import typing as t @@ -122,4 +123,4 @@ def command_new( ) sys_editor = os.environ.get("EDITOR", "vim") - subprocess.call([sys_editor, workspace_path]) + subprocess.call([*shlex.split(sys_editor), workspace_path]) diff --git a/tests/cli/test_new.py b/tests/cli/test_new.py index 773ff45a72..482dfb9610 100644 --- a/tests/cli/test_new.py +++ b/tests/cli/test_new.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pathlib import typing as t import pytest @@ -96,3 +97,17 @@ def test_new_creates_workspace_dir( assert config_dir.exists() workspace_path = config_dir / "myproject.yaml" assert workspace_path.exists() + + +def test_new_editor_with_flags( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that EDITOR with flags (e.g., 'code -w') is split correctly.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + monkeypatch.setenv("EDITOR", "true --ignored-flag") + + cli.cli(["new", "flagtest"]) + + workspace_path = tmp_path / "flagtest.yaml" + assert workspace_path.exists() From 8c671d351de4e67568470398e6e8d1189af72239 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 16:52:31 -0500 Subject: [PATCH 069/143] fix(importers[tmuxinator]): Pass through pane title and lifecycle hook keys why: The importer silently dropped enable_pane_titles, pane_title_*, and on_project_* keys even though both tmuxinator and tmuxp support them. what: - Add passthrough for enable_pane_titles, pane_title_position, pane_title_format - Add passthrough for on_project_start, restart, exit, stop - Preserve on_project_first_start with warning (not yet supported in builder) - Add tests verifying passthrough and warning behavior --- src/tmuxp/workspace/importers.py | 22 ++++++++++++ tests/workspace/test_import_tmuxinator.py | 41 +++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 0e93ec467f..c0b0824911 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -115,6 +115,28 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ) tmuxp_workspace["socket_name"] = explicit_name + # Passthrough keys supported by both tmuxinator and tmuxp + for _pass_key in ( + "enable_pane_titles", + "pane_title_position", + "pane_title_format", + "on_project_start", + "on_project_restart", + "on_project_exit", + "on_project_stop", + ): + if _pass_key in workspace_dict: + tmuxp_workspace[_pass_key] = workspace_dict[_pass_key] + + if "on_project_first_start" in workspace_dict: + logger.warning( + "on_project_first_start is not yet supported by tmuxp; " + "consider using on_project_start instead", + ) + tmuxp_workspace["on_project_first_start"] = workspace_dict[ + "on_project_first_start" + ] + tmuxp_workspace["windows"] = [] if "tabs" in workspace_dict: diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index e5f3124a63..8db5ae0a03 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -487,3 +487,44 @@ def test_import_tmuxinator_pre_list_joined_for_before_script() -> None: from tmuxp.workspace import loader loader.expand(result) + + +def test_import_tmuxinator_passthrough_pane_titles_and_hooks() -> None: + """Pane title and lifecycle hook keys are copied through to tmuxp config.""" + workspace = { + "name": "passthrough", + "enable_pane_titles": True, + "pane_title_position": "bottom", + "pane_title_format": "#{pane_index}", + "on_project_start": "echo starting", + "on_project_restart": "echo restarting", + "on_project_exit": "echo exiting", + "on_project_stop": "echo stopping", + "windows": [{"editor": "vim"}], + } + result = importers.import_tmuxinator(workspace) + + assert result["enable_pane_titles"] is True + assert result["pane_title_position"] == "bottom" + assert result["pane_title_format"] == "#{pane_index}" + assert result["on_project_start"] == "echo starting" + assert result["on_project_restart"] == "echo restarting" + assert result["on_project_exit"] == "echo exiting" + assert result["on_project_stop"] == "echo stopping" + + +def test_import_tmuxinator_on_project_first_start_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Warn when on_project_first_start is used (not yet supported by tmuxp).""" + workspace = { + "name": "first-start", + "on_project_first_start": "rake db:create", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_tmuxinator(workspace) + + assert result["on_project_first_start"] == "rake db:create" + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("on_project_first_start" in r.message for r in warning_records) From 63d12168e6452f918a6fc648687b6649d42dcaf1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 18:25:08 -0500 Subject: [PATCH 070/143] fix(importers[tmuxinator]): Warn on silently dropped tmux_command, attach, post keys why: These tmuxinator YAML keys were silently dropped with no warning, unlike other unsupported keys which already log warnings. Users migrating from tmuxinator with tmux_command: wemux or attach: false got no feedback. what: - Log WARNING for tmux_command (no custom binary support in tmuxp) - Log WARNING for attach (use tmuxp load -d instead) - Log WARNING for post (deprecated; use on_project_exit instead) - Add parametrized tests with NamedTuple fixtures --- src/tmuxp/workspace/importers.py | 14 +++++++ tests/workspace/test_import_tmuxinator.py | 51 +++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index c0b0824911..6e8b78b2ca 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -137,6 +137,20 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: "on_project_first_start" ] + # Warn on tmuxinator keys that have no tmuxp equivalent + _TMUXINATOR_UNMAPPED_KEYS = { + "tmux_command": "custom tmux binary is not supported; tmuxp always uses 'tmux'", + "attach": "use 'tmuxp load -d' for detached mode instead", + "post": "deprecated in tmuxinator; use on_project_exit instead", + } + for _ukey, _uhint in _TMUXINATOR_UNMAPPED_KEYS.items(): + if _ukey in workspace_dict: + logger.warning( + "tmuxinator key %r is not supported by tmuxp: %s", + _ukey, + _uhint, + ) + tmuxp_workspace["windows"] = [] if "tabs" in workspace_dict: diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 8db5ae0a03..8e0196b208 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -528,3 +528,54 @@ def test_import_tmuxinator_on_project_first_start_warns( assert result["on_project_first_start"] == "rake db:create" warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert any("on_project_first_start" in r.message for r in warning_records) + + +class UnmappedKeyFixture(t.NamedTuple): + """Fixture for tmuxinator keys with no tmuxp equivalent.""" + + test_id: str + key: str + value: t.Any + + +UNMAPPED_KEY_FIXTURES: list[UnmappedKeyFixture] = [ + UnmappedKeyFixture( + test_id="tmux_command", + key="tmux_command", + value="wemux", + ), + UnmappedKeyFixture( + test_id="attach", + key="attach", + value=False, + ), + UnmappedKeyFixture( + test_id="post", + key="post", + value="echo done", + ), +] + + +@pytest.mark.parametrize( + list(UnmappedKeyFixture._fields), + UNMAPPED_KEY_FIXTURES, + ids=[f.test_id for f in UNMAPPED_KEY_FIXTURES], +) +def test_import_tmuxinator_warns_on_unmapped_key( + caplog: pytest.LogCaptureFixture, + test_id: str, + key: str, + value: t.Any, +) -> None: + """Unmapped tmuxinator keys log a warning instead of being silently dropped.""" + workspace = { + "name": "unmapped-test", + "windows": [{"editor": "vim"}], + key: value, + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any(key in r.message for r in warning_records) From f0afc96e0386e435a35c38e0039489a0dc381e5d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 18:27:15 -0500 Subject: [PATCH 071/143] fix(loader[expand]): Validate pane_title_position against top/bottom/off why: tmuxinator validates pane_title_position against ["top","bottom","off"] (project.rb:472) but tmuxp passed any value through to tmux's pane-border-status, causing cryptic tmux errors for invalid values. what: - Validate position against {"top", "bottom", "off"} - Log WARNING and default to "top" for invalid values - Add parametrized tests with NamedTuple fixtures --- src/tmuxp/workspace/loader.py | 9 +++++ tests/workspace/test_config.py | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 499c5cba95..8f8b7401a8 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -248,8 +248,17 @@ def expand( workspace_dict["shell_command_after"] = expand_cmd(shell_command_after) # Desugar pane title session-level config into per-window options + _VALID_PANE_TITLE_POSITIONS = {"top", "bottom", "off"} if workspace_dict.get("enable_pane_titles") and "windows" in workspace_dict: position = workspace_dict.pop("pane_title_position", "top") + if position not in _VALID_PANE_TITLE_POSITIONS: + logger.warning( + "invalid pane_title_position %r, expected one of %s; " + "defaulting to 'top'", + position, + _VALID_PANE_TITLE_POSITIONS, + ) + position = "top" fmt = workspace_dict.pop( "pane_title_format", "#{pane_index}: #{pane_title}", diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 5a66006741..2faf7d84db 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -497,6 +497,74 @@ def test_expand_pane_titles_defaults() -> None: ) +class PaneTitlePositionFixture(t.NamedTuple): + """Fixture for pane_title_position validation.""" + + test_id: str + position: str + expected_position: str + expect_warning: bool + + +PANE_TITLE_POSITION_FIXTURES: list[PaneTitlePositionFixture] = [ + PaneTitlePositionFixture( + test_id="top", + position="top", + expected_position="top", + expect_warning=False, + ), + PaneTitlePositionFixture( + test_id="bottom", + position="bottom", + expected_position="bottom", + expect_warning=False, + ), + PaneTitlePositionFixture( + test_id="off", + position="off", + expected_position="off", + expect_warning=False, + ), + PaneTitlePositionFixture( + test_id="invalid-falls-back-to-top", + position="invalid_value", + expected_position="top", + expect_warning=True, + ), +] + + +@pytest.mark.parametrize( + list(PaneTitlePositionFixture._fields), + PANE_TITLE_POSITION_FIXTURES, + ids=[f.test_id for f in PANE_TITLE_POSITION_FIXTURES], +) +def test_expand_pane_title_position_validation( + caplog: pytest.LogCaptureFixture, + test_id: str, + position: str, + expected_position: str, + expect_warning: bool, +) -> None: + """Invalid pane_title_position values default to 'top' with a warning.""" + workspace: dict[str, t.Any] = { + "session_name": "pos-test", + "enable_pane_titles": True, + "pane_title_position": position, + "windows": [{"window_name": "main", "panes": [{"shell_command": "echo hi"}]}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.loader"): + result = loader.expand(workspace) + + assert result["windows"][0]["options"]["pane-border-status"] == expected_position + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + if expect_warning: + assert any("pane_title_position" in r.message for r in warning_records) + else: + assert not any("pane_title_position" in r.message for r in warning_records) + + def test_expand_logs_debug( tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, From 51ed7e134cbb37a8505454ceef345c2ab3d74e20 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 18:29:22 -0500 Subject: [PATCH 072/143] test(cli[load]): Assert --no-shell-command-before actually strips commands why: test_load_workspace_no_shell_command_before had expect_before_cmd parametrized but never used in assertions. It only checked session.name, passing trivially even if the flag was broken. what: - Capture pane output and verify __BEFORE__ presence based on expect_before_cmd - Use retry_until for positive case, time.sleep+assert for negative case --- tests/cli/test_load.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index d1d11ba478..3ccee19b2e 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -949,6 +949,24 @@ def test_load_workspace_no_shell_command_before( assert isinstance(session, Session) assert session.name == "scb_test" + window = session.active_window + assert window is not None + pane = window.active_pane + assert pane is not None + + from libtmux.test.retry import retry_until + + if expect_before_cmd: + assert retry_until( + lambda: "__BEFORE__" in "\n".join(pane.capture_pane()), + seconds=5, + ) + else: + import time + + time.sleep(1) + assert "__BEFORE__" not in "\n".join(pane.capture_pane()) + def test_load_no_shell_command_before_strips_all_levels( tmp_path: pathlib.Path, From 821eafd15d229ef04a09d708c58e1ade02703faa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 18:31:15 -0500 Subject: [PATCH 073/143] fix(cli[new]): Handle missing EDITOR binary gracefully why: subprocess.call raised unhandled FileNotFoundError when EDITOR was set to a nonexistent binary, crashing after the workspace file was already created. what: - Catch FileNotFoundError and show helpful error with colors - Replace single test with parametrized NamedTuple fixture covering valid editor, editor with flags, and missing editor --- src/tmuxp/cli/new.py | 9 +++++++- tests/cli/test_new.py | 52 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py index 71ebfd6995..22afbd5da5 100644 --- a/src/tmuxp/cli/new.py +++ b/src/tmuxp/cli/new.py @@ -123,4 +123,11 @@ def command_new( ) sys_editor = os.environ.get("EDITOR", "vim") - subprocess.call([*shlex.split(sys_editor), workspace_path]) + try: + subprocess.call([*shlex.split(sys_editor), workspace_path]) + except FileNotFoundError: + tmuxp_echo( + colors.error("Editor not found: ") + + colors.info(sys_editor) + + colors.muted(" (set $EDITOR to a valid editor)"), + ) diff --git a/tests/cli/test_new.py b/tests/cli/test_new.py index 482dfb9610..4fcc2db728 100644 --- a/tests/cli/test_new.py +++ b/tests/cli/test_new.py @@ -99,15 +99,57 @@ def test_new_creates_workspace_dir( assert workspace_path.exists() -def test_new_editor_with_flags( +class EditorFixture(t.NamedTuple): + """Fixture for EDITOR environment variable handling.""" + + test_id: str + editor: str + expect_error_output: bool + + +EDITOR_FIXTURES: list[EditorFixture] = [ + EditorFixture( + test_id="valid-editor", + editor="true", + expect_error_output=False, + ), + EditorFixture( + test_id="editor-with-flags", + editor="true --ignored-flag", + expect_error_output=False, + ), + EditorFixture( + test_id="missing-editor", + editor="nonexistent_editor_binary_xyz", + expect_error_output=True, + ), +] + + +@pytest.mark.parametrize( + list(EditorFixture._fields), + EDITOR_FIXTURES, + ids=[f.test_id for f in EDITOR_FIXTURES], +) +def test_new_editor_handling( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + editor: str, + expect_error_output: bool, ) -> None: - """Test that EDITOR with flags (e.g., 'code -w') is split correctly.""" + """Test EDITOR handling: flags, missing binary, valid editor.""" monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) - monkeypatch.setenv("EDITOR", "true --ignored-flag") + monkeypatch.setenv("EDITOR", editor) - cli.cli(["new", "flagtest"]) + cli.cli(["new", f"editortest_{test_id}"]) - workspace_path = tmp_path / "flagtest.yaml" + workspace_path = tmp_path / f"editortest_{test_id}.yaml" assert workspace_path.exists() + + captured = capsys.readouterr() + if expect_error_output: + assert "Editor not found" in captured.out + else: + assert "Editor not found" not in captured.out From cb8d82b37a2506bc21f7a129976c2da336b7ecc4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 23:32:39 -0500 Subject: [PATCH 074/143] fix(importers[tmuxinator]): Map standalone pre_window/pre_tab without pre why: pre_window and pre_tab were only mapped to shell_command_before when pre was also present. tmuxinator treats pre_window independently (project.rb:175, template.erb:60,71). A config with only pre_window silently lost the per-window command. what: - Add elif branch for pre_window_val when pre is absent - Handle list pre_window by joining with "; " (matches tmuxinator) - Add parametrized tests covering all 5 pre/pre_window combinations --- src/tmuxp/workspace/importers.py | 8 +++ tests/workspace/test_import_tmuxinator.py | 73 +++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 6e8b78b2ca..a1ee67c6c9 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -182,6 +182,14 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: tmuxp_workspace["before_script"] = "; ".join(pre_val) else: tmuxp_workspace["before_script"] = pre_val + elif pre_window_val is not None: + # pre_window/pre_tab without pre — tmuxinator treats these independently + if isinstance(pre_window_val, list): + tmuxp_workspace["shell_command_before"] = ["; ".join(pre_window_val)] + elif isinstance(pre_window_val, str): + tmuxp_workspace["shell_command_before"] = [pre_window_val] + else: + tmuxp_workspace["shell_command_before"] = pre_window_val if "rbenv" in workspace_dict: if "shell_command_before" not in tmuxp_workspace: diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 8e0196b208..a4e2b6a743 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -579,3 +579,76 @@ def test_import_tmuxinator_warns_on_unmapped_key( warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert any(key in r.message for r in warning_records) + + +class PreWindowStandaloneFixture(t.NamedTuple): + """Fixture for pre_window/pre_tab without pre key.""" + + test_id: str + config_extra: dict[str, t.Any] + expect_shell_command_before: list[str] | None + expect_before_script: str | None + + +PRE_WINDOW_STANDALONE_FIXTURES: list[PreWindowStandaloneFixture] = [ + PreWindowStandaloneFixture( + test_id="pre_window-only", + config_extra={"pre_window": "echo PRE"}, + expect_shell_command_before=["echo PRE"], + expect_before_script=None, + ), + PreWindowStandaloneFixture( + test_id="pre_tab-only", + config_extra={"pre_tab": "rbenv shell 3.0"}, + expect_shell_command_before=["rbenv shell 3.0"], + expect_before_script=None, + ), + PreWindowStandaloneFixture( + test_id="pre_window-list", + config_extra={"pre_window": ["echo a", "echo b"]}, + expect_shell_command_before=["echo a; echo b"], + expect_before_script=None, + ), + PreWindowStandaloneFixture( + test_id="pre-and-pre_window", + config_extra={"pre": "sudo start", "pre_window": "echo PRE"}, + expect_shell_command_before=["echo PRE"], + expect_before_script="sudo start", + ), + PreWindowStandaloneFixture( + test_id="pre-only", + config_extra={"pre": "sudo start"}, + expect_shell_command_before=None, + expect_before_script="sudo start", + ), +] + + +@pytest.mark.parametrize( + list(PreWindowStandaloneFixture._fields), + PRE_WINDOW_STANDALONE_FIXTURES, + ids=[f.test_id for f in PRE_WINDOW_STANDALONE_FIXTURES], +) +def test_import_tmuxinator_pre_window_standalone( + test_id: str, + config_extra: dict[str, t.Any], + expect_shell_command_before: list[str] | None, + expect_before_script: str | None, +) -> None: + """pre_window/pre_tab map to shell_command_before independently of pre.""" + workspace: dict[str, t.Any] = { + "name": "pre-window-test", + "windows": [{"editor": "vim"}], + **config_extra, + } + result = importers.import_tmuxinator(workspace) + + if expect_shell_command_before is not None: + assert result.get("shell_command_before") == expect_shell_command_before + else: + assert "shell_command_before" not in result + + if expect_before_script is not None: + assert result.get("before_script") == expect_before_script + else: + assert "before_script" not in result From e2ada35f0f18c370e7b81d2079f0fa8b99e10a97 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 23:35:08 -0500 Subject: [PATCH 075/143] fix(importers[tmuxinator]): Log info on numeric startup_window/pane index resolution why: tmuxinator passes startup_window directly to tmux as a target (project.rb:262), where tmux resolves against base-index. tmuxp uses 0-based Python list indices. With base-index=1, startup_window: 1 picks different windows in each tool. Name-based matching avoids this. what: - Log INFO when numeric fallback is used, suggesting window names - Log WARNING when numeric index is out of range - Same treatment for startup_pane - Add parametrized tests for name match, numeric, out-of-range, no-match --- src/tmuxp/workspace/importers.py | 22 +++++ tests/workspace/test_import_tmuxinator.py | 107 ++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index a1ee67c6c9..76e8562d79 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -255,6 +255,17 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: _idx = int(_startup_window) if 0 <= _idx < len(tmuxp_workspace["windows"]): tmuxp_workspace["windows"][_idx]["focus"] = True + logger.info( + "startup_window %r resolved as 0-based list index; " + "use window name for unambiguous matching across tools", + _startup_window, + ) + else: + logger.warning( + "startup_window index %d out of range (0-%d)", + _idx, + len(tmuxp_workspace["windows"]) - 1, + ) except (ValueError, IndexError): logger.warning( "startup_window %s not found", @@ -278,6 +289,17 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: "shell_command": [_pane] if _pane else [], "focus": True, } + logger.info( + "startup_pane %r resolved as 0-based list index; " + "use window name + pane index for clarity", + _startup_pane, + ) + else: + logger.warning( + "startup_pane index %d out of range (0-%d)", + _pidx, + len(_target["panes"]) - 1, + ) except (ValueError, IndexError): logger.warning( "startup_pane %s not found", diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index a4e2b6a743..588635ad7c 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -652,3 +652,110 @@ def test_import_tmuxinator_pre_window_standalone( assert result.get("before_script") == expect_before_script else: assert "before_script" not in result + + +class StartupIndexFixture(t.NamedTuple): + """Fixture for startup_window/startup_pane numeric index resolution.""" + + test_id: str + startup_window: str | int + window_names: list[str] + expected_focus_index: int | None + expect_info_log: bool + expect_warning_log: bool + + +STARTUP_INDEX_FIXTURES: list[StartupIndexFixture] = [ + StartupIndexFixture( + test_id="name-match", + startup_window="editor", + window_names=["editor", "console"], + expected_focus_index=0, + expect_info_log=False, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-zero", + startup_window=0, + window_names=["win1", "win2"], + expected_focus_index=0, + expect_info_log=True, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-one", + startup_window=1, + window_names=["win1", "win2"], + expected_focus_index=1, + expect_info_log=True, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="out-of-range", + startup_window=5, + window_names=["win1", "win2"], + expected_focus_index=None, + expect_info_log=False, + expect_warning_log=True, + ), + StartupIndexFixture( + test_id="no-match-string", + startup_window="nonexistent", + window_names=["win1", "win2"], + expected_focus_index=None, + expect_info_log=False, + expect_warning_log=True, + ), +] + + +@pytest.mark.parametrize( + list(StartupIndexFixture._fields), + STARTUP_INDEX_FIXTURES, + ids=[f.test_id for f in STARTUP_INDEX_FIXTURES], +) +def test_import_tmuxinator_startup_window_index_resolution( + caplog: pytest.LogCaptureFixture, + test_id: str, + startup_window: str | int, + window_names: list[str], + expected_focus_index: int | None, + expect_info_log: bool, + expect_warning_log: bool, +) -> None: + """startup_window resolves by name first, then 0-based index with logging.""" + workspace: dict[str, t.Any] = { + "name": "startup-test", + "startup_window": startup_window, + "windows": [{wn: "echo hi"} for wn in window_names], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + result = importers.import_tmuxinator(workspace) + + windows = result["windows"] + for i, w in enumerate(windows): + if expected_focus_index is not None and i == expected_focus_index: + assert w.get("focus") is True, f"window {i} should have focus" + else: + assert not w.get("focus"), f"window {i} should not have focus" + + info_records = [ + r + for r in caplog.records + if r.levelno == logging.INFO and "startup_window" in r.message + ] + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "startup_window" in r.message + ] + + if expect_info_log: + assert len(info_records) >= 1 + else: + assert len(info_records) == 0 + + if expect_warning_log: + assert len(warning_records) >= 1 + else: + assert len(warning_records) == 0 From 90d77fd11e7897cadb5beca518d56a011c650de8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 23:38:36 -0500 Subject: [PATCH 076/143] fix(builder[here]): Detect duplicate session name before renaming in --here mode why: --here blindly renamed the current session to the config's session_name. If another session already owned that name, tmux errored with a duplicate session name. what: - Check server.sessions for existing name before rename_session - Raise TmuxpException with clear message on conflict - Add parametrized tests: same-name, no-conflict, and conflict cases --- src/tmuxp/workspace/builder.py | 6 +++ tests/workspace/test_builder.py | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index b165b551dd..e03e2d5669 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -575,6 +575,12 @@ def build( if here: session_name = self.session_config["session_name"] if session.name != session_name: + existing = self.server.sessions.get( + session_name=session_name, default=None + ) + if existing is not None: + msg = f"cannot rename to {session_name!r}: session already exists" + raise exc.TmuxpException(msg) session.rename_session(session_name) for window, window_config in self.iter_create_windows( diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 8d97e3ece0..28adac9a67 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -535,6 +535,73 @@ def test_here_mode_cleans_existing_panes( assert len(reused_window.panes) == 1 +class HereDuplicateFixture(t.NamedTuple): + """Fixture for --here duplicate session name detection.""" + + test_id: str + config_session_name: str + expect_error: bool + + +HERE_DUPLICATE_FIXTURES: list[HereDuplicateFixture] = [ + HereDuplicateFixture( + test_id="same-name-no-rename", + config_session_name="__CURRENT__", + expect_error=False, + ), + HereDuplicateFixture( + test_id="different-name-no-conflict", + config_session_name="unique_target", + expect_error=False, + ), + HereDuplicateFixture( + test_id="name-conflict-with-existing", + config_session_name="__EXISTING__", + expect_error=True, + ), +] + + +@pytest.mark.parametrize( + list(HereDuplicateFixture._fields), + HERE_DUPLICATE_FIXTURES, + ids=[f.test_id for f in HERE_DUPLICATE_FIXTURES], +) +def test_here_mode_duplicate_session_name( + session: Session, + test_id: str, + config_session_name: str, + expect_error: bool, +) -> None: + """--here mode detects duplicate session names before renaming.""" + server = session.server + + # Create a second session to conflict with + existing = server.new_session(session_name="existing_blocker") + + # Resolve sentinel values + if config_session_name == "__CURRENT__": + target_name = session.name + elif config_session_name == "__EXISTING__": + target_name = existing.name + else: + target_name = config_session_name + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/here_mode.yaml"), + ) + workspace = loader.expand(workspace) + workspace["session_name"] = target_name + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + if expect_error: + with pytest.raises(exc.TmuxpException, match="session already exists"): + builder.build(session=session, here=True) + else: + builder.build(session=session, here=True) + + def test_window_shell( session: Session, ) -> None: From f25957da61ed3ed8ad1ae5aa6de81994d0d8980e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 23:41:07 -0500 Subject: [PATCH 077/143] fix(builder[here]): Provision environment and window_shell in --here mode why: The --here path only renamed the window, killed extra panes, and sent cd. The normal path provisions window_shell and environment at window creation. A config with environment or window_shell worked normally but not with --here. what: - Export environment variables into active pane via send_keys - Send window_shell command to active pane before shell_command - Extract from first pane config (same precedence as normal path) - Add test verifying environment is accessible in --here mode --- src/tmuxp/workspace/builder.py | 26 ++++++++++++++++++++++++++ tests/workspace/test_builder.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index e03e2d5669..88e40f681d 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -691,6 +691,32 @@ def iter_create_windows( f"cd {shlex.quote(start_directory)}", enter=True, ) + + # Provision environment — no window.set_environment in tmux, + # so export into the active pane's shell + environment = window_config.get("environment") + if panes and "environment" in panes[0]: + environment = panes[0]["environment"] + if environment: + _here_pane = window.active_pane + if _here_pane is not None: + for _ekey, _eval in environment.items(): + _here_pane.send_keys( + f"export {_ekey}={shlex.quote(str(_eval))}", + enter=True, + ) + + # Provision window_shell — send to active pane + window_shell = window_config.get("window_shell") + try: + if panes[0]["shell"] != "": + window_shell = panes[0]["shell"] + except (KeyError, IndexError): + pass + if window_shell: + _here_pane = window.active_pane + if _here_pane is not None: + _here_pane.send_keys(window_shell, enter=True) else: is_first_window_pass = self.first_window_pass( window_iterator, diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 28adac9a67..b6f9c7fa62 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -602,6 +602,38 @@ def test_here_mode_duplicate_session_name( builder.build(session=session, here=True) +def test_here_mode_provisions_environment( + session: Session, +) -> None: + """--here mode exports environment variables into the active pane.""" + from libtmux.test.retry import retry_until + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "env-test", + "environment": {"TMUXP_HERE_TEST": "hello_here"}, + "panes": [ + {"shell_command": ["echo $TMUXP_HERE_TEST"]}, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + pane = session.active_window.active_pane + assert pane is not None + + assert retry_until( + lambda: "hello_here" in "\n".join(pane.capture_pane()), + seconds=5, + ) + + def test_window_shell( session: Session, ) -> None: From e8fe2187a840dbefefee6c2cad6e022236e6b0e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 21 Mar 2026 23:43:06 -0500 Subject: [PATCH 078/143] fix(cli[copy]): Respect TMUXP_CONFIGDIR even when directory doesn't exist yet why: copy called get_workspace_dir() which skips non-existent directories. When TMUXP_CONFIGDIR was set but didn't exist, the file landed in the fallback ~/.tmuxp instead. command_new already had the correct pattern. what: - Check TMUXP_CONFIGDIR directly before falling back to get_workspace_dir - Mirror the TMUXP_CONFIGDIR handling already used by command_new - Add parametrized tests for existing and non-existing configdir --- src/tmuxp/cli/copy.py | 5 +++- tests/cli/test_copy.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py index 8d3e79438d..ef09c0c98c 100644 --- a/src/tmuxp/cli/copy.py +++ b/src/tmuxp/cli/copy.py @@ -108,7 +108,10 @@ def command_copy( return if is_pure_name(destination): - workspace_dir = get_workspace_dir() + configdir_env = os.environ.get("TMUXP_CONFIGDIR") + workspace_dir = ( + os.path.expanduser(configdir_env) if configdir_env else get_workspace_dir() + ) os.makedirs(workspace_dir, exist_ok=True) dest_path = os.path.join(workspace_dir, f"{destination}.yaml") else: diff --git a/tests/cli/test_copy.py b/tests/cli/test_copy.py index 1a60548a08..7fe1817415 100644 --- a/tests/cli/test_copy.py +++ b/tests/cli/test_copy.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pathlib import typing as t import pytest @@ -104,3 +105,54 @@ def test_copy_to_path( captured = capsys.readouterr() assert "Copied" in captured.out + + +class CopyConfigdirFixture(t.NamedTuple): + """Fixture for TMUXP_CONFIGDIR handling in copy command.""" + + test_id: str + configdir_exists_before: bool + + +COPY_CONFIGDIR_FIXTURES: list[CopyConfigdirFixture] = [ + CopyConfigdirFixture( + test_id="configdir-exists", + configdir_exists_before=True, + ), + CopyConfigdirFixture( + test_id="configdir-not-exists", + configdir_exists_before=False, + ), +] + + +@pytest.mark.parametrize( + list(CopyConfigdirFixture._fields), + COPY_CONFIGDIR_FIXTURES, + ids=[f.test_id for f in COPY_CONFIGDIR_FIXTURES], +) +def test_copy_respects_tmuxp_configdir( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + configdir_exists_before: bool, +) -> None: + """Copy lands in TMUXP_CONFIGDIR even if it doesn't exist yet.""" + # Source file in a separate directory + source_dir = tmp_path / "source_dir" + source_dir.mkdir() + source_file = source_dir / "orig.yaml" + source_file.write_text("session_name: copied\n") + + # Target configdir — may or may not exist + config_dir = tmp_path / "custom_config" + if configdir_exists_before: + config_dir.mkdir() + + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + cli.cli(["copy", str(source_file), "myworkspace"]) + + expected = config_dir / "myworkspace.yaml" + assert expected.exists(), f"expected {expected} to exist" + assert expected.read_text() == "session_name: copied\n" From 73f69d044d3739a60b9103c55f50b35d91757a43 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 07:35:56 -0500 Subject: [PATCH 079/143] fix(importers[tmuxinator]): Drop dead on_project_first_start key from output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The importer preserved on_project_first_start in the output dict, but nothing in the builder or CLI ever reads it — dead data that misleads users into thinking the hook works. The warning log already tells users to use on_project_start instead; stop also copying the value. --- src/tmuxp/workspace/importers.py | 3 --- tests/workspace/test_import_tmuxinator.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 76e8562d79..c90d9928af 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -133,9 +133,6 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: "on_project_first_start is not yet supported by tmuxp; " "consider using on_project_start instead", ) - tmuxp_workspace["on_project_first_start"] = workspace_dict[ - "on_project_first_start" - ] # Warn on tmuxinator keys that have no tmuxp equivalent _TMUXINATOR_UNMAPPED_KEYS = { diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 588635ad7c..2320ba2863 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -525,7 +525,7 @@ def test_import_tmuxinator_on_project_first_start_warns( with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): result = importers.import_tmuxinator(workspace) - assert result["on_project_first_start"] == "rake db:create" + assert "on_project_first_start" not in result warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert any("on_project_first_start" in r.message for r in warning_records) From 3ec417c051a46ad0c90e397f5be398b1d1b698ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 07:36:19 -0500 Subject: [PATCH 080/143] fix(importers[tmuxinator]): Join pre_window list with "; " when pre is also present When pre and pre_window were both present and pre_window was a list, the importer passed the array through as-is to shell_command_before. But tmuxinator's parsed_parameters() always joins arrays with "; ". The standalone pre_window path already did this joining correctly; the combo path did not. Now both paths match tmuxinator semantics. --- src/tmuxp/workspace/importers.py | 4 +++- tests/workspace/test_import_tmuxinator.py | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index c90d9928af..487b0e5ea8 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -165,7 +165,9 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: else: tmuxp_workspace["before_script"] = pre_val - if isinstance(pre_window_val, str): + if isinstance(pre_window_val, list): + tmuxp_workspace["shell_command_before"] = ["; ".join(pre_window_val)] + elif isinstance(pre_window_val, str): tmuxp_workspace["shell_command_before"] = [pre_window_val] else: tmuxp_workspace["shell_command_before"] = pre_window_val diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 2320ba2863..0b42b008a9 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -615,6 +615,12 @@ class PreWindowStandaloneFixture(t.NamedTuple): expect_shell_command_before=["echo PRE"], expect_before_script="sudo start", ), + PreWindowStandaloneFixture( + test_id="pre-and-pre_window-list", + config_extra={"pre": "sudo start", "pre_window": ["cd /app", "nvm use 18"]}, + expect_shell_command_before=["cd /app; nvm use 18"], + expect_before_script="sudo start", + ), PreWindowStandaloneFixture( test_id="pre-only", config_extra={"pre": "sudo start"}, From 310625e7764bb64ccd6a97531bad5e34edbe9138 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 07:36:34 -0500 Subject: [PATCH 081/143] docs(cli[import]): Fix pre mapping description (before_script, not on_project_start) The docs said tmuxinator's pre maps to on_project_start, but the importer code maps pre to before_script in all branches. These have different semantics: before_script runs during session build via run_before_script(), while on_project_start is a lifecycle hook that runs via run_hook_commands(). --- docs/cli/import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/import.md b/docs/cli/import.md index 8bf42e79cb..3cf3733b8a 100644 --- a/docs/cli/import.md +++ b/docs/cli/import.md @@ -84,7 +84,7 @@ $ tmuxp import tmuxinator /path/to/file.json The tmuxinator importer maps: -- **Hook mapping** — `pre` maps to `on_project_start`, `pre_window` maps to `shell_command_before` +- **Hook mapping** — `pre` maps to `before_script`, `pre_window` maps to `shell_command_before` - **CLI args** — `cli_args` values (`-f`, `-S`, `-L`) are parsed into tmuxp config equivalents - **Synchronize** — `synchronize` window key is converted - **Startup focus** — `startup_window` / `startup_pane` convert to `focus: true` From e2a6c80d7f8e2c644cb23d4105dc165c16174a33 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 08:33:12 -0500 Subject: [PATCH 082/143] fix(util[run_hook_commands]): Catch OSError for nonexistent cwd run_hook_commands only caught TimeoutExpired. A nonexistent cwd raises FileNotFoundError (subclass of OSError), which propagated unhandled and could crash tmuxp stop before session.kill() executes. Now catches OSError and logs a warning, matching tmuxinator's graceful handling. --- src/tmuxp/util.py | 6 ++++++ tests/test_util.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 9012f3b29b..6e6cbc8334 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -154,6 +154,12 @@ def run_hook_commands( except subprocess.TimeoutExpired: logger.warning("hook command timed out after 120s: %s", joined) return + except OSError: + logger.warning( + "hook command failed (bad cwd or shell): %s", + joined, + ) + return if result.returncode != 0: logger.warning( "hook command failed with exit code %d", diff --git a/tests/test_util.py b/tests/test_util.py index 543d57e5c5..fe99990322 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -316,3 +316,17 @@ def test_run_hook_commands_cwd( """run_hook_commands() respects cwd parameter.""" run_hook_commands("touch marker_file", cwd=tmp_path) assert (tmp_path / "marker_file").exists() + + +def test_run_hook_commands_missing_cwd_warns( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """run_hook_commands() logs warning on nonexistent cwd instead of raising.""" + missing_dir = tmp_path / "does_not_exist" + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + run_hook_commands("echo hello", cwd=missing_dir) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "bad cwd or shell" in warning_records[0].message From 3550d5b65ce745a72a026c54cf447e99c59bc1ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 08:34:04 -0500 Subject: [PATCH 083/143] fix(cli[stop]): Enable current-session fallback when no args given The no-args guard in cli/__init__.py printed help and returned before command_stop() was called, making the get_session() fallback in stop.py dead code. tmuxinator stop without args stops the current session; tmuxp should too. Removed the guard so command_stop handles resolution. --- src/tmuxp/cli/__init__.py | 3 --- tests/cli/test_help_examples.py | 2 +- tests/cli/test_stop.py | 16 ++++++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 9d333af8e7..d8963128cf 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -454,9 +454,6 @@ def cli(_args: list[str] | None = None) -> None: parser=parser, ) elif args.subparser_name == "stop": - if not args.session_name: - args.print_help() - return command_stop( args=CLIStopNamespace(**vars(args)), parser=parser, diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index 9261390ee7..2875d9d255 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -300,7 +300,7 @@ def test_search_no_args_shows_help() -> None: assert result.returncode == 0 -@pytest.mark.parametrize("subcommand", ["stop", "new", "copy", "delete"]) +@pytest.mark.parametrize("subcommand", ["new", "copy", "delete"]) def test_new_commands_no_args_shows_help(subcommand: str) -> None: """Running new commands with no args shows help.""" result = subprocess.run( diff --git a/tests/cli/test_stop.py b/tests/cli/test_stop.py index d06acd9cef..801d4ae69a 100644 --- a/tests/cli/test_stop.py +++ b/tests/cli/test_stop.py @@ -114,6 +114,22 @@ def test_stop_runs_on_project_stop_hook( assert not server.has_session("hook-stop-test") +def test_stop_no_args_uses_fallback( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp stop with no session name falls back to first session.""" + monkeypatch.delenv("TMUX", raising=False) + + server.new_session(session_name="fallback-target") + assert server.has_session("fallback-target") + + assert server.socket_name is not None + cli.cli(["stop", "-L", server.socket_name]) + + assert not server.has_session("fallback-target") + + def test_stop_without_hook( server: Server, monkeypatch: pytest.MonkeyPatch, From 45c438b1c5aa6d47284578953cd8109d1d9b80b5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 10:45:47 -0500 Subject: [PATCH 084/143] fix(cli[stop]): Require TMUX env for no-args current-session fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this guard, tmuxp stop with no args outside tmux would connect to the default server and kill server.sessions[0] — the user's first real session. Now requires TMUX env var to be set (proving we're inside tmux) before falling back to current session detection. Outside tmux with no args, shows a clear error message instead. --- src/tmuxp/cli/stop.py | 8 +++++++- tests/cli/test_stop.py | 33 ++++++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py index 60ad2a97ed..4ce9e2bd97 100644 --- a/src/tmuxp/cli/stop.py +++ b/src/tmuxp/cli/stop.py @@ -4,6 +4,7 @@ import argparse import logging +import os import typing as t from libtmux.server import Server @@ -109,8 +110,13 @@ def command_stop( session_name=args.session_name, default=None, ) - else: + elif os.environ.get("TMUX"): session = util.get_session(server) + else: + tmuxp_echo( + colors.error("No session name given and not inside tmux."), + ) + return if not session: raise exc.SessionNotFound(args.session_name) diff --git a/tests/cli/test_stop.py b/tests/cli/test_stop.py index 801d4ae69a..0f8f3326ee 100644 --- a/tests/cli/test_stop.py +++ b/tests/cli/test_stop.py @@ -114,22 +114,45 @@ def test_stop_runs_on_project_stop_hook( assert not server.has_session("hook-stop-test") -def test_stop_no_args_uses_fallback( +def test_stop_no_args_inside_tmux_uses_fallback( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Tmuxp stop with no session name falls back to first session.""" - monkeypatch.delenv("TMUX", raising=False) - - server.new_session(session_name="fallback-target") + """Tmuxp stop with no session name inside tmux falls back to current session.""" + sess = server.new_session(session_name="fallback-target") assert server.has_session("fallback-target") + # Simulate being inside tmux by setting TMUX and TMUX_PANE + pane = sess.active_window.active_pane + assert pane is not None + monkeypatch.setenv("TMUX", f"/tmp/tmux-test,{sess.session_id},0") + monkeypatch.setenv("TMUX_PANE", pane.pane_id or "") + assert server.socket_name is not None cli.cli(["stop", "-L", server.socket_name]) assert not server.has_session("fallback-target") +def test_stop_no_args_outside_tmux_shows_error( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tmuxp stop with no session name outside tmux shows error.""" + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + + server.new_session(session_name="should-survive") + + assert server.socket_name is not None + cli.cli(["stop", "-L", server.socket_name]) + + captured = capsys.readouterr() + assert "not inside tmux" in captured.out + assert server.has_session("should-survive") + + def test_stop_without_hook( server: Server, monkeypatch: pytest.MonkeyPatch, From 4bddd88fced3bd5a9ba3d32ea3eb61ff2c7b4d96 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 10:46:24 -0500 Subject: [PATCH 085/143] fix(importers[tmuxinator]): Use exclusive rbenv/rvm/pre_tab/pre_window precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmuxinator's pre_window method uses an exclusive if/elsif chain: rbenv > rvm > pre_tab > pre_window — only ONE is selected. The tmuxp importer was unconditionally appending rbenv and rvm after pre_window, combining them all. A config with rvm and pre_tab produced both commands in tmuxp but only the rvm command in tmuxinator. Restructured to mirror tmuxinator's exclusive precedence. The pre key (before_script) remains independent since tmuxinator treats it separately from pre_window. --- src/tmuxp/workspace/importers.py | 74 ++++++++++------------- tests/fixtures/import_tmuxinator/test5.py | 2 +- tests/workspace/test_import_tmuxinator.py | 52 ++++++++++++++++ 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 487b0e5ea8..56b5b6ae08 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -153,56 +153,44 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "tabs" in workspace_dict: workspace_dict["windows"] = workspace_dict.pop("tabs") - pre_window_val = workspace_dict.get( - "pre_window", - workspace_dict.get("pre_tab"), - ) - - if "pre" in workspace_dict and pre_window_val is not None: + # Handle pre → before_script (independent of pre_window chain) + if "pre" in workspace_dict: pre_val = workspace_dict["pre"] if isinstance(pre_val, list): + if ( + workspace_dict.get("pre_window") is None + and workspace_dict.get("pre_tab") is None + ): + logger.info( + "multi-command pre list mapped to before_script; " + "consider splitting into before_script and shell_command_before", + ) tmuxp_workspace["before_script"] = "; ".join(pre_val) else: tmuxp_workspace["before_script"] = pre_val - if isinstance(pre_window_val, list): - tmuxp_workspace["shell_command_before"] = ["; ".join(pre_window_val)] - elif isinstance(pre_window_val, str): - tmuxp_workspace["shell_command_before"] = [pre_window_val] - else: - tmuxp_workspace["shell_command_before"] = pre_window_val - elif "pre" in workspace_dict: - pre_val = workspace_dict["pre"] - if isinstance(pre_val, list): - logger.info( - "multi-command pre list mapped to before_script; " - "consider splitting into before_script and shell_command_before", - ) - tmuxp_workspace["before_script"] = "; ".join(pre_val) - else: - tmuxp_workspace["before_script"] = pre_val - elif pre_window_val is not None: - # pre_window/pre_tab without pre — tmuxinator treats these independently - if isinstance(pre_window_val, list): - tmuxp_workspace["shell_command_before"] = ["; ".join(pre_window_val)] - elif isinstance(pre_window_val, str): - tmuxp_workspace["shell_command_before"] = [pre_window_val] - else: - tmuxp_workspace["shell_command_before"] = pre_window_val - + # Resolve shell_command_before using tmuxinator's exclusive precedence: + # rbenv > rvm > pre_tab > pre_window (only ONE is selected) + _scb_val: str | None = None if "rbenv" in workspace_dict: - if "shell_command_before" not in tmuxp_workspace: - tmuxp_workspace["shell_command_before"] = [] - tmuxp_workspace["shell_command_before"].append( - "rbenv shell {}".format(workspace_dict["rbenv"]), - ) - - if "rvm" in workspace_dict: - if "shell_command_before" not in tmuxp_workspace: - tmuxp_workspace["shell_command_before"] = [] - tmuxp_workspace["shell_command_before"].append( - "rvm use {}".format(workspace_dict["rvm"]), - ) + _scb_val = "rbenv shell {}".format(workspace_dict["rbenv"]) + elif "rvm" in workspace_dict: + _scb_val = "rvm use {}".format(workspace_dict["rvm"]) + elif "pre_tab" in workspace_dict: + _raw = workspace_dict["pre_tab"] + if isinstance(_raw, list): + _scb_val = "; ".join(_raw) + elif isinstance(_raw, str): + _scb_val = _raw + elif "pre_window" in workspace_dict: + _raw = workspace_dict["pre_window"] + if isinstance(_raw, list): + _scb_val = "; ".join(_raw) + elif isinstance(_raw, str): + _scb_val = _raw + + if _scb_val is not None: + tmuxp_workspace["shell_command_before"] = [_scb_val] _startup_window = workspace_dict.get("startup_window") _startup_pane = workspace_dict.get("startup_pane") diff --git a/tests/fixtures/import_tmuxinator/test5.py b/tests/fixtures/import_tmuxinator/test5.py index f8b3176a49..51e7849ffd 100644 --- a/tests/fixtures/import_tmuxinator/test5.py +++ b/tests/fixtures/import_tmuxinator/test5.py @@ -24,7 +24,7 @@ "session_name": "ruby-app", "start_directory": "~/projects/ruby-app", "before_script": "./scripts/bootstrap.sh", - "shell_command_before": ["source .env", "rvm use 2.1.1"], + "shell_command_before": ["rvm use 2.1.1"], "windows": [ {"window_name": "editor", "panes": ["vim"]}, { diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 0b42b008a9..a006ff027e 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -660,6 +660,58 @@ def test_import_tmuxinator_pre_window_standalone( assert "before_script" not in result +class PreWindowPrecedenceFixture(t.NamedTuple): + """Fixture for rbenv/rvm/pre_tab/pre_window exclusive precedence.""" + + test_id: str + config_extra: dict[str, t.Any] + expect_shell_command_before: list[str] + + +PRE_WINDOW_PRECEDENCE_FIXTURES: list[PreWindowPrecedenceFixture] = [ + PreWindowPrecedenceFixture( + test_id="rbenv-beats-pre_window", + config_extra={"rbenv": "2.7.0", "pre_window": "echo PRE"}, + expect_shell_command_before=["rbenv shell 2.7.0"], + ), + PreWindowPrecedenceFixture( + test_id="rvm-beats-pre_tab", + config_extra={"rvm": "2.1.1", "pre_tab": "source .env"}, + expect_shell_command_before=["rvm use 2.1.1"], + ), + PreWindowPrecedenceFixture( + test_id="rbenv-beats-rvm", + config_extra={"rbenv": "3.2.0", "rvm": "2.1.1"}, + expect_shell_command_before=["rbenv shell 3.2.0"], + ), + PreWindowPrecedenceFixture( + test_id="pre_tab-beats-pre_window", + config_extra={"pre_tab": "nvm use 18", "pre_window": "echo OTHER"}, + expect_shell_command_before=["nvm use 18"], + ), +] + + +@pytest.mark.parametrize( + list(PreWindowPrecedenceFixture._fields), + PRE_WINDOW_PRECEDENCE_FIXTURES, + ids=[f.test_id for f in PRE_WINDOW_PRECEDENCE_FIXTURES], +) +def test_import_tmuxinator_pre_window_precedence( + test_id: str, + config_extra: dict[str, t.Any], + expect_shell_command_before: list[str], +) -> None: + """Tmuxinator uses exclusive rbenv > rvm > pre_tab > pre_window precedence.""" + workspace: dict[str, t.Any] = { + "name": "precedence-test", + "windows": [{"editor": "vim"}], + **config_extra, + } + result = importers.import_tmuxinator(workspace) + assert result.get("shell_command_before") == expect_shell_command_before + + class StartupIndexFixture(t.NamedTuple): """Fixture for startup_window/startup_pane numeric index resolution.""" From dd66c6ccb74232fb3404a8235d7c6640f743cd6a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 10:47:06 -0500 Subject: [PATCH 086/143] fix(builder[hooks]): Add cwd to on_project_exit run-shell command on_project_exit ran via tmux run-shell with no workspace directory. Relative commands behaved differently from on_project_start and on_project_stop which do get cwd. tmuxinator's template runs cd before all hooks. Now prepends cd && to the hook command when start_directory is set, using shlex.quote for safety. --- src/tmuxp/workspace/builder.py | 3 +++ tests/workspace/test_builder.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 88e40f681d..3d30f5438c 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -552,6 +552,9 @@ def build( if isinstance(exit_cmds, str): exit_cmds = [exit_cmds] _joined = "; ".join(exit_cmds) + _start_dir = self.session_config.get("start_directory") + if _start_dir: + _joined = f"cd {shlex.quote(_start_dir)} && {_joined}" _escaped = _joined.replace("'", "'\\''") self.session.set_hook("client-detached", f"run-shell '{_escaped}'") diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index b6f9c7fa62..96cc5e8236 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -2089,6 +2089,30 @@ def test_on_project_exit_sets_hook_list( builder.session.kill() +def test_on_project_exit_hook_includes_cwd( + server: Server, +) -> None: + """on_project_exit hook includes cd to start_directory.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-exit-cwd-test", + "start_directory": "/tmp", + "on_project_exit": "echo goodbye", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_values = list(hooks.values()) + matched = [v for v in hook_values if "cd" in str(v) and "/tmp" in str(v)] + assert len(matched) >= 1 + + builder.session.kill() + + def test_on_project_stop_sets_environment( server: Server, ) -> None: From 8acf644fdf4e13a7ca30f95f3570e7af80ae98b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 10:48:19 -0500 Subject: [PATCH 087/143] fix(cli[load]): Make --here and --append mutually exclusive Both flags were accepted silently with --here taking precedence. Now uses argparse mutually exclusive group so passing both produces a clear error. teamocil's --here is standalone; this matches that pattern. --- src/tmuxp/cli/load.py | 5 +++-- tests/cli/test_load.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 2a33db5aa5..f7218d0d1d 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -897,14 +897,15 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP action="store_true", help="load the session without attaching it", ) - parser.add_argument( + load_mode_group = parser.add_mutually_exclusive_group() + load_mode_group.add_argument( "-a", "--append", dest="append", action="store_true", help="load workspace, appending windows to the current session", ) - parser.add_argument( + load_mode_group.add_argument( "--here", dest="here", action="store_true", diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 3ccee19b2e..7bca0e7f0e 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1476,3 +1476,12 @@ def test_load_workspace_template_no_context( assert isinstance(session, Session) assert session.name == "plain-session" + + +def test_load_here_and_append_mutually_exclusive() -> None: + """--here and --append cannot be used together.""" + from tmuxp.cli import create_parser + + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["load", "--here", "--append", "myconfig"]) From fb9089de5a66fee1a61390682742dbe042f9da8b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 10:49:43 -0500 Subject: [PATCH 088/143] fix(cli[load]): Warn when --here used outside tmux --here outside tmux silently fell through to normal _load_attached with no indication. Now logs a warning and shows a user-facing message before falling back. teamocil's --here also requires being inside tmux. --- src/tmuxp/cli/load.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index f7218d0d1d..3d7327ec69 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -414,6 +414,13 @@ def _dispatch_build( if "TMUX" in os.environ: # tmuxp ran from inside tmux _load_here_in_current_session(builder) else: + logger.warning( + "--here ignored: not inside tmux, falling back to normal attach", + ) + tmuxp_echo( + cli_colors.warning("[Warning]") + + " --here requires running inside tmux; loading normally", + ) _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) return _setup_plugins(builder) From 596db38345316a164b002d6674e5d9e8132ab649 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 11:10:57 -0500 Subject: [PATCH 089/143] test(cli[help]): Add safety test for dangerous subprocess tmuxp calls AST-based test scans all test files for subprocess.run/call invocations that run tmuxp mutation commands (stop, load) without -L test socket. These could kill real tmux sessions when tests run inside tmux (e.g. via just watch-test in the tmuxp dev session). The test would have caught the bug where tmuxp stop no-args killed the dev session. --- tests/cli/test_help_examples.py | 72 +++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index 2875d9d255..a0a314a77d 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -331,3 +331,75 @@ def test_main_help_examples_are_colorized(monkeypatch: pytest.MonkeyPatch) -> No # Should contain ANSI escape codes for colorization assert "\033[" in help_text, "Example sections should be colorized" + + +# Commands that can mutate tmux state (kill sessions, create sessions, etc.) +# These must NEVER be called via subprocess without -L . +_DANGEROUS_SUBCOMMANDS = {"stop", "load"} + + +def test_no_dangerous_subprocess_tmuxp_calls() -> None: + """Subprocess calls to tmuxp mutation commands must use -L test socket. + + Catches bugs like the one where ``subprocess.run(["tmuxp", "stop"])`` + killed the user's real tmux session because it ran on the default server + without ``-L``. + """ + import ast + import pathlib + + tests_dir = pathlib.Path(__file__).parent.parent + violations: list[str] = [] + + for py_file in tests_dir.rglob("*.py"): + try: + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + except SyntaxError: + continue + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + # Match subprocess.run(...) or subprocess.call(...) + func = node.func + is_subprocess = False + if ( + isinstance(func, ast.Attribute) + and func.attr in ("run", "call") + and isinstance(func.value, ast.Name) + and func.value.id == "subprocess" + ): + is_subprocess = True + if not is_subprocess: + continue + + # Check first arg is a list literal like ["tmuxp", "stop", ...] + if not node.args or not isinstance(node.args[0], ast.List): + continue + elts = node.args[0].elts + if len(elts) < 2: + continue + if not (isinstance(elts[0], ast.Constant) and elts[0].value == "tmuxp"): + continue + if not isinstance(elts[1], ast.Constant): + continue + + subcmd = str(elts[1].value) + if subcmd not in _DANGEROUS_SUBCOMMANDS: + continue + + # Check if -L appears anywhere in the arg list + has_socket = any( + isinstance(e, ast.Constant) and e.value == "-L" for e in elts + ) + if not has_socket: + rel = py_file.relative_to(tests_dir) + violations.append( + f"{rel}:{node.lineno}: subprocess calls " + f"'tmuxp {subcmd}' without -L test socket" + ) + + assert not violations, ( + "Dangerous subprocess tmuxp calls found (could kill real sessions):\n" + + "\n".join(f" {v}" for v in violations) + ) From 95d4b4473b2d7f6b3a3896d91443440e5fc660d9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 19:57:51 -0500 Subject: [PATCH 090/143] fix(importers[tmuxinator]): Map pre to on_project_start instead of before_script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before_script calls run_before_script() which uses shlex.split + Popen(shell=False) — it expects a file path. tmuxinator pre is a raw shell command (e.g., pre: "mysql.server start"). Imported configs with raw commands crashed with BeforeLoadScriptNotExists. on_project_start uses run_hook_commands(shell=True) which handles raw shell commands correctly, matching tmuxinator's template.erb behavior where pre is emitted as a raw shell line. --- docs/cli/import.md | 2 +- src/tmuxp/workspace/importers.py | 17 +++------ tests/fixtures/import_tmuxinator/test2.py | 2 +- tests/fixtures/import_tmuxinator/test3.py | 2 +- tests/fixtures/import_tmuxinator/test5.py | 2 +- tests/workspace/test_import_tmuxinator.py | 45 +++++++---------------- 6 files changed, 24 insertions(+), 46 deletions(-) diff --git a/docs/cli/import.md b/docs/cli/import.md index 3cf3733b8a..8bf42e79cb 100644 --- a/docs/cli/import.md +++ b/docs/cli/import.md @@ -84,7 +84,7 @@ $ tmuxp import tmuxinator /path/to/file.json The tmuxinator importer maps: -- **Hook mapping** — `pre` maps to `before_script`, `pre_window` maps to `shell_command_before` +- **Hook mapping** — `pre` maps to `on_project_start`, `pre_window` maps to `shell_command_before` - **CLI args** — `cli_args` values (`-f`, `-S`, `-L`) are parsed into tmuxp config equivalents - **Synchronize** — `synchronize` window key is converted - **Startup focus** — `startup_window` / `startup_pane` convert to `focus: true` diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 56b5b6ae08..e25042f19e 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -153,21 +153,16 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "tabs" in workspace_dict: workspace_dict["windows"] = workspace_dict.pop("tabs") - # Handle pre → before_script (independent of pre_window chain) + # Handle pre → on_project_start (independent of pre_window chain) + # tmuxinator's pre is a raw shell command emitted as a line in a bash script. + # on_project_start uses run_hook_commands(shell=True) which handles raw commands. + # before_script requires a file path and would crash on raw commands. if "pre" in workspace_dict: pre_val = workspace_dict["pre"] if isinstance(pre_val, list): - if ( - workspace_dict.get("pre_window") is None - and workspace_dict.get("pre_tab") is None - ): - logger.info( - "multi-command pre list mapped to before_script; " - "consider splitting into before_script and shell_command_before", - ) - tmuxp_workspace["before_script"] = "; ".join(pre_val) + tmuxp_workspace["on_project_start"] = "; ".join(pre_val) else: - tmuxp_workspace["before_script"] = pre_val + tmuxp_workspace["on_project_start"] = pre_val # Resolve shell_command_before using tmuxinator's exclusive precedence: # rbenv > rvm > pre_tab > pre_window (only ONE is selected) diff --git a/tests/fixtures/import_tmuxinator/test2.py b/tests/fixtures/import_tmuxinator/test2.py index 4953347b94..8767443b28 100644 --- a/tests/fixtures/import_tmuxinator/test2.py +++ b/tests/fixtures/import_tmuxinator/test2.py @@ -49,7 +49,7 @@ "socket_name": "foo", "config": "~/.tmux.mac.conf", "start_directory": "~/test", - "before_script": "sudo /etc/rc.d/mysqld start", + "on_project_start": "sudo /etc/rc.d/mysqld start", "shell_command_before": ["rbenv shell 2.0.0-p247"], "windows": [ { diff --git a/tests/fixtures/import_tmuxinator/test3.py b/tests/fixtures/import_tmuxinator/test3.py index 4dc7b6681d..6a2a6af3e2 100644 --- a/tests/fixtures/import_tmuxinator/test3.py +++ b/tests/fixtures/import_tmuxinator/test3.py @@ -50,7 +50,7 @@ "socket_name": "foo", "start_directory": "~/test", "config": "~/.tmux.mac.conf", - "before_script": "sudo /etc/rc.d/mysqld start", + "on_project_start": "sudo /etc/rc.d/mysqld start", "shell_command_before": ["rbenv shell 2.0.0-p247"], "windows": [ { diff --git a/tests/fixtures/import_tmuxinator/test5.py b/tests/fixtures/import_tmuxinator/test5.py index 51e7849ffd..194416dcfb 100644 --- a/tests/fixtures/import_tmuxinator/test5.py +++ b/tests/fixtures/import_tmuxinator/test5.py @@ -23,7 +23,7 @@ expected = { "session_name": "ruby-app", "start_directory": "~/projects/ruby-app", - "before_script": "./scripts/bootstrap.sh", + "on_project_start": "./scripts/bootstrap.sh", "shell_command_before": ["rvm use 2.1.1"], "windows": [ {"window_name": "editor", "panes": ["vim"]}, diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index a006ff027e..30b1e169eb 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -96,23 +96,6 @@ def test_import_tmuxinator_logs_debug( assert getattr(records[0], "tmux_session", None) == "test" -def test_logs_info_on_multi_command_pre_list( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that multi-command pre list logs info about before_script mapping.""" - workspace = { - "name": "multi-pre", - "root": "~/test", - "pre": ["cmd1", "cmd2"], - "windows": [{"editor": "vim"}], - } - with caplog.at_level(logging.INFO, logger="tmuxp.workspace.importers"): - importers.import_tmuxinator(workspace) - - pre_records = [r for r in caplog.records if "multi-command pre list" in r.message] - assert len(pre_records) == 1 - - def test_startup_window_sets_focus_by_name() -> None: """Startup_window sets focus on the matching window by name.""" workspace = { @@ -473,15 +456,15 @@ def test_import_tmuxinator_socket_name_same_no_warning( assert len(warning_records) == 0 -def test_import_tmuxinator_pre_list_joined_for_before_script() -> None: - """List pre values are joined with '; ' so expand() doesn't crash.""" +def test_import_tmuxinator_pre_list_joined_for_on_project_start() -> None: + """List pre values are joined with '; ' for on_project_start.""" workspace = { "name": "pre-list", "windows": [{"editor": "vim"}], "pre": ["echo one", "echo two"], } result = importers.import_tmuxinator(workspace) - assert result["before_script"] == "echo one; echo two" + assert result["on_project_start"] == "echo one; echo two" # Verify it survives expand() without TypeError from tmuxp.workspace import loader @@ -587,7 +570,7 @@ class PreWindowStandaloneFixture(t.NamedTuple): test_id: str config_extra: dict[str, t.Any] expect_shell_command_before: list[str] | None - expect_before_script: str | None + expect_on_project_start: str | None PRE_WINDOW_STANDALONE_FIXTURES: list[PreWindowStandaloneFixture] = [ @@ -595,37 +578,37 @@ class PreWindowStandaloneFixture(t.NamedTuple): test_id="pre_window-only", config_extra={"pre_window": "echo PRE"}, expect_shell_command_before=["echo PRE"], - expect_before_script=None, + expect_on_project_start=None, ), PreWindowStandaloneFixture( test_id="pre_tab-only", config_extra={"pre_tab": "rbenv shell 3.0"}, expect_shell_command_before=["rbenv shell 3.0"], - expect_before_script=None, + expect_on_project_start=None, ), PreWindowStandaloneFixture( test_id="pre_window-list", config_extra={"pre_window": ["echo a", "echo b"]}, expect_shell_command_before=["echo a; echo b"], - expect_before_script=None, + expect_on_project_start=None, ), PreWindowStandaloneFixture( test_id="pre-and-pre_window", config_extra={"pre": "sudo start", "pre_window": "echo PRE"}, expect_shell_command_before=["echo PRE"], - expect_before_script="sudo start", + expect_on_project_start="sudo start", ), PreWindowStandaloneFixture( test_id="pre-and-pre_window-list", config_extra={"pre": "sudo start", "pre_window": ["cd /app", "nvm use 18"]}, expect_shell_command_before=["cd /app; nvm use 18"], - expect_before_script="sudo start", + expect_on_project_start="sudo start", ), PreWindowStandaloneFixture( test_id="pre-only", config_extra={"pre": "sudo start"}, expect_shell_command_before=None, - expect_before_script="sudo start", + expect_on_project_start="sudo start", ), ] @@ -639,7 +622,7 @@ def test_import_tmuxinator_pre_window_standalone( test_id: str, config_extra: dict[str, t.Any], expect_shell_command_before: list[str] | None, - expect_before_script: str | None, + expect_on_project_start: str | None, ) -> None: """pre_window/pre_tab map to shell_command_before independently of pre.""" workspace: dict[str, t.Any] = { @@ -654,10 +637,10 @@ def test_import_tmuxinator_pre_window_standalone( else: assert "shell_command_before" not in result - if expect_before_script is not None: - assert result.get("before_script") == expect_before_script + if expect_on_project_start is not None: + assert result.get("on_project_start") == expect_on_project_start else: - assert "before_script" not in result + assert "on_project_start" not in result class PreWindowPrecedenceFixture(t.NamedTuple): From b93cb19182d41421540dc870aa3bb41feaf9601a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 19:58:26 -0500 Subject: [PATCH 091/143] fix(builder[here]): Move rename-conflict check before session mutation build(here=True) was setting options, environment, and hooks on the session before checking if the target session name already exists. If rename failed, the user's session was left with stale hooks/options. Now checks for conflicts first, before any session state is modified. --- src/tmuxp/workspace/builder.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 3d30f5438c..2a69bd4540 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -534,6 +534,18 @@ def build( if self.on_build_event: self.on_build_event({"event": "before_script_done"}) + # Check for rename conflicts early, before any session mutation + if here: + session_name = self.session_config["session_name"] + if session.name != session_name: + existing = self.server.sessions.get( + session_name=session_name, default=None + ) + if existing is not None: + msg = f"cannot rename to {session_name!r}: session already exists" + raise exc.TmuxpException(msg) + session.rename_session(session_name) + if "options" in self.session_config: for option, value in self.session_config["options"].items(): self.session.set_option(option, value) @@ -575,17 +587,6 @@ def build( self.session_config["start_directory"], ) - if here: - session_name = self.session_config["session_name"] - if session.name != session_name: - existing = self.server.sessions.get( - session_name=session_name, default=None - ) - if existing is not None: - msg = f"cannot rename to {session_name!r}: session already exists" - raise exc.TmuxpException(msg) - session.rename_session(session_name) - for window, window_config in self.iter_create_windows( session, append, here=here ): From 8e4bfe516edb9edbfaf7e905f20eae5bbe3a2040 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 19:59:08 -0500 Subject: [PATCH 092/143] fix(cli[stop]): Exit with code 1 on SessionNotFound command_stop caught SessionNotFound and the outside-tmux case, printed the error, then returned with exit code 0. Exit non-zero so scripts can detect the failure. --- src/tmuxp/cli/stop.py | 5 +++-- tests/cli/test_stop.py | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py index 4ce9e2bd97..39a364b71c 100644 --- a/src/tmuxp/cli/stop.py +++ b/src/tmuxp/cli/stop.py @@ -5,6 +5,7 @@ import argparse import logging import os +import sys import typing as t from libtmux.server import Server @@ -116,13 +117,13 @@ def command_stop( tmuxp_echo( colors.error("No session name given and not inside tmux."), ) - return + sys.exit(1) if not session: raise exc.SessionNotFound(args.session_name) except TmuxpException as e: tmuxp_echo(colors.error(str(e))) - return + sys.exit(1) session_name = session.name diff --git a/tests/cli/test_stop.py b/tests/cli/test_stop.py index 0f8f3326ee..c02055ef9c 100644 --- a/tests/cli/test_stop.py +++ b/tests/cli/test_stop.py @@ -62,13 +62,15 @@ def test_stop_nonexistent_session( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - """Test stopping a session that doesn't exist shows error.""" + """Test stopping a session that doesn't exist exits with code 1.""" monkeypatch.delenv("TMUX", raising=False) assert server.socket_name is not None - cli.cli(["stop", "nonexistent", "-L", server.socket_name]) + with pytest.raises(SystemExit) as exc_info: + cli.cli(["stop", "nonexistent", "-L", server.socket_name]) + assert exc_info.value.code == 1 captured = capsys.readouterr() assert "Session not found" in captured.out @@ -146,8 +148,10 @@ def test_stop_no_args_outside_tmux_shows_error( server.new_session(session_name="should-survive") assert server.socket_name is not None - cli.cli(["stop", "-L", server.socket_name]) + with pytest.raises(SystemExit) as exc_info: + cli.cli(["stop", "-L", server.socket_name]) + assert exc_info.value.code == 1 captured = capsys.readouterr() assert "not inside tmux" in captured.out assert server.has_session("should-survive") From af3451b22c0ef70eefb3716f072d0e6a54e03a2e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 22 Mar 2026 19:59:43 -0500 Subject: [PATCH 093/143] fix(cli[load]): Add --detached to --here/--append mutual exclusion group --detached silently overrode --here (short-circuited in _dispatch_build before reaching the here check). Now all three load modes (-d, -a/ --append, --here) are in the same argparse mutually exclusive group. --- src/tmuxp/cli/load.py | 4 ++-- tests/cli/test_load.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 3d7327ec69..daaee7cf6c 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -898,13 +898,13 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP action="store_true", help="always answer yes", ) - parser.add_argument( + load_mode_group = parser.add_mutually_exclusive_group() + load_mode_group.add_argument( "-d", dest="detached", action="store_true", help="load the session without attaching it", ) - load_mode_group = parser.add_mutually_exclusive_group() load_mode_group.add_argument( "-a", "--append", diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 7bca0e7f0e..38e755c781 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1485,3 +1485,21 @@ def test_load_here_and_append_mutually_exclusive() -> None: parser = create_parser() with pytest.raises(SystemExit): parser.parse_args(["load", "--here", "--append", "myconfig"]) + + +def test_load_here_and_detached_mutually_exclusive() -> None: + """--here and -d cannot be used together.""" + from tmuxp.cli import create_parser + + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["load", "--here", "-d", "myconfig"]) + + +def test_load_append_and_detached_mutually_exclusive() -> None: + """--append and -d cannot be used together.""" + from tmuxp.cli import create_parser + + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["load", "--append", "-d", "myconfig"]) From 5ea65aafe2fba5fe05fd341918d1f6cac11a4fdb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 10:18:43 -0500 Subject: [PATCH 094/143] fix(builder[hooks]): Fix double-escaping in on_project_exit cwd shlex.quote(_start_dir) wraps the path in single quotes, then .replace("'", "'\\''") escapes all quotes in the joined string including those from shlex.quote. Use shlex.quote(_joined) for the outer run-shell quoting instead of manual replace + wrapping. --- src/tmuxp/workspace/builder.py | 6 ++-- tests/workspace/test_builder.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 2a69bd4540..1956d4be68 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -567,8 +567,10 @@ def build( _start_dir = self.session_config.get("start_directory") if _start_dir: _joined = f"cd {shlex.quote(_start_dir)} && {_joined}" - _escaped = _joined.replace("'", "'\\''") - self.session.set_hook("client-detached", f"run-shell '{_escaped}'") + self.session.set_hook( + "client-detached", + f"run-shell {shlex.quote(_joined)}", + ) # Store on_project_stop in session environment for tmuxp stop if "on_project_stop" in self.session_config: diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 96cc5e8236..f70692f834 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -2113,6 +2113,65 @@ def test_on_project_exit_hook_includes_cwd( builder.session.kill() +class OnProjectExitCwdSpecialFixture(t.NamedTuple): + """Test fixture for on_project_exit hook with special cwd characters.""" + + test_id: str + dir_name: str + expected_substring: str + + +ON_PROJECT_EXIT_CWD_SPECIAL_FIXTURES: list[OnProjectExitCwdSpecialFixture] = [ + OnProjectExitCwdSpecialFixture( + test_id="spaces_in_path", + dir_name="my project dir", + expected_substring="my project dir", + ), + OnProjectExitCwdSpecialFixture( + test_id="single_quote_in_path", + dir_name="it's a project", + expected_substring="a project", + ), +] + + +@pytest.mark.parametrize( + list(OnProjectExitCwdSpecialFixture._fields), + ON_PROJECT_EXIT_CWD_SPECIAL_FIXTURES, + ids=[f.test_id for f in ON_PROJECT_EXIT_CWD_SPECIAL_FIXTURES], +) +def test_on_project_exit_hook_cwd_special_chars( + server: Server, + tmp_path: pathlib.Path, + test_id: str, + dir_name: str, + expected_substring: str, +) -> None: + """on_project_exit hook correctly quotes start_directory with special chars.""" + special_dir = tmp_path / dir_name + special_dir.mkdir() + workspace: dict[str, t.Any] = { + "session_name": f"hook-exit-{test_id}", + "start_directory": str(special_dir), + "on_project_exit": "echo goodbye", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_values = [str(v) for v in hooks.values()] + matched = [v for v in hook_values if expected_substring in v] + assert len(matched) >= 1, ( + f"Expected {expected_substring!r} in hook values, got {hook_values}" + ) + + builder.session.kill() + + def test_on_project_stop_sets_environment( server: Server, ) -> None: From 515b59015a12beb0b47588a26f3217c6e977e47f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 10:20:54 -0500 Subject: [PATCH 095/143] fix(importers[tmuxinator]): Preserve passthrough on_project_start over legacy pre The passthrough block copies on_project_start from the tmuxinator config, then the pre handler unconditionally overwrites it. Guard the pre mapping with "on_project_start not in tmuxp_workspace" so the native key takes precedence over the legacy alias. --- src/tmuxp/workspace/importers.py | 2 +- tests/workspace/test_import_tmuxinator.py | 46 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index e25042f19e..a7be132d16 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -157,7 +157,7 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: # tmuxinator's pre is a raw shell command emitted as a line in a bash script. # on_project_start uses run_hook_commands(shell=True) which handles raw commands. # before_script requires a file path and would crash on raw commands. - if "pre" in workspace_dict: + if "pre" in workspace_dict and "on_project_start" not in tmuxp_workspace: pre_val = workspace_dict["pre"] if isinstance(pre_val, list): tmuxp_workspace["on_project_start"] = "; ".join(pre_val) diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 30b1e169eb..0a346982e6 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -472,6 +472,52 @@ def test_import_tmuxinator_pre_list_joined_for_on_project_start() -> None: loader.expand(result) +class PreVsPassthroughFixture(t.NamedTuple): + """Test fixture for pre vs on_project_start passthrough precedence.""" + + test_id: str + workspace: dict[str, t.Any] + expected_on_project_start: str + + +PRE_VS_PASSTHROUGH_FIXTURES: list[PreVsPassthroughFixture] = [ + PreVsPassthroughFixture( + test_id="passthrough_wins_over_pre", + workspace={ + "name": "both-keys", + "on_project_start": "echo native-start", + "pre": "echo legacy-pre", + "windows": [{"editor": "vim"}], + }, + expected_on_project_start="echo native-start", + ), + PreVsPassthroughFixture( + test_id="pre_maps_when_no_passthrough", + workspace={ + "name": "pre-only", + "pre": "echo starting", + "windows": [{"editor": "vim"}], + }, + expected_on_project_start="echo starting", + ), +] + + +@pytest.mark.parametrize( + list(PreVsPassthroughFixture._fields), + PRE_VS_PASSTHROUGH_FIXTURES, + ids=[f.test_id for f in PRE_VS_PASSTHROUGH_FIXTURES], +) +def test_import_tmuxinator_pre_vs_passthrough_on_project_start( + test_id: str, + workspace: dict[str, t.Any], + expected_on_project_start: str, +) -> None: + """Passthrough on_project_start takes precedence over legacy pre key.""" + result = importers.import_tmuxinator(workspace) + assert result["on_project_start"] == expected_on_project_start + + def test_import_tmuxinator_passthrough_pane_titles_and_hooks() -> None: """Pane title and lifecycle hook keys are copied through to tmuxp config.""" workspace = { From 539c403fec0508b09d58943a0c8bd480e6e51fb4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 10:22:27 -0500 Subject: [PATCH 096/143] docs(cli[load]): Correct --debug output stream from stderr to stdout --- docs/cli/load.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/load.md b/docs/cli/load.md index 8867cf3d39..7429e9351e 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -274,7 +274,7 @@ $ tmuxp load --no-shell-command-before myproject ## Debug mode -The `--debug` flag shows tmux commands as they execute. This disables the progress spinner and attaches a debug handler to libtmux's logger, printing each tmux command to stderr. +The `--debug` flag shows tmux commands as they execute. This disables the progress spinner and attaches a debug handler to libtmux's logger, printing each tmux command to stdout. ```console $ tmuxp load --debug myproject From 1d6a3457e7cbec1cf405cf1dc7daaaae6ee252fa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 10:31:25 -0500 Subject: [PATCH 097/143] fix(cli[load]): Add doctest for _TmuxCommandDebugHandler.emit() --- src/tmuxp/cli/load.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index daaee7cf6c..327e36099b 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -65,7 +65,33 @@ def __init__(self, colors: Colors) -> None: self._colors = colors def emit(self, record: logging.LogRecord) -> None: - """Print tmux command if present in the log record's extra fields.""" + """Print tmux command if present in the log record's extra fields. + + Examples + -------- + Handler prints the tmux command when present: + + >>> import logging + >>> from tmuxp.cli.load import _TmuxCommandDebugHandler + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> handler = _TmuxCommandDebugHandler(colors) + >>> record = logging.LogRecord( + ... name="test", level=logging.DEBUG, + ... pathname="", lineno=0, msg="", args=(), exc_info=None, + ... ) + >>> record.tmux_cmd = "list-sessions" + >>> handler.emit(record) + $ list-sessions + + No output when tmux_cmd is absent: + + >>> record2 = logging.LogRecord( + ... name="test", level=logging.DEBUG, + ... pathname="", lineno=0, msg="", args=(), exc_info=None, + ... ) + >>> handler.emit(record2) + """ cmd = getattr(record, "tmux_cmd", None) if cmd is not None: tmuxp_echo(self._colors.muted("$ ") + self._colors.info(str(cmd))) From ea107d3ab88d03adb8855a67e2ba632a78dcf4e7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 10:34:55 -0500 Subject: [PATCH 098/143] fix(cli[load]): Replace callable() doctests with real exercising doctests _load_here_in_current_session now demonstrates the ActiveSessionMissingWorkspaceException path when no tmux session is attached. _dispatch_build now builds a detached workspace using the server fixture. Add tmuxp.cli.load to DOCTEST_NEEDS_TMUX. --- conftest.py | 1 + src/tmuxp/cli/load.py | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/conftest.py b/conftest.py index 9f5e9fa64c..2d206d488a 100644 --- a/conftest.py +++ b/conftest.py @@ -114,6 +114,7 @@ def socket_name(request: pytest.FixtureRequest) -> str: # Modules that actually need tmux fixtures in their doctests DOCTEST_NEEDS_TMUX = { + "tmuxp.cli.load", "tmuxp.cli.stop", "tmuxp.workspace.builder", } diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 327e36099b..7a167f06da 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -358,9 +358,21 @@ def _load_here_in_current_session(builder: WorkspaceBuilder) -> None: Examples -------- + Raises when no attached tmux session is available: + + >>> from tmuxp.workspace.builder import WorkspaceBuilder >>> from tmuxp.cli.load import _load_here_in_current_session - >>> callable(_load_here_in_current_session) - True + >>> from tmuxp import exc + >>> config = { + ... 'session_name': 'here-doctest', + ... 'windows': [{'window_name': 'main'}], + ... } + >>> builder = WorkspaceBuilder(session_config=config, server=server) + >>> try: + ... _load_here_in_current_session(builder) + ... except exc.ActiveSessionMissingWorkspaceException: + ... print("raised") + raised """ current_attached_session = builder.find_current_attached_session() builder.build(current_attached_session, here=True) @@ -427,9 +439,29 @@ def _dispatch_build( Examples -------- + Build a minimal workspace in detached mode: + + >>> from tmuxp.workspace.builder import WorkspaceBuilder + >>> from tmuxp.workspace import loader >>> from tmuxp.cli.load import _dispatch_build - >>> callable(_dispatch_build) + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> config = loader.trickle(loader.expand({ + ... 'session_name': 'dispatch-doctest', + ... 'windows': [{'window_name': 'main'}], + ... })) + >>> builder = WorkspaceBuilder(session_config=config, server=server) + >>> colors = Colors(ColorMode.NEVER) + >>> result = _dispatch_build( + ... builder, + ... detached=True, + ... append=False, + ... answer_yes=False, + ... cli_colors=colors, + ... ) + Session created in detached state. + >>> result is not None True + >>> result.kill() """ try: if detached: From ea4562747119c992a6234992e5356f48df41fe25 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:19:24 -0500 Subject: [PATCH 099/143] fix(importers[tmuxinator]): Rename window_dict shadowing to tmuxp_window The inner loop reassigned window_dict (the loop variable) to a new dict for the tmuxp-format window. Rename to tmuxp_window to avoid confusion between the tmuxinator input dict and the tmuxp output dict. --- src/tmuxp/workspace/importers.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index a7be132d16..657e59add5 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -192,37 +192,39 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: for window_dict in workspace_dict["windows"]: for k, v in window_dict.items(): - window_dict = {"window_name": str(k) if k is not None else k} + tmuxp_window: dict[str, t.Any] = { + "window_name": str(k) if k is not None else k, + } if isinstance(v, str) or v is None: - window_dict["panes"] = [v] - tmuxp_workspace["windows"].append(window_dict) + tmuxp_window["panes"] = [v] + tmuxp_workspace["windows"].append(tmuxp_window) continue if isinstance(v, list): - window_dict["panes"] = _convert_named_panes(v) - tmuxp_workspace["windows"].append(window_dict) + tmuxp_window["panes"] = _convert_named_panes(v) + tmuxp_workspace["windows"].append(tmuxp_window) continue if "pre" in v: - window_dict["shell_command_before"] = v["pre"] + tmuxp_window["shell_command_before"] = v["pre"] if "panes" in v: - window_dict["panes"] = _convert_named_panes(v["panes"]) + tmuxp_window["panes"] = _convert_named_panes(v["panes"]) if "root" in v: - window_dict["start_directory"] = v["root"] + tmuxp_window["start_directory"] = v["root"] if "layout" in v: - window_dict["layout"] = v["layout"] + tmuxp_window["layout"] = v["layout"] if "synchronize" in v: sync = v["synchronize"] if sync is True or sync == "before": - window_dict.setdefault("options", {})["synchronize-panes"] = "on" + tmuxp_window.setdefault("options", {})["synchronize-panes"] = "on" elif sync == "after": - window_dict.setdefault("options_after", {})["synchronize-panes"] = ( - "on" - ) + tmuxp_window.setdefault("options_after", {})[ + "synchronize-panes" + ] = "on" - tmuxp_workspace["windows"].append(window_dict) + tmuxp_workspace["windows"].append(tmuxp_window) # Post-process startup_window / startup_pane into focus flags if _startup_window is not None and tmuxp_workspace["windows"]: From 3e95321b1f89032b1251d2b26ac77174aa79888b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:21:52 -0500 Subject: [PATCH 100/143] fix(loader): Guard expandshell against None window names tmuxinator configs can have null window keys (YAML ~). The importer preserves None as the window_name, but expandshell() expects str. Add isinstance guard before calling expandshell on window_name. --- src/tmuxp/workspace/loader.py | 4 +++- tests/workspace/test_import_tmuxinator.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 8f8b7401a8..7d2c6c5baa 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -158,7 +158,9 @@ def expand( if "session_name" in workspace_dict: workspace_dict["session_name"] = expandshell(workspace_dict["session_name"]) - if "window_name" in workspace_dict: + if "window_name" in workspace_dict and isinstance( + workspace_dict["window_name"], str + ): workspace_dict["window_name"] = expandshell(workspace_dict["window_name"]) if "environment" in workspace_dict: for key in workspace_dict["environment"]: diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 0a346982e6..fe3f6f3ab8 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -846,3 +846,21 @@ def test_import_tmuxinator_startup_window_index_resolution( assert len(warning_records) >= 1 else: assert len(warning_records) == 0 + + +def test_import_tmuxinator_none_window_name_no_crash() -> None: + """Tmuxinator config with None (null) window key imports without crashing.""" + workspace = { + "name": "null-window", + "windows": [{None: "vim"}], + } + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0]["window_name"] is None + assert result["windows"][0]["panes"] == ["vim"] + + # Verify expand + trickle don't crash on None window_name + from tmuxp.workspace import loader + + expanded = loader.expand(result) + loader.trickle(expanded) From 48019db398b5e44791db87c45808f4f55941ce7f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:23:43 -0500 Subject: [PATCH 101/143] fix(cli[load]): Rename t variable to srv to avoid typing module shadow The Server object was bound to t, shadowing the module-level import typing as t. Any later t.cast() etc. in the same scope would resolve to the Server instance and fail. --- src/tmuxp/cli/load.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 7a167f06da..5241305fc0 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -751,7 +751,7 @@ def _cleanup_debug() -> None: # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) - t = Server( # create tmux server object + srv = Server( # create tmux server object socket_name=socket_name, socket_path=socket_path, config_file=tmux_config_file, @@ -765,7 +765,7 @@ def _cleanup_debug() -> None: builder = WorkspaceBuilder( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), - server=t, + server=srv, ) except exc.EmptyWorkspaceException: logger.warning( From 4f0430a877ae45ac5918cc07777bb259f22aeea2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:31:22 -0500 Subject: [PATCH 102/143] fix(cli[copy,delete,new]): Exit with code 1 on error paths command_copy, command_delete, and command_new returned without sys.exit(1) on error conditions (source not found, editor not found, workspace not found). The CLI dispatcher also returned 0 when required positional args were missing. Match command_stop's pattern of calling sys.exit(1) on all failure paths. --- src/tmuxp/cli/__init__.py | 6 +-- src/tmuxp/cli/copy.py | 5 ++- src/tmuxp/cli/delete.py | 6 +++ src/tmuxp/cli/new.py | 2 + tests/cli/test_copy.py | 72 ++++++++++++++++++++++++++++++--- tests/cli/test_delete.py | 65 ++++++++++++++++++++++++++--- tests/cli/test_help_examples.py | 4 +- tests/cli/test_new.py | 67 ++++++++++++++++++++++++++---- 8 files changed, 201 insertions(+), 26 deletions(-) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index d8963128cf..76a16dfb37 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -422,7 +422,7 @@ def cli(_args: list[str] | None = None) -> None: elif args.subparser_name == "new": if not args.workspace_name: args.print_help() - return + sys.exit(1) command_new( workspace_name=args.workspace_name, parser=parser, @@ -431,7 +431,7 @@ def cli(_args: list[str] | None = None) -> None: elif args.subparser_name == "copy": if not args.source or not args.destination: args.print_help() - return + sys.exit(1) command_copy( source=args.source, destination=args.destination, @@ -441,7 +441,7 @@ def cli(_args: list[str] | None = None) -> None: elif args.subparser_name == "delete": if not args.workspace_names: args.print_help() - return + sys.exit(1) command_delete( workspace_names=args.workspace_names, answer_yes=args.answer_yes, diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py index ef09c0c98c..519e28cfa5 100644 --- a/src/tmuxp/cli/copy.py +++ b/src/tmuxp/cli/copy.py @@ -5,6 +5,7 @@ import logging import os import shutil +import sys import typing as t from tmuxp._internal.private_path import PrivatePath @@ -105,7 +106,7 @@ def command_copy( source_path = find_workspace_file(source) except FileNotFoundError: tmuxp_echo(colors.error(f"Source not found: {source}")) - return + sys.exit(1) if is_pure_name(destination): configdir_env = os.environ.get("TMUXP_CONFIGDIR") @@ -125,7 +126,7 @@ def command_copy( color_mode=color_mode, ): tmuxp_echo(colors.muted("Aborted.")) - return + sys.exit(1) shutil.copy2(source_path, dest_path) tmuxp_echo( diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py index 1742c04a8a..53e37e97b3 100644 --- a/src/tmuxp/cli/delete.py +++ b/src/tmuxp/cli/delete.py @@ -4,6 +4,7 @@ import logging import os +import sys import typing as t from tmuxp._internal.private_path import PrivatePath @@ -100,11 +101,13 @@ def command_delete( color_mode = get_color_mode(color) colors = Colors(color_mode) + _had_error = False for name in workspace_names: try: workspace_path = find_workspace_file(name) except FileNotFoundError: tmuxp_echo(colors.warning(f"Workspace not found: {name}")) + _had_error = True continue if not answer_yes and not prompt_yes_no( @@ -119,3 +122,6 @@ def command_delete( tmuxp_echo( colors.success("Deleted ") + colors.info(str(PrivatePath(workspace_path))), ) + + if _had_error: + sys.exit(1) diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py index 22afbd5da5..92677d7c22 100644 --- a/src/tmuxp/cli/new.py +++ b/src/tmuxp/cli/new.py @@ -6,6 +6,7 @@ import os import shlex import subprocess +import sys import typing as t from tmuxp._internal.private_path import PrivatePath @@ -131,3 +132,4 @@ def command_new( + colors.info(sys_editor) + colors.muted(" (set $EDITOR to a valid editor)"), ) + sys.exit(1) diff --git a/tests/cli/test_copy.py b/tests/cli/test_copy.py index 7fe1817415..70bc43400f 100644 --- a/tests/cli/test_copy.py +++ b/tests/cli/test_copy.py @@ -67,17 +67,20 @@ def test_copy( source_path = config_dir / f"{source_name}.yaml" source_path.write_text(source_content) - cli.cli(cli_args) - - captured = capsys.readouterr() - dest_path = config_dir / f"{dest_name}.yaml" - if expect_copied: + cli.cli(cli_args) + + captured = capsys.readouterr() + dest_path = config_dir / f"{dest_name}.yaml" assert dest_path.exists() assert dest_path.read_text() == source_content assert "Copied" in captured.out else: - assert not dest_path.exists() + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() assert "not found" in captured.out.lower() @@ -156,3 +159,60 @@ def test_copy_respects_tmuxp_configdir( expected = config_dir / "myworkspace.yaml" assert expected.exists(), f"expected {expected} to exist" assert expected.read_text() == "session_name: copied\n" + + +class CopyExitCodeFixture(t.NamedTuple): + """Test fixture for tmuxp copy error exit codes.""" + + test_id: str + cli_args: list[str] + expected_exit_code: int + expected_output_fragment: str + + +COPY_EXIT_CODE_FIXTURES: list[CopyExitCodeFixture] = [ + CopyExitCodeFixture( + test_id="missing_source", + cli_args=["copy", "nonexistent", "dst"], + expected_exit_code=1, + expected_output_fragment="Source not found", + ), + CopyExitCodeFixture( + test_id="no_args", + cli_args=["copy"], + expected_exit_code=1, + expected_output_fragment="", + ), + CopyExitCodeFixture( + test_id="missing_destination", + cli_args=["copy", "src"], + expected_exit_code=1, + expected_output_fragment="", + ), +] + + +@pytest.mark.parametrize( + list(CopyExitCodeFixture._fields), + COPY_EXIT_CODE_FIXTURES, + ids=[f.test_id for f in COPY_EXIT_CODE_FIXTURES], +) +def test_copy_error_exits_nonzero( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + expected_exit_code: int, + expected_output_fragment: str, +) -> None: + """Tmuxp copy exits with code 1 on error conditions.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == expected_exit_code + if expected_output_fragment: + captured = capsys.readouterr() + assert expected_output_fragment in captured.out diff --git a/tests/cli/test_delete.py b/tests/cli/test_delete.py index 986a19ff0b..12c87075c1 100644 --- a/tests/cli/test_delete.py +++ b/tests/cli/test_delete.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pathlib import typing as t import pytest @@ -61,15 +62,18 @@ def test_delete( if file_exists: workspace_path.write_text("session_name: target\n") - cli.cli(cli_args) - - captured = capsys.readouterr() - if expect_deleted: + cli.cli(cli_args) + + captured = capsys.readouterr() assert not workspace_path.exists() assert "Deleted" in captured.out else: - assert not workspace_path.exists() + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() assert "not found" in captured.out.lower() @@ -93,3 +97,54 @@ def test_delete_multiple( captured = capsys.readouterr() assert captured.out.count("Deleted") == 2 + + +class DeleteExitCodeFixture(t.NamedTuple): + """Test fixture for tmuxp delete error exit codes.""" + + test_id: str + cli_args: list[str] + expected_exit_code: int + expected_output_fragment: str + + +DELETE_EXIT_CODE_FIXTURES: list[DeleteExitCodeFixture] = [ + DeleteExitCodeFixture( + test_id="nonexistent_workspace", + cli_args=["delete", "-y", "nonexistent"], + expected_exit_code=1, + expected_output_fragment="Workspace not found", + ), + DeleteExitCodeFixture( + test_id="no_args", + cli_args=["delete"], + expected_exit_code=1, + expected_output_fragment="", + ), +] + + +@pytest.mark.parametrize( + list(DeleteExitCodeFixture._fields), + DELETE_EXIT_CODE_FIXTURES, + ids=[f.test_id for f in DELETE_EXIT_CODE_FIXTURES], +) +def test_delete_error_exits_nonzero( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + expected_exit_code: int, + expected_output_fragment: str, +) -> None: + """Tmuxp delete exits with code 1 on error conditions.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == expected_exit_code + if expected_output_fragment: + captured = capsys.readouterr() + assert expected_output_fragment in captured.out diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index a0a314a77d..6ccf247ef7 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -302,14 +302,14 @@ def test_search_no_args_shows_help() -> None: @pytest.mark.parametrize("subcommand", ["new", "copy", "delete"]) def test_new_commands_no_args_shows_help(subcommand: str) -> None: - """Running new commands with no args shows help.""" + """Running new commands with no args shows help and exits 1.""" result = subprocess.run( ["tmuxp", subcommand], capture_output=True, text=True, ) assert f"usage: tmuxp {subcommand}" in result.stdout - assert result.returncode == 0 + assert result.returncode == 1 def test_main_help_example_sections_have_examples_suffix() -> None: diff --git a/tests/cli/test_new.py b/tests/cli/test_new.py index 4fcc2db728..bc88f8b383 100644 --- a/tests/cli/test_new.py +++ b/tests/cli/test_new.py @@ -109,20 +109,15 @@ class EditorFixture(t.NamedTuple): EDITOR_FIXTURES: list[EditorFixture] = [ EditorFixture( - test_id="valid-editor", + test_id="valid_editor", editor="true", expect_error_output=False, ), EditorFixture( - test_id="editor-with-flags", + test_id="editor_with_flags", editor="true --ignored-flag", expect_error_output=False, ), - EditorFixture( - test_id="missing-editor", - editor="nonexistent_editor_binary_xyz", - expect_error_output=True, - ), ] @@ -139,7 +134,7 @@ def test_new_editor_handling( editor: str, expect_error_output: bool, ) -> None: - """Test EDITOR handling: flags, missing binary, valid editor.""" + """Test EDITOR handling: flags and valid editor.""" monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) monkeypatch.setenv("EDITOR", editor) @@ -153,3 +148,59 @@ def test_new_editor_handling( assert "Editor not found" in captured.out else: assert "Editor not found" not in captured.out + + +class NewExitCodeFixture(t.NamedTuple): + """Test fixture for tmuxp new error exit codes.""" + + test_id: str + cli_args: list[str] + editor: str + expected_exit_code: int + expected_output_fragment: str + + +NEW_EXIT_CODE_FIXTURES: list[NewExitCodeFixture] = [ + NewExitCodeFixture( + test_id="no_args", + cli_args=["new"], + editor="true", + expected_exit_code=1, + expected_output_fragment="", + ), + NewExitCodeFixture( + test_id="missing_editor", + cli_args=["new", "editortest_missing"], + editor="nonexistent_editor_binary_xyz", + expected_exit_code=1, + expected_output_fragment="Editor not found", + ), +] + + +@pytest.mark.parametrize( + list(NewExitCodeFixture._fields), + NEW_EXIT_CODE_FIXTURES, + ids=[f.test_id for f in NEW_EXIT_CODE_FIXTURES], +) +def test_new_error_exits_nonzero( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + editor: str, + expected_exit_code: int, + expected_output_fragment: str, +) -> None: + """Tmuxp new exits with code 1 on error conditions.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + monkeypatch.setenv("EDITOR", editor) + + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == expected_exit_code + if expected_output_fragment: + captured = capsys.readouterr() + assert expected_output_fragment in captured.out From af85952e9394bab4bb297eeeae8c6142465a1ac6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:33:34 -0500 Subject: [PATCH 103/143] fix(cli[copy]): Preserve source file extension for pure-name destinations copy.py hardcoded .yaml extension for destination when given a pure name. Now uses os.path.splitext to preserve the source extension (e.g. copying a .json workspace keeps .json, not .yaml). --- src/tmuxp/cli/copy.py | 3 ++- tests/cli/test_copy.py | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py index 519e28cfa5..c248fb7c5c 100644 --- a/src/tmuxp/cli/copy.py +++ b/src/tmuxp/cli/copy.py @@ -114,7 +114,8 @@ def command_copy( os.path.expanduser(configdir_env) if configdir_env else get_workspace_dir() ) os.makedirs(workspace_dir, exist_ok=True) - dest_path = os.path.join(workspace_dir, f"{destination}.yaml") + _, src_ext = os.path.splitext(source_path) + dest_path = os.path.join(workspace_dir, f"{destination}{src_ext or '.yaml'}") else: dest_path = os.path.expanduser(destination) if not os.path.isabs(dest_path): diff --git a/tests/cli/test_copy.py b/tests/cli/test_copy.py index 70bc43400f..5266c56ab0 100644 --- a/tests/cli/test_copy.py +++ b/tests/cli/test_copy.py @@ -161,6 +161,61 @@ def test_copy_respects_tmuxp_configdir( assert expected.read_text() == "session_name: copied\n" +class CopyExtensionFixture(t.NamedTuple): + """Test fixture for source extension preservation in copy.""" + + test_id: str + source_ext: str + expected_dest_ext: str + + +COPY_EXTENSION_FIXTURES: list[CopyExtensionFixture] = [ + CopyExtensionFixture( + test_id="yaml_source", + source_ext=".yaml", + expected_dest_ext=".yaml", + ), + CopyExtensionFixture( + test_id="json_source", + source_ext=".json", + expected_dest_ext=".json", + ), + CopyExtensionFixture( + test_id="yml_source", + source_ext=".yml", + expected_dest_ext=".yml", + ), +] + + +@pytest.mark.parametrize( + list(CopyExtensionFixture._fields), + COPY_EXTENSION_FIXTURES, + ids=[f.test_id for f in COPY_EXTENSION_FIXTURES], +) +def test_copy_preserves_source_extension( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + source_ext: str, + expected_dest_ext: str, +) -> None: + """Copy uses the source file extension when destination is a pure name.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + source_content = '{"session_name": "test"}\n' + source_path = config_dir / f"src{source_ext}" + source_path.write_text(source_content) + + cli.cli(["copy", str(source_path), "dst"]) + + expected = config_dir / f"dst{expected_dest_ext}" + assert expected.exists(), f"expected {expected} to exist" + assert expected.read_text() == source_content + + class CopyExitCodeFixture(t.NamedTuple): """Test fixture for tmuxp copy error exit codes.""" From 8699f66f5d7fb5775ad4ed9ae1cd58b15fde944e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:38:00 -0500 Subject: [PATCH 104/143] fix(cli[new]): Validate workspace name against path traversal and YAML-hostile chars Reject names containing path separators (a/b), parent references (..), YAML reserved words (yes/true/null), and YAML special characters (#*&!|>'"%). Quote session_name in the YAML template to handle remaining edge cases. --- src/tmuxp/cli/new.py | 38 +++++++++++++++++++++++- tests/cli/test_new.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py index 92677d7c22..85956f9552 100644 --- a/src/tmuxp/cli/new.py +++ b/src/tmuxp/cli/new.py @@ -4,6 +4,7 @@ import logging import os +import re import shlex import subprocess import sys @@ -18,13 +19,43 @@ logger = logging.getLogger(__name__) WORKSPACE_TEMPLATE = """\ -session_name: {name} +session_name: '{name}' windows: - window_name: main panes: - """ +_YAML_RESERVED_RE = re.compile( + r"^(true|false|yes|no|on|off|null|~)$", + re.IGNORECASE, +) + + +def _validate_workspace_name(name: str) -> str | None: + """Return error message if name is invalid for a workspace, None if OK. + + Examples + -------- + >>> from tmuxp.cli.new import _validate_workspace_name + >>> _validate_workspace_name("myproject") is None + True + >>> _validate_workspace_name("../escape") is not None + True + >>> _validate_workspace_name("yes") is not None + True + """ + if os.sep in name or (os.altsep and os.altsep in name): + return f"workspace name must not contain path separators: {name!r}" + if ".." in name: + return f"workspace name must not contain '..': {name!r}" + if _YAML_RESERVED_RE.match(name): + return f"workspace name is a YAML reserved word: {name!r}" + if name.startswith(("#", "*", "&", "!", "|", ">", "'", '"', "%", "@", "`")): + return f"workspace name starts with YAML special character: {name!r}" + return None + + NEW_DESCRIPTION = build_description( """ Create a new workspace config from a minimal template. @@ -99,6 +130,11 @@ def command_new( color_mode = get_color_mode(color) colors = Colors(color_mode) + err = _validate_workspace_name(workspace_name) + if err: + tmuxp_echo(colors.error(err)) + sys.exit(1) + # Use TMUXP_CONFIGDIR directly if set, since get_workspace_dir() # only returns it when the directory already exists. The new command # needs to create files there even if it doesn't exist yet. diff --git a/tests/cli/test_new.py b/tests/cli/test_new.py index bc88f8b383..c2573ebcf0 100644 --- a/tests/cli/test_new.py +++ b/tests/cli/test_new.py @@ -204,3 +204,70 @@ def test_new_error_exits_nonzero( if expected_output_fragment: captured = capsys.readouterr() assert expected_output_fragment in captured.out + + +class NewNameValidationFixture(t.NamedTuple): + """Test fixture for workspace name validation.""" + + test_id: str + workspace_name: str + expected_error_fragment: str + + +NEW_NAME_VALIDATION_FIXTURES: list[NewNameValidationFixture] = [ + NewNameValidationFixture( + test_id="path_traversal", + workspace_name="../outside", + expected_error_fragment="path separators", + ), + NewNameValidationFixture( + test_id="yaml_boolean_yes", + workspace_name="yes", + expected_error_fragment="YAML reserved word", + ), + NewNameValidationFixture( + test_id="yaml_boolean_true", + workspace_name="true", + expected_error_fragment="YAML reserved word", + ), + NewNameValidationFixture( + test_id="yaml_comment", + workspace_name="#tmp", + expected_error_fragment="YAML special character", + ), + NewNameValidationFixture( + test_id="yaml_alias", + workspace_name="*alias", + expected_error_fragment="YAML special character", + ), + NewNameValidationFixture( + test_id="path_separator", + workspace_name="a/b", + expected_error_fragment="path separators", + ), +] + + +@pytest.mark.parametrize( + list(NewNameValidationFixture._fields), + NEW_NAME_VALIDATION_FIXTURES, + ids=[f.test_id for f in NEW_NAME_VALIDATION_FIXTURES], +) +def test_new_rejects_invalid_workspace_name( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + workspace_name: str, + expected_error_fragment: str, +) -> None: + """Tmuxp new rejects path traversal and YAML-hostile workspace names.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + monkeypatch.setenv("EDITOR", "true") + + with pytest.raises(SystemExit) as exc_info: + cli.cli(["new", workspace_name]) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert expected_error_fragment in captured.out From a06d8f250c5f20f6b3b03ae001de949233bc15b9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:40:20 -0500 Subject: [PATCH 105/143] fix(util): Log hook stdout/stderr at DEBUG level on non-zero exit run_hook_commands() captured output but discarded it. Now logs stdout and stderr at DEBUG when the hook fails, aiding diagnosis. --- src/tmuxp/util.py | 4 ++++ tests/test_util.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 6e6cbc8334..77ff99206a 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -166,6 +166,10 @@ def run_hook_commands( result.returncode, extra={"tmux_exit_code": result.returncode}, ) + if result.stdout: + logger.debug("hook stdout: %s", result.stdout.rstrip()) + if result.stderr: + logger.debug("hook stderr: %s", result.stderr.rstrip()) def oh_my_zsh_auto_title() -> None: diff --git a/tests/test_util.py b/tests/test_util.py index fe99990322..382fe0c5ca 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -330,3 +330,19 @@ def test_run_hook_commands_missing_cwd_warns( warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert len(warning_records) >= 1 assert "bad cwd or shell" in warning_records[0].message + + +def test_run_hook_commands_failure_logs_output_at_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """run_hook_commands() logs stdout/stderr at DEBUG when hook fails.""" + with caplog.at_level(logging.DEBUG, logger="tmuxp.util"): + run_hook_commands("echo HOOK_OUT && echo HOOK_ERR >&2 && exit 1") + + debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] + stdout_records = [r for r in debug_records if "hook stdout" in r.message] + stderr_records = [r for r in debug_records if "hook stderr" in r.message] + assert len(stdout_records) >= 1 + assert "HOOK_OUT" in stdout_records[0].message + assert len(stderr_records) >= 1 + assert "HOOK_ERR" in stderr_records[0].message From b95cb9c27c59fff49f3ac5b01b72e88d5bc65609 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:41:02 -0500 Subject: [PATCH 106/143] docs(cli[load],CHANGES): Clarify template syntax is simple variable substitution The implementation is a regex-based {{ variable }} replacer, not Jinja2. No filters, conditions, loops, or dotted names. --- CHANGES | 2 +- docs/cli/load.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 325416af04..30d41a0cb6 100644 --- a/CHANGES +++ b/CHANGES @@ -83,7 +83,7 @@ hook system: - `on_project_stop` — runs before `tmuxp stop` kills the session ### Config templating (#1025) -Workspace configs now support Jinja2-style `{{ variable }}` placeholders. +Workspace configs now support simple `{{ variable }}` placeholders for variable substitution. Pass values via `--set KEY=VALUE` on the command line: ```console diff --git a/docs/cli/load.md b/docs/cli/load.md index 7429e9351e..e932a6f001 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -282,7 +282,7 @@ $ tmuxp load --debug myproject ## Config templating -Workspace configs support Jinja2-style `{{ variable }}` placeholders. Pass values via `--set KEY=VALUE`: +Workspace configs support simple `{{ variable }}` placeholders for variable substitution. Pass values via `--set KEY=VALUE`: ```console $ tmuxp load --set project=myapp mytemplate.yaml From 0e81c23d0f2e432f8883cad16596972bd830d4be Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:41:23 -0500 Subject: [PATCH 107/143] docs(configuration): Add note that on_project_exit fires on any client detach --- docs/configuration/top-level.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 5d132aade7..aa7bb521be 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -76,6 +76,10 @@ on_project_start: These hooks correspond to tmuxinator's `on_project_start`, `on_project_restart`, `on_project_exit`, and `on_project_stop` keys. ``` +```{note} +`on_project_exit` uses tmux's `client-detached` hook, which fires on **any** client detach — including terminal close, SSH disconnect, or manual `tmux detach`. This matches tmuxinator's behavior. +``` + ## Pane titles Enable pane border titles to display labels on each pane: From 1da5c94be86d81d6f64cf3be2bec2ab106e7ea4d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:43:43 -0500 Subject: [PATCH 108/143] docs(comparison,cli[load]): Add --debug semantic footnote, --here shell note, fix version - Add note that --debug executes (unlike tmuxinator debug which is dry-run) - Document --here POSIX shell assumption - Replace unreleased 1.68.0 with "Next" in comparison table --- docs/cli/load.md | 4 ++++ docs/comparison.md | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/cli/load.md b/docs/cli/load.md index e932a6f001..fed1a91e0d 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -264,6 +264,10 @@ $ tmuxp load --here . When used, tmuxp builds the workspace panes inside the current window rather than spawning a new session. +```{note} +`--here` sends shell commands (such as `cd` and `export` for environment variables) directly to the active pane via `send-keys`. The pane must be running a POSIX-compatible shell (bash, zsh, etc.). If the active pane is running a non-shell program (e.g., `vim`, `python`), those commands will be interpreted as input to that program. +``` + ## Skipping shell_command_before The `--no-shell-command-before` flag skips all `shell_command_before` entries at every level (session, window, pane). This is useful for quick reloads when the setup commands (virtualenv activation, etc.) are already active. diff --git a/docs/comparison.md b/docs/comparison.md index 783c9031c6..b5d8fee2e9 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -6,7 +6,7 @@ | | tmuxp | tmuxinator | teamocil | |---|---|---|---| -| **Version** | 1.68.0 | 3.3.7 | 1.4.2 | +| **Version** | Next | 3.3.7 | 1.4.2 | | **Language** | Python | Ruby | Ruby | | **Min tmux** | 3.2 | 1.5+ (1.5–3.6a tested) | (not specified) | | **Config formats** | YAML, JSON | YAML (with ERB) | YAML | @@ -166,6 +166,10 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Load multiple configs | `tmuxp load f1 f2 ...` (all but last detached) | (none) | (none) | | Local config | `tmuxp load .` | `tmuxinator local` | (none) | +```{note} +**`--debug` semantics differ**: `tmuxp load --debug` *executes* the workspace and shows each tmux command on stdout. `tmuxinator debug ` performs a *dry run* and prints the generated bash script without executing it. +``` + ## Config File Discovery | Feature | tmuxp | tmuxinator | teamocil | From 4abd889ab581180c787156b6e94a47752786dd28 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:45:34 -0500 Subject: [PATCH 109/143] chore: Exclude notes/ and .claude/commands/ from version control Development planning artifacts and Claude Code command prompts should not ship in releases. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 9dadb843bd..a4fb3479c9 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,7 @@ docs/_static/css/fonts.css **/CLAUDE.local.md **/CLAUDE.*.md **/.claude/settings.local.json +.claude/commands/ + +# Development notes +notes/ From ecd33778af547991206157f2bfdb33d9020ae10b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:46:40 -0500 Subject: [PATCH 110/143] fix(cli[load]): Skip (k)ill option in --here error recovery In --here mode, builder.session is the user's existing live session, not a scratch session tmuxp created. The generic error recovery prompt offering (k)ill would destroy the user's real work. Skip the kill option and default to (d)etach when --here mode encounters a build error. --- src/tmuxp/cli/load.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 5241305fc0..adcb378b39 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -521,13 +521,21 @@ def _dispatch_build( logger.debug("workspace build failed", exc_info=True) tmuxp_echo(cli_colors.error("[Error]") + f" {e}") - choice = prompt_choices( - cli_colors.error("Error loading workspace.") - + " (k)ill, (a)ttach, (d)etach?", - choices=["k", "a", "d"], - default="k", - color_mode=cli_colors.mode, - ) + if here: + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + " (a)ttach, (d)etach?", + choices=["a", "d"], + default="d", + color_mode=cli_colors.mode, + ) + else: + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", + choices=["k", "a", "d"], + default="k", + color_mode=cli_colors.mode, + ) if choice == "k": if builder.session is not None: From 3f24e8bad3830c660e8471eae0a6ded3714ddbc9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:49:05 -0500 Subject: [PATCH 111/143] fix(util): Raise SessionNotFound instead of falling back to sessions[0] get_session() previously fell back to server.sessions[0] when TMUX_PANE was unset or stale. For destructive commands like `tmuxp stop`, this could kill the wrong session. Now raises SessionNotFound so callers must handle the error explicitly. --- src/tmuxp/util.py | 9 +++++---- tests/test_util.py | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 77ff99206a..b6a7b80477 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -214,6 +214,7 @@ def get_session( current_pane: Pane | None = None, ) -> Session: """Get tmux session for server by session name, respects current pane, if passed.""" + session: Session | None = None try: if session_name: session = server.sessions.get(session_name=session_name) @@ -223,15 +224,15 @@ def get_session( current_pane = get_current_pane(server) if current_pane: session = server.sessions.get(session_id=current_pane.session_id) - else: - session = server.sessions[0] - except Exception as e: if session_name: raise exc.SessionNotFound(session_name) from e raise exc.SessionNotFound from e - assert session is not None + if session is None: + if session_name: + raise exc.SessionNotFound(session_name) + raise exc.SessionNotFound return session diff --git a/tests/test_util.py b/tests/test_util.py index 382fe0c5ca..9d8b95e7f3 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -162,19 +162,20 @@ def test_get_session_should_default_to_local_attached_session( assert get_session(server) == second_session -def test_get_session_should_return_first_session_if_no_active_session( +def test_get_session_raises_when_no_active_pane( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """get_session() should return first session if no active session.""" + """get_session() should raise SessionNotFound when TMUX_PANE is unset.""" # Clear outer tmux environment to ensure no active pane interferes monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) - first_session = server.new_session(session_name="myfirstsession") + server.new_session(session_name="myfirstsession") server.new_session(session_name="mysecondsession") - assert get_session(server) == first_session + with pytest.raises(exc.SessionNotFound): + get_session(server) def test_get_pane_logs_debug_on_failure( From 80c7908e9f6c3cc5f3fb32405afc9bb44865ee51 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:51:38 -0500 Subject: [PATCH 112/143] fix(cli[load],docs): Tighten lifecycle hook semantics and parity claims - on_project_start: now only fires on new session creation (not reattach) - on_project_restart: now only fires on confirmed interactive reattach (not detached loads of existing sessions) - Docs: remove "matches tmuxinator" claim for on_project_exit; document it as tmuxp-specific behavior that fires on every detach event - CHANGES: correct hook descriptions to match new semantics - Update test to assert detached loads do NOT trigger restart hook --- CHANGES | 9 ++++----- docs/configuration/top-level.md | 10 ++++++---- src/tmuxp/cli/load.py | 11 ++--------- tests/cli/test_load.py | 5 +++-- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/CHANGES b/CHANGES index 30d41a0cb6..829a1eaea5 100644 --- a/CHANGES +++ b/CHANGES @@ -74,12 +74,11 @@ $ tmuxp delete old-project ``` ### Lifecycle hooks (#1025) -Workspace configs now support four lifecycle hooks, matching tmuxinator's -hook system: +Workspace configs now support four lifecycle hooks inspired by tmuxinator: -- `on_project_start` — runs before session build (every invocation) -- `on_project_restart` — runs when reattaching to an existing session -- `on_project_exit` — runs on client detach (via tmux `client-detached` hook) +- `on_project_start` — runs before session build (new session creation only) +- `on_project_restart` — runs when reattaching to an existing session (confirmed attach only) +- `on_project_exit` — runs on client detach (via tmux `client-detached` hook; fires on every detach event) - `on_project_stop` — runs before `tmuxp stop` kills the session ### Config templating (#1025) diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index aa7bb521be..8e426c2bc5 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -59,8 +59,8 @@ windows: | Hook | When it runs | |------|-------------| -| `on_project_start` | Before session build, every `tmuxp load` invocation | -| `on_project_restart` | When reattaching to an existing session | +| `on_project_start` | Before session build (new session creation only) | +| `on_project_restart` | When reattaching to an existing session (confirmed attach only) | | `on_project_exit` | On client detach (tmux `client-detached` hook) | | `on_project_stop` | Before `tmuxp stop` kills the session | @@ -73,11 +73,13 @@ on_project_start: ``` ```{note} -These hooks correspond to tmuxinator's `on_project_start`, `on_project_restart`, `on_project_exit`, and `on_project_stop` keys. +These hooks are inspired by tmuxinator's lifecycle hooks but have tmuxp-specific semantics. +`on_project_start` only fires on new session creation (not on reattach). +`on_project_restart` only fires when you confirm reattaching to an existing session. ``` ```{note} -`on_project_exit` uses tmux's `client-detached` hook, which fires on **any** client detach — including terminal close, SSH disconnect, or manual `tmux detach`. This matches tmuxinator's behavior. +`on_project_exit` uses tmux's `client-detached` hook, which fires on **any** client detach — including terminal close, SSH disconnect, or manual `tmux detach`. Note: unlike tmuxinator (which fires `on_project_exit` once when the wrapper script exits), tmuxp's hook fires on every detach event for the lifetime of the session. ``` ## Pane titles diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index adcb378b39..e11e69b7a6 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -799,21 +799,14 @@ def _cleanup_debug() -> None: color_mode=cli_colors.mode, ) ) - if _confirmed or detached: - if "on_project_start" in expanded_workspace: - _hook_cwd = expanded_workspace.get("start_directory") - util.run_hook_commands( - expanded_workspace["on_project_start"], - cwd=_hook_cwd, - ) - # Run on_project_restart hook — fires when reattaching + # Run on_project_restart hook — only when actually reattaching + if _confirmed: if "on_project_restart" in expanded_workspace: _hook_cwd = expanded_workspace.get("start_directory") util.run_hook_commands( expanded_workspace["on_project_restart"], cwd=_hook_cwd, ) - if _confirmed: _reattach(builder, cli_colors) _cleanup_debug() return None diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 38e755c781..cbf05d9044 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1203,13 +1203,14 @@ def test_load_on_project_restart_runs_hook( assert session is not None assert not marker.exists() - # Second load triggers on_project_restart (session already exists) + # Second detached load does NOT trigger on_project_restart + # (restart hook only fires on confirmed interactive reattach) load_workspace( workspace_file, socket_name=server.socket_name, detached=True, ) - assert marker.exists() + assert not marker.exists() session.kill() From 584d457630c3265a53a3af385ba562b254abef22 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:52:51 -0500 Subject: [PATCH 113/143] fix(importers): Warn on numeric startup_window/startup_pane index resolution Numeric startup_window/startup_pane values are resolved as 0-based Python list indices, which may differ from tmuxinator's tmux base-index semantics. Upgrade log level from INFO to WARNING to make this deviation explicit to users importing tmuxinator configs. --- src/tmuxp/workspace/importers.py | 14 ++++++++------ tests/workspace/test_import_tmuxinator.py | 8 ++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 657e59add5..7cb7508abf 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -239,9 +239,10 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: _idx = int(_startup_window) if 0 <= _idx < len(tmuxp_workspace["windows"]): tmuxp_workspace["windows"][_idx]["focus"] = True - logger.info( - "startup_window %r resolved as 0-based list index; " - "use window name for unambiguous matching across tools", + logger.warning( + "startup_window %r resolved as 0-based list index, " + "which may differ from tmuxinator's tmux base-index " + "semantics; use window name for reliable matching", _startup_window, ) else: @@ -273,9 +274,10 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: "shell_command": [_pane] if _pane else [], "focus": True, } - logger.info( - "startup_pane %r resolved as 0-based list index; " - "use window name + pane index for clarity", + logger.warning( + "startup_pane %r resolved as 0-based list index, " + "which may differ from tmuxinator's tmux " + "pane-base-index semantics", _startup_pane, ) else: diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index fe3f6f3ab8..329b623e88 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -766,16 +766,16 @@ class StartupIndexFixture(t.NamedTuple): startup_window=0, window_names=["win1", "win2"], expected_focus_index=0, - expect_info_log=True, - expect_warning_log=False, + expect_info_log=False, + expect_warning_log=True, ), StartupIndexFixture( test_id="numeric-one", startup_window=1, window_names=["win1", "win2"], expected_focus_index=1, - expect_info_log=True, - expect_warning_log=False, + expect_info_log=False, + expect_warning_log=True, ), StartupIndexFixture( test_id="out-of-range", From e58cd829ae438a83aa5f615d2f09d1d856626089 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:53:59 -0500 Subject: [PATCH 114/143] fix(cli[new]): Reject quotes in workspace names to prevent invalid YAML The WORKSPACE_TEMPLATE uses single-quoted YAML for session_name, so embedded quotes like foo'bar produce invalid YAML. Add validation to reject names containing single or double quotes. --- src/tmuxp/cli/new.py | 4 ++++ tests/cli/test_new.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py index 85956f9552..1491172eec 100644 --- a/src/tmuxp/cli/new.py +++ b/src/tmuxp/cli/new.py @@ -44,6 +44,8 @@ def _validate_workspace_name(name: str) -> str | None: True >>> _validate_workspace_name("yes") is not None True + >>> _validate_workspace_name("foo'bar") is not None + True """ if os.sep in name or (os.altsep and os.altsep in name): return f"workspace name must not contain path separators: {name!r}" @@ -53,6 +55,8 @@ def _validate_workspace_name(name: str) -> str | None: return f"workspace name is a YAML reserved word: {name!r}" if name.startswith(("#", "*", "&", "!", "|", ">", "'", '"', "%", "@", "`")): return f"workspace name starts with YAML special character: {name!r}" + if "'" in name or '"' in name: + return f"workspace name must not contain quotes: {name!r}" return None diff --git a/tests/cli/test_new.py b/tests/cli/test_new.py index c2573ebcf0..781a28f663 100644 --- a/tests/cli/test_new.py +++ b/tests/cli/test_new.py @@ -245,6 +245,16 @@ class NewNameValidationFixture(t.NamedTuple): workspace_name="a/b", expected_error_fragment="path separators", ), + NewNameValidationFixture( + test_id="single_quote", + workspace_name="foo'bar", + expected_error_fragment="quotes", + ), + NewNameValidationFixture( + test_id="double_quote", + workspace_name='foo"bar', + expected_error_fragment="quotes", + ), ] From 36cccd4b0712564d187182b00e0c31a72e3a4898 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:55:03 -0500 Subject: [PATCH 115/143] fix(cli[copy]): Guard against self-copy with resolved real path check Compare os.path.realpath() of source and destination before calling shutil.copy2() to prevent unhandled SameFileError when copying a workspace to itself. --- src/tmuxp/cli/copy.py | 7 +++++++ tests/cli/test_copy.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py index c248fb7c5c..dc3add31c2 100644 --- a/src/tmuxp/cli/copy.py +++ b/src/tmuxp/cli/copy.py @@ -121,6 +121,13 @@ def command_copy( if not os.path.isabs(dest_path): dest_path = os.path.normpath(os.path.join(os.getcwd(), dest_path)) + if os.path.realpath(source_path) == os.path.realpath(dest_path): + tmuxp_echo( + colors.error("Source and destination are the same file: ") + + colors.info(str(PrivatePath(source_path))), + ) + sys.exit(1) + if os.path.exists(dest_path) and not prompt_yes_no( f"Overwrite {colors.info(str(PrivatePath(dest_path)))}?", default=False, diff --git a/tests/cli/test_copy.py b/tests/cli/test_copy.py index 5266c56ab0..5ddf3b46e2 100644 --- a/tests/cli/test_copy.py +++ b/tests/cli/test_copy.py @@ -271,3 +271,22 @@ def test_copy_error_exits_nonzero( if expected_output_fragment: captured = capsys.readouterr() assert expected_output_fragment in captured.out + + +def test_copy_self_copy_rejected( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tmuxp copy rejects copying a workspace to itself.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + + workspace_file = tmp_path / "self.yaml" + workspace_file.write_text("session_name: self\n", encoding="utf-8") + + with pytest.raises(SystemExit) as exc_info: + cli.cli(["copy", str(workspace_file), str(workspace_file)]) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "same file" in captured.out From 781f4a1c85927a48eafbacc7e35eb3bda2c03e2a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:56:09 -0500 Subject: [PATCH 116/143] fix(importers): Handle attached POSIX flags in cli_args parsing Support both space-separated (-L mysocket) and attached (-Lmysocket) forms for -f, -L, and -S flags when parsing tmuxinator cli_args. Previously only the space-separated form was recognized; attached forms were silently dropped. --- src/tmuxp/workspace/importers.py | 7 +++++++ tests/workspace/test_import_tmuxinator.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 7cb7508abf..60976f5d5d 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -98,9 +98,16 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: it = iter(tokens) for token in it: if token in flag_map: + # Space-separated: -L mysocket value = next(it, None) if value is not None: tmuxp_workspace[flag_map[token]] = value + else: + # Attached form: -Lmysocket + for prefix, key in flag_map.items(): + if token.startswith(prefix) and len(token) > len(prefix): + tmuxp_workspace[key] = token[len(prefix) :] + break if "socket_name" in workspace_dict: explicit_name = workspace_dict["socket_name"] diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 329b623e88..108125a2ca 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -848,6 +848,20 @@ def test_import_tmuxinator_startup_window_index_resolution( assert len(warning_records) == 0 +def test_import_tmuxinator_cli_args_attached_flags() -> None: + """Tmuxinator cli_args with attached POSIX flags like -Lmysocket.""" + workspace = { + "name": "attached-flags", + "root": "~/app", + "cli_args": "-f~/.tmux.mac.conf -Lmysocket", + "windows": [{"editor": "vim"}], + } + result = importers.import_tmuxinator(workspace) + + assert result["config"] == "~/.tmux.mac.conf" + assert result["socket_name"] == "mysocket" + + def test_import_tmuxinator_none_window_name_no_crash() -> None: """Tmuxinator config with None (null) window key imports without crashing.""" workspace = { From e8516ede6193976ca3a575c2c6dc0270df82bb69 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:57:22 -0500 Subject: [PATCH 117/143] fix(loader): Validate --set template values against YAML-unsafe characters render_template() now rejects values containing colons, braces, brackets, or newlines before substitution. These characters can corrupt YAML document structure when injected as raw text before parsing. --- src/tmuxp/workspace/loader.py | 27 +++++++++++++++++++++++++++ tests/workspace/test_config.py | 13 +++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 7d2c6c5baa..688f961124 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -31,6 +31,29 @@ def expandshell(value: str) -> str: _TEMPLATE_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}") +_YAML_UNSAFE_RE = re.compile(r"[\n\r:{}\[\]]") + + +def _validate_template_values(context: dict[str, str]) -> None: + """Raise ValueError if any template value could break YAML structure. + + Examples + -------- + >>> _validate_template_values({"key": "simple"}) + + >>> _validate_template_values({"key": "foo: bar"}) + Traceback (most recent call last): + ... + ValueError: --set value for 'key' contains YAML-unsafe characters ... + """ + for key, value in context.items(): + if _YAML_UNSAFE_RE.search(value): + msg = ( + f"--set value for {key!r} contains YAML-unsafe characters " + f"(colons, braces, brackets, or newlines): {value!r}" + ) + raise ValueError(msg) + def render_template(content: str, context: dict[str, str]) -> str: """Render ``{{ variable }}`` expressions in raw config content. @@ -40,6 +63,9 @@ def render_template(content: str, context: dict[str, str]) -> str: ``$ENV_VAR`` expansion (which runs later, after YAML parsing) is unaffected. + Raises :class:`ValueError` if any value contains characters that could + corrupt YAML structure (colons, braces, brackets, newlines). + Parameters ---------- content : str @@ -64,6 +90,7 @@ def render_template(content: str, context: dict[str, str]) -> str: >>> render_template("no templates here", {"key": "val"}) 'no templates here' """ + _validate_template_values(context) def _replace(match: re.Match[str]) -> str: key = match.group(1) diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 2faf7d84db..5076128111 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -739,3 +739,16 @@ def test_render_template( """render_template() replaces {{ var }} expressions with context values.""" result = loader.render_template(content, context) assert result == expected + + +def test_render_template_rejects_yaml_unsafe_values() -> None: + """render_template() raises ValueError for YAML-unsafe --set values.""" + with pytest.raises(ValueError, match="YAML-unsafe"): + loader.render_template("cmd: {{ val }}", {"val": "foo: bar"}) + + with pytest.raises(ValueError, match="YAML-unsafe"): + loader.render_template("cmd: {{ val }}", {"val": "line1\nline2"}) + + # Safe values should work fine + result = loader.render_template("cmd: {{ val }}", {"val": "hello-world"}) + assert result == "cmd: hello-world" From c6c02356141eedf5383aa5916d1bb1b1b0721fa2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:58:02 -0500 Subject: [PATCH 118/143] docs(cli[load]): Document --no-shell-command-before as intentionally broader Add note explaining that tmuxp's --no-shell-command-before strips at all levels (session/window/pane), which is intentionally broader than tmuxinator's --no-pre-window that only targets the window chain. --- docs/cli/load.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/cli/load.md b/docs/cli/load.md index fed1a91e0d..22e6741130 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -276,6 +276,10 @@ The `--no-shell-command-before` flag skips all `shell_command_before` entries at $ tmuxp load --no-shell-command-before myproject ``` +```{note} +This flag is intentionally broader than tmuxinator's `--no-pre-window`, which only disables the window-level `pre_window` chain. tmuxp's flag strips `shell_command_before` at all levels for a clean reload experience. +``` + ## Debug mode The `--debug` flag shows tmux commands as they execute. This disables the progress spinner and attaches a debug handler to libtmux's logger, printing each tmux command to stdout. From 34eaee68b9bdc50d2c1b7ac9e9cc4e9aeba0e4fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:59:03 -0500 Subject: [PATCH 119/143] fix(builder): Guard on_project_exit hook with session_attached check Wrap the client-detached hook command in a #{session_attached} guard so it only fires when the last client detaches. This prevents premature cleanup in multi-client scenarios (pair programming, SSH drops) where other clients are still attached. --- src/tmuxp/workspace/builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 1956d4be68..2b60c51b44 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -567,9 +567,11 @@ def build( _start_dir = self.session_config.get("start_directory") if _start_dir: _joined = f"cd {shlex.quote(_start_dir)} && {_joined}" + # Guard: only run when last client detaches (safe for multi-client) + _guarded = f"if [ #{{session_attached}} -eq 0 ]; then {_joined}; fi" self.session.set_hook( "client-detached", - f"run-shell {shlex.quote(_joined)}", + f"run-shell {shlex.quote(_guarded)}", ) # Store on_project_stop in session environment for tmuxp stop From f105b1ee2cb09622541977ccaecac58c52df6247 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 03:59:55 -0500 Subject: [PATCH 120/143] refactor(importers): Move _TMUXINATOR_UNMAPPED_KEYS to module level The dict was re-created inside import_tmuxinator() on every call. Move to module-level constant per Python convention for immutable lookup tables. --- src/tmuxp/workspace/importers.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 60976f5d5d..ff645fa16c 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -8,6 +8,12 @@ logger = logging.getLogger(__name__) +_TMUXINATOR_UNMAPPED_KEYS: dict[str, str] = { + "tmux_command": "custom tmux binary is not supported; tmuxp always uses 'tmux'", + "attach": "use 'tmuxp load -d' for detached mode instead", + "post": "deprecated in tmuxinator; use on_project_exit instead", +} + def _convert_named_panes(panes: list[t.Any]) -> list[t.Any]: """Convert tmuxinator named pane dicts to tmuxp format. @@ -142,11 +148,6 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ) # Warn on tmuxinator keys that have no tmuxp equivalent - _TMUXINATOR_UNMAPPED_KEYS = { - "tmux_command": "custom tmux binary is not supported; tmuxp always uses 'tmux'", - "attach": "use 'tmuxp load -d' for detached mode instead", - "post": "deprecated in tmuxinator; use on_project_exit instead", - } for _ukey, _uhint in _TMUXINATOR_UNMAPPED_KEYS.items(): if _ukey in workspace_dict: logger.warning( From a5d6c751d81e17516626d7da4ce20bc3d06f57c9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 04:03:50 -0500 Subject: [PATCH 121/143] fix(util,cli[stop]): Require pane resolution for destructive get_session Add require_pane_resolution parameter to get_session(). When True, raises SessionNotFound instead of falling back to server.sessions[0] when TMUX_PANE is unset/stale. tmuxp stop now uses strict mode to prevent killing an unrelated session. Non-destructive commands (shell, freeze) retain the fallback behavior. --- conftest.py | 1 + src/tmuxp/cli/stop.py | 2 +- src/tmuxp/util.py | 43 ++++++++++++++++++++++++++++++++++++------- tests/test_util.py | 21 +++++++++++++++++---- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/conftest.py b/conftest.py index 2d206d488a..85ac2c894f 100644 --- a/conftest.py +++ b/conftest.py @@ -116,6 +116,7 @@ def socket_name(request: pytest.FixtureRequest) -> str: DOCTEST_NEEDS_TMUX = { "tmuxp.cli.load", "tmuxp.cli.stop", + "tmuxp.util", "tmuxp.workspace.builder", } diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py index 39a364b71c..aa8f950a03 100644 --- a/src/tmuxp/cli/stop.py +++ b/src/tmuxp/cli/stop.py @@ -112,7 +112,7 @@ def command_stop( default=None, ) elif os.environ.get("TMUX"): - session = util.get_session(server) + session = util.get_session(server, require_pane_resolution=True) else: tmuxp_echo( colors.error("No session name given and not inside tmux."), diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index b6a7b80477..48eb23251d 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -212,28 +212,57 @@ def get_session( server: Server, session_name: str | None = None, current_pane: Pane | None = None, + require_pane_resolution: bool = False, ) -> Session: - """Get tmux session for server by session name, respects current pane, if passed.""" - session: Session | None = None + """Get tmux session for server by session name, respects current pane, if passed. + + Parameters + ---------- + server : Server + tmux server to search. + session_name : str, optional + Explicit session name to look up. + current_pane : Pane, optional + Pane to infer session from. + require_pane_resolution : bool + If True, raise SessionNotFound when TMUX_PANE cannot be resolved + instead of falling back to server.sessions[0]. Use for destructive + operations like ``tmuxp stop``. + + Examples + -------- + >>> from tmuxp.util import get_session + >>> get_session(server, session_name=session.name) == session + True + """ + session_result: Session | None = None try: if session_name: - session = server.sessions.get(session_name=session_name) + session_result = server.sessions.get(session_name=session_name) elif current_pane is not None: - session = server.sessions.get(session_id=current_pane.session_id) + session_result = server.sessions.get( + session_id=current_pane.session_id, + ) else: current_pane = get_current_pane(server) if current_pane: - session = server.sessions.get(session_id=current_pane.session_id) + session_result = server.sessions.get( + session_id=current_pane.session_id, + ) + elif require_pane_resolution: + pass # session_result stays None → raises below + elif server.sessions: + session_result = server.sessions[0] except Exception as e: if session_name: raise exc.SessionNotFound(session_name) from e raise exc.SessionNotFound from e - if session is None: + if session_result is None: if session_name: raise exc.SessionNotFound(session_name) raise exc.SessionNotFound - return session + return session_result def get_window( diff --git a/tests/test_util.py b/tests/test_util.py index 9d8b95e7f3..223824d9b8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -162,12 +162,25 @@ def test_get_session_should_default_to_local_attached_session( assert get_session(server) == second_session -def test_get_session_raises_when_no_active_pane( +def test_get_session_falls_back_to_first_when_no_pane( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """get_session() should raise SessionNotFound when TMUX_PANE is unset.""" - # Clear outer tmux environment to ensure no active pane interferes + """get_session() falls back to first session when TMUX_PANE is unset.""" + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + + first_session = server.new_session(session_name="myfirstsession") + server.new_session(session_name="mysecondsession") + + assert get_session(server) == first_session + + +def test_get_session_strict_raises_when_no_active_pane( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """get_session(require_pane_resolution=True) raises when TMUX_PANE unset.""" monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) @@ -175,7 +188,7 @@ def test_get_session_raises_when_no_active_pane( server.new_session(session_name="mysecondsession") with pytest.raises(exc.SessionNotFound): - get_session(server) + get_session(server, require_pane_resolution=True) def test_get_pane_logs_debug_on_failure( From 2c76e6e7edb081645f314db2d40927de3080e2f1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 04:41:22 -0500 Subject: [PATCH 122/143] fix(builder[here]): Use respawn-pane and set_environment instead of send_keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace send_keys("export ...") and send_keys(window_shell) with tmux primitives in --here mode: - Environment: session.set_environment() + respawn-pane -e (inherited by new panes, no POSIX shell assumption) - Shell replacement: respawn-pane -k (kills current process, starts fresh shell — no typing into foreground programs) - Directory: respawn-pane -c (tmux primitive, no send_keys cd) This eliminates all send_keys usage for infrastructure setup in --here mode, matching teamocil's approach of using tmux primitives over send_keys. Fixes the fish/nu shell incompatibility and the "types into vim" failure mode. Closes #1031 --- src/tmuxp/workspace/builder.py | 44 ++++++++++++++++++--------------- tests/workspace/test_builder.py | 7 +++++- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 2b60c51b44..88bedae658 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -692,39 +692,43 @@ def iter_create_windows( if panes and "start_directory" in panes[0]: start_directory = panes[0]["start_directory"] - if start_directory: - active_pane = window.active_pane - if active_pane is not None: - active_pane.send_keys( - f"cd {shlex.quote(start_directory)}", - enter=True, - ) - - # Provision environment — no window.set_environment in tmux, - # so export into the active pane's shell + # Provision environment via tmux session env (inherited + # by new panes). Matches teamocil, which does not inject + # env vars via send_keys at all. environment = window_config.get("environment") if panes and "environment" in panes[0]: environment = panes[0]["environment"] if environment: - _here_pane = window.active_pane - if _here_pane is not None: - for _ekey, _eval in environment.items(): - _here_pane.send_keys( - f"export {_ekey}={shlex.quote(str(_eval))}", - enter=True, - ) + for _ekey, _eval in environment.items(): + session.set_environment(_ekey, str(_eval)) - # Provision window_shell — send to active pane + # Resolve window_shell window_shell = window_config.get("window_shell") try: if panes[0]["shell"] != "": window_shell = panes[0]["shell"] except (KeyError, IndexError): pass - if window_shell: + + # respawn-pane -k provisions the reused pane with the + # correct directory, environment, and shell — no POSIX + # shell assumption, no typing into foreground programs, + # no history pollution. Matches teamocil's use of tmux + # primitives for infrastructure setup. + if start_directory or environment or window_shell: _here_pane = window.active_pane if _here_pane is not None: - _here_pane.send_keys(window_shell, enter=True) + _respawn_args: list[str] = ["respawn-pane", "-k"] + if start_directory: + _respawn_args.extend(["-c", start_directory]) + if environment: + for _ekey, _eval in environment.items(): + _respawn_args.extend( + ["-e", f"{_ekey}={_eval}"], + ) + if window_shell: + _respawn_args.append(window_shell) + _here_pane.cmd(*_respawn_args) else: is_first_window_pass = self.first_window_pass( window_iterator, diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index f70692f834..91b0fef8b8 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -605,7 +605,7 @@ def test_here_mode_duplicate_session_name( def test_here_mode_provisions_environment( session: Session, ) -> None: - """--here mode exports environment variables into the active pane.""" + """--here mode sets environment via session and respawn-pane, not send_keys.""" from libtmux.test.retry import retry_until workspace: dict[str, t.Any] = { @@ -625,6 +625,11 @@ def test_here_mode_provisions_environment( builder = WorkspaceBuilder(session_config=workspace, server=session.server) builder.build(session=session, here=True) + # Verify env var is set at session level (tmux primitive) + env = session.show_environment() + assert env.get("TMUXP_HERE_TEST") == "hello_here" + + # Verify the respawned pane also sees the var pane = session.active_window.active_pane assert pane is not None From cbe2594a8da1aeb16b488c329ef64b77e3a86b16 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 04:53:03 -0500 Subject: [PATCH 123/143] fix(builder[here]): Warn when respawn-pane will kill running processes Before calling respawn-pane -k, check pgrep -P for child processes. If the shell has running children (background jobs, foreground programs), log a WARNING so users know their processes will be terminated. Gracefully handles missing pgrep. --- src/tmuxp/workspace/builder.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 88bedae658..399da2b58e 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -6,6 +6,7 @@ import os import shlex import shutil +import subprocess import time import typing as t @@ -718,6 +719,26 @@ def iter_create_windows( if start_directory or environment or window_shell: _here_pane = window.active_pane if _here_pane is not None: + # Warn if the pane has running child processes + # that would be killed by respawn-pane -k + _pane_pid = _here_pane.pane_pid + if _pane_pid: + try: + _children = subprocess.run( + ["pgrep", "-P", _pane_pid], + capture_output=True, + text=True, + ) + if _children.returncode == 0: + logger.warning( + "--here will kill running processes " + "in the active pane (pid %s) to " + "provision directory/environment", + _pane_pid, + ) + except FileNotFoundError: + pass # pgrep not available + _respawn_args: list[str] = ["respawn-pane", "-k"] if start_directory: _respawn_args.extend(["-c", start_directory]) From ae09c8a1386fdded043ac7fee51e94ae76729cad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 05:05:36 -0500 Subject: [PATCH 124/143] test(builder,cli[load]): Add tests for --here respawn-pane and error recovery New builder tests (NamedTuple + test_id pattern): - HereRespawnFixture: parametrized over 4 scenarios (dir-only, env-only, dir-and-env, nothing-to-provision) verifying PID changes on respawn, directory provisioning, and session environment - test_here_mode_respawn_multiple_env_vars: 3 env vars via set_environment - test_here_mode_respawn_warns_on_running_processes: background sleep job triggers pgrep WARNING before respawn-pane -k - test_here_mode_no_warning_when_pane_idle: idle pane produces no warning New load CLI tests (NamedTuple + test_id pattern): - HereErrorRecoveryFixture: parametrized over 2 scenarios verifying --here mode skips (k)ill option (choices=[a,d], default=d) while normal mode retains it (choices=[k,a,d], default=k) --- tests/cli/test_load.py | 99 ++++++++++++++ tests/workspace/test_builder.py | 225 ++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index cbf05d9044..e19f1bd315 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -1504,3 +1504,102 @@ def test_load_append_and_detached_mutually_exclusive() -> None: parser = create_parser() with pytest.raises(SystemExit): parser.parse_args(["load", "--append", "-d", "myconfig"]) + + +# --- --here error recovery tests --- + + +class HereErrorRecoveryFixture(t.NamedTuple): + """Fixture for --here error recovery prompt behavior.""" + + test_id: str + here: bool + expected_choices: list[str] + expected_default: str + kill_option_present: bool + + +HERE_ERROR_RECOVERY_FIXTURES: list[HereErrorRecoveryFixture] = [ + HereErrorRecoveryFixture( + test_id="here-mode-no-kill", + here=True, + expected_choices=["a", "d"], + expected_default="d", + kill_option_present=False, + ), + HereErrorRecoveryFixture( + test_id="normal-mode-has-kill", + here=False, + expected_choices=["k", "a", "d"], + expected_default="k", + kill_option_present=True, + ), +] + + +@pytest.mark.parametrize( + list(HereErrorRecoveryFixture._fields), + HERE_ERROR_RECOVERY_FIXTURES, + ids=[f.test_id for f in HERE_ERROR_RECOVERY_FIXTURES], +) +def test_here_error_recovery_prompt( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + here: bool, + expected_choices: list[str], + expected_default: str, + kill_option_present: bool, +) -> None: + """--here error recovery skips (k)ill to protect user's live session.""" + from unittest.mock import MagicMock + + from tmuxp._internal.colors import ColorMode, Colors + from tmuxp.cli.load import _dispatch_build + + captured_kwargs: dict[str, t.Any] = {} + + def _capture_prompt_choices(*args: t.Any, **kwargs: t.Any) -> str: + captured_kwargs.update(kwargs) + captured_kwargs["choices"] = kwargs.get("choices", []) + return "d" # Always detach to exit cleanly + + monkeypatch.setattr( + "tmuxp.cli.load.prompt_choices", + _capture_prompt_choices, + ) + + # Create a mock builder that raises TmuxpException when built + from tmuxp import exc + + mock_builder = MagicMock() + mock_builder.session = None + + # Simulate the here path raising an error + if here: + monkeypatch.setattr( + "tmuxp.cli.load._load_here_in_current_session", + MagicMock(side_effect=exc.TmuxpException("test error")), + ) + monkeypatch.setenv("TMUX", "/tmp/tmux-test/default,12345,0") + else: + monkeypatch.setattr( + "tmuxp.cli.load._load_attached", + MagicMock(side_effect=exc.TmuxpException("test error")), + ) + monkeypatch.delenv("TMUX", raising=False) + + cli_colors = Colors(ColorMode.NEVER) + + with pytest.raises(SystemExit): + _dispatch_build( + builder=mock_builder, + detached=False, + append=False, + answer_yes=not here, # answer_yes triggers _load_attached path + cli_colors=cli_colors, + here=here, + ) + + assert captured_kwargs["choices"] == expected_choices + assert captured_kwargs.get("default") == expected_default + assert ("k" in captured_kwargs["choices"]) == kill_option_present diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 91b0fef8b8..308ac5dfa4 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -639,6 +639,231 @@ def test_here_mode_provisions_environment( ) +# --- respawn-pane provisioning tests --- + + +class HereRespawnFixture(t.NamedTuple): + """Fixture for --here respawn-pane provisioning scenarios.""" + + test_id: str + start_directory: bool + environment: dict[str, str] | None + window_shell: str | None + expect_respawn: bool + + +HERE_RESPAWN_FIXTURES: list[HereRespawnFixture] = [ + HereRespawnFixture( + test_id="dir-only", + start_directory=True, + environment=None, + window_shell=None, + expect_respawn=True, + ), + HereRespawnFixture( + test_id="env-only", + start_directory=False, + environment={"TMUXP_TEST_VAR": "respawn_val"}, + window_shell=None, + expect_respawn=True, + ), + HereRespawnFixture( + test_id="dir-and-env", + start_directory=True, + environment={"TMUXP_DIR_ENV": "combined"}, + window_shell=None, + expect_respawn=True, + ), + HereRespawnFixture( + test_id="nothing-to-provision", + start_directory=False, + environment=None, + window_shell=None, + expect_respawn=False, + ), +] + + +@pytest.mark.parametrize( + list(HereRespawnFixture._fields), + HERE_RESPAWN_FIXTURES, + ids=[f.test_id for f in HERE_RESPAWN_FIXTURES], +) +def test_here_mode_respawn_provisioning( + session: Session, + tmp_path: pathlib.Path, + test_id: str, + start_directory: bool, + environment: dict[str, str] | None, + window_shell: str | None, + expect_respawn: bool, +) -> None: + """--here mode provisions the reused pane via respawn-pane.""" + test_dir = tmp_path / "here_respawn" + test_dir.mkdir() + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "respawn-test", + "panes": [{"shell_command": []}], + }, + ], + } + if start_directory: + workspace["windows"][0]["start_directory"] = str(test_dir) + if environment: + workspace["windows"][0]["environment"] = environment + + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + original_pane = session.active_window.active_pane + assert original_pane is not None + original_pid = original_pane.pane_pid + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + pane = session.active_window.active_pane + assert pane is not None + + if expect_respawn: + # respawn-pane -k replaces the shell process, so PID changes + assert pane.pane_pid != original_pid, ( + f"Expected new PID after respawn, got same: {pane.pane_pid}" + ) + else: + # No provisioning needed — pane process should be unchanged + assert pane.pane_pid == original_pid + + if start_directory: + expected_path = os.path.realpath(str(test_dir)) + assert retry_until( + lambda: pane.pane_current_path == expected_path, + seconds=5, + ), f"Expected {expected_path}, got {pane.pane_current_path}" + + if environment: + env = session.show_environment() + for key, val in environment.items(): + assert env.get(key) == val + + +def test_here_mode_respawn_multiple_env_vars( + session: Session, +) -> None: + """--here mode sets multiple environment variables via set_environment.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "multi-env", + "environment": { + "TMUXP_A": "alpha", + "TMUXP_B": "bravo", + "TMUXP_C": "charlie", + }, + "panes": [{"shell_command": []}], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + env = session.show_environment() + assert env.get("TMUXP_A") == "alpha" + assert env.get("TMUXP_B") == "bravo" + assert env.get("TMUXP_C") == "charlie" + + +def test_here_mode_respawn_warns_on_running_processes( + session: Session, + caplog: pytest.LogCaptureFixture, + tmp_path: pathlib.Path, +) -> None: + """--here mode warns when respawn-pane will kill child processes.""" + # Start a background process in the active pane so pgrep finds children + pane = session.active_window.active_pane + assert pane is not None + pane.send_keys("sleep 300 &", enter=True) + + # Give the shell time to fork the background job; best-effort — + # the pgrep warning check below is the real assertion + retry_until( + lambda: ( + "sleep" in (pane.pane_current_command or "") or len(pane.capture_pane()) > 1 + ), + seconds=3, + raises=False, + ) + + test_dir = tmp_path / "warn_test" + test_dir.mkdir() + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "warn-test", + "start_directory": str(test_dir), + "panes": [{"shell_command": []}], + }, + ], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"): + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "kill running processes" in r.message + ] + # pgrep should find the sleep background job and emit a warning + assert len(warning_records) >= 1 + + +def test_here_mode_no_warning_when_pane_idle( + session: Session, + caplog: pytest.LogCaptureFixture, + tmp_path: pathlib.Path, +) -> None: + """--here mode does not warn when pane has no child processes.""" + test_dir = tmp_path / "idle_test" + test_dir.mkdir() + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "idle-test", + "start_directory": str(test_dir), + "panes": [{"shell_command": []}], + }, + ], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"): + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "kill running processes" in r.message + ] + assert len(warning_records) == 0 + + def test_window_shell( session: Session, ) -> None: From ad3f0fde93e535f70256210c50c2e37829a3cf46 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 06:00:28 -0500 Subject: [PATCH 125/143] cli/load(fix[hooks]): Run on_project_start only for new session builds why: on_project_start had been triggered before dispatch, so it also ran for paths that reused an existing session. That made --here rebuilds and interactive append flows execute a hook documented as new-session-only. what: - Move on_project_start execution into the attached and detached new-session load paths - Keep --here rebuilds inside tmux and append flows from invoking the hook - Preserve the outside-tmux --here fallback behavior, which still creates a new session - Add dispatch tests for attached, detached, append, and here routing - Add an on_project_exit guard assertion and fix the loader doctest ellipsis - Update the related load, comparison, and configuration docs to match current behavior --- docs/cli/load.md | 2 +- docs/comparison.md | 9 +- docs/configuration/top-level.md | 6 +- src/tmuxp/cli/load.py | 70 ++++++++++-- src/tmuxp/workspace/loader.py | 2 +- tests/cli/test_load.py | 191 ++++++++++++++++++++++++++++++++ tests/workspace/test_builder.py | 23 ++++ 7 files changed, 286 insertions(+), 17 deletions(-) diff --git a/docs/cli/load.md b/docs/cli/load.md index 22e6741130..38b696e683 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -265,7 +265,7 @@ $ tmuxp load --here . When used, tmuxp builds the workspace panes inside the current window rather than spawning a new session. ```{note} -`--here` sends shell commands (such as `cd` and `export` for environment variables) directly to the active pane via `send-keys`. The pane must be running a POSIX-compatible shell (bash, zsh, etc.). If the active pane is running a non-shell program (e.g., `vim`, `python`), those commands will be interpreted as input to that program. +When `--here` needs to provision a directory, environment, or shell, tmuxp uses tmux primitives (`set-environment` and `respawn-pane`) instead of typing `cd` / `export` into the pane. If provisioning is needed, tmux will replace the active pane process before the workspace commands run, so long-running child processes in that pane can be terminated. ``` ## Skipping shell_command_before diff --git a/docs/comparison.md b/docs/comparison.md index b5d8fee2e9..51313ec28b 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -74,10 +74,11 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Hook | tmuxp | tmuxinator | teamocil | |---|---|---|---| -| Every start invocation | `on_project_start` | `on_project_start` | (none) | -| First start only | `before_script` | `on_project_first_start` | (none) | +| Every start invocation | (none) | `on_project_start` | (none) | +| New session creation only | `on_project_start` | `on_project_first_start` | (none) | +| Before first script | `before_script` | (none) | (none) | | On reattach | `on_project_restart` + Plugin: `reattach()` | `on_project_restart` | (none) | -| On exit/detach | `on_project_exit` (tmux `client-detached` hook) | `on_project_exit` | (none) | +| On last client detach | `on_project_exit` (guarded `client-detached` hook) | `on_project_exit` | (none) | | On stop/kill | `on_project_stop` (via `tmuxp stop`) | `on_project_stop` | (none) | | Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) | | On window create | Plugin: `on_window_create()` | (none) | (none) | @@ -85,6 +86,8 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Deprecated pre | (none) | `pre` (deprecated → `on_project_start`+`on_project_restart`; runs before session create) | (none) | | Deprecated post | (none) | `post` (deprecated → `on_project_stop`+`on_project_exit`; runs after attach on every invocation) | (none) | +tmuxp's lifecycle hook names are intentionally close to tmuxinator's, but `on_project_start` is limited to new-session creation and `on_project_exit` is guarded so teardown only runs when the last client detaches. + ### Window-Level | Key | tmuxp | tmuxinator | teamocil | diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 8e426c2bc5..ba894f5f2d 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -61,7 +61,7 @@ windows: |------|-------------| | `on_project_start` | Before session build (new session creation only) | | `on_project_restart` | When reattaching to an existing session (confirmed attach only) | -| `on_project_exit` | On client detach (tmux `client-detached` hook) | +| `on_project_exit` | When the last client detaches (tmux `client-detached` hook) | | `on_project_stop` | Before `tmuxp stop` kills the session | Each hook accepts a string (single command) or a list of strings (multiple commands run sequentially). @@ -74,12 +74,12 @@ on_project_start: ```{note} These hooks are inspired by tmuxinator's lifecycle hooks but have tmuxp-specific semantics. -`on_project_start` only fires on new session creation (not on reattach). +`on_project_start` only fires on new session creation (not on reattach, append, or `--here`). `on_project_restart` only fires when you confirm reattaching to an existing session. ``` ```{note} -`on_project_exit` uses tmux's `client-detached` hook, which fires on **any** client detach — including terminal close, SSH disconnect, or manual `tmux detach`. Note: unlike tmuxinator (which fires `on_project_exit` once when the wrapper script exits), tmuxp's hook fires on every detach event for the lifetime of the session. +`on_project_exit` uses tmux's `client-detached` hook, but tmuxp guards it with `#{session_attached} == 0` so the command only runs when the **last** client detaches. This avoids repeated teardown in multi-client sessions. Unlike tmuxinator's wrapper-process hook, tmuxp keeps the hook on the session itself for the session lifetime. ``` ## Pane titles diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index e11e69b7a6..34f72e0f36 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -278,6 +278,7 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: def _load_attached( builder: WorkspaceBuilder, detached: bool, + pre_build_hook: t.Callable[[], None] | None = None, pre_attach_hook: t.Callable[[], None] | None = None, ) -> None: """ @@ -287,10 +288,15 @@ def _load_attached( ---------- builder: :class:`workspace.builder.WorkspaceBuilder` detached : bool + pre_build_hook : callable, optional + called immediately before ``builder.build()`` for new-session load paths. pre_attach_hook : callable, optional called after build, before attach/switch_client; use to stop the spinner so its cleanup sequences don't appear inside the tmux pane. """ + if pre_build_hook is not None: + pre_build_hook() + builder.build() assert builder.session is not None @@ -311,6 +317,7 @@ def _load_attached( def _load_detached( builder: WorkspaceBuilder, colors: Colors | None = None, + pre_build_hook: t.Callable[[], None] | None = None, pre_output_hook: t.Callable[[], None] | None = None, ) -> None: """ @@ -321,9 +328,14 @@ def _load_detached( builder: :class:`workspace.builder.WorkspaceBuilder` colors : Colors | None Optional Colors instance for styled output. + pre_build_hook : Callable | None + Called immediately before ``builder.build()`` for new-session load paths. pre_output_hook : Callable | None Called after build but before printing, e.g. to stop a spinner. """ + if pre_build_hook is not None: + pre_build_hook() + builder.build() assert builder.session is not None @@ -400,6 +412,7 @@ def _dispatch_build( answer_yes: bool, cli_colors: Colors, here: bool = False, + pre_build_hook: t.Callable[[], None] | None = None, pre_attach_hook: t.Callable[[], None] | None = None, on_error_hook: t.Callable[[], None] | None = None, pre_prompt_hook: t.Callable[[], None] | None = None, @@ -424,6 +437,8 @@ def _dispatch_build( Colors instance for styled output. here : bool Use current window for first workspace window. + pre_build_hook : callable, optional + Called before the build only for code paths that create a new session. pre_attach_hook : callable, optional Called before attach/switch_client (e.g. stop spinner). on_error_hook : callable, optional @@ -465,7 +480,12 @@ def _dispatch_build( """ try: if detached: - _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) + _load_detached( + builder, + cli_colors, + pre_build_hook=pre_build_hook, + pre_output_hook=pre_attach_hook, + ) return _setup_plugins(builder) if here: @@ -479,7 +499,12 @@ def _dispatch_build( cli_colors.warning("[Warning]") + " --here requires running inside tmux; loading normally", ) - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) return _setup_plugins(builder) @@ -487,13 +512,23 @@ def _dispatch_build( if "TMUX" in os.environ: # tmuxp ran from inside tmux _load_append_windows_to_current_session(builder) else: - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) return _setup_plugins(builder) # append and answer_yes have no meaning if specified together if answer_yes: - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) return _setup_plugins(builder) if "TMUX" in os.environ: # tmuxp ran from inside tmux @@ -507,13 +542,27 @@ def _dispatch_build( choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) if choice == "y": - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) elif choice == "a": _load_append_windows_to_current_session(builder) else: - _load_detached(builder, cli_colors) + _load_detached( + builder, + cli_colors, + pre_build_hook=pre_build_hook, + ) else: - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) except exc.TmuxpException as e: if on_error_hook is not None: @@ -811,8 +860,9 @@ def _cleanup_debug() -> None: _cleanup_debug() return None - # Run on_project_start hook — fires before new session build - if "on_project_start" in expanded_workspace: + def _run_on_project_start() -> None: + if "on_project_start" not in expanded_workspace: + return _hook_cwd = expanded_workspace.get("start_directory") util.run_hook_commands( expanded_workspace["on_project_start"], @@ -828,6 +878,7 @@ def _cleanup_debug() -> None: answer_yes, cli_colors, here=here, + pre_build_hook=_run_on_project_start, ) if result is not None: summary = "" @@ -905,6 +956,7 @@ def _emit_success() -> None: answer_yes, cli_colors, here=here, + pre_build_hook=_run_on_project_start, pre_attach_hook=_emit_success, on_error_hook=spinner.stop, pre_prompt_hook=spinner.stop, diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 688f961124..c0795f94f2 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -41,7 +41,7 @@ def _validate_template_values(context: dict[str, str]) -> None: -------- >>> _validate_template_values({"key": "simple"}) - >>> _validate_template_values({"key": "foo: bar"}) + >>> _validate_template_values({"key": "foo: bar"}) # doctest: +ELLIPSIS Traceback (most recent call last): ... ValueError: --set value for 'key' contains YAML-unsafe characters ... diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index e19f1bd315..2c2358562c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -15,9 +15,11 @@ from tests.constants import FIXTURE_PATH from tests.fixtures import utils as test_utils from tmuxp import cli +from tmuxp._internal.colors import ColorMode, Colors from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.cli.load import ( + _dispatch_build, _load_append_windows_to_current_session, _load_attached, load_plugins, @@ -1172,6 +1174,195 @@ def test_load_on_project_start_runs_hook( session.kill() +class DispatchBuildHookFixture(t.NamedTuple): + """Fixture for on_project_start dispatch behavior.""" + + test_id: str + detached: bool + append: bool + answer_yes: bool + here: bool + inside_tmux: bool + prompt_choice: str | None + expected_loader: str + expect_pre_build_hook: bool + + +DISPATCH_BUILD_HOOK_FIXTURES: list[DispatchBuildHookFixture] = [ + DispatchBuildHookFixture( + test_id="detached-new-session-runs-hook", + detached=True, + append=False, + answer_yes=False, + here=False, + inside_tmux=False, + prompt_choice=None, + expected_loader="detached", + expect_pre_build_hook=True, + ), + DispatchBuildHookFixture( + test_id="interactive-append-skips-hook", + detached=False, + append=False, + answer_yes=False, + here=False, + inside_tmux=True, + prompt_choice="a", + expected_loader="append", + expect_pre_build_hook=False, + ), + DispatchBuildHookFixture( + test_id="interactive-detach-runs-hook", + detached=False, + append=False, + answer_yes=False, + here=False, + inside_tmux=True, + prompt_choice="n", + expected_loader="detached", + expect_pre_build_hook=True, + ), + DispatchBuildHookFixture( + test_id="interactive-attach-runs-hook", + detached=False, + append=False, + answer_yes=False, + here=False, + inside_tmux=True, + prompt_choice="y", + expected_loader="attached", + expect_pre_build_hook=True, + ), + DispatchBuildHookFixture( + test_id="here-inside-tmux-skips-hook", + detached=False, + append=False, + answer_yes=False, + here=True, + inside_tmux=True, + prompt_choice=None, + expected_loader="here", + expect_pre_build_hook=False, + ), + DispatchBuildHookFixture( + test_id="here-outside-tmux-fallback-runs-hook", + detached=False, + append=False, + answer_yes=False, + here=True, + inside_tmux=False, + prompt_choice=None, + expected_loader="attached", + expect_pre_build_hook=True, + ), +] + + +@pytest.mark.parametrize( + list(DispatchBuildHookFixture._fields), + DISPATCH_BUILD_HOOK_FIXTURES, + ids=[f.test_id for f in DISPATCH_BUILD_HOOK_FIXTURES], +) +def test_dispatch_build_on_project_start_only_for_new_session_paths( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + detached: bool, + append: bool, + answer_yes: bool, + here: bool, + inside_tmux: bool, + prompt_choice: str | None, + expected_loader: str, + expect_pre_build_hook: bool, +) -> None: + """_dispatch_build only runs on_project_start on new-session load paths.""" + + class DummyBuilder: + """Minimal builder stub for dispatch tests.""" + + def __init__(self) -> None: + self.session = object() + self.plugins: list[t.Any] = [] + self.on_progress: t.Any = "sentinel" + self.on_before_script: t.Any = "sentinel" + self.on_script_output: t.Any = "sentinel" + self.on_build_event: t.Any = "sentinel" + + builder = t.cast(WorkspaceBuilder, DummyBuilder()) + loader_calls: list[str] = [] + hook_calls: list[str] = [] + + def _pre_build_hook() -> None: + hook_calls.append("hook") + + def _attached_stub( + builder: DummyBuilder, + detached: bool, + pre_build_hook: t.Callable[[], None] | None = None, + pre_attach_hook: t.Callable[[], None] | None = None, + ) -> None: + if pre_build_hook is not None: + pre_build_hook() + loader_calls.append("attached") + + def _detached_stub( + builder: DummyBuilder, + colors: Colors | None = None, + pre_build_hook: t.Callable[[], None] | None = None, + pre_output_hook: t.Callable[[], None] | None = None, + ) -> None: + if pre_build_hook is not None: + pre_build_hook() + loader_calls.append("detached") + + def _append_stub(builder: DummyBuilder) -> None: + loader_calls.append("append") + + def _here_stub(builder: DummyBuilder) -> None: + loader_calls.append("here") + + monkeypatch.setattr("tmuxp.cli.load._load_attached", _attached_stub) + monkeypatch.setattr("tmuxp.cli.load._load_detached", _detached_stub) + monkeypatch.setattr( + "tmuxp.cli.load._load_append_windows_to_current_session", + _append_stub, + ) + monkeypatch.setattr("tmuxp.cli.load._load_here_in_current_session", _here_stub) + monkeypatch.setattr( + "tmuxp.cli.load._setup_plugins", + lambda builder: builder.session, + ) + + if prompt_choice is not None: + monkeypatch.setattr( + "tmuxp.cli.load.prompt_choices", + lambda *a, **kw: prompt_choice, + ) + + if inside_tmux: + monkeypatch.setenv("TMUX", "/tmp/tmux-test/default,12345,0") + else: + monkeypatch.delenv("TMUX", raising=False) + + result = _dispatch_build( + builder=builder, + detached=detached, + append=append, + answer_yes=answer_yes, + cli_colors=Colors(ColorMode.NEVER), + here=here, + pre_build_hook=_pre_build_hook, + ) + + assert result is builder.session + assert loader_calls == [expected_loader], test_id + assert hook_calls == (["hook"] if expect_pre_build_hook else []), test_id + assert builder.on_progress is None + assert builder.on_before_script is None + assert builder.on_script_output is None + assert builder.on_build_event is None + + def test_load_on_project_restart_runs_hook( tmp_path: pathlib.Path, server: Server, diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 308ac5dfa4..56be8562e4 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -2297,6 +2297,29 @@ def test_on_project_exit_sets_hook( builder.session.kill() +def test_on_project_exit_hook_guards_last_client_detach( + server: Server, +) -> None: + """on_project_exit hook only runs when the last client detaches.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-exit-guard-test", + "on_project_exit": "echo goodbye", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_values = [str(v) for v in hooks.values()] + matched = [v for v in hook_values if "#{session_attached}" in v] + assert len(matched) >= 1 + + builder.session.kill() + + def test_on_project_exit_sets_hook_list( server: Server, ) -> None: From 9ad98cbaf74ccca04796d152e54acbd99d3f7ab6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 06:03:25 -0500 Subject: [PATCH 126/143] CHANGES(docs): Correct lifecycle hook semantics why: Keep the changelog aligned with the lifecycle behavior shipped on this branch so readers do not infer broader hook semantics than the code implements. what: - Document on_project_exit as running when the last client detaches - Match the guarded client-detached hook behavior in WorkspaceBuilder --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 829a1eaea5..723616bb22 100644 --- a/CHANGES +++ b/CHANGES @@ -78,7 +78,7 @@ Workspace configs now support four lifecycle hooks inspired by tmuxinator: - `on_project_start` — runs before session build (new session creation only) - `on_project_restart` — runs when reattaching to an existing session (confirmed attach only) -- `on_project_exit` — runs on client detach (via tmux `client-detached` hook; fires on every detach event) +- `on_project_exit` — runs when the last client detaches (via tmux `client-detached` hook) - `on_project_stop` — runs before `tmuxp stop` kills the session ### Config templating (#1025) From 372083234ce91ab73cfede07b8d182317d657ebf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 06:48:02 -0500 Subject: [PATCH 127/143] WorkspaceBuilder(fix[here]): Preserve reused sessions on startup failures why: --here and --append reuse an existing tmux session. Startup failures must abort without destroying that live session, and duplicate target names must stop before any plugin hooks or before_script side effects run. what: - track whether build created the session before cleaning it up on failure - move the --here duplicate-session check ahead of startup hooks and script execution - add builder coverage for reused-session failures and pre-hook rename conflicts --- src/tmuxp/workspace/builder.py | 25 ++++-- tests/workspace/test_builder.py | 144 +++++++++++++++++++++++++++----- 2 files changed, 142 insertions(+), 27 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 399da2b58e..65d77f2fac 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -431,6 +431,8 @@ def build( here : bool reuse current window for first window and rename session """ + session_created = session is None + if not session: if not self.server: msg = ( @@ -495,6 +497,19 @@ def build( assert isinstance(session, Session) + # Check --here rename conflicts before plugin hooks, before_script, + # or any session/window mutation with user-visible side effects. + if here: + session_name = self.session_config["session_name"] + if session.name != session_name: + existing = self.server.sessions.get( + session_name=session_name, + default=None, + ) + if existing is not None: + msg = f"cannot rename to {session_name!r}: session already exists" + raise exc.TmuxpException(msg) + for plugin in self.plugins: plugin.before_workspace_builder(self.session) @@ -529,22 +544,16 @@ def build( ), }, ) - self.session.kill() + if session_created: + self.session.kill() raise finally: if self.on_build_event: self.on_build_event({"event": "before_script_done"}) - # Check for rename conflicts early, before any session mutation if here: session_name = self.session_config["session_name"] if session.name != session_name: - existing = self.server.sessions.get( - session_name=session_name, default=None - ) - if existing is not None: - msg = f"cannot rename to {session_name!r}: session already exists" - raise exc.TmuxpException(msg) session.rename_session(session_name) if "options" in self.session_config: diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 56be8562e4..b65fd773f6 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1219,18 +1219,17 @@ def test_before_script_throw_error_if_retcode_error( builder = WorkspaceBuilder(session_config=workspace, server=server) - with temp_session(server) as sess: - session_name = sess.name - assert session_name is not None + session_name = workspace["session_name"] + assert isinstance(session_name, str) - with ( - caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), - pytest.raises(exc.BeforeLoadScriptError), - ): - builder.build(session=sess) + with ( + caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), + pytest.raises(exc.BeforeLoadScriptError), + ): + builder.build() - result = server.has_session(session_name) - assert not result, "Kills session if before_script exits with errcode" + result = server.has_session(session_name) + assert not result, "Kills created session if before_script exits with errcode" error_records = [r for r in caplog.records if r.levelno == logging.ERROR] assert len(error_records) >= 1 @@ -1254,17 +1253,124 @@ def test_before_script_throw_error_if_file_not_exists( builder = WorkspaceBuilder(session_config=workspace, server=server) - with temp_session(server) as session: - session_name = session.name + session_name = workspace["session_name"] + assert isinstance(session_name, str) + + with pytest.raises((exc.BeforeLoadScriptNotExists, OSError)): + builder.build() + result = server.has_session(session_name) + assert not result, "Kills created session if before_script doesn't exist" + + +class ReusedSessionBeforeScriptFailureFixture(t.NamedTuple): + """Fixture for before_script failures on reused sessions.""" + + test_id: str + fixture_name: str + build_kwargs: dict[str, bool] + expected_exception: type[BaseException] | tuple[type[BaseException], ...] + + +REUSED_SESSION_BEFORE_SCRIPT_FAILURE_FIXTURES: list[ + ReusedSessionBeforeScriptFailureFixture +] = [ + ReusedSessionBeforeScriptFailureFixture( + test_id="append-retcode-error-keeps-session", + fixture_name="workspace/builder/config_script_fails.yaml", + build_kwargs={"append": True}, + expected_exception=exc.BeforeLoadScriptError, + ), + ReusedSessionBeforeScriptFailureFixture( + test_id="here-retcode-error-keeps-session", + fixture_name="workspace/builder/config_script_fails.yaml", + build_kwargs={"here": True}, + expected_exception=exc.BeforeLoadScriptError, + ), + ReusedSessionBeforeScriptFailureFixture( + test_id="here-missing-script-keeps-session", + fixture_name="workspace/builder/config_script_not_exists.yaml", + build_kwargs={"here": True}, + expected_exception=(exc.BeforeLoadScriptNotExists, OSError), + ), +] + + +@pytest.mark.parametrize( + list(ReusedSessionBeforeScriptFailureFixture._fields), + REUSED_SESSION_BEFORE_SCRIPT_FAILURE_FIXTURES, + ids=[f.test_id for f in REUSED_SESSION_BEFORE_SCRIPT_FAILURE_FIXTURES], +) +def test_before_script_failure_on_reused_session_keeps_session( + server: Server, + test_id: str, + fixture_name: str, + build_kwargs: dict[str, bool], + expected_exception: type[BaseException] | tuple[type[BaseException], ...], +) -> None: + """before_script failures do not kill reused sessions for append or here.""" + fixture_template = test_utils.read_workspace_file(fixture_name) + workspace_yaml = fixture_template.format( + script_failed=FIXTURE_PATH / "script_failed.sh", + script_not_exists=FIXTURE_PATH / "script_not_exists.sh", + ) + workspace = ConfigReader._load(fmt="yaml", content=workspace_yaml) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + with temp_session(server) as current_session: + session_name = current_session.name assert session_name is not None - temp_session_exists = server.has_session(session_name) - assert temp_session_exists - with pytest.raises((exc.BeforeLoadScriptNotExists, OSError)) as excinfo: - builder.build(session=session) - excinfo.match(r"No such file or directory") - result = server.has_session(session_name) - assert not result, "Kills session if before_script doesn't exist" + workspace["session_name"] = session_name + builder.session_config = workspace + + with pytest.raises(expected_exception): + builder.build(session=current_session, **build_kwargs) + + assert server.has_session(session_name), test_id + + +def test_here_mode_duplicate_session_name_fails_before_startup_hooks( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """--here rename conflicts abort before plugins or before_script run.""" + before_script_marker = tmp_path / "before-script-ran" + plugin_marker = tmp_path / "plugin-ran" + + workspace: dict[str, t.Any] = { + "session_name": "existing-blocker", + "before_script": f"touch {before_script_marker}", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + class PluginProbe: + """Minimal plugin stub for startup-hook ordering tests.""" + + def before_workspace_builder(self, session: Session) -> None: + plugin_marker.touch() + + builder = WorkspaceBuilder( + session_config=workspace, + server=server, + plugins=[PluginProbe()], + ) + + with ( + temp_session(server) as current_session, + temp_session(server) as existing_session, + ): + existing_session.rename_session("existing-blocker") + with pytest.raises(exc.TmuxpException, match="session already exists"): + builder.build(session=current_session, here=True) + + assert current_session.name is not None + assert not before_script_marker.exists() + assert not plugin_marker.exists() + assert server.has_session(current_session.name) def test_before_script_true_if_test_passes( From 6d17e1c21bc5a367ded87b9df1f6e7219b91bf3f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 06:49:40 -0500 Subject: [PATCH 128/143] cli/load(fix[here]): Reject multi-workspace here invocations why: --here is a current-window workflow. Accepting multiple workspace files silently changes behavior for earlier entries and leaves behind unexpected sessions instead of failing fast. what: - reject --here when more than one workspace file is provided - clarify the parser help text for the single-workspace contract - add CLI coverage that exits before any workspace is loaded - document the single-workspace restriction in the load guide --- docs/cli/load.md | 2 ++ src/tmuxp/cli/load.py | 12 ++++++++- tests/cli/test_load.py | 57 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/cli/load.md b/docs/cli/load.md index 38b696e683..6a27fca387 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -264,6 +264,8 @@ $ tmuxp load --here . When used, tmuxp builds the workspace panes inside the current window rather than spawning a new session. +`--here` only supports a single workspace file per invocation. + ```{note} When `--here` needs to provision a directory, environment, or shell, tmuxp uses tmux primitives (`set-environment` and `respawn-pane`) instead of typing `cd` / `export` into the pane. If provisioning is needed, tmux will replace the active pane process before the workspace commands run, so long-running child processes in that pane can be terminated. ``` diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 34f72e0f36..b95c568b0a 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -1027,7 +1027,10 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP "--here", dest="here", action="store_true", - help="use the current window for the first workspace window", + help=( + "use the current window for the first workspace window " + "(single workspace only)" + ), ) parser.add_argument( "--no-shell-command-before", @@ -1170,6 +1173,13 @@ def command_load( sys.exit() return + if args.here and len(args.workspace_files) > 1: + msg = "--here only supports one workspace file" + if parser is not None: + parser.error(msg) + tmuxp_echo(cli_colors.error("[Error]") + f" {msg}") + sys.exit(2) + # Parse --set KEY=VALUE args into template context template_context: dict[str, str] | None = None if args.set: diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 2c2358562c..762ff5cdf0 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -19,9 +19,11 @@ from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.cli.load import ( + CLILoadNamespace, _dispatch_build, _load_append_windows_to_current_session, _load_attached, + command_load, load_plugins, load_workspace, ) @@ -1688,6 +1690,61 @@ def test_load_here_and_detached_mutually_exclusive() -> None: parser.parse_args(["load", "--here", "-d", "myconfig"]) +class MultiWorkspaceHereFixture(t.NamedTuple): + """Fixture for invalid multi-workspace --here invocations.""" + + test_id: str + cli_args: list[str] + expected_exit_code: int + expected_error: str + + +MULTI_WORKSPACE_HERE_FIXTURES: list[MultiWorkspaceHereFixture] = [ + MultiWorkspaceHereFixture( + test_id="rejects-two-workspaces", + cli_args=["load", "--here", "first", "second"], + expected_exit_code=2, + expected_error="--here only supports one workspace file", + ), +] + + +@pytest.mark.parametrize( + list(MultiWorkspaceHereFixture._fields), + MULTI_WORKSPACE_HERE_FIXTURES, + ids=[fixture.test_id for fixture in MULTI_WORKSPACE_HERE_FIXTURES], +) +def test_load_here_rejects_multiple_workspace_files( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + expected_exit_code: int, + expected_error: str, +) -> None: + """--here exits before load_workspace when multiple files are provided.""" + parser = cli.create_parser() + args = t.cast(CLILoadNamespace, parser.parse_args(cli_args)) + load_calls: list[str] = [] + + monkeypatch.setattr( + "tmuxp.cli.load.util.oh_my_zsh_auto_title", + lambda: None, + ) + monkeypatch.setattr( + "tmuxp.cli.load.load_workspace", + lambda *args, **kwargs: load_calls.append(test_id), + ) + + with pytest.raises(SystemExit) as excinfo: + command_load(args, parser=parser) + + result = capsys.readouterr() + assert excinfo.value.code == expected_exit_code + assert expected_error in result.err + assert load_calls == [] + + def test_load_append_and_detached_mutually_exclusive() -> None: """--append and -d cannot be used together.""" from tmuxp.cli import create_parser From 90ec5433e1974bcc3942f7b2703f4d5e6aa72bc8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 09:09:51 -0500 Subject: [PATCH 129/143] Tmuxinator(fix[startup_targets]): Respect tmux base indices during import why: tmuxinator numeric startup_window and startup_pane values are tmux indices, not Python list offsets. Importing them as list positions changes which window or pane receives focus and breaks compatibility with existing configs, especially when base-index or pane-base-index are nonzero. what: - resolve numeric startup targets against tmux base-index and pane-base-index - read live tmux index settings in the tmuxinator import CLI path - add importer and CLI coverage for base-index aware conversion and fallback --- src/tmuxp/cli/import_config.py | 66 +++++++++++- src/tmuxp/workspace/importers.py | 119 ++++++++++++++-------- tests/cli/test_import.py | 93 +++++++++++++++++ tests/workspace/test_import_tmuxinator.py | 73 +++++++------ 4 files changed, 274 insertions(+), 77 deletions(-) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index df49c221ba..89dbb5dcd5 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -9,6 +9,8 @@ import sys import typing as t +from libtmux.common import tmux_cmd + from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import importers @@ -89,6 +91,59 @@ def _resolve_path_no_overwrite(workspace_file: str) -> str: return str(path) +def _read_tmux_index_option(*args: str) -> int | None: + """Return tmux index option value, or ``None`` when unavailable. + + Examples + -------- + >>> from collections import namedtuple + >>> import tmuxp.cli.import_config as import_config + >>> FakeResponse = namedtuple("FakeResponse", "returncode stdout") + >>> monkeypatch.setattr( + ... import_config, + ... "tmux_cmd", + ... lambda *args: FakeResponse(returncode=0, stdout=["1"]), + ... ) + >>> import_config._read_tmux_index_option("show-options", "-gv", "base-index") + 1 + """ + try: + response = tmux_cmd(*args) + except Exception: + return None + + if response.returncode != 0 or not response.stdout: + return None + + try: + return int(response.stdout[0]) + except ValueError: + return None + + +def _get_tmuxinator_base_indices() -> tuple[int, int]: + """Return tmux base-index and pane-base-index for tmuxinator import. + + Examples + -------- + >>> import tmuxp.cli.import_config as import_config + >>> monkeypatch.setattr( + ... import_config, + ... "_read_tmux_index_option", + ... lambda *args: 1 if args[-1] == "base-index" else 2, + ... ) + >>> import_config._get_tmuxinator_base_indices() + (1, 2) + """ + base_index = _read_tmux_index_option("show-options", "-gv", "base-index") + pane_base_index = _read_tmux_index_option( + "show-window-options", + "-gv", + "pane-base-index", + ) + return (base_index or 0, pane_base_index or 0) + + def command_import( workspace_file: str, print_list: str, @@ -253,12 +308,21 @@ def command_import_tmuxinator( """ color_mode = get_color_mode(color) colors = Colors(color_mode) + base_index, pane_base_index = _get_tmuxinator_base_indices() workspace_file = find_workspace_file( workspace_file, workspace_dir=get_tmuxinator_dir(), ) - import_config(workspace_file, importers.import_tmuxinator, colors=colors) + + def tmuxinator_importer(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + return importers.import_tmuxinator( + workspace_dict, + base_index=base_index, + pane_base_index=pane_base_index, + ) + + import_config(workspace_file, tmuxinator_importer, colors=colors) def command_import_teamocil( diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index ff645fa16c..d653a8a56d 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -61,7 +61,56 @@ def _convert_named_panes(panes: list[t.Any]) -> list[t.Any]: return result -def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: +def _resolve_tmux_list_position( + target: str | int, + *, + base_index: int, + item_count: int, +) -> int | None: + """Resolve a tmux index into a Python list position. + + Parameters + ---------- + target : str or int + tmux index from tmuxinator configuration + base_index : int + tmux base index for the list being resolved + item_count : int + number of items in the generated tmuxp list + + Returns + ------- + int or None + Python list position if the target resolves within bounds + + Examples + -------- + >>> _resolve_tmux_list_position(1, base_index=1, item_count=2) + 0 + + >>> _resolve_tmux_list_position("2", base_index=1, item_count=2) + 1 + + >>> _resolve_tmux_list_position(3, base_index=1, item_count=2) is None + True + """ + try: + list_position = int(target) - base_index + except ValueError: + return None + + if 0 <= list_position < item_count: + return list_position + + return None + + +def import_tmuxinator( + workspace_dict: dict[str, t.Any], + *, + base_index: int = 0, + pane_base_index: int = 0, +) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. .. _tmuxinator: https://github.com/aziz/tmuxinator @@ -243,26 +292,18 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: _matched = True break if not _matched: - try: - _idx = int(_startup_window) - if 0 <= _idx < len(tmuxp_workspace["windows"]): - tmuxp_workspace["windows"][_idx]["focus"] = True - logger.warning( - "startup_window %r resolved as 0-based list index, " - "which may differ from tmuxinator's tmux base-index " - "semantics; use window name for reliable matching", - _startup_window, - ) - else: - logger.warning( - "startup_window index %d out of range (0-%d)", - _idx, - len(tmuxp_workspace["windows"]) - 1, - ) - except (ValueError, IndexError): + _idx = _resolve_tmux_list_position( + _startup_window, + base_index=base_index, + item_count=len(tmuxp_workspace["windows"]), + ) + if _idx is not None: + tmuxp_workspace["windows"][_idx]["focus"] = True + else: logger.warning( - "startup_window %s not found", + "startup_window %r not found for tmux base-index %d", _startup_window, + base_index, ) if _startup_pane is not None and tmuxp_workspace["windows"]: @@ -271,33 +312,25 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: tmuxp_workspace["windows"][0], ) if "panes" in _target: - try: - _pidx = int(_startup_pane) - if 0 <= _pidx < len(_target["panes"]): - _pane = _target["panes"][_pidx] - if isinstance(_pane, dict): - _pane["focus"] = True - else: - _target["panes"][_pidx] = { - "shell_command": [_pane] if _pane else [], - "focus": True, - } - logger.warning( - "startup_pane %r resolved as 0-based list index, " - "which may differ from tmuxinator's tmux " - "pane-base-index semantics", - _startup_pane, - ) + _pidx = _resolve_tmux_list_position( + _startup_pane, + base_index=pane_base_index, + item_count=len(_target["panes"]), + ) + if _pidx is not None: + _pane = _target["panes"][_pidx] + if isinstance(_pane, dict): + _pane["focus"] = True else: - logger.warning( - "startup_pane index %d out of range (0-%d)", - _pidx, - len(_target["panes"]) - 1, - ) - except (ValueError, IndexError): + _target["panes"][_pidx] = { + "shell_command": [_pane] if _pane else [], + "focus": True, + } + else: logger.warning( - "startup_pane %s not found", + "startup_pane %r not found for tmux pane-base-index %d", _startup_pane, + pane_base_index, ) return tmuxp_workspace diff --git a/tests/cli/test_import.py b/tests/cli/test_import.py index 4faad8fe2c..f77c15347f 100644 --- a/tests/cli/test_import.py +++ b/tests/cli/test_import.py @@ -10,6 +10,7 @@ from tests.fixtures import utils as test_utils from tmuxp import cli +from tmuxp.cli import import_config as import_config_module if t.TYPE_CHECKING: import pathlib @@ -173,3 +174,95 @@ def test_import_tmuxinator( new_config_yaml = tmp_path / "la.yaml" assert new_config_yaml.exists() + + +def test_get_tmuxinator_base_indices_reads_live_tmux_options( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxinator import reads tmux base indices from live tmux options.""" + + class FakeTmuxResponse(t.NamedTuple): + """Fake tmux command response.""" + + returncode: int + stdout: list[str] + + def fake_tmux_cmd(*args: str) -> FakeTmuxResponse: + if args == ("show-options", "-gv", "base-index"): + return FakeTmuxResponse(returncode=0, stdout=["1"]) + if args == ("show-window-options", "-gv", "pane-base-index"): + return FakeTmuxResponse(returncode=0, stdout=["2"]) + msg = f"unexpected tmux args: {args!r}" + raise AssertionError(msg) + + monkeypatch.setattr(import_config_module, "tmux_cmd", fake_tmux_cmd) + + assert import_config_module._get_tmuxinator_base_indices() == (1, 2) + + +def test_get_tmuxinator_base_indices_falls_back_when_tmux_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxinator import falls back to tmux defaults when lookup fails.""" + + def raise_tmux_error(*args: str) -> t.NoReturn: + msg = f"tmux unavailable for {args!r}" + raise RuntimeError(msg) + + monkeypatch.setattr(import_config_module, "tmux_cmd", raise_tmux_error) + + assert import_config_module._get_tmuxinator_base_indices() == (0, 0) + + +def test_command_import_tmuxinator_passes_resolved_base_indices( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxinator import command passes resolved tmux indices to the importer.""" + captured: dict[str, t.Any] = {} + + def fake_find_workspace_file( + workspace_file: str, + workspace_dir: t.Any, + ) -> str: + captured["workspace_file"] = workspace_file + captured["workspace_dir"] = workspace_dir + return workspace_file + + def fake_import_config( + workspace_file: str, + importfunc: t.Callable[[dict[str, t.Any]], dict[str, t.Any]], + parser: t.Any = None, + colors: t.Any = None, + ) -> None: + captured["workspace_file"] = workspace_file + captured["parser"] = parser + captured["colors"] = colors + captured["imported"] = importfunc( + { + "name": "sample", + "startup_window": 1, + "startup_pane": 2, + "windows": [{"editor": ["vim", "logs"]}], + } + ) + + monkeypatch.setattr( + import_config_module, + "find_workspace_file", + fake_find_workspace_file, + ) + monkeypatch.setattr(import_config_module, "import_config", fake_import_config) + monkeypatch.setattr( + import_config_module, + "_get_tmuxinator_base_indices", + lambda: (1, 2), + ) + + import_config_module.command_import_tmuxinator("sample.yml") + + imported = captured["imported"] + assert imported["windows"][0]["focus"] is True + assert imported["windows"][0]["panes"][0] == { + "shell_command": ["vim"], + "focus": True, + } diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 108125a2ca..473420a494 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -113,7 +113,7 @@ def test_startup_window_sets_focus_by_name() -> None: def test_startup_window_sets_focus_by_index() -> None: - """Startup_window sets focus by numeric index when name doesn't match.""" + """Startup_window resolves numeric values with tmux base-index semantics.""" workspace = { "name": "test", "startup_window": 1, @@ -122,14 +122,14 @@ def test_startup_window_sets_focus_by_index() -> None: {"server": "rails s"}, ], } - result = importers.import_tmuxinator(workspace) + result = importers.import_tmuxinator(workspace, base_index=1) - assert result["windows"][0].get("focus") is None - assert result["windows"][1]["focus"] is True + assert result["windows"][0]["focus"] is True + assert result["windows"][1].get("focus") is None def test_startup_pane_sets_focus_on_pane() -> None: - """Startup_pane converts the target pane to a dict with focus.""" + """Startup_pane resolves numeric values with tmux pane-base-index.""" workspace = { "name": "test", "startup_window": "editor", @@ -142,12 +142,12 @@ def test_startup_pane_sets_focus_on_pane() -> None: }, ], } - result = importers.import_tmuxinator(workspace) + result = importers.import_tmuxinator(workspace, pane_base_index=1) assert result["windows"][0]["focus"] is True panes = result["windows"][0]["panes"] - assert panes[0] == "vim" - assert panes[1] == {"shell_command": ["guard"], "focus": True} + assert panes[0] == {"shell_command": ["vim"], "focus": True} + assert panes[1] == "guard" assert panes[2] == "top" @@ -164,10 +164,11 @@ def test_startup_pane_without_startup_window() -> None: }, ], } - result = importers.import_tmuxinator(workspace) + result = importers.import_tmuxinator(workspace, pane_base_index=1) panes = result["windows"][0]["panes"] - assert panes[1] == {"shell_command": ["guard"], "focus": True} + assert panes[0] == {"shell_command": ["vim"], "focus": True} + assert panes[1] == "guard" def test_startup_window_warns_on_no_match( @@ -746,9 +747,9 @@ class StartupIndexFixture(t.NamedTuple): test_id: str startup_window: str | int + base_index: int window_names: list[str] expected_focus_index: int | None - expect_info_log: bool expect_warning_log: bool @@ -756,41 +757,57 @@ class StartupIndexFixture(t.NamedTuple): StartupIndexFixture( test_id="name-match", startup_window="editor", + base_index=0, window_names=["editor", "console"], expected_focus_index=0, - expect_info_log=False, expect_warning_log=False, ), StartupIndexFixture( - test_id="numeric-zero", + test_id="numeric-zero-base-zero", startup_window=0, + base_index=0, window_names=["win1", "win2"], expected_focus_index=0, - expect_info_log=False, - expect_warning_log=True, + expect_warning_log=False, ), StartupIndexFixture( - test_id="numeric-one", + test_id="numeric-one-base-zero", startup_window=1, + base_index=0, window_names=["win1", "win2"], expected_focus_index=1, - expect_info_log=False, - expect_warning_log=True, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-one-base-one", + startup_window=1, + base_index=1, + window_names=["win1", "win2"], + expected_focus_index=0, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-two-base-one", + startup_window=2, + base_index=1, + window_names=["win1", "win2"], + expected_focus_index=1, + expect_warning_log=False, ), StartupIndexFixture( test_id="out-of-range", startup_window=5, + base_index=1, window_names=["win1", "win2"], expected_focus_index=None, - expect_info_log=False, expect_warning_log=True, ), StartupIndexFixture( test_id="no-match-string", startup_window="nonexistent", + base_index=0, window_names=["win1", "win2"], expected_focus_index=None, - expect_info_log=False, expect_warning_log=True, ), ] @@ -805,19 +822,19 @@ def test_import_tmuxinator_startup_window_index_resolution( caplog: pytest.LogCaptureFixture, test_id: str, startup_window: str | int, + base_index: int, window_names: list[str], expected_focus_index: int | None, - expect_info_log: bool, expect_warning_log: bool, ) -> None: - """startup_window resolves by name first, then 0-based index with logging.""" + """startup_window resolves by name first, then tmux base-index.""" workspace: dict[str, t.Any] = { "name": "startup-test", "startup_window": startup_window, "windows": [{wn: "echo hi"} for wn in window_names], } with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): - result = importers.import_tmuxinator(workspace) + result = importers.import_tmuxinator(workspace, base_index=base_index) windows = result["windows"] for i, w in enumerate(windows): @@ -826,22 +843,12 @@ def test_import_tmuxinator_startup_window_index_resolution( else: assert not w.get("focus"), f"window {i} should not have focus" - info_records = [ - r - for r in caplog.records - if r.levelno == logging.INFO and "startup_window" in r.message - ] warning_records = [ r for r in caplog.records if r.levelno == logging.WARNING and "startup_window" in r.message ] - if expect_info_log: - assert len(info_records) >= 1 - else: - assert len(info_records) == 0 - if expect_warning_log: assert len(warning_records) >= 1 else: From 4f10142e6e07b2a25ba144baf02e2aa75a00cad0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 09:10:06 -0500 Subject: [PATCH 130/143] WorkspaceBuilder(fix[reused_sessions]): Keep existing session hooks and env intact why: append and --here reuse a live tmux session rather than creating a tmuxp owned session. Writing lifecycle hooks or stop metadata onto that reused session can overwrite unrelated teardown behavior, and copying first-pane environment into session state makes later windows inherit variables they were never meant to see. what: - limit on_project_exit, on_project_stop, and start_directory session metadata to sessions created by the current build - keep --here first-pane provisioning local to respawn-pane instead of the session environment - add reused-session and non-leaking here-mode tests around hooks and env --- src/tmuxp/workspace/builder.py | 16 ++-- tests/workspace/test_builder.py | 135 +++++++++++++++++++++++++++----- 2 files changed, 122 insertions(+), 29 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 65d77f2fac..eb40a30f6b 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -568,8 +568,10 @@ def build( for option, value in self.session_config["environment"].items(): self.session.set_environment(option, value) - # Set lifecycle tmux hooks - if "on_project_exit" in self.session_config: + # Session-scoped lifecycle hooks and metadata belong only to sessions + # created by this build. Reused sessions may already carry unrelated + # hooks or tmuxp metadata from other windows/workspaces. + if session_created and "on_project_exit" in self.session_config: exit_cmds = self.session_config["on_project_exit"] if isinstance(exit_cmds, str): exit_cmds = [exit_cmds] @@ -585,7 +587,7 @@ def build( ) # Store on_project_stop in session environment for tmuxp stop - if "on_project_stop" in self.session_config: + if session_created and "on_project_stop" in self.session_config: stop_cmds = self.session_config["on_project_stop"] if isinstance(stop_cmds, str): stop_cmds = [stop_cmds] @@ -595,7 +597,7 @@ def build( ) # Store start_directory in session environment for hook cwd - if "start_directory" in self.session_config: + if session_created and "start_directory" in self.session_config: self.session.set_environment( "TMUXP_START_DIRECTORY", self.session_config["start_directory"], @@ -702,15 +704,9 @@ def iter_create_windows( if panes and "start_directory" in panes[0]: start_directory = panes[0]["start_directory"] - # Provision environment via tmux session env (inherited - # by new panes). Matches teamocil, which does not inject - # env vars via send_keys at all. environment = window_config.get("environment") if panes and "environment" in panes[0]: environment = panes[0]["environment"] - if environment: - for _ekey, _eval in environment.items(): - session.set_environment(_ekey, str(_eval)) # Resolve window_shell window_shell = window_config.get("window_shell") diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index b65fd773f6..7a6a87f488 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -605,9 +605,7 @@ def test_here_mode_duplicate_session_name( def test_here_mode_provisions_environment( session: Session, ) -> None: - """--here mode sets environment via session and respawn-pane, not send_keys.""" - from libtmux.test.retry import retry_until - + """--here mode provisions the reused pane without mutating session env.""" workspace: dict[str, t.Any] = { "session_name": session.name, "windows": [ @@ -615,7 +613,11 @@ def test_here_mode_provisions_environment( "window_name": "env-test", "environment": {"TMUXP_HERE_TEST": "hello_here"}, "panes": [ - {"shell_command": ["echo $TMUXP_HERE_TEST"]}, + { + "shell_command": [ + "printf '%s' \"${TMUXP_HERE_TEST-unset}\"", + ], + }, ], }, ], @@ -625,11 +627,9 @@ def test_here_mode_provisions_environment( builder = WorkspaceBuilder(session_config=workspace, server=session.server) builder.build(session=session, here=True) - # Verify env var is set at session level (tmux primitive) env = session.show_environment() - assert env.get("TMUXP_HERE_TEST") == "hello_here" + assert env.get("TMUXP_HERE_TEST") is None - # Verify the respawned pane also sees the var pane = session.active_window.active_pane assert pane is not None @@ -747,25 +747,44 @@ def test_here_mode_respawn_provisioning( if environment: env = session.show_environment() - for key, val in environment.items(): - assert env.get(key) == val + for key in environment: + assert env.get(key) is None -def test_here_mode_respawn_multiple_env_vars( +def test_here_mode_does_not_leak_first_pane_environment( session: Session, ) -> None: - """--here mode sets multiple environment variables via set_environment.""" + """--here mode keeps first-pane environment out of later windows.""" workspace: dict[str, t.Any] = { "session_name": session.name, "windows": [ { - "window_name": "multi-env", + "window_name": "first-window", "environment": { - "TMUXP_A": "alpha", - "TMUXP_B": "bravo", - "TMUXP_C": "charlie", + "TMUXP_HERE_ALPHA": "alpha", + "TMUXP_HERE_BRAVO": "bravo", + "TMUXP_HERE_CHARLIE": "charlie", }, - "panes": [{"shell_command": []}], + "panes": [ + { + "shell_command": [ + "printf '%s:%s:%s' " + '"$TMUXP_HERE_ALPHA" ' + '"$TMUXP_HERE_BRAVO" ' + '"$TMUXP_HERE_CHARLIE"', + ], + }, + ], + }, + { + "window_name": "later-window", + "panes": [ + { + "shell_command": [ + "printf '%s' \"${TMUXP_HERE_ALPHA-unset}\"", + ], + }, + ], }, ], } @@ -775,9 +794,87 @@ def test_here_mode_respawn_multiple_env_vars( builder.build(session=session, here=True) env = session.show_environment() - assert env.get("TMUXP_A") == "alpha" - assert env.get("TMUXP_B") == "bravo" - assert env.get("TMUXP_C") == "charlie" + assert env.get("TMUXP_HERE_ALPHA") is None + assert env.get("TMUXP_HERE_BRAVO") is None + assert env.get("TMUXP_HERE_CHARLIE") is None + + first_window = next( + window for window in session.windows if window.name == "first-window" + ) + later_window = next( + window for window in session.windows if window.name == "later-window" + ) + first_pane = first_window.active_pane + later_pane = later_window.active_pane + assert first_pane is not None + assert later_pane is not None + + assert retry_until( + lambda: "alpha:bravo:charlie" in "\n".join(first_pane.capture_pane()), + seconds=5, + ) + assert retry_until( + lambda: "unset" in "\n".join(later_pane.capture_pane()), + seconds=5, + ) + + +class ReusedSessionMetadataFixture(t.NamedTuple): + """Fixture for reused-session lifecycle metadata scenarios.""" + + test_id: str + build_kwargs: dict[str, bool] + + +REUSED_SESSION_METADATA_FIXTURES: list[ReusedSessionMetadataFixture] = [ + ReusedSessionMetadataFixture( + test_id="append", + build_kwargs={"append": True}, + ), + ReusedSessionMetadataFixture( + test_id="here", + build_kwargs={"here": True}, + ), +] + + +@pytest.mark.parametrize( + list(ReusedSessionMetadataFixture._fields), + REUSED_SESSION_METADATA_FIXTURES, + ids=[fixture.test_id for fixture in REUSED_SESSION_METADATA_FIXTURES], +) +def test_reused_session_keeps_existing_lifecycle_metadata( + session: Session, + tmp_path: pathlib.Path, + test_id: str, + build_kwargs: dict[str, bool], +) -> None: + """Append and --here preserve pre-existing session hook and env metadata.""" + original_hook = "run-shell 'printf %s original-exit >/dev/null'" + session.set_hook("client-detached", original_hook) + session.set_environment("TMUXP_ON_PROJECT_STOP", "original stop") + session.set_environment("TMUXP_START_DIRECTORY", "/original/start") + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "start_directory": str(tmp_path), + "on_project_exit": "printf '%s' new-exit >/dev/null", + "on_project_stop": "printf '%s' new-stop >/dev/null", + "windows": [ + {"window_name": f"{test_id}-window", "panes": [{"shell_command": []}]} + ], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, **build_kwargs) + + hooks = [str(value) for value in session.show_hooks().values()] + assert any("original-exit" in value for value in hooks) + assert all("new-exit" not in value for value in hooks) + assert session.getenv("TMUXP_ON_PROJECT_STOP") == "original stop" + assert session.getenv("TMUXP_START_DIRECTORY") == "/original/start" def test_here_mode_respawn_warns_on_running_processes( From d0dfdefad9aa2b3a272e19234b51baf838790cc4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 10:08:25 -0500 Subject: [PATCH 131/143] docs(CHANGES): Restructure parity release notes to house style why: The unreleased entry removed the KEEP THIS PLACEHOLDER insertion anchor and skipped the lead paragraph and fixed subheadings that every published release uses, while the importer section leaked internal key-mapping mechanics into user-facing notes. what: - Restore the placeholder block; entries land below the END marker - Open with a release lead paragraph; group deliverables under What's new - Collapse importer key-mapping bullets into one prose deliverable - Cross-reference cli-stop, top-level, cli-import, and comparison docs - Drop tmux mechanism asides (select-pane -T, client-detached hook) --- CHANGES | 123 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 61 insertions(+), 62 deletions(-) diff --git a/CHANGES b/CHANGES index 723616bb22..eec12d148d 100644 --- a/CHANGES +++ b/CHANGES @@ -38,89 +38,88 @@ $ tmuxp@next load yoursession ## tmuxp 1.71.0 (Yet to be released) -### New commands + -#### `tmuxp stop` — kill a tmux session (#1025) -Stop (kill) a running tmux session by name. Runs the `on_project_stop` -lifecycle hook before killing the session, giving your project a chance -to clean up. + +_Notes on the upcoming release will go here._ + + +tmuxp 1.71.0 brings tmuxp to feature parity with tmuxinator and +teamocil. Sessions can be managed end to end from the CLI (`stop`, +`new`, `copy`, `delete`), workspace configs gain lifecycle hooks, +`{{ variable }}` templating, and pane titles, and `tmuxp load` learns +`--here` and friends. Imports from tmuxinator and teamocil now convert +much more of an existing config. See {doc}`comparison` for a +side-by-side of all three tools. + +### What's new + +#### `tmuxp stop` — kill a session with cleanup (#1025) + +Stop a running session by name. The `on_project_stop` lifecycle hook +runs before the session is killed, giving your project a chance to +clean up. See {ref}`cli-stop`. ```console $ tmuxp stop mysession ``` -#### `tmuxp new` — create a workspace config (#1025) -Create a new workspace configuration file from a minimal template and -open it in `$EDITOR`. +#### `tmuxp new`, `tmuxp copy`, `tmuxp delete` — manage workspace configs (#1025) + +Create a workspace config from a starter template and open it in +`$EDITOR`, duplicate an existing config under a new name, or delete +configs (with a confirmation prompt unless `-y` is passed). ```console $ tmuxp new myproject ``` -#### `tmuxp copy` — copy a workspace config (#1025) -Copy an existing workspace config to a new name. Source is resolved -using the same logic as `tmuxp load`. - -```console -$ tmuxp copy myproject myproject-backup -``` +#### Lifecycle hooks (#1025) -#### `tmuxp delete` — delete workspace configs (#1025) -Delete one or more workspace config files. Prompts for confirmation -unless `-y` is passed. +Workspace configs support four hooks, modeled on tmuxinator's: -```console -$ tmuxp delete old-project -``` +- `on_project_start` — before a new session is built +- `on_project_restart` — after you confirm reattaching to an existing session +- `on_project_exit` — when the last client detaches +- `on_project_stop` — before `tmuxp stop` kills the session -### Lifecycle hooks (#1025) -Workspace configs now support four lifecycle hooks inspired by tmuxinator: +See {ref}`top-level` for examples. -- `on_project_start` — runs before session build (new session creation only) -- `on_project_restart` — runs when reattaching to an existing session (confirmed attach only) -- `on_project_exit` — runs when the last client detaches (via tmux `client-detached` hook) -- `on_project_stop` — runs before `tmuxp stop` kills the session +#### Config templating with `--set` (#1025) -### Config templating (#1025) -Workspace configs now support simple `{{ variable }}` placeholders for variable substitution. -Pass values via `--set KEY=VALUE` on the command line: +Workspace configs may contain `{{ variable }}` placeholders, filled at +load time: ```console $ tmuxp load --set project=myapp mytemplate.yaml ``` -### New config keys (#1025) -- **`enable_pane_titles`** / **`pane_title_position`** / **`pane_title_format`** — - session-level keys that enable tmux pane border titles. -- **`title`** — pane-level key to set individual pane titles via - `select-pane -T`. -- **`synchronize`** — window-level shorthand (`before` / `after` / `true`) - that sets `synchronize-panes` without needing `options_after`. -- **`shell_command_after`** — window-level key; commands sent to every pane - after the window is fully built. -- **`clear`** — window-level boolean; sends `clear` to every pane after - commands complete. - -### New `tmuxp load` flags (#1025) -- `--here` — reuse the current tmux window instead of creating a new session. -- `--no-shell-command-before` — skip all `shell_command_before` entries. -- `--debug` — show tmux commands as they execute (disables progress spinner). -- `--set KEY=VALUE` — pass template variables for config templating. - -### Importer improvements (#1025) -#### tmuxinator - -- Map `pre` → `on_project_start`, `pre_window` → `shell_command_before`. -- Parse `cli_args` (`-f`, `-S`, `-L`) into tmuxp equivalents. -- Convert `synchronize` window key. -- Convert `startup_window` / `startup_pane` → `focus: true`. -- Convert named panes (hash-key syntax) → `title` on the pane. - -#### teamocil - -- Support v1.x format (`windows` at top level, `commands` key in panes). -- Convert `focus: true` on windows and panes. -- Pass through window `options`. +#### Pane titles (#1025) + +Session-level `enable_pane_titles`, `pane_title_position`, and +`pane_title_format` keys turn on tmux pane border titles; a pane-level +`title` key names individual panes. + +#### New window keys: `synchronize`, `shell_command_after`, `clear` (#1025) + +`synchronize: before/after/true` mirrors keystrokes across a window's +panes; `shell_command_after` runs commands in every pane after the +window is built; `clear: true` clears each pane once its commands +complete. + +#### New `tmuxp load` flags (#1025) + +- `--here` — build the workspace in your current tmux window instead of a new session +- `--no-shell-command-before` — skip all `shell_command_before` entries +- `--debug` — show each tmux command as it executes +- `--set KEY=VALUE` — fill config template variables + +#### Broader tmuxinator and teamocil imports (#1025) + +`tmuxp import` now converts lifecycle hooks, named panes, startup +window/pane focus, synchronized windows, and tmux CLI arguments from +tmuxinator configs, and recognizes teamocil's v1.x format. See +{ref}`cli-import` for the full key mappings. ## tmuxp 1.70.0 (2026-05-23) From 56f480ac0eac064a19bbe79ae42013f81edcda98 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 11:57:56 -0500 Subject: [PATCH 132/143] docs(comparison): Drop dated last-updated marker why: Dated "as of" claims rot immediately (AGENTS.md brittle-references rule); the page read as three months stale despite ongoing updates. what: - Remove the Last updated line; the Version table row remains the durable anchor for which tmuxinator/teamocil releases were compared --- docs/comparison.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 51313ec28b..202d8966d1 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -1,7 +1,5 @@ # Feature Comparison: tmuxp vs tmuxinator vs teamocil -*Last updated: 2026-03-07* - ## Overview | | tmuxp | tmuxinator | teamocil | From 9a36d43c8da940c1fb2748bc78ca47086415c156 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 12:03:25 -0500 Subject: [PATCH 133/143] test(cli[load]): Replace MagicMock with monkeypatch stubs in --here error recovery test why: AGENTS.md testing guidelines require monkeypatch over unittest.mock; the MagicMock builder also hid which attributes _dispatch_build actually touches, weakening the test's contract. what: - Stub the failing load paths with typed raising functions matching the real loader signatures, registered via monkeypatch.setattr - Stand in for the builder with a minimal DummyBuilder exposing only session, mirroring the dispatch-loaders test's existing pattern - Promote the exc import to module level alongside cli --- tests/cli/test_load.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 762ff5cdf0..49f727a63a 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -14,7 +14,7 @@ from tests.constants import FIXTURE_PATH from tests.fixtures import utils as test_utils -from tmuxp import cli +from tmuxp import cli, exc from tmuxp._internal.colors import ColorMode, Colors from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath @@ -1799,10 +1799,14 @@ def test_here_error_recovery_prompt( kill_option_present: bool, ) -> None: """--here error recovery skips (k)ill to protect user's live session.""" - from unittest.mock import MagicMock - from tmuxp._internal.colors import ColorMode, Colors - from tmuxp.cli.load import _dispatch_build + class DummyBuilder: + """Minimal builder stub for the error-recovery prompt path.""" + + def __init__(self) -> None: + self.session = None + + builder = t.cast(WorkspaceBuilder, DummyBuilder()) captured_kwargs: dict[str, t.Any] = {} @@ -1816,23 +1820,30 @@ def _capture_prompt_choices(*args: t.Any, **kwargs: t.Any) -> str: _capture_prompt_choices, ) - # Create a mock builder that raises TmuxpException when built - from tmuxp import exc + def _raise_here(builder: WorkspaceBuilder) -> None: + msg = "test error" + raise exc.TmuxpException(msg) - mock_builder = MagicMock() - mock_builder.session = None + def _raise_attached( + builder: WorkspaceBuilder, + detached: bool, + pre_build_hook: t.Callable[[], None] | None = None, + pre_attach_hook: t.Callable[[], None] | None = None, + ) -> None: + msg = "test error" + raise exc.TmuxpException(msg) - # Simulate the here path raising an error + # Make the exercised load path fail so the recovery prompt runs if here: monkeypatch.setattr( "tmuxp.cli.load._load_here_in_current_session", - MagicMock(side_effect=exc.TmuxpException("test error")), + _raise_here, ) monkeypatch.setenv("TMUX", "/tmp/tmux-test/default,12345,0") else: monkeypatch.setattr( "tmuxp.cli.load._load_attached", - MagicMock(side_effect=exc.TmuxpException("test error")), + _raise_attached, ) monkeypatch.delenv("TMUX", raising=False) @@ -1840,7 +1851,7 @@ def _capture_prompt_choices(*args: t.Any, **kwargs: t.Any) -> str: with pytest.raises(SystemExit): _dispatch_build( - builder=mock_builder, + builder=builder, detached=False, append=False, answer_yes=not here, # answer_yes triggers _load_attached path From e490d1c2de50b0e302990e7c29bc07efc1905ae6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 12:06:21 -0500 Subject: [PATCH 134/143] docs(importers): Add doctests to import_tmuxinator and import_teamocil why: AGENTS.md requires working doctests; both entry points were substantially rewritten for parity (new keyword params, v1.x support) yet only their helpers gained examples, and the new base_index / pane_base_index parameters were undocumented. what: - Add Examples covering session_name, window, and pane conversion for both importers, plus the pre -> on_project_start hook mapping - Document base_index and pane_base_index in the Parameters section --- src/tmuxp/workspace/importers.py | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index d653a8a56d..c4264afc1c 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -119,10 +119,36 @@ def import_tmuxinator( ---------- workspace_dict : dict python dict for tmuxp workspace. + base_index : int + tmux ``base-index``, used to resolve ``startup_window`` targets. + pane_base_index : int + tmux ``pane-base-index``, used to resolve ``startup_pane`` targets. Returns ------- dict + + Examples + -------- + >>> result = import_tmuxinator( + ... {"name": "demo", "windows": [{"editor": {"panes": ["vim"]}}]} + ... ) + >>> result["session_name"] + 'demo' + + >>> result["windows"][0]["window_name"] + 'editor' + + >>> result["windows"][0]["panes"] + ['vim'] + + tmuxinator lifecycle keys map to tmuxp hooks: + + >>> result = import_tmuxinator( + ... {"name": "hooked", "pre": "docker compose up -d", "windows": []} + ... ) + >>> result["on_project_start"] + 'docker compose up -d' """ logger.debug( "importing tmuxinator workspace", @@ -345,6 +371,20 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ---------- workspace_dict : dict python dict for tmuxp workspace + + Examples + -------- + >>> result = import_teamocil( + ... {"windows": [{"name": "dev", "root": "~/code", "panes": [{"cmd": "ls"}]}]} + ... ) + >>> result["windows"][0]["window_name"] + 'dev' + + >>> result["windows"][0]["start_directory"] + '~/code' + + >>> result["windows"][0]["panes"] + [{'shell_command': 'ls'}] """ _inner = workspace_dict.get("session", workspace_dict) logger.debug( From 382be3d66eadb9de2d17daaaac08dbb8ae6625d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 12:44:14 -0500 Subject: [PATCH 135/143] WorkspaceBuilder(fix[options_after]): Apply options_after after the post-build fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: synchronize: after desugars to options_after synchronize-panes=on, and tmux mirrors send-keys input across panes while that option is on. Applying options_after first made every shell_command_after entry (and clear) run once per pane per send — four executions in a two-pane window instead of two. what: - Reorder config_after_window: shell_command_after, then clear, then options_after - Add test asserting each pane runs the after-command exactly once while synchronize-panes still ends up enabled --- src/tmuxp/workspace/builder.py | 18 ++++++++----- tests/workspace/test_builder.py | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index eb40a30f6b..e577a90791 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -983,13 +983,6 @@ def config_after_window( window_config : dict config section for window """ - if "options_after" in window_config and isinstance( - window_config["options_after"], - dict, - ): - for key, val in window_config["options_after"].items(): - window.set_option(key, val) - if "shell_command_after" in window_config and isinstance( window_config["shell_command_after"], dict, @@ -1002,6 +995,17 @@ def config_after_window( for pane in window.panes: pane.send_keys("clear", enter=True) + # options_after must land last: synchronize-panes (the + # `synchronize: after` desugaring) mirrors send-keys input to + # every pane, so enabling it before the fan-out above would run + # each after-command once per pane per send. + if "options_after" in window_config and isinstance( + window_config["options_after"], + dict, + ): + for key, val in window_config["options_after"].items(): + window.set_option(key, val) + def find_current_attached_session(self) -> Session: """Return current attached session.""" assert self.server is not None diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 7a6a87f488..d9df99b196 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -413,6 +413,54 @@ def check(p: Pane = pane) -> bool: assert "__AFTER__" not in captured +def test_synchronize_after_runs_shell_command_after_once_per_pane( + session: Session, +) -> None: + """synchronize: after must not mirror shell_command_after across panes. + + tmux duplicates send-keys input to every pane while + synchronize-panes is on, so the option must be applied only after + the shell_command_after fan-out has been delivered. + """ + # Quote-split so the typed command line never matches its output + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "sync-after-cmds", + "synchronize": "after", + "shell_command_after": [ + "echo __SYNC_AF''TER__", + "echo __SYNC_DO''NE__", + ], + "panes": ["echo pane0", "echo pane1"], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("synchronize-panes") is True + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + for pane in window.panes: + + def done(p: Pane = pane) -> bool: + return output_lines(p, "__SYNC_DONE__") >= 1 + + assert retry_until(done), f"Expected __SYNC_DONE__ in pane {pane.pane_id}" + count = output_lines(pane, "__SYNC_AFTER__") + assert count == 1, ( + f"Pane {pane.pane_id} ran shell_command_after {count} times; " + "synchronize-panes was enabled before the fan-out" + ) + + def test_pane_titles( session: Session, ) -> None: From 520c29c18e90358eb3c2185d176c2377dbc334c3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 12:50:19 -0500 Subject: [PATCH 136/143] WorkspaceBuilder(fix[here]): Avoid respawning the pane running tmuxp why: --here is normally invoked from the pane being reused, so respawn-pane -k killed the foreground tmuxp process mid-build: pane commands were never sent and remaining windows never built. The existing tests drove the builder from outside the pane, so the self-kill path was unreachable in CI. what: - Add _running_inside_pane() predicate (TMUX/TMUX_PANE plus socket comparison when the server socket path is known) - Self-pane fallback: environment via session set-environment (inherited by the panes the build creates), directory via quoted cd send-keys, window_shell skipped with a warning; other panes keep the respawn-pane path - Scrub ambient TMUX_PANE in builder tests so the developer's own pane id cannot collide with fresh test-server pane ids - Document both provisioning paths in the --here note --- docs/cli/load.md | 2 +- src/tmuxp/workspace/builder.py | 92 +++++++++++++++++++++++- tests/workspace/test_builder.py | 122 ++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/docs/cli/load.md b/docs/cli/load.md index 6a27fca387..4ed710374f 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -267,7 +267,7 @@ When used, tmuxp builds the workspace panes inside the current window rather tha `--here` only supports a single workspace file per invocation. ```{note} -When `--here` needs to provision a directory, environment, or shell, tmuxp uses tmux primitives (`set-environment` and `respawn-pane`) instead of typing `cd` / `export` into the pane. If provisioning is needed, tmux will replace the active pane process before the workspace commands run, so long-running child processes in that pane can be terminated. +When `--here` needs to provision a directory, environment, or shell, tmuxp uses tmux primitives instead of typing `cd` / `export` into the pane. The pane tmuxp itself runs in is never respawned (that would kill the build mid-flight): there the directory is provisioned with a quoted `cd`, environment variables land in the tmux session environment (inherited by the panes the build creates, not the already-running shell), and `window_shell` is skipped with a warning. Any other reused pane is provisioned with `respawn-pane`, which replaces the pane's process before the workspace commands run — long-running child processes in that pane will be terminated. ``` ## Skipping shell_command_before diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index e577a90791..178d3830ca 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -85,6 +85,72 @@ def _wait_for_pane_ready( return False +def _running_inside_pane( + pane_id: str | None, + *, + socket_path: str | None, + environ: t.Mapping[str, str], +) -> bool: + """Whether the current process is running inside the given tmux pane. + + Guards ``--here`` provisioning: ``respawn-pane -k`` on the pane + that is running tmuxp would kill the build mid-flight. + + Parameters + ---------- + pane_id : str or None + target pane id (e.g. ``%5``) + socket_path : str or None + socket path of the server being built against, when known + environ : mapping + process environment supplying ``TMUX`` and ``TMUX_PANE`` + + Returns + ------- + bool + + Examples + -------- + >>> _running_inside_pane( + ... "%5", + ... socket_path="/tmp/tmux-1000/default", + ... environ={"TMUX": "/tmp/tmux-1000/default,123,0", "TMUX_PANE": "%5"}, + ... ) + True + + A different pane is not self: + + >>> _running_inside_pane( + ... "%7", + ... socket_path="/tmp/tmux-1000/default", + ... environ={"TMUX": "/tmp/tmux-1000/default,123,0", "TMUX_PANE": "%5"}, + ... ) + False + + A coinciding pane id on a different server is not self: + + >>> _running_inside_pane( + ... "%5", + ... socket_path="/tmp/tmux-1000/isolated", + ... environ={"TMUX": "/tmp/tmux-1000/default,123,0", "TMUX_PANE": "%5"}, + ... ) + False + + Outside tmux there is no self pane: + + >>> _running_inside_pane("%5", socket_path=None, environ={}) + False + """ + tmux = environ.get("TMUX") + tmux_pane = environ.get("TMUX_PANE") + if not tmux or not tmux_pane or pane_id is None: + return False + env_socket = tmux.split(",")[0] + if socket_path is not None and env_socket != socket_path: + return False + return tmux_pane == pane_id + + COLUMNS_FALLBACK = 80 @@ -723,7 +789,31 @@ def iter_create_windows( # primitives for infrastructure setup. if start_directory or environment or window_shell: _here_pane = window.active_pane - if _here_pane is not None: + if _here_pane is not None and _running_inside_pane( + _here_pane.pane_id, + socket_path=getattr(self.server, "socket_path", None), + environ=os.environ, + ): + # respawn-pane -k here would kill the tmuxp + # process driving this build; provision with + # primitives that leave the foreground process + # alive instead. + if environment: + for _ekey, _eval in environment.items(): + session.set_environment(_ekey, str(_eval)) + if start_directory: + _here_pane.send_keys( + f"cd {shlex.quote(start_directory)}", + enter=True, + ) + if window_shell: + logger.warning( + "--here cannot replace the shell of the " + "pane running tmuxp; window_shell %s " + "skipped", + window_shell, + ) + elif _here_pane is not None: # Warn if the pane has running child processes # that would be killed by respawn-pane -k _pane_pid = _here_pane.pane_pid diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index d9df99b196..ec6fff725f 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -39,6 +39,17 @@ def __call__(self, cmd: str, hist: str) -> bool: ... +@pytest.fixture(autouse=True) +def _scrub_ambient_tmux_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """Isolate tests from the developer's surrounding tmux pane. + + An inherited TMUX_PANE id (e.g. %0) can coincide with pane ids on + the freshly started test servers and trip --here's running-inside- + pane detection. Tests that need TMUX_PANE set it explicitly. + """ + monkeypatch.delenv("TMUX_PANE", raising=False) + + def test_split_windows(session: Session) -> None: """Test workspace builder splits windows in a tmux session.""" workspace = ConfigReader._from_file( @@ -799,6 +810,117 @@ def test_here_mode_respawn_provisioning( assert env.get(key) is None +class HereSelfPaneFixture(t.NamedTuple): + """Fixture for --here provisioning when tmuxp runs in the reused pane.""" + + test_id: str + start_directory: bool + environment: dict[str, str] | None + window_shell: str | None + expect_shell_warning: bool + + +HERE_SELF_PANE_FIXTURES: list[HereSelfPaneFixture] = [ + HereSelfPaneFixture( + test_id="dir-and-env-without-respawn", + start_directory=True, + environment={"TMUXP_SELF_ENV": "kept"}, + window_shell=None, + expect_shell_warning=False, + ), + HereSelfPaneFixture( + test_id="window-shell-warns-and-skips", + start_directory=False, + environment=None, + window_shell="sh", + expect_shell_warning=True, + ), +] + + +@pytest.mark.parametrize( + list(HereSelfPaneFixture._fields), + HERE_SELF_PANE_FIXTURES, + ids=[f.test_id for f in HERE_SELF_PANE_FIXTURES], +) +def test_here_mode_keeps_pane_running_tmuxp_alive( + session: Session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + test_id: str, + start_directory: bool, + environment: dict[str, str] | None, + window_shell: str | None, + expect_shell_warning: bool, +) -> None: + """--here must not respawn-pane -k the pane tmuxp itself runs in. + + respawn-pane -k on the invoking pane kills the foreground tmuxp + process mid-build, so provisioning falls back to session + set-environment and a cd send-keys; window_shell cannot be + replaced under a live foreground process and is skipped with a + warning. + """ + test_dir = tmp_path / "here_self" + test_dir.mkdir() + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "self-pane", + "panes": [{"shell_command": []}], + }, + ], + } + if start_directory: + workspace["windows"][0]["start_directory"] = str(test_dir) + if environment: + workspace["windows"][0]["environment"] = environment + if window_shell: + workspace["windows"][0]["window_shell"] = window_shell + + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + original_pane = session.active_window.active_pane + assert original_pane is not None + assert original_pane.pane_id is not None + original_pid = original_pane.pane_pid + + # Simulate tmuxp running inside the pane --here is about to reuse + monkeypatch.setenv("TMUX", "/tmp/tmux-test/default,1234,0") + monkeypatch.setenv("TMUX_PANE", original_pane.pane_id) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"): + builder.build(session=session, here=True) + + pane = session.active_window.active_pane + assert pane is not None + assert pane.pane_pid == original_pid, "--here respawned the pane running tmuxp" + + if start_directory: + expected_path = os.path.realpath(str(test_dir)) + assert retry_until( + lambda: pane.pane_current_path == expected_path, + seconds=5, + ), f"Expected {expected_path}, got {pane.pane_current_path}" + + if environment: + env = session.show_environment() + for key, value in environment.items(): + assert env.get(key) == value + + shell_warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "window_shell" in r.getMessage() + ] + assert bool(shell_warnings) == expect_shell_warning + + def test_here_mode_does_not_leak_first_pane_environment( session: Session, ) -> None: From 20b92707208b174ad6c1a76c00c1f161eac937b0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 13:07:29 -0500 Subject: [PATCH 137/143] WorkspaceBuilder(fix[here]): Normalize socket_path before self-pane comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: libtmux Server.socket_path may hold a pathlib.Path, and str != Path is always True, so _running_inside_pane misclassified a genuine self-pane as not-self for programmatic callers — falling through to respawn-pane -k and killing the tmuxp process mid-build, the exact failure the guard exists to prevent. what: - Compare the TMUX env socket against str(socket_path); widen the parameter to accept pathlib.Path and add a Path doctest case - Document that socket_path=None skips the cross-server check and errs toward the kill-free provisioning path - Rescope the respawn rationale comment to the respawn branch; the block header no longer claims "no typing into foreground programs" above the self-pane branch that sends a cd --- src/tmuxp/workspace/builder.py | 38 ++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 178d3830ca..c41de8dbfa 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -4,6 +4,7 @@ import logging import os +import pathlib import shlex import shutil import subprocess @@ -88,7 +89,7 @@ def _wait_for_pane_ready( def _running_inside_pane( pane_id: str | None, *, - socket_path: str | None, + socket_path: str | pathlib.Path | None, environ: t.Mapping[str, str], ) -> bool: """Whether the current process is running inside the given tmux pane. @@ -100,8 +101,10 @@ def _running_inside_pane( ---------- pane_id : str or None target pane id (e.g. ``%5``) - socket_path : str or None - socket path of the server being built against, when known + socket_path : str, :class:`pathlib.Path`, or None + socket path of the server being built against, when known. + ``None`` skips the cross-server check and matches on pane id + alone, erring toward the kill-free provisioning path. environ : mapping process environment supplying ``TMUX`` and ``TMUX_PANE`` @@ -118,6 +121,16 @@ def _running_inside_pane( ... ) True + :class:`pathlib.Path` socket paths match their string form: + + >>> import pathlib + >>> _running_inside_pane( + ... "%5", + ... socket_path=pathlib.Path("/tmp/tmux-1000/default"), + ... environ={"TMUX": "/tmp/tmux-1000/default,123,0", "TMUX_PANE": "%5"}, + ... ) + True + A different pane is not self: >>> _running_inside_pane( @@ -136,7 +149,7 @@ def _running_inside_pane( ... ) False - Outside tmux there is no self pane: + Without ``TMUX`` in the environment there is no self pane: >>> _running_inside_pane("%5", socket_path=None, environ={}) False @@ -146,7 +159,7 @@ def _running_inside_pane( if not tmux or not tmux_pane or pane_id is None: return False env_socket = tmux.split(",")[0] - if socket_path is not None and env_socket != socket_path: + if socket_path is not None and env_socket != str(socket_path): return False return tmux_pane == pane_id @@ -782,11 +795,8 @@ def iter_create_windows( except (KeyError, IndexError): pass - # respawn-pane -k provisions the reused pane with the - # correct directory, environment, and shell — no POSIX - # shell assumption, no typing into foreground programs, - # no history pollution. Matches teamocil's use of tmux - # primitives for infrastructure setup. + # Provision the reused pane with the correct directory, + # environment, and shell. if start_directory or environment or window_shell: _here_pane = window.active_pane if _here_pane is not None and _running_inside_pane( @@ -797,7 +807,8 @@ def iter_create_windows( # respawn-pane -k here would kill the tmuxp # process driving this build; provision with # primitives that leave the foreground process - # alive instead. + # alive instead: cd lands in the pane's tty + # buffer, env vars go to the session environment. if environment: for _ekey, _eval in environment.items(): session.set_environment(_ekey, str(_eval)) @@ -814,6 +825,11 @@ def iter_create_windows( window_shell, ) elif _here_pane is not None: + # respawn-pane -k provisions the pane with no + # POSIX shell assumption, no typing into + # foreground programs, no history pollution. + # Matches teamocil's use of tmux primitives for + # infrastructure setup. # Warn if the pane has running child processes # that would be killed by respawn-pane -k _pane_pid = _here_pane.pane_pid From b9a8e28408e3243754782e557ee3927455e268d7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 13:20:50 -0500 Subject: [PATCH 138/143] cli/delete(fix[validation]): Refuse to unlink non-workspace files why: find_workspace_file resolves any existing direct path, so `tmuxp delete -y README.md` unlinked an arbitrary file. The command's contract is deleting workspace configs; destruction happened before any validation. what: - Require the resolved path's extension to be .yaml/.yml/.json before os.remove; refuse with a warning and exit code 1 otherwise - Add parametrized refusal tests (markdown, plain text) asserting the file survives and the exit code - Add a refusal doctest and a docs note on the constraint --- docs/cli/delete.md | 4 ++++ src/tmuxp/cli/delete.py | 26 +++++++++++++++++++++++++ tests/cli/test_delete.py | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/docs/cli/delete.md b/docs/cli/delete.md index 49a183d9fa..88b38b3488 100644 --- a/docs/cli/delete.md +++ b/docs/cli/delete.md @@ -35,3 +35,7 @@ Delete multiple workspaces: ```console $ tmuxp delete proj1 proj2 ``` + +```{note} +Only workspace files are deletable: the resolved path must end in `.yaml`, `.yml`, or `.json`. Any other file is refused and the command exits with code 1. +``` diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py index 53e37e97b3..483ed3513a 100644 --- a/src/tmuxp/cli/delete.py +++ b/src/tmuxp/cli/delete.py @@ -8,6 +8,7 @@ import typing as t from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS from tmuxp.workspace.finders import find_workspace_file from ._colors import Colors, build_description, get_color_mode @@ -97,6 +98,18 @@ def command_delete( Deleted ...doomed.yaml >>> (tmp_path / "doomed.yaml").exists() False + + Non-workspace files are refused: + + >>> _ = (tmp_path / "notes.md").write_text("hi") + >>> import contextlib + >>> with contextlib.suppress(SystemExit): + ... command_delete( + ... [str(tmp_path / "notes.md")], answer_yes=True, color="never" + ... ) # doctest: +ELLIPSIS + Not a workspace file (expected .yaml/.yml/.json): ...notes.md + >>> (tmp_path / "notes.md").exists() + True """ color_mode = get_color_mode(color) colors = Colors(color_mode) @@ -110,6 +123,19 @@ def command_delete( _had_error = True continue + # The finder resolves any existing direct path; deletion is + # destructive, so require a workspace extension before unlinking. + if ( + os.path.splitext(workspace_path)[1] + not in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ): + tmuxp_echo( + colors.warning("Not a workspace file (expected .yaml/.yml/.json): ") + + colors.info(str(PrivatePath(workspace_path))), + ) + _had_error = True + continue + if not answer_yes and not prompt_yes_no( f"Delete {colors.info(str(PrivatePath(workspace_path)))}?", default=False, diff --git a/tests/cli/test_delete.py b/tests/cli/test_delete.py index 12c87075c1..d6dcda431f 100644 --- a/tests/cli/test_delete.py +++ b/tests/cli/test_delete.py @@ -148,3 +148,45 @@ def test_delete_error_exits_nonzero( if expected_output_fragment: captured = capsys.readouterr() assert expected_output_fragment in captured.out + + +class DeleteNonWorkspaceFixture(t.NamedTuple): + """Test fixture for tmuxp delete refusing non-workspace files.""" + + test_id: str + filename: str + + +DELETE_NON_WORKSPACE_FIXTURES: list[DeleteNonWorkspaceFixture] = [ + DeleteNonWorkspaceFixture(test_id="markdown_file", filename="README.md"), + DeleteNonWorkspaceFixture(test_id="text_file", filename="notes.txt"), +] + + +@pytest.mark.parametrize( + list(DeleteNonWorkspaceFixture._fields), + DELETE_NON_WORKSPACE_FIXTURES, + ids=[f.test_id for f in DELETE_NON_WORKSPACE_FIXTURES], +) +def test_delete_refuses_non_workspace_file( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + filename: str, +) -> None: + """Tmuxp delete must not unlink files without a workspace extension.""" + configdir = tmp_path / "configdir" + configdir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(configdir)) + monkeypatch.chdir(tmp_path) + target = tmp_path / filename + target.write_text("not a workspace", encoding="utf-8") + + with pytest.raises(SystemExit) as exc_info: + cli.cli(["delete", "-y", filename]) + + assert exc_info.value.code == 1 + assert target.exists(), "non-workspace file was deleted" + captured = capsys.readouterr() + assert "Not a workspace file" in captured.out From 249b3a672c664566e7d9866c02b7e4fc90505f4a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 13:24:16 -0500 Subject: [PATCH 139/143] util(fix[run_hook_commands]): Let lifecycle hooks run to completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The 120s subprocess timeout silently killed long-running hooks (database setup, docker compose teardown) and continued anyway — on_project_stop could leave cleanup half-done while the session was still killed afterward. The hook docs promise the commands run at lifecycle boundaries with no mention of a limit, and tmuxinator (the parity target) imposes none. what: - Drop the timeout and the TimeoutExpired handler; OSError handling and the non-zero-exit warning stay - Log a structured INFO record when hooks start, so long waits are attributable - Add a test pinning the no-time-limit contract and a docs note that hooks block until completion (output captured; Ctrl-C interrupts) --- docs/configuration/top-level.md | 6 ++++++ src/tmuxp/util.py | 8 +++----- tests/test_util.py | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index ba894f5f2d..ede1db20ba 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -78,6 +78,12 @@ These hooks are inspired by tmuxinator's lifecycle hooks but have tmuxp-specific `on_project_restart` only fires when you confirm reattaching to an existing session. ``` +```{note} +Hooks block tmuxp until they complete — there is no time limit. Hook output is +captured rather than shown (failures log the output at debug level), so a +long-running hook looks quiet; `Ctrl-C` interrupts both the hook and tmuxp. +``` + ```{note} `on_project_exit` uses tmux's `client-detached` hook, but tmuxp guards it with `#{session_attached} == 0` so the command only runs when the **last** client detaches. This avoids repeated teardown in multi-client sessions. Unlike tmuxinator's wrapper-process hook, tmuxp keeps the hook on the session itself for the session lifetime. ``` diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 48eb23251d..e386238bd7 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -113,6 +113,8 @@ def run_hook_commands( Unlike :func:`run_before_script`, hooks use ``shell=True`` for full shell support (pipes, redirects, etc.) and do NOT raise on failure. + Hooks block until they complete — no time limit is imposed, matching + tmuxinator's behavior and the documented lifecycle contract. Parameters ---------- @@ -140,7 +142,7 @@ def run_hook_commands( joined = "; ".join(commands) if not joined.strip(): return - logger.debug("running hook commands %s", joined) + logger.info("running hook commands", extra={"tmux_cmd": joined}) try: result = subprocess.run( joined, @@ -149,11 +151,7 @@ def run_hook_commands( check=False, capture_output=True, text=True, - timeout=120, ) - except subprocess.TimeoutExpired: - logger.warning("hook command timed out after 120s: %s", joined) - return except OSError: logger.warning( "hook command failed (bad cwd or shell): %s", diff --git a/tests/test_util.py b/tests/test_util.py index 223824d9b8..3bd5dbead1 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,6 +5,7 @@ import logging import os import pathlib +import subprocess import sys import typing as t @@ -360,3 +361,23 @@ def test_run_hook_commands_failure_logs_output_at_debug( assert "HOOK_OUT" in stdout_records[0].message assert len(stderr_records) >= 1 assert "HOOK_ERR" in stderr_records[0].message + + +def test_run_hook_commands_no_time_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_hook_commands() imposes no time limit on hook subprocesses.""" + captured_kwargs: dict[str, t.Any] = {} + real_run = subprocess.run + + def _capture_run( + *args: t.Any, + **kwargs: t.Any, + ) -> subprocess.CompletedProcess[str]: + captured_kwargs.update(kwargs) + return real_run(*args, **kwargs) + + monkeypatch.setattr("tmuxp.util.subprocess.run", _capture_run) + run_hook_commands("true") + + assert "timeout" not in captured_kwargs From f26556614d1fbd40bdb653b87223bb6872253945 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 14:03:07 -0500 Subject: [PATCH 140/143] cli/load(fix[here]): Normalize --here outside tmux before session-exists handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The session-exists guard skipped its prompt-and-reattach path for here=True unconditionally, but the outside-tmux fallback to a normal attach happened later in _dispatch_build — so a running session reached Server.new_session() and crashed with libtmux's TmuxSessionExists, which the TmuxpException recovery prompt does not catch. what: - Normalize here=False (with the relocated warning) at the top of load_workspace when no TMUX client is present, so the existing-session prompt, on_project_restart, and attach flow behave like a normal load - Drop the now-unreachable outside-tmux fallback from _dispatch_build and document that its here path requires a tmux client - Replace the dispatch-level fallback fixture with a load_workspace-level test: existing session + --here outside tmux now prompts to attach instead of crashing --- src/tmuxp/cli/load.py | 35 ++++++++-------- tests/cli/test_load.py | 95 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 29 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index b95c568b0a..2eba12da95 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -436,7 +436,9 @@ def _dispatch_build( cli_colors : Colors Colors instance for styled output. here : bool - Use current window for first workspace window. + Use current window for first workspace window. Requires running + inside tmux; :func:`load_workspace` normalizes ``here=False`` + when no tmux client is present. pre_build_hook : callable, optional Called before the build only for code paths that create a new session. pre_attach_hook : callable, optional @@ -489,23 +491,7 @@ def _dispatch_build( return _setup_plugins(builder) if here: - if "TMUX" in os.environ: # tmuxp ran from inside tmux - _load_here_in_current_session(builder) - else: - logger.warning( - "--here ignored: not inside tmux, falling back to normal attach", - ) - tmuxp_echo( - cli_colors.warning("[Warning]") - + " --here requires running inside tmux; loading normally", - ) - _load_attached( - builder, - detached, - pre_build_hook=pre_build_hook, - pre_attach_hook=pre_attach_hook, - ) - + _load_here_in_current_session(builder) return _setup_plugins(builder) if append: @@ -721,6 +707,19 @@ def load_workspace( if cli_colors is None: cli_colors = Colors(ColorMode.AUTO) + # --here requires a tmux client; normalize before any session + # handling so the existing-session prompt and hooks behave like a + # normal load instead of crashing on Server.new_session(). + if here and "TMUX" not in os.environ: + logger.warning( + "--here ignored: not inside tmux, falling back to normal attach", + ) + tmuxp_echo( + cli_colors.warning("[Warning]") + + " --here requires running inside tmux; loading normally", + ) + here = False + # get the canonical path, eliminating any symlinks if isinstance(workspace_file, (str, os.PathLike)): workspace_file = pathlib.Path(workspace_file) diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 49f727a63a..6ac85fcb0c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -4,6 +4,7 @@ import contextlib import io +import logging import pathlib import typing as t @@ -1246,17 +1247,6 @@ class DispatchBuildHookFixture(t.NamedTuple): expected_loader="here", expect_pre_build_hook=False, ), - DispatchBuildHookFixture( - test_id="here-outside-tmux-fallback-runs-hook", - detached=False, - append=False, - answer_yes=False, - here=True, - inside_tmux=False, - prompt_choice=None, - expected_loader="attached", - expect_pre_build_hook=True, - ), ] @@ -1862,3 +1852,86 @@ def _raise_attached( assert captured_kwargs["choices"] == expected_choices assert captured_kwargs.get("default") == expected_default assert ("k" in captured_kwargs["choices"]) == kill_option_present + + +class HereOutsideTmuxFixture(t.NamedTuple): + """Fixture for --here normalization when run outside tmux.""" + + test_id: str + here: bool + expect_warning: bool + + +HERE_OUTSIDE_TMUX_FIXTURES: list[HereOutsideTmuxFixture] = [ + HereOutsideTmuxFixture( + test_id="here-normalized-to-normal-load", + here=True, + expect_warning=True, + ), + HereOutsideTmuxFixture( + test_id="normal-load-control", + here=False, + expect_warning=False, + ), +] + + +@pytest.mark.parametrize( + list(HereOutsideTmuxFixture._fields), + HERE_OUTSIDE_TMUX_FIXTURES, + ids=[f.test_id for f in HERE_OUTSIDE_TMUX_FIXTURES], +) +def test_load_here_outside_tmux_uses_existing_session_flow( + server: Server, + session: Session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + test_id: str, + here: bool, + expect_warning: bool, +) -> None: + """--here outside tmux must fall back to the existing-session flow. + + Without normalization, the session-exists guard is skipped for + here=True and the build crashes with TmuxSessionExists instead of + offering to attach. + """ + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + + workspace_file = tmp_path / "exists.yaml" + workspace_file.write_text( + f"session_name: {session.name}\n" + "windows:\n" + " - window_name: w\n" + " panes:\n" + " - echo hi\n", + encoding="utf-8", + ) + + prompts: list[str] = [] + + def _decline(prompt: str, *args: t.Any, **kwargs: t.Any) -> bool: + prompts.append(prompt) + return False + + monkeypatch.setattr("tmuxp.cli.load.prompt_yes_no", _decline) + + with caplog.at_level(logging.WARNING, logger="tmuxp.cli.load"): + result = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + here=here, + ) + + assert result is None + assert len(prompts) == 1 + assert "is already running" in prompts[0] + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "--here ignored" in r.message + ] + assert bool(warning_records) == expect_warning From 75f8c77aa18c1494b63bef920745220fa1af1fe1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 14:05:28 -0500 Subject: [PATCH 141/143] cli/delete(fix[validation]): Match workspace extensions case-insensitively why: The new deletion guard compared extensions case-sensitively while finders, search, and ls all lowercase before matching, so an explicitly-typed .YAML path was refused on case-insensitive filesystems even though tmuxp load accepts it. what: - Lowercase the splitext result before checking VALID_WORKSPACE_DIR_FILE_EXTENSIONS, matching the discovery code's established pattern - Add parametrized tests deleting uppercase .YAML/.JSON paths --- src/tmuxp/cli/delete.py | 2 +- tests/cli/test_delete.py | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py index 483ed3513a..40ff82fc3b 100644 --- a/src/tmuxp/cli/delete.py +++ b/src/tmuxp/cli/delete.py @@ -126,7 +126,7 @@ def command_delete( # The finder resolves any existing direct path; deletion is # destructive, so require a workspace extension before unlinking. if ( - os.path.splitext(workspace_path)[1] + os.path.splitext(workspace_path)[1].lower() not in VALID_WORKSPACE_DIR_FILE_EXTENSIONS ): tmuxp_echo( diff --git a/tests/cli/test_delete.py b/tests/cli/test_delete.py index d6dcda431f..030764a39d 100644 --- a/tests/cli/test_delete.py +++ b/tests/cli/test_delete.py @@ -190,3 +190,44 @@ def test_delete_refuses_non_workspace_file( assert target.exists(), "non-workspace file was deleted" captured = capsys.readouterr() assert "Not a workspace file" in captured.out + + +class DeleteCaseInsensitiveFixture(t.NamedTuple): + """Test fixture for case-insensitive workspace extension matching.""" + + test_id: str + filename: str + + +DELETE_CASE_INSENSITIVE_FIXTURES: list[DeleteCaseInsensitiveFixture] = [ + DeleteCaseInsensitiveFixture(test_id="uppercase_yaml", filename="proj.YAML"), + DeleteCaseInsensitiveFixture(test_id="uppercase_json", filename="proj.JSON"), +] + + +@pytest.mark.parametrize( + list(DeleteCaseInsensitiveFixture._fields), + DELETE_CASE_INSENSITIVE_FIXTURES, + ids=[f.test_id for f in DELETE_CASE_INSENSITIVE_FIXTURES], +) +def test_delete_matches_extensions_case_insensitively( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + filename: str, +) -> None: + """Workspace files with uppercase extensions are deletable. + + finders, search, and ls all match extensions case-insensitively; + the delete guard must not be stricter than discovery. + """ + configdir = tmp_path / "configdir" + configdir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(configdir)) + monkeypatch.chdir(tmp_path) + target = tmp_path / filename + target.write_text("session_name: x\n", encoding="utf-8") + + cli.cli(["delete", "-y", f"./{filename}"]) + + assert not target.exists(), "uppercase-extension workspace was not deleted" From 1cde216d04ad9ca798c011f95b115b53b8093129 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 14:07:48 -0500 Subject: [PATCH 142/143] util(fix[run_hook_commands]): Align hook start log with logging schema why: The hook start record tagged a user shell string with tmux_cmd, whose schema contract is "tmux command line" and which downstream consumers may filter on; the message also used progressive tense against the past-tense-for-events standard. what: - Log "hook commands started" with a dedicated tmux_hook_cmd key - Register tmux_hook_cmd in the AGENTS.md core key table - Test the INFO record's key, value, message, and the absence of tmux_cmd on the hook path --- AGENTS.md | 1 + src/tmuxp/util.py | 2 +- tests/test_util.py | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a11b5825c8..5065b6c1c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,7 @@ Pass structured data on every log call where useful for filtering, searching, or | `tmux_pane` | `str` | pane identifier | | `tmux_config_path` | `str` | workspace config file path | | `tmux_layout` | `str` | window layout string | +| `tmux_hook_cmd` | `str` | lifecycle hook shell command line | **Heavy/optional keys** (DEBUG only, potentially large): diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index e386238bd7..587875c09a 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -142,7 +142,7 @@ def run_hook_commands( joined = "; ".join(commands) if not joined.strip(): return - logger.info("running hook commands", extra={"tmux_cmd": joined}) + logger.info("hook commands started", extra={"tmux_hook_cmd": joined}) try: result = subprocess.run( joined, diff --git a/tests/test_util.py b/tests/test_util.py index 3bd5dbead1..b23ee5d686 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -381,3 +381,20 @@ def _capture_run( run_hook_commands("true") assert "timeout" not in captured_kwargs + + +def test_run_hook_commands_logs_start_at_info( + caplog: pytest.LogCaptureFixture, +) -> None: + """run_hook_commands() logs a structured INFO record when hooks start.""" + with caplog.at_level(logging.INFO, logger="tmuxp.util"): + run_hook_commands("true") + + start_records = [r for r in caplog.records if hasattr(r, "tmux_hook_cmd")] + assert len(start_records) == 1 + assert start_records[0].levelno == logging.INFO + assert start_records[0].tmux_hook_cmd == "true" + assert start_records[0].message == "hook commands started" + + # Hook strings are user shell commands, not tmux command lines + assert not any(hasattr(r, "tmux_cmd") for r in caplog.records) From 13dc17e8557824908c0e2bd2659b450cb3ac1bfa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 6 Jun 2026 14:09:40 -0500 Subject: [PATCH 143/143] cli/delete(docs[help]): Note the workspace-extension restriction why: The help text claimed resolution parity with tmuxp load without mentioning that delete refuses non-workspace files, implying identical accept/reject behavior between the two commands. what: - State in the --help intro that only .yaml/.yml/.json files are deleted, matching the docs page note --- src/tmuxp/cli/delete.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py index 40ff82fc3b..6b9699d12e 100644 --- a/src/tmuxp/cli/delete.py +++ b/src/tmuxp/cli/delete.py @@ -21,6 +21,7 @@ Delete workspace config files. Resolves workspace names using the same logic as ``tmuxp load``. + Only workspace files (.yaml/.yml/.json) are deleted. Prompts for confirmation unless ``-y`` is passed. """, (