From 0868c75e635d03f11e6ba8bd1ee783e28e75dee2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:00:15 -0600 Subject: [PATCH 001/152] ai(rules[check:*,implement]) Add tmuxinator parity commands --- .claude/commands/check/parity.md | 78 ++++++++++++ .claude/commands/check/shortcomings.md | 51 ++++++++ .claude/commands/implement.md | 160 +++++++++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 .claude/commands/check/parity.md create mode 100644 .claude/commands/check/shortcomings.md create mode 100644 .claude/commands/implement.md diff --git a/.claude/commands/check/parity.md b/.claude/commands/check/parity.md new file mode 100644 index 0000000000..533a06eb1e --- /dev/null +++ b/.claude/commands/check/parity.md @@ -0,0 +1,78 @@ +# /check:parity — Feature Parity Analysis + +Deep-dive analysis of tmuxp vs tmuxinator and teamocil. Updates comparison docs and parity notes. + +## Workflow + +1. **Read source code** of all three projects: + - tmuxp: `src/tmuxp/workspace/` (builder.py, loader.py, importers.py), `src/tmuxp/cli/load.py` + - tmuxinator: `~/study/ruby/tmuxinator/lib/tmuxinator/` (project.rb, window.rb, pane.rb, hooks/, assets/template.erb) + - teamocil: `~/study/ruby/teamocil/lib/teamocil/tmux/` (session.rb, window.rb, pane.rb) + +2. **Read existing docs** for baseline: + - `docs/about.md` — tmuxp's own feature description + - `docs/comparison.md` — feature comparison table (create if missing) + - `notes/parity-tmuxinator.md` — tmuxinator parity analysis (create if missing) + - `notes/parity-teamocil.md` — teamocil parity analysis (create if missing) + +3. **Update `docs/comparison.md`** with tabular feature comparison: + - Overview table (language, min tmux, config format, architecture) + - Configuration keys table (every key across all three, with ✓/✗) + - CLI commands table (side-by-side) + - Architecture comparison (ORM vs script generation vs command objects) + - Include version numbers for each project + +4. **Update `notes/parity-tmuxinator.md`** with: + - Features tmuxinator has that tmuxp lacks (with source locations) + - Import behavior analysis (what the current importer handles vs misses) + - WorkspaceBuilder requirements for 100% feature support + - Code quality issues in current importer + +5. **Update `notes/parity-teamocil.md`** with: + - Features teamocil has that tmuxp lacks (with source locations) + - v0.x vs v1.4.2 format differences (current importer targets v0.x only) + - Import behavior analysis + - WorkspaceBuilder requirements for full parity + +6. **Commit each file separately** + +## Key areas to verify + +- Check `importers.py` line-by-line against actual tmuxinator/teamocil config keys +- Verify `load_workspace()` actually reads config keys it claims to support +- Cross-reference CHANGELOGs for version-specific features +- Check test fixtures match real-world configs + +--- + +# Import Behavior + +Study tmuxp, teamocil, and tmuxinator source code. Find any syntax they support that tmuxp's native syntax doesn't. + +Create/update: +- `notes/import-teamocil.md` +- `notes/import-tmuxinator.md` + +## Syntax Level Differences / Limitations + +For each config key and syntax pattern discovered, classify as: + +### Differences (Translatable) + +Syntax that differs but can be automatically converted during import. Document the mapping. + +### Limitations (tmuxp needs to add support) + +Syntax/features that cannot be imported because tmuxp lacks the underlying capability. For each, note: +1. What the feature does in the source tool +2. Why it can't be imported +3. What tmuxp would need to add + +--- + +# WorkspaceBuilder + +Analyze what WorkspaceBuilder needs to: + +1. **Auto-detect config format** — Determine heuristics to identify tmuxinator vs teamocil vs tmuxp configs transparently +2. **100% feature support** — List every feature/behavior needed for complete compatibility, including behavioral idiosyncrasies diff --git a/.claude/commands/check/shortcomings.md b/.claude/commands/check/shortcomings.md new file mode 100644 index 0000000000..48a3669da0 --- /dev/null +++ b/.claude/commands/check/shortcomings.md @@ -0,0 +1,51 @@ +# /check:shortcomings — API Limitations Analysis + +Second-step command that reads parity analysis and outputs API blockers to `notes/plan.md`. + +## Input Files (from /check:parity) + +- `notes/parity-tmuxinator.md` +- `notes/parity-teamocil.md` +- `notes/import-tmuxinator.md` +- `notes/import-teamocil.md` + +## Workflow + +1. **Read parity analysis files** to understand feature gaps + +2. **Explore libtmux** at `~/work/python/libtmux/`: + - What APIs are missing? (e.g., no `pane.set_title()`) + - What's hardcoded? (e.g., `shutil.which("tmux")`) + +3. **Explore tmuxp** at `~/work/python/tmuxp/`: + - What config keys are dead data? + - What keys are missing from loader/builder? + - What CLI flags are missing? + +4. **Update `notes/plan.md`** with: + - libtmux limitations (what Server/Pane/Window/Session can't do) + - tmuxp limitations (what WorkspaceBuilder/loader/cli can't do) + - Dead config keys (imported but ignored) + - Required API additions for each gap + - Non-breaking implementation notes + +5. **Commit** `notes/plan.md` + +## Output Structure + +notes/plan.md should follow this format: + +### libtmux Limitations +Per-limitation: +- **Blocker**: What API is missing/hardcoded +- **Blocks**: What parity feature this prevents +- **Required**: What API addition is needed + +### tmuxp Limitations +Per-limitation: +- **Blocker**: What's missing/broken +- **Blocks**: What parity feature this prevents +- **Required**: What change is needed + +### Implementation Notes +Non-breaking approach for each limitation. diff --git a/.claude/commands/implement.md b/.claude/commands/implement.md new file mode 100644 index 0000000000..869bfb9805 --- /dev/null +++ b/.claude/commands/implement.md @@ -0,0 +1,160 @@ +# /implement — Plan and Implement from notes/plan.md + +Orchestrates the full implementation workflow: plan → implement → test → verify → commit → document. + +## Reference Codebases + +- **tmuxinator**: `~/study/ruby/tmuxinator/` +- **teamocil**: `~/study/ruby/teamocil/` +- **tmux**: `~/study/c/tmux/` +- **libtmux**: `~/work/python/libtmux/` +- **tmuxp**: `~/work/python/tmuxp/` + +## Workflow + +### Phase 1: Planning Mode + +1. **Read the plan**: Load `notes/plan.md` to understand what needs to be implemented +2. **Select a task**: Pick the highest priority incomplete item from the plan +3. **Research**: + - Read relevant tmuxinator/teamocil Ruby source for behavior reference + - Read libtmux Python source for available APIs + - Read tmuxp source for integration points + - **Study existing tests** for similar functionality (see Testing Pattern below) +4. **Create implementation plan**: Design the specific changes needed +5. **Exit planning mode** with the finalized approach + +### Phase 2: Implementation + +1. **Make changes**: Edit the necessary files +2. **Follow conventions**: Match existing code style, use type hints, add docstrings + +### Phase 3: Write Tests + +**CRITICAL**: Before running verification, write tests for new functionality. + +1. **Find similar tests**: Search `tests/` for existing tests of similar features +2. **Follow the project test pattern** (see Testing Pattern below) +3. **Add test cases**: Cover normal cases, edge cases, and error conditions + +### Phase 4: Verification + +Run the full QA suite: + +```bash +uv run ruff check . --fix --show-fixes +uv run ruff format . +uv run mypy +uv run py.test --reruns 0 -vvv +``` + +All checks must pass before proceeding. + +### Phase 5: Commit Implementation + +**Source and tests must be in separate commits.** + +1. **Commit source code first**: Implementation changes only (e.g., `fix(cli): Read socket_name/path and config from workspace config`) +2. **Commit tests second**: Test files only (e.g., `tests(cli): Add config key precedence tests for load_workspace`) + +Follow the project's commit conventions (e.g., `feat:`, `fix:`, `refactor:` for source; `tests:` or `tests():` for tests). + +### Phase 6: Update Documentation + +1. **Update `notes/completed.md`**: Add entry for what was implemented + - Date + - What was done + - Files changed + - Any notes or follow-ups + +2. **Update `notes/plan.md`**: Mark the item as complete or remove it + +3. **Commit notes separately**: Use message like `notes: Mark as complete` + +--- + +## Testing Pattern + +This project uses a consistent test pattern. **Always follow this pattern for new tests.** + +### 1. NamedTuple Fixture Class + +```python +import typing as t + +class MyFeatureTestFixture(t.NamedTuple): + """Test fixture for my feature tests.""" + + # pytest (internal): Test fixture name + test_id: str + + # test params + input_value: str + expected_output: str + expected_error: str | None = None +``` + +### 2. Fixture List + +```python +TEST_MY_FEATURE_FIXTURES: list[MyFeatureTestFixture] = [ + MyFeatureTestFixture( + test_id="normal-case", + input_value="foo", + expected_output="bar", + ), + MyFeatureTestFixture( + test_id="edge-case-empty", + input_value="", + expected_output="", + ), + MyFeatureTestFixture( + test_id="error-case", + input_value="bad", + expected_output="", + expected_error="Invalid input", + ), +] +``` + +### 3. Parametrized Test Function + +```python +@pytest.mark.parametrize( + "test", + TEST_MY_FEATURE_FIXTURES, + ids=[test.test_id for test in TEST_MY_FEATURE_FIXTURES], +) +def test_my_feature(test: MyFeatureTestFixture) -> None: + """Test my feature with various inputs.""" + result = my_function(test.input_value) + assert result == test.expected_output + + if test.expected_error: + # check error handling + pass +``` + +### Key Rules + +- **Function tests only** — No `class TestFoo:` groupings (per CLAUDE.md) +- **Use fixtures from `tests/fixtures/`** — Prefer real tmux fixtures over mocks +- **Use `tmp_path`** — Not Python's `tempfile` +- **Use `monkeypatch`** — Not `unittest.mock` + +--- + +## Output + +After completion, report: +- What was implemented +- Files changed (including test files) +- Test results summary +- What remains in the plan + +## Notes + +- If tests fail, fix the issues before committing +- If libtmux changes are needed, note them but don't modify libtmux in this workflow +- One logical change per run — don't implement multiple unrelated items +- **Always write tests** — No implementation is complete without tests From 45c93615e8c5a3a21e43fd4ece578186b5755b9b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:08:05 -0600 Subject: [PATCH 002/152] docs(comparison) Add feature comparison table for tmuxp/tmuxinator/teamocil Comprehensive 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 0c8720620d20e0c729345558d45e404fd89cecc5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:08:11 -0600 Subject: [PATCH 003/152] notes(parity) Add tmuxinator parity analysis Documents 12 feature gaps (hooks, stop command, pane sync, pane titles, ERB templating, wemux, debug/dry-run, config management CLIs), import behavior with bug inventory, and WorkspaceBuilder requirements. --- notes/parity-tmuxinator.md | 266 +++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 notes/parity-tmuxinator.md diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md new file mode 100644 index 0000000000..83aba4e00f --- /dev/null +++ b/notes/parity-tmuxinator.md @@ -0,0 +1,266 @@ +# Tmuxinator Parity Analysis + +*Last updated: 2026-02-08* +*Tmuxinator version analyzed: 3.3.7* +*tmuxp version: 1.47.0+* + +## Features tmuxinator has that tmuxp lacks + +### 1. Project Hooks (Lifecycle Events) + +**Source**: `lib/tmuxinator/hooks/project.rb`, `assets/template.erb` + +tmuxinator has 5 lifecycle hooks: + +| Hook | Description | tmuxp equivalent | +|---|---|---| +| `on_project_start` | Runs on every `start` invocation | `before_script` (partial — runs commands, but kills session on failure) | +| `on_project_first_start` | Runs only when session doesn't exist yet | No equivalent | +| `on_project_restart` | Runs when attaching to existing session | Plugin `reattach()` (requires writing a plugin) | +| `on_project_exit` | Runs when detaching from session | No equivalent | +| `on_project_stop` | Runs on `tmuxinator stop` | No equivalent (tmuxp has no `stop` command) | + +**Gap**: tmuxp's `before_script` only covers the "first start" case and kills the session on failure. It has no hooks for detach/exit/stop events, and no distinction between first start vs. restart. + +**WorkspaceBuilder requirement**: Add config keys for `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`. The exit/stop hooks require shell integration (trap signals, set-hook in tmux). + +### 2. Stop/Kill Session Command + +**Source**: `lib/tmuxinator/cli.rb` (`stop`, `stop_all`), `assets/template-stop.erb` + +tmuxinator provides: + +```bash +tmuxinator stop # Kill specific session + run on_project_stop hook +tmuxinator stop-all # Kill all tmuxinator-managed sessions +``` + +**Gap**: tmuxp has no `stop` or `kill` command. Users must use `tmux kill-session` directly, which skips any cleanup hooks. + +### 3. Session Name Override at Load Time + +**Source**: `lib/tmuxinator/cli.rb` (`--name` / `-n` option) + +```bash +tmuxinator start myproject --name custom-session-name +``` + +**Gap**: tmuxp has `tmuxp load -s ` which provides this. **No gap** — tmuxp already supports this. + +### 4. Startup Window / Startup Pane Selection + +**Source**: `lib/tmuxinator/project.rb` (`startup_window`, `startup_pane`) + +```yaml +startup_window: editor # Select this window after build +startup_pane: 1 # Select this pane within the startup window +``` + +**Gap**: tmuxp supports `focus: true` on windows and panes (boolean), which is equivalent but syntactically different. The `startup_window` key allows referencing by name or index. **Partial parity** — tmuxp can achieve this but uses a different mechanism (`focus` key on individual windows/panes). + +### 5. Pane Synchronization + +**Source**: `lib/tmuxinator/window.rb` (`synchronize`) + +```yaml +windows: + - editor: + synchronize: true # or "before" or "after" + panes: + - vim + - vim +``` + +- `synchronize: true` / `synchronize: before` — enable pane sync before running pane commands +- `synchronize: after` — enable pane sync after running pane commands + +**Gap**: tmuxp has no `synchronize` config key. Users would need to set `synchronize-panes on` via `options` manually, but this doesn't support the before/after distinction. + +**WorkspaceBuilder requirement**: Add `synchronize` key to window config with `before`/`after`/`true`/`false` values. + +### 6. Pane Titles + +**Source**: `lib/tmuxinator/project.rb`, `lib/tmuxinator/pane.rb` + +```yaml +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - editor: + panes: + - my-editor: vim # "my-editor" becomes the pane title +``` + +**Gap**: tmuxp has no pane title support. Named panes in tmuxinator (hash syntax: `pane_name: command`) set both a title and commands. + +**WorkspaceBuilder requirement**: Add session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` keys. Add per-pane `title` key. Issue `select-pane -T ` after pane creation. + +### 7. ERB Templating / Variable Interpolation + +**Source**: `lib/tmuxinator/project.rb` (`parse_settings`, `render_template`) + +```bash +tmuxinator start myproject env=production port=3000 +``` + +```yaml +# config.yml +root: ~/apps/<%= @settings["app"] %> +windows: + - server: + panes: + - rails server -p <%= @settings["port"] || 3000 %> +``` + +**Gap**: tmuxp has no config templating. Environment variable expansion (`$VAR`) is supported in `start_directory` paths, but not arbitrary variable interpolation in config values. + +**WorkspaceBuilder requirement**: This is an architectural difference. tmuxp could support Jinja2 templating or Python string formatting, but this is a significant feature addition. + +### 8. Wemux Support + +**Source**: `lib/tmuxinator/wemux_support.rb`, `assets/wemux_template.erb` + +```yaml +tmux_command: wemux +``` + +**Gap**: tmuxp has no wemux support. libtmux is tightly bound to the `tmux` command. + +**WorkspaceBuilder requirement**: Allow configurable tmux command binary. Requires libtmux changes. + +### 9. Debug / Dry-Run Output + +**Source**: `lib/tmuxinator/cli.rb` (`debug`) + +```bash +tmuxinator debug myproject +``` + +Outputs the generated shell script without executing it. + +**Gap**: tmuxp has no dry-run mode. Since tmuxp uses API calls rather than script generation, a dry-run would need to log the libtmux calls that *would* be made. + +### 10. Config Management Commands + +**Source**: `lib/tmuxinator/cli.rb` + +| Command | Description | +|---|---| +| `tmuxinator new <name>` | Create new config from template | +| `tmuxinator copy <src> <dst>` | Copy existing config | +| `tmuxinator delete <name>` | Delete config (with confirmation) | +| `tmuxinator implode` | Delete ALL configs | +| `tmuxinator stop <project>` | Stop session + run hooks | +| `tmuxinator stop-all` | Stop all managed sessions | + +**Gap**: tmuxp has `edit` but not `new`, `copy`, `delete`, `implode`, or `stop` commands. + +### 11. `--no-pre-window` Flag + +**Source**: `lib/tmuxinator/cli.rb` + +```bash +tmuxinator start myproject --no-pre-window +``` + +Skips `pre_window` commands. Useful for debugging. + +**Gap**: tmuxp has no equivalent flag to skip `shell_command_before`. + +### 12. `--here` Equivalent + +**Source**: teamocil provides `--here` to reuse the current window. tmuxinator has no `--here` per se but tmuxp also lacks this. + +**Gap**: Neither tmuxp nor tmuxinator has this; teamocil does. + +### 13. Create Config from Running Session + +**Source**: `lib/tmuxinator/cli.rb` (`new <name> <session>`) + +```bash +tmuxinator new myproject existing-session-name +``` + +Creates a config file pre-populated from a running tmux session. + +**Gap**: tmuxp has `tmuxp freeze` which exports to YAML/JSON. **Different approach, same result** — tmuxp's freeze is arguably more complete. + +## Import Behavior Analysis + +### Current Importer: `importers.py:import_tmuxinator` + +**What it handles:** + +| tmuxinator key | Mapped to | Status | +|---|---|---| +| `project_name` / `name` | `session_name` | ✓ Correct | +| `project_root` / `root` | `start_directory` | ✓ Correct | +| `cli_args` / `tmux_options` | `config` (extracts `-f`) | ⚠ Only handles `-f` flag, ignores `-L`, `-S` | +| `socket_name` | `socket_name` | ✓ Correct | +| `tabs` → `windows` | `windows` | ✓ Correct | +| `pre` + `pre_window` | `shell_command` + `shell_command_before` | ⚠ `shell_command` is not a valid tmuxp key | +| `pre` (alone) | `shell_command_before` | ✓ Correct | +| `rbenv` | appended to `shell_command_before` | ✓ Correct | +| Window hash key | `window_name` | ✓ Correct | +| Window `pre` | `shell_command_before` | ✓ Correct | +| Window `panes` | `panes` | ✓ Correct | +| Window `root` | `start_directory` | ✓ Correct | +| Window `layout` | `layout` | ✓ Correct | + +**What it misses or handles incorrectly:** + +| tmuxinator key | Issue | +|---|---| +| `attach` | Not imported. tmuxp uses CLI flags instead. | +| `startup_window` | Not imported. tmuxp uses `focus: true` on windows. | +| `startup_pane` | Not imported. tmuxp uses `focus: true` on panes. | +| `tmux_command` | Not imported. tmuxp has no equivalent. | +| `socket_path` | Not imported. tmuxp takes this via CLI. | +| `pre_tab` | Not imported (alias for `pre_window`). | +| `rvm` | Not imported (only `rbenv` is handled). | +| `post` | Not imported. tmuxp has no equivalent. | +| `synchronize` | Not imported. tmuxp has no equivalent. | +| `enable_pane_titles` | Not imported. tmuxp has no equivalent. | +| `pane_title_position` | Not imported. tmuxp has no equivalent. | +| `pane_title_format` | Not imported. tmuxp has no equivalent. | +| `on_project_start` | Not imported. tmuxp has no equivalent. | +| `on_project_first_start` | Not imported. Could map to `before_script`. | +| `on_project_restart` | Not imported. tmuxp has no equivalent. | +| `on_project_exit` | Not imported. tmuxp has no equivalent. | +| `on_project_stop` | Not imported. tmuxp has no equivalent. | +| Named panes (hash syntax) | Not imported. Pane names/titles are lost. | +| ERB templating | Not handled. YAML parsing will fail on ERB syntax. | +| `pre` + `pre_window` combo | Bug: sets `shell_command` which is not a tmuxp session-level key | + +### Code Quality Issues in Importer + +1. **Line 60**: When both `pre` and `pre_window` exist, the importer sets `tmuxp_workspace["shell_command"]` — but `shell_command` is not a valid session-level tmuxp key. The `pre` commands would be silently ignored. + +2. **Line 36-49**: The `cli_args`/`tmux_options` handler only extracts `-f` (config file). It ignores `-L` (socket name) and `-S` (socket path) which could also appear in these fields. + +3. **Line 79-101**: The window iteration uses `for k, v in window_dict.items()` which assumes windows are always dicts with a single key (the window name). This is correct for tmuxinator's format but fragile — if a window dict has multiple keys, only the last one is processed. + +4. **Missing `pre_tab`**: The `pre_tab` alias for `pre_window` is not handled. + +5. **Missing `rvm`**: Only `rbenv` is imported; `rvm` (another deprecated but still functional key) is ignored. + +6. **No validation or warnings**: The importer silently drops unsupported keys with no feedback to the user. + +## WorkspaceBuilder Requirements for 100% Feature Support + +### Must-Have for Parity + +1. **Pane synchronization** (`synchronize` window key) — `set-window-option synchronize-panes on/off` +2. **Pane titles** — `select-pane -T <title>`, `set-option pane-border-status top`, `set-option pane-border-format <fmt>` +3. **Startup window/pane selection** — Already achievable via `focus: true`, but could add `startup_window`/`startup_pane` as aliases +4. **Stop command** — `tmuxp stop <session>` to kill session + +### Nice-to-Have + +5. **Lifecycle hooks** — `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop` +6. **Config templating** — Jinja2 or Python format string support for config values +7. **Debug/dry-run** — Log tmux commands without executing +8. **Config management** — `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands +9. **`--no-shell-command-before`** flag — Skip `shell_command_before` for debugging +10. **Custom tmux binary** — `tmux_command` key for wemux/byobu support (requires libtmux changes) From fbd7f4c47448627283dbb391942bfd4bb952e549 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:08:16 -0600 Subject: [PATCH 004/152] notes(parity) Add teamocil parity analysis Documents v0.x vs v1.x format differences, 3 feature gaps (--here flag, debug mode, shell_command_after), import bugs (v1.x incompatibility, redundant filter loops), and WorkspaceBuilder requirements. --- notes/parity-teamocil.md | 216 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 notes/parity-teamocil.md diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md new file mode 100644 index 0000000000..1e4190c02f --- /dev/null +++ b/notes/parity-teamocil.md @@ -0,0 +1,216 @@ +# Teamocil Parity Analysis + +*Last updated: 2026-02-08* +*Teamocil version analyzed: 1.4.2* +*tmuxp version: 1.47.0+* + +## Version History Context + +Teamocil has had two distinct config formats: + +- **v0.x** (pre-1.0): Wrapped in `session:` key, used `splits` for panes, `filters` for before/after commands, `cmd` for pane commands +- **v1.x** (1.0–1.4.2): Simplified format — top-level `windows`, `panes` with `commands` key, `focus` support, window `options` + +The current tmuxp importer (`importers.py:import_teamocil`) **targets the v0.x format**. It handles the `session:` wrapper, `splits`, `filters`, and `cmd` keys — all of which are v0.x-only constructs. It does **not** handle the v1.x format natively, though v1.x configs may partially work since the `windows`/`panes` structure is similar. + +## Features teamocil has that tmuxp lacks + +### 1. `--here` Option (Reuse Current Window) + +**Source**: `lib/teamocil/tmux/window.rb`, `lib/teamocil/utils/option_parser.rb` + +```bash +teamocil --here my-layout +``` + +When `--here` is specified: +- First window: **renames** current window instead of creating a new one +- First window: sends `cd <root>` if root is specified +- Subsequent windows: created normally + +**Gap**: tmuxp always creates new windows. There is no way to populate the current window with a layout. + +**WorkspaceBuilder requirement**: Add `--here` CLI flag. For first window, use rename + cd instead of `new_window()`. This would require special handling in `WorkspaceBuilder.first_window_pass()`. + +### 2. `--show` Option (Show Raw Config) + +**Source**: `lib/teamocil/layout.rb` + +```bash +teamocil --show my-layout +``` + +Outputs the raw YAML content of the layout file. + +**Gap**: tmuxp has no equivalent. Users can `cat` the file manually. + +### 3. `--debug` Option (Show Commands Without Executing) + +**Source**: `lib/teamocil/layout.rb` + +```bash +teamocil --debug my-layout +``` + +Outputs the tmux commands that would be executed, one per line, without running them. + +**Gap**: tmuxp has no dry-run mode. Since tmuxp uses libtmux API calls rather than generating command strings, implementing this would require a logging/recording mode in the builder. + +### 4. Window-Level `focus` Key + +**Source**: `lib/teamocil/tmux/window.rb` + +```yaml +windows: + - name: editor + focus: true + panes: + - vim +``` + +**Gap**: tmuxp **does** support `focus: true` on windows. **No gap**. + +### 5. Pane-Level `focus` Key + +**Source**: `lib/teamocil/tmux/pane.rb` + +```yaml +panes: + - commands: + - vim + focus: true +``` + +**Gap**: tmuxp **does** support `focus: true` on panes. **No gap**. + +### 6. Window-Level `options` Key + +**Source**: `lib/teamocil/tmux/window.rb` + +```yaml +windows: + - name: editor + options: + main-pane-width: '100' +``` + +Maps to `set-window-option -t <window> <key> <value>`. + +**Gap**: tmuxp **does** support `options` on windows. **No gap**. + +### 7. Multiple Commands Joined by Semicolon + +**Source**: `lib/teamocil/tmux/pane.rb` + +Teamocil joins multiple pane commands with `; ` and sends them as a single `send-keys` invocation: + +```ruby +# Pane with commands: ["cd /path", "vim"] +# → send-keys "cd /path; vim" +``` + +**Gap**: tmuxp sends each command separately via individual `pane.send_keys()` calls. This is actually more reliable (each command gets its own Enter press), so this is a **behavioral difference** rather than a gap. + +## v0.x vs v1.x Format Differences + +| Feature | v0.x | v1.x (current) | +|---|---|---| +| Top-level wrapper | `session:` key | None (top-level `windows`) | +| Session name | `session.name` | `name` | +| Session root | `session.root` | (none, per-window only) | +| Panes key | `splits` | `panes` | +| Pane commands | `cmd` (string or list) | `commands` (list) | +| Before commands | `filters.before` (list) | (none) | +| After commands | `filters.after` (list) | (none) | +| Pane width | `width` (number) | (none) | +| Window clear | `clear` (boolean) | (none) | +| Pane focus | (none) | `focus` (boolean) | +| Window focus | (none) | `focus` (boolean) | +| Window options | (none) | `options` (hash) | +| Pane string shorthand | (none) | `- command_string` | + +## Import Behavior Analysis + +### Current Importer: `importers.py:import_teamocil` + +**What it handles (v0.x format):** + +| teamocil key | Mapped to | Status | +|---|---|---| +| `session` (wrapper) | Unwrapped | ✓ Correct | +| `session.name` | `session_name` | ✓ Correct | +| `session.root` | `start_directory` | ✓ Correct | +| Window `name` | `window_name` | ✓ Correct | +| Window `root` | `start_directory` | ✓ Correct | +| Window `layout` | `layout` | ✓ Correct | +| Window `clear` | `clear` | ⚠ Preserved but tmuxp doesn't use `clear` | +| Window `filters.before` | `shell_command_before` | ✓ Correct | +| Window `filters.after` | `shell_command_after` | ⚠ tmuxp doesn't support `shell_command_after` | +| `splits` → `panes` | `panes` | ✓ Correct | +| Pane `cmd` | `shell_command` | ✓ Correct | +| Pane `width` | Dropped | ⚠ Silently dropped with TODO comment | + +**What it misses:** + +| Feature | Issue | +|---|---| +| v1.x `commands` key | Not handled — only `cmd` (v0.x) is mapped | +| v1.x pane string shorthand | Not handled — expects dict with `cmd` key | +| v1.x `focus` (window) | Not imported | +| v1.x `focus` (pane) | Not imported | +| v1.x `options` (window) | Not imported | +| Session-level `name` (without `session:` wrapper) | Handled (uses `.get("name")`) | +| `with_env_var` (v0.x) | Not handled (noted in docstring TODO) | +| `cmd_separator` (v0.x) | Not handled (noted in docstring TODO) | + +### Code Quality Issues in Importer + +1. **Lines 145-149**: The `filters.before` and `filters.after` handling has redundant `for _b in` loops that serve no purpose. The inner assignment just reassigns the same value each iteration: + ```python + for _b in w["filters"]["before"]: + window_dict["shell_command_before"] = w["filters"]["before"] + ``` + This iterates N times but sets the same value each time. It should be a direct assignment. + +2. **Line 142**: `clear` is preserved in the config but tmuxp has no handling for it. It will be silently ignored during workspace building. + +3. **Line 149**: `shell_command_after` is not a tmuxp-supported key. It will be silently ignored. + +4. **Line 162-163**: `width` is silently dropped with a TODO comment. No warning to the user. + +5. **v1.x incompatibility**: The importer assumes v0.x format. A v1.x config with `commands` instead of `cmd`, or string panes, will not import correctly: + - String pane `"git status"` → error (tries to access `p["cmd"]` on a string) + - `commands: [...]` → not mapped to `shell_command` + +6. **No format detection**: The importer doesn't attempt to detect whether the input is v0.x or v1.x format. + +## WorkspaceBuilder Requirements for Full Parity + +### Already Supported (No Changes Needed) + +- Window `focus` — ✓ +- Pane `focus` — ✓ +- Window `options` — ✓ +- Window `layout` — ✓ +- Window `root`/`start_directory` — ✓ +- Pane commands — ✓ + +### Gaps Requiring New Features + +1. **`--here` flag** — Reuse current window for first window of layout. Requires `WorkspaceBuilder` to rename instead of create, and send `cd` for root directory. + +2. **`--debug` / dry-run mode** — Log commands without executing. Architectural challenge since tmuxp uses libtmux API, not command strings. + +3. **`shell_command_after`** — Commands run after pane commands. The importer preserves this from teamocil's `filters.after` but tmuxp has no support for it in the builder. + +### Import-Only Fixes (No Builder Changes) + +4. **v1.x format support** — The importer should handle: + - `commands` key (v1.x) in addition to `cmd` (v0.x) + - String pane shorthand + - `focus` on windows and panes + - `options` on windows + +5. **Redundant loop cleanup** — Fix the `filters` handling code. + +6. **Drop unsupported keys with warnings** — Instead of silently preserving `clear` or dropping `width`, warn the user. From d73686f4981fa3e3fe4da53de8091120916de1d6 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:08:21 -0600 Subject: [PATCH 005/152] notes(import) Add tmuxinator import behavior analysis Classifies each config key as difference (translatable) or limitation (needs tmuxp feature). Identifies pre/pre_window bug, missing rvm/pre_tab mappings, and 5 features requiring new tmuxp capabilities. --- notes/import-tmuxinator.md | 221 +++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 notes/import-tmuxinator.md diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md new file mode 100644 index 0000000000..df1ce4e137 --- /dev/null +++ b/notes/import-tmuxinator.md @@ -0,0 +1,221 @@ +# Tmuxinator Import Behavior + +*Last updated: 2026-02-08* +*Importer: `src/tmuxp/workspace/importers.py:import_tmuxinator`* + +## Syntax Differences (Translatable) + +These are config keys/patterns that differ syntactically but can be automatically converted during import. + +### 1. Session Name + +| tmuxinator | tmuxp | +|---|---| +| `name: myproject` | `session_name: myproject` | +| `project_name: myproject` | `session_name: myproject` | + +**Importer status**: ✓ Handled (lines 24-29) + +### 2. Root Directory + +| tmuxinator | tmuxp | +|---|---| +| `root: ~/project` | `start_directory: ~/project` | +| `project_root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 31-34) + +### 3. Windows List Key + +| tmuxinator | tmuxp | +|---|---| +| `tabs:` | `windows:` | +| `windows:` | `windows:` | + +**Importer status**: ✓ Handled (lines 56-57) + +### 4. Window Name Syntax + +| tmuxinator | tmuxp | +|---|---| +| `- editor:` (hash key) | `- window_name: editor` | + +**Importer status**: ✓ Handled (lines 79-81) + +### 5. Window Root + +| tmuxinator | tmuxp | +|---|---| +| `root: ./src` (under window hash) | `start_directory: ./src` | + +**Importer status**: ✓ Handled (lines 96-97) + +### 6. Window Pre-Commands + +| tmuxinator | tmuxp | +|---|---| +| `pre: "source .env"` (under window hash) | `shell_command_before: ["source .env"]` | + +**Importer status**: ✓ Handled (lines 92-93) + +### 7. Socket Name + +| tmuxinator | tmuxp | +|---|---| +| `socket_name: myapp` | `socket_name: myapp` | + +**Importer status**: ✓ Handled (lines 51-52). Note: tmuxp doesn't use `socket_name` as a config key in `WorkspaceBuilder` — it's a CLI flag. The importer preserves it but it may not be used. + +### 8. CLI Args / Tmux Options → Config File + +| tmuxinator | tmuxp | +|---|---| +| `cli_args: "-f ~/.tmux.special.conf"` | `config: ~/.tmux.special.conf` | +| `tmux_options: "-f ~/.tmux.special.conf"` | `config: ~/.tmux.special.conf` | + +**Importer status**: ⚠ Partially handled (lines 36-49). Only extracts `-f` flag value. Other flags like `-L` (socket name) and `-S` (socket path) in `cli_args`/`tmux_options` are silently included in the `config` value, which is incorrect — `config` should only be a file path. + +### 9. Rbenv + +| tmuxinator | tmuxp | +|---|---| +| `rbenv: 2.7.0` | `shell_command_before: ["rbenv shell 2.7.0"]` | + +**Importer status**: ✓ Handled (lines 72-77) + +### 10. Pre / Pre-Window Commands + +| tmuxinator | tmuxp | +|---|---| +| `pre: "cmd"` (session-level, alone) | `shell_command_before: ["cmd"]` | +| `pre_window: "cmd"` + `pre: "cmd"` | `shell_command: "cmd"` + `shell_command_before: ["cmd"]` | + +**Importer status**: ⚠ Bug (lines 59-70). When both `pre` and `pre_window` exist, the importer sets `shell_command` (not a valid tmuxp session-level key) for `pre` and `shell_command_before` for `pre_window`. The `pre` commands are lost. + +**Correct mapping**: Both should map to `shell_command_before`, with `pre` commands first, then `pre_window` commands. + +### 11. Window as String/List + +| tmuxinator | tmuxp | +|---|---| +| `- editor: vim` | `- window_name: editor` + `panes: [vim]` | +| `- editor: [vim, "git status"]` | `- window_name: editor` + `panes: [vim, "git status"]` | + +**Importer status**: ✓ Handled (lines 83-90) + +### 12. `startup_window` → `focus` + +| tmuxinator | tmuxp | +|---|---| +| `startup_window: editor` | Set `focus: true` on the matching window | + +**Importer status**: ✗ Not handled. Could be translated by finding the matching window and adding `focus: true`. + +### 13. `startup_pane` → `focus` + +| tmuxinator | tmuxp | +|---|---| +| `startup_pane: 1` | Set `focus: true` on the matching pane | + +**Importer status**: ✗ Not handled. Could be translated by finding the pane at the given index and adding `focus: true`. + +### 14. `pre_tab` → `shell_command_before` + +| tmuxinator | tmuxp | +|---|---| +| `pre_tab: "source .env"` | `shell_command_before: ["source .env"]` | + +**Importer status**: ✗ Not handled. `pre_tab` is a deprecated alias for `pre_window`. + +### 15. `rvm` → `shell_command_before` + +| tmuxinator | tmuxp | +|---|---| +| `rvm: ruby-2.7@mygemset` | `shell_command_before: ["rvm use ruby-2.7@mygemset"]` | + +**Importer status**: ✗ Not handled. Only `rbenv` is mapped; `rvm` is ignored. + +### 16. `attach: false` → CLI Flag + +| tmuxinator | tmuxp | +|---|---| +| `attach: false` | `tmuxp load -d` (detached mode) | + +**Importer status**: ✗ Not handled. Could add a comment or warning suggesting `-d` flag. + +## Limitations (tmuxp Needs to Add Support) + +These are features that cannot be imported because tmuxp lacks the underlying capability. + +### 1. Lifecycle Hooks + +**What it does in tmuxinator**: Five project hooks (`on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`) allow running arbitrary commands at different lifecycle stages. + +**Why it can't be imported**: tmuxp only has `before_script` (partial equivalent to `on_project_first_start`). The exit/stop/restart hooks require tmux `set-hook` integration or signal trapping that tmuxp doesn't support. + +**What tmuxp would need to add**: Session-level `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop` config keys, plus builder logic to execute them at appropriate points. For exit/stop hooks, tmuxp would need a `stop` command and tmux `set-hook` for `client-detached`. + +### 2. Pane Synchronization + +**What it does in tmuxinator**: `synchronize: true/before/after` on windows enables `synchronize-panes` option, with control over whether sync happens before or after pane commands. + +**Why it can't be imported**: tmuxp has no `synchronize` config key. While users can set `synchronize-panes` via `options`, the before/after timing distinction requires builder support. + +**What tmuxp would need to add**: `synchronize` key on windows with `before`/`after`/`true`/`false` values. Builder should call `set-window-option synchronize-panes on` at the appropriate point. + +### 3. Pane Titles + +**What it does in tmuxinator**: Named pane syntax (`pane_name: command`) sets pane titles via `select-pane -T`. Session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` control display. + +**Why it can't be imported**: tmuxp has no pane title support. + +**What tmuxp would need to add**: Per-pane `title` key, session-level title configuration. Builder calls `select-pane -T <title>` after pane creation. + +### 4. ERB Templating + +**What it does in tmuxinator**: Config files are processed through ERB before YAML parsing. Supports `<%= @settings["key"] %>` interpolation and full Ruby expressions. Variables passed via `key=value` CLI args. + +**Why it can't be imported**: ERB is a Ruby templating system. The importer receives already-parsed YAML (ERB would have already been processed in Ruby). When importing a raw tmuxinator config file with ERB syntax, YAML parsing will fail. + +**What tmuxp would need to add**: Either a Jinja2 templating pass, Python string formatting, or environment variable expansion in config values. This is a significant architectural feature. + +### 5. Wemux Support + +**What it does in tmuxinator**: `tmux_command: wemux` uses an alternate template and wemux-specific commands. + +**Why it can't be imported**: tmuxp and libtmux are tightly bound to the `tmux` binary. + +**What tmuxp would need to add**: Configurable tmux binary path in libtmux's `Server` class. + +### 6. `--no-pre-window` Flag + +**What it does in tmuxinator**: Skips all `pre_window` commands when starting a session. Useful for debugging. + +**Why it can't be imported**: This is a runtime behavior, not a config key. + +**What tmuxp would need to add**: `--no-shell-command-before` CLI flag on `tmuxp load`. + +## Summary Table + +| tmuxinator Feature | Import Status | Classification | +|---|---|---| +| `name`/`project_name` → `session_name` | ✓ Handled | Difference | +| `root`/`project_root` → `start_directory` | ✓ Handled | Difference | +| `tabs` → `windows` | ✓ Handled | Difference | +| `socket_name` | ✓ Handled | Difference | +| `cli_args`/`tmux_options` → `config` | ⚠ Partial | Difference (needs fix) | +| `rbenv` → `shell_command_before` | ✓ Handled | Difference | +| `pre` → `shell_command_before` | ⚠ Bug when combined with `pre_window` | Difference (needs fix) | +| Window hash syntax | ✓ Handled | Difference | +| Window `root`/`pre`/`layout`/`panes` | ✓ Handled | Difference | +| `rvm` → `shell_command_before` | ✗ Missing | Difference (needs add) | +| `pre_tab` → `shell_command_before` | ✗ Missing | Difference (needs add) | +| `startup_window` → `focus` | ✗ Missing | Difference (needs add) | +| `startup_pane` → `focus` | ✗ Missing | Difference (needs add) | +| `attach: false` | ✗ Missing | Difference (needs add) | +| `on_project_*` hooks | ✗ Missing | **Limitation** | +| `synchronize` | ✗ Missing | **Limitation** | +| `enable_pane_titles` / titles | ✗ Missing | **Limitation** | +| ERB templating | ✗ Missing | **Limitation** | +| `tmux_command` (wemux) | ✗ Missing | **Limitation** | +| `--no-pre-window` | N/A (runtime flag) | **Limitation** | From 9e6e05887fb47f461cc48f9d1650a296c065fc01 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:08:26 -0600 Subject: [PATCH 006/152] notes(import) Add teamocil import behavior analysis Documents v0.x-only targeting (v1.x unsupported), string pane TypeError bug, redundant filter loop bug, and 6 missing v1.x key mappings (commands, focus, options, string shorthand). --- notes/import-teamocil.md | 252 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 notes/import-teamocil.md diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md new file mode 100644 index 0000000000..c078727f1e --- /dev/null +++ b/notes/import-teamocil.md @@ -0,0 +1,252 @@ +# Teamocil Import Behavior + +*Last updated: 2026-02-08* +*Importer: `src/tmuxp/workspace/importers.py:import_teamocil`* + +## Format Detection Problem + +Teamocil has two distinct config formats: + +- **v0.x** (pre-1.0): `session:` wrapper, `splits`, `filters`, `cmd` +- **v1.x** (1.0–1.4.2): Flat `windows`, `panes`, `commands`, `focus`, `options` + +The current importer **targets v0.x only**. It handles v0.x-specific constructs (`session:` wrapper, `splits`, `filters.before`, `filters.after`, `cmd`) but does not handle v1.x-specific constructs (`commands`, string pane shorthand, `focus`, window `options`). + +Since teamocil 1.4.2 uses the v1.x format, the importer is outdated for current teamocil configs. + +## Syntax Differences (Translatable) + +### 1. Session Wrapper (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `session:` + `name:` + `windows:` | `session_name:` + `windows:` | + +**Importer status**: ✓ Handled (lines 127-128). Unwraps the `session:` key. + +### 2. Session Name + +| teamocil | tmuxp | +|---|---| +| `name: my-layout` | `session_name: my-layout` | + +**Importer status**: ✓ Handled (line 130) + +### 3. Session Root (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `session.root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 132-133). Note: v1.x teamocil has no session-level root. + +### 4. Window Name + +| teamocil | tmuxp | +|---|---| +| `name: editor` | `window_name: editor` | + +**Importer status**: ✓ Handled (line 138) + +### 5. Window Root + +| teamocil | tmuxp | +|---|---| +| `root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 151-152) + +### 6. Window Layout + +| teamocil | tmuxp | +|---|---| +| `layout: main-vertical` | `layout: main-vertical` | + +**Importer status**: ✓ Handled (lines 166-167). Same key name, direct pass-through. + +### 7. Splits → Panes (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `splits:` | `panes:` | + +**Importer status**: ✓ Handled (lines 154-155). Renames key. + +### 8. Pane `cmd` → `shell_command` (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `cmd: vim` | `shell_command: vim` | +| `cmd: [cd /path, vim]` | `shell_command: [cd /path, vim]` | + +**Importer status**: ✓ Handled (lines 159-160). Renames key. + +### 9. Filters Before → Shell Command Before (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `filters: { before: [cmd1, cmd2] }` | `shell_command_before: [cmd1, cmd2]` | + +**Importer status**: ⚠ Handled but with redundant loop (lines 144-146). The `for _b in` loop iterates uselessly — the assignment inside is the same each iteration. Should be a direct assignment. + +### 10. Pane `commands` → `shell_command` (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `commands: [git pull, vim]` | `shell_command: [git pull, vim]` | + +**Importer status**: ✗ Not handled. The v1.x `commands` key is not mapped. Only `cmd` (v0.x) is recognized. + +### 11. String Pane Shorthand (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `- git status` (string in panes list) | `- shell_command: [git status]` | + +**Importer status**: ✗ Not handled. The importer expects each pane to be a dict (tries `p["cmd"]`). String panes will cause a `TypeError`. + +### 12. Window Focus (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `focus: true` (on window) | `focus: true` | + +**Importer status**: ✗ Not handled. The key is not imported. + +### 13. Pane Focus (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `focus: true` (on pane) | `focus: true` | + +**Importer status**: ✗ Not handled. The key is not imported. + +### 14. Window Options (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `options: { main-pane-width: '100' }` | `options: { main-pane-width: '100' }` | + +**Importer status**: ✗ Not handled. Same key name in tmuxp, but not imported from teamocil configs. + +## Limitations (tmuxp Needs to Add Support) + +### 1. `--here` Flag (Reuse Current Window) + +**What it does in teamocil**: First window is renamed and reused instead of creating a new one. Root directory applied via `cd` command. + +**Why it can't be imported**: This is a runtime CLI flag, not a config key. + +**What tmuxp would need to add**: `--here` flag on `tmuxp load` that tells WorkspaceBuilder to rename the current window for the first window instead of creating new. + +### 2. Filters After / `shell_command_after` (v0.x) + +**What it does in teamocil**: `filters.after` commands run after pane commands. + +**Why it can't be imported**: The importer maps this to `shell_command_after`, but tmuxp has no support for this key in the WorkspaceBuilder. The key is silently ignored. + +**What tmuxp would need to add**: `shell_command_after` key on windows/panes. Builder would send these commands after all pane `shell_command` entries. + +### 3. Pane Width (v0.x) + +**What it does in teamocil v0.x**: `width` on splits to set pane width. + +**Why it can't be imported**: tmuxp drops this with a TODO comment. tmuxp relies on tmux layouts for pane geometry. + +**What tmuxp would need to add**: Per-pane `width`/`height` keys. Builder would use `resize-pane -x <width>` or `resize-pane -y <height>` after split. Alternatively, support custom layout strings. + +### 4. Window Clear (v0.x) + +**What it does in teamocil v0.x**: `clear: true` on windows. + +**Why it can't be imported**: The importer preserves the `clear` key but tmuxp doesn't act on it. + +**What tmuxp would need to add**: `clear` key on windows. Builder would send `clear` (or `send-keys C-l`) after pane creation. + +### 5. `with_env_var` (v0.x) + +**What it does in teamocil v0.x**: Sets environment variables for panes. + +**Why it can't be imported**: Noted in importer docstring TODO. Not implemented. + +**What tmuxp would need to add**: tmuxp already has `environment` on sessions, windows, and panes. The import just needs to map `with_env_var` → `environment`. + +### 6. `cmd_separator` (v0.x) + +**What it does in teamocil v0.x**: Custom separator for joining multiple commands (default: `; `). + +**Why it can't be imported**: Noted in importer docstring TODO. Not implemented. + +**What tmuxp would need to add**: tmuxp sends commands individually (one `send_keys` per command), so this is a non-issue. The behavioral difference is actually better in tmuxp. + +## Code Issues in Current Importer + +### Bug: Redundant Filter Loop + +```python +# Lines 144-149 (current) +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"] +``` + +The `for _b in` loops are pointless — they iterate over the list but set the same value each time. Should be: + +```python +if "filters" in w: + if "before" in w["filters"]: + window_dict["shell_command_before"] = w["filters"]["before"] + if "after" in w["filters"]: + window_dict["shell_command_after"] = w["filters"]["after"] +``` + +### Bug: v1.x String Panes Cause TypeError + +```python +# Lines 157-163 (current) +if "panes" in w: + for p in w["panes"]: + if "cmd" in p: # TypeError if p is a string + p["shell_command"] = p.pop("cmd") +``` + +If `p` is a string (v1.x shorthand), `"cmd" in p` will check for substring match in the string, not a dict key. This will either silently pass (if the command doesn't contain "cmd") or incorrectly match. + +### Missing: `with_env_var` and `cmd_separator` + +Noted in the docstring TODOs but never implemented. `with_env_var` could map to tmuxp's `environment` key. `cmd_separator` is irrelevant since tmuxp sends commands individually. + +### Silent Drops + +- `clear` is preserved but unused by tmuxp +- `width` is dropped with no user warning +- `shell_command_after` is set but unused by tmuxp + +## Summary Table + +| teamocil Feature | Import Status | Classification | +|---|---|---| +| `session:` wrapper (v0.x) | ✓ Handled | Difference | +| `name` → `session_name` | ✓ Handled | Difference | +| `root` → `start_directory` | ✓ Handled | Difference | +| Window `name` → `window_name` | ✓ Handled | Difference | +| Window `root` → `start_directory` | ✓ Handled | Difference | +| Window `layout` | ✓ Handled | Difference | +| `splits` → `panes` (v0.x) | ✓ Handled | Difference | +| Pane `cmd` → `shell_command` (v0.x) | ✓ Handled | Difference | +| `filters.before` → `shell_command_before` (v0.x) | ⚠ Bug (redundant loop) | Difference (needs fix) | +| Pane `commands` → `shell_command` (v1.x) | ✗ Missing | Difference (needs add) | +| String pane shorthand (v1.x) | ✗ Missing (causes error) | Difference (needs add) | +| Window `focus` (v1.x) | ✗ Missing | Difference (needs add) | +| Pane `focus` (v1.x) | ✗ Missing | Difference (needs add) | +| Window `options` (v1.x) | ✗ Missing | Difference (needs add) | +| `with_env_var` → `environment` (v0.x) | ✗ Missing | Difference (needs add) | +| `filters.after` → `shell_command_after` | ⚠ Imported but unused | **Limitation** | +| Pane `width` (v0.x) | ⚠ Dropped silently | **Limitation** | +| Window `clear` (v0.x) | ⚠ Preserved but unused | **Limitation** | +| `cmd_separator` (v0.x) | ✗ Missing | N/A (tmuxp is better) | +| `--here` flag | N/A (runtime flag) | **Limitation** | From bfbd8c973f39c5da67e7c90a705d6c681b6b6649 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:15:20 -0600 Subject: [PATCH 007/152] 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 +++--- notes/import-tmuxinator.md | 2 +- notes/parity-tmuxinator.md | 6 +++--- 3 files changed, 7 insertions(+), 7 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) | diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md index df1ce4e137..439037fd7f 100644 --- a/notes/import-tmuxinator.md +++ b/notes/import-tmuxinator.md @@ -125,7 +125,7 @@ These are config keys/patterns that differ syntactically but can be automaticall |---|---| | `pre_tab: "source .env"` | `shell_command_before: ["source .env"]` | -**Importer status**: ✗ Not handled. `pre_tab` is a deprecated alias for `pre_window`. +**Importer status**: ✗ Not handled. `pre_tab` is a deprecated predecessor to `pre_window` (not an alias — it was renamed). ### 15. `rvm` → `shell_command_before` diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md index 83aba4e00f..abee871e10 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -56,7 +56,7 @@ startup_window: editor # Select this window after build startup_pane: 1 # Select this pane within the startup window ``` -**Gap**: tmuxp supports `focus: true` on windows and panes (boolean), which is equivalent but syntactically different. The `startup_window` key allows referencing by name or index. **Partial parity** — tmuxp can achieve this but uses a different mechanism (`focus` key on individual windows/panes). +**Gap**: tmuxp supports `focus: true` on windows and panes (boolean), which is equivalent but syntactically different. The `startup_window` key allows referencing by name or index (rendered as `"#{name}:#{value}"`). **Partial parity** — tmuxp can achieve this but uses a different mechanism (`focus` key on individual windows/panes rather than a centralized key). ### 5. Pane Synchronization @@ -217,7 +217,7 @@ Creates a config file pre-populated from a running tmux session. | `startup_pane` | Not imported. tmuxp uses `focus: true` on panes. | | `tmux_command` | Not imported. tmuxp has no equivalent. | | `socket_path` | Not imported. tmuxp takes this via CLI. | -| `pre_tab` | Not imported (alias for `pre_window`). | +| `pre_tab` | Not imported (deprecated predecessor to `pre_window`). | | `rvm` | Not imported (only `rbenv` is handled). | | `post` | Not imported. tmuxp has no equivalent. | | `synchronize` | Not imported. tmuxp has no equivalent. | @@ -241,7 +241,7 @@ Creates a config file pre-populated from a running tmux session. 3. **Line 79-101**: The window iteration uses `for k, v in window_dict.items()` which assumes windows are always dicts with a single key (the window name). This is correct for tmuxinator's format but fragile — if a window dict has multiple keys, only the last one is processed. -4. **Missing `pre_tab`**: The `pre_tab` alias for `pre_window` is not handled. +4. **Missing `pre_tab`**: The `pre_tab` deprecated predecessor to `pre_window` is not handled. 5. **Missing `rvm`**: Only `rbenv` is imported; `rvm` (another deprecated but still functional key) is ignored. From 03b01f738e21452b85f9b1e4a155fe7133c282f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:21:04 -0600 Subject: [PATCH 008/152] 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 <config>` | `tmuxinator start <project>` | `teamocil <layout>` | -| Load detached | `tmuxp load -d <config>` | `tmuxinator start -d` / `attach: false` | (none) | +| Load detached | `tmuxp load -d <config>` | `attach: false` / `tmuxinator start --no-attach` | (none) | | Load with name override | `tmuxp load -s <name> <config>` | `tmuxinator start -n <name>` | (none) | | Append to session | `tmuxp load -a` | `tmuxinator start --append` | (none) | | List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | From 95382894d4c25242a9a9f3b567337df434626bfb Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:21:09 -0600 Subject: [PATCH 009/152] fix(import-tmuxinator): Add missing socket_path entry - Add socket_path as item 16 (tmuxinator config key not handled) - socket_path takes precedence over socket_name in tmuxinator - tmuxp only accepts socket path via CLI -S flag - Add to summary table as missing Difference --- notes/import-tmuxinator.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md index 439037fd7f..943f42448c 100644 --- a/notes/import-tmuxinator.md +++ b/notes/import-tmuxinator.md @@ -135,7 +135,15 @@ These are config keys/patterns that differ syntactically but can be automaticall **Importer status**: ✗ Not handled. Only `rbenv` is mapped; `rvm` is ignored. -### 16. `attach: false` → CLI Flag +### 16. `socket_path` + +| tmuxinator | tmuxp | +|---|---| +| `socket_path: /tmp/my.sock` | (CLI `-S /tmp/my.sock`) | + +**Importer status**: ✗ Not handled. `socket_path` is a tmuxinator config key (takes precedence over `socket_name`) but the importer ignores it. tmuxp takes socket path via CLI `-S` flag only. + +### 17. `attach: false` → CLI Flag | tmuxinator | tmuxp | |---|---| @@ -212,6 +220,7 @@ These are features that cannot be imported because tmuxp lacks the underlying ca | `pre_tab` → `shell_command_before` | ✗ Missing | Difference (needs add) | | `startup_window` → `focus` | ✗ Missing | Difference (needs add) | | `startup_pane` → `focus` | ✗ Missing | Difference (needs add) | +| `socket_path` | ✗ Missing | Difference (needs add) | | `attach: false` | ✗ Missing | Difference (needs add) | | `on_project_*` hooks | ✗ Missing | **Limitation** | | `synchronize` | ✗ Missing | **Limitation** | From 788c8a52a7987fc5970c3d7165e6d86a5dd193f3 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:21:16 -0600 Subject: [PATCH 010/152] fix(import-teamocil): Reclassify with_env_var and cmd_separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - with_env_var is an import-only fix (tmuxp already has environment key), not a Limitation — moved to new "Import-Only Fixes" section - cmd_separator is irrelevant (tmuxp sends commands individually), clarified it needs no import --- notes/import-teamocil.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md index c078727f1e..7687222ca0 100644 --- a/notes/import-teamocil.md +++ b/notes/import-teamocil.md @@ -163,21 +163,21 @@ Since teamocil 1.4.2 uses the v1.x format, the importer is outdated for current **What tmuxp would need to add**: `clear` key on windows. Builder would send `clear` (or `send-keys C-l`) after pane creation. -### 5. `with_env_var` (v0.x) +## Import-Only Fixes (No Builder Changes) + +### 5. `with_env_var` → `environment` (v0.x) **What it does in teamocil v0.x**: Sets environment variables for panes. -**Why it can't be imported**: Noted in importer docstring TODO. Not implemented. +**Why it's not imported**: Noted in importer docstring TODO. Not implemented. -**What tmuxp would need to add**: tmuxp already has `environment` on sessions, windows, and panes. The import just needs to map `with_env_var` → `environment`. +**Fix**: tmuxp already has `environment` on sessions, windows, and panes. The importer just needs to map `with_env_var` → `environment`. No builder changes required. ### 6. `cmd_separator` (v0.x) **What it does in teamocil v0.x**: Custom separator for joining multiple commands (default: `; `). -**Why it can't be imported**: Noted in importer docstring TODO. Not implemented. - -**What tmuxp would need to add**: tmuxp sends commands individually (one `send_keys` per command), so this is a non-issue. The behavioral difference is actually better in tmuxp. +**Note**: tmuxp sends commands individually (one `send_keys` per command), so this is irrelevant. The behavioral difference is actually better in tmuxp — no import needed. ## Code Issues in Current Importer From f50679ef31e0e47ea66dc8151119ba4af50fb871 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:26:08 -0600 Subject: [PATCH 011/152] 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 <name> <config>` | `tmuxinator start -n <name>` | (none) | | Append to session | `tmuxp load -a` | `tmuxinator start --append` | (none) | | List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | -| Edit config | `tmuxp edit <config>` | `tmuxinator edit <project>` / `new` | `teamocil --edit <layout>` | +| Edit config | `tmuxp edit <config>` | `tmuxinator edit <project>` (alias of `new`) | `teamocil --edit <layout>` | | Show/debug config | (none) | `tmuxinator debug <project>` | `teamocil --show` / `--debug` | | Create new config | (none) | `tmuxinator new <project>` | (none) | | Copy config | (none) | `tmuxinator copy <src> <dst>` | (none) | From d785144eeca07b56e3f19c918fd14bef8e02c0ea Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:36:22 -0600 Subject: [PATCH 012/152] 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 32e1f59a137ab647148f96a27725c1c1b01581d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:41:59 -0600 Subject: [PATCH 013/152] 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 ++-- notes/parity-teamocil.md | 9 +++++---- notes/parity-tmuxinator.md | 6 +++--- 3 files changed, 10 insertions(+), 9 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) | diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index 1e4190c02f..d508c62d13 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -24,13 +24,14 @@ teamocil --here my-layout ``` When `--here` is specified: -- First window: **renames** current window instead of creating a new one -- First window: sends `cd <root>` if root is specified -- Subsequent windows: created normally +- First window: **renames** current window (`rename-window`) instead of creating a new one +- First window: sends `cd "<root>"` + `Enter` via `send-keys` to change directory (since no `-c` flag is available on an existing window) +- First window: decrements the window count when calculating base indices for subsequent windows +- Subsequent windows: created normally with `new-window` **Gap**: tmuxp always creates new windows. There is no way to populate the current window with a layout. -**WorkspaceBuilder requirement**: Add `--here` CLI flag. For first window, use rename + cd instead of `new_window()`. This would require special handling in `WorkspaceBuilder.first_window_pass()`. +**WorkspaceBuilder requirement**: Add `--here` CLI flag. For first window, use `rename-window` + `send-keys cd` instead of `new_window()`. Must also adjust window index calculation. This would require special handling in `WorkspaceBuilder.first_window_pass()`. ### 2. `--show` Option (Show Raw Config) diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md index abee871e10..fd5f3daca1 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -14,13 +14,13 @@ tmuxinator has 5 lifecycle hooks: | Hook | Description | tmuxp equivalent | |---|---|---| -| `on_project_start` | Runs on every `start` invocation | `before_script` (partial — runs commands, but kills session on failure) | -| `on_project_first_start` | Runs only when session doesn't exist yet | No equivalent | +| `on_project_start` | Runs on every `start` invocation | No equivalent | +| `on_project_first_start` | Runs only when session doesn't exist yet | `before_script` (partial — runs before windows, but kills session on failure) | | `on_project_restart` | Runs when attaching to existing session | Plugin `reattach()` (requires writing a plugin) | | `on_project_exit` | Runs when detaching from session | No equivalent | | `on_project_stop` | Runs on `tmuxinator stop` | No equivalent (tmuxp has no `stop` command) | -**Gap**: tmuxp's `before_script` only covers the "first start" case and kills the session on failure. It has no hooks for detach/exit/stop events, and no distinction between first start vs. restart. +**Gap**: tmuxp's `before_script` is a partial equivalent of `on_project_first_start` — it runs before windows are created and kills the session on failure. tmuxp has no equivalent for `on_project_start` (runs every time, including reattach), no hooks for detach/exit/stop events, and no distinction between first start vs. restart. **WorkspaceBuilder requirement**: Add config keys for `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`. The exit/stop hooks require shell integration (trap signals, set-hook in tmux). From 188ea76ef8b57ad8e779130d40f170b9c33f4d70 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 06:52:56 -0600 Subject: [PATCH 014/152] fix(parity-docs): Correct line number references in teamocil notes - import-teamocil.md: Code block comment said "Lines 144-149" but the `if "filters"` guard is on line 143, so range is 143-149 - parity-teamocil.md: Referenced "Line 142" for `clear` handling but actual code is lines 140-141 (line 142 is blank) --- notes/import-teamocil.md | 2 +- notes/parity-teamocil.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md index 7687222ca0..5d3a42de9a 100644 --- a/notes/import-teamocil.md +++ b/notes/import-teamocil.md @@ -184,7 +184,7 @@ Since teamocil 1.4.2 uses the v1.x format, the importer is outdated for current ### Bug: Redundant Filter Loop ```python -# Lines 144-149 (current) +# Lines 143-149 (current) if "filters" in w: if "before" in w["filters"]: for _b in w["filters"]["before"]: diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index d508c62d13..9ff48bd875 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -173,7 +173,7 @@ Teamocil joins multiple pane commands with `; ` and sends them as a single `send ``` This iterates N times but sets the same value each time. It should be a direct assignment. -2. **Line 142**: `clear` is preserved in the config but tmuxp has no handling for it. It will be silently ignored during workspace building. +2. **Lines 140-141**: `clear` is preserved in the config but tmuxp has no handling for it. It will be silently ignored during workspace building. 3. **Line 149**: `shell_command_after` is not a tmuxp-supported key. It will be silently ignored. From b66dc7826fd7bd7627f525e1ac6935669a447bcb Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:03:53 -0600 Subject: [PATCH 015/152] 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 <config>` | `tmuxinator start <project>` | `teamocil <layout>` | | Load detached | `tmuxp load -d <config>` | `attach: false` / `tmuxinator start --no-attach` | (none) | | Load with name override | `tmuxp load -s <name> <config>` | `tmuxinator start -n <name>` | (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 <config>` | `tmuxinator edit <project>` (alias of `new`) | `teamocil --edit <layout>` | +| Edit config | `tmuxp edit <config>` | `tmuxinator edit <project>` | `teamocil --edit <layout>` | | Show/debug config | (none) | `tmuxinator debug <project>` | `teamocil --show` / `--debug` | | Create new config | (none) | `tmuxinator new <project>` | (none) | | Copy config | (none) | `tmuxinator copy <src> <dst>` | (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 5b5355e6a2fa8f44267eb838a1cf50904fd474ef Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:04:00 -0600 Subject: [PATCH 016/152] fix(parity-tmuxinator): Fix startup_window/pane semantics, pre_window chain, remove --here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix startup_window: accepts name OR index (not just name) - Document pre_window fallback chain: rbenv → rvm → pre_tab → pre_window - Remove section 12 (--here) — this is a teamocil feature, not tmuxinator - Renumber section 13 → 12 - Clarify freeze vs tmuxinator new comparison - Add rvm source reference (project.rb:181) - Add tmuxinator version range to header --- notes/parity-tmuxinator.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md index fd5f3daca1..10bf491698 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -1,7 +1,7 @@ # Tmuxinator Parity Analysis *Last updated: 2026-02-08* -*Tmuxinator version analyzed: 3.3.7* +*Tmuxinator version analyzed: 3.3.7 (supports tmux 1.5–3.6a)* *tmuxp version: 1.47.0+* ## Features tmuxinator has that tmuxp lacks @@ -56,7 +56,7 @@ startup_window: editor # Select this window after build startup_pane: 1 # Select this pane within the startup window ``` -**Gap**: tmuxp supports `focus: true` on windows and panes (boolean), which is equivalent but syntactically different. The `startup_window` key allows referencing by name or index (rendered as `"#{name}:#{value}"`). **Partial parity** — tmuxp can achieve this but uses a different mechanism (`focus` key on individual windows/panes rather than a centralized key). +**Gap**: tmuxp supports `focus: true` on windows and panes (boolean), which is equivalent but syntactically different. The `startup_window` key allows referencing by window name or numeric index (rendered as `"#{name}:#{value}"`, defaults to `base_index` if omitted). The `startup_pane` is relative to the startup window (rendered as `"#{startup_window}.#{value}"`, defaults to `pane_base_index`). **Partial parity** — tmuxp can achieve this but uses a different mechanism (`focus` key on individual windows/panes rather than a centralized key). ### 5. Pane Synchronization @@ -166,15 +166,11 @@ tmuxinator start myproject --no-pre-window Skips `pre_window` commands. Useful for debugging. -**Gap**: tmuxp has no equivalent flag to skip `shell_command_before`. - -### 12. `--here` Equivalent +Note: tmuxinator's `pre_window` method has a fallback chain (`project.rb:175-188`): `rbenv` → `rvm` → `pre_tab` → `pre_window`. The `--no-pre-window` flag disables all of these, not just `pre_window`. -**Source**: teamocil provides `--here` to reuse the current window. tmuxinator has no `--here` per se but tmuxp also lacks this. - -**Gap**: Neither tmuxp nor tmuxinator has this; teamocil does. +**Gap**: tmuxp has no equivalent flag to skip `shell_command_before`. -### 13. Create Config from Running Session +### 12. Create Config from Running Session **Source**: `lib/tmuxinator/cli.rb` (`new <name> <session>`) @@ -182,9 +178,9 @@ Skips `pre_window` commands. Useful for debugging. tmuxinator new myproject existing-session-name ``` -Creates a config file pre-populated from a running tmux session. +Creates a config file pre-populated from a running tmux session. Note: tmuxinator captures only the window/pane structure and names, not running commands. -**Gap**: tmuxp has `tmuxp freeze` which exports to YAML/JSON. **Different approach, same result** — tmuxp's freeze is arguably more complete. +**Gap**: tmuxp has `tmuxp freeze` which exports to YAML/JSON with more detail (captures pane working directories and current commands). Different approach, functionally equivalent. ## Import Behavior Analysis @@ -243,7 +239,7 @@ Creates a config file pre-populated from a running tmux session. 4. **Missing `pre_tab`**: The `pre_tab` deprecated predecessor to `pre_window` is not handled. -5. **Missing `rvm`**: Only `rbenv` is imported; `rvm` (another deprecated but still functional key) is ignored. +5. **Missing `rvm`**: Only `rbenv` is imported; `rvm` (another deprecated but still functional key) is ignored. In tmuxinator, `rvm` maps to `rvm use #{value}` (`project.rb:181`). 6. **No validation or warnings**: The importer silently drops unsupported keys with no feedback to the user. From 2785db46e059fac21ac5468fa70556ce119cfeec Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:04:11 -0600 Subject: [PATCH 017/152] fix(parity-teamocil): Add session rename behavior, fix with_env_var/cmd_separator - Add section 1: teamocil renames session (rename-session), not creates - Note auto-generated session name (teamocil-session-RANDOM) - Add window focus implementation detail (session.rb:24-25) - Add --list and --edit note for teamocil CLI - Reclassify with_env_var and cmd_separator as unverified (not in source) - Add session rename mode to WorkspaceBuilder gaps - Fix line number references (144-149, 147-149, 161-163) - Renumber sections to account for new section 1 --- notes/parity-teamocil.md | 52 ++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index 9ff48bd875..4d17f1eb1a 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -13,9 +13,19 @@ Teamocil has had two distinct config formats: The current tmuxp importer (`importers.py:import_teamocil`) **targets the v0.x format**. It handles the `session:` wrapper, `splits`, `filters`, and `cmd` keys — all of which are v0.x-only constructs. It does **not** handle the v1.x format natively, though v1.x configs may partially work since the `windows`/`panes` structure is similar. +Note: teamocil v1.x does not create new sessions — it **renames** the current session (`rename-session`) and adds windows to it. This is fundamentally different from tmuxp/tmuxinator which create fresh sessions. + ## Features teamocil has that tmuxp lacks -### 1. `--here` Option (Reuse Current Window) +### 1. Session Rename (Not Create) + +**Source**: `lib/teamocil/tmux/session.rb:18-20` + +teamocil does not create a new session. It **renames** the current session via `rename-session` and adds windows to it. If no `name` is provided, it auto-generates one: `"teamocil-session-#{rand(1_000_000)}"`. + +**Gap**: tmuxp always creates a new session (unless appending with `--append`). There is no way to rename and populate the current session. + +### 2. `--here` Option (Reuse Current Window) **Source**: `lib/teamocil/tmux/window.rb`, `lib/teamocil/utils/option_parser.rb` @@ -33,7 +43,7 @@ When `--here` is specified: **WorkspaceBuilder requirement**: Add `--here` CLI flag. For first window, use `rename-window` + `send-keys cd` instead of `new_window()`. Must also adjust window index calculation. This would require special handling in `WorkspaceBuilder.first_window_pass()`. -### 2. `--show` Option (Show Raw Config) +### 3. `--show` Option (Show Raw Config) **Source**: `lib/teamocil/layout.rb` @@ -45,7 +55,7 @@ Outputs the raw YAML content of the layout file. **Gap**: tmuxp has no equivalent. Users can `cat` the file manually. -### 3. `--debug` Option (Show Commands Without Executing) +### 4. `--debug` Option (Show Commands Without Executing) **Source**: `lib/teamocil/layout.rb` @@ -57,7 +67,9 @@ Outputs the tmux commands that would be executed, one per line, without running **Gap**: tmuxp has no dry-run mode. Since tmuxp uses libtmux API calls rather than generating command strings, implementing this would require a logging/recording mode in the builder. -### 4. Window-Level `focus` Key +Note: teamocil also has `--list` (lists available layouts in `~/.teamocil/`) and `--edit` (opens layout file in `$EDITOR`). Both are available in tmuxp (`tmuxp ls`, `tmuxp edit`). + +### 5. Window-Level `focus` Key **Source**: `lib/teamocil/tmux/window.rb` @@ -71,7 +83,9 @@ windows: **Gap**: tmuxp **does** support `focus: true` on windows. **No gap**. -### 5. Pane-Level `focus` Key +Note: teamocil handles window focus at the session level in `session.rb:24-25` — after all windows are created, it finds the focused window and issues `select-window`. tmuxp handles this the same way. + +### 6. Pane-Level `focus` Key **Source**: `lib/teamocil/tmux/pane.rb` @@ -84,7 +98,7 @@ panes: **Gap**: tmuxp **does** support `focus: true` on panes. **No gap**. -### 6. Window-Level `options` Key +### 7. Window-Level `options` Key **Source**: `lib/teamocil/tmux/window.rb` @@ -99,7 +113,7 @@ Maps to `set-window-option -t <window> <key> <value>`. **Gap**: tmuxp **does** support `options` on windows. **No gap**. -### 7. Multiple Commands Joined by Semicolon +### 8. Multiple Commands Joined by Semicolon **Source**: `lib/teamocil/tmux/pane.rb` @@ -161,12 +175,12 @@ Teamocil joins multiple pane commands with `; ` and sends them as a single `send | v1.x `focus` (pane) | Not imported | | v1.x `options` (window) | Not imported | | Session-level `name` (without `session:` wrapper) | Handled (uses `.get("name")`) | -| `with_env_var` (v0.x) | Not handled (noted in docstring TODO) | -| `cmd_separator` (v0.x) | Not handled (noted in docstring TODO) | +| `with_env_var` (importer TODO) | Not handled — does not exist in current teamocil source | +| `cmd_separator` (importer TODO) | Not handled — does not exist in current teamocil source | ### Code Quality Issues in Importer -1. **Lines 145-149**: The `filters.before` and `filters.after` handling has redundant `for _b in` loops that serve no purpose. The inner assignment just reassigns the same value each iteration: +1. **Lines 144-149**: The `filters.before` and `filters.after` handling has redundant `for _b in` loops that serve no purpose. The inner assignment just reassigns the same value each iteration: ```python for _b in w["filters"]["before"]: window_dict["shell_command_before"] = w["filters"]["before"] @@ -175,9 +189,9 @@ Teamocil joins multiple pane commands with `; ` and sends them as a single `send 2. **Lines 140-141**: `clear` is preserved in the config but tmuxp has no handling for it. It will be silently ignored during workspace building. -3. **Line 149**: `shell_command_after` is not a tmuxp-supported key. It will be silently ignored. +3. **Lines 147-149**: `shell_command_after` is set from `filters.after` but is not a tmuxp-supported key. It will be silently ignored during workspace building. -4. **Line 162-163**: `width` is silently dropped with a TODO comment. No warning to the user. +4. **Lines 161-163**: `width` is silently dropped with a TODO comment. No warning to the user. 5. **v1.x incompatibility**: The importer assumes v0.x format. A v1.x config with `commands` instead of `cmd`, or string panes, will not import correctly: - String pane `"git status"` → error (tries to access `p["cmd"]` on a string) @@ -198,20 +212,22 @@ Teamocil joins multiple pane commands with `; ` and sends them as a single `send ### Gaps Requiring New Features -1. **`--here` flag** — Reuse current window for first window of layout. Requires `WorkspaceBuilder` to rename instead of create, and send `cd` for root directory. +1. **Session rename mode** — teamocil renames the current session rather than creating a new one. tmuxp always creates a fresh session. + +2. **`--here` flag** — Reuse current window for first window of layout. Requires `WorkspaceBuilder` to rename instead of create, and send `cd` for root directory. -2. **`--debug` / dry-run mode** — Log commands without executing. Architectural challenge since tmuxp uses libtmux API, not command strings. +3. **`--debug` / dry-run mode** — Log commands without executing. Architectural challenge since tmuxp uses libtmux API, not command strings. -3. **`shell_command_after`** — Commands run after pane commands. The importer preserves this from teamocil's `filters.after` but tmuxp has no support for it in the builder. +4. **`shell_command_after`** — Commands run after pane commands. The importer preserves this from teamocil's `filters.after` but tmuxp has no support for it in the builder. ### Import-Only Fixes (No Builder Changes) -4. **v1.x format support** — The importer should handle: +5. **v1.x format support** — The importer should handle: - `commands` key (v1.x) in addition to `cmd` (v0.x) - String pane shorthand - `focus` on windows and panes - `options` on windows -5. **Redundant loop cleanup** — Fix the `filters` handling code. +6. **Redundant loop cleanup** — Fix the `filters` handling code. -6. **Drop unsupported keys with warnings** — Instead of silently preserving `clear` or dropping `width`, warn the user. +7. **Drop unsupported keys with warnings** — Instead of silently preserving `clear` or dropping `width`, warn the user. From 9b83c74c991e3c9ffadc2598f0bc6b7a9ae46e5a Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:04:19 -0600 Subject: [PATCH 018/152] fix(import-tmuxinator): Correct pre/pre_window semantics and cli_args analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix pre/pre_window mapping: pre → before_script (session-level, runs once), pre_window → shell_command_before (per-pane) - Add template.erb line references for execution order - Expand cli_args fragility analysis (str.replace is unsafe) - Add tmuxinator source references for tmux_options and socket handling --- notes/import-tmuxinator.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md index 943f42448c..eca2e54dcd 100644 --- a/notes/import-tmuxinator.md +++ b/notes/import-tmuxinator.md @@ -73,7 +73,9 @@ These are config keys/patterns that differ syntactically but can be automaticall | `cli_args: "-f ~/.tmux.special.conf"` | `config: ~/.tmux.special.conf` | | `tmux_options: "-f ~/.tmux.special.conf"` | `config: ~/.tmux.special.conf` | -**Importer status**: ⚠ Partially handled (lines 36-49). Only extracts `-f` flag value. Other flags like `-L` (socket name) and `-S` (socket path) in `cli_args`/`tmux_options` are silently included in the `config` value, which is incorrect — `config` should only be a file path. +**Importer status**: ⚠ Partially handled (lines 36-49). Only extracts `-f` flag value via `str.replace("-f", "").strip()`, which is fragile — it would also match strings containing `-f` as a substring (e.g. a path like `/opt/foobar`). Other flags like `-L` (socket name) and `-S` (socket path) that may appear in `cli_args`/`tmux_options` are silently included in the `config` value, which is incorrect — `config` should only be a file path. + +In tmuxinator, `cli_args` is deprecated in favor of `tmux_options` (`project.rb:17-19`). The actual tmux command is built as `"#{tmux_command}#{tmux_options}#{socket}"` (`project.rb:196`), where `socket` handles `-L`/`-S` separately from `socket_name`/`socket_path` config keys. ### 9. Rbenv @@ -90,9 +92,13 @@ These are config keys/patterns that differ syntactically but can be automaticall | `pre: "cmd"` (session-level, alone) | `shell_command_before: ["cmd"]` | | `pre_window: "cmd"` + `pre: "cmd"` | `shell_command: "cmd"` + `shell_command_before: ["cmd"]` | -**Importer status**: ⚠ Bug (lines 59-70). When both `pre` and `pre_window` exist, the importer sets `shell_command` (not a valid tmuxp session-level key) for `pre` and `shell_command_before` for `pre_window`. The `pre` commands are lost. +**Importer status**: ⚠ Bug (lines 59-70). When both `pre` and `pre_window` exist, the importer sets `shell_command` (not a valid tmuxp session-level key) for `pre` and `shell_command_before` for `pre_window`. The `pre` commands are silently lost. + +In tmuxinator, `pre` is a deprecated session-level command run once before creating windows (in `template.erb:19`, equivalent to `on_project_start`). `pre_window` is a per-pane command run before each pane's commands (in `template.erb:71-73`). These are different scopes. -**Correct mapping**: Both should map to `shell_command_before`, with `pre` commands first, then `pre_window` commands. +**Correct mapping**: +- `pre` → `before_script` (runs once before windows are created) +- `pre_window` → `shell_command_before` (runs per pane) ### 11. Window as String/List From ad1621684a6bae8c73a1da7e03a2ac317970ea3d Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:04:25 -0600 Subject: [PATCH 019/152] fix(import-teamocil): Mark with_env_var/cmd_separator as unverified stale TODOs - with_env_var does not exist in teamocil 1.4.2 source - cmd_separator does not exist in teamocil 1.4.2 source - Both are only in importer docstring TODOs (importers.py:121-123) - Reclassify both as unverified in summary table - Update code issues section to note stale TODOs --- notes/import-teamocil.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md index 5d3a42de9a..0a5b99c8dc 100644 --- a/notes/import-teamocil.md +++ b/notes/import-teamocil.md @@ -165,19 +165,17 @@ Since teamocil 1.4.2 uses the v1.x format, the importer is outdated for current ## Import-Only Fixes (No Builder Changes) -### 5. `with_env_var` → `environment` (v0.x) +### 5. `with_env_var` (listed in importer TODO) -**What it does in teamocil v0.x**: Sets environment variables for panes. +**Note**: `with_env_var` is listed in the importer's docstring TODOs (`importers.py:121`) but does **not exist** in teamocil's current source (v1.4.2) or in any teamocil file. This may have been a feature from a very old version that was removed, or it may never have existed. The TODO should be removed or verified against historical teamocil releases. -**Why it's not imported**: Noted in importer docstring TODO. Not implemented. +If it did exist, tmuxp's `environment` key would be the natural mapping. -**Fix**: tmuxp already has `environment` on sessions, windows, and panes. The importer just needs to map `with_env_var` → `environment`. No builder changes required. +### 6. `cmd_separator` (listed in importer TODO) -### 6. `cmd_separator` (v0.x) +**Note**: Like `with_env_var`, `cmd_separator` is listed in the importer's docstring TODOs but does **not exist** in teamocil's current source (v1.4.2). Teamocil v1.x hardcodes `commands.join('; ')` in `pane.rb:7`. There is no configurable separator. -**What it does in teamocil v0.x**: Custom separator for joining multiple commands (default: `; `). - -**Note**: tmuxp sends commands individually (one `send_keys` per command), so this is irrelevant. The behavioral difference is actually better in tmuxp — no import needed. +tmuxp sends commands individually (one `send_keys` per command), so even if this existed, it would be irrelevant. ## Code Issues in Current Importer @@ -216,9 +214,9 @@ if "panes" in w: If `p` is a string (v1.x shorthand), `"cmd" in p` will check for substring match in the string, not a dict key. This will either silently pass (if the command doesn't contain "cmd") or incorrectly match. -### Missing: `with_env_var` and `cmd_separator` +### Stale TODOs: `with_env_var` and `cmd_separator` -Noted in the docstring TODOs but never implemented. `with_env_var` could map to tmuxp's `environment` key. `cmd_separator` is irrelevant since tmuxp sends commands individually. +Listed in the importer's docstring TODOs (`importers.py:121-123`) but neither exists in teamocil's current source (v1.4.2). These TODOs may reference features from a very old teamocil version or may be incorrect. They should be removed or verified against historical releases. ### Silent Drops @@ -244,9 +242,9 @@ Noted in the docstring TODOs but never implemented. `with_env_var` could map to | Window `focus` (v1.x) | ✗ Missing | Difference (needs add) | | Pane `focus` (v1.x) | ✗ Missing | Difference (needs add) | | Window `options` (v1.x) | ✗ Missing | Difference (needs add) | -| `with_env_var` → `environment` (v0.x) | ✗ Missing | Difference (needs add) | +| `with_env_var` (in importer TODO) | ✗ Missing | Unverified (not in current teamocil source) | | `filters.after` → `shell_command_after` | ⚠ Imported but unused | **Limitation** | | Pane `width` (v0.x) | ⚠ Dropped silently | **Limitation** | | Window `clear` (v0.x) | ⚠ Preserved but unused | **Limitation** | -| `cmd_separator` (v0.x) | ✗ Missing | N/A (tmuxp is better) | +| `cmd_separator` (in importer TODO) | ✗ Missing | Unverified (not in current teamocil source) | | `--here` flag | N/A (runtime flag) | **Limitation** | From 2f7df6694a7c2406946b8c507ac2a0e8f95e7ab3 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:13:39 -0600 Subject: [PATCH 020/152] docs(plan): Add parity implementation plan with API blockers Analyze libtmux and tmuxp limitations blocking feature parity with tmuxinator and teamocil. Document dead config keys, importer bugs, and required API additions organized by implementation phase. --- notes/plan.md | 235 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 notes/plan.md diff --git a/notes/plan.md b/notes/plan.md new file mode 100644 index 0000000000..4511fcf408 --- /dev/null +++ b/notes/plan.md @@ -0,0 +1,235 @@ +# Parity Implementation Plan + +*Last updated: 2026-02-08* +*Based on: parity-tmuxinator.md, parity-teamocil.md, import-tmuxinator.md, import-teamocil.md* + +## libtmux Limitations + +### L1. No `Pane.set_title()` Method + +- **Blocker**: libtmux has no method wrapping `select-pane -T <title>`. The `pane_title` format variable was removed from the format query list (`formats.py:70`) in tmux 3.1+, but `select-pane -T` still works in tmux 3.2+. libtmux already knows about the display options (`pane_border_status`, `pane_border_format` in `constants.py:163-173`) but has no setter for the title itself. +- **Blocks**: Pane titles (tmuxinator feature: named pane syntax `pane_name: command` → `select-pane -T`). Also blocks `enable_pane_titles`, `pane_title_position`, `pane_title_format` session-level config. +- **Required**: Add `Pane.set_title(title: str)` method that calls `self.cmd("select-pane", "-T", title)`. This is a simple wrapper — `Pane.cmd()` already exists (`pane.py:177`) and `select-pane` is already used for `Pane.select()` (`pane.py:601`). +- **Non-breaking**: Pure addition, no existing API changes. + +### L2. Hardcoded tmux Binary Path + +- **Blocker**: `shutil.which("tmux")` is hardcoded in two places: + - `common.py:252` (`tmux_cmd.__init__`) + - `server.py:223` (`Server.is_alive`) + There is no way to use a custom tmux binary (wemux, byobu, or custom-built tmux). +- **Blocks**: Wemux support (tmuxinator `tmux_command: wemux`). Also blocks CI/container use with non-standard tmux locations. +- **Required**: Add optional `tmux_bin` parameter to `Server.__init__()` that propagates to `tmux_cmd`. Default remains `shutil.which("tmux")`. +- **Non-breaking**: Optional parameter with backward-compatible default. Existing code is unaffected. + +### L3. No Dry-Run / Command Preview Mode + +- **Blocker**: `tmux_cmd` (`common.py:252-296`) always executes commands. Debug logging exists (`logger.debug` at line 291) but only logs stdout after execution, not the command being sent. There is no facility to collect commands without executing them. +- **Blocks**: `--debug` / dry-run mode (both tmuxinator and teamocil have this). tmuxinator generates a bash script that can be previewed; teamocil's `--debug` outputs the tmux command list. +- **Required**: Either (a) add a `dry_run` flag to `tmux_cmd` that collects commands instead of executing, or (b) add pre-execution logging at DEBUG level that logs the full command before `subprocess.run()`. Option (b) is simpler and doesn't change behavior. +- **Non-breaking**: Logging change only. tmuxp would implement the user-facing `--debug` flag by capturing log output. +- **Note**: Since tmuxp uses libtmux API calls (not command strings), a true dry-run would require a recording layer in `WorkspaceBuilder` that logs each API call. This is architecturally different from tmuxinator/teamocil's approach and may not be worth full parity. + +### L4. Available APIs (No Blockers) + +These libtmux APIs already exist and do NOT need changes: + +| API | Location | Supports | +|---|---|---| +| `Session.rename_session(name)` | `session.py:412` | teamocil session rename mode | +| `Window.rename_window(name)` | `window.py:462` | teamocil `--here` flag | +| `Pane.resize(height, width)` | `pane.py:217` | teamocil v0.x pane `width` | +| `Pane.send_keys(cmd, enter)` | `pane.py:423` | All command sending | +| `Pane.select()` | `pane.py:581` | Pane focus | +| `Window.set_option(key, val)` | `options.py:578` (OptionsMixin) | `synchronize-panes`, window options | +| `Session.set_hook(hook, cmd)` | `hooks.py:111` (HooksMixin) | Lifecycle hooks (`client-detached`, etc.) | +| `Session.set_option(key, val)` | `options.py:578` (OptionsMixin) | `pane-border-status`, `pane-border-format` | +| `HooksMixin` on Session/Window/Pane | `session.py:55`, `window.py:56`, `pane.py:51` | All entities inherit hooks | + +## tmuxp Limitations + +### T1. No `synchronize` Config Key + +- **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. The key is silently ignored if present. +- **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). +- **Required**: Add `synchronize` handling in `builder.py`. For `before`/`true`: call `window.set_option("synchronize-panes", "on")` before pane commands in `iter_create_panes()`. For `after`: call it in `config_after_window()`. For `false`/omitted: no action. +- **Insertion point**: `iter_create_windows()` around line 424 (after window options are set) for `before`/`true`. `config_after_window()` around line 560 for `after`. +- **Non-breaking**: New optional config key. Existing configs are unaffected. + +### T2. No Pane Title Config Key + +- **Blocker**: `WorkspaceBuilder` has no handling for pane `title` key or session-level `enable_pane_titles` / `pane_title_position` / `pane_title_format`. +- **Blocks**: Pane titles (tmuxinator named pane syntax). +- **Required**: + 1. Session-level: set `pane-border-status` and `pane-border-format` options via `session.set_option()` in `build()` (around line 311). + 2. Pane-level: call `pane.cmd("select-pane", "-T", title)` after pane creation in `iter_create_panes()` (around line 538). Requires L1 (libtmux `set_title()`), or can use `pane.cmd()` directly. +- **Config keys**: `enable_pane_titles: true`, `pane_title_position: top`, `pane_title_format: "..."` (session-level). `title: "my-title"` (pane-level). +- **Non-breaking**: New optional config keys. + +### T3. No `shell_command_after` Config Key + +- **Blocker**: The teamocil importer produces `shell_command_after` (from `filters.after`, `importers.py:149`), but `WorkspaceBuilder` never reads it. The `trickle()` function in `loader.py` has no logic for it either. +- **Blocks**: teamocil v0.x `filters.after` — commands run after pane commands. +- **Required**: Add handling in `iter_create_panes()` after the `shell_command` loop (around line 534). Read `pane_config.get("shell_command_after", [])` and send each command via `pane.send_keys()`. +- **Non-breaking**: New optional config key. + +### T4. No `--here` CLI Flag + +- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 406). +- **Blocks**: teamocil `--here` — reuse current window for first window. +- **Required**: + 1. Add `--here` flag to `cli/load.py` (around line 516, near `--append`). + 2. Pass `here=True` through to `WorkspaceBuilder.build()`. + 3. In `iter_create_windows()`, when `here=True` and first window: use `window.rename_window(name)` instead of `session.new_window()`, and send `cd <root>` via `pane.send_keys()` for directory change. + 4. Adjust `first_window_pass()` logic (line 584). +- **Depends on**: libtmux `Window.rename_window()` (already exists, L4). +- **Non-breaking**: New optional CLI flag. + +### T5. No `stop` / `kill` CLI Command + +- **Blocker**: tmuxp has no `stop` command. The CLI modules (`cli/__init__.py`) only register: `load`, `freeze`, `ls`, `search`, `shell`, `convert`, `import`, `edit`, `debug-info`. +- **Blocks**: tmuxinator `stop` / `stop-all` — kill session with cleanup hooks. +- **Required**: Add `tmuxp stop <session>` command. Implementation: find session by name via `server.sessions`, call `session.kill()`. For hook support, run `on_project_stop` hook before kill. +- **Non-breaking**: New CLI command. + +### T6. No Lifecycle Hook Config Keys + +- **Blocker**: tmuxp's plugin system (`plugin.py:216-292`) has 5 hooks: `before_workspace_builder`, `on_window_create`, `after_window_finished`, `before_script`, `reattach`. These are Python plugin hooks, not config-driven shell command hooks. There are no config keys for `on_project_start`, `on_project_exit`, etc. +- **Blocks**: tmuxinator lifecycle hooks (`on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`). +- **Required**: Add config-level hook keys. Mapping: + - `on_project_start` → run shell command at start of `build()`, before `before_script` + - `on_project_first_start` → already partially covered by `before_script` + - `on_project_restart` → run when reattaching (currently only plugin `reattach()` hook) + - `on_project_exit` → use tmux `set-hook client-detached` via `session.set_hook()` (libtmux L4) + - `on_project_stop` → run in new `tmuxp stop` command (T5) +- **Depends on**: T5 for `on_project_stop`. +- **Non-breaking**: New optional config keys. + +### T7. No `--no-shell-command-before` CLI Flag + +- **Blocker**: `tmuxp load` has no flag to skip `shell_command_before`. The `trickle()` function (`loader.py:245-256`) always prepends these commands. +- **Blocks**: tmuxinator `--no-pre-window` — skip per-pane pre-commands for debugging. +- **Required**: Add `--no-shell-command-before` flag to `cli/load.py`. When set, clear `shell_command_before` from all levels before calling `trickle()`. +- **Non-breaking**: New optional CLI flag. + +### T8. No Config Templating + +- **Blocker**: tmuxp has no variable interpolation in config values. Environment variable expansion (`$VAR`) works in `start_directory` paths via `os.path.expandvars()` in `loader.py`, but not in arbitrary config values. +- **Blocks**: tmuxinator ERB templating (`<%= @settings["key"] %>`). +- **Required**: Add a Jinja2 or Python `string.Template` pass before YAML parsing. Allow `key=value` CLI args to set template variables. This is a significant architectural addition. +- **Non-breaking**: Opt-in feature, existing configs are unaffected. + +### T9. No `--debug` / Dry-Run CLI Flag + +- **Blocker**: `tmuxp load` has no dry-run mode. Since tmuxp uses libtmux API calls rather than generating command strings, there's no natural command list to preview. +- **Blocks**: tmuxinator `debug` and teamocil `--debug` / `--show`. +- **Required**: Either (a) add a recording proxy layer around libtmux calls that logs what would be done, or (b) add verbose logging that shows each tmux command before execution (depends on L3). +- **Non-breaking**: New optional CLI flag. + +### T10. Missing Config Management Commands + +- **Blocker**: tmuxp only has `edit`. Missing: `new` (create from template), `copy` (duplicate config), `delete` (remove config with confirmation). +- **Blocks**: tmuxinator `new`, `copy`, `delete`, `implode` commands. +- **Required**: Add CLI commands. These are straightforward file operations. +- **Non-breaking**: New CLI commands. + +## Dead Config Keys + +Keys produced by importers but silently ignored by the builder: + +| Key | Producer | Importer Line | Builder Handling | Issue | +|---|---|---|---|---| +| `shell_command` (session-level) | tmuxinator importer | `importers.py:60` | Not a valid session key | **Bug**: `pre` commands lost when both `pre` and `pre_window` exist | +| `config` | tmuxinator importer | `importers.py:37,44` | Never read | Dead data — extracted `-f` path goes nowhere | +| `socket_name` | tmuxinator importer | `importers.py:52` | Never read | Dead data — CLI uses `-L` flag | +| `clear` | teamocil importer | `importers.py:141` | Never read | Dead data — tmuxp has no clear support | +| `shell_command_after` | teamocil importer | `importers.py:149` | Never read | Dead data — tmuxp has no after-command support | + +## Importer Bugs (No Builder Changes Needed) + +### I1. tmuxinator `pre` + `pre_window` Mapping Bug + +- **Bug**: When both `pre` and `pre_window` exist (`importers.py:59-65`), `pre` maps to `shell_command` (invalid session-level key) and `pre_window` maps to `shell_command_before`. The `pre` commands are silently lost. +- **Correct mapping**: `pre` → `before_script` (session-level, runs once before windows). `pre_window` → `shell_command_before` (per-pane). +- **Note**: `before_script` expects a file path, not inline commands. This may need a different approach — either write a temp script, or add an `on_project_start` config key (T6). + +### I2. tmuxinator `cli_args` / `tmux_options` Fragile Parsing + +- **Bug**: `str.replace("-f", "").strip()` (`importers.py:41,48`) matches `-f` as a substring anywhere in the string. A path like `/opt/foobar` would be corrupted. Also ignores `-L` (socket name) and `-S` (socket path) flags. +- **Fix**: Use proper argument parsing (e.g., `shlex.split()` + iterate to find `-f` flag and its value). + +### I3. teamocil Redundant Filter Loops + +- **Bug**: `for _b in w["filters"]["before"]:` loops (`importers.py:145-149`) iterate N times but set the same value each time. +- **Fix**: Replace with direct assignment. + +### I4. teamocil v1.x Format Not Supported + +- **Bug**: Importer assumes v0.x format. String panes cause incorrect behavior (`"cmd" in "git status"` checks substring, not dict key). `commands` key (v1.x) not mapped. +- **Fix**: Add format detection. Handle string panes, `commands` key, `focus`, and `options`. + +### I5. tmuxinator Missing Keys + +Not imported but translatable: +- `rvm` → `shell_command_before: ["rvm use {value}"]` +- `pre_tab` → `shell_command_before` (deprecated predecessor to `pre_window`) +- `startup_window` → find matching window, set `focus: true` +- `startup_pane` → find matching pane, set `focus: true` +- `on_project_first_start` → `before_script` +- `socket_path` → warn user to use CLI `-S` flag +- `attach: false` → warn user to use CLI `-d` flag + +### I6. teamocil Missing Keys (v1.x) + +Not imported but translatable (same key names in tmuxp): +- `commands` → `shell_command` +- `focus` (window) → `focus` (pass-through) +- `focus` (pane) → `focus` (pass-through) +- `options` (window) → `options` (pass-through) +- String pane shorthand → `shell_command: [command]` + +### I7. Stale Importer TODOs + +`importers.py:121-123` lists `with_env_var` and `cmd_separator` as TODOs, but neither exists in teamocil v1.4.2 source. These are stale references from ~2013 and should be removed. + +## Implementation Priority + +### Phase 1: Import Fixes (No Builder/libtmux Changes) + +These fix existing bugs and add missing translations without touching the builder: + +1. **I3**: Fix redundant filter loops (teamocil) +2. **I4**: Add v1.x teamocil format support +3. **I6**: Import teamocil v1.x keys (`commands`, `focus`, `options`, string panes) +4. **I5**: Import missing tmuxinator keys (`rvm`, `pre_tab`, `startup_window`, `startup_pane`) +5. **I1**: Fix `pre`/`pre_window` mapping (tmuxinator) +6. **I2**: Fix `cli_args` parsing (tmuxinator) +7. **I7**: Remove stale TODOs + +### Phase 2: Builder Additions (tmuxp Only) + +These add new config key handling to the builder: + +1. **T1**: `synchronize` config key — straightforward `set_option()` call +2. **T3**: `shell_command_after` config key — straightforward `send_keys()` loop +3. **T4**: `--here` CLI flag — moderate complexity, uses existing libtmux APIs + +### Phase 3: libtmux Additions + +These require changes to the libtmux package: + +1. **L1**: `Pane.set_title()` — simple wrapper, needed for T2 +2. **T2**: Pane title config keys — depends on L1 + +### Phase 4: New CLI Commands + +3. **T5**: `tmuxp stop` command +4. **T10**: `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands + +### Phase 5: Larger Features (Nice-to-Have) + +5. **T6**: Lifecycle hook config keys — complex, needs design +6. **T7**: `--no-shell-command-before` flag — simple +7. **T8**: Config templating — significant architectural addition +8. **T9**: `--debug` / dry-run mode — depends on L3 +9. **L2**: Custom tmux binary — requires libtmux changes From 1eeb6b370befefc125cc5cf0dd4e8ef43c48ee24 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:20:31 -0600 Subject: [PATCH 021/152] fix(plan): Correct line refs, add isinstance bug, expand T4/T8/L2 details - L2: Fix method name (raise_if_dead, not is_alive), document two independent code paths - L4: Fix Pane.select() line number (577, not 581) - T4: Add session rename mode alongside --here, note --append gap - T8: Correct env var expansion scope (works in most values, not just start_directory) - I1: Document isinstance check bug (checks pre type instead of pre_window type) --- notes/plan.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 4511fcf408..ea3194eced 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -14,12 +14,12 @@ ### L2. Hardcoded tmux Binary Path -- **Blocker**: `shutil.which("tmux")` is hardcoded in two places: - - `common.py:252` (`tmux_cmd.__init__`) - - `server.py:223` (`Server.is_alive`) +- **Blocker**: `shutil.which("tmux")` is hardcoded in two independent code paths: + - `common.py:252` — `tmux_cmd.__init__()`, the class through which all libtmux commands flow (called by `Server.cmd()` at `server.py:311`) + - `server.py:223` — `Server.raise_if_dead()`, a separate code path that calls `subprocess.check_call()` directly There is no way to use a custom tmux binary (wemux, byobu, or custom-built tmux). - **Blocks**: Wemux support (tmuxinator `tmux_command: wemux`). Also blocks CI/container use with non-standard tmux locations. -- **Required**: Add optional `tmux_bin` parameter to `Server.__init__()` that propagates to `tmux_cmd`. Default remains `shutil.which("tmux")`. +- **Required**: Add optional `tmux_bin` parameter to `Server.__init__()` that propagates to `tmux_cmd`. Both code paths must be updated. Default remains `shutil.which("tmux")`. - **Non-breaking**: Optional parameter with backward-compatible default. Existing code is unaffected. ### L3. No Dry-Run / Command Preview Mode @@ -40,7 +40,7 @@ These libtmux APIs already exist and do NOT need changes: | `Window.rename_window(name)` | `window.py:462` | teamocil `--here` flag | | `Pane.resize(height, width)` | `pane.py:217` | teamocil v0.x pane `width` | | `Pane.send_keys(cmd, enter)` | `pane.py:423` | All command sending | -| `Pane.select()` | `pane.py:581` | Pane focus | +| `Pane.select()` | `pane.py:577` | Pane focus | | `Window.set_option(key, val)` | `options.py:578` (OptionsMixin) | `synchronize-panes`, window options | | `Session.set_hook(hook, cmd)` | `hooks.py:111` (HooksMixin) | Lifecycle hooks (`client-detached`, etc.) | | `Session.set_option(key, val)` | `options.py:578` (OptionsMixin) | `pane-border-status`, `pane-border-format` | @@ -73,16 +73,17 @@ These libtmux APIs already exist and do NOT need changes: - **Required**: Add handling in `iter_create_panes()` after the `shell_command` loop (around line 534). Read `pane_config.get("shell_command_after", [])` and send each command via `pane.send_keys()`. - **Non-breaking**: New optional config key. -### T4. No `--here` CLI Flag +### T4. No Session Rename Mode / `--here` CLI Flag -- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 406). -- **Blocks**: teamocil `--here` — reuse current window for first window. +- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 406). Additionally, teamocil's session rename mode (rename current session instead of creating new) is partially covered by tmuxp's `--append` flag, but `--append` does not rename the session. +- **Blocks**: teamocil `--here` (reuse current window for first window) and teamocil session rename mode. - **Required**: 1. Add `--here` flag to `cli/load.py` (around line 516, near `--append`). 2. Pass `here=True` through to `WorkspaceBuilder.build()`. 3. In `iter_create_windows()`, when `here=True` and first window: use `window.rename_window(name)` instead of `session.new_window()`, and send `cd <root>` via `pane.send_keys()` for directory change. 4. Adjust `first_window_pass()` logic (line 584). -- **Depends on**: libtmux `Window.rename_window()` (already exists, L4). + 5. For session rename: when `--here` is used, also call `session.rename_session(name)` (line 262 area in `build()`). +- **Depends on**: libtmux `Window.rename_window()` and `Session.rename_session()` (both already exist, L4). - **Non-breaking**: New optional CLI flag. ### T5. No `stop` / `kill` CLI Command @@ -114,7 +115,7 @@ These libtmux APIs already exist and do NOT need changes: ### T8. No Config Templating -- **Blocker**: tmuxp has no variable interpolation in config values. Environment variable expansion (`$VAR`) works in `start_directory` paths via `os.path.expandvars()` in `loader.py`, but not in arbitrary config values. +- **Blocker**: tmuxp has no user-defined variable interpolation. Environment variable expansion (`$VAR` via `os.path.expandvars()`) already works in most config values — `session_name`, `window_name`, `start_directory`, `before_script`, `environment`, `options`, `global_options` (see `loader.py:108-160`). But there is no way to pass custom `key=value` variables at load time. - **Blocks**: tmuxinator ERB templating (`<%= @settings["key"] %>`). - **Required**: Add a Jinja2 or Python `string.Template` pass before YAML parsing. Allow `key=value` CLI args to set template variables. This is a significant architectural addition. - **Non-breaking**: Opt-in feature, existing configs are unaffected. @@ -149,9 +150,11 @@ Keys produced by importers but silently ignored by the builder: ### I1. tmuxinator `pre` + `pre_window` Mapping Bug -- **Bug**: When both `pre` and `pre_window` exist (`importers.py:59-65`), `pre` maps to `shell_command` (invalid session-level key) and `pre_window` maps to `shell_command_before`. The `pre` commands are silently lost. +- **Bug**: When both `pre` and `pre_window` exist (`importers.py:59-65`): + 1. `pre` maps to `shell_command` (line 60) — invalid session-level key, silently ignored by builder. The `pre` commands are lost. + 2. The `isinstance` check on line 62 tests `workspace_dict["pre"]` type to decide how to wrap `workspace_dict["pre_window"]` — it should check `pre_window`'s type, not `pre`'s. If `pre` is a list but `pre_window` is a string, `pre_window` won't be wrapped in a list. - **Correct mapping**: `pre` → `before_script` (session-level, runs once before windows). `pre_window` → `shell_command_before` (per-pane). -- **Note**: `before_script` expects a file path, not inline commands. This may need a different approach — either write a temp script, or add an `on_project_start` config key (T6). +- **Note**: `before_script` expects a file path or command (executed via `subprocess.Popen` after `shlex.split()` in `util.py:27-32`), not inline shell commands. For inline commands, either write a temp script, or add an `on_project_start` config key (T6). ### I2. tmuxinator `cli_args` / `tmux_options` Fragile Parsing From fc5f87dca8dd62d5e825f1ca8e99a5fd06e4bfc3 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:28:19 -0600 Subject: [PATCH 022/152] fix(plan): Correct L1/T1/T3 details from tmux source verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L1: pane_title is excluded from libtmux's bulk format queries, not removed from tmux itself — tmux still supports #{pane_title} (format.c:205) and select-pane -T (added in tmux 2.6). T1: synchronize before/true insertion point is build() line 320 (after on_window_create hook, before iter_create_panes loop), not iter_create_windows() line 424 which is inside the generator. T3: shell_command_after is a window-level key (set by teamocil importer on window_dict), not per-pane. Correct insertion point is config_after_window() or after the pane loop in build(). --- notes/plan.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index ea3194eced..00488cfec2 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -7,7 +7,7 @@ ### L1. No `Pane.set_title()` Method -- **Blocker**: libtmux has no method wrapping `select-pane -T <title>`. The `pane_title` format variable was removed from the format query list (`formats.py:70`) in tmux 3.1+, but `select-pane -T` still works in tmux 3.2+. libtmux already knows about the display options (`pane_border_status`, `pane_border_format` in `constants.py:163-173`) but has no setter for the title itself. +- **Blocker**: libtmux has no method wrapping `select-pane -T <title>`. The `pane_title` format variable is excluded from libtmux's bulk format queries (`formats.py:70`, commented out with note "removed in 3.1+"), but this is a libtmux-side exclusion — tmux itself still supports both `#{pane_title}` (in `format.c:205`) and `select-pane -T` (added in tmux 2.6). libtmux already knows about the display options (`pane_border_status`, `pane_border_format` in `constants.py:163-173`) but has no setter for the title itself. - **Blocks**: Pane titles (tmuxinator feature: named pane syntax `pane_name: command` → `select-pane -T`). Also blocks `enable_pane_titles`, `pane_title_position`, `pane_title_format` session-level config. - **Required**: Add `Pane.set_title(title: str)` method that calls `self.cmd("select-pane", "-T", title)`. This is a simple wrapper — `Pane.cmd()` already exists (`pane.py:177`) and `select-pane` is already used for `Pane.select()` (`pane.py:601`). - **Non-breaking**: Pure addition, no existing API changes. @@ -52,8 +52,8 @@ These libtmux APIs already exist and do NOT need changes: - **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. The key is silently ignored if present. - **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). -- **Required**: Add `synchronize` handling in `builder.py`. For `before`/`true`: call `window.set_option("synchronize-panes", "on")` before pane commands in `iter_create_panes()`. For `after`: call it in `config_after_window()`. For `false`/omitted: no action. -- **Insertion point**: `iter_create_windows()` around line 424 (after window options are set) for `before`/`true`. `config_after_window()` around line 560 for `after`. +- **Required**: Add `synchronize` handling in `builder.py`. For `before`/`true`: call `window.set_option("synchronize-panes", "on")` before pane commands are sent. For `after`: call it in `config_after_window()`. For `false`/omitted: no action. +- **Insertion point**: In `build()` around line 320 (after `on_window_create` plugin hook, before `iter_create_panes()` loop) for `before`/`true`. In `config_after_window()` around line 565 for `after`. Note: setting sync before pane creation works because `synchronize-panes` applies to all panes in the window, including those created later by split. - **Non-breaking**: New optional config key. Existing configs are unaffected. ### T2. No Pane Title Config Key @@ -68,9 +68,9 @@ These libtmux APIs already exist and do NOT need changes: ### T3. No `shell_command_after` Config Key -- **Blocker**: The teamocil importer produces `shell_command_after` (from `filters.after`, `importers.py:149`), but `WorkspaceBuilder` never reads it. The `trickle()` function in `loader.py` has no logic for it either. -- **Blocks**: teamocil v0.x `filters.after` — commands run after pane commands. -- **Required**: Add handling in `iter_create_panes()` after the `shell_command` loop (around line 534). Read `pane_config.get("shell_command_after", [])` and send each command via `pane.send_keys()`. +- **Blocker**: The teamocil importer produces `shell_command_after` on the **window** dict (from `filters.after`, `importers.py:149`), but `WorkspaceBuilder` never reads it. The `trickle()` function in `loader.py` has no logic for it either. +- **Blocks**: teamocil v0.x `filters.after` — commands run after all pane commands in a window. +- **Required**: Add handling in `config_after_window()` (around line 565) or in `build()` after the `iter_create_panes()` loop (around line 331). Read `window_config.get("shell_command_after", [])` and send each command to every pane via `pane.send_keys()`. Note: this is a **window-level** key set by the teamocil importer, not per-pane. - **Non-breaking**: New optional config key. ### T4. No Session Rename Mode / `--here` CLI Flag From f46570548e5cead178f985bd42a1da8906830455 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:34:50 -0600 Subject: [PATCH 023/152] fix(plan): Correct T2 insertion points and I7 line references T2: Session-level pane title options insertion is alongside other session options at lines 303-309, not "around line 311". Pane-level title should be set after commands are sent (around line 535), before focus handling at line 536. I7: Stale TODOs are at lines 121 and 123 (not 121-123), since line 122 is `clear` which is a real teamocil feature. --- notes/plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 00488cfec2..3c417cded5 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -61,8 +61,8 @@ These libtmux APIs already exist and do NOT need changes: - **Blocker**: `WorkspaceBuilder` has no handling for pane `title` key or session-level `enable_pane_titles` / `pane_title_position` / `pane_title_format`. - **Blocks**: Pane titles (tmuxinator named pane syntax). - **Required**: - 1. Session-level: set `pane-border-status` and `pane-border-format` options via `session.set_option()` in `build()` (around line 311). - 2. Pane-level: call `pane.cmd("select-pane", "-T", title)` after pane creation in `iter_create_panes()` (around line 538). Requires L1 (libtmux `set_title()`), or can use `pane.cmd()` directly. + 1. Session-level: set `pane-border-status` and `pane-border-format` options via `session.set_option()` in `build()` alongside other session options (lines 303-309). + 2. Pane-level: call `pane.cmd("select-pane", "-T", title)` after commands are sent in `iter_create_panes()`, before focus handling (around line 535). Requires L1 (libtmux `set_title()`), or can use `pane.cmd()` directly. - **Config keys**: `enable_pane_titles: true`, `pane_title_position: top`, `pane_title_format: "..."` (session-level). `title: "my-title"` (pane-level). - **Non-breaking**: New optional config keys. @@ -193,7 +193,7 @@ Not imported but translatable (same key names in tmuxp): ### I7. Stale Importer TODOs -`importers.py:121-123` lists `with_env_var` and `cmd_separator` as TODOs, but neither exists in teamocil v1.4.2 source. These are stale references from ~2013 and should be removed. +`importers.py:121,123` lists `with_env_var` and `cmd_separator` as TODOs (with `clear` at line 122 in between), but neither `with_env_var` nor `cmd_separator` exists in teamocil v1.4.2 source. These are stale references from ~2013 and should be removed. ## Implementation Priority From bfa55fa49cec1c97328bbb892c8857d0cd0afe07 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:41:33 -0600 Subject: [PATCH 024/152] fix(plan): Add tmux 3.2 sync scope detail and before_script limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1: synchronize-panes became dual-scope (window|pane) in tmux 3.2 (options-table.c:1423). Since tmuxp requires 3.2+, window.set_option() works through option inheritance — panes inherit window-level setting. I5: on_project_first_start → before_script mapping only works for single commands or script paths. Multi-command strings with semicolons fail because Popen runs without shell=True. --- notes/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 3c417cded5..070ca03e70 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -53,7 +53,7 @@ These libtmux APIs already exist and do NOT need changes: - **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. The key is silently ignored if present. - **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). - **Required**: Add `synchronize` handling in `builder.py`. For `before`/`true`: call `window.set_option("synchronize-panes", "on")` before pane commands are sent. For `after`: call it in `config_after_window()`. For `false`/omitted: no action. -- **Insertion point**: In `build()` around line 320 (after `on_window_create` plugin hook, before `iter_create_panes()` loop) for `before`/`true`. In `config_after_window()` around line 565 for `after`. Note: setting sync before pane creation works because `synchronize-panes` applies to all panes in the window, including those created later by split. +- **Insertion point**: In `build()` around line 320 (after `on_window_create` plugin hook, before `iter_create_panes()` loop) for `before`/`true`. In `config_after_window()` around line 565 for `after`. Note: in tmux 3.2+ (tmuxp's minimum), `synchronize-panes` is a dual-scope option (window|pane, `options-table.c:1423`). Setting it at window level via `window.set_option()` makes all panes inherit it, including those created later by split. - **Non-breaking**: New optional config key. Existing configs are unaffected. ### T2. No Pane Title Config Key @@ -178,7 +178,7 @@ Not imported but translatable: - `pre_tab` → `shell_command_before` (deprecated predecessor to `pre_window`) - `startup_window` → find matching window, set `focus: true` - `startup_pane` → find matching pane, set `focus: true` -- `on_project_first_start` → `before_script` +- `on_project_first_start` → `before_script` (only if value is a single command or script path; multi-command strings joined by `;` won't work since `before_script` uses `Popen` without `shell=True`) - `socket_path` → warn user to use CLI `-S` flag - `attach: false` → warn user to use CLI `-d` flag From 30d5ea3280986a6031b5c33d38401af360f8a5b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:48:34 -0600 Subject: [PATCH 025/152] fix(plan): Add missing width drop note and importer update dependencies I4: Note that v0.x pane width is silently dropped (importers.py:161-163) with no user warning. Pane.resize() exists in libtmux (L4) so this could be preserved. Phase 2/3: Note that builder additions (T1 synchronize, T2 pane titles) require corresponding tmuxinator importer updates to actually import those keys from tmuxinator configs. --- notes/plan.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/notes/plan.md b/notes/plan.md index 070ca03e70..8ead10f734 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -170,6 +170,7 @@ Keys produced by importers but silently ignored by the builder: - **Bug**: Importer assumes v0.x format. String panes cause incorrect behavior (`"cmd" in "git status"` checks substring, not dict key). `commands` key (v1.x) not mapped. - **Fix**: Add format detection. Handle string panes, `commands` key, `focus`, and `options`. +- **Also**: v0.x pane `width` is silently dropped (`importers.py:161-163`) with a TODO but no user warning. Since libtmux's `Pane.resize()` exists (L4), the importer could preserve `width` and the builder could call `pane.resize(width=value)` after split. Alternatively, warn the user that width is not supported. ### I5. tmuxinator Missing Keys @@ -211,10 +212,12 @@ These fix existing bugs and add missing translations without touching the builde ### Phase 2: Builder Additions (tmuxp Only) -These add new config key handling to the builder: +These add new config key handling to the builder. Each also needs a corresponding importer update: 1. **T1**: `synchronize` config key — straightforward `set_option()` call + - Then update tmuxinator importer to import `synchronize` key (pass-through, same name) 2. **T3**: `shell_command_after` config key — straightforward `send_keys()` loop + - teamocil importer already produces this key (I3 fixes the loop); builder just needs to read it 3. **T4**: `--here` CLI flag — moderate complexity, uses existing libtmux APIs ### Phase 3: libtmux Additions @@ -223,6 +226,7 @@ These require changes to the libtmux package: 1. **L1**: `Pane.set_title()` — simple wrapper, needed for T2 2. **T2**: Pane title config keys — depends on L1 + - Then update tmuxinator importer to import `enable_pane_titles`, `pane_title_position`, `pane_title_format`, and named pane syntax (`pane_name: command` → `title` + `shell_command`) ### Phase 4: New CLI Commands From 2fb56dd7ee4761059f43a63287e1b06786562e5c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 07:50:27 -0600 Subject: [PATCH 026/152] fix(plan): Fix phase numbering and add missing L3 to phase listing Phase 4/5 item numbers now restart per-phase (consistent with Phases 1-3). Previously continued numbering from prior phases. L3 (pre-execution command logging) was a dependency of T9 but was not listed in any phase. Now explicitly listed in Phase 5 before T9. --- notes/plan.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 8ead10f734..a5a940df26 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -230,13 +230,14 @@ These require changes to the libtmux package: ### Phase 4: New CLI Commands -3. **T5**: `tmuxp stop` command -4. **T10**: `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands +1. **T5**: `tmuxp stop` command +2. **T10**: `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands ### Phase 5: Larger Features (Nice-to-Have) -5. **T6**: Lifecycle hook config keys — complex, needs design -6. **T7**: `--no-shell-command-before` flag — simple -7. **T8**: Config templating — significant architectural addition -8. **T9**: `--debug` / dry-run mode — depends on L3 -9. **L2**: Custom tmux binary — requires libtmux changes +1. **T6**: Lifecycle hook config keys — complex, needs design +2. **T7**: `--no-shell-command-before` flag — simple +3. **T8**: Config templating — significant architectural addition +4. **L3**: Pre-execution command logging in libtmux — prerequisite for T9 +5. **T9**: `--debug` / dry-run mode — depends on L3 +6. **L2**: Custom tmux binary — requires libtmux changes From f8b4cbc4d6d554cd18ad623c8d1b2270f5893f98 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 8 Feb 2026 08:25:23 -0600 Subject: [PATCH 027/152] fix(plan): Correct L3 logging description and I2 bug example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L3: common.py:291 debug log includes both cmd and stdout (not "only stdout"), but after execution — the blocker is lack of pre-execution logging. I2: Replace misleading /opt/foobar example with accurate demonstration of -L flag leaking into config value. --- notes/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index a5a940df26..258e61d294 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -24,7 +24,7 @@ ### L3. No Dry-Run / Command Preview Mode -- **Blocker**: `tmux_cmd` (`common.py:252-296`) always executes commands. Debug logging exists (`logger.debug` at line 291) but only logs stdout after execution, not the command being sent. There is no facility to collect commands without executing them. +- **Blocker**: `tmux_cmd` (`common.py:252-296`) always executes commands. Debug logging exists (`logger.debug` at line 291) but logs the command and its stdout *after* execution, not before. There is no pre-execution logging or facility to collect commands without executing them. - **Blocks**: `--debug` / dry-run mode (both tmuxinator and teamocil have this). tmuxinator generates a bash script that can be previewed; teamocil's `--debug` outputs the tmux command list. - **Required**: Either (a) add a `dry_run` flag to `tmux_cmd` that collects commands instead of executing, or (b) add pre-execution logging at DEBUG level that logs the full command before `subprocess.run()`. Option (b) is simpler and doesn't change behavior. - **Non-breaking**: Logging change only. tmuxp would implement the user-facing `--debug` flag by capturing log output. @@ -158,7 +158,7 @@ Keys produced by importers but silently ignored by the builder: ### I2. tmuxinator `cli_args` / `tmux_options` Fragile Parsing -- **Bug**: `str.replace("-f", "").strip()` (`importers.py:41,48`) matches `-f` as a substring anywhere in the string. A path like `/opt/foobar` would be corrupted. Also ignores `-L` (socket name) and `-S` (socket path) flags. +- **Bug**: `str.replace("-f", "").strip()` (`importers.py:41,48`) does a global string replacement, not flag-aware parsing. A value like `"-f ~/.tmux.conf -L mysocket"` would produce `"~/.tmux.conf -L mysocket"` as the `config` value (including the `-L` flag in a file path). Also ignores `-L` (socket name) and `-S` (socket path) flags entirely. - **Fix**: Use proper argument parsing (e.g., `shlex.split()` + iterate to find `-f` flag and its value). ### I3. teamocil Redundant Filter Loops From b5bb31b62cd13139935af18db9f5a7677a00a006 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:08:35 -0600 Subject: [PATCH 028/152] 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 fafec7c6db7a5eba8f8dabfb4d089ae3b7c160d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:08:38 -0600 Subject: [PATCH 029/152] docs(parity-tmuxinator): Update version, add template execution order why: Document exact execution order from template.erb to clarify hook timing. what: - Update tmuxp version to 1.64.0, timestamp to 2026-03-06 - Add execution order detail from template.erb analysis --- notes/parity-tmuxinator.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md index 10bf491698..cbdb27397c 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -1,8 +1,8 @@ # Tmuxinator Parity Analysis -*Last updated: 2026-02-08* +*Last updated: 2026-03-06* *Tmuxinator version analyzed: 3.3.7 (supports tmux 1.5–3.6a)* -*tmuxp version: 1.47.0+* +*tmuxp version: 1.64.0* ## Features tmuxinator has that tmuxp lacks @@ -22,6 +22,8 @@ tmuxinator has 5 lifecycle hooks: **Gap**: tmuxp's `before_script` is a partial equivalent of `on_project_first_start` — it runs before windows are created and kills the session on failure. tmuxp has no equivalent for `on_project_start` (runs every time, including reattach), no hooks for detach/exit/stop events, and no distinction between first start vs. restart. +**Execution order from `template.erb`**: `cd root` → `on_project_start` → (if new session: `pre` → `on_project_first_start` → `new-session` → create windows → build panes → select startup → attach) OR (if existing: `on_project_restart`) → `post` → `on_project_exit`. Note that `post` and `on_project_exit` run on every invocation (outside the new/existing conditional). + **WorkspaceBuilder requirement**: Add config keys for `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`. The exit/stop hooks require shell integration (trap signals, set-hook in tmux). ### 2. Stop/Kill Session Command From c799c7eca146da2684f0a59f5c61a01c1c498bbf Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:08:42 -0600 Subject: [PATCH 030/152] docs(parity-teamocil): Update version and timestamp why: Keep parity analysis current with tmuxp 1.64.0. what: - Update tmuxp version to 1.64.0, timestamp to 2026-03-06 --- notes/parity-teamocil.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index 4d17f1eb1a..a81acfb1bf 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -1,8 +1,8 @@ # Teamocil Parity Analysis -*Last updated: 2026-02-08* +*Last updated: 2026-03-06* *Teamocil version analyzed: 1.4.2* -*tmuxp version: 1.47.0+* +*tmuxp version: 1.64.0* ## Version History Context From 0d8014180df56e7d6ff878c25a4271f55bd43784 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:08:45 -0600 Subject: [PATCH 031/152] docs(import): Update timestamps for import behavior notes why: Refresh timestamps after source code re-verification. what: - Update timestamps to 2026-03-06 in import-tmuxinator.md and import-teamocil.md --- notes/import-teamocil.md | 2 +- notes/import-tmuxinator.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md index 0a5b99c8dc..a1bce59f8a 100644 --- a/notes/import-teamocil.md +++ b/notes/import-teamocil.md @@ -1,6 +1,6 @@ # Teamocil Import Behavior -*Last updated: 2026-02-08* +*Last updated: 2026-03-06* *Importer: `src/tmuxp/workspace/importers.py:import_teamocil`* ## Format Detection Problem diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md index eca2e54dcd..b203c12757 100644 --- a/notes/import-tmuxinator.md +++ b/notes/import-tmuxinator.md @@ -1,6 +1,6 @@ # Tmuxinator Import Behavior -*Last updated: 2026-02-08* +*Last updated: 2026-03-06* *Importer: `src/tmuxp/workspace/importers.py:import_tmuxinator`* ## Syntax Differences (Translatable) From 6785e73451fbc786255e95a2e67910e818f0ac1e Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:10:20 -0600 Subject: [PATCH 032/152] docs(teamocil): Verify v0.x features from 0.4-stable branch why: Agent analysis of teamocil's 0.4-stable branch confirmed that with_env_var and cmd_separator were real v0.x features, not stale TODOs. what: - Verify with_env_var (exports TEAMOCIL=1) and cmd_separator as v0.x features - Add missing v0.x pane keys: height and target - Add v0.x ERB templating to format differences table - Update summary table classifications from "Unverified" to verified --- notes/import-teamocil.md | 30 ++++++++++++++++++++---------- notes/parity-teamocil.md | 7 ++++++- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md index a1bce59f8a..cfbd932bcd 100644 --- a/notes/import-teamocil.md +++ b/notes/import-teamocil.md @@ -165,17 +165,19 @@ Since teamocil 1.4.2 uses the v1.x format, the importer is outdated for current ## Import-Only Fixes (No Builder Changes) -### 5. `with_env_var` (listed in importer TODO) +### 5. `with_env_var` (v0.x only) -**Note**: `with_env_var` is listed in the importer's docstring TODOs (`importers.py:121`) but does **not exist** in teamocil's current source (v1.4.2) or in any teamocil file. This may have been a feature from a very old version that was removed, or it may never have existed. The TODO should be removed or verified against historical teamocil releases. +**Verified**: `with_env_var` exists in teamocil's v0.x (`0.4-stable` branch) at `lib/teamocil/layout/window.rb`. When `true` (the default), it exports `TEAMOCIL=1` environment variable in each pane's command chain. Removed in v1.x rewrite. -If it did exist, tmuxp's `environment` key would be the natural mapping. +tmuxp's `environment` key would be the natural mapping: `environment: { TEAMOCIL: "1" }`. However, since this was a default behavior in v0.x (auto-exported unless disabled), the importer should either: +- Always add `environment: { TEAMOCIL: "1" }` unless `with_env_var: false` +- Or simply drop it, since it's an implementation detail of teamocil -### 6. `cmd_separator` (listed in importer TODO) +### 6. `cmd_separator` (v0.x only) -**Note**: Like `with_env_var`, `cmd_separator` is listed in the importer's docstring TODOs but does **not exist** in teamocil's current source (v1.4.2). Teamocil v1.x hardcodes `commands.join('; ')` in `pane.rb:7`. There is no configurable separator. +**Verified**: `cmd_separator` exists in teamocil's v0.x at `lib/teamocil/layout/window.rb`. It's a per-window string (default `"; "`) used to join multiple pane commands before sending via `send-keys`. Removed in v1.x (hardcoded to `"; "`). -tmuxp sends commands individually (one `send_keys` per command), so even if this existed, it would be irrelevant. +tmuxp sends commands individually (one `send_keys` per command), so this is irrelevant — the importer can safely ignore it. ## Code Issues in Current Importer @@ -214,9 +216,15 @@ if "panes" in w: If `p` is a string (v1.x shorthand), `"cmd" in p` will check for substring match in the string, not a dict key. This will either silently pass (if the command doesn't contain "cmd") or incorrectly match. -### Stale TODOs: `with_env_var` and `cmd_separator` +### Verified TODOs: `with_env_var` and `cmd_separator` -Listed in the importer's docstring TODOs (`importers.py:121-123`) but neither exists in teamocil's current source (v1.4.2). These TODOs may reference features from a very old teamocil version or may be incorrect. They should be removed or verified against historical releases. +Listed in the importer's docstring TODOs (`importers.py:121-123`). Both verified as v0.x features (present in `0.4-stable` branch, removed in v1.x rewrite). `with_env_var` auto-exports `TEAMOCIL=1`; `cmd_separator` controls command joining. Since the importer targets v0.x, these are valid TODOs — but `cmd_separator` is irrelevant since tmuxp sends commands individually. + +### Missing v0.x Features: `height` and `target` + +Not mentioned in the importer TODOs but present in v0.x: +- `height` (pane): Percentage for vertical split (`split-window -p <height>`). Like `width`, silently dropped. +- `target` (pane): Target pane for split operation (`split-window -t <target>`). Not imported. ### Silent Drops @@ -242,9 +250,11 @@ Listed in the importer's docstring TODOs (`importers.py:121-123`) but neither ex | Window `focus` (v1.x) | ✗ Missing | Difference (needs add) | | Pane `focus` (v1.x) | ✗ Missing | Difference (needs add) | | Window `options` (v1.x) | ✗ Missing | Difference (needs add) | -| `with_env_var` (in importer TODO) | ✗ Missing | Unverified (not in current teamocil source) | +| `with_env_var` (v0.x) | ✗ Missing | Difference (v0.x only, can map to `environment`) | | `filters.after` → `shell_command_after` | ⚠ Imported but unused | **Limitation** | | Pane `width` (v0.x) | ⚠ Dropped silently | **Limitation** | | Window `clear` (v0.x) | ⚠ Preserved but unused | **Limitation** | -| `cmd_separator` (in importer TODO) | ✗ Missing | Unverified (not in current teamocil source) | +| `cmd_separator` (v0.x) | ✗ Missing | Difference (v0.x only, irrelevant — tmuxp sends individually) | +| `height` (v0.x pane) | ✗ Missing | **Limitation** (like `width`, no per-pane sizing) | +| `target` (v0.x pane) | ✗ Missing | **Limitation** (no split targeting) | | `--here` flag | N/A (runtime flag) | **Limitation** | diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index a81acfb1bf..6f8e91007d 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -137,8 +137,13 @@ Teamocil joins multiple pane commands with `; ` and sends them as a single `send | Pane commands | `cmd` (string or list) | `commands` (list) | | Before commands | `filters.before` (list) | (none) | | After commands | `filters.after` (list) | (none) | -| Pane width | `width` (number) | (none) | +| Pane width | `width` (number, horizontal split %) | (none) | +| Pane height | `height` (number, vertical split %) | (none) | +| Pane target | `target` (pane to split from) | (none) | | Window clear | `clear` (boolean) | (none) | +| TEAMOCIL env var | `with_env_var` (default true, exports `TEAMOCIL=1`) | (none) | +| Command separator | `cmd_separator` (default `"; "`) | Hardcoded `"; "` | +| ERB templating | Yes (layouts processed as ERB) | No | | Pane focus | (none) | `focus` (boolean) | | Window focus | (none) | `focus` (boolean) | | Window options | (none) | `options` (hash) | From a093f9e4a748784f04644d91c1f591c5b0e4f816 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:10:32 -0600 Subject: [PATCH 033/152] 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 87c069e27ccfea457ffa9a236ab07cf8899a4e13 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:23:42 -0600 Subject: [PATCH 034/152] 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 454f65091b5a475b80442cda9843af2ab8fc8511 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:23:48 -0600 Subject: [PATCH 035/152] docs(import-teamocil): Document accidental focus/target passthrough why: Test fixtures reveal that pane focus and target survive the v0.x importer through in-place dict mutation, which was undocumented. what: - Document pane focus accidentally preserved (and actually works in builder) - Document pane target accidentally preserved (but builder ignores it) - Update summary table with corrected import statuses - Separate height (truly missing) from target/focus (passthrough) --- notes/import-teamocil.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md index cfbd932bcd..33894ec0fd 100644 --- a/notes/import-teamocil.md +++ b/notes/import-teamocil.md @@ -113,13 +113,13 @@ Since teamocil 1.4.2 uses the v1.x format, the importer is outdated for current **Importer status**: ✗ Not handled. The key is not imported. -### 13. Pane Focus (v1.x) +### 13. Pane Focus (v0.x and v1.x) -| teamocil v1.x | tmuxp | +| teamocil | tmuxp | |---|---| | `focus: true` (on pane) | `focus: true` | -**Importer status**: ✗ Not handled. The key is not imported. +**Importer status**: ✓ Accidentally handled in v0.x. The importer modifies pane dicts in-place (only renaming `cmd` → `shell_command` and dropping `width`), so `focus` survives as an unhandled key that passes through. Test fixture `test3.py` and `layouts.py` confirm this. For v1.x format, pane `focus` would also survive if the pane is a dict (but not if it's a string shorthand). ### 14. Window Options (v1.x) @@ -220,11 +220,16 @@ If `p` is a string (v1.x shorthand), `"cmd" in p` will check for substring match Listed in the importer's docstring TODOs (`importers.py:121-123`). Both verified as v0.x features (present in `0.4-stable` branch, removed in v1.x rewrite). `with_env_var` auto-exports `TEAMOCIL=1`; `cmd_separator` controls command joining. Since the importer targets v0.x, these are valid TODOs — but `cmd_separator` is irrelevant since tmuxp sends commands individually. -### Missing v0.x Features: `height` and `target` +### Missing v0.x Features: `height` Not mentioned in the importer TODOs but present in v0.x: - `height` (pane): Percentage for vertical split (`split-window -p <height>`). Like `width`, silently dropped. -- `target` (pane): Target pane for split operation (`split-window -t <target>`). Not imported. + +### Accidentally Preserved: `target` and `focus` + +Through in-place dict mutation, these v0.x pane keys survive the import without explicit handling: +- `target` (pane): Preserved in output (see `layouts.py:106-108`), but tmuxp's WorkspaceBuilder ignores it. +- `focus` (pane): Preserved in output (see `layouts.py:109`, `test3.py:39`), and tmuxp's WorkspaceBuilder **does** use `focus` — so pane focus actually works correctly through the v0.x importer by accident. ### Silent Drops @@ -248,7 +253,7 @@ Not mentioned in the importer TODOs but present in v0.x: | Pane `commands` → `shell_command` (v1.x) | ✗ Missing | Difference (needs add) | | String pane shorthand (v1.x) | ✗ Missing (causes error) | Difference (needs add) | | Window `focus` (v1.x) | ✗ Missing | Difference (needs add) | -| Pane `focus` (v1.x) | ✗ Missing | Difference (needs add) | +| Pane `focus` (v0.x/v1.x) | ✓ Accidentally preserved (v0.x dict passthrough) | Difference (explicit handling needed for v1.x) | | Window `options` (v1.x) | ✗ Missing | Difference (needs add) | | `with_env_var` (v0.x) | ✗ Missing | Difference (v0.x only, can map to `environment`) | | `filters.after` → `shell_command_after` | ⚠ Imported but unused | **Limitation** | @@ -256,5 +261,5 @@ Not mentioned in the importer TODOs but present in v0.x: | Window `clear` (v0.x) | ⚠ Preserved but unused | **Limitation** | | `cmd_separator` (v0.x) | ✗ Missing | Difference (v0.x only, irrelevant — tmuxp sends individually) | | `height` (v0.x pane) | ✗ Missing | **Limitation** (like `width`, no per-pane sizing) | -| `target` (v0.x pane) | ✗ Missing | **Limitation** (no split targeting) | +| `target` (v0.x pane) | ✓ Accidentally preserved (but builder ignores it) | **Limitation** (no split targeting) | | `--here` flag | N/A (runtime flag) | **Limitation** | From 5a164d9dcfa88337f0f46b6abbcf58c10bfff567 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:23:53 -0600 Subject: [PATCH 036/152] docs(parity-teamocil): Add accidental v0.x focus/target passthrough why: Test fixtures confirm pane focus and target survive import via in-place dict mutation, correcting the "What it misses" table. what: - Add v0.x focus and target as accidentally preserved in import table - Update with_env_var and cmd_separator descriptions --- notes/parity-teamocil.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index 6f8e91007d..5c597bf979 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -180,8 +180,10 @@ Teamocil joins multiple pane commands with `; ` and sends them as a single `send | v1.x `focus` (pane) | Not imported | | v1.x `options` (window) | Not imported | | Session-level `name` (without `session:` wrapper) | Handled (uses `.get("name")`) | -| `with_env_var` (importer TODO) | Not handled — does not exist in current teamocil source | -| `cmd_separator` (importer TODO) | Not handled — does not exist in current teamocil source | +| v0.x `focus` (pane) | ✓ Accidentally preserved (in-place dict mutation keeps unhandled keys) | +| v0.x `target` (pane) | ✓ Accidentally preserved (same reason) | +| `with_env_var` (v0.x) | Not handled — silently dropped by importer | +| `cmd_separator` (v0.x) | Not handled — silently dropped by importer | ### Code Quality Issues in Importer From 78eb1f23295a4a9417c657dcfc9f44313964b969 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:35:34 -0600 Subject: [PATCH 037/152] 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 2136cb12822ae29da930d1fa32dcc274d83d5983 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:35:39 -0600 Subject: [PATCH 038/152] =?UTF-8?q?docs(import-tmuxinator):=20Document=20p?= =?UTF-8?q?re=E2=86=92before=5Fscript=20semantic=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The importer maps tmuxinator's `pre` to `shell_command_before` (per-pane), but `pre` runs once before session creation like `before_script`. This changes "run once" to "run in every pane." what: - Add three-column table showing correct vs current importer mapping - Document both bugs: wrong scope (pre alone) and invalid key (pre+pre_window) - Update summary table to reflect correct target key --- notes/import-tmuxinator.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md index b203c12757..ff22cf14f4 100644 --- a/notes/import-tmuxinator.md +++ b/notes/import-tmuxinator.md @@ -87,14 +87,17 @@ In tmuxinator, `cli_args` is deprecated in favor of `tmux_options` (`project.rb: ### 10. Pre / Pre-Window Commands -| tmuxinator | tmuxp | -|---|---| -| `pre: "cmd"` (session-level, alone) | `shell_command_before: ["cmd"]` | -| `pre_window: "cmd"` + `pre: "cmd"` | `shell_command: "cmd"` + `shell_command_before: ["cmd"]` | +| tmuxinator | tmuxp (correct) | tmuxp (current importer) | +|---|---|---| +| `pre: "cmd"` (session-level, alone) | `before_script: "cmd"` | `shell_command_before: ["cmd"]` (wrong scope) | +| `pre_window: "cmd"` | `shell_command_before: ["cmd"]` | ✓ Correct (when alone) | +| `pre: "cmd"` + `pre_window: "cmd2"` | `before_script: "cmd"` + `shell_command_before: ["cmd2"]` | `shell_command: "cmd"` (invalid key, lost) + `shell_command_before: ["cmd2"]` | -**Importer status**: ⚠ Bug (lines 59-70). When both `pre` and `pre_window` exist, the importer sets `shell_command` (not a valid tmuxp session-level key) for `pre` and `shell_command_before` for `pre_window`. The `pre` commands are silently lost. +**Importer status**: ⚠ Bug (lines 59-70). Two issues: +1. When both `pre` and `pre_window` exist, the importer sets `shell_command` (not a valid tmuxp session-level key) for `pre`. The `pre` commands are silently lost. +2. When only `pre` exists, the importer maps it to `shell_command_before` — but `pre` runs once before session creation (like `before_script`), not per-pane. This changes the semantics from "run once" to "run in every pane." -In tmuxinator, `pre` is a deprecated session-level command run once before creating windows (in `template.erb:19`, equivalent to `on_project_start`). `pre_window` is a per-pane command run before each pane's commands (in `template.erb:71-73`). These are different scopes. +In tmuxinator, `pre` is a deprecated session-level command run once before creating windows (in `template.erb:19`, inside the new-session conditional). Its deprecation message says it's replaced by `on_project_start` + `on_project_restart`. `pre_window` is a per-pane command run before each pane's commands (in `template.erb:71-73`). These are different scopes. **Correct mapping**: - `pre` → `before_script` (runs once before windows are created) @@ -219,7 +222,7 @@ These are features that cannot be imported because tmuxp lacks the underlying ca | `socket_name` | ✓ Handled | Difference | | `cli_args`/`tmux_options` → `config` | ⚠ Partial | Difference (needs fix) | | `rbenv` → `shell_command_before` | ✓ Handled | Difference | -| `pre` → `shell_command_before` | ⚠ Bug when combined with `pre_window` | Difference (needs fix) | +| `pre` → `before_script` | ⚠ Bug: maps to wrong key (`shell_command_before` alone, `shell_command` with `pre_window`) | Difference (needs fix) | | Window hash syntax | ✓ Handled | Difference | | Window `root`/`pre`/`layout`/`panes` | ✓ Handled | Difference | | `rvm` → `shell_command_before` | ✗ Missing | Difference (needs add) | From aa7f3f0aca9678b9d0512fc8802905016ec5a2c6 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 17:35:44 -0600 Subject: [PATCH 039/152] =?UTF-8?q?docs(parity-tmuxinator):=20Add=20pre?= =?UTF-8?q?=E2=86=92before=5Fscript=20scope=20bug=20to=20importer=20analys?= =?UTF-8?q?is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The pre-alone case was marked as correct but has a semantic bug — tmuxinator's pre runs once, but mapping to shell_command_before runs it in every pane. what: - Mark pre-alone mapping as wrong scope in import behavior table - Expand code quality issue #1 to document both pre bugs - Update "misses" table with combined bug description --- notes/parity-tmuxinator.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md index cbdb27397c..fe3c171226 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -197,8 +197,8 @@ Creates a config file pre-populated from a running tmux session. Note: tmuxinato | `cli_args` / `tmux_options` | `config` (extracts `-f`) | ⚠ Only handles `-f` flag, ignores `-L`, `-S` | | `socket_name` | `socket_name` | ✓ Correct | | `tabs` → `windows` | `windows` | ✓ Correct | -| `pre` + `pre_window` | `shell_command` + `shell_command_before` | ⚠ `shell_command` is not a valid tmuxp key | -| `pre` (alone) | `shell_command_before` | ✓ Correct | +| `pre` + `pre_window` | `shell_command` + `shell_command_before` | ⚠ `shell_command` is not a valid tmuxp session key; should be `before_script` + `shell_command_before` | +| `pre` (alone) | `shell_command_before` | ⚠ Wrong scope: `pre` runs once (like `before_script`), not per-pane | | `rbenv` | appended to `shell_command_before` | ✓ Correct | | Window hash key | `window_name` | ✓ Correct | | Window `pre` | `shell_command_before` | ✓ Correct | @@ -229,13 +229,15 @@ Creates a config file pre-populated from a running tmux session. Note: tmuxinato | `on_project_stop` | Not imported. tmuxp has no equivalent. | | Named panes (hash syntax) | Not imported. Pane names/titles are lost. | | ERB templating | Not handled. YAML parsing will fail on ERB syntax. | -| `pre` + `pre_window` combo | Bug: sets `shell_command` which is not a tmuxp session-level key | +| `pre` mapping | Bug: maps to `shell_command_before` (per-pane) instead of `before_script` (once); combo with `pre_window` uses invalid `shell_command` key | ### Code Quality Issues in Importer -1. **Line 60**: When both `pre` and `pre_window` exist, the importer sets `tmuxp_workspace["shell_command"]` — but `shell_command` is not a valid session-level tmuxp key. The `pre` commands would be silently ignored. +1. **Lines 59-70 (`pre` handling)**: Two bugs: + - When both `pre` and `pre_window` exist (line 60), the importer sets `tmuxp_workspace["shell_command"]` — but `shell_command` is not a valid session-level tmuxp key. The `pre` commands are silently lost. + - When only `pre` exists (line 68), it maps to `shell_command_before` — but tmuxinator's `pre` runs *once* before session creation (`template.erb:19`), not per-pane. The correct mapping is `before_script`. -2. **Line 36-49**: The `cli_args`/`tmux_options` handler only extracts `-f` (config file). It ignores `-L` (socket name) and `-S` (socket path) which could also appear in these fields. +2. **Lines 36-49**: The `cli_args`/`tmux_options` handler only extracts `-f` (config file). It ignores `-L` (socket name) and `-S` (socket path) which could also appear in these fields. 3. **Line 79-101**: The window iteration uses `for k, v in window_dict.items()` which assumes windows are always dicts with a single key (the window name). This is correct for tmuxinator's format but fragile — if a window dict has multiple keys, only the last one is processed. From 172c2da5b06eb61faa93ce6a1c6264d36eb2f960 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 18:23:03 -0600 Subject: [PATCH 040/152] =?UTF-8?q?docs(plan):=20Add=20solo=20pre=E2=86=92?= =?UTF-8?q?before=5Fscript=20scope=20bug=20and=20update=20I1=20analysis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Fresh parity analysis (2026-03-06) found a second bug in the tmuxinator pre key mapping not previously documented in the plan. what: - Add Bug A: solo pre maps to shell_command_before (per-pane) instead of before_script (session-level) - Clarify existing Bug B with dead config keys table cross-reference - Document before_script shell limitation (no shell=True in Popen) - Update plan date to 2026-03-06 --- notes/plan.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 258e61d294..3cb2a0f352 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -1,6 +1,6 @@ # Parity Implementation Plan -*Last updated: 2026-02-08* +*Last updated: 2026-03-06* *Based on: parity-tmuxinator.md, parity-teamocil.md, import-tmuxinator.md, import-teamocil.md* ## libtmux Limitations @@ -140,7 +140,7 @@ Keys produced by importers but silently ignored by the builder: | Key | Producer | Importer Line | Builder Handling | Issue | |---|---|---|---|---| -| `shell_command` (session-level) | tmuxinator importer | `importers.py:60` | Not a valid session key | **Bug**: `pre` commands lost when both `pre` and `pre_window` exist | +| `shell_command` (session-level) | tmuxinator importer | `importers.py:60` | Not a valid session key | **Bug** (I1 Bug B): `pre` commands lost when both `pre` and `pre_window` exist | | `config` | tmuxinator importer | `importers.py:37,44` | Never read | Dead data — extracted `-f` path goes nowhere | | `socket_name` | tmuxinator importer | `importers.py:52` | Never read | Dead data — CLI uses `-L` flag | | `clear` | teamocil importer | `importers.py:141` | Never read | Dead data — tmuxp has no clear support | @@ -148,13 +148,29 @@ Keys produced by importers but silently ignored by the builder: ## Importer Bugs (No Builder Changes Needed) -### I1. tmuxinator `pre` + `pre_window` Mapping Bug +### I1. tmuxinator `pre` / `pre_window` Mapping Bugs + +Two bugs in `importers.py:59-70`, covering both code paths for the `pre` key: + +#### Bug A: Solo `pre` maps to wrong key (NEW — 2026-03-06) + +- **Bug**: When only `pre` exists (no `pre_window`) (`importers.py:66-70`), it maps to `shell_command_before` — a per-pane key that runs before each pane's commands. But tmuxinator's `pre` is a session-level hook that runs **once** before any windows are created. The correct target is `before_script`. +- **Effect**: Instead of running once at session start, the `pre` commands run N times (once per pane) as pane setup commands. This changes both the semantics (pre-session → per-pane) and the execution count. + +#### Bug B: Combo `pre` + `pre_window` loses `pre` commands - **Bug**: When both `pre` and `pre_window` exist (`importers.py:59-65`): - 1. `pre` maps to `shell_command` (line 60) — invalid session-level key, silently ignored by builder. The `pre` commands are lost. + 1. `pre` maps to `shell_command` (line 60) — invalid session-level key, silently ignored by builder. The `pre` commands are lost entirely (see Dead Config Keys table). 2. The `isinstance` check on line 62 tests `workspace_dict["pre"]` type to decide how to wrap `workspace_dict["pre_window"]` — it should check `pre_window`'s type, not `pre`'s. If `pre` is a list but `pre_window` is a string, `pre_window` won't be wrapped in a list. -- **Correct mapping**: `pre` → `before_script` (session-level, runs once before windows). `pre_window` → `shell_command_before` (per-pane). -- **Note**: `before_script` expects a file path or command (executed via `subprocess.Popen` after `shlex.split()` in `util.py:27-32`), not inline shell commands. For inline commands, either write a temp script, or add an `on_project_start` config key (T6). + +#### Correct mapping + +- `pre` → `before_script` (session-level, runs once before windows) +- `pre_window` → `shell_command_before` (per-pane, runs before each pane's commands) + +#### `before_script` shell limitation + +`before_script` is executed via `subprocess.Popen` after `shlex.split()` in `util.py:27-32` — **without `shell=True`**. This means shell constructs (pipes `|`, `&&`, redirects `>`, subshells `$(...)`) won't work in `before_script` values. For inline shell commands, the forward path is the `on_project_start` config key (T6), which would use `shell=True` or write a temp script. ### I2. tmuxinator `cli_args` / `tmux_options` Fragile Parsing From ad7058fc3f2618996c5655d6a226d4bbefc60d07 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 18:34:40 -0600 Subject: [PATCH 041/152] docs(plan): Expand L4 APIs, fix I7 stale claim, add height/with_env_var gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Fresh /check:shortcomings pass found libtmux has more APIs than documented (Pane.clear, Pane.split target, Session.set_environment), I7 incorrectly called with_env_var/cmd_separator "stale", and teamocil height pane key was undocumented as a dead key. what: - Add 5 libtmux APIs to L4 table (set_hooks, set_environment, clear, reset, split target) - Fix I7: with_env_var/cmd_separator are verified v0.x features, not stale — triage instead of remove - Add with_env_var and height to I6 as v0.x translatable keys - Add height to dead config keys table (not popped like width) - Update clear dead key note to reference Pane.clear() in libtmux - Update I4 to cover height alongside width --- notes/plan.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 3cb2a0f352..afdf78b3e4 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -45,6 +45,11 @@ These libtmux APIs already exist and do NOT need changes: | `Session.set_hook(hook, cmd)` | `hooks.py:111` (HooksMixin) | Lifecycle hooks (`client-detached`, etc.) | | `Session.set_option(key, val)` | `options.py:578` (OptionsMixin) | `pane-border-status`, `pane-border-format` | | `HooksMixin` on Session/Window/Pane | `session.py:55`, `window.py:56`, `pane.py:51` | All entities inherit hooks | +| `HooksMixin.set_hooks()` (bulk) | `hooks.py:430` | Efficient multi-hook setup (dict/list input) | +| `Session.set_environment(key, val)` | `session.py:53` (EnvironmentMixin) | Session-level env vars (teamocil `with_env_var`) | +| `Pane.clear()` | `pane.py:818` | Sends `reset` to clear pane (teamocil `clear`) | +| `Pane.reset()` | `pane.py:823` | `send-keys -R \; clear-history` (full reset) | +| `Pane.split(target=...)` | `pane.py:625` | Split targeting (teamocil v0.x `target`) | ## tmuxp Limitations @@ -143,7 +148,8 @@ Keys produced by importers but silently ignored by the builder: | `shell_command` (session-level) | tmuxinator importer | `importers.py:60` | Not a valid session key | **Bug** (I1 Bug B): `pre` commands lost when both `pre` and `pre_window` exist | | `config` | tmuxinator importer | `importers.py:37,44` | Never read | Dead data — extracted `-f` path goes nowhere | | `socket_name` | tmuxinator importer | `importers.py:52` | Never read | Dead data — CLI uses `-L` flag | -| `clear` | teamocil importer | `importers.py:141` | Never read | Dead data — tmuxp has no clear support | +| `clear` | teamocil importer | `importers.py:141` | Never read | Dead data — builder doesn't read it, but libtmux has `Pane.clear()` (L4) | +| `height` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — `width` is popped but `height` passes through silently | | `shell_command_after` | teamocil importer | `importers.py:149` | Never read | Dead data — tmuxp has no after-command support | ## Importer Bugs (No Builder Changes Needed) @@ -186,7 +192,7 @@ Two bugs in `importers.py:59-70`, covering both code paths for the `pre` key: - **Bug**: Importer assumes v0.x format. String panes cause incorrect behavior (`"cmd" in "git status"` checks substring, not dict key). `commands` key (v1.x) not mapped. - **Fix**: Add format detection. Handle string panes, `commands` key, `focus`, and `options`. -- **Also**: v0.x pane `width` is silently dropped (`importers.py:161-163`) with a TODO but no user warning. Since libtmux's `Pane.resize()` exists (L4), the importer could preserve `width` and the builder could call `pane.resize(width=value)` after split. Alternatively, warn the user that width is not supported. +- **Also**: v0.x pane `width` is silently dropped (`importers.py:161-163`) with a TODO but no user warning. `height` is not even popped — it passes through as a dead key. Since libtmux's `Pane.resize()` exists (L4), the importer could preserve both `width` and `height` and the builder could call `pane.resize(width=value)` or `pane.resize(height=value)` after split. Alternatively, warn the user. ### I5. tmuxinator Missing Keys @@ -199,18 +205,28 @@ Not imported but translatable: - `socket_path` → warn user to use CLI `-S` flag - `attach: false` → warn user to use CLI `-d` flag -### I6. teamocil Missing Keys (v1.x) +### I6. teamocil Missing Keys -Not imported but translatable (same key names in tmuxp): +Not imported but translatable: + +**v1.x keys** (same key names in tmuxp): - `commands` → `shell_command` - `focus` (window) → `focus` (pass-through) - `focus` (pane) → `focus` (pass-through) - `options` (window) → `options` (pass-through) - String pane shorthand → `shell_command: [command]` -### I7. Stale Importer TODOs +**v0.x keys**: +- `with_env_var` → `environment: { TEAMOCIL: "1" }` (default `true` in v0.x; maps to session-level `environment` key) +- `height` (pane) → should be popped like `width` (currently passes through as dead key) + +### I7. Importer TODOs Need Triage + +`importers.py:121,123` lists `with_env_var` and `cmd_separator` as TODOs (with `clear` at line 122 in between). Both are verified v0.x features (present in teamocil's `0.4-stable` branch at `lib/teamocil/layout/window.rb`), not stale references: -`importers.py:121,123` lists `with_env_var` and `cmd_separator` as TODOs (with `clear` at line 122 in between), but neither `with_env_var` nor `cmd_separator` exists in teamocil v1.4.2 source. These are stale references from ~2013 and should be removed. +- **`with_env_var`** (line 121): When `true` (the default in v0.x), exports `TEAMOCIL=1` in each pane. Should map to `environment: { TEAMOCIL: "1" }` (tmuxp's `environment` key works at session level via `Session.set_environment()`, L4). Implement, don't remove. +- **`clear`** (line 122): Already imported at line 141 but builder ignores it. libtmux has `Pane.clear()` (L4), so builder support is feasible. +- **`cmd_separator`** (line 123): Per-window string (default `"; "`) used to join commands before `send-keys`. Irrelevant for tmuxp since it sends commands individually. Remove TODO. ## Implementation Priority @@ -224,7 +240,7 @@ These fix existing bugs and add missing translations without touching the builde 4. **I5**: Import missing tmuxinator keys (`rvm`, `pre_tab`, `startup_window`, `startup_pane`) 5. **I1**: Fix `pre`/`pre_window` mapping (tmuxinator) 6. **I2**: Fix `cli_args` parsing (tmuxinator) -7. **I7**: Remove stale TODOs +7. **I7**: Triage importer TODOs (implement `with_env_var`, remove `cmd_separator`) ### Phase 2: Builder Additions (tmuxp Only) From 05404a800927352860ca0a388301c6de6ac3f32c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Fri, 6 Mar 2026 18:45:04 -0600 Subject: [PATCH 042/152] docs(plan): Add missing post and target keys from parity cross-reference why: Systematic cross-reference of all parity summary tables against plan found two items not yet documented. what: - Add tmuxinator post to I5 missing keys (deprecated on_project_exit) - Add teamocil target to dead config keys table (passthrough, builder ignores, but libtmux Pane.split(target=...) exists) --- notes/plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/notes/plan.md b/notes/plan.md index afdf78b3e4..28f472a5f0 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -150,6 +150,7 @@ Keys produced by importers but silently ignored by the builder: | `socket_name` | tmuxinator importer | `importers.py:52` | Never read | Dead data — CLI uses `-L` flag | | `clear` | teamocil importer | `importers.py:141` | Never read | Dead data — builder doesn't read it, but libtmux has `Pane.clear()` (L4) | | `height` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — `width` is popped but `height` passes through silently | +| `target` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — accidentally preserved via dict mutation, but libtmux has `Pane.split(target=...)` (L4) | | `shell_command_after` | teamocil importer | `importers.py:149` | Never read | Dead data — tmuxp has no after-command support | ## Importer Bugs (No Builder Changes Needed) @@ -202,6 +203,7 @@ Not imported but translatable: - `startup_window` → find matching window, set `focus: true` - `startup_pane` → find matching pane, set `focus: true` - `on_project_first_start` → `before_script` (only if value is a single command or script path; multi-command strings joined by `;` won't work since `before_script` uses `Popen` without `shell=True`) +- `post` → deprecated predecessor to `on_project_exit`; runs after windows are built on every invocation. No tmuxp equivalent until T6 lifecycle hooks exist. - `socket_path` → warn user to use CLI `-S` flag - `attach: false` → warn user to use CLI `-d` flag From fb045f3871428cdb0d498a7a5a12c5cba0ab070d Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 04:26:34 -0600 Subject: [PATCH 043/152] 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 81c02918b9360298e2d50ed5e2323d481776ebd7 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 04:26:39 -0600 Subject: [PATCH 044/152] docs(parity-tmuxinator): Add synchronize deprecation and pane_title_format default why: tmuxinator source confirms synchronize true/before is deprecated. what: - Note synchronize true/before deprecated in project.rb:21-29 - Add context that import should still honor original semantics per value - Document pane_title_format default and pane_title_position default --- notes/parity-tmuxinator.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md index fe3c171226..d9692c0572 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -1,6 +1,6 @@ # Tmuxinator Parity Analysis -*Last updated: 2026-03-06* +*Last updated: 2026-03-07* *Tmuxinator version analyzed: 3.3.7 (supports tmux 1.5–3.6a)* *tmuxp version: 1.64.0* @@ -73,8 +73,10 @@ windows: - vim ``` -- `synchronize: true` / `synchronize: before` — enable pane sync before running pane commands -- `synchronize: after` — enable pane sync after running pane commands +- `synchronize: true` / `synchronize: before` — enable pane sync before running pane commands (**deprecated** in tmuxinator `project.rb:21-29`) +- `synchronize: after` — enable pane sync after running pane commands (recommended) + +Note: tmuxinator deprecates `synchronize: true` and `synchronize: before` in favor of `synchronize: after`. The deprecation message says `before` was the original behavior but `after` is the recommended pattern. Import should still honor the original semantics of each value. **Gap**: tmuxp has no `synchronize` config key. Users would need to set `synchronize-panes on` via `options` manually, but this doesn't support the before/after distinction. @@ -86,8 +88,8 @@ windows: ```yaml enable_pane_titles: true -pane_title_position: top -pane_title_format: "#{pane_index}: #{pane_title}" +pane_title_position: top # default: "top" +pane_title_format: "#{pane_index}: #{pane_title}" # this is the default format windows: - editor: panes: From 311c9fbfefc6bb675ebdff4ee24075db1e9ab33c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 04:26:44 -0600 Subject: [PATCH 045/152] docs(parity-teamocil): Add v1.0 rewrite context from README why: teamocil agent confirmed v1.0 explicitly dropped v0.x features. what: - Add paragraph explaining v1.0 rewrite dropped hooks, env vars, DSL - Provides context for why v0.x import has more keys than v1.x --- notes/parity-teamocil.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index 5c597bf979..7378fba806 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -1,6 +1,6 @@ # Teamocil Parity Analysis -*Last updated: 2026-03-06* +*Last updated: 2026-03-07* *Teamocil version analyzed: 1.4.2* *tmuxp version: 1.64.0* @@ -15,6 +15,8 @@ The current tmuxp importer (`importers.py:import_teamocil`) **targets the v0.x f Note: teamocil v1.x does not create new sessions — it **renames** the current session (`rename-session`) and adds windows to it. This is fundamentally different from tmuxp/tmuxinator which create fresh sessions. +**v1.0 rewrite context** (from teamocil README): Teamocil 1.0 was a complete rewrite that explicitly dropped several v0.x features: no hook system (pre/post execution scripts), no pane-specific environment variables (`with_env_var`), no inline scripting or complex DSL, and no `cmd_separator` customization. The focus narrowed to core declarative window/pane creation. + ## Features teamocil has that tmuxp lacks ### 1. Session Rename (Not Create) From 9efe080b64827e80306e95b4c66d6fc4533fac43 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 04:26:49 -0600 Subject: [PATCH 046/152] docs(import-tmuxinator): Note synchronize deprecation in summary table why: Deprecation context helps prioritize import implementation. what: - Add deprecation note to synchronize row in summary table --- notes/import-tmuxinator.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md index ff22cf14f4..921376beee 100644 --- a/notes/import-tmuxinator.md +++ b/notes/import-tmuxinator.md @@ -1,6 +1,6 @@ # Tmuxinator Import Behavior -*Last updated: 2026-03-06* +*Last updated: 2026-03-07* *Importer: `src/tmuxp/workspace/importers.py:import_tmuxinator`* ## Syntax Differences (Translatable) @@ -232,7 +232,7 @@ These are features that cannot be imported because tmuxp lacks the underlying ca | `socket_path` | ✗ Missing | Difference (needs add) | | `attach: false` | ✗ Missing | Difference (needs add) | | `on_project_*` hooks | ✗ Missing | **Limitation** | -| `synchronize` | ✗ Missing | **Limitation** | +| `synchronize` | ✗ Missing (`true`/`before` deprecated in tmuxinator → `after` recommended) | **Limitation** | | `enable_pane_titles` / titles | ✗ Missing | **Limitation** | | ERB templating | ✗ Missing | **Limitation** | | `tmux_command` (wemux) | ✗ Missing | **Limitation** | From 357960d42db5a904894b8d930eeec20058aa2f98 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 04:26:53 -0600 Subject: [PATCH 047/152] docs(import-teamocil): Update date after source verification why: Cross-referenced against teamocil source, no corrections needed. what: - Update last-updated date to 2026-03-07 --- notes/import-teamocil.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md index 33894ec0fd..3b1f4af697 100644 --- a/notes/import-teamocil.md +++ b/notes/import-teamocil.md @@ -1,6 +1,6 @@ # Teamocil Import Behavior -*Last updated: 2026-03-06* +*Last updated: 2026-03-07* *Importer: `src/tmuxp/workspace/importers.py:import_teamocil`* ## Format Detection Problem From 4d95da09973377da8b2970aea2aa2acb4ac75f94 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 04:26:58 -0600 Subject: [PATCH 048/152] docs(plan): Add synchronize deprecation context to T1 why: Deprecation affects import behavior and priority decisions. what: - Note tmuxinator deprecates true/before in favor of after - Clarify that import should still honor original semantics per value --- notes/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 28f472a5f0..0c15ada86f 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -1,6 +1,6 @@ # Parity Implementation Plan -*Last updated: 2026-03-06* +*Last updated: 2026-03-07* *Based on: parity-tmuxinator.md, parity-teamocil.md, import-tmuxinator.md, import-teamocil.md* ## libtmux Limitations @@ -56,7 +56,7 @@ These libtmux APIs already exist and do NOT need changes: ### T1. No `synchronize` Config Key - **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. The key is silently ignored if present. -- **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). +- **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). Note: tmuxinator deprecates `true`/`before` in favor of `after` (`project.rb:21-29`), but all three values still function. The import should honor original semantics of each value. - **Required**: Add `synchronize` handling in `builder.py`. For `before`/`true`: call `window.set_option("synchronize-panes", "on")` before pane commands are sent. For `after`: call it in `config_after_window()`. For `false`/omitted: no action. - **Insertion point**: In `build()` around line 320 (after `on_window_create` plugin hook, before `iter_create_panes()` loop) for `before`/`true`. In `config_after_window()` around line 565 for `after`. Note: in tmux 3.2+ (tmuxp's minimum), `synchronize-panes` is a dual-scope option (window|pane, `options-table.c:1423`). Setting it at window level via `window.set_option()` makes all panes inherit it, including those created later by split. - **Non-breaking**: New optional config key. Existing configs are unaffected. From a59bcbdd0a0147ce7b5f83f73d953fcfb4ab2faa Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 04:43:06 -0600 Subject: [PATCH 049/152] docs(plan): Fix accuracy issues from issue #1016 review why: Cross-referencing issue #1016 against source code found 2 inaccuracies. what: - I1 Bug B: Add double-wrapping consequence when pre/pre_window types mismatch - T4: Clarify teamocil always renames session regardless of --here flag --- notes/plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 0c15ada86f..d312e6b1ab 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -80,8 +80,8 @@ These libtmux APIs already exist and do NOT need changes: ### T4. No Session Rename Mode / `--here` CLI Flag -- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 406). Additionally, teamocil's session rename mode (rename current session instead of creating new) is partially covered by tmuxp's `--append` flag, but `--append` does not rename the session. -- **Blocks**: teamocil `--here` (reuse current window for first window) and teamocil session rename mode. +- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 406). Additionally, teamocil always renames the current session (`session.rb:18-20`), regardless of `--here`; the `--here` flag only affects **window** behavior (reuse current window for first window instead of creating new). tmuxp's `--append` flag partially covers session rename mode, but does not rename the session. +- **Blocks**: teamocil `--here` (reuse current window for first window) and teamocil session rename (always active, not conditional on `--here`). - **Required**: 1. Add `--here` flag to `cli/load.py` (around line 516, near `--append`). 2. Pass `here=True` through to `WorkspaceBuilder.build()`. @@ -168,7 +168,7 @@ Two bugs in `importers.py:59-70`, covering both code paths for the `pre` key: - **Bug**: When both `pre` and `pre_window` exist (`importers.py:59-65`): 1. `pre` maps to `shell_command` (line 60) — invalid session-level key, silently ignored by builder. The `pre` commands are lost entirely (see Dead Config Keys table). - 2. The `isinstance` check on line 62 tests `workspace_dict["pre"]` type to decide how to wrap `workspace_dict["pre_window"]` — it should check `pre_window`'s type, not `pre`'s. If `pre` is a list but `pre_window` is a string, `pre_window` won't be wrapped in a list. + 2. The `isinstance` check on line 62 tests `workspace_dict["pre"]` type to decide how to wrap `workspace_dict["pre_window"]` — it should check `pre_window`'s type, not `pre`'s. When `pre` is a string but `pre_window` is a list, `pre_window` gets double-wrapped as `[["cmd1", "cmd2"]]` (nested list). When `pre` is a list but `pre_window` is a string, `pre_window` won't be wrapped in a list — leaving a bare string where a list is expected. #### Correct mapping From f96003093ff28839784d004a0db465ff1ea9ace1 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 05:03:19 -0600 Subject: [PATCH 050/152] docs(parity-tmuxinator): Fix fallback order, add missing CLI details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Second parity pass found inaccurate fallback chain order and missing CLI commands/flags. what: - Fix pre_window fallback chain: pre_window → pre_tab → rbenv → rvm (was reversed) - Add doctor command and completions to commands table - Add start --append, --no-pre-window, --project-config flags - Add list --active, --newline flags --- notes/parity-tmuxinator.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md index d9692c0572..bd1f0ce3de 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -157,8 +157,20 @@ Outputs the generated shell script without executing it. | `tmuxinator implode` | Delete ALL configs | | `tmuxinator stop <project>` | Stop session + run hooks | | `tmuxinator stop-all` | Stop all managed sessions | +| `tmuxinator doctor` | Check system setup (tmux installed, version) | +| `tmuxinator completions` | Shell completion helper | -**Gap**: tmuxp has `edit` but not `new`, `copy`, `delete`, `implode`, or `stop` commands. +**Gap**: tmuxp has `edit` but not `new`, `copy`, `delete`, `implode`, `stop`, or `doctor` commands. + +Additional CLI flags on `start`: +- `--append` — append windows to existing session (tmuxp has `--append`) +- `--no-pre-window` — skip pre_window commands (tmuxp lacks this) +- `--project-config` / `-p` — use specific config file (tmuxp uses positional arg) +- `--suppress-tmux-version-warning` — skip tmux version check + +Additional flags on `list`: +- `--active` / `-a` — filter by active sessions (tmuxp lacks this) +- `--newline` / `-n` — one entry per line ### 11. `--no-pre-window` Flag @@ -170,7 +182,7 @@ tmuxinator start myproject --no-pre-window Skips `pre_window` commands. Useful for debugging. -Note: tmuxinator's `pre_window` method has a fallback chain (`project.rb:175-188`): `rbenv` → `rvm` → `pre_tab` → `pre_window`. The `--no-pre-window` flag disables all of these, not just `pre_window`. +Note: tmuxinator's `pre_window` method has a fallback chain (`project.rb:175-186`): `pre_window` → `pre_tab` → `rbenv` → `rvm` (highest priority first, using Ruby's `||` operator). The `--no-pre-window` flag disables all of these, not just `pre_window`. **Gap**: tmuxp has no equivalent flag to skip `shell_command_before`. From ef3989142750387d671823ce21c51b3e644b3b08 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 05:03:26 -0600 Subject: [PATCH 051/152] docs(parity-teamocil): Add layout-per-pane behavior and path expansion why: Second parity pass found undocumented behavioral differences. what: - Add section 8: teamocil applies layout after each pane split (vs tmuxp once at end) - Add section 10: root path expansion comparison (no gap, both tools expand) - Renumber existing sections 8-9 to 9-10 --- notes/parity-teamocil.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index 7378fba806..1d590bdee0 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -115,7 +115,15 @@ Maps to `set-window-option -t <window> <key> <value>`. **Gap**: tmuxp **does** support `options` on windows. **No gap**. -### 8. Multiple Commands Joined by Semicolon +### 8. Layout Applied After Each Pane + +**Source**: `lib/teamocil/tmux/pane.rb:9` + +teamocil applies `select-layout` after EACH pane is created (not once at the end). This means the layout is re-applied after every `split-window`, keeping panes evenly distributed as they're added. tmuxp applies layout once after all panes are created (`builder.py:511`). + +**Gap**: This is a behavioral difference. For named layouts (`main-vertical`, `tiled`, etc.) the result is the same. For custom layout strings, the timing matters — tmuxp's approach is correct for custom strings since the string encodes the final geometry. **No action needed** — tmuxp's behavior is actually better for custom layouts. + +### 9. Multiple Commands Joined by Semicolon **Source**: `lib/teamocil/tmux/pane.rb` @@ -128,6 +136,14 @@ Teamocil joins multiple pane commands with `; ` and sends them as a single `send **Gap**: tmuxp sends each command separately via individual `pane.send_keys()` calls. This is actually more reliable (each command gets its own Enter press), so this is a **behavioral difference** rather than a gap. +### 10. Root Path Expansion + +**Source**: `lib/teamocil/tmux/window.rb:8` + +teamocil expands `root` to absolute path via `File.expand_path(root)` at window initialization. This resolves `~` and relative paths before passing to `new-window -c`. + +**Gap**: tmuxp also does this via `expandshell()` in `loader.py` (`os.path.expandvars(os.path.expanduser(value))`). **No gap** — both tools expand paths. + ## v0.x vs v1.x Format Differences | Feature | v0.x | v1.x (current) | From 07b33d05e46457d15672c69cfe3708647c41cf08 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 05:11:14 -0600 Subject: [PATCH 052/152] docs(plan): Add test coverage gaps section from fixture analysis why: Third parity pass found importer test fixtures cover only ~40% of real-world patterns. what: - Add Tier 1 crash risks: v1.x teamocil string panes, commands key, rvm - Add Tier 2 missing coverage: YAML aliases, emoji names, pane titles, etc. - List required new fixtures for Phase 1 implementation --- notes/plan.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/notes/plan.md b/notes/plan.md index d312e6b1ab..ef5023999f 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -230,6 +230,35 @@ Not imported but translatable: - **`clear`** (line 122): Already imported at line 141 but builder ignores it. libtmux has `Pane.clear()` (L4), so builder support is feasible. - **`cmd_separator`** (line 123): Per-window string (default `"; "`) used to join commands before `send-keys`. Irrelevant for tmuxp since it sends commands individually. Remove TODO. +## Test Coverage Gaps + +Current importer test fixtures cover ~40% of real-world config patterns. Key gaps by severity: + +### Tier 1: Will Crash or Silently Lose Data + +- **v1.x teamocil string panes**: `panes: ["git status"]` → `TypeError` (importer tries `"cmd" in p` on string) +- **v1.x teamocil `commands` key**: `commands: [...]` → silently dropped (only `cmd` recognized) +- **tmuxinator `rvm`**: Completely ignored by importer (only `rbenv` handled) +- **tmuxinator `pre` scope bug**: Tests pass because fixtures don't verify execution semantics + +### Tier 2: Missing Coverage + +- **YAML aliases/anchors**: Real tmuxinator configs use `&defaults` / `*defaults` — no test coverage +- **Numeric/emoji window names**: `222:`, `true:`, `🍩:` — YAML type coercion edge cases untested +- **Pane title syntax**: `pane_name: command` dict form — no fixtures +- **`startup_window`/`startup_pane`**: Not tested +- **`pre_tab`** (deprecated): Not tested +- **Window-level `root` with relative paths**: Not tested +- **`tmux_options` with non-`-f` flags**: Not tested (importer bug I2) + +### Required New Fixtures + +When implementing Phase 1 import fixes, each item needs corresponding test fixtures. See `tests/fixtures/import_tmuxinator/` and `tests/fixtures/import_teamocil/` for existing patterns. + +**tmuxinator fixtures needed**: YAML aliases, emoji names, numeric names, `rvm`, `pre_tab`, `startup_window`/`startup_pane`, pane titles, `socket_path`, multi-flag `tmux_options` + +**teamocil fixtures needed**: v1.x format (`commands`, string panes, window `focus`/`options`), pane `height`, `with_env_var`, mixed v0.x/v1.x detection + ## Implementation Priority ### Phase 1: Import Fixes (No Builder/libtmux Changes) From 00da754da143997b485bf4a8dbd5b5fe0269450f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 05:11:19 -0600 Subject: [PATCH 053/152] docs(import-tmuxinator): Add YAML aliases and numeric/emoji name findings why: Third parity pass found untested edge cases from tmuxinator spec fixtures. what: - Add section 18: YAML aliases/anchors (transparent, but untested) - Add section 19: numeric/emoji window names (YAML type coercion risk) - Update summary table with new entries --- notes/import-tmuxinator.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md index 921376beee..d5c6b9e576 100644 --- a/notes/import-tmuxinator.md +++ b/notes/import-tmuxinator.md @@ -160,6 +160,22 @@ In tmuxinator, `pre` is a deprecated session-level command run once before creat **Importer status**: ✗ Not handled. Could add a comment or warning suggesting `-d` flag. +### 18. YAML Aliases/Anchors + +| tmuxinator | tmuxp | +|---|---| +| `defaults: &defaults` + `<<: *defaults` | Same (YAML 1.1 feature) | + +**Importer status**: ✓ Handled transparently. YAML aliases are resolved by the YAML parser before the importer sees the dict. No special handling needed. However, tmuxp's test fixtures have **no coverage** of this pattern — real tmuxinator configs commonly use anchors to DRY up repeated settings (see `tmuxinator/spec/fixtures/sample_alias.yml`). + +### 19. Numeric/Emoji Window Names + +| tmuxinator | tmuxp | +|---|---| +| `- 222:` or `- true:` or `- 🍩:` | `window_name: "222"` or `window_name: "True"` or `window_name: "🍩"` | + +**Importer status**: ⚠ Potentially handled but **untested**. YAML parsers coerce bare `222` to int and `true` to bool. tmuxinator handles this via Ruby's `.to_s` method. The importer iterates `window_dict.items()` (line 80) which will produce `(222, ...)` or `(True, ...)` — the `window_name` will be an int/bool, not a string. tmuxp's builder may or may not handle non-string window names correctly. Needs test coverage. + ## Limitations (tmuxp Needs to Add Support) These are features that cannot be imported because tmuxp lacks the underlying capability. @@ -231,6 +247,8 @@ These are features that cannot be imported because tmuxp lacks the underlying ca | `startup_pane` → `focus` | ✗ Missing | Difference (needs add) | | `socket_path` | ✗ Missing | Difference (needs add) | | `attach: false` | ✗ Missing | Difference (needs add) | +| YAML aliases/anchors | ✓ Transparent (YAML parser resolves) | No action needed | +| Numeric/emoji window names | ⚠ Untested (YAML type coercion risk) | Difference (needs tests) | | `on_project_*` hooks | ✗ Missing | **Limitation** | | `synchronize` | ✗ Missing (`true`/`before` deprecated in tmuxinator → `after` recommended) | **Limitation** | | `enable_pane_titles` / titles | ✗ Missing | **Limitation** | From 22d45b3d7452e048fcca3553d1999b709f0e9f21 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:09:52 -0600 Subject: [PATCH 054/152] ai(claude[command]): Add /check:audit parity audit command why: Parity work (issue #1016) spans many files; need idempotent way to assess progress across deliverables, importers, DX, and tests. what: - Add .claude/commands/check/audit.md slash command - 6 sub-agent dimensions: deliverables, tmuxinator parity, teamocil parity, DX happiness, pytest conventions, test coverage - Synthesizes status summary with prioritized TODO list --- .claude/commands/check/audit.md | 241 ++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 .claude/commands/check/audit.md diff --git a/.claude/commands/check/audit.md b/.claude/commands/check/audit.md new file mode 100644 index 0000000000..7c36e9cf39 --- /dev/null +++ b/.claude/commands/check/audit.md @@ -0,0 +1,241 @@ +--- +description: Idempotent parity audit — checks deliverables, tmuxinator/teamocil parity, DX, pytest conventions, and test coverage +--- + +# /check:audit — Parity Audit + +Idempotent status check for tmuxinator/teamocil parity work (issue #1016). Launches sub-agents to assess 6 dimensions, then synthesizes a status report with prioritized TODOs. + +## Workflow + +### Batch 1: Launch 3 Explore agents in parallel + +#### Agent 1: Deliverable Check + +Read these files and check each item: + +- `src/tmuxp/workspace/importers.py` +- `src/tmuxp/workspace/builder.py` (search for `shell_command_after`, `synchronize`) +- `src/tmuxp/cli/load.py` (search for `--here`, `here`) +- `src/tmuxp/cli/__init__.py` (search for `stop`, `new`, `copy`, `delete`) + +**Checklist** (mark ✅ done, ❌ missing, 🔧 partially done): + +| ID | Item | What to check | +|----|------|---------------| +| I1 | `pre`/`pre_window` mapping | `pre` → `before_script` (not `shell_command`/`shell_command_before`). `pre_window` → `shell_command_before` | +| I2 | `cli_args`/`tmux_options` parsing | Uses `shlex.split()` token iteration, not `.replace("-f", "")` | +| I3 | Filter loop fix | Direct assignment with truthiness guard, not `for _b in` loop | +| I4 | v1.x teamocil format | String panes and `commands` key handled | +| I5 | Missing tmuxinator keys | `rvm`, `pre_tab`, `startup_window`, `startup_pane` handled | +| I6 | Missing teamocil keys | `focus`, `target`, `options` explicitly copied (not accidental mutation) | +| I7 | Importer TODOs | `with_env_var` → window `environment`, stale TODO docstring removed | +| T1 | `synchronize` desugar | Desugared to `options`/`options_after` in importer | +| T3 | `shell_command_after` | Processed in `config_after_window()` in builder | +| T4 | `--here` CLI flag | Flag exists in load.py, mutually exclusive with `--append` | +| T5 | `tmuxp stop` | Command registered in CLI | +| T10 | `tmuxp new/copy/delete` | Commands registered in CLI | + +#### Agent 2: Tmuxinator Parity + +Read `src/tmuxp/workspace/importers.py` (function `import_tmuxinator`). + +If available, also read tmuxinator Ruby source at `~/study/ruby/tmuxinator/lib/tmuxinator/project.rb`. + +For each tmuxinator config key, check if it's handled in the importer and if a test fixture covers it: + +| Key | Handled? | Mapping correct? | Test fixture? | +|-----|----------|-------------------|---------------| +| `name` | | | | +| `project_name` | | | | +| `root` / `project_root` | | | | +| `pre` (string) | | | | +| `pre` (list) | | | | +| `pre_window` | | | | +| `pre_tab` | | | | +| `rbenv` | | | | +| `rvm` | | | | +| `tmux_options` / `cli_args` | | | | +| `socket_name` | | | | +| `startup_window` | | | | +| `startup_pane` | | | | +| `synchronize` (true/before/after/false) | | | | +| `tabs` (alias for windows) | | | | +| `on_project_start/exit/stop` | | | | +| `enable_pane_titles` | | | | +| `pane_title_position/format` | | | | +| window `pre` | | | | +| window `root` | | | | +| window `layout` | | | | +| window `panes` (list of strings) | | | | +| window `panes` (list of dicts) | | | | + +#### Agent 3: Teamocil Parity + +Read `src/tmuxp/workspace/importers.py` (function `import_teamocil`). + +If available, also read teamocil Ruby source at `~/study/ruby/teamocil/lib/teamocil/tmux/`. + +For each teamocil config key, check if it's handled in the importer and if a test fixture covers it: + +| Key | Handled? | v0.x? | v1.x? | Test fixture? | +|-----|----------|-------|-------|---------------| +| `session.name` | | | | | +| `session.root` | | | | | +| `windows[].name` | | | | | +| `windows[].root` | | | | | +| `windows[].layout` | | | | | +| `windows[].clear` | | | | | +| `windows[].filters.before` | | | | | +| `windows[].filters.after` | | | | | +| `windows[].with_env_var` | | | | | +| `windows[].cmd_separator` | | | | | +| `windows[].focus` | | | | | +| `windows[].options` | | | | | +| `splits` (alias for panes) | | | | | +| `panes[].cmd` (string) | | | | | +| `panes[].cmd` (list) | | | | | +| `panes[].commands` (v1.x) | | | | | +| `panes[]` as string (v1.x) | | | | | +| `panes[]` as None | | | | | +| `panes[].focus` | | | | | +| `panes[].target` | | | | | +| `panes[].width` | | | | | +| `panes[].height` | | | | | + +### Batch 2: Launch 3 more Explore agents in parallel + +#### Agent 4: DX Happiness + +Read: +- `src/tmuxp/cli/load.py` — flags, help strings, error messages +- `src/tmuxp/cli/import_config.py` — import flow warnings +- `src/tmuxp/util.py` — `run_before_script()` behavior + +Check: +- Import CLI warns about manual adjustments after import +- Unsupported keys (width, height) produce log warnings (not silent drops) +- Multi-command `pre` lists warn with actionable guidance +- `--here` outside tmux gives clear error (not cryptic traceback) +- Schema validation catches bad imports before tmux session creation +- `tmuxp stop` has `--yes` flag for scripting + +Report each issue with severity: **blocker** / **warning** / **nice-to-have**. + +#### Agent 5: Pytest Happiness + +Read all test files in: +- `tests/workspace/test_import_tmuxinator.py` +- `tests/workspace/test_import_teamocil.py` +- `tests/workspace/test_builder.py` +- `tests/cli/test_load.py` (if exists) + +And all fixture files in: +- `tests/fixtures/import_tmuxinator/*.py` +- `tests/fixtures/import_teamocil/*.py` + +**Convention checklist** (from CLAUDE.md): + +| Convention | Status | Evidence | +|------------|--------|----------| +| Functional tests only (no `class TestFoo:`) | | | +| `NamedTuple` fixture classes with `test_id` | | | +| `@pytest.mark.parametrize` with `ids=` | | | +| Fixture modules export `*_yaml`, `*_dict`, `expected` | | | +| Tests call `validation.validate_schema()` | | | +| No `unittest.mock` (use `monkeypatch`) | | | +| No `tempfile` (use `tmp_path`) | | | +| `from __future__ import annotations` | | | +| `import typing as t` namespace | | | + +List any violations with file path and line number. + +#### Agent 6: Test Coverage + +Read `src/tmuxp/workspace/importers.py` and enumerate every branch/condition. +Read all test files and fixtures to determine which branches are covered. + +**`import_tmuxinator()` branches**: + +| Branch | Condition | Tested? | Test ID | +|--------|-----------|---------|---------| +| session name | `project_name` present | | | +| session name | `name` present | | | +| session name | neither → `None` | | | +| start dir | `project_root` present | | | +| start dir | `root` present | | | +| cli args | `cli_args` with `-f` | | | +| cli args | `tmux_options` with `-f` | | | +| cli args | multi-flag (`-f -L`) | | | +| socket | `socket_name` present | | | +| pre | `pre` string only | | | +| pre | `pre` list single cmd | | | +| pre | `pre` list multi cmd (warning) | | | +| pre | `pre` + `pre_window` combo | | | +| pre_window | string | | | +| pre_window | list | | | +| pre_tab | alias for pre_window | | | +| rbenv | present | | | +| rvm | present | | | +| tabs | alias for windows | | | +| synchronize | true / "before" | | | +| synchronize | "after" | | | +| synchronize | false | | | +| startup_window | present | | | +| startup_pane | present | | | +| window | string value | | | +| window | None value | | | +| window | list value | | | +| window | dict with pre/panes/root/layout | | | + +**`import_teamocil()` branches**: + +| Branch | Condition | Tested? | Test ID | +|--------|-----------|---------|---------| +| session wrapper | `session` key present | | | +| session name | `name` present / absent | | | +| session root | `root` present | | | +| window clear | `clear` present | | | +| filters before | non-empty list | | | +| filters before | empty list | | | +| filters after | non-empty list | | | +| filters after | empty list | | | +| with_env_var | true | | | +| with_env_var | false | | | +| window root | present | | | +| splits alias | `splits` → `panes` | | | +| pane cmd | string | | | +| pane cmd | list | | | +| pane commands | v1.x key | | | +| pane string | v1.x format | | | +| pane None | blank pane | | | +| pane focus | present | | | +| pane target | present | | | +| pane width | warning | | | +| pane height | warning | | | +| window layout | present | | | +| window focus | present | | | +| window options | present | | | + +### Synthesis + +After all 6 agents complete, synthesize results into: + +**Status Summary** — one line per dimension: +``` +1. Deliverables: X/12 items complete +2. Tmuxinator: X/Y keys handled, X tested +3. Teamocil: X/Y keys handled, X tested +4. DX: X blockers, Y warnings +5. Pytest: X/Y conventions met +6. Coverage: X/Y branches tested +``` + +**Prioritized TODO** — ordered by impact: +1. Blockers (broken behavior, data loss) +2. Missing features (unhandled keys) +3. Test gaps (untested branches) +4. Convention violations +5. Nice-to-have DX improvements + +**Ready to ship?** — Yes / No, with blocking items listed. From 3cb9e2ded9117cacf14e3115112716c39d969040 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:25:51 -0600 Subject: [PATCH 055/152] ai(claude[command]): Add /check:plan parity plan command why: Need a command that audits parity status and converts findings into an executable sequence of atomic commits with QA gates. what: - Create .claude/commands/check/plan.md - Phase 1: runs same 6-agent audit as /check:audit - Phase 2: generates numbered commit sequence grouped by priority - Phase 3: enforces QA gate (ruff, mypy, pytest) before each commit - Phase 4: executes commits with specific file staging - Phase 5: re-audits to verify progress --- .claude/commands/check/plan.md | 109 +++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .claude/commands/check/plan.md diff --git a/.claude/commands/check/plan.md b/.claude/commands/check/plan.md new file mode 100644 index 0000000000..037d0febf8 --- /dev/null +++ b/.claude/commands/check/plan.md @@ -0,0 +1,109 @@ +--- +description: Audit parity status and generate a commit-by-commit implementation plan with QA gates +--- + +# /check:plan — Parity Implementation Plan + +Runs the same audit as `/check:audit`, then converts findings into an ordered sequence of atomic commits. Each commit has a mandatory QA gate. Source and test commits are separate. + +## Phase 1: Audit + +Run `/check:audit` inline — launch 6 Explore agents in 2 batches of 3 (see `.claude/commands/check/audit.md` for the full agent definitions). Collect results for all 6 dimensions: + +1. Deliverable Check +2. Tmuxinator Parity +3. Teamocil Parity +4. DX Happiness +5. Pytest Happiness +6. Test Coverage + +## Phase 2: Generate Commit Plan + +Using audit results, generate a **numbered commit sequence**. Group by logical topic, ordered by priority: + +1. **Bug fixes first** (broken behavior, data loss) +2. **Missing features** (unhandled keys, new code paths) +3. **Test coverage** (new fixtures and test cases) +4. **DX improvements** (warnings, validation, error messages) +5. **Future features** (CLI commands, lifecycle hooks) + +### Commit Structure Rules + +Each commit entry must specify: + +``` +### Commit N: <commit message> + +**Files**: list of files to modify +**Changes**: +- Specific change 1 +- Specific change 2 +**Test fixtures** (if test commit): list of new fixture files/test IDs +**Depends on**: Commit X (if sequential dependency) +``` + +### Commit Pairing Convention + +Source and tests are **separate commits** (per AGENTS.md): + +- Source commit: `fix(importers[import_tmuxinator]): Fix pre mapping to before_script` +- Test commit: `test(importers[import_tmuxinator]): Add pre mapping fixtures` + +### Commit Message Format + +Follow project conventions: +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +``` + +## Phase 3: QA Gate + +**Before every commit**, run the full QA suite: + +```bash +uv run ruff check . --fix --show-fixes && uv run ruff format . && uv run mypy && uv run py.test -vvv +``` + +**All four commands must pass.** If `ruff check --fix` modifies files, stage those fixes into the same commit. If `mypy` or `py.test` fails, fix the issue before committing. + +Do NOT use `--no-verify` or skip any step. + +## Phase 4: Execute + +For each commit in the plan: + +1. **Make the changes** described in the commit entry +2. **Run the QA gate** — all 4 commands must pass +3. **Stage specific files** — `git add <files>`, never `git add .` or `git add -A` +4. **Commit** with the specified message (use heredoc for multi-line) +5. **Verify** — `git log --oneline -1` to confirm +6. **Proceed** to next commit + +If a commit fails QA: +- Fix the issue +- Re-run QA +- Create a **new** commit (never amend) + +## Phase 5: Re-audit + +After all commits are done, re-run the audit (Phase 1) to verify progress. Report: + +``` +Before: X/Y items complete +After: X/Y items complete +Remaining: list of items still TODO +``` + +## Reference + +- **Audit dimensions**: `.claude/commands/check/audit.md` +- **Implementation patterns**: `.claude/commands/implement.md` +- **Commit conventions**: `CLAUDE.md` (Git Commit Standards) +- **Test patterns**: `CLAUDE.md` (Testing Guidelines) +- **Primary source**: `src/tmuxp/workspace/importers.py` +- **Test files**: `tests/workspace/test_import_tmuxinator.py`, `tests/workspace/test_import_teamocil.py` +- **Fixture dirs**: `tests/fixtures/import_tmuxinator/`, `tests/fixtures/import_teamocil/` From f80de2756d7bee4058b9f8d31369489ea0ae55a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:43:19 -0600 Subject: [PATCH 056/152] 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 fc0d1bb0b244d3d216525900415658e10497998d Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:43:24 -0600 Subject: [PATCH 057/152] feat(importers[import_tmuxinator]): Use shlex.split for cli_args/tmux_options parsing why: The old str.replace("-f", "") approach couldn't handle multiple flags or values containing "-f" as a substring. shlex.split tokenizes properly. what: - Replace str.replace with shlex.split token iteration - Parse -f -> config, -L -> socket_name, -S -> socket_path - Add test4 fixture with multi-flag cli_args (-f and -L together) --- 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 5b51471abf49e37acfb5d29dd13fa6abbce354ad Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:44:36 -0600 Subject: [PATCH 058/152] 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 c7598a0ae6f92557acd495d50e3cbe8982dce1bb Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:44:41 -0600 Subject: [PATCH 059/152] 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 ac009efcd50d3990704298bc297269376de3dd72 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:46:47 -0600 Subject: [PATCH 060/152] 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 <version>) - 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 30905193d137a379bf3389a0b8198fa68c724713 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:46:53 -0600 Subject: [PATCH 061/152] feat(importers[import_teamocil]): Add focus, options, and height handling why: These teamocil keys were silently dropped during import. what: - Add window-level focus -> focus: true on target window - Add window-level options -> pass through to tmuxp options - Add height pop parallel to existing width pop - Add test6 fixture covering focus, options, and height --- 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 cf011c8d7cff842ece5ea3d675ed6060970761f7 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 7 Mar 2026 07:48:59 -0600 Subject: [PATCH 062/152] 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 114e7d22ab74d8b07bbdd9feb3954ab449a43d48 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 07:59:15 -0500 Subject: [PATCH 063/152] docs(notes[plan]): Mark L1/L2/L3 as resolved in libtmux v0.55.0 and reorganize phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: libtmux#635 is closed — all three API additions (Pane.set_title, Server(tmux_bin=...), pre-execution logging) shipped in v0.55.0. T2 and T9 are no longer blocked. what: - Mark L1, L2, L3 sections as RESOLVED with libtmux source locations - Move T2 (pane titles) from Phase 3 to Phase 2 (now unblocked) - Mark Phase 3 as COMPLETE - Move T9 (dry-run) and T7 into Phase 5, remove L2/L3 from Phase 5 - Update issue #1016 body with progress summary and status banner --- notes/plan.md | 62 +++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index ef5023999f..f8bbce8db0 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -1,33 +1,30 @@ # Parity Implementation Plan -*Last updated: 2026-03-07* +*Last updated: 2026-03-15* *Based on: parity-tmuxinator.md, parity-teamocil.md, import-tmuxinator.md, import-teamocil.md* ## libtmux Limitations -### L1. No `Pane.set_title()` Method +### L1. No `Pane.set_title()` Method — **RESOLVED in libtmux v0.55.0** -- **Blocker**: libtmux has no method wrapping `select-pane -T <title>`. The `pane_title` format variable is excluded from libtmux's bulk format queries (`formats.py:70`, commented out with note "removed in 3.1+"), but this is a libtmux-side exclusion — tmux itself still supports both `#{pane_title}` (in `format.c:205`) and `select-pane -T` (added in tmux 2.6). libtmux already knows about the display options (`pane_border_status`, `pane_border_format` in `constants.py:163-173`) but has no setter for the title itself. -- **Blocks**: Pane titles (tmuxinator feature: named pane syntax `pane_name: command` → `select-pane -T`). Also blocks `enable_pane_titles`, `pane_title_position`, `pane_title_format` session-level config. -- **Required**: Add `Pane.set_title(title: str)` method that calls `self.cmd("select-pane", "-T", title)`. This is a simple wrapper — `Pane.cmd()` already exists (`pane.py:177`) and `select-pane` is already used for `Pane.select()` (`pane.py:601`). -- **Non-breaking**: Pure addition, no existing API changes. +- **Status**: `Pane.set_title(title)` added at `pane.py:834-859`. Unblocks T2 (pane titles). +- ~~**Blocker**: libtmux has no method wrapping `select-pane -T <title>`.~~ +- ~~**Blocks**: Pane titles (tmuxinator feature: named pane syntax `pane_name: command` → `select-pane -T`).~~ +- ~~**Required**: Add `Pane.set_title(title: str)` method.~~ -### L2. Hardcoded tmux Binary Path +### L2. Hardcoded tmux Binary Path — **RESOLVED in libtmux v0.55.0** -- **Blocker**: `shutil.which("tmux")` is hardcoded in two independent code paths: - - `common.py:252` — `tmux_cmd.__init__()`, the class through which all libtmux commands flow (called by `Server.cmd()` at `server.py:311`) - - `server.py:223` — `Server.raise_if_dead()`, a separate code path that calls `subprocess.check_call()` directly - There is no way to use a custom tmux binary (wemux, byobu, or custom-built tmux). -- **Blocks**: Wemux support (tmuxinator `tmux_command: wemux`). Also blocks CI/container use with non-standard tmux locations. -- **Required**: Add optional `tmux_bin` parameter to `Server.__init__()` that propagates to `tmux_cmd`. Both code paths must be updated. Default remains `shutil.which("tmux")`. -- **Non-breaking**: Optional parameter with backward-compatible default. Existing code is unaffected. +- **Status**: `Server(tmux_bin=...)` added at `server.py:131-146`. Unblocks tmuxinator `tmux_command` support. +- ~~**Blocker**: `shutil.which("tmux")` is hardcoded in two independent code paths.~~ +- ~~**Blocks**: Wemux support (tmuxinator `tmux_command: wemux`).~~ +- ~~**Required**: Add optional `tmux_bin` parameter to `Server.__init__()`.~~ -### L3. No Dry-Run / Command Preview Mode +### L3. No Dry-Run / Command Preview Mode — **RESOLVED in libtmux v0.55.0** -- **Blocker**: `tmux_cmd` (`common.py:252-296`) always executes commands. Debug logging exists (`logger.debug` at line 291) but logs the command and its stdout *after* execution, not before. There is no pre-execution logging or facility to collect commands without executing them. -- **Blocks**: `--debug` / dry-run mode (both tmuxinator and teamocil have this). tmuxinator generates a bash script that can be previewed; teamocil's `--debug` outputs the tmux command list. -- **Required**: Either (a) add a `dry_run` flag to `tmux_cmd` that collects commands instead of executing, or (b) add pre-execution logging at DEBUG level that logs the full command before `subprocess.run()`. Option (b) is simpler and doesn't change behavior. -- **Non-breaking**: Logging change only. tmuxp would implement the user-facing `--debug` flag by capturing log output. +- **Status**: Pre-execution `logger.debug` added at `common.py:263-268`. Unblocks T9 (dry-run mode). +- ~~**Blocker**: `tmux_cmd` always executes commands with no pre-execution logging.~~ +- ~~**Blocks**: `--debug` / dry-run mode (both tmuxinator and teamocil have this).~~ +- ~~**Required**: Add pre-execution logging at DEBUG level.~~ - **Note**: Since tmuxp uses libtmux API calls (not command strings), a true dry-run would require a recording layer in `WorkspaceBuilder` that logs each API call. This is architecturally different from tmuxinator/teamocil's approach and may not be worth full parity. ### L4. Available APIs (No Blockers) @@ -281,26 +278,27 @@ These add new config key handling to the builder. Each also needs a correspondin - Then update tmuxinator importer to import `synchronize` key (pass-through, same name) 2. **T3**: `shell_command_after` config key — straightforward `send_keys()` loop - teamocil importer already produces this key (I3 fixes the loop); builder just needs to read it -3. **T4**: `--here` CLI flag — moderate complexity, uses existing libtmux APIs +3. **T2**: Pane title config keys — **now unblocked** (L1 resolved in libtmux v0.55.0) + - Use `pane.set_title()` in builder. Session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` via `session.set_option()`. + - Update tmuxinator importer to import named pane syntax (`pane_name: command` → `title` + `shell_command`) +4. **T4**: `--here` CLI flag — moderate complexity, uses existing libtmux APIs -### Phase 3: libtmux Additions +### ~~Phase 3: libtmux Additions~~ — **COMPLETE** (libtmux v0.55.0, issue #635 closed) -These require changes to the libtmux package: +All libtmux API additions shipped in v0.55.0 (2026-03-07). tmuxp pins `libtmux~=0.55.0`. -1. **L1**: `Pane.set_title()` — simple wrapper, needed for T2 -2. **T2**: Pane title config keys — depends on L1 - - Then update tmuxinator importer to import `enable_pane_titles`, `pane_title_position`, `pane_title_format`, and named pane syntax (`pane_name: command` → `title` + `shell_command`) +- ~~**L1**: `Pane.set_title()`~~ → `pane.py:834-859` +- ~~**L2**: `Server(tmux_bin=...)`~~ → `server.py:131-146` +- ~~**L3**: Pre-execution `logger.debug`~~ → `common.py:263-268` ### Phase 4: New CLI Commands 1. **T5**: `tmuxp stop` command 2. **T10**: `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands -### Phase 5: Larger Features (Nice-to-Have) +### Phase 5: CLI Flags & Larger Features -1. **T6**: Lifecycle hook config keys — complex, needs design -2. **T7**: `--no-shell-command-before` flag — simple -3. **T8**: Config templating — significant architectural addition -4. **L3**: Pre-execution command logging in libtmux — prerequisite for T9 -5. **T9**: `--debug` / dry-run mode — depends on L3 -6. **L2**: Custom tmux binary — requires libtmux changes +1. **T7**: `--no-shell-command-before` flag — simple +2. **T9**: `--debug` / dry-run mode — **now unblocked** (L3 resolved in libtmux v0.55.0) +3. **T6**: Lifecycle hook config keys — complex, needs design +4. **T8**: Config templating — significant architectural addition From 0d3cd40c63d48f91389bafdd60cb333e96e3cc6f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 09:30:35 -0500 Subject: [PATCH 064/152] docs(notes[plan]): Fix stale libtmux and builder line references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Line numbers in L4 API table and T1-T4 builder sections drifted as libtmux code grew. Importer references left unchanged — those bugs are already fixed on this branch and the old line numbers serve as historical documentation of where the bugs were. what: - Fix 9 libtmux L4 API line references (session, pane, options, hooks) - Fix EnvironmentMixin location (common.py:63, not session.py:53) - Fix 5 builder.py line references in T1-T4 sections - Fix L2 Phase 3 reference (server.py:142, not 131-146) --- notes/plan.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index f8bbce8db0..8833cc19ae 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -33,20 +33,20 @@ These libtmux APIs already exist and do NOT need changes: | API | Location | Supports | |---|---|---| -| `Session.rename_session(name)` | `session.py:412` | teamocil session rename mode | +| `Session.rename_session(name)` | `session.py:422` | teamocil session rename mode | | `Window.rename_window(name)` | `window.py:462` | teamocil `--here` flag | | `Pane.resize(height, width)` | `pane.py:217` | teamocil v0.x pane `width` | | `Pane.send_keys(cmd, enter)` | `pane.py:423` | All command sending | -| `Pane.select()` | `pane.py:577` | Pane focus | -| `Window.set_option(key, val)` | `options.py:578` (OptionsMixin) | `synchronize-panes`, window options | -| `Session.set_hook(hook, cmd)` | `hooks.py:111` (HooksMixin) | Lifecycle hooks (`client-detached`, etc.) | -| `Session.set_option(key, val)` | `options.py:578` (OptionsMixin) | `pane-border-status`, `pane-border-format` | +| `Pane.select()` | `pane.py:586` | Pane focus | +| `Window.set_option(key, val)` | `options.py:593` (OptionsMixin) | `synchronize-panes`, window options | +| `Session.set_hook(hook, cmd)` | `hooks.py:118` (HooksMixin) | Lifecycle hooks (`client-detached`, etc.) | +| `Session.set_option(key, val)` | `options.py:593` (OptionsMixin) | `pane-border-status`, `pane-border-format` | | `HooksMixin` on Session/Window/Pane | `session.py:55`, `window.py:56`, `pane.py:51` | All entities inherit hooks | -| `HooksMixin.set_hooks()` (bulk) | `hooks.py:430` | Efficient multi-hook setup (dict/list input) | -| `Session.set_environment(key, val)` | `session.py:53` (EnvironmentMixin) | Session-level env vars (teamocil `with_env_var`) | -| `Pane.clear()` | `pane.py:818` | Sends `reset` to clear pane (teamocil `clear`) | -| `Pane.reset()` | `pane.py:823` | `send-keys -R \; clear-history` (full reset) | -| `Pane.split(target=...)` | `pane.py:625` | Split targeting (teamocil v0.x `target`) | +| `HooksMixin.set_hooks()` (bulk) | `hooks.py:437` | Efficient multi-hook setup (dict/list input) | +| `Session.set_environment(key, val)` | `common.py:63` (EnvironmentMixin) | Session-level env vars (teamocil `with_env_var`) | +| `Pane.clear()` | `pane.py:869` | Sends `reset` to clear pane (teamocil `clear`) | +| `Pane.reset()` | `pane.py:874` | `send-keys -R \; clear-history` (full reset) | +| `Pane.split(target=...)` | `pane.py:634` | Split targeting (teamocil v0.x `target`) | ## tmuxp Limitations @@ -55,7 +55,7 @@ These libtmux APIs already exist and do NOT need changes: - **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. The key is silently ignored if present. - **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). Note: tmuxinator deprecates `true`/`before` in favor of `after` (`project.rb:21-29`), but all three values still function. The import should honor original semantics of each value. - **Required**: Add `synchronize` handling in `builder.py`. For `before`/`true`: call `window.set_option("synchronize-panes", "on")` before pane commands are sent. For `after`: call it in `config_after_window()`. For `false`/omitted: no action. -- **Insertion point**: In `build()` around line 320 (after `on_window_create` plugin hook, before `iter_create_panes()` loop) for `before`/`true`. In `config_after_window()` around line 565 for `after`. Note: in tmux 3.2+ (tmuxp's minimum), `synchronize-panes` is a dual-scope option (window|pane, `options-table.c:1423`). Setting it at window level via `window.set_option()` makes all panes inherit it, including those created later by split. +- **Insertion point**: In `build()` around line 541 (after `on_window_create` plugin hook, before `iter_create_panes()` loop) for `before`/`true`. In `config_after_window()` around line 822 for `after`. Note: in tmux 3.2+ (tmuxp's minimum), `synchronize-panes` is a dual-scope option (window|pane, `options-table.c:1423`). Setting it at window level via `window.set_option()` makes all panes inherit it, including those created later by split. - **Non-breaking**: New optional config key. Existing configs are unaffected. ### T2. No Pane Title Config Key @@ -63,8 +63,8 @@ These libtmux APIs already exist and do NOT need changes: - **Blocker**: `WorkspaceBuilder` has no handling for pane `title` key or session-level `enable_pane_titles` / `pane_title_position` / `pane_title_format`. - **Blocks**: Pane titles (tmuxinator named pane syntax). - **Required**: - 1. Session-level: set `pane-border-status` and `pane-border-format` options via `session.set_option()` in `build()` alongside other session options (lines 303-309). - 2. Pane-level: call `pane.cmd("select-pane", "-T", title)` after commands are sent in `iter_create_panes()`, before focus handling (around line 535). Requires L1 (libtmux `set_title()`), or can use `pane.cmd()` directly. + 1. Session-level: set `pane-border-status` and `pane-border-format` options via `session.set_option()` in `build()` alongside other session options (lines 529-539). + 2. Pane-level: call `pane.cmd("select-pane", "-T", title)` after commands are sent in `iter_create_panes()`, before focus handling (around line 816). Requires L1 (libtmux `set_title()`), or can use `pane.cmd()` directly. - **Config keys**: `enable_pane_titles: true`, `pane_title_position: top`, `pane_title_format: "..."` (session-level). `title: "my-title"` (pane-level). - **Non-breaking**: New optional config keys. @@ -72,18 +72,18 @@ These libtmux APIs already exist and do NOT need changes: - **Blocker**: The teamocil importer produces `shell_command_after` on the **window** dict (from `filters.after`, `importers.py:149`), but `WorkspaceBuilder` never reads it. The `trickle()` function in `loader.py` has no logic for it either. - **Blocks**: teamocil v0.x `filters.after` — commands run after all pane commands in a window. -- **Required**: Add handling in `config_after_window()` (around line 565) or in `build()` after the `iter_create_panes()` loop (around line 331). Read `window_config.get("shell_command_after", [])` and send each command to every pane via `pane.send_keys()`. Note: this is a **window-level** key set by the teamocil importer, not per-pane. +- **Required**: Add handling in `config_after_window()` (around line 822) or in `build()` after the `iter_create_panes()` loop. Read `window_config.get("shell_command_after", [])` and send each command to every pane via `pane.send_keys()`. Note: this is a **window-level** key set by the teamocil importer, not per-pane. - **Non-breaking**: New optional config key. ### T4. No Session Rename Mode / `--here` CLI Flag -- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 406). Additionally, teamocil always renames the current session (`session.rb:18-20`), regardless of `--here`; the `--here` flag only affects **window** behavior (reuse current window for first window instead of creating new). tmuxp's `--append` flag partially covers session rename mode, but does not rename the session. +- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 649). Additionally, teamocil always renames the current session (`session.rb:18-20`), regardless of `--here`; the `--here` flag only affects **window** behavior (reuse current window for first window instead of creating new). tmuxp's `--append` flag partially covers session rename mode, but does not rename the session. - **Blocks**: teamocil `--here` (reuse current window for first window) and teamocil session rename (always active, not conditional on `--here`). - **Required**: 1. Add `--here` flag to `cli/load.py` (around line 516, near `--append`). 2. Pass `here=True` through to `WorkspaceBuilder.build()`. 3. In `iter_create_windows()`, when `here=True` and first window: use `window.rename_window(name)` instead of `session.new_window()`, and send `cd <root>` via `pane.send_keys()` for directory change. - 4. Adjust `first_window_pass()` logic (line 584). + 4. Adjust `first_window_pass()` logic (line 864). 5. For session rename: when `--here` is used, also call `session.rename_session(name)` (line 262 area in `build()`). - **Depends on**: libtmux `Window.rename_window()` and `Session.rename_session()` (both already exist, L4). - **Non-breaking**: New optional CLI flag. @@ -288,7 +288,7 @@ These add new config key handling to the builder. Each also needs a correspondin All libtmux API additions shipped in v0.55.0 (2026-03-07). tmuxp pins `libtmux~=0.55.0`. - ~~**L1**: `Pane.set_title()`~~ → `pane.py:834-859` -- ~~**L2**: `Server(tmux_bin=...)`~~ → `server.py:131-146` +- ~~**L2**: `Server(tmux_bin=...)`~~ → `server.py:142` - ~~**L3**: Pre-execution `logger.debug`~~ → `common.py:263-268` ### Phase 4: New CLI Commands From 932c685eeb468ae00a491fa3f7a696f0ca197583 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 10:42:38 -0500 Subject: [PATCH 065/152] 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 92eb6db54f7484a99bdc4f7369a7cc1e44f59331 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 10:42:45 -0500 Subject: [PATCH 066/152] 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 342e03221e094783c93bc94c5929cc19659844f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 10:43:15 -0500 Subject: [PATCH 067/152] docs(notes[plan]): Mark T1 synchronize as complete why: T1 synchronize config key implemented via expand() desugaring. what: - Mark T1 as resolved in tmuxp Limitations section - Update Phase 2 checklist entry --- notes/plan.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 8833cc19ae..e250da644b 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -50,13 +50,9 @@ These libtmux APIs already exist and do NOT need changes: ## tmuxp Limitations -### T1. No `synchronize` Config Key +### T1. `synchronize` Config Key ✅ Resolved -- **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. The key is silently ignored if present. -- **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). Note: tmuxinator deprecates `true`/`before` in favor of `after` (`project.rb:21-29`), but all three values still function. The import should honor original semantics of each value. -- **Required**: Add `synchronize` handling in `builder.py`. For `before`/`true`: call `window.set_option("synchronize-panes", "on")` before pane commands are sent. For `after`: call it in `config_after_window()`. For `false`/omitted: no action. -- **Insertion point**: In `build()` around line 541 (after `on_window_create` plugin hook, before `iter_create_panes()` loop) for `before`/`true`. In `config_after_window()` around line 822 for `after`. Note: in tmux 3.2+ (tmuxp's minimum), `synchronize-panes` is a dual-scope option (window|pane, `options-table.c:1423`). Setting it at window level via `window.set_option()` makes all panes inherit it, including those created later by split. -- **Non-breaking**: New optional config key. Existing configs are unaffected. +Resolved in `feat(loader[expand])` — `expand()` desugars `synchronize: true/before/after` into `options`/`options_after` with `synchronize-panes: on`. The builder's existing `options` and `options_after` handling applies the setting. Tests: `test_synchronize` (builder integration), `test_expand_synchronize` (unit). ### T2. No Pane Title Config Key @@ -274,8 +270,7 @@ These fix existing bugs and add missing translations without touching the builde These add new config key handling to the builder. Each also needs a corresponding importer update: -1. **T1**: `synchronize` config key — straightforward `set_option()` call - - Then update tmuxinator importer to import `synchronize` key (pass-through, same name) +1. **T1**: ✅ `synchronize` config key — resolved via `expand()` desugaring in `loader.py` 2. **T3**: `shell_command_after` config key — straightforward `send_keys()` loop - teamocil importer already produces this key (I3 fixes the loop); builder just needs to read it 3. **T2**: Pane title config keys — **now unblocked** (L1 resolved in libtmux v0.55.0) From 422a5c88cc66dc65c9e3030f9a2fb9b1d8bedf01 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 11:41:13 -0500 Subject: [PATCH 068/152] 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 ae402b4ca7c7f1371474684c3dff797b508902f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 11:41:19 -0500 Subject: [PATCH 069/152] 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 873faf01f67e3c2024a3cdb585e06e1193d9c72d Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 11:41:49 -0500 Subject: [PATCH 070/152] docs(notes[plan]): Mark T3 shell_command_after as complete why: T3 shell_command_after implemented via expand() + config_after_window(). what: - Mark T3 as resolved in tmuxp Limitations section - Update Phase 2 checklist entry - Update dead config keys table --- notes/plan.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index e250da644b..9c5a563672 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -64,12 +64,9 @@ Resolved in `feat(loader[expand])` — `expand()` desugars `synchronize: true/be - **Config keys**: `enable_pane_titles: true`, `pane_title_position: top`, `pane_title_format: "..."` (session-level). `title: "my-title"` (pane-level). - **Non-breaking**: New optional config keys. -### T3. No `shell_command_after` Config Key +### T3. `shell_command_after` Config Key ✅ Resolved -- **Blocker**: The teamocil importer produces `shell_command_after` on the **window** dict (from `filters.after`, `importers.py:149`), but `WorkspaceBuilder` never reads it. The `trickle()` function in `loader.py` has no logic for it either. -- **Blocks**: teamocil v0.x `filters.after` — commands run after all pane commands in a window. -- **Required**: Add handling in `config_after_window()` (around line 822) or in `build()` after the `iter_create_panes()` loop. Read `window_config.get("shell_command_after", [])` and send each command to every pane via `pane.send_keys()`. Note: this is a **window-level** key set by the teamocil importer, not per-pane. -- **Non-breaking**: New optional config key. +Resolved in `feat(builder[config_after_window],loader[expand])` — `expand()` normalizes `shell_command_after` via `expand_cmd()`, then `config_after_window()` sends each command to every pane in the window. Tests: `test_shell_command_after` (builder integration), `test_expand_shell_command_after` (unit). ### T4. No Session Rename Mode / `--here` CLI Flag @@ -144,7 +141,7 @@ Keys produced by importers but silently ignored by the builder: | `clear` | teamocil importer | `importers.py:141` | Never read | Dead data — builder doesn't read it, but libtmux has `Pane.clear()` (L4) | | `height` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — `width` is popped but `height` passes through silently | | `target` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — accidentally preserved via dict mutation, but libtmux has `Pane.split(target=...)` (L4) | -| `shell_command_after` | teamocil importer | `importers.py:149` | Never read | Dead data — tmuxp has no after-command support | +| ~~`shell_command_after`~~ | teamocil importer | `importers.py:149` | ✅ `config_after_window()` | Resolved — T3 | ## Importer Bugs (No Builder Changes Needed) @@ -271,8 +268,7 @@ These fix existing bugs and add missing translations without touching the builde These add new config key handling to the builder. Each also needs a corresponding importer update: 1. **T1**: ✅ `synchronize` config key — resolved via `expand()` desugaring in `loader.py` -2. **T3**: `shell_command_after` config key — straightforward `send_keys()` loop - - teamocil importer already produces this key (I3 fixes the loop); builder just needs to read it +2. **T3**: ✅ `shell_command_after` config key — resolved via `expand()` + `config_after_window()` 3. **T2**: Pane title config keys — **now unblocked** (L1 resolved in libtmux v0.55.0) - Use `pane.set_title()` in builder. Session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` via `session.set_option()`. - Update tmuxinator importer to import named pane syntax (`pane_name: command` → `title` + `shell_command`) From b155e46f8131ac9cf5cebc96c9dbedff7b3b951b Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 11:48:45 -0500 Subject: [PATCH 071/152] 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 d30213b48197e59cf44438756a17f02712187b9f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 11:48:52 -0500 Subject: [PATCH 072/152] 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 7d8c76e89c98f6d073a8146d13564d9c153f47fa Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 11:49:18 -0500 Subject: [PATCH 073/152] docs(notes[plan]): Mark T2 pane titles as complete why: T2 pane title config keys implemented via expand() desugaring + builder. what: - Mark T2 as resolved in tmuxp Limitations section - Update Phase 2 checklist entry --- notes/plan.md | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 9c5a563672..a9c29f546d 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -54,15 +54,9 @@ These libtmux APIs already exist and do NOT need changes: Resolved in `feat(loader[expand])` — `expand()` desugars `synchronize: true/before/after` into `options`/`options_after` with `synchronize-panes: on`. The builder's existing `options` and `options_after` handling applies the setting. Tests: `test_synchronize` (builder integration), `test_expand_synchronize` (unit). -### T2. No Pane Title Config Key +### T2. Pane Title Config Key ✅ Resolved -- **Blocker**: `WorkspaceBuilder` has no handling for pane `title` key or session-level `enable_pane_titles` / `pane_title_position` / `pane_title_format`. -- **Blocks**: Pane titles (tmuxinator named pane syntax). -- **Required**: - 1. Session-level: set `pane-border-status` and `pane-border-format` options via `session.set_option()` in `build()` alongside other session options (lines 529-539). - 2. Pane-level: call `pane.cmd("select-pane", "-T", title)` after commands are sent in `iter_create_panes()`, before focus handling (around line 816). Requires L1 (libtmux `set_title()`), or can use `pane.cmd()` directly. -- **Config keys**: `enable_pane_titles: true`, `pane_title_position: top`, `pane_title_format: "..."` (session-level). `title: "my-title"` (pane-level). -- **Non-breaking**: New optional config keys. +Resolved in `feat(loader[expand],builder[iter_create_panes])` — Session-level `enable_pane_titles`/`pane_title_position`/`pane_title_format` desugared in `expand()` into per-window `options` (`pane-border-status`, `pane-border-format`). Pane-level `title` handled in `iter_create_panes()` via `pane.set_title()`. Tests: `test_pane_titles` (builder integration), `test_expand_pane_titles`/`_disabled`/`_defaults` (unit). ### T3. `shell_command_after` Config Key ✅ Resolved @@ -269,9 +263,7 @@ These add new config key handling to the builder. Each also needs a correspondin 1. **T1**: ✅ `synchronize` config key — resolved via `expand()` desugaring in `loader.py` 2. **T3**: ✅ `shell_command_after` config key — resolved via `expand()` + `config_after_window()` -3. **T2**: Pane title config keys — **now unblocked** (L1 resolved in libtmux v0.55.0) - - Use `pane.set_title()` in builder. Session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` via `session.set_option()`. - - Update tmuxinator importer to import named pane syntax (`pane_name: command` → `title` + `shell_command`) +3. **T2**: ✅ Pane title config keys — resolved via `expand()` desugaring + `pane.set_title()` in builder 4. **T4**: `--here` CLI flag — moderate complexity, uses existing libtmux APIs ### ~~Phase 3: libtmux Additions~~ — **COMPLETE** (libtmux v0.55.0, issue #635 closed) From ab74763871520d987153a8d8fc57adda2b04d05f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 12:01:13 -0500 Subject: [PATCH 074/152] 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 - Add here param to load_workspace, _dispatch_build, build, iter_create_windows - 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 ffda900cabad2e85c32df9ff95ad9e851bdb1cff Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 12:01:21 -0500 Subject: [PATCH 075/152] 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 02188aafe961b6773f8592e1c4e134dd3be6a3ba Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 12:01:48 -0500 Subject: [PATCH 076/152] docs(notes[plan]): Mark T4 --here CLI flag as complete --- notes/plan.md | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index a9c29f546d..0cce076f92 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -62,18 +62,9 @@ Resolved in `feat(loader[expand],builder[iter_create_panes])` — Session-level Resolved in `feat(builder[config_after_window],loader[expand])` — `expand()` normalizes `shell_command_after` via `expand_cmd()`, then `config_after_window()` sends each command to every pane in the window. Tests: `test_shell_command_after` (builder integration), `test_expand_shell_command_after` (unit). -### T4. No Session Rename Mode / `--here` CLI Flag - -- **Blocker**: `tmuxp load` (`cli/load.py`) has no `--here` flag. `WorkspaceBuilder.iter_create_windows()` always creates new windows via `session.new_window()` (line 649). Additionally, teamocil always renames the current session (`session.rb:18-20`), regardless of `--here`; the `--here` flag only affects **window** behavior (reuse current window for first window instead of creating new). tmuxp's `--append` flag partially covers session rename mode, but does not rename the session. -- **Blocks**: teamocil `--here` (reuse current window for first window) and teamocil session rename (always active, not conditional on `--here`). -- **Required**: - 1. Add `--here` flag to `cli/load.py` (around line 516, near `--append`). - 2. Pass `here=True` through to `WorkspaceBuilder.build()`. - 3. In `iter_create_windows()`, when `here=True` and first window: use `window.rename_window(name)` instead of `session.new_window()`, and send `cd <root>` via `pane.send_keys()` for directory change. - 4. Adjust `first_window_pass()` logic (line 864). - 5. For session rename: when `--here` is used, also call `session.rename_session(name)` (line 262 area in `build()`). -- **Depends on**: libtmux `Window.rename_window()` and `Session.rename_session()` (both already exist, L4). -- **Non-breaking**: New optional CLI flag. +### T4. No Session Rename Mode / `--here` CLI Flag ✅ Resolved + +Resolved in `feat(cli[load],builder)` — `--here` flag added to CLI, passed through `load_workspace` → `_dispatch_build` → `build()` → `iter_create_windows()`. In `build()`, renames session to match config. In `iter_create_windows()`, reuses active window for first window (rename + cd) instead of `first_window_pass` trick. Skips session-exists prompt. Tests: `test_here_mode` (builder integration). ### T5. No `stop` / `kill` CLI Command @@ -264,7 +255,7 @@ These add new config key handling to the builder. Each also needs a correspondin 1. **T1**: ✅ `synchronize` config key — resolved via `expand()` desugaring in `loader.py` 2. **T3**: ✅ `shell_command_after` config key — resolved via `expand()` + `config_after_window()` 3. **T2**: ✅ Pane title config keys — resolved via `expand()` desugaring + `pane.set_title()` in builder -4. **T4**: `--here` CLI flag — moderate complexity, uses existing libtmux APIs +4. **T4**: ✅ `--here` CLI flag — resolved via CLI arg + builder `here` param ### ~~Phase 3: libtmux Additions~~ — **COMPLETE** (libtmux v0.55.0, issue #635 closed) From 56d2ac6830d9d5cae63ab64709166b54cfcbfe7b Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 12:44:52 -0500 Subject: [PATCH 077/152] 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 - Register stop subparser in cli/__init__.py (5 touch points) - 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 5fa64cad31f3b9036c247d90a6a116ff1097d832 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 12:44:56 -0500 Subject: [PATCH 078/152] 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 e5ee005f5d3e8870d7a21726b03091b769991b00 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 12:45:28 -0500 Subject: [PATCH 079/152] docs(notes[plan]): Mark T5 stop command as complete --- notes/plan.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 0cce076f92..439803c09c 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -66,12 +66,9 @@ Resolved in `feat(builder[config_after_window],loader[expand])` — `expand()` n Resolved in `feat(cli[load],builder)` — `--here` flag added to CLI, passed through `load_workspace` → `_dispatch_build` → `build()` → `iter_create_windows()`. In `build()`, renames session to match config. In `iter_create_windows()`, reuses active window for first window (rename + cd) instead of `first_window_pass` trick. Skips session-exists prompt. Tests: `test_here_mode` (builder integration). -### T5. No `stop` / `kill` CLI Command +### ~~T5. No `stop` / `kill` CLI Command~~ ✅ -- **Blocker**: tmuxp has no `stop` command. The CLI modules (`cli/__init__.py`) only register: `load`, `freeze`, `ls`, `search`, `shell`, `convert`, `import`, `edit`, `debug-info`. -- **Blocks**: tmuxinator `stop` / `stop-all` — kill session with cleanup hooks. -- **Required**: Add `tmuxp stop <session>` command. Implementation: find session by name via `server.sessions`, call `session.kill()`. For hook support, run `on_project_stop` hook before kill. -- **Non-breaking**: New CLI command. +Resolved in `feat(cli[stop])` — `tmuxp stop <session-name>` command added. Follows `freeze.py` pattern: optional session-name positional arg with current-session fallback via `util.get_session()`, `-L`/`-S` socket pass-through. Kills session via `session.kill()`. Uses `Colors` semantic hierarchy for output (green success + magenta session name). Lifecycle hooks (T6 `on_project_stop`) will layer on top. ### T6. No Lifecycle Hook Config Keys @@ -267,7 +264,7 @@ All libtmux API additions shipped in v0.55.0 (2026-03-07). tmuxp pins `libtmux~= ### Phase 4: New CLI Commands -1. **T5**: `tmuxp stop` command +1. ~~**T5**: `tmuxp stop` command~~ 2. **T10**: `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands ### Phase 5: CLI Flags & Larger Features From ff2c6614f6456198eef84e8db2693803ff5bb901 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 12:47:27 -0500 Subject: [PATCH 080/152] test(cli[stop]): Register stop in help examples validation tests why: Adding stop to CLI_DESCRIPTION examples requires updating the help examples test that validates subcommand references. what: - Add "stop" to valid_subcommands set - Add "stop" to parametrized subcommand list - Add test_stop_subcommand_examples_are_valid test --- 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 f14d4c73be969b02bb128c5f51621abe31ccc400 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 13:57:31 -0500 Subject: [PATCH 081/152] 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 <name>` to create workspace from template + open in $EDITOR - Add `tmuxp copy <source> <dest>` to duplicate workspace configs - Add `tmuxp delete <name> [-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 ``<name>.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 8c894bf53a4b53d8fbc8f56acaf260759f5ac469 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 13:57:43 -0500 Subject: [PATCH 082/152] 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 f3330a6485a86439c19bff0fc081927506ef279a Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 13:57:59 -0500 Subject: [PATCH 083/152] docs(notes[plan]): Mark T10 config management commands as complete --- notes/plan.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 439803c09c..8aefb3509c 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -104,12 +104,13 @@ Resolved in `feat(cli[stop])` — `tmuxp stop <session-name>` command added. Fol - **Required**: Either (a) add a recording proxy layer around libtmux calls that logs what would be done, or (b) add verbose logging that shows each tmux command before execution (depends on L3). - **Non-breaking**: New optional CLI flag. -### T10. Missing Config Management Commands +### T10. Missing Config Management Commands ✅ Resolved -- **Blocker**: tmuxp only has `edit`. Missing: `new` (create from template), `copy` (duplicate config), `delete` (remove config with confirmation). -- **Blocks**: tmuxinator `new`, `copy`, `delete`, `implode` commands. -- **Required**: Add CLI commands. These are straightforward file operations. -- **Non-breaking**: New CLI commands. +Resolved in `feat(cli[new,copy,delete])` — Three config management commands added: +- `tmuxp new <name>` creates workspace from template + opens in `$EDITOR` +- `tmuxp copy <source> <dest>` duplicates workspace configs (supports names and paths) +- `tmuxp delete <name> [-y]` removes workspace configs with confirmation prompt +All commands follow existing CLI patterns (`edit.py`, `convert.py`), use `Colors` semantic hierarchy, and integrate with `find_workspace_file()`/`get_workspace_dir()`. Skipped `implode` (destructive nuke-all, low value). ## Dead Config Keys From 10c533421dde7547c9c7fc082f26cd9d1d26b04e Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 17:20:52 -0500 Subject: [PATCH 084/152] 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 f05db44c2f58c39051be63eb1ff5cb7fec31d0b9 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 17:20:58 -0500 Subject: [PATCH 085/152] 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 e6dfca2f04cac2e6558f58c1c36d9ac5b1a0f81e Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 17:21:23 -0500 Subject: [PATCH 086/152] docs(notes[plan]): Mark T7 --no-shell-command-before as complete --- notes/plan.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 8aefb3509c..4c58426e27 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -83,12 +83,9 @@ Resolved in `feat(cli[stop])` — `tmuxp stop <session-name>` command added. Fol - **Depends on**: T5 for `on_project_stop`. - **Non-breaking**: New optional config keys. -### T7. No `--no-shell-command-before` CLI Flag +### T7. No `--no-shell-command-before` CLI Flag ✅ Resolved -- **Blocker**: `tmuxp load` has no flag to skip `shell_command_before`. The `trickle()` function (`loader.py:245-256`) always prepends these commands. -- **Blocks**: tmuxinator `--no-pre-window` — skip per-pane pre-commands for debugging. -- **Required**: Add `--no-shell-command-before` flag to `cli/load.py`. When set, clear `shell_command_before` from all levels before calling `trickle()`. -- **Non-breaking**: New optional CLI flag. +Resolved in `feat(cli[load])` — `--no-shell-command-before` flag added to `tmuxp load`. When set, strips `shell_command_before` from session, window, and pane levels after `expand()` but before `trickle()`. Equivalent to tmuxinator's `--no-pre-window`. ### T8. No Config Templating @@ -270,7 +267,7 @@ All libtmux API additions shipped in v0.55.0 (2026-03-07). tmuxp pins `libtmux~= ### Phase 5: CLI Flags & Larger Features -1. **T7**: `--no-shell-command-before` flag — simple +1. ~~**T7**: `--no-shell-command-before` flag~~ ✅ 2. **T9**: `--debug` / dry-run mode — **now unblocked** (L3 resolved in libtmux v0.55.0) 3. **T6**: Lifecycle hook config keys — complex, needs design 4. **T8**: Config templating — significant architectural addition From afce7b448fbdef0f4259109c93bee44d0ba346e7 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 17:44:11 -0500 Subject: [PATCH 087/152] 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 0a9c50cfa4dae9bc19c2daf83508a578ac5d87b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 17:44:18 -0500 Subject: [PATCH 088/152] 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 d5b8f931db8573865944083bc82919cf72a3c079 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 17:45:06 -0500 Subject: [PATCH 089/152] docs(notes[plan]): Mark T9 --debug CLI flag as complete --- notes/plan.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 4c58426e27..1858b3fe62 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -94,12 +94,9 @@ Resolved in `feat(cli[load])` — `--no-shell-command-before` flag added to `tmu - **Required**: Add a Jinja2 or Python `string.Template` pass before YAML parsing. Allow `key=value` CLI args to set template variables. This is a significant architectural addition. - **Non-breaking**: Opt-in feature, existing configs are unaffected. -### T9. No `--debug` / Dry-Run CLI Flag +### T9. `--debug` CLI Flag ✅ Resolved -- **Blocker**: `tmuxp load` has no dry-run mode. Since tmuxp uses libtmux API calls rather than generating command strings, there's no natural command list to preview. -- **Blocks**: tmuxinator `debug` and teamocil `--debug` / `--show`. -- **Required**: Either (a) add a recording proxy layer around libtmux calls that logs what would be done, or (b) add verbose logging that shows each tmux command before execution (depends on L3). -- **Non-breaking**: New optional CLI flag. +Resolved in `feat(cli[load])` — `--debug` flag added to `tmuxp load` that shows tmux commands as they execute. Uses a `_TmuxCommandDebugHandler` that attaches to libtmux's `libtmux.common` logger and intercepts structured `tmux_cmd` extra fields. Implies `--no-progress` (spinner disabled). Handler is properly cleaned up on all return paths. Not a true dry-run (tmux commands still execute — required for API-based building), but provides the debugging visibility that tmuxinator `debug` and teamocil `--debug` offer. ### T10. Missing Config Management Commands ✅ Resolved @@ -268,6 +265,6 @@ All libtmux API additions shipped in v0.55.0 (2026-03-07). tmuxp pins `libtmux~= ### Phase 5: CLI Flags & Larger Features 1. ~~**T7**: `--no-shell-command-before` flag~~ ✅ -2. **T9**: `--debug` / dry-run mode — **now unblocked** (L3 resolved in libtmux v0.55.0) +2. ~~**T9**: `--debug` / dry-run mode~~ ✅ 3. **T6**: Lifecycle hook config keys — complex, needs design 4. **T8**: Config templating — significant architectural addition From 7d2e079e0efcff1038c587810eb9e2c7cca5a751 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 18:12:26 -0500 Subject: [PATCH 090/152] 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 249f8421442aef65677a5f08c14ae0db7ca49191 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 18:12:36 -0500 Subject: [PATCH 091/152] 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 56a5b295d28b05ff85fc27797c3a0fa46e5e7891 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 18:12:38 -0500 Subject: [PATCH 092/152] docs(notes[plan]): Mark T6 lifecycle hook config keys as complete --- notes/plan.md | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 1858b3fe62..b12ab536c6 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -70,18 +70,9 @@ Resolved in `feat(cli[load],builder)` — `--here` flag added to CLI, passed thr Resolved in `feat(cli[stop])` — `tmuxp stop <session-name>` command added. Follows `freeze.py` pattern: optional session-name positional arg with current-session fallback via `util.get_session()`, `-L`/`-S` socket pass-through. Kills session via `session.kill()`. Uses `Colors` semantic hierarchy for output (green success + magenta session name). Lifecycle hooks (T6 `on_project_stop`) will layer on top. -### T6. No Lifecycle Hook Config Keys - -- **Blocker**: tmuxp's plugin system (`plugin.py:216-292`) has 5 hooks: `before_workspace_builder`, `on_window_create`, `after_window_finished`, `before_script`, `reattach`. These are Python plugin hooks, not config-driven shell command hooks. There are no config keys for `on_project_start`, `on_project_exit`, etc. -- **Blocks**: tmuxinator lifecycle hooks (`on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`). -- **Required**: Add config-level hook keys. Mapping: - - `on_project_start` → run shell command at start of `build()`, before `before_script` - - `on_project_first_start` → already partially covered by `before_script` - - `on_project_restart` → run when reattaching (currently only plugin `reattach()` hook) - - `on_project_exit` → use tmux `set-hook client-detached` via `session.set_hook()` (libtmux L4) - - `on_project_stop` → run in new `tmuxp stop` command (T5) -- **Depends on**: T5 for `on_project_stop`. -- **Non-breaking**: New optional config keys. +### T6. Lifecycle Hook Config Keys ✅ Resolved + +Resolved in `feat(util,builder,cli[load,stop],loader)` — 4 lifecycle hook config keys added: `on_project_start` (runs on every `tmuxp load`, before session creation), `on_project_restart` (runs when reattaching to existing session), `on_project_exit` (fires on detach via tmux `set-hook client-detached`), `on_project_stop` (runs before `session.kill()` in `tmuxp stop`, stored in session env). `run_hook_commands()` helper uses `shell=True` for full shell support. `on_project_first_start` skipped (covered by `before_script`). Hook values expanded via `expandshell()` in `loader.expand()`. Tests: `test_run_hook_commands*` (unit), `test_on_project_exit_sets_hook*` / `test_on_project_stop_sets_environment` / `test_on_project_stop_sets_start_directory_env` (builder integration), `test_load_on_project_start_runs_hook` / `test_load_on_project_restart_runs_hook` (CLI load), `test_stop_runs_on_project_stop_hook` / `test_stop_without_hook` (CLI stop), `test_expand_lifecycle_hooks_*` (loader expand). ### T7. No `--no-shell-command-before` CLI Flag ✅ Resolved From 220a6d5232945c57a8c53e034bc54b67a49aa212 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 18:25:31 -0500 Subject: [PATCH 093/152] fix(importers[tmuxinator]): Convert startup_window/startup_pane to focus flags why: startup_window and startup_pane were stored as dead config keys (start_window/start_pane) that the builder never read. 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 f5ec12f267aafc097fb9e453b18253f64e421798 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 18:25:39 -0500 Subject: [PATCH 094/152] 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 f1fae727f50327fb7aad3e3da547cc27c74e0032 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 18:25:47 -0500 Subject: [PATCH 095/152] docs(notes[plan]): Mark I1-I7 importer fixes as complete, update priority why: All importer bugs and missing key imports are resolved. what: - Mark I1-I7 as resolved with test references - Update Dead Config Keys table (startup_window/startup_pane resolved) - Mark Phases 1-5 as complete - Add Phase 6 with remaining items (T8, dead keys, edge cases) - Remove stale Tier 1 test coverage gaps (all covered) --- notes/plan.md | 166 ++++++++++++++------------------------------------ 1 file changed, 45 insertions(+), 121 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index b12ab536c6..468f429d37 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -101,161 +101,85 @@ All commands follow existing CLI patterns (`edit.py`, `convert.py`), use `Colors Keys produced by importers but silently ignored by the builder: -| Key | Producer | Importer Line | Builder Handling | Issue | -|---|---|---|---|---| -| `shell_command` (session-level) | tmuxinator importer | `importers.py:60` | Not a valid session key | **Bug** (I1 Bug B): `pre` commands lost when both `pre` and `pre_window` exist | -| `config` | tmuxinator importer | `importers.py:37,44` | Never read | Dead data — extracted `-f` path goes nowhere | -| `socket_name` | tmuxinator importer | `importers.py:52` | Never read | Dead data — CLI uses `-L` flag | -| `clear` | teamocil importer | `importers.py:141` | Never read | Dead data — builder doesn't read it, but libtmux has `Pane.clear()` (L4) | -| `height` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — `width` is popped but `height` passes through silently | -| `target` (pane) | teamocil importer | passthrough (not popped) | Never read | Dead data — accidentally preserved via dict mutation, but libtmux has `Pane.split(target=...)` (L4) | -| ~~`shell_command_after`~~ | teamocil importer | `importers.py:149` | ✅ `config_after_window()` | Resolved — T3 | +| Key | Producer | Builder Handling | Status | +|---|---|---|---| +| `config` | tmuxinator importer | Never read | Dead data — extracted `-f` path, CLI uses `-f` flag directly | +| `socket_name` | tmuxinator importer | Never read | Dead data — CLI uses `-L` flag directly | +| `clear` | teamocil importer | Never read | Dead data — builder doesn't read it, but libtmux has `Pane.clear()` (L4) | +| ~~`shell_command` (session-level)~~ | ~~tmuxinator importer~~ | ~~Not a valid session key~~ | ✅ Resolved — I1: `pre` now maps to `before_script` | +| ~~`shell_command_after`~~ | ~~teamocil importer~~ | ✅ `config_after_window()` | ✅ Resolved — T3 | +| ~~`height` (pane)~~ | ~~teamocil importer~~ | ~~Dead data~~ | ✅ Resolved — warned + dropped | +| ~~`start_window`/`start_pane`~~ | ~~tmuxinator importer~~ | ~~Dead data~~ | ✅ Resolved — converted to `focus: true` in importer | -## Importer Bugs (No Builder Changes Needed) +## Importer Fixes — All ✅ Resolved -### I1. tmuxinator `pre` / `pre_window` Mapping Bugs +### I1. tmuxinator `pre` / `pre_window` Mapping ✅ Resolved -Two bugs in `importers.py:59-70`, covering both code paths for the `pre` key: +Resolved — `pre` now correctly maps to `before_script` (session-level, runs once). `pre_window`/`pre_tab` maps to `shell_command_before`. Type check on `pre_window_val` is correct. Multi-command `pre` lists log an info message suggesting split. Tests: `test3` (combo), `test5` (`pre` + `pre_tab`), `test_logs_info_on_multi_command_pre_list`. -#### Bug A: Solo `pre` maps to wrong key (NEW — 2026-03-06) +### I2. tmuxinator `cli_args` / `tmux_options` Parsing ✅ Resolved -- **Bug**: When only `pre` exists (no `pre_window`) (`importers.py:66-70`), it maps to `shell_command_before` — a per-pane key that runs before each pane's commands. But tmuxinator's `pre` is a session-level hook that runs **once** before any windows are created. The correct target is `before_script`. -- **Effect**: Instead of running once at session start, the `pre` commands run N times (once per pane) as pane setup commands. This changes both the semantics (pre-session → per-pane) and the execution count. +Resolved — Uses `shlex.split()` with proper flag-aware iteration. Supports `-f`, `-L`, `-S` flags. Tests: `test3` (single flag), `test4` (multi-flag). -#### Bug B: Combo `pre` + `pre_window` loses `pre` commands +### I3. teamocil Redundant Filter Loops ✅ Resolved -- **Bug**: When both `pre` and `pre_window` exist (`importers.py:59-65`): - 1. `pre` maps to `shell_command` (line 60) — invalid session-level key, silently ignored by builder. The `pre` commands are lost entirely (see Dead Config Keys table). - 2. The `isinstance` check on line 62 tests `workspace_dict["pre"]` type to decide how to wrap `workspace_dict["pre_window"]` — it should check `pre_window`'s type, not `pre`'s. When `pre` is a string but `pre_window` is a list, `pre_window` gets double-wrapped as `[["cmd1", "cmd2"]]` (nested list). When `pre` is a list but `pre_window` is a string, `pre_window` won't be wrapped in a list — leaving a bare string where a list is expected. +Resolved — Direct assignment replaces redundant loops. Tests: existing `test2` (filters fixture). -#### Correct mapping +### I4. teamocil v1.x Format ✅ Resolved -- `pre` → `before_script` (session-level, runs once before windows) -- `pre_window` → `shell_command_before` (per-pane, runs before each pane's commands) +Resolved — Handles string panes, `None` panes, `commands` key (v1.x), `cmd` key (v0.x). `width`/`height` warned and dropped. Tests: `test5` (v1.x format), `test6` (focus/options/height). -#### `before_script` shell limitation +### I5. tmuxinator Missing Keys ✅ Resolved -`before_script` is executed via `subprocess.Popen` after `shlex.split()` in `util.py:27-32` — **without `shell=True`**. This means shell constructs (pipes `|`, `&&`, redirects `>`, subshells `$(...)`) won't work in `before_script` values. For inline shell commands, the forward path is the `on_project_start` config key (T6), which would use `shell=True` or write a temp script. +Resolved — `rvm` → `shell_command_before`, `pre_tab` → alias for `pre_window`, `startup_window` → `focus: true` on matching window, `startup_pane` → `focus: true` on matching pane. Tests: `test5` (rvm/pre_tab/startup), `test_startup_window_*`, `test_startup_pane_*`. -### I2. tmuxinator `cli_args` / `tmux_options` Fragile Parsing +### I6. teamocil Missing Keys ✅ Resolved -- **Bug**: `str.replace("-f", "").strip()` (`importers.py:41,48`) does a global string replacement, not flag-aware parsing. A value like `"-f ~/.tmux.conf -L mysocket"` would produce `"~/.tmux.conf -L mysocket"` as the `config` value (including the `-L` flag in a file path). Also ignores `-L` (socket name) and `-S` (socket path) flags entirely. -- **Fix**: Use proper argument parsing (e.g., `shlex.split()` + iterate to find `-f` flag and its value). +Resolved — v1.x: `commands` → `shell_command`, string panes handled, window `focus`/`options` pass-through. v0.x: `with_env_var` and `cmd_separator` log warnings. Tests: `test5` (v1.x), `test6` (focus/options), `test_warns_on_with_env_var_and_cmd_separator`. -### I3. teamocil Redundant Filter Loops +### I7. Importer TODOs ✅ Resolved -- **Bug**: `for _b in w["filters"]["before"]:` loops (`importers.py:145-149`) iterate N times but set the same value each time. -- **Fix**: Replace with direct assignment. +Resolved — `with_env_var` logs warning (unsupported), `cmd_separator` logs warning (irrelevant for tmuxp), `width`/`height` warn and drop. Tests: `test_warns_on_width_height_drop`, `test_warns_on_with_env_var_and_cmd_separator`. -### I4. teamocil v1.x Format Not Supported +## Remaining Test Coverage Gaps -- **Bug**: Importer assumes v0.x format. String panes cause incorrect behavior (`"cmd" in "git status"` checks substring, not dict key). `commands` key (v1.x) not mapped. -- **Fix**: Add format detection. Handle string panes, `commands` key, `focus`, and `options`. -- **Also**: v0.x pane `width` is silently dropped (`importers.py:161-163`) with a TODO but no user warning. `height` is not even popped — it passes through as a dead key. Since libtmux's `Pane.resize()` exists (L4), the importer could preserve both `width` and `height` and the builder could call `pane.resize(width=value)` or `pane.resize(height=value)` after split. Alternatively, warn the user. +### Tier 1: Covered ✅ -### I5. tmuxinator Missing Keys +All previously-identified Tier 1 gaps (v1.x string panes, `commands` key, `rvm`, `pre` scope) are now fixed and tested. -Not imported but translatable: -- `rvm` → `shell_command_before: ["rvm use {value}"]` -- `pre_tab` → `shell_command_before` (deprecated predecessor to `pre_window`) -- `startup_window` → find matching window, set `focus: true` -- `startup_pane` → find matching pane, set `focus: true` -- `on_project_first_start` → `before_script` (only if value is a single command or script path; multi-command strings joined by `;` won't work since `before_script` uses `Popen` without `shell=True`) -- `post` → deprecated predecessor to `on_project_exit`; runs after windows are built on every invocation. No tmuxp equivalent until T6 lifecycle hooks exist. -- `socket_path` → warn user to use CLI `-S` flag -- `attach: false` → warn user to use CLI `-d` flag - -### I6. teamocil Missing Keys - -Not imported but translatable: - -**v1.x keys** (same key names in tmuxp): -- `commands` → `shell_command` -- `focus` (window) → `focus` (pass-through) -- `focus` (pane) → `focus` (pass-through) -- `options` (window) → `options` (pass-through) -- String pane shorthand → `shell_command: [command]` - -**v0.x keys**: -- `with_env_var` → `environment: { TEAMOCIL: "1" }` (default `true` in v0.x; maps to session-level `environment` key) -- `height` (pane) → should be popped like `width` (currently passes through as dead key) - -### I7. Importer TODOs Need Triage - -`importers.py:121,123` lists `with_env_var` and `cmd_separator` as TODOs (with `clear` at line 122 in between). Both are verified v0.x features (present in teamocil's `0.4-stable` branch at `lib/teamocil/layout/window.rb`), not stale references: - -- **`with_env_var`** (line 121): When `true` (the default in v0.x), exports `TEAMOCIL=1` in each pane. Should map to `environment: { TEAMOCIL: "1" }` (tmuxp's `environment` key works at session level via `Session.set_environment()`, L4). Implement, don't remove. -- **`clear`** (line 122): Already imported at line 141 but builder ignores it. libtmux has `Pane.clear()` (L4), so builder support is feasible. -- **`cmd_separator`** (line 123): Per-window string (default `"; "`) used to join commands before `send-keys`. Irrelevant for tmuxp since it sends commands individually. Remove TODO. - -## Test Coverage Gaps - -Current importer test fixtures cover ~40% of real-world config patterns. Key gaps by severity: - -### Tier 1: Will Crash or Silently Lose Data - -- **v1.x teamocil string panes**: `panes: ["git status"]` → `TypeError` (importer tries `"cmd" in p` on string) -- **v1.x teamocil `commands` key**: `commands: [...]` → silently dropped (only `cmd` recognized) -- **tmuxinator `rvm`**: Completely ignored by importer (only `rbenv` handled) -- **tmuxinator `pre` scope bug**: Tests pass because fixtures don't verify execution semantics - -### Tier 2: Missing Coverage +### Tier 2: Edge Cases (Low Priority) - **YAML aliases/anchors**: Real tmuxinator configs use `&defaults` / `*defaults` — no test coverage - **Numeric/emoji window names**: `222:`, `true:`, `🍩:` — YAML type coercion edge cases untested - **Pane title syntax**: `pane_name: command` dict form — no fixtures -- **`startup_window`/`startup_pane`**: Not tested -- **`pre_tab`** (deprecated): Not tested -- **Window-level `root` with relative paths**: Not tested -- **`tmux_options` with non-`-f` flags**: Not tested (importer bug I2) - -### Required New Fixtures - -When implementing Phase 1 import fixes, each item needs corresponding test fixtures. See `tests/fixtures/import_tmuxinator/` and `tests/fixtures/import_teamocil/` for existing patterns. - -**tmuxinator fixtures needed**: YAML aliases, emoji names, numeric names, `rvm`, `pre_tab`, `startup_window`/`startup_pane`, pane titles, `socket_path`, multi-flag `tmux_options` - -**teamocil fixtures needed**: v1.x format (`commands`, string panes, window `focus`/`options`), pane `height`, `with_env_var`, mixed v0.x/v1.x detection ## Implementation Priority -### Phase 1: Import Fixes (No Builder/libtmux Changes) - -These fix existing bugs and add missing translations without touching the builder: +### ~~Phase 1: Import Fixes~~ — **COMPLETE** -1. **I3**: Fix redundant filter loops (teamocil) -2. **I4**: Add v1.x teamocil format support -3. **I6**: Import teamocil v1.x keys (`commands`, `focus`, `options`, string panes) -4. **I5**: Import missing tmuxinator keys (`rvm`, `pre_tab`, `startup_window`, `startup_pane`) -5. **I1**: Fix `pre`/`pre_window` mapping (tmuxinator) -6. **I2**: Fix `cli_args` parsing (tmuxinator) -7. **I7**: Triage importer TODOs (implement `with_env_var`, remove `cmd_separator`) +All importer bugs (I1-I7) resolved. Importers handle v1.x format, missing keys, proper `pre`/`pre_window` mapping, flag-aware `cli_args` parsing, `startup_window`/`startup_pane` → `focus: true`, and unsupported key warnings. -### Phase 2: Builder Additions (tmuxp Only) +### ~~Phase 2: Builder Additions~~ — **COMPLETE** -These add new config key handling to the builder. Each also needs a corresponding importer update: +All builder config keys resolved: T1 (`synchronize`), T2 (pane titles), T3 (`shell_command_after`), T4 (`--here`). -1. **T1**: ✅ `synchronize` config key — resolved via `expand()` desugaring in `loader.py` -2. **T3**: ✅ `shell_command_after` config key — resolved via `expand()` + `config_after_window()` -3. **T2**: ✅ Pane title config keys — resolved via `expand()` desugaring + `pane.set_title()` in builder -4. **T4**: ✅ `--here` CLI flag — resolved via CLI arg + builder `here` param +### ~~Phase 3: libtmux Additions~~ — **COMPLETE** (libtmux v0.55.0) -### ~~Phase 3: libtmux Additions~~ — **COMPLETE** (libtmux v0.55.0, issue #635 closed) +All libtmux API additions shipped in v0.55.0. -All libtmux API additions shipped in v0.55.0 (2026-03-07). tmuxp pins `libtmux~=0.55.0`. +### ~~Phase 4: New CLI Commands~~ — **COMPLETE** -- ~~**L1**: `Pane.set_title()`~~ → `pane.py:834-859` -- ~~**L2**: `Server(tmux_bin=...)`~~ → `server.py:142` -- ~~**L3**: Pre-execution `logger.debug`~~ → `common.py:263-268` +T5 (`tmuxp stop`), T10 (`tmuxp new`, `tmuxp copy`, `tmuxp delete`). -### Phase 4: New CLI Commands +### ~~Phase 5: CLI Flags & Features~~ — **MOSTLY COMPLETE** -1. ~~**T5**: `tmuxp stop` command~~ -2. **T10**: `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands +- ~~T7: `--no-shell-command-before` flag~~ ✅ +- ~~T9: `--debug` mode~~ ✅ +- ~~T6: Lifecycle hook config keys~~ ✅ -### Phase 5: CLI Flags & Larger Features +### Phase 6: Remaining -1. ~~**T7**: `--no-shell-command-before` flag~~ ✅ -2. ~~**T9**: `--debug` / dry-run mode~~ ✅ -3. **T6**: Lifecycle hook config keys — complex, needs design -4. **T8**: Config templating — significant architectural addition +1. **T8**: Config templating — significant architectural addition (Jinja2 or `string.Template` pass before YAML parsing, with `key=value` CLI args) +2. **Dead config keys**: `config`, `socket_name` (from tmuxinator importer) — builder doesn't read them. Low priority since CLI flags serve the same purpose. +3. **`clear` config key**: teamocil importer preserves it but builder ignores it. libtmux has `Pane.clear()`. Low priority. +4. **Edge case test coverage**: YAML aliases/anchors, numeric/emoji window names, pane title syntax. From 0ce9ab6c9e1a141e13c1625197d6b66f60da9ea1 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 19:04:45 -0500 Subject: [PATCH 096/152] 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 8688e340910be05d99488934a734d4166e59c5f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 19:04:50 -0500 Subject: [PATCH 097/152] 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 d8a951cf8d311174a0b4478f1f4452fad9d34b99 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 19:04:54 -0500 Subject: [PATCH 098/152] docs(notes[plan]): Mark clear config key as complete why: Track resolution of the clear dead config key. what: - Update dead config keys table: clear now resolved - Update Phase 6 remaining items: clear marked done --- notes/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 468f429d37..46dc805ede 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -105,7 +105,7 @@ Keys produced by importers but silently ignored by the builder: |---|---|---|---| | `config` | tmuxinator importer | Never read | Dead data — extracted `-f` path, CLI uses `-f` flag directly | | `socket_name` | tmuxinator importer | Never read | Dead data — CLI uses `-L` flag directly | -| `clear` | teamocil importer | Never read | Dead data — builder doesn't read it, but libtmux has `Pane.clear()` (L4) | +| ~~`clear`~~ | ~~teamocil importer~~ | ~~Never read~~ | ✅ Resolved — `config_after_window()` sends `clear` to all panes when `clear: true` | | ~~`shell_command` (session-level)~~ | ~~tmuxinator importer~~ | ~~Not a valid session key~~ | ✅ Resolved — I1: `pre` now maps to `before_script` | | ~~`shell_command_after`~~ | ~~teamocil importer~~ | ✅ `config_after_window()` | ✅ Resolved — T3 | | ~~`height` (pane)~~ | ~~teamocil importer~~ | ~~Dead data~~ | ✅ Resolved — warned + dropped | @@ -181,5 +181,5 @@ T5 (`tmuxp stop`), T10 (`tmuxp new`, `tmuxp copy`, `tmuxp delete`). 1. **T8**: Config templating — significant architectural addition (Jinja2 or `string.Template` pass before YAML parsing, with `key=value` CLI args) 2. **Dead config keys**: `config`, `socket_name` (from tmuxinator importer) — builder doesn't read them. Low priority since CLI flags serve the same purpose. -3. **`clear` config key**: teamocil importer preserves it but builder ignores it. libtmux has `Pane.clear()`. Low priority. +3. ~~**`clear` config key**~~: ✅ Resolved — `config_after_window()` sends `clear` to all panes when `clear: true`. 4. **Edge case test coverage**: YAML aliases/anchors, numeric/emoji window names, pane title syntax. From 67efc4024733f026203925ea9eea92adb6d1b793 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 19:14:51 -0500 Subject: [PATCH 099/152] 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 84f7906180c75607ac2fb47775e9bc6eaa3ad291 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 19:14:58 -0500 Subject: [PATCH 100/152] 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 ee2c7f3a1dfddb0ca359bd74a4d8cfe45fc9d687 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 19:15:19 -0500 Subject: [PATCH 101/152] docs(notes[plan]): Mark dead config keys (config, socket_name) as complete why: Track resolution of the remaining dead config keys from tmuxinator importer. what: - Update dead config keys table: config and socket_name now resolved - Update Phase 6 remaining items: dead config keys marked done --- notes/plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 46dc805ede..748f957847 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -103,8 +103,8 @@ Keys produced by importers but silently ignored by the builder: | Key | Producer | Builder Handling | Status | |---|---|---|---| -| `config` | tmuxinator importer | Never read | Dead data — extracted `-f` path, CLI uses `-f` flag directly | -| `socket_name` | tmuxinator importer | Never read | Dead data — CLI uses `-L` flag directly | +| ~~`config`~~ | ~~tmuxinator importer~~ | ~~Never read~~ | ✅ Resolved — `load_workspace()` reads as fallback for `-f` CLI flag | +| ~~`socket_name`~~ | ~~tmuxinator importer~~ | ~~Never read~~ | ✅ Resolved — `load_workspace()` reads as fallback for `-L` CLI flag | | ~~`clear`~~ | ~~teamocil importer~~ | ~~Never read~~ | ✅ Resolved — `config_after_window()` sends `clear` to all panes when `clear: true` | | ~~`shell_command` (session-level)~~ | ~~tmuxinator importer~~ | ~~Not a valid session key~~ | ✅ Resolved — I1: `pre` now maps to `before_script` | | ~~`shell_command_after`~~ | ~~teamocil importer~~ | ✅ `config_after_window()` | ✅ Resolved — T3 | @@ -180,6 +180,6 @@ T5 (`tmuxp stop`), T10 (`tmuxp new`, `tmuxp copy`, `tmuxp delete`). ### Phase 6: Remaining 1. **T8**: Config templating — significant architectural addition (Jinja2 or `string.Template` pass before YAML parsing, with `key=value` CLI args) -2. **Dead config keys**: `config`, `socket_name` (from tmuxinator importer) — builder doesn't read them. Low priority since CLI flags serve the same purpose. +2. ~~**Dead config keys**~~: ✅ Resolved — `config`, `socket_name`, `socket_path` now read as fallbacks in `load_workspace()`. CLI flags override. 3. ~~**`clear` config key**~~: ✅ Resolved — `config_after_window()` sends `clear` to all panes when `clear: true`. 4. **Edge case test coverage**: YAML aliases/anchors, numeric/emoji window names, pane title syntax. From 75ee9fc49a23856573f1965c8bbc5dbae606c3f1 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 20:15:15 -0500 Subject: [PATCH 102/152] 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 3a9eca0267e59578a631b45f46ae0fa17aa23ccc Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 20:15:21 -0500 Subject: [PATCH 103/152] 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 2a76f2a80293d265dfcffab8aedb4cd31f6ba1ac Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 20:15:46 -0500 Subject: [PATCH 104/152] docs(notes[plan]): Mark T8 config templating as complete why: Track resolution of config templating feature. what: - Update T8 section with resolution details - Update Phase 6 remaining items --- notes/plan.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 748f957847..e0333a5e04 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -78,12 +78,9 @@ Resolved in `feat(util,builder,cli[load,stop],loader)` — 4 lifecycle hook conf Resolved in `feat(cli[load])` — `--no-shell-command-before` flag added to `tmuxp load`. When set, strips `shell_command_before` from session, window, and pane levels after `expand()` but before `trickle()`. Equivalent to tmuxinator's `--no-pre-window`. -### T8. No Config Templating +### T8. Config Templating ✅ Resolved -- **Blocker**: tmuxp has no user-defined variable interpolation. Environment variable expansion (`$VAR` via `os.path.expandvars()`) already works in most config values — `session_name`, `window_name`, `start_directory`, `before_script`, `environment`, `options`, `global_options` (see `loader.py:108-160`). But there is no way to pass custom `key=value` variables at load time. -- **Blocks**: tmuxinator ERB templating (`<%= @settings["key"] %>`). -- **Required**: Add a Jinja2 or Python `string.Template` pass before YAML parsing. Allow `key=value` CLI args to set template variables. This is a significant architectural addition. -- **Non-breaking**: Opt-in feature, existing configs are unaffected. +Resolved in `feat(loader,cli[load])` — `render_template()` in `loader.py` replaces `{{ variable }}` expressions in raw config content before YAML/JSON parsing. `--set KEY=VALUE` CLI flag (repeatable) passes template context through `load_workspace()` → `ConfigReader._from_file(template_context=...)`. Zero new dependencies (regex-based, no Jinja2). Unknown `{{ var }}` expressions left unchanged. Coexists with existing `$ENV_VAR` expansion (which runs after YAML parsing in `expand()`). Tests: `test_render_template` (9 parametrized unit tests), `test_load_workspace_template_context`/`_no_context` (CLI integration). ### T9. `--debug` CLI Flag ✅ Resolved @@ -179,7 +176,7 @@ T5 (`tmuxp stop`), T10 (`tmuxp new`, `tmuxp copy`, `tmuxp delete`). ### Phase 6: Remaining -1. **T8**: Config templating — significant architectural addition (Jinja2 or `string.Template` pass before YAML parsing, with `key=value` CLI args) +1. ~~**T8**~~: ✅ Resolved — `{{ variable }}` templating with `--set KEY=VALUE` CLI flag. 2. ~~**Dead config keys**~~: ✅ Resolved — `config`, `socket_name`, `socket_path` now read as fallbacks in `load_workspace()`. CLI flags override. 3. ~~**`clear` config key**~~: ✅ Resolved — `config_after_window()` sends `clear` to all panes when `clear: true`. 4. **Edge case test coverage**: YAML aliases/anchors, numeric/emoji window names, pane title syntax. From 947202447960bac6f46254e67ebad90d2fd9eed8 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 21:08:20 -0500 Subject: [PATCH 105/152] 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 95c6bb27f017eb47deb3bcdaa96d5e11ca26f4aa Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 21:08:27 -0500 Subject: [PATCH 106/152] 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: - 7 parametrized window name coercion tests (int, bool, float, None, emoji, mixed) - 1 numeric window name survives expand() integration test - 1 YAML aliases/anchors resolution test - 5 parametrized _convert_named_panes() unit tests - 2 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 ad847947b84bb4f0af8f224d081fc19db3ef00c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 21:08:55 -0500 Subject: [PATCH 107/152] docs(notes[plan]): Mark edge case test coverage as complete why: Track resolution of all Tier 2 edge cases. what: - Mark YAML aliases, numeric/emoji window names, pane title syntax as resolved - Update Phase 6 remaining items --- notes/plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index e0333a5e04..a029b35d21 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -144,11 +144,11 @@ Resolved — `with_env_var` logs warning (unsupported), `cmd_separator` logs war All previously-identified Tier 1 gaps (v1.x string panes, `commands` key, `rvm`, `pre` scope) are now fixed and tested. -### Tier 2: Edge Cases (Low Priority) +### Tier 2: Edge Cases ✅ Resolved -- **YAML aliases/anchors**: Real tmuxinator configs use `&defaults` / `*defaults` — no test coverage -- **Numeric/emoji window names**: `222:`, `true:`, `🍩:` — YAML type coercion edge cases untested -- **Pane title syntax**: `pane_name: command` dict form — no fixtures +- ~~**YAML aliases/anchors**~~: ✅ Tested — aliases resolve transparently via YAML parser before import +- ~~**Numeric/emoji window names**~~: ✅ Fixed + tested — `str(k)` coercion in importer prevents `TypeError` in `expandshell()` +- ~~**Pane title syntax**~~: ✅ Fixed + tested — `_convert_named_panes()` converts `{name: commands}` to `{shell_command, title}` ## Implementation Priority @@ -179,4 +179,4 @@ T5 (`tmuxp stop`), T10 (`tmuxp new`, `tmuxp copy`, `tmuxp delete`). 1. ~~**T8**~~: ✅ Resolved — `{{ variable }}` templating with `--set KEY=VALUE` CLI flag. 2. ~~**Dead config keys**~~: ✅ Resolved — `config`, `socket_name`, `socket_path` now read as fallbacks in `load_workspace()`. CLI flags override. 3. ~~**`clear` config key**~~: ✅ Resolved — `config_after_window()` sends `clear` to all panes when `clear: true`. -4. **Edge case test coverage**: YAML aliases/anchors, numeric/emoji window names, pane title syntax. +4. ~~**Edge case test coverage**~~: ✅ Resolved — YAML aliases tested, numeric/emoji window names fixed + tested, pane title syntax fixed + tested. From e4ee51c8bad9f151af8f1ff45eeb747d8e82997c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 04:57:51 -0500 Subject: [PATCH 108/152] 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 beb81f49c968a33081bab9262482b137f5e54a09 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 04:58:31 -0500 Subject: [PATCH 109/152] 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 1b80e9d902b4562e7a46ab21bccccb864aa02710 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 04:59:22 -0500 Subject: [PATCH 110/152] 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 709dc8523b6d5f1927c818a1762c60b7aeac6490 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 07:32:56 -0500 Subject: [PATCH 111/152] docs(CHANGES): Add 1.68.0 release notes for parity features why: Document all new features and fixes landing in the parity branch. what: - New commands: stop, new, copy, delete - Lifecycle hooks, config templating, new config keys - New load flags, importer improvements, bug fixes --- CHANGES | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 0330651353..a914139974 100644 --- a/CHANGES +++ b/CHANGES @@ -33,11 +33,96 @@ $ pipx install \ ## tmuxp 1.68.0 (Yet to be released) -<!-- To maintainers and contributors: Please add notes for the forthcoming version below --> +### New commands -<!-- KEEP THIS PLACEHOLDER - DO NOT REMOVE OR MODIFY THIS LINE --> -_Notes on the upcoming release will go here._ -<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE --> +#### `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`. + +### Bug fixes + +- Only fire `on_project_start` hook when load actually proceeds (not on + cancellation) (#1025) +- Only fire `on_project_restart` after the user confirms reattach (#1025) ## tmuxp 1.67.0 (2026-03-08) From 94ae202b568a5a210a9e17becba74e8c89434a04 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 07:33:43 -0500 Subject: [PATCH 112/152] docs(cli[stop,new,copy,delete]): Add command docs and API pages why: New CLI commands need user-facing and API documentation. what: - Add CLI doc pages for stop, new, copy, delete - Add API doc pages for stop, new, copy, delete - Update toctrees in cli/index.md and api/cli/index.md --- docs/api/cli/copy.md | 8 ++++++++ docs/api/cli/delete.md | 8 ++++++++ docs/api/cli/index.md | 4 ++++ docs/api/cli/new.md | 8 ++++++++ docs/api/cli/stop.md | 8 ++++++++ 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 +++++++++++++++++++++++++++++++++++++ 10 files changed, 164 insertions(+) create mode 100644 docs/api/cli/copy.md create mode 100644 docs/api/cli/delete.md create mode 100644 docs/api/cli/new.md create mode 100644 docs/api/cli/stop.md 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 diff --git a/docs/api/cli/copy.md b/docs/api/cli/copy.md new file mode 100644 index 0000000000..9e15404999 --- /dev/null +++ b/docs/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/api/cli/delete.md b/docs/api/cli/delete.md new file mode 100644 index 0000000000..7873640e95 --- /dev/null +++ b/docs/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/api/cli/index.md b/docs/api/cli/index.md index 1381fbc90f..f5c5ebcd44 100644 --- a/docs/api/cli/index.md +++ b/docs/api/cli/index.md @@ -19,6 +19,10 @@ ls progress search shell +stop +new +copy +delete utils ``` diff --git a/docs/api/cli/new.md b/docs/api/cli/new.md new file mode 100644 index 0000000000..bec0862ce1 --- /dev/null +++ b/docs/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/api/cli/stop.md b/docs/api/cli/stop.md new file mode 100644 index 0000000000..7f01b8a4d3 --- /dev/null +++ b/docs/api/cli/stop.md @@ -0,0 +1,8 @@ +# tmuxp stop - `tmuxp.cli.stop` + +```{eval-rst} +.. automodule:: tmuxp.cli.stop + :members: + :show-inheritance: + :undoc-members: +``` 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 cb8bf94b3b..3b86eea5a3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -12,6 +12,7 @@ load shell ls search +stop ``` ```{toctree} @@ -22,6 +23,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 +``` From 7cb02302b818f024bded3cad11f9c3eee13f79d7 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 07:34:02 -0500 Subject: [PATCH 113/152] docs(cli[load]): Document new flags and config templating why: New load flags and config templating need user-facing documentation. 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 47d6178b274b23706d71db3208ce40606a8eb3b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 07:34:29 -0500 Subject: [PATCH 114/152] docs(config[top-level]): Document new config keys and lifecycle hooks why: New configuration keys need user-facing documentation. 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 49d9ff1a8133205de2b61ee17938b883705d6b6b Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 07:35:47 -0500 Subject: [PATCH 115/152] docs(config[examples]): Add examples and tests for new features why: New features need example configs and tests to verify them. 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 9534366b1606415f35ac8412726c0178b962b930 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 07:35:52 -0500 Subject: [PATCH 116/152] docs(comparison): Update feature tables for parity why: Feature comparison table needs to reflect new parity features. what: - Update tmuxp version from 1.64.0 to 1.68.0 - 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 <config>` | `tmuxinator edit <project>` | `teamocil --edit <layout>` | -| Show/debug config | (none) | `tmuxinator debug <project>` | `teamocil --show` / `--debug` | -| Create new config | (none) | `tmuxinator new <project>` | (none) | -| Copy config | (none) | `tmuxinator copy <src> <dst>` | (none) | -| Delete config | (none) | `tmuxinator delete <project>` | (none) | +| Show/debug config | `tmuxp load --debug` | `tmuxinator debug <project>` | `teamocil --show` / `--debug` | +| Create new config | `tmuxp new <project>` | `tmuxinator new <project>` | (none) | +| Copy config | `tmuxp copy <src> <dst>` | `tmuxinator copy <src> <dst>` | (none) | +| Delete config | `tmuxp delete <project>` | `tmuxinator delete <project>` | (none) | | Delete all configs | (none) | `tmuxinator implode` | (none) | -| Stop/kill session | (none) | `tmuxinator stop <project>` | (none) | +| Stop/kill session | `tmuxp stop <session>` | `tmuxinator stop <project>` | (none) | | Stop all sessions | (none) | `tmuxinator stop-all` | (none) | | Freeze/export session | `tmuxp freeze <session>` | (none) | (none) | | Convert format | `tmuxp convert <file>` | (none) | (none) | @@ -158,9 +158,9 @@ teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Com | Search workspaces | `tmuxp search <pattern>` | (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 5fd1328e40d017f40760138d71b633d5a859eec9 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 07:35:57 -0500 Subject: [PATCH 117/152] 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..726a74424c 100644 --- a/docs/cli/import.md +++ b/docs/cli/import.md @@ -38,6 +38,14 @@ $ tmuxp import teamocil /path/to/file.json ```` +### Importer improvements + +The teamocil importer now supports: + +- **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 ``` ```` + +### Importer improvements + +The tmuxinator importer now supports: + +- **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 fd8a21575f..a88dd2bc45 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,6 +37,7 @@ api/index history migration about_tmux +Comparison <comparison> glossary GitHub <https://github.com/tmux-python/tmuxp> ``` 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 2ed81f3ad94fe9a1f0c5b94108cf60cf9e617957 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Mon, 16 Mar 2026 21:28:31 -0500 Subject: [PATCH 118/152] 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 228b73c8e0fa80feecbd5427585da58054c6a177 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 09:25:49 -0500 Subject: [PATCH 119/152] 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: - Replace f'cd "{start_directory}"' with f"cd {shlex.quote(...)}" - Add import shlex to builder.py - Add test for --here mode with special characters in directory names --- 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 4a763eac777de0ce4dad02ac655b9e7a8d23af70 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 09:31:08 -0500 Subject: [PATCH 120/152] 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 5ae04a57a3..fd7a74a42c 100644 --- a/conftest.py +++ b/conftest.py @@ -100,6 +100,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 29fe884965f72203878a7249e413fbe46e95e0ee Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 09:33:03 -0500 Subject: [PATCH 121/152] 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 9e6c0425acb8db2cc9ad848a0bc4f8af15c69f5d Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 09:35:16 -0500 Subject: [PATCH 122/152] fix(importers[tmuxinator]): Warn when explicit socket_name overrides cli_args -L why: The -L socket_name from cli_args was silently overwritten by an explicit socket_name key, which could confuse users debugging config. 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 5298c7a758b926e292a5971e4fbf5031e28a6cd0 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 14:21:58 -0500 Subject: [PATCH 123/152] 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 5b52733e29f37913584fe33c8c4fa7f160877579 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 16:42:30 -0500 Subject: [PATCH 124/152] 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 3186a02f48485fb363e399970d5e6b5b75570408 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 16:44:22 -0500 Subject: [PATCH 125/152] 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 b8ed1909aae728e7c6ef3b090654cc1a056a0d15 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 16:47:10 -0500 Subject: [PATCH 126/152] 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 cb5a1a587a282ff544125cbef26d985033fb01f2 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 16:50:35 -0500 Subject: [PATCH 127/152] 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 41279b2fd6c24a1c9fbb48bde850aef796a1bf86 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 16:52:31 -0500 Subject: [PATCH 128/152] 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 dc68200891e8e78252d43d03cba47b2678536113 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 18:25:08 -0500 Subject: [PATCH 129/152] 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 c9725bf690be96e79bed5bafbfbd295e662f5759 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 18:27:15 -0500 Subject: [PATCH 130/152] 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 2666ac2613c131826dc005f7681be36a0de9bc45 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 18:29:22 -0500 Subject: [PATCH 131/152] 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 f46dae99970e24d7b0ac9ed3c4eeeaef876d4d7f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 18:31:15 -0500 Subject: [PATCH 132/152] 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 6a56f507925fc11e4c8a85ef9d9af96dd0ecca3b Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 23:32:39 -0500 Subject: [PATCH 133/152] 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 47b44dd4b0bd2aaa292e59f265423aea7d15846e Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 23:35:08 -0500 Subject: [PATCH 134/152] 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 0929d2e57dbf19b4bee831e44c88b78d743ffc1a Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 23:38:36 -0500 Subject: [PATCH 135/152] 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 e25e43485a338bc5b8a6cf2e74dffce8766bd49c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 23:41:07 -0500 Subject: [PATCH 136/152] 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 0e5b95adcca6bde72825b69843b83111d191e89c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 21 Mar 2026 23:43:06 -0500 Subject: [PATCH 137/152] 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 - Same pattern as command_new (new.py:101-107) - 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 eaf96fb9f60d331951a8af59fc63ec7213e89a0a Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 07:35:56 -0500 Subject: [PATCH 138/152] 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 066db98b09ba93ffa8d5a26827dd1c50383508c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 07:36:19 -0500 Subject: [PATCH 139/152] 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 5b84a1c24f05e12609d081042bfe0ca22558f323 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 07:36:34 -0500 Subject: [PATCH 140/152] 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 726a74424c..45a1768a71 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 now supports: -- **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 a753426101463e3bc709b6ab3345fe39abf33cfa Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 08:33:12 -0500 Subject: [PATCH 141/152] 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 917d4e470194035368aff998c5e3128e5f139f9f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 08:34:04 -0500 Subject: [PATCH 142/152] 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 2e05c474c182b6f2536f43119c4ecb500fbdc440 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 10:45:47 -0500 Subject: [PATCH 143/152] 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 81256aa0b5c05df758613699fd18a6593a2978c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 10:46:24 -0500 Subject: [PATCH 144/152] 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 c0aa981539c9b296e280e618aec74774e126f12c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 10:47:06 -0500 Subject: [PATCH 145/152] 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 <root> before all hooks. Now prepends cd <start_directory> && 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 705b1ac220d1b6dfacc0ef024c48f4807bc600c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 10:48:19 -0500 Subject: [PATCH 146/152] 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 30148bc8a7a0c75649e479cdade9656fe213a237 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 10:49:43 -0500 Subject: [PATCH 147/152] 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 b4333c2a9a9119a56a0faac1028c5f31c9d1bc8c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 11:10:57 -0500 Subject: [PATCH 148/152] 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 <test_socket>. +_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 3e9361fa27dd91973cca702ccf7ce997374b8f49 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 19:57:51 -0500 Subject: [PATCH 149/152] 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 45a1768a71..726a74424c 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 now supports: -- **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 cd46b4ac900c4eecf90ba699ed0e8dadf2613239 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 19:58:26 -0500 Subject: [PATCH 150/152] 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 151c6938593246014a22f06b4475a94cc4ad1c1f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 19:59:08 -0500 Subject: [PATCH 151/152] fix(cli[stop]): Exit with code 1 on SessionNotFound command_stop caught SessionNotFound, printed the error, but returned normally with exit code 0. This broke CI/scripting that relies on non-zero exit codes to detect failures. Now calls sys.exit(1). --- 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 92ba6f42df8a8d6b1912e6c8b66eb7ed59b74b60 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 22 Mar 2026 19:59:43 -0500 Subject: [PATCH 152/152] 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"])