From 8eb975e4e1cf84818910a622de2e05cc290a28b6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:00:15 -0600 Subject: [PATCH 01/89] ai(rules[check:*,implement]) Add tmuxinator parity commands --- .claude/commands/check/parity.md | 80 +++++++++++++ .claude/commands/check/shortcomings.md | 51 ++++++++ .claude/commands/implement.md | 155 +++++++++++++++++++++++++ 3 files changed, 286 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..82dca9f61c --- /dev/null +++ b/.claude/commands/check/parity.md @@ -0,0 +1,80 @@ +# /check:parity — Feature Parity Analysis + +Deep-dive analysis of tmuxp vs tmuxinator and teamocil. Updates comparison docs and parity notes. + +This command produces three artifacts: +- Feature parity notes (`notes/parity-*.md`) +- Import behavior notes (`notes/import-*.md`) +- Auto-detection heuristics (in `docs/comparison.md`) + +## Workflow + +1. **Read source code** of all three projects (see `.claude/commands/implement.md` for shared reference codebase paths). Focus on `src/tmuxp/workspace/`, `tmuxinator/lib/tmuxinator/{project,window,pane}.rb` + `hooks/` + `assets/template.erb`, and `teamocil/lib/teamocil/tmux/{session,window,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..bd7481caa0 --- /dev/null +++ b/.claude/commands/implement.md @@ -0,0 +1,155 @@ +# /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 +``` + +See `CLAUDE.md` "Testing Guidelines" for the project-wide test conventions (function tests only, real fixtures, `tmp_path`, `monkeypatch`). + +--- + +## 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 b68d09d9b395189650ba31aef35b23ac8251b69d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:08:05 -0600 Subject: [PATCH 02/89] 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 bd157eeabf705912f822a302a4b20301b906227d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Feb 2026 06:08:11 -0600 Subject: [PATCH 03/89] 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 | 262 +++++++++++++++++++++++++++++++++++++ 1 file changed, 262 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..5f3d5229b2 --- /dev/null +++ b/notes/parity-tmuxinator.md @@ -0,0 +1,262 @@ +# Tmuxinator Parity Analysis + +*Last updated: 2026-02-08* +*Tmuxinator version analyzed: 3.3.7* +*tmuxp version: 1.47.0+* + +## Confirmed parity (no action needed) + +| Feature | tmuxp equivalent | +|---|---| +| `tmuxinator start --name ` | `tmuxp load -s ` | + +## 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. + +### 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 01544e16598a2087dd60e25d98ed610375072b90 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 04/89] 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 | 182 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 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..af838e131f --- /dev/null +++ b/notes/parity-teamocil.md @@ -0,0 +1,182 @@ +# 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. + +## Confirmed parity (no action needed) + +| Feature | tmuxp equivalent / note | +|---|---| +| Window `focus: true` | Same key, same semantics; teamocil applies via `session.rb:24-25` `select-window` after build, tmuxp does the same | +| Pane `focus: true` | Same key, same semantics | +| Window `options` | Same key, maps to `set-window-option` | + +## 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. 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 aca877eb133488d52370792736ed46122ad61639 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 05/89] 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 0efa4b71369937c3069510c5335905f4b6e885a3 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 06/89] 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 61abc114ab5a477a47548cc6775b601e3a42ab58 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 07/89] 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 | 24 ++++-------------------- 3 files changed, 8 insertions(+), 24 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 5f3d5229b2..e0afc25258 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -52,7 +52,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 @@ -186,23 +186,7 @@ Creates a config file pre-populated from a running tmux session. ### 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 | +For the full key-by-key mapping, see `notes/import-tmuxinator.md`. **What it misses or handles incorrectly:** @@ -213,7 +197,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. | @@ -237,7 +221,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 a4b2ed316d0bb1436ffdb5908142204d9a328e5c 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 08/89] 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 db1811b6f708a565409c68f307509164b47d591a 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 09/89] 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 c48ed72dcfe3bf924cb49cd4822c66df50761a70 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 10/89] 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 42f5605005abba897f4dd332ee72575542a1e68f 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 11/89] 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 | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 480dd7f2be..48b3a14a0b 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -18,29 +18,11 @@ ## 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. +| Tool | Build model | Practical consequence | +|---|---|---| +| tmuxp | libtmux API calls (Python ORM over `tmux(1)`) | Mid-build error recovery, plugin hooks, scripting via Python API | +| tmuxinator | ERB-rendered bash script then `exec`'d | Debuggable script output (`tmuxinator debug`), ERB templating, no mid-build recovery | +| teamocil | `Command` objects joined with `; ` and run via `Kernel.system()` | Simple and predictable, no hooks/templating/recovery | ## Configuration Keys @@ -65,7 +47,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 +121,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 c584bb21fafbaea110bf6848135914fa14f800df 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 12/89] 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 48b3a14a0b..db7803af19 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -43,8 +43,8 @@ | 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 5e7b498d2289d8e4a022ab775dd758bcb9f7b3ef 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 13/89] 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 db7803af19..fbde370007 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -56,8 +56,8 @@ | 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 af838e131f..c120934fd8 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -32,13 +32,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 e0afc25258..51a2326d3b 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -20,13 +20,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 a672022cc45040af1ba963f035595aa94181179d 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 14/89] 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 c120934fd8..d524cdbf0a 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -139,7 +139,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 3282a2ed55eaba92479c653f48401719e87843a3 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 15/89] 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 fbde370007..c324ae741f 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 | @@ -30,7 +30,7 @@ | 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) | @@ -42,7 +42,7 @@ | 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) | @@ -64,7 +64,8 @@ | 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 @@ -81,8 +82,8 @@ | 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) | @@ -98,8 +99,8 @@ | 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 @@ -119,9 +120,9 @@ | 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) | @@ -138,6 +139,7 @@ | 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 cd50db2be1ce559f69dab5d5a50d2f2caff87451 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 16/89] 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 51a2326d3b..845afafe31 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+* ## Confirmed parity (no action needed) @@ -52,7 +52,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 @@ -162,15 +162,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>`) @@ -178,9 +174,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 @@ -223,7 +219,7 @@ For the full key-by-key mapping, see `notes/import-tmuxinator.md`. 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 b945c3c529b5f7ff82e2abf6727df88a7e47dfe7 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 17/89] 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 | 61 ++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index d524cdbf0a..7fcc5ac12b 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -13,6 +13,8 @@ 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. + ## Confirmed parity (no action needed) | Feature | tmuxp equivalent / note | @@ -23,7 +25,15 @@ The current tmuxp importer (`importers.py:import_teamocil`) **targets the v0.x f ## 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` @@ -41,7 +51,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` @@ -53,7 +63,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` @@ -65,7 +75,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. Multiple Commands Joined by Semicolon +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. Multiple Commands Joined by Semicolon **Source**: `lib/teamocil/tmux/pane.rb` @@ -100,22 +112,7 @@ Teamocil joins multiple pane commands with `; ` and sends them as a single `send ### 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 | +For the full v0.x key-by-key mapping, see `notes/import-teamocil.md`. **What it misses:** @@ -127,12 +124,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"] @@ -141,9 +138,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) @@ -164,20 +161,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 f0d929a1471518805d329b3d0325e42b828cb840 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 18/89] 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 f7a75a300f83d7cf366d47e4cd9fc1844545d863 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 19/89] 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 481fde8fdc4ab3ebb627ad58a20eae4f7cbf6c8c 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 20/89] 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 | 234 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 notes/plan.md diff --git a/notes/plan.md b/notes/plan.md new file mode 100644 index 0000000000..68520e83be --- /dev/null +++ b/notes/plan.md @@ -0,0 +1,234 @@ +# Parity Implementation Plan + +*Last updated: 2026-02-08* + +## 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 db14a857ecffbc15ddf142ea4b66f79098514a3b 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 21/89] fix(plan): Correct line refs, add isinstance bug, expand 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 68520e83be..157adbac35 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -13,12 +13,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 @@ -39,7 +39,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` | @@ -72,16 +72,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 @@ -113,7 +114,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. @@ -148,9 +149,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 e2b59beea0b0e425a8a7dd65dbe50298ab0a82d1 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 22/89] fix(plan): Correct 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 157adbac35..99e753c474 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -6,7 +6,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. @@ -51,8 +51,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 @@ -67,9 +67,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 a93b64a595c8156170aef74022a8ea97c33dbbab 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 23/89] fix(plan): Correct pane-title insertion points and teamocil 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 99e753c474..058520a471 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -60,8 +60,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. @@ -192,7 +192,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 d06c900376a497f4e6fa2d5f0f8d21763f98a9ee 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 24/89] 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 058520a471..0892343d1b 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -52,7 +52,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 @@ -177,7 +177,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 0665e488be6c8c0824345d9ea3d9c9ec941ff714 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 25/89] 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 0892343d1b..c7974673a1 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -169,6 +169,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 @@ -210,10 +211,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 @@ -222,6 +225,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 88b5a4a71e27989089dfd2a9ed9cba55c35bda92 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 26/89] 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 c7974673a1..08638f4293 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -229,13 +229,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 e4c7ccd7d213179ab64fe8e2d939b8aed01dea4c 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 27/89] fix(plan): Correct logging description and shlex 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 08638f4293..52dca5abc3 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -23,7 +23,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. @@ -157,7 +157,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 6e21bd3b631e8078216d2a0715a7b1dc0d127d39 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 28/89] 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 c324ae741f..90eecc0b4f 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 | @@ -64,8 +64,8 @@ | 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 @@ -154,3 +154,33 @@ | 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 79ee7d57225ee6abcff5ff6517d043af3bccb62e 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 29/89] 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 845afafe31..19602b3569 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* ## Confirmed parity (no action needed) @@ -28,6 +28,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 0613488057281158cece2b54270a72f50a58f31a 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 30/89] 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 7fcc5ac12b..93f913e56c 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 3f0004ef6c02861c5da930b9c959d1e3f316b0fe 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 31/89] 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 8fb29bbb781b0096e04d9915f70f0107b233f0f0 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 32/89] 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 93f913e56c..108483f34f 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -101,8 +101,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 6030cc014c575a67c4e54ed6cda289d3399a8362 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 33/89] 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 90eecc0b4f..6bad56b5bf 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -101,6 +101,9 @@ | 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 47bbc445bad2c8e165b614e34381a7f422eac211 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 34/89] 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 | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 6bad56b5bf..175f6af242 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -177,13 +177,3 @@ If tmuxp were to auto-detect and transparently load tmuxinator/teamocil configs, | `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 95fa5abe4898a8e1dcdc72733b0d5949d818b61d 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 35/89] 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 | 42 ++++++++++------------------------------ 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/notes/import-teamocil.md b/notes/import-teamocil.md index cfbd932bcd..8ccb59e0b0 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,41 +220,19 @@ 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 - `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` (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` (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** | From c048105d188ba30a88f07562dd48568287880802 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 36/89] 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 108483f34f..ebb8757978 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -129,8 +129,10 @@ For the full v0.x key-by-key mapping, see `notes/import-teamocil.md`. | 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 2ca93c940461f5b9ad4745ce37b894cf78bd3259 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 37/89] 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 175f6af242..624b2a0a14 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -41,7 +41,7 @@ | 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) | @@ -64,8 +64,8 @@ | 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 2e82f05c12e019cc5c1b1b6f2e069ef453eba768 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 38/89] =?UTF-8?q?docs(import-tmuxinator):=20Document=20pre?= =?UTF-8?q?=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 f0dd7c17b74462e2bf9c1082bd5fbdfb67ac0502 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 39/89] =?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 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md index 19602b3569..b6d44faf59 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -209,13 +209,15 @@ For the full key-by-key mapping, see `notes/import-tmuxinator.md`. | `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 0b4d6f0054bf250385b2dc704cefe13a4b42bc5f 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 40/89] docs(plan): Add solo pre-to-before_script scope bug and update analysis 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 52dca5abc3..1b16338b2e 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* ## libtmux Limitations @@ -139,7 +139,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 | @@ -147,13 +147,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 + +- **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 11c5e9dc4b1347a60c7e089e4c3a29467025d635 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 41/89] docs(plan): Expand available libtmux APIs, fix stale teamocil claim, add 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 1b16338b2e..626d75666b 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -44,6 +44,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 @@ -142,7 +147,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) @@ -185,7 +191,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 @@ -198,18 +204,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 @@ -223,7 +239,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 55e04ce68015176211a13a91c636a6b8143c21cd 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 42/89] 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 626d75666b..ac56478d33 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -149,6 +149,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) @@ -201,6 +202,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 26d81ec47ba43d3ede2e31b1834655ecfe58cb8c 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 43/89] 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 624b2a0a14..027a694d22 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 @@ -83,7 +83,7 @@ | 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) | @@ -100,6 +100,7 @@ | 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 %) | @@ -144,6 +145,7 @@ | 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 20f5e5f91e1a2b92e7a5a58f5e22046e5f3a442b 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 44/89] 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 b6d44faf59..b61c92ac10 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* @@ -69,8 +69,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. @@ -82,8 +84,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 cc8df5b4496d998a2d69cea8d75dbec440db79e4 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 45/89] 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 ebb8757978..24c4c8f788 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. + ## Confirmed parity (no action needed) | Feature | tmuxp equivalent / note | From a70491d4974c5b493aeef09c65d00ecf1541f20c 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 46/89] 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 6258093476eef8d9109800a334dd82d12a673f98 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 47/89] 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 8ccb59e0b0..67e5324a59 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 f6364a4869308243cbc0ce41669b413fc917f901 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 48/89] docs(plan): Add synchronize deprecation context 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 ac56478d33..2b5a7eee6d 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* ## libtmux Limitations @@ -55,7 +55,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 4c878b61c69e3d69131a6df7c14165c3ef570c12 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 49/89] 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 2b5a7eee6d..7da9a17f9a 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -79,8 +79,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()`. @@ -167,7 +167,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 1f26c6e20f1d249336a43a471bd7dce8ea3bab66 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 50/89] 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 b61c92ac10..542d6d7747 100644 --- a/notes/parity-tmuxinator.md +++ b/notes/parity-tmuxinator.md @@ -153,8 +153,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 @@ -166,7 +178,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 dac633b5b4f4e8a7d84cd98108d930f2f9868b51 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 51/89] 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 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md index 24c4c8f788..26d0f2b29e 100644 --- a/notes/parity-teamocil.md +++ b/notes/parity-teamocil.md @@ -24,6 +24,8 @@ Note: teamocil v1.x does not create new sessions — it **renames** the current | Window `focus: true` | Same key, same semantics; teamocil applies via `session.rb:24-25` `select-window` after build, tmuxp does the same | | Pane `focus: true` | Same key, same semantics | | Window `options` | Same key, maps to `set-window-option` | +| Layout timing (per-pane vs end) | teamocil reapplies `select-layout` after each pane (`pane.rb:9`); tmuxp applies once at end (`builder.py:511`). Same result for named layouts; tmuxp is correct for custom layout strings | +| Root path expansion | teamocil uses `File.expand_path`; tmuxp uses `expandshell()` in `loader.py` | ## Features teamocil has that tmuxp lacks From aa3719538b1ee2166dd3d740d8489b6f3234ca0a 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 52/89] 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 7da9a17f9a..ea856a84a7 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -229,6 +229,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 8a9c16389e67f1e12800fd000b4bf27a1a9f8f2d 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 53/89] 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 | 42 +++++++++++++++----------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md index 921376beee..38502b8e1f 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. @@ -211,29 +227,3 @@ These are features that cannot be imported because tmuxp lacks the underlying ca **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` → `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) | -| `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 (`true`/`before` deprecated in tmuxinator → `after` recommended) | **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 368ec52b94ff4ec72508c78401e606cb1f231c16 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sun, 15 Mar 2026 09:01:39 -0500 Subject: [PATCH 54/89] docs(notes[plan]): Mark L1/L2/L3 resolved, fix stale line numbers, reorganize phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: libtmux#635 closed — all three API additions shipped in v0.55.0. Line references drifted as code grew. Phase structure misrepresented what's blocked vs unblocked. what: - Mark L1, L2, L3 as RESOLVED with correct libtmux source locations - Fix 20+ stale line number references across importers.py, builder.py, libtmux - Move T2 from Phase 3 to Phase 2, mark Phase 3 COMPLETE - Move T9 to Phase 5 as unblocked, remove L2/L3 from Phase 5 - Fix EnvironmentMixin location (common.py, not session.py) --- notes/plan.md | 216 +++++++++++++++----------------------------------- 1 file changed, 65 insertions(+), 151 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index ea856a84a7..461b2ca163 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -1,33 +1,22 @@ # Parity Implementation Plan -*Last updated: 2026-03-07* +*Last updated: 2026-03-15* ## 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. -### 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:142`. Unblocks tmuxinator `tmux_command`. -### 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. -- **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. +**Status**: Pre-execution `logger.debug` added at `common.py:263-268`. Unblocks T9. + +**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) @@ -35,57 +24,58 @@ 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 ### 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`). 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. +- **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. Silently ignored if present. +- **Blocks**: Pane synchronization (tmuxinator `synchronize: true/before/after`). tmuxinator deprecates `true`/`before` in favor of `after` (`project.rb:21-29`), but all three values still function — import should honor original semantics. +- **Required**: For `before`/`true`, call `window.set_option("synchronize-panes", "on")` in `build()` ~line 541 (after `on_window_create` plugin hook, before `iter_create_panes()` loop). For `after`, same call in `config_after_window()` ~line 822. For `false`/omitted, no action. +- **Note**: In tmux 3.2+ (tmuxp's minimum), `synchronize-panes` is a dual-scope option (window\|pane, `options-table.c:1423`); window-level set propagates to later splits. +- **Non-breaking**: New optional config key. + +### Simple gaps (table form) + +| ID | Gap | Required change | Notes | +|---|---|---|---| +| T3 | No `shell_command_after` config key | In `config_after_window()` ~line 822 (or after `iter_create_panes()`), read `window_config.get("shell_command_after", [])` and `pane.send_keys()` to each pane | teamocil importer already produces this on the **window** dict (`importers.py:149`); only builder read is missing | +| T7 | No `--no-shell-command-before` CLI flag | Add flag to `cli/load.py`; clear `shell_command_before` from all levels before `trickle()` (`loader.py:245-256`) | Mirrors tmuxinator `--no-pre-window` | +| T10 | Missing config management commands (`new`, `copy`, `delete`) | Add CLI commands in `cli/`; straightforward file operations | Mirrors tmuxinator `new`, `copy`, `delete`, `implode` | ### 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()` 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. -### T3. No `shell_command_after` Config Key - -- **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 -- **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. @@ -110,13 +100,6 @@ These libtmux APIs already exist and do NOT need changes: - **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 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. @@ -131,103 +114,33 @@ These libtmux APIs already exist and do NOT need changes: - **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** (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) | +| `shell_command` (session-level) | tmuxinator importer | `importers.py:71` | Not a valid session key | **Bug** (I1 Bug B): `pre` commands lost when both `pre` and `pre_window` exist | +| `config` | tmuxinator importer | `importers.py:48,55` | Never read | Dead data — extracted `-f` path goes nowhere | +| `socket_name` | tmuxinator importer | `importers.py:63` | Never read | Dead data — CLI uses `-L` flag | +| `clear` | teamocil importer | `importers.py:158` | 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:166` | Never read | Dead data — tmuxp has no after-command support | ## Importer Bugs (No Builder Changes Needed) -### 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 - -- **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 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. - -#### 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 - -- **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 - -- **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`. -- **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 - -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: +For full bug analysis with file:line refs, see `notes/import-tmuxinator.md` and `notes/import-teamocil.md`. -- **`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. +| ID | Importer | Bug | Fix scope | +|---|---|---|---| +| I1 | tmuxinator | `pre` (alone) maps to `shell_command_before` (per-pane) instead of `before_script` (once); combo `pre` + `pre_window` writes `pre` to invalid `shell_command` key and `isinstance` checks the wrong var (lines 70-81) | Map `pre` → `before_script`; check `pre_window`'s type, not `pre`'s. **Caveat**: `before_script` runs `Popen` without `shell=True`, so pipes/redirects in `pre` won't work — long-term fix needs T6 lifecycle hooks | +| I2 | tmuxinator | `cli_args`/`tmux_options` use `str.replace("-f", "")` (lines 50-60); breaks on `-L`/`-S` flags or paths containing `-f` | Use `shlex.split()` + iterate flags | +| I3 | teamocil | Redundant `for _b in w["filters"]["before"]` loops set same value N times (lines 160-166) | Replace with direct assignment | +| I4 | teamocil | v1.x format not detected: string panes cause `"cmd" in str` substring check; `commands` key dropped; pane `width` silently dropped, `height` passes through | Add format detection; handle v1.x keys; preserve `width`/`height` (libtmux `Pane.resize` exists, L4) or warn | +| I5 | tmuxinator | Missing translations: `rvm`, `pre_tab`, `startup_window`, `startup_pane`, `on_project_first_start`, `post`, `socket_path`, `attach: false` | Add explicit mappings (see `import-tmuxinator.md` for per-key target) | +| I6 | teamocil | Missing v1.x mappings (`commands`, window/pane `focus`, window `options`, string pane shorthand) and v0.x `with_env_var`/`height` | Add explicit mappings | +| I7 | teamocil | TODOs at `importers.py:132-134` (`with_env_var`, `clear`, `cmd_separator`) — first two are real v0.x features, third is irrelevant | Implement `with_env_var` → `environment: { TEAMOCIL: "1" }`; wire `clear` to `Pane.clear()` (L4); remove `cmd_separator` TODO | ## Test Coverage Gaps @@ -280,26 +193,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 options via `session.set_option()`. + - Update tmuxinator importer for named pane syntax +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:142` +- ~~**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 a14574bb1929a13f1542062ba72d59f2b9e1e0e0 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 07:54:03 -0500 Subject: [PATCH 55/89] fix(import[teamocil]): Replace redundant filter loops with direct assignment why: Eliminate dead loop overhead in importers.py:160-166. what: - Replace `for _b in w["filters"]["before"]:` (and after) with direct assignment. The loop iterated N times but only ever set the same value, producing identical output but obscuring intent. - Output unchanged; tests confirm equivalence. ref: notes/plan.md I3 --- src/tmuxp/workspace/importers.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 65184d73a4..0eba01afa3 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -159,11 +159,9 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "filters" in w: if "before" in w["filters"]: - for _b in w["filters"]["before"]: - window_dict["shell_command_before"] = 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"] + window_dict["shell_command_after"] = w["filters"]["after"] if "root" in w: window_dict["start_directory"] = w.pop("root") From 7c661841eb3f044cf1af556bf12374f0d4d01766 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 07:58:10 -0500 Subject: [PATCH 56/89] fix(import[tmuxinator]): Map pre to before_script, warn on shell constructs why: tmuxinator's `pre` is a session-level hook that runs once before any windows are created. The importer was mapping it to `shell_command_before` (per-pane) when alone, and to the invalid `shell_command` key (silently dropped) when combined with `pre_window`. Both produced wrong semantics and execution counts. Additionally, the combo branch's `isinstance(workspace_dict["pre"], str)` checked the wrong variable to decide how to wrap `pre_window`. what: - Map `pre` -> `before_script` (session-level), matching tmuxinator semantics in template.erb:18-19. - Map `pre_window` -> `shell_command_before` independently; isinstance check now correctly inspects `pre_window`'s type. - Warn via `logger.warning` with extra={"tmux_key": "pre", ...} when `pre` contains shell metacharacters (`|`, `&&`, `>`, `$(`, etc.) since `before_script` runs via `subprocess.Popen` without `shell=True`. - New module-private `_has_shell_metachars()` helper. - Fix existing fixtures (test2/test3) that encoded the old buggy behavior; expected output now matches corrected mapping. ref: notes/plan.md I1 --- src/tmuxp/workspace/importers.py | 59 ++++++++++++++++++----- tests/fixtures/import_tmuxinator/test2.py | 3 +- tests/fixtures/import_tmuxinator/test3.py | 2 +- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 0eba01afa3..d326c7b09f 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -8,6 +8,35 @@ logger = logging.getLogger(__name__) +_SHELL_METACHAR_TOKENS = ("|", "&&", "||", ">", "<", "$(", "`", ";") + + +def _has_shell_metachars(value: t.Any) -> bool: + """Return True if value contains shell metacharacters that need a real shell. + + tmuxp's `before_script` runs via `subprocess.Popen` after `shlex.split()` — + no shell process. Pipes, redirects, command substitution, and `&&` chains + don't work. This helper flags such values so the caller can warn the user. + + Strings, lists of strings, and dicts containing strings are scanned. Any + other type returns False (nothing to scan). + + >>> _has_shell_metachars("plain command") + False + >>> _has_shell_metachars("echo a | grep b") + True + >>> _has_shell_metachars(["safe", "echo $(date)"]) + True + >>> _has_shell_metachars(None) + False + """ + if isinstance(value, str): + return any(token in value for token in _SHELL_METACHAR_TOKENS) + if isinstance(value, list): + return any(_has_shell_metachars(item) for item in value) + return False + + def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -67,18 +96,24 @@ 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: - tmuxp_workspace["shell_command"] = workspace_dict["pre"] - - if isinstance(workspace_dict["pre"], 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"] + if "pre" in workspace_dict: + pre_value = workspace_dict["pre"] + if _has_shell_metachars(pre_value): + logger.warning( + "pre contains shell constructs that will not work in " + "before_script (runs without shell=True)", + extra={ + "tmux_key": "pre", + "tmux_session": tmuxp_workspace.get("session_name"), + }, + ) + tmuxp_workspace["before_script"] = pre_value + + if "pre_window" in workspace_dict: + pre_window = workspace_dict["pre_window"] + tmuxp_workspace["shell_command_before"] = ( + [pre_window] if isinstance(pre_window, str) else pre_window + ) 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 00379670e143cfd65faab35fa56aac916e0a586d Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:03:16 -0500 Subject: [PATCH 57/89] tests(import[tmuxinator]): Cover pre-to-before_script fix and shell-metachar warning why: Lock in the corrected pre/pre_window mapping behavior. what: - New fixtures: test_pre_alone (solo pre -> before_script), test_pre_combo (pre + pre_window map independently), and test_pre_shell (pre with shell metacharacters). - Wire fixtures into the parametrized test_config_to_dict suite. - Add test_import_tmuxinator_warns_on_shell_metachars_in_pre asserting WARNING record with extra.tmux_key="pre" and extra.tmux_session set. Build workspace inline since the parametrized fixture dict gets mutated by the importer's .pop() calls in earlier tests. - Add test_import_tmuxinator_no_warning_when_pre_is_plain as the negative case. ref: notes/plan.md I1 --- tests/fixtures/import_tmuxinator/__init__.py | 2 +- .../import_tmuxinator/test_pre_alone.py | 21 +++++++ .../import_tmuxinator/test_pre_alone.yaml | 5 ++ .../import_tmuxinator/test_pre_combo.py | 21 +++++++ .../import_tmuxinator/test_pre_combo.yaml | 7 +++ .../import_tmuxinator/test_pre_shell.py | 19 +++++++ .../import_tmuxinator/test_pre_shell.yaml | 4 ++ tests/workspace/test_import_tmuxinator.py | 57 +++++++++++++++++++ 8 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/import_tmuxinator/test_pre_alone.py create mode 100644 tests/fixtures/import_tmuxinator/test_pre_alone.yaml create mode 100644 tests/fixtures/import_tmuxinator/test_pre_combo.py create mode 100644 tests/fixtures/import_tmuxinator/test_pre_combo.yaml create mode 100644 tests/fixtures/import_tmuxinator/test_pre_shell.py create mode 100644 tests/fixtures/import_tmuxinator/test_pre_shell.yaml diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py index 84508e0405..80359ab6ef 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, test_pre_alone, test_pre_combo, test_pre_shell diff --git a/tests/fixtures/import_tmuxinator/test_pre_alone.py b/tests/fixtures/import_tmuxinator/test_pre_alone.py new file mode 100644 index 0000000000..919295cfbd --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_alone.py @@ -0,0 +1,21 @@ +"""tmuxinator fixture: solo `pre` maps to `before_script`.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_pre_alone.yaml", +) +tmuxinator_dict = { + "name": "alone", + "root": "~/test", + "pre": "sudo /etc/rc.d/mysqld start", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "alone", + "start_directory": "~/test", + "before_script": "sudo /etc/rc.d/mysqld start", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_pre_alone.yaml b/tests/fixtures/import_tmuxinator/test_pre_alone.yaml new file mode 100644 index 0000000000..c4d1f226ea --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_alone.yaml @@ -0,0 +1,5 @@ +name: alone +root: ~/test +pre: sudo /etc/rc.d/mysqld start +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_pre_combo.py b/tests/fixtures/import_tmuxinator/test_pre_combo.py new file mode 100644 index 0000000000..728f165024 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_combo.py @@ -0,0 +1,21 @@ +"""tmuxinator fixture: `pre` + `pre_window` map independently.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_pre_combo.yaml", +) +tmuxinator_dict = { + "name": "combo", + "pre": "sudo /etc/rc.d/mysqld start", + "pre_window": ["echo first", "echo second"], + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "combo", + "before_script": "sudo /etc/rc.d/mysqld start", + "shell_command_before": ["echo first", "echo second"], + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_pre_combo.yaml b/tests/fixtures/import_tmuxinator/test_pre_combo.yaml new file mode 100644 index 0000000000..6c65f9360c --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_combo.yaml @@ -0,0 +1,7 @@ +name: combo +pre: sudo /etc/rc.d/mysqld start +pre_window: +- echo first +- echo second +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_pre_shell.py b/tests/fixtures/import_tmuxinator/test_pre_shell.py new file mode 100644 index 0000000000..b65dd99d8a --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_shell.py @@ -0,0 +1,19 @@ +"""tmuxinator fixture: `pre` with shell metacharacters triggers warning.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_pre_shell.yaml", +) +tmuxinator_dict = { + "name": "shell-pre", + "pre": "echo a | grep b && echo done", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "shell-pre", + "before_script": "echo a | grep b && echo done", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_pre_shell.yaml b/tests/fixtures/import_tmuxinator/test_pre_shell.yaml new file mode 100644 index 0000000000..96976304f1 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_pre_shell.yaml @@ -0,0 +1,4 @@ +name: shell-pre +pre: echo a | grep b && echo done +windows: +- editor: vim diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 457605f2ab..d3d0ce30c2 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -40,6 +40,24 @@ class TmuxinatorConfigTestFixture(t.NamedTuple): tmuxinator_dict=fixtures.test3.tmuxinator_dict, tmuxp_dict=fixtures.test3.expected, ), + TmuxinatorConfigTestFixture( + test_id="pre_alone", # solo pre -> before_script + tmuxinator_yaml=fixtures.test_pre_alone.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_pre_alone.tmuxinator_dict, + tmuxp_dict=fixtures.test_pre_alone.expected, + ), + TmuxinatorConfigTestFixture( + test_id="pre_combo", # pre + pre_window map independently + tmuxinator_yaml=fixtures.test_pre_combo.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_pre_combo.tmuxinator_dict, + tmuxp_dict=fixtures.test_pre_combo.expected, + ), + TmuxinatorConfigTestFixture( + test_id="pre_shell_metachars", # warning when pre has shell constructs + tmuxinator_yaml=fixtures.test_pre_shell.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_pre_shell.tmuxinator_dict, + tmuxp_dict=fixtures.test_pre_shell.expected, + ), ] @@ -76,3 +94,42 @@ 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_import_tmuxinator_warns_on_shell_metachars_in_pre( + caplog: pytest.LogCaptureFixture, +) -> None: + """`pre` containing shell constructs emits WARNING with tmux_key=pre.""" + workspace = { + "name": "shell-pre", + "pre": "echo a | grep b && echo done", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "pre" + ] + assert len(warnings) == 1 + assert getattr(warnings[0], "tmux_session", None) == "shell-pre" + + +def test_import_tmuxinator_no_warning_when_pre_is_plain( + caplog: pytest.LogCaptureFixture, +) -> None: + """A `pre` value without shell metacharacters emits no warning.""" + workspace = { + "name": "plain-pre", + "pre": "sudo /etc/rc.d/mysqld start", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "pre" + ] + assert warnings == [] From ce87be3badf51a258d97606077bbaafa4c6d824e Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:05:27 -0500 Subject: [PATCH 58/89] fix(import[tmuxinator]): Use shlex to parse cli_args/tmux_options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The previous `str.replace("-f", "").strip()` global replacement was flag-unaware. It would corrupt paths containing `-f` (e.g. `~/-f-test.conf`) and silently dropped `-L`/`-S` socket flags entirely. what: - New module-private `_parse_tmuxinator_tmux_args()` splits the argument string with `shlex.split()` and walks tokens, extracting `-f` -> `config`, `-L` -> `socket_name`, `-S` -> `socket_path`. - Unknown flags log `WARNING` with extra={"tmux_key": <flag>}. - Existing test2 (`cli_args: "-f ~/.tmux.mac.conf"`) and test3 (`tmux_options: "-f ~/.tmux.mac.conf"`) still pass — same output for the simple case. - Behavior change: configs that previously passed `-L`/`-S` flags in `cli_args`/`tmux_options` now have those mapped to their tmuxp equivalents instead of being dropped. ref: notes/plan.md I2 --- src/tmuxp/workspace/importers.py | 56 ++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index d326c7b09f..22a76ebe50 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__) @@ -37,6 +38,40 @@ def _has_shell_metachars(value: t.Any) -> bool: return False +def _parse_tmuxinator_tmux_args( + args_str: str, + target: dict[str, t.Any], + session_name: str | None, +) -> None: + """Parse tmuxinator `cli_args`/`tmux_options` into individual tmux flags. + + Splits via `shlex` and walks tokens to extract `-f` (config), `-L` + (socket name), and `-S` (socket path). Unknown flags are warned. + Mutates ``target`` in place. + """ + mapping = {"-f": "config", "-L": "socket_name", "-S": "socket_path"} + tokens = shlex.split(args_str) + i = 0 + while i < len(tokens): + flag = tokens[i] + if flag in mapping: + if i + 1 < len(tokens): + target[mapping[flag]] = tokens[i + 1] + i += 2 + else: + logger.warning( + "tmux flag requires a value but none was provided", + extra={"tmux_key": flag, "tmux_session": session_name}, + ) + i += 1 + else: + logger.warning( + "unrecognized tmux flag in cli_args/tmux_options", + extra={"tmux_key": flag, "tmux_session": session_name}, + ) + i += 1 + + def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -73,20 +108,13 @@ 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() - ) + args_str = workspace_dict.get("cli_args") or workspace_dict.get("tmux_options") + if args_str: + _parse_tmuxinator_tmux_args( + args_str, + tmuxp_workspace, + tmuxp_workspace.get("session_name"), + ) if "socket_name" in workspace_dict: tmuxp_workspace["socket_name"] = workspace_dict["socket_name"] From 3d4df506cd430355d434bfbad3ab3109df1f18c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:07:20 -0500 Subject: [PATCH 59/89] tests(import[tmuxinator]): Cover cli_args/tmux_options shlex parsing why: Verify I2 fix for fragile -f parsing. what: - New fixture test_cli_args_multi: `-f ~/.tmux.conf -L mysock -S /tmp/...` -> config + socket_name + socket_path (was: only -f extracted, -L/-S silently dropped). - New fixture test_cli_args_dash_path: `-f /home/me/-f-config.conf` -> config preserved (was: corrupted by str.replace("-f", "")). - Both wired into parametrized test_config_to_dict. - New test_import_tmuxinator_warns_on_unknown_cli_args_flag asserts WARNING fires with extra.tmux_key=<flag>. ref: notes/plan.md I2 --- tests/fixtures/import_tmuxinator/__init__.py | 11 ++++++- .../test_cli_args_dash_path.py | 19 +++++++++++ .../test_cli_args_dash_path.yaml | 4 +++ .../import_tmuxinator/test_cli_args_multi.py | 21 ++++++++++++ .../test_cli_args_multi.yaml | 4 +++ tests/workspace/test_import_tmuxinator.py | 32 +++++++++++++++++++ 6 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/import_tmuxinator/test_cli_args_dash_path.py create mode 100644 tests/fixtures/import_tmuxinator/test_cli_args_dash_path.yaml create mode 100644 tests/fixtures/import_tmuxinator/test_cli_args_multi.py create mode 100644 tests/fixtures/import_tmuxinator/test_cli_args_multi.yaml diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py index 80359ab6ef..61ca7b2dea 100644 --- a/tests/fixtures/import_tmuxinator/__init__.py +++ b/tests/fixtures/import_tmuxinator/__init__.py @@ -2,4 +2,13 @@ from __future__ import annotations -from . import test1, test2, test3, test_pre_alone, test_pre_combo, test_pre_shell +from . import ( + test1, + test2, + test3, + test_cli_args_dash_path, + test_cli_args_multi, + test_pre_alone, + test_pre_combo, + test_pre_shell, +) diff --git a/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.py b/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.py new file mode 100644 index 0000000000..9635367e75 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.py @@ -0,0 +1,19 @@ +"""tmuxinator fixture: tmux_options path containing literal `-f` (regression).""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_cli_args_dash_path.yaml", +) +tmuxinator_dict = { + "name": "dash-path", + "tmux_options": "-f /home/me/-f-config.conf", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "dash-path", + "config": "/home/me/-f-config.conf", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.yaml b/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.yaml new file mode 100644 index 0000000000..0db364edc2 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_cli_args_dash_path.yaml @@ -0,0 +1,4 @@ +name: dash-path +tmux_options: -f /home/me/-f-config.conf +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_cli_args_multi.py b/tests/fixtures/import_tmuxinator/test_cli_args_multi.py new file mode 100644 index 0000000000..f6e13b0bf9 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_cli_args_multi.py @@ -0,0 +1,21 @@ +"""tmuxinator fixture: cli_args with -f, -L, -S all extracted.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_cli_args_multi.yaml", +) +tmuxinator_dict = { + "name": "multi-flags", + "cli_args": "-f ~/.tmux.conf -L mysock -S /tmp/tmux.sock", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "multi-flags", + "config": "~/.tmux.conf", + "socket_name": "mysock", + "socket_path": "/tmp/tmux.sock", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_cli_args_multi.yaml b/tests/fixtures/import_tmuxinator/test_cli_args_multi.yaml new file mode 100644 index 0000000000..a0d4b1ee11 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_cli_args_multi.yaml @@ -0,0 +1,4 @@ +name: multi-flags +cli_args: -f ~/.tmux.conf -L mysock -S /tmp/tmux.sock +windows: +- editor: vim diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index d3d0ce30c2..900f53c70d 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -58,6 +58,18 @@ class TmuxinatorConfigTestFixture(t.NamedTuple): tmuxinator_dict=fixtures.test_pre_shell.tmuxinator_dict, tmuxp_dict=fixtures.test_pre_shell.expected, ), + TmuxinatorConfigTestFixture( + test_id="cli_args_multi_flags", # -f/-L/-S all extracted + tmuxinator_yaml=fixtures.test_cli_args_multi.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_cli_args_multi.tmuxinator_dict, + tmuxp_dict=fixtures.test_cli_args_multi.expected, + ), + TmuxinatorConfigTestFixture( + test_id="cli_args_dash_in_path", # regression: -f in path doesn't corrupt + tmuxinator_yaml=fixtures.test_cli_args_dash_path.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_cli_args_dash_path.tmuxinator_dict, + tmuxp_dict=fixtures.test_cli_args_dash_path.expected, + ), ] @@ -133,3 +145,23 @@ def test_import_tmuxinator_no_warning_when_pre_is_plain( if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "pre" ] assert warnings == [] + + +def test_import_tmuxinator_warns_on_unknown_cli_args_flag( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unrecognized tmux flag in cli_args emits a WARNING.""" + workspace = { + "name": "weird", + "cli_args": "-f ~/.tmux.conf -X bogus", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "-X" + ] + assert len(warnings) == 1 + assert getattr(warnings[0], "tmux_session", None) == "weird" From b48483c51eaf6c079683de1ef2bd160cefc64990 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:10:08 -0500 Subject: [PATCH 60/89] feat(import[tmuxinator]): Add rvm, pre_tab, startup_*, socket_path, attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: These tmuxinator config keys were silently dropped by the importer. Add explicit translation matching tmuxinator's actual semantics. what: - `rvm` -> wrapped as `rvm use {value}` in shell_command_before, mirroring tmuxinator project.rb:181. - `pre_tab` -> shell_command_before (deprecated predecessor to pre_window). - `pre_window` source resolution now follows tmuxinator's OR-fallback chain (rbenv -> rvm -> pre_tab -> pre_window) — first non-nil wins, matching project.rb:175-188. - `on_project_first_start` -> before_script when `pre` not set. - `socket_path` -> socket_path (top-level pass-through). - `attach: false` -> WARNING directing user to `tmuxp load -d`. - `startup_window` / `startup_pane`: scan windows by name OR integer index, set `focus: true` on matched window/pane. Unresolved values emit WARNING. Pane resolution converts positional pane strings to dict form when adding `focus`. - New helpers: `_apply_tmuxinator_startup_focus()`, `_resolve_startup_index()`. ref: notes/plan.md I5 --- src/tmuxp/workspace/importers.py | 154 ++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 15 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 22a76ebe50..c666623f3f 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -86,6 +86,11 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + # Top-level shallow copy so .pop()s below don't mutate the caller's dict. + # Nested window/pane dicts are still mutated in place by the per-window + # loop; if you pass a dict whose nested values are shared elsewhere, + # deepcopy upstream. + workspace_dict = {**workspace_dict} logger.debug( "importing tmuxinator workspace", extra={ @@ -119,14 +124,32 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "socket_name" in workspace_dict: tmuxp_workspace["socket_name"] = workspace_dict["socket_name"] + if "socket_path" in workspace_dict: + tmuxp_workspace["socket_path"] = workspace_dict["socket_path"] + + if workspace_dict.get("attach") is False: + logger.warning( + "attach: false has no tmuxp config equivalent; pass `-d` to " + "`tmuxp load` instead", + extra={ + "tmux_key": "attach", + "tmux_session": tmuxp_workspace.get("session_name"), + }, + ) + tmuxp_workspace["windows"] = [] if "tabs" in workspace_dict: workspace_dict["windows"] = workspace_dict.pop("tabs") - if "pre" in workspace_dict: - pre_value = workspace_dict["pre"] - if _has_shell_metachars(pre_value): + # `pre` runs once before the session is created (template.erb:18-19). + # `on_project_first_start` is the equivalent in the modern hook system + # (project.rb:165-168) — fall back to it when `pre` is not set. + pre_source = workspace_dict.get("pre") + if pre_source is None and "on_project_first_start" in workspace_dict: + pre_source = workspace_dict["on_project_first_start"] + if pre_source is not None: + if _has_shell_metachars(pre_source): logger.warning( "pre contains shell constructs that will not work in " "before_script (runs without shell=True)", @@ -135,20 +158,26 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: "tmux_session": tmuxp_workspace.get("session_name"), }, ) - tmuxp_workspace["before_script"] = pre_value - - if "pre_window" in workspace_dict: - pre_window = workspace_dict["pre_window"] - tmuxp_workspace["shell_command_before"] = ( - [pre_window] if isinstance(pre_window, str) else pre_window - ) + tmuxp_workspace["before_script"] = pre_source + # tmuxinator computes pre_window from an OR-fallback chain + # (project.rb:175-188): rbenv -> rvm -> pre_tab -> pre_window. First + # non-nil wins. `rbenv` and `rvm` are wrapped as shell commands. + pre_window_chain: list[str] = [] 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"]), - ) + pre_window_chain.append(f"rbenv shell {workspace_dict['rbenv']}") + elif "rvm" in workspace_dict: + pre_window_chain.append(f"rvm use {workspace_dict['rvm']}") + elif "pre_tab" in workspace_dict: + pre_window_chain.append(workspace_dict["pre_tab"]) + elif "pre_window" in workspace_dict: + pre_tab_or_window = workspace_dict["pre_window"] + if isinstance(pre_tab_or_window, list): + pre_window_chain.extend(pre_tab_or_window) + else: + pre_window_chain.append(pre_tab_or_window) + if pre_window_chain: + tmuxp_workspace["shell_command_before"] = pre_window_chain for window_dict in workspace_dict["windows"]: for k, v in window_dict.items(): @@ -173,9 +202,104 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "layout" in v: window_dict["layout"] = v["layout"] tmuxp_workspace["windows"].append(window_dict) + + _apply_tmuxinator_startup_focus(workspace_dict, tmuxp_workspace) return tmuxp_workspace +def _apply_tmuxinator_startup_focus( + workspace_dict: dict[str, t.Any], + tmuxp_workspace: dict[str, t.Any], +) -> None: + """Map tmuxinator `startup_window`/`startup_pane` to tmuxp `focus: true`. + + tmuxinator passes these values directly to tmux as targets + (`project.rb:261-267`); a string is a window name, an integer is an + index. tmuxp uses a per-window/per-pane `focus` flag instead. We + resolve the value (try int first, fall back to name) and set `focus: + true` on the matching window (and pane). Unresolved values warn. + """ + windows = tmuxp_workspace.get("windows", []) + + target_window_idx: int | None = None + if "startup_window" in workspace_dict: + target_window_idx = _resolve_startup_index( + workspace_dict["startup_window"], + windows, + "window_name", + "startup_window", + tmuxp_workspace.get("session_name"), + ) + if target_window_idx is not None: + windows[target_window_idx]["focus"] = True + + if "startup_pane" in workspace_dict and target_window_idx is not None: + panes = windows[target_window_idx].get("panes") or [] + # Pane lists in this importer are positional strings; we can't add + # a `focus` flag without converting. Skip unless panes are dicts. + normalized_panes: list[t.Any] = [] + pane_target = _resolve_startup_index( + workspace_dict["startup_pane"], + panes, + None, + "startup_pane", + tmuxp_workspace.get("session_name"), + ) + for idx, pane in enumerate(panes): + if idx == pane_target: + if isinstance(pane, dict): + normalized_panes.append({**pane, "focus": True}) + else: + normalized_panes.append( + {"shell_command": [pane] if pane else [], "focus": True}, + ) + else: + normalized_panes.append(pane) + windows[target_window_idx]["panes"] = normalized_panes + + +def _resolve_startup_index( + value: t.Any, + items: list[t.Any], + name_key: str | None, + field: str, + session_name: str | None, +) -> int | None: + """Resolve a tmuxinator startup_window/startup_pane value to a list index. + + Tries integer first (treated as 0-based index into ``items``), then + falls back to matching by ``name_key`` if provided. Returns None and + warns if no match. + + >>> windows = [{"window_name": "shell"}, {"window_name": "editor"}] + >>> _resolve_startup_index(0, windows, "window_name", "f", None) + 0 + >>> _resolve_startup_index("editor", windows, "window_name", "f", None) + 1 + >>> _resolve_startup_index("missing", windows, "window_name", "f", None) is None + True + """ + try: + idx = int(value) + except (TypeError, ValueError): + idx = None + if idx is not None and 0 <= idx < len(items): + return idx + if name_key is not None: + for i, item in enumerate(items): + if isinstance(item, dict) and item.get(name_key) == value: + return i + logger.warning( + "%s value did not match any window/pane", + field, + extra={ + "tmux_key": field, + "tmux_session": session_name, + }, + ) + return None + + def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `teamocil`_ yaml workspace. From 4a74f798b02bfb98203eb8fc7efb8dc391a148cd Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:12:29 -0500 Subject: [PATCH 61/89] tests(import[tmuxinator]): Cover rvm, startup_*, socket_path, attach why: Lock in the I5 behavior for the new tmuxinator key translations. what: - New fixtures: test_rvm, test_startup_window_by_{name,index}, test_socket_path. All wired into parametrized test_config_to_dict. - New unit tests: - test_import_tmuxinator_warns_on_attach_false (I5: attach false -> WARNING about CLI -d). - test_import_tmuxinator_on_project_first_start_falls_back_to_pre (I5: hook key mapped to before_script when `pre` absent). - test_import_tmuxinator_pre_window_chain_first_match_wins (I5: rbenv wins over rvm/pre_tab/pre_window per project.rb:175-188). - test_import_tmuxinator_warns_on_unresolved_startup_window (I5: name not found -> WARNING with tmux_key=startup_window). ref: notes/plan.md I5 --- tests/fixtures/import_tmuxinator/__init__.py | 4 + tests/fixtures/import_tmuxinator/test_rvm.py | 17 +++ .../fixtures/import_tmuxinator/test_rvm.yaml | 4 + .../import_tmuxinator/test_socket_path.py | 19 +++ .../import_tmuxinator/test_socket_path.yaml | 4 + .../test_startup_window_by_index.py | 22 ++++ .../test_startup_window_by_index.yaml | 6 + .../test_startup_window_by_name.py | 22 ++++ .../test_startup_window_by_name.yaml | 6 + tests/workspace/test_import_tmuxinator.py | 112 ++++++++++++++++++ 10 files changed, 216 insertions(+) create mode 100644 tests/fixtures/import_tmuxinator/test_rvm.py create mode 100644 tests/fixtures/import_tmuxinator/test_rvm.yaml create mode 100644 tests/fixtures/import_tmuxinator/test_socket_path.py create mode 100644 tests/fixtures/import_tmuxinator/test_socket_path.yaml create mode 100644 tests/fixtures/import_tmuxinator/test_startup_window_by_index.py create mode 100644 tests/fixtures/import_tmuxinator/test_startup_window_by_index.yaml create mode 100644 tests/fixtures/import_tmuxinator/test_startup_window_by_name.py create mode 100644 tests/fixtures/import_tmuxinator/test_startup_window_by_name.yaml diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py index 61ca7b2dea..0a25a590d2 100644 --- a/tests/fixtures/import_tmuxinator/__init__.py +++ b/tests/fixtures/import_tmuxinator/__init__.py @@ -11,4 +11,8 @@ test_pre_alone, test_pre_combo, test_pre_shell, + test_rvm, + test_socket_path, + test_startup_window_by_index, + test_startup_window_by_name, ) diff --git a/tests/fixtures/import_tmuxinator/test_rvm.py b/tests/fixtures/import_tmuxinator/test_rvm.py new file mode 100644 index 0000000000..d217f70158 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_rvm.py @@ -0,0 +1,17 @@ +"""tmuxinator fixture: `rvm` -> shell_command_before with `rvm use` wrapper.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test_rvm.yaml") +tmuxinator_dict = { + "name": "rvm-project", + "rvm": "3.2.0", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "rvm-project", + "shell_command_before": ["rvm use 3.2.0"], + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_rvm.yaml b/tests/fixtures/import_tmuxinator/test_rvm.yaml new file mode 100644 index 0000000000..da95ec3c36 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_rvm.yaml @@ -0,0 +1,4 @@ +name: rvm-project +rvm: 3.2.0 +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_socket_path.py b/tests/fixtures/import_tmuxinator/test_socket_path.py new file mode 100644 index 0000000000..ebeb932f5f --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_socket_path.py @@ -0,0 +1,19 @@ +"""tmuxinator fixture: `socket_path` passes through to tmuxp top-level.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_socket_path.yaml", +) +tmuxinator_dict = { + "name": "sock-project", + "socket_path": "/tmp/my-tmux.sock", + "windows": [{"editor": "vim"}], +} +expected = { + "session_name": "sock-project", + "socket_path": "/tmp/my-tmux.sock", + "windows": [{"window_name": "editor", "panes": ["vim"]}], +} diff --git a/tests/fixtures/import_tmuxinator/test_socket_path.yaml b/tests/fixtures/import_tmuxinator/test_socket_path.yaml new file mode 100644 index 0000000000..8fe84549f5 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_socket_path.yaml @@ -0,0 +1,4 @@ +name: sock-project +socket_path: /tmp/my-tmux.sock +windows: +- editor: vim diff --git a/tests/fixtures/import_tmuxinator/test_startup_window_by_index.py b/tests/fixtures/import_tmuxinator/test_startup_window_by_index.py new file mode 100644 index 0000000000..2fabad3810 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_startup_window_by_index.py @@ -0,0 +1,22 @@ +"""tmuxinator fixture: `startup_window` resolved by 0-based index.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_startup_window_by_index.yaml", +) +tmuxinator_dict = { + "name": "focus-by-idx", + "startup_window": 2, + "windows": [{"shell": "bash"}, {"editor": "vim"}, {"logs": "tail -f log"}], +} +expected = { + "session_name": "focus-by-idx", + "windows": [ + {"window_name": "shell", "panes": ["bash"]}, + {"window_name": "editor", "panes": ["vim"]}, + {"window_name": "logs", "panes": ["tail -f log"], "focus": True}, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test_startup_window_by_index.yaml b/tests/fixtures/import_tmuxinator/test_startup_window_by_index.yaml new file mode 100644 index 0000000000..f579b6d4bc --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_startup_window_by_index.yaml @@ -0,0 +1,6 @@ +name: focus-by-idx +startup_window: 2 +windows: +- shell: bash +- editor: vim +- logs: tail -f log diff --git a/tests/fixtures/import_tmuxinator/test_startup_window_by_name.py b/tests/fixtures/import_tmuxinator/test_startup_window_by_name.py new file mode 100644 index 0000000000..b87d6421a7 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_startup_window_by_name.py @@ -0,0 +1,22 @@ +"""tmuxinator fixture: `startup_window` resolved by window name.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file( + "import_tmuxinator/test_startup_window_by_name.yaml", +) +tmuxinator_dict = { + "name": "focus-by-name", + "startup_window": "editor", + "windows": [{"shell": "bash"}, {"editor": "vim"}, {"logs": "tail -f log"}], +} +expected = { + "session_name": "focus-by-name", + "windows": [ + {"window_name": "shell", "panes": ["bash"]}, + {"window_name": "editor", "panes": ["vim"], "focus": True}, + {"window_name": "logs", "panes": ["tail -f log"]}, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test_startup_window_by_name.yaml b/tests/fixtures/import_tmuxinator/test_startup_window_by_name.yaml new file mode 100644 index 0000000000..05d0fe8401 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test_startup_window_by_name.yaml @@ -0,0 +1,6 @@ +name: focus-by-name +startup_window: editor +windows: +- shell: bash +- editor: vim +- logs: tail -f log diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 900f53c70d..d5ab394c13 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -70,6 +70,30 @@ class TmuxinatorConfigTestFixture(t.NamedTuple): tmuxinator_dict=fixtures.test_cli_args_dash_path.tmuxinator_dict, tmuxp_dict=fixtures.test_cli_args_dash_path.expected, ), + TmuxinatorConfigTestFixture( + test_id="rvm", # rvm wrapped as `rvm use <ver>` + tmuxinator_yaml=fixtures.test_rvm.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_rvm.tmuxinator_dict, + tmuxp_dict=fixtures.test_rvm.expected, + ), + TmuxinatorConfigTestFixture( + test_id="startup_window_by_name", # name -> focus + tmuxinator_yaml=fixtures.test_startup_window_by_name.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_startup_window_by_name.tmuxinator_dict, + tmuxp_dict=fixtures.test_startup_window_by_name.expected, + ), + TmuxinatorConfigTestFixture( + test_id="startup_window_by_index", # int -> focus + tmuxinator_yaml=fixtures.test_startup_window_by_index.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_startup_window_by_index.tmuxinator_dict, + tmuxp_dict=fixtures.test_startup_window_by_index.expected, + ), + TmuxinatorConfigTestFixture( + test_id="socket_path", # socket_path passes through + tmuxinator_yaml=fixtures.test_socket_path.tmuxinator_yaml, + tmuxinator_dict=fixtures.test_socket_path.tmuxinator_dict, + tmuxp_dict=fixtures.test_socket_path.expected, + ), ] @@ -165,3 +189,91 @@ def test_import_tmuxinator_warns_on_unknown_cli_args_flag( ] assert len(warnings) == 1 assert getattr(warnings[0], "tmux_session", None) == "weird" + + +def test_import_tmuxinator_warns_on_attach_false( + caplog: pytest.LogCaptureFixture, +) -> None: + """`attach: false` emits WARNING directing user to CLI `-d` flag.""" + workspace = { + "name": "no-attach", + "attach": False, + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "attach" + ] + assert len(warnings) == 1 + + +def test_import_tmuxinator_on_project_first_start_falls_back_to_pre( + caplog: pytest.LogCaptureFixture, +) -> None: + """`on_project_first_start` maps to before_script when `pre` absent.""" + workspace = { + "name": "fallback", + "on_project_first_start": "echo first", + "windows": [{"editor": "vim"}], + } + result = importers.import_tmuxinator(workspace) + assert result["before_script"] == "echo first" + + +def test_import_tmuxinator_pre_window_chain_first_match_wins( + caplog: pytest.LogCaptureFixture, +) -> None: + """Rbenv wins over rvm/pre_tab/pre_window per project.rb:175-188.""" + workspace = { + "name": "chain", + "rbenv": "2.7.0", + "rvm": "3.2.0", + "pre_tab": "echo pre_tab", + "pre_window": "echo pre_window", + "windows": [{"editor": "vim"}], + } + result = importers.import_tmuxinator(workspace) + assert result["shell_command_before"] == ["rbenv shell 2.7.0"] + + +def test_import_tmuxinator_warns_on_unresolved_startup_window( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unresolvable `startup_window` value emits WARNING.""" + workspace = { + "name": "miss", + "startup_window": "nonexistent", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING + and getattr(r, "tmux_key", None) == "startup_window" + ] + assert len(warnings) == 1 + + +def test_import_tmuxinator_warns_on_out_of_range_startup_window_int( + caplog: pytest.LogCaptureFixture, +) -> None: + """An integer `startup_window` past the window count emits WARNING.""" + workspace = { + "name": "oor", + "startup_window": 99, + "windows": [{"editor": "vim"}, {"shell": "bash"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING + and getattr(r, "tmux_key", None) == "startup_window" + ] + assert len(warnings) == 1 From 8c2e6bc28e38e7dcd890fa2ea3b03d2288c112be Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:16:50 -0500 Subject: [PATCH 62/89] feat(import[teamocil]): Implement with_env_var, warn on cmd_separator/clear why: Triage the v0.x-only TODOs that were in the importer's docstring. what: - with_env_var (default true in v0.x per teamocil 0.4-stable): when the `session:` wrapper is present (v0.x marker) and `with_env_var` is not explicitly false, emit `environment: {TEAMOCIL: "1"}` at the session level so each pane inherits the env var. - cmd_separator: emit a WARNING that it has no effect in tmuxp, since tmuxp sends commands individually via separate send-keys calls. - clear: still preserved on the window dict, but emit a WARNING that the builder does not yet act on it (deferred to a future T-item). - Refactor the v0.x detection: capture `is_v0x = "session" in workspace_dict` once for the with_env_var gate. - Update docstring TODOs: remove the now-handled keys, document the per-key behavior in a Notes section. - Existing fixture `three_windows_within_a_session` (the only v0.x multisession layout) updated to expect the new TEAMOCIL env var. ref: notes/plan.md I7 --- src/tmuxp/workspace/importers.py | 63 +++++++++++++++++++---- tests/fixtures/import_teamocil/layouts.py | 1 + 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index c666623f3f..aa78192f2d 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -11,6 +11,23 @@ _SHELL_METACHAR_TOKENS = ("|", "&&", "||", ">", "<", "$(", "`", ";") +_FALSY_YAML_STRINGS = frozenset({"false", "no", "off"}) + + +def _is_falsy_yaml(value: t.Any) -> bool: + """Treat False, None, and YAML-style falsy strings as falsy. + + PyYAML usually decodes ``false`` to Python ``False``, but if a user + quotes the value (``with_env_var: "false"``), it arrives as a + truthy non-empty string. Coerce manually so the importer respects + intent rather than Python's bool semantics. + """ + if value is False or value is None: + return True + if isinstance(value, str): + return value.strip().lower() in _FALSY_YAML_STRINGS + return False + def _has_shell_metachars(value: t.Any) -> bool: """Return True if value contains shell metacharacters that need a real shell. @@ -312,14 +329,18 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: Notes ----- - Todos: - - - change 'root' to a cd or start_directory - - width in pane -> main-pain-width - - with_env_var - - clear - - cmd_separator + Behavior of v0.x-only keys: + + - ``with_env_var`` (default ``true`` in v0.x) maps to a session-level + ``environment: {TEAMOCIL: "1"}`` to mirror teamocil 0.4-stable. + - ``clear`` is preserved on the window dict but the builder does not + yet act on it; a warning is emitted. + - ``cmd_separator`` is irrelevant since tmuxp sends commands + individually; a warning is emitted. + - ``width`` is currently dropped from panes; geometry support is a + separate builder change. """ + is_v0x = "session" in workspace_dict _inner = workspace_dict.get("session", workspace_dict) logger.debug( "importing teamocil workspace", @@ -328,7 +349,7 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: tmuxp_workspace: dict[str, t.Any] = {} - if "session" in workspace_dict: + if is_v0x: workspace_dict = workspace_dict["session"] tmuxp_workspace["session_name"] = workspace_dict.get("name", None) @@ -336,6 +357,22 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "root" in workspace_dict: tmuxp_workspace["start_directory"] = workspace_dict.pop("root") + # v0.x default: TEAMOCIL=1 is exported in every pane (with_env_var=true + # by default per teamocil 0.4-stable). _is_falsy_yaml handles quoted + # YAML strings like with_env_var: "false" that survive to Python as + # truthy strings. + if is_v0x and not _is_falsy_yaml(workspace_dict.get("with_env_var", True)): + tmuxp_workspace.setdefault("environment", {})["TEAMOCIL"] = "1" + + if "cmd_separator" in workspace_dict: + logger.warning( + "cmd_separator has no effect in tmuxp; commands are sent individually", + extra={ + "tmux_key": "cmd_separator", + "tmux_session": tmuxp_workspace.get("session_name"), + }, + ) + tmuxp_workspace["windows"] = [] for w in workspace_dict["windows"]: @@ -343,6 +380,14 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "clear" in w: window_dict["clear"] = w["clear"] + logger.warning( + "clear is preserved on the window but the builder does not " + "yet act on it", + extra={ + "tmux_key": "clear", + "tmux_session": tmuxp_workspace.get("session_name"), + }, + ) if "filters" in w: if "before" in w["filters"]: @@ -361,7 +406,7 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "cmd" in p: p["shell_command"] = p.pop("cmd") if "width" in p: - # TODO support for height/width + # TODO: builder support for per-pane geometry p.pop("width") window_dict["panes"] = w["panes"] diff --git a/tests/fixtures/import_teamocil/layouts.py b/tests/fixtures/import_teamocil/layouts.py index 6e3e06bf7f..ff2d628254 100644 --- a/tests/fixtures/import_teamocil/layouts.py +++ b/tests/fixtures/import_teamocil/layouts.py @@ -163,6 +163,7 @@ three_windows_within_a_session = { "session_name": "my awesome session", + "environment": {"TEAMOCIL": "1"}, "windows": [ {"window_name": "first window", "panes": [{"shell_command": "echo 'foo'"}]}, {"window_name": "second window", "panes": [{"shell_command": "echo 'foo'"}]}, From ae13a59e58bf2a293ac6abe157d86e8318117b35 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:18:45 -0500 Subject: [PATCH 63/89] tests(import[teamocil]): Cover with_env_var and TODO triage warnings why: Lock in I7 behavior for the v0.x TODO key triage. what: - New fixtures: test_with_env_var_default (default true -> TEAMOCIL=1) and test_with_env_var_false (explicit false -> no env var). Wired into parametrized test_config_to_dict. - New unit tests: - test_import_teamocil_warns_on_cmd_separator (I7: WARNING with extra.tmux_key=cmd_separator). - test_import_teamocil_warns_on_clear (I7: WARNING with extra.tmux_key=clear). - test_import_teamocil_v1x_skips_env_var (I7 regression: a v1.x config (no session: wrapper) gets no TEAMOCIL env var). ref: notes/plan.md I7 --- tests/fixtures/import_teamocil/__init__.py | 10 ++- .../test_with_env_var_default.py | 22 +++++ .../test_with_env_var_default.yaml | 6 ++ .../test_with_env_var_false.py | 22 +++++ .../test_with_env_var_false.yaml | 7 ++ tests/workspace/test_import_teamocil.py | 81 +++++++++++++++++++ 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/import_teamocil/test_with_env_var_default.py create mode 100644 tests/fixtures/import_teamocil/test_with_env_var_default.yaml create mode 100644 tests/fixtures/import_teamocil/test_with_env_var_false.py create mode 100644 tests/fixtures/import_teamocil/test_with_env_var_false.yaml diff --git a/tests/fixtures/import_teamocil/__init__.py b/tests/fixtures/import_teamocil/__init__.py index 1ec7c59fd5..ce1e8cc87a 100644 --- a/tests/fixtures/import_teamocil/__init__.py +++ b/tests/fixtures/import_teamocil/__init__.py @@ -2,4 +2,12 @@ from __future__ import annotations -from . import layouts, test1, test2, test3, test4 +from . import ( + layouts, + test1, + test2, + test3, + test4, + test_with_env_var_default, + test_with_env_var_false, +) diff --git a/tests/fixtures/import_teamocil/test_with_env_var_default.py b/tests/fixtures/import_teamocil/test_with_env_var_default.py new file mode 100644 index 0000000000..4d9cf478c3 --- /dev/null +++ b/tests/fixtures/import_teamocil/test_with_env_var_default.py @@ -0,0 +1,22 @@ +"""teamocil fixture: v0.x default with_env_var=true exports TEAMOCIL=1.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file( + "import_teamocil/test_with_env_var_default.yaml", +) +teamocil_dict = { + "session": { + "name": "env-default", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, +} +expected = { + "session_name": "env-default", + "environment": {"TEAMOCIL": "1"}, + "windows": [ + {"window_name": "main", "panes": [{"shell_command": "echo hi"}]}, + ], +} diff --git a/tests/fixtures/import_teamocil/test_with_env_var_default.yaml b/tests/fixtures/import_teamocil/test_with_env_var_default.yaml new file mode 100644 index 0000000000..db57192bb7 --- /dev/null +++ b/tests/fixtures/import_teamocil/test_with_env_var_default.yaml @@ -0,0 +1,6 @@ +session: + name: env-default + windows: + - name: main + panes: + - cmd: echo hi diff --git a/tests/fixtures/import_teamocil/test_with_env_var_false.py b/tests/fixtures/import_teamocil/test_with_env_var_false.py new file mode 100644 index 0000000000..a801bf9009 --- /dev/null +++ b/tests/fixtures/import_teamocil/test_with_env_var_false.py @@ -0,0 +1,22 @@ +"""teamocil fixture: explicit with_env_var=false suppresses the env var.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file( + "import_teamocil/test_with_env_var_false.yaml", +) +teamocil_dict = { + "session": { + "name": "env-off", + "with_env_var": False, + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, +} +expected = { + "session_name": "env-off", + "windows": [ + {"window_name": "main", "panes": [{"shell_command": "echo hi"}]}, + ], +} diff --git a/tests/fixtures/import_teamocil/test_with_env_var_false.yaml b/tests/fixtures/import_teamocil/test_with_env_var_false.yaml new file mode 100644 index 0000000000..5057f7f12e --- /dev/null +++ b/tests/fixtures/import_teamocil/test_with_env_var_false.yaml @@ -0,0 +1,7 @@ +session: + name: env-off + with_env_var: false + windows: + - name: main + panes: + - cmd: echo hi diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 0ea457e7c6..487d72ce67 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -46,6 +46,18 @@ class TeamocilConfigTestFixture(t.NamedTuple): teamocil_dict=fixtures.test4.teamocil_dict, tmuxp_dict=fixtures.test4.expected, ), + TeamocilConfigTestFixture( + test_id="with_env_var_default", # v0.x default exports TEAMOCIL=1 + teamocil_yaml=fixtures.test_with_env_var_default.teamocil_yaml, + teamocil_dict=fixtures.test_with_env_var_default.teamocil_dict, + tmuxp_dict=fixtures.test_with_env_var_default.expected, + ), + TeamocilConfigTestFixture( + test_id="with_env_var_false", # explicit false suppresses + teamocil_yaml=fixtures.test_with_env_var_false.teamocil_yaml, + teamocil_dict=fixtures.test_with_env_var_false.teamocil_dict, + tmuxp_dict=fixtures.test_with_env_var_false.expected, + ), ] @@ -157,3 +169,72 @@ 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_import_teamocil_with_env_var_string_false_suppresses() -> None: + """A YAML-quoted ``with_env_var`` of ``false`` suppresses TEAMOCIL=1.""" + workspace = { + "session": { + "name": "string-false", + "with_env_var": "false", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + result = importers.import_teamocil(workspace) + assert "environment" not in result + + +def test_import_teamocil_warns_on_cmd_separator( + caplog: pytest.LogCaptureFixture, +) -> None: + """`cmd_separator` emits WARNING.""" + workspace = { + "session": { + "name": "sep", + "cmd_separator": " && ", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING + and getattr(r, "tmux_key", None) == "cmd_separator" + ] + assert len(warnings) == 1 + + +def test_import_teamocil_warns_on_clear( + caplog: pytest.LogCaptureFixture, +) -> None: + """`clear` on a window emits WARNING.""" + workspace = { + "session": { + "name": "clr", + "windows": [ + {"name": "main", "clear": True, "panes": [{"cmd": "echo hi"}]}, + ], + }, + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and getattr(r, "tmux_key", None) == "clear" + ] + assert len(warnings) == 1 + + +def test_import_teamocil_v1x_skips_env_var( + caplog: pytest.LogCaptureFixture, +) -> None: + """A v1.x config (no `session:` wrapper) does not get TEAMOCIL=1 (I7).""" + workspace = { + "name": "v1x", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + } + result = importers.import_teamocil(workspace) + assert "environment" not in result From 9aae4dbf6aea1ebfa5cf66371e51189e6eab9172 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:26:36 -0500 Subject: [PATCH 64/89] fix(import[teamocil]): Detect v0.x by markers when session wrapper omitted why: Real-world v0.x configs sometimes omit the `session:` wrapper but still use v0.x pane keys (`splits`, `cmd`) and `filters`. Routing them to the v1.x path silently dropped commands. what: - New `_has_v0x_window_markers()` helper inspects the inner workspace for v0.x signals: window has `splits` or `filters`, or any pane has `cmd`. Routes to `_import_teamocil_v0x` if found. - All existing v0.x test fixtures (test1-test4 and the layouts.py multisession scenarios) updated to expect the new `environment: {TEAMOCIL: "1"}` from I7's with_env_var default. - The v1.x skip test (`test_import_teamocil_v1x_skips_env_var`) now uses the real v1.x `commands` key (was using v0.x `cmd` and incorrectly being treated as v1.x by the old detection). ref: notes/plan.md I4 --- src/tmuxp/workspace/importers.py | 115 +++++++++++++++++++--- tests/fixtures/import_teamocil/layouts.py | 3 + tests/fixtures/import_teamocil/test1.py | 1 + tests/fixtures/import_teamocil/test2.py | 1 + tests/fixtures/import_teamocil/test3.py | 1 + tests/fixtures/import_teamocil/test4.py | 1 + tests/workspace/test_import_teamocil.py | 4 +- 7 files changed, 112 insertions(+), 14 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index aa78192f2d..69da54eac1 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -322,6 +322,15 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: .. _teamocil: https://github.com/remiprev/teamocil + Detects the teamocil format and dispatches: + + - **v0.x** (pre-1.0): ``session:`` wrapper present. Handles + ``splits``/``cmd``/``filters``/``clear``/``with_env_var``/ + ``cmd_separator``. + - **v1.x** (1.0-1.4.2): top-level ``windows``. Handles ``commands``, + string pane shorthand, per-window/pane ``focus``, window + ``options``. + Parameters ---------- workspace_dict : dict @@ -337,22 +346,48 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: yet act on it; a warning is emitted. - ``cmd_separator`` is irrelevant since tmuxp sends commands individually; a warning is emitted. - - ``width`` is currently dropped from panes; geometry support is a - separate builder change. + - ``width``/``height``/``target`` (per-pane geometry) are dropped + with a warning; builder support is a separate change. """ - is_v0x = "session" in workspace_dict - _inner = workspace_dict.get("session", workspace_dict) + has_session_wrapper = "session" in workspace_dict + inner = workspace_dict["session"] if has_session_wrapper else workspace_dict + is_v0x = has_session_wrapper or _has_v0x_window_markers(inner) logger.debug( "importing teamocil workspace", - extra={"tmux_session": _inner.get("name", "")}, + extra={ + "tmux_session": inner.get("name", ""), + "tmux_importer_version": "v0.x" if is_v0x else "v1.x", + }, ) + if is_v0x: + return _import_teamocil_v0x(inner) + return _import_teamocil_v1x(workspace_dict) - tmuxp_workspace: dict[str, t.Any] = {} - if is_v0x: - workspace_dict = workspace_dict["session"] +def _has_v0x_window_markers(inner: dict[str, t.Any]) -> bool: + """Return True if any window/pane uses a v0.x-only key. + + Some v0.x configs in the wild omit the ``session:`` wrapper but still + use ``splits`` (v0.x pane key), ``cmd`` (v0.x command key), or + ``filters`` (v0.x before/after hooks). Detect those so they route to + the v0.x importer instead of being misclassified as v1.x. + """ + for w in inner.get("windows", []) or []: + if not isinstance(w, dict): + continue + if "splits" in w or "filters" in w: + return True + for pane in w.get("panes", []) or []: + if isinstance(pane, dict) and "cmd" in pane: + return True + return False - tmuxp_workspace["session_name"] = workspace_dict.get("name", None) + +def _import_teamocil_v0x(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Convert a teamocil v0.x workspace (``session:`` wrapped) to tmuxp.""" + tmuxp_workspace: dict[str, t.Any] = { + "session_name": workspace_dict.get("name"), + } if "root" in workspace_dict: tmuxp_workspace["start_directory"] = workspace_dict.pop("root") @@ -360,8 +395,8 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: # v0.x default: TEAMOCIL=1 is exported in every pane (with_env_var=true # by default per teamocil 0.4-stable). _is_falsy_yaml handles quoted # YAML strings like with_env_var: "false" that survive to Python as - # truthy strings. - if is_v0x and not _is_falsy_yaml(workspace_dict.get("with_env_var", True)): + # truthy strings. We're already in the v0.x helper so no is_v0x check. + if not _is_falsy_yaml(workspace_dict.get("with_env_var", True)): tmuxp_workspace.setdefault("environment", {})["TEAMOCIL"] = "1" if "cmd_separator" in workspace_dict: @@ -376,7 +411,7 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: tmuxp_workspace["windows"] = [] for w in workspace_dict["windows"]: - window_dict = {"window_name": w["name"]} + window_dict: dict[str, t.Any] = {"window_name": w["name"]} if "clear" in w: window_dict["clear"] = w["clear"] @@ -415,3 +450,59 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: tmuxp_workspace["windows"].append(window_dict) return tmuxp_workspace + + +def _import_teamocil_v1x(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + """Convert a teamocil v1.x workspace (top-level ``windows``) to tmuxp. + + v1.x dropped the ``session:`` wrapper, ``filters``, ``with_env_var``, + and ``cmd_separator``. A bare string pane is shorthand for + ``{commands: [<string>]}`` (window.rb:12-14 in upstream teamocil). + """ + tmuxp_workspace: dict[str, t.Any] = { + "session_name": workspace_dict.get("name"), + } + + if "root" in workspace_dict: + tmuxp_workspace["start_directory"] = workspace_dict["root"] + + tmuxp_workspace["windows"] = [] + for w in workspace_dict.get("windows", []): + window_dict: dict[str, t.Any] = {"window_name": w["name"]} + if "root" in w: + window_dict["start_directory"] = w["root"] + if "layout" in w: + window_dict["layout"] = w["layout"] + if "focus" in w: + window_dict["focus"] = w["focus"] + if "options" in w: + window_dict["options"] = w["options"] + window_dict["panes"] = [_normalize_v1x_pane(p) for p in w.get("panes", [])] + tmuxp_workspace["windows"].append(window_dict) + + return tmuxp_workspace + + +def _normalize_v1x_pane(pane: t.Any) -> dict[str, t.Any]: + """Normalize a teamocil v1.x pane (string or dict) to tmuxp pane dict. + + >>> _normalize_v1x_pane("vim") + {'shell_command': ['vim']} + >>> _normalize_v1x_pane({"commands": ["a", "b"], "focus": True}) + {'shell_command': ['a', 'b'], 'focus': True} + """ + if isinstance(pane, str): + return {"shell_command": [pane]} + out: dict[str, t.Any] = {} + if "commands" in pane: + out["shell_command"] = pane["commands"] + if "focus" in pane: + out["focus"] = pane["focus"] + if not out and isinstance(pane, dict) and pane: + # Dict had keys, but none we recognized — surface to user instead + # of silently producing a no-command pane. + logger.warning( + "v1.x pane dict has no recognizable keys; produced empty pane", + extra={"tmux_key": ",".join(sorted(pane.keys()))}, + ) + return out diff --git a/tests/fixtures/import_teamocil/layouts.py b/tests/fixtures/import_teamocil/layouts.py index ff2d628254..b33222d40f 100644 --- a/tests/fixtures/import_teamocil/layouts.py +++ b/tests/fixtures/import_teamocil/layouts.py @@ -87,6 +87,7 @@ two_windows = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "foo", @@ -114,6 +115,7 @@ two_windows_with_filters = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "foo", @@ -136,6 +138,7 @@ two_windows_with_custom_command_options = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "foo", diff --git a/tests/fixtures/import_teamocil/test1.py b/tests/fixtures/import_teamocil/test1.py index 8e2065fec2..0f141a62a4 100644 --- a/tests/fixtures/import_teamocil/test1.py +++ b/tests/fixtures/import_teamocil/test1.py @@ -18,6 +18,7 @@ expected = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "sample-two-panes", diff --git a/tests/fixtures/import_teamocil/test2.py b/tests/fixtures/import_teamocil/test2.py index 0353a0edbf..556ba511ae 100644 --- a/tests/fixtures/import_teamocil/test2.py +++ b/tests/fixtures/import_teamocil/test2.py @@ -18,6 +18,7 @@ expected = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "sample-four-panes", diff --git a/tests/fixtures/import_teamocil/test3.py b/tests/fixtures/import_teamocil/test3.py index 1bcd32fef1..06911434f2 100644 --- a/tests/fixtures/import_teamocil/test3.py +++ b/tests/fixtures/import_teamocil/test3.py @@ -27,6 +27,7 @@ expected = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "my-first-window", diff --git a/tests/fixtures/import_teamocil/test4.py b/tests/fixtures/import_teamocil/test4.py index 1837abf508..869b909414 100644 --- a/tests/fixtures/import_teamocil/test4.py +++ b/tests/fixtures/import_teamocil/test4.py @@ -18,6 +18,7 @@ expected = { "session_name": None, + "environment": {"TEAMOCIL": "1"}, "windows": [ { "window_name": "erb-example", diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 487d72ce67..d63e79abc9 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -231,10 +231,10 @@ def test_import_teamocil_warns_on_clear( def test_import_teamocil_v1x_skips_env_var( caplog: pytest.LogCaptureFixture, ) -> None: - """A v1.x config (no `session:` wrapper) does not get TEAMOCIL=1 (I7).""" + """A v1.x config (no `session:` wrapper, no `cmd`/`splits`) skips env.""" workspace = { "name": "v1x", - "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + "windows": [{"name": "main", "panes": [{"commands": ["echo hi"]}]}], } result = importers.import_teamocil(workspace) assert "environment" not in result From 5a9265cf53993ee598da779c83333b72d908f66d Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:28:26 -0500 Subject: [PATCH 65/89] tests(import[teamocil]): Cover v1.x format dispatch and pane shorthand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Lock in I4 behavior — string pane shorthand, `commands` key, per-window/pane `focus`, window `options`, and the v1.x format dispatch. what: - New fixture test_v1x_string_pane: bare string panes (`panes: [vim, top]`) -> `[{shell_command: [vim]}, {shell_command: [top]}]`. - New fixture test_v1x_full: covers `commands`, window `focus`, pane `focus`, window `options`, mixed string + dict panes, session-level `root`, window-level `root`. - Both wired into parametrized test_config_to_dict. ref: notes/plan.md I4 --- tests/fixtures/import_teamocil/__init__.py | 2 + .../fixtures/import_teamocil/test_v1x_full.py | 46 +++++++++++++++++++ .../import_teamocil/test_v1x_full.yaml | 17 +++++++ .../import_teamocil/test_v1x_string_pane.py | 25 ++++++++++ .../import_teamocil/test_v1x_string_pane.yaml | 6 +++ tests/workspace/test_import_teamocil.py | 32 +++++++++++++ 6 files changed, 128 insertions(+) create mode 100644 tests/fixtures/import_teamocil/test_v1x_full.py create mode 100644 tests/fixtures/import_teamocil/test_v1x_full.yaml create mode 100644 tests/fixtures/import_teamocil/test_v1x_string_pane.py create mode 100644 tests/fixtures/import_teamocil/test_v1x_string_pane.yaml diff --git a/tests/fixtures/import_teamocil/__init__.py b/tests/fixtures/import_teamocil/__init__.py index ce1e8cc87a..9cfd5fd40d 100644 --- a/tests/fixtures/import_teamocil/__init__.py +++ b/tests/fixtures/import_teamocil/__init__.py @@ -8,6 +8,8 @@ test2, test3, test4, + test_v1x_full, + test_v1x_string_pane, test_with_env_var_default, test_with_env_var_false, ) diff --git a/tests/fixtures/import_teamocil/test_v1x_full.py b/tests/fixtures/import_teamocil/test_v1x_full.py new file mode 100644 index 0000000000..e75321b89a --- /dev/null +++ b/tests/fixtures/import_teamocil/test_v1x_full.py @@ -0,0 +1,46 @@ +"""teamocil v1.x fixture: commands, focus, options, mixed pane forms.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test_v1x_full.yaml") +teamocil_dict = { + "name": "v1x-full", + "root": "~/proj", + "windows": [ + { + "name": "editor", + "root": "~/proj/src", + "layout": "main-vertical", + "focus": True, + "options": {"main-pane-width": "120"}, + "panes": [ + {"commands": ["vim"], "focus": True}, + "top", + ], + }, + {"name": "shell", "panes": ["bash"]}, + ], +} +expected = { + "session_name": "v1x-full", + "start_directory": "~/proj", + "windows": [ + { + "window_name": "editor", + "start_directory": "~/proj/src", + "layout": "main-vertical", + "focus": True, + "options": {"main-pane-width": "120"}, + "panes": [ + {"shell_command": ["vim"], "focus": True}, + {"shell_command": ["top"]}, + ], + }, + { + "window_name": "shell", + "panes": [{"shell_command": ["bash"]}], + }, + ], +} diff --git a/tests/fixtures/import_teamocil/test_v1x_full.yaml b/tests/fixtures/import_teamocil/test_v1x_full.yaml new file mode 100644 index 0000000000..d44e48ad2d --- /dev/null +++ b/tests/fixtures/import_teamocil/test_v1x_full.yaml @@ -0,0 +1,17 @@ +name: v1x-full +root: ~/proj +windows: +- name: editor + root: ~/proj/src + layout: main-vertical + focus: true + options: + main-pane-width: '120' + panes: + - commands: + - vim + focus: true + - top +- name: shell + panes: + - bash diff --git a/tests/fixtures/import_teamocil/test_v1x_string_pane.py b/tests/fixtures/import_teamocil/test_v1x_string_pane.py new file mode 100644 index 0000000000..c48740beba --- /dev/null +++ b/tests/fixtures/import_teamocil/test_v1x_string_pane.py @@ -0,0 +1,25 @@ +"""teamocil v1.x fixture: bare string pane -> shell_command list.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file( + "import_teamocil/test_v1x_string_pane.yaml", +) +teamocil_dict = { + "name": "v1x-string", + "windows": [{"name": "editor", "panes": ["vim", "top"]}], +} +expected = { + "session_name": "v1x-string", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": ["vim"]}, + {"shell_command": ["top"]}, + ], + }, + ], +} diff --git a/tests/fixtures/import_teamocil/test_v1x_string_pane.yaml b/tests/fixtures/import_teamocil/test_v1x_string_pane.yaml new file mode 100644 index 0000000000..08413e2e42 --- /dev/null +++ b/tests/fixtures/import_teamocil/test_v1x_string_pane.yaml @@ -0,0 +1,6 @@ +name: v1x-string +windows: +- name: editor + panes: + - vim + - top diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index d63e79abc9..00018f7aa9 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -58,6 +58,18 @@ class TeamocilConfigTestFixture(t.NamedTuple): teamocil_dict=fixtures.test_with_env_var_false.teamocil_dict, tmuxp_dict=fixtures.test_with_env_var_false.expected, ), + TeamocilConfigTestFixture( + test_id="v1x_string_pane", # bare string pane -> shell_command list + teamocil_yaml=fixtures.test_v1x_string_pane.teamocil_yaml, + teamocil_dict=fixtures.test_v1x_string_pane.teamocil_dict, + tmuxp_dict=fixtures.test_v1x_string_pane.expected, + ), + TeamocilConfigTestFixture( + test_id="v1x_full", # commands, focus, options, mixed panes + teamocil_yaml=fixtures.test_v1x_full.teamocil_yaml, + teamocil_dict=fixtures.test_v1x_full.teamocil_dict, + tmuxp_dict=fixtures.test_v1x_full.expected, + ), ] @@ -238,3 +250,23 @@ def test_import_teamocil_v1x_skips_env_var( } result = importers.import_teamocil(workspace) assert "environment" not in result + + +def test_import_teamocil_v1x_unknown_pane_keys_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """A v1.x pane dict with no recognizable keys warns and produces {}.""" + workspace = { + "name": "v1x", + "windows": [{"name": "w", "panes": [{"width": 50}]}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_teamocil(workspace) + assert result["windows"][0]["panes"][0] == {} + warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "no recognizable keys" in r.msg + ] + assert len(warnings) == 1 + assert getattr(warnings[0], "tmux_key", None) == "width" From 4e9dbac85a00240941c9b4723ef216200799da11 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:31:12 -0500 Subject: [PATCH 66/89] fix(import[teamocil]): Pop v0.x pane width/height/target with warning why: `width` was already popped silently. `height` and `target` were left on the pane dict (silently passing through to the builder, which ignored them). Make all three pop with an audible WARNING so users know the importer dropped per-pane geometry. what: - Loop over ("width", "height", "target") inside the v0.x pane loop; pop each and emit `logger.warning` with extra={"tmux_key": <key>}. - Update existing layouts fixture expected output to drop the previously-preserved `target` key. ref: notes/plan.md I6 --- src/tmuxp/workspace/importers.py | 23 ++++++++++++++++++++--- tests/fixtures/import_teamocil/layouts.py | 10 ++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 69da54eac1..dc9a173bff 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -349,6 +349,10 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: - ``width``/``height``/``target`` (per-pane geometry) are dropped with a warning; builder support is a separate change. """ + # Top-level shallow copy so the dispatch helpers' .pop()s don't mutate + # the caller's dict. Nested window/pane dicts are still mutated in place + # by the v0.x window loop; deepcopy upstream if you share nested values. + workspace_dict = {**workspace_dict} has_session_wrapper = "session" in workspace_dict inner = workspace_dict["session"] if has_session_wrapper else workspace_dict is_v0x = has_session_wrapper or _has_v0x_window_markers(inner) @@ -440,9 +444,22 @@ def _import_teamocil_v0x(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: for p in w["panes"]: if "cmd" in p: p["shell_command"] = p.pop("cmd") - if "width" in p: - # TODO: builder support for per-pane geometry - p.pop("width") + for geom_key in ("width", "height", "target"): + if geom_key in p: + # No builder support for per-pane geometry yet + # (libtmux Pane.split(target=...) and Pane.resize() + # exist but the builder doesn't wire them up). + p.pop(geom_key) + logger.warning( + "v0.x pane key dropped (no per-pane geometry " + "support in tmuxp)", + extra={ + "tmux_key": geom_key, + "tmux_session": tmuxp_workspace.get( + "session_name", + ), + }, + ) window_dict["panes"] = w["panes"] if "layout" in w: diff --git a/tests/fixtures/import_teamocil/layouts.py b/tests/fixtures/import_teamocil/layouts.py index b33222d40f..ef08f4b98d 100644 --- a/tests/fixtures/import_teamocil/layouts.py +++ b/tests/fixtures/import_teamocil/layouts.py @@ -103,10 +103,7 @@ "window_name": "bar", "start_directory": "/bar", "panes": [ - { - "shell_command": ["echo 'bar'", "echo 'bar in an array'"], - "target": "bottom-right", - }, + {"shell_command": ["echo 'bar'", "echo 'bar in an array'"]}, {"shell_command": "echo 'bar again'", "focus": True}, ], }, @@ -154,10 +151,7 @@ "window_name": "bar", "start_directory": "/bar", "panes": [ - { - "shell_command": ["echo 'bar'", "echo 'bar in an array'"], - "target": "bottom-right", - }, + {"shell_command": ["echo 'bar'", "echo 'bar in an array'"]}, {"shell_command": "echo 'bar again'", "focus": True}, ], }, From 23481f4bb91975567f75adb6f82e349e3691c16c Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:33:31 -0500 Subject: [PATCH 67/89] tests(import[teamocil]): Cover v0.x pane geometry-key drop warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Lock in I6 behavior — width/height/target each warn when popped. what: New test_import_teamocil_warns_on_v0x_pane_geometry asserts all three keys produce WARNING records with extra.tmux_key set. ref: notes/plan.md I6 --- tests/workspace/test_import_teamocil.py | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 00018f7aa9..8b93344c2d 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -270,3 +270,34 @@ def test_import_teamocil_v1x_unknown_pane_keys_warns( ] assert len(warnings) == 1 assert getattr(warnings[0], "tmux_key", None) == "width" + + + +def test_import_teamocil_warns_on_v0x_pane_geometry( + caplog: pytest.LogCaptureFixture, +) -> None: + """v0.x pane width/height/target each emit WARNING.""" + workspace = { + "session": { + "name": "geom", + "windows": [ + { + "name": "w", + "splits": [ + {"cmd": "echo a", "width": 50}, + {"cmd": "echo b", "height": 30}, + {"cmd": "echo c", "target": "bottom-right"}, + ], + }, + ], + }, + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + keys: list[str] = sorted( + t.cast(str, getattr(r, "tmux_key", "")) + for r in caplog.records + if r.levelno == logging.WARNING + and getattr(r, "tmux_key", None) in {"width", "height", "target"} + ) + assert keys == ["height", "target", "width"] From f8472c8539296ec63d326c1280028a5764550ddc Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 08:34:17 -0500 Subject: [PATCH 68/89] docs(notes[plan]): Mark importer fixes resolved with implementation summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Phase 1 (Import Fixes) is complete. Update plan.md status so the table reflects shipped behavior. what: - I1: pre → before_script with shell-metachar warning. - I2: shlex parsing for cli_args/tmux_options. - I3: direct assignment replaces redundant filter loops. - I4: v0.x/v1.x dispatch, detection by session: wrapper OR splits/filters/cmd markers; v1.x handles string panes, commands, focus, options. - I5: pre_window OR-fallback chain (rbenv→rvm→pre_tab→pre_window); startup_window/pane resolved to focus: true; on_project_first_start fallback; socket_path pass-through; attach: false warns. - I6: v0.x height/target now popped with WARNING. - I7: with_env_var → environment.TEAMOCIL=1 for v0.x; cmd_separator and clear warn (clear builder support deferred). Total Phase 1 commits: 14 (7 source + 7 tests). ref: notes/plan.md Phase 1 --- notes/plan.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/notes/plan.md b/notes/plan.md index 461b2ca163..b3b5e4aa83 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -134,13 +134,13 @@ For full bug analysis with file:line refs, see `notes/import-tmuxinator.md` and | ID | Importer | Bug | Fix scope | |---|---|---|---| -| I1 | tmuxinator | `pre` (alone) maps to `shell_command_before` (per-pane) instead of `before_script` (once); combo `pre` + `pre_window` writes `pre` to invalid `shell_command` key and `isinstance` checks the wrong var (lines 70-81) | Map `pre` → `before_script`; check `pre_window`'s type, not `pre`'s. **Caveat**: `before_script` runs `Popen` without `shell=True`, so pipes/redirects in `pre` won't work — long-term fix needs T6 lifecycle hooks | -| I2 | tmuxinator | `cli_args`/`tmux_options` use `str.replace("-f", "")` (lines 50-60); breaks on `-L`/`-S` flags or paths containing `-f` | Use `shlex.split()` + iterate flags | -| I3 | teamocil | Redundant `for _b in w["filters"]["before"]` loops set same value N times (lines 160-166) | Replace with direct assignment | -| I4 | teamocil | v1.x format not detected: string panes cause `"cmd" in str` substring check; `commands` key dropped; pane `width` silently dropped, `height` passes through | Add format detection; handle v1.x keys; preserve `width`/`height` (libtmux `Pane.resize` exists, L4) or warn | -| I5 | tmuxinator | Missing translations: `rvm`, `pre_tab`, `startup_window`, `startup_pane`, `on_project_first_start`, `post`, `socket_path`, `attach: false` | Add explicit mappings (see `import-tmuxinator.md` for per-key target) | -| I6 | teamocil | Missing v1.x mappings (`commands`, window/pane `focus`, window `options`, string pane shorthand) and v0.x `with_env_var`/`height` | Add explicit mappings | -| I7 | teamocil | TODOs at `importers.py:132-134` (`with_env_var`, `clear`, `cmd_separator`) — first two are real v0.x features, third is irrelevant | Implement `with_env_var` → `environment: { TEAMOCIL: "1" }`; wire `clear` to `Pane.clear()` (L4); remove `cmd_separator` TODO | +| I1 | tmuxinator | `pre` (alone) maps to `shell_command_before` (per-pane) instead of `before_script` (once); combo `pre` + `pre_window` writes `pre` to invalid `shell_command` key and `isinstance` checks the wrong var (lines 70-81) | **Resolved**: maps `pre` → `before_script`; warns on shell metacharacters (Popen runs without `shell=True`). Long-term shell fix waits on T6. | +| I2 | tmuxinator | `cli_args`/`tmux_options` use `str.replace("-f", "")` (lines 50-60); breaks on `-L`/`-S` flags or paths containing `-f` | **Resolved**: shlex-based parsing extracts `-f`/`-L`/`-S`; warns on unknown flags. | +| I3 | teamocil | Redundant `for _b in w["filters"]["before"]` loops set same value N times (lines 160-166) | **Resolved**: direct assignment. | +| I4 | teamocil | v1.x format not detected: string panes cause `"cmd" in str` substring check; `commands` key dropped; pane `width` silently dropped, `height` passes through | **Resolved**: dispatch to `_import_teamocil_v0x` / `_import_teamocil_v1x`; v0.x detected by `session:` wrapper OR window `splits`/`filters`/pane `cmd`. v1.x handles string panes, `commands`, per-window/pane `focus`, window `options`. | +| I5 | tmuxinator | Missing translations: `rvm`, `pre_tab`, `startup_window`, `startup_pane`, `on_project_first_start`, `post`, `socket_path`, `attach: false` | **Resolved**: rbenv → rvm → pre_tab → pre_window OR-fallback chain implemented; `startup_window`/`startup_pane` resolved by name or int → `focus: true`; `on_project_first_start` → `before_script`; `socket_path` pass-through; `attach: false` warns. `post` deferred (needs T6). | +| I6 | teamocil | Missing v1.x mappings (`commands`, window/pane `focus`, window `options`, string pane shorthand) and v0.x `with_env_var`/`height` | **Resolved** by I4 (v1.x mappings) + I7 (`with_env_var`); v0.x `height`/`target` now popped with WARNING. | +| I7 | teamocil | TODOs at `importers.py:132-134` (`with_env_var`, `clear`, `cmd_separator`) — first two are real v0.x features, third is irrelevant | **Resolved**: `with_env_var` → session-level `environment: {TEAMOCIL: "1"}` for v0.x configs; `cmd_separator` warns; `clear` preserved with WARNING (builder support deferred). | ## Test Coverage Gaps From b58358889e28364cdac6f0501816a34ce2deba24 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 09:32:45 -0500 Subject: [PATCH 69/89] docs(CHANGES): Note tmuxinator/teamocil import behavior changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Phase 1 of the tmuxinator-parity branch (PR #1014) is behavior- changing for users importing tmuxinator/teamocil configs. Document what changed so upgraders know what to expect. what: Adds a "Behavior changes — tmuxinator/teamocil import" section to the upcoming-release placeholder, summarizing all 7 import-side fixes and the v1.x teamocil format support. Also drops a stray blank line in tests/workspace/test_import_teamocil.py left by an autosquash conflict resolution (cosmetic, ruff-format). --- CHANGES | 28 +++++++++++++++++++++++++ tests/workspace/test_import_teamocil.py | 1 - 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index edece8c7ba..be25f57124 100644 --- a/CHANGES +++ b/CHANGES @@ -39,6 +39,34 @@ $ pipx install \ _Notes on the upcoming release will go here._ <!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE --> +### Behavior changes — tmuxinator/teamocil import (#1014) + +- **tmuxinator**: `pre` now correctly maps to `before_script` (was: per-pane + `shell_command_before`). Importer warns when `pre` contains shell + constructs like pipes/redirects since `before_script` runs without + `shell=True`. Combo `pre` + `pre_window` previously dropped `pre` + entirely; both keys now map independently. +- **tmuxinator**: `cli_args`/`tmux_options` now use `shlex` parsing. + `-L` (socket name) and `-S` (socket path) are extracted (were silently + dropped). Paths containing `-f` no longer corrupt the config value. + Unknown flags and missing-value flags warn. +- **tmuxinator**: new key translations — `rvm`, `pre_tab`, + `startup_window`, `startup_pane`, `on_project_first_start`, + `socket_path`. `attach: false` warns and directs to `tmuxp load -d`. + `startup_window`/`startup_pane` resolve by name OR integer index to + set `focus: true` on the matching window/pane. +- **teamocil**: v1.x format now supported. Importer auto-dispatches to + v0.x or v1.x based on `session:` wrapper, `splits`/`filters`/`cmd` + markers. v1.x handles bare-string panes, `commands`, per-window/pane + `focus`, window `options`. v1.x pane dicts with no recognizable keys + produce empty panes and warn. +- **teamocil**: v0.x configs now emit `environment: {TEAMOCIL: "1"}` by + default (matches `with_env_var: true` from teamocil 0.4-stable). + Suppress with `with_env_var: false` (also accepts the YAML-quoted + string `"false"`). +- **teamocil**: v0.x pane `width`/`height`/`target` keys now drop with a + WARNING (were silently dropped or passed through as dead data). + ### Documentation - Visual improvements to API docs from [gp-sphinx](https://gp-sphinx.git-pull.com)-based Sphinx packages (#1035) diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 8b93344c2d..c0676fb096 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -272,7 +272,6 @@ def test_import_teamocil_v1x_unknown_pane_keys_warns( assert getattr(warnings[0], "tmux_key", None) == "width" - def test_import_teamocil_warns_on_v0x_pane_geometry( caplog: pytest.LogCaptureFixture, ) -> None: From 13e89e4d5eb5c89ec3673449520693b48427e035 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:25:15 -0500 Subject: [PATCH 70/89] feat(builder): Handle synchronize config key with before/true/after why: Tmuxinator's `synchronize` key turns on `synchronize-panes` for a window. tmuxp silently ignored it. With this change, configs that import from tmuxinator preserve the sync semantics. what: - builder.iter_create_windows: when `synchronize` is True or "before", call `window.set_option("synchronize-panes", "on")` after the window's `options` block is applied. Pane commands run with sync enabled. - builder.config_after_window: when `synchronize` is "after", call the same option after pane commands complete. Matches tmuxinator's recommended modern usage (true/before are deprecated upstream but still accepted). - import_tmuxinator: pass `synchronize` through unchanged on the window dict so the builder picks it up. ref: notes/plan.md T1 --- src/tmuxp/workspace/builder.py | 12 ++++++++++++ src/tmuxp/workspace/importers.py | 3 +++ 2 files changed, 15 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..4a8bc1fc95 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -674,6 +674,13 @@ def iter_create_windows( for key, val in window_config["options"].items(): window.set_option(key, val) + # synchronize-panes: before/true fires here, after fires in + # config_after_window. tmuxinator deprecates true/before in favor + # of after (project.rb:21-29) but accepts all three for compat. + sync = window_config.get("synchronize") + if sync is True or sync == "before": + window.set_option("synchronize-panes", "on") + if window_config.get("focus"): window.select() @@ -844,6 +851,11 @@ def config_after_window( for key, val in window_config["options_after"].items(): window.set_option(key, val) + # synchronize-panes value "after" fires here (post-pane-build) so + # the option is enabled only after pane commands have been sent. + if window_config.get("synchronize") == "after": + window.set_option("synchronize-panes", "on") + def find_current_attached_session(self) -> Session: """Return current attached session.""" assert self.server is not None diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index dc9a173bff..b259915c00 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -218,6 +218,9 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "layout" in v: window_dict["layout"] = v["layout"] + # pass synchronize through (true/before/after) — same key name. + if "synchronize" in v: + window_dict["synchronize"] = v["synchronize"] tmuxp_workspace["windows"].append(window_dict) _apply_tmuxinator_startup_focus(workspace_dict, tmuxp_workspace) From ac2dea801dbb08f3511528c813eccfe2eb5d7c99 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:27:58 -0500 Subject: [PATCH 71/89] tests(builder): Cover synchronize-panes config key why: Lock in T1 behavior across before/after/omitted/true. what: - 3 new fixtures (synchronize_before/after/omitted.yaml). - Parametrized test_synchronize_panes asserts `window.show_option('synchronize-panes')` is truthy after build for before/after, falsy when omitted. - test_synchronize_panes_true_treated_as_before locks in the true/before equivalence inline (no fixture needed). ref: notes/plan.md T1 --- .../workspace/builder/synchronize_after.yaml | 9 +++ .../workspace/builder/synchronize_before.yaml | 9 +++ .../builder/synchronize_omitted.yaml | 8 +++ tests/workspace/test_builder.py | 70 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 tests/fixtures/workspace/builder/synchronize_after.yaml create mode 100644 tests/fixtures/workspace/builder/synchronize_before.yaml create mode 100644 tests/fixtures/workspace/builder/synchronize_omitted.yaml diff --git a/tests/fixtures/workspace/builder/synchronize_after.yaml b/tests/fixtures/workspace/builder/synchronize_after.yaml new file mode 100644 index 0000000000..b71ecf60a7 --- /dev/null +++ b/tests/fixtures/workspace/builder/synchronize_after.yaml @@ -0,0 +1,9 @@ +session_name: sync-after +windows: +- window_name: w1 + synchronize: after + panes: + - shell_command: + - echo first + - shell_command: + - echo second diff --git a/tests/fixtures/workspace/builder/synchronize_before.yaml b/tests/fixtures/workspace/builder/synchronize_before.yaml new file mode 100644 index 0000000000..053f1f8af8 --- /dev/null +++ b/tests/fixtures/workspace/builder/synchronize_before.yaml @@ -0,0 +1,9 @@ +session_name: sync-before +windows: +- window_name: w1 + synchronize: before + panes: + - shell_command: + - echo first + - shell_command: + - echo second diff --git a/tests/fixtures/workspace/builder/synchronize_omitted.yaml b/tests/fixtures/workspace/builder/synchronize_omitted.yaml new file mode 100644 index 0000000000..1eb3ea0506 --- /dev/null +++ b/tests/fixtures/workspace/builder/synchronize_omitted.yaml @@ -0,0 +1,8 @@ +session_name: sync-off +windows: +- window_name: w1 + panes: + - shell_command: + - echo first + - shell_command: + - echo second diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..e9b4d0003a 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1768,3 +1768,73 @@ def test_builder_logs_window_and_pane_creation( assert len(cmd_logs) >= 1 builder.session.kill() + + +class SynchronizeFixture(t.NamedTuple): + """Synchronize-panes fixture.""" + + test_id: str + workspace_file: str + expected_enabled: bool # True if synchronize-panes is "on" after build + + +SYNCHRONIZE_FIXTURES: list[SynchronizeFixture] = [ + SynchronizeFixture( + test_id="before", + workspace_file="workspace/builder/synchronize_before.yaml", + expected_enabled=True, + ), + SynchronizeFixture( + test_id="after", + workspace_file="workspace/builder/synchronize_after.yaml", + expected_enabled=True, + ), + SynchronizeFixture( + test_id="omitted", + workspace_file="workspace/builder/synchronize_omitted.yaml", + expected_enabled=False, + ), +] + + +@pytest.mark.parametrize( + list(SynchronizeFixture._fields), + SYNCHRONIZE_FIXTURES, + ids=[f.test_id for f in SYNCHRONIZE_FIXTURES], +) +def test_synchronize_panes( + test_id: str, + workspace_file: str, + expected_enabled: bool, + session: Session, +) -> None: + """`synchronize` config key turns synchronize-panes on at the right phase.""" + workspace = ConfigReader._from_file(test_utils.get_workspace_file(workspace_file)) + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + window = builder.session.windows[-1] + assert bool(window.show_option("synchronize-panes")) is expected_enabled + builder.session.kill() + + +def test_synchronize_panes_true_treated_as_before( + session: Session, +) -> None: + """`synchronize: true` is equivalent to `synchronize: before`.""" + workspace = { + "session_name": "sync-true", + "windows": [ + { + "window_name": "w1", + "synchronize": True, + "panes": [{"shell_command": ["echo a"]}, {"shell_command": ["echo b"]}], + }, + ], + } + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + window = builder.session.windows[-1] + assert bool(window.show_option("synchronize-panes")) is True + builder.session.kill() From 2625d28a76590839f3585eb423ac4cb6604ec01f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:29:35 -0500 Subject: [PATCH 72/89] feat(builder): Read window-level shell_command_after and send to each pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: teamocil's `filters.after` (and the I3-fixed importer) emits `shell_command_after` on a window dict, but tmuxp silently ignored it. Builder now reads the key and sends each command to every pane after the main `shell_command` block runs. what: - builder.config_after_window: iterate `window_config.get("shell_command_after", [])` and for each command call `pane.send_keys(cmd, suppress_history=False)` on every pane in the window. Window-level only — no trickle to/from session, matching teamocil's filter semantics. ref: notes/plan.md T3 --- src/tmuxp/workspace/builder.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 4a8bc1fc95..4e3388e913 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -856,6 +856,13 @@ def config_after_window( if window_config.get("synchronize") == "after": window.set_option("synchronize-panes", "on") + # Window-level shell_command_after: send each command to every pane + # after the main shell_command for that pane has run. The teamocil + # importer produces this on the window dict from `filters.after`. + for after_cmd in window_config.get("shell_command_after", []) or []: + for pane in window.panes: + pane.send_keys(after_cmd, suppress_history=False) + def find_current_attached_session(self) -> Session: """Return current attached session.""" assert self.server is not None From 78038728b96a60bf72345d8219453b03a0dc3b13 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:31:28 -0500 Subject: [PATCH 73/89] tests(builder): Cover window-level shell_command_after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Lock in the new builder behavior — the after-commands must actually appear in every pane's output. what: - New fixture shell_command_after.yaml: 1 window with shell_command_after: [echo TMUXP_T3_MARKER] and 2 panes. - New test_shell_command_after_runs_in_each_pane uses retry_until on pane.capture-pane to check for the marker in each pane. - @pytest.mark.flaky(reruns=5) since send_keys output capture is timing-sensitive (matches existing test_window_options_after). ref: notes/plan.md T3 --- .../builder/shell_command_after.yaml | 10 +++++++ tests/workspace/test_builder.py | 27 +++++++++++++++++++ 2 files changed, 37 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..7076b5c005 --- /dev/null +++ b/tests/fixtures/workspace/builder/shell_command_after.yaml @@ -0,0 +1,10 @@ +session_name: shell-command-after +windows: +- window_name: w1 + shell_command_after: + - echo TMUXP_T3_MARKER + panes: + - shell_command: + - echo first + - shell_command: + - echo second diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index e9b4d0003a..aa50d8c7ea 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1838,3 +1838,30 @@ def test_synchronize_panes_true_treated_as_before( window = builder.session.windows[-1] assert bool(window.show_option("synchronize-panes")) is True builder.session.kill() + + +@pytest.mark.flaky(reruns=5) +def test_shell_command_after_runs_in_each_pane( + session: Session, +) -> None: + """Window-level `shell_command_after` runs in every pane post-build.""" + 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) + + def saw_marker(p: Pane) -> bool: + def f() -> bool: + captured = p.cmd("capture-pane", "-p", "-J").stdout + return any("TMUXP_T3_MARKER" in line for line in captured) + + return retry_until(f, raises=False) + + window = builder.session.windows[-1] + for pane in window.panes: + assert saw_marker(pane), ( + f"shell_command_after did not run in pane {pane.pane_id}" + ) + builder.session.kill() From e9304dc825e01cc6797fe3db6b5ab42776fb5ac9 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:34:20 -0500 Subject: [PATCH 74/89] feat(cli[load]): Add --no-shell-command-before flag why: tmuxinator users have `--no-pre-window` to skip per-pane prep commands during debugging. tmuxp's equivalent: skip the trickle of shell_command_before from session/window/pane levels into each pane's shell_command list. what: - New `--no-shell-command-before` CLI flag on `tmuxp load` (action=store_true, default False). Added to CLILoadNamespace too. - load_workspace gains a `no_shell_command_before` kwarg which is forwarded to loader.trickle(). - loader.trickle now accepts a keyword-only `no_shell_command_before` parameter; when True, skip the entire shell_command_before propagation block for every pane. - Behavior difference from tmuxinator's --no-pre-window noted in the trickle docstring: tmuxinator's flag only suppresses pre_window proper, while tmuxp's flag is broader (skips all shell_command_before regardless of source). ref: notes/plan.md T7 --- src/tmuxp/cli/load.py | 19 ++++++++++++++++++- src/tmuxp/workspace/loader.py | 12 +++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..534c91242e 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 + no_shell_command_before: bool colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None @@ -450,6 +451,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. @@ -566,7 +568,10 @@ def load_workspace( expanded_workspace["session_name"] = new_session_name # propagate workspace inheritance (e.g. session -> window, window -> pane) - expanded_workspace = loader.trickle(expanded_workspace) + expanded_workspace = loader.trickle( + expanded_workspace, + no_shell_command_before=no_shell_command_before, + ) t = Server( # create tmux server object socket_name=socket_name, @@ -758,6 +763,17 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP action="store_true", help="load workspace, appending windows to the current session", ) + parser.add_argument( + "--no-shell-command-before", + dest="no_shell_command_before", + action="store_true", + default=False, + help=( + "skip session/window/pane shell_command_before propagation " + "(broader than tmuxinator's --no-pre-window, which only skips " + "pre_window)" + ), + ) colorsgroup = parser.add_mutually_exclusive_group() colorsgroup.add_argument( @@ -904,4 +920,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, ) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 9efcd05b52..c7710ac02e 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -193,7 +193,11 @@ def expand( return workspace_dict -def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: +def trickle( + workspace_dict: dict[str, t.Any], + *, + no_shell_command_before: bool = False, +) -> dict[str, t.Any]: """Return a dict with "trickled down" / inherited workspace values. This will only work if workspace has been expanded to full form with @@ -207,6 +211,12 @@ def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ---------- workspace_dict : dict the tmuxp workspace. + no_shell_command_before : bool, optional + When True, skip the shell_command_before propagation entirely. + Mirrors tmuxinator's ``--no-pre-window``, but slightly broader: + tmuxinator's flag only suppresses ``pre_window`` proper, whereas + tmuxp's flag skips ``shell_command_before`` at session, window, + and pane levels. Returns ------- From ea2542e237a2e249c62666989279e87bc29a5720 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:38:34 -0500 Subject: [PATCH 75/89] fix(loader): Apply no_shell_command_before guard to trickle propagation why: The T7 source commit (`a97730a8`) added the kwarg to trickle's signature but a stale edit lost the actual `if not no_shell_command_before:` guard around the prepend block. The kwarg was a no-op until now. what: - loader.trickle: wrap the three `shell_command_before` extend calls in `if not no_shell_command_before:` so the flag actually skips propagation. - tests/workspace/test_config.py: add test_trickle_no_shell_command_before_skips_propagation covering both flag values; deepcopy between calls since trickle mutates nested dicts. ref: notes/plan.md T7 --- src/tmuxp/workspace/loader.py | 29 +++++++++++++---------- tests/workspace/test_config.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index c7710ac02e..aa1509f854 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -261,19 +261,22 @@ def trickle( for pane_idx, pane_dict in enumerate(window_dict["panes"]): commands_before = [] - # Prepend shell_command_before to commands - if "shell_command_before" in workspace_dict: - commands_before.extend( - workspace_dict["shell_command_before"]["shell_command"], - ) - if "shell_command_before" in window_dict: - commands_before.extend( - window_dict["shell_command_before"]["shell_command"], - ) - if "shell_command_before" in pane_dict: - commands_before.extend( - pane_dict["shell_command_before"]["shell_command"], - ) + # Prepend shell_command_before to commands. Skipped entirely when + # no_shell_command_before is True (mirrors tmuxinator's + # --no-pre-window, but broader — we skip all three levels). + if not no_shell_command_before: + if "shell_command_before" in workspace_dict: + commands_before.extend( + workspace_dict["shell_command_before"]["shell_command"], + ) + if "shell_command_before" in window_dict: + commands_before.extend( + window_dict["shell_command_before"]["shell_command"], + ) + if "shell_command_before" in pane_dict: + commands_before.extend( + pane_dict["shell_command_before"]["shell_command"], + ) if "shell_command" in pane_dict: commands_before.extend(pane_dict["shell_command"]) diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index fc6d5ccd5b..6cdbfb1326 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -163,6 +163,49 @@ def test_trickle_relative_start_directory(config_fixture: WorkspaceTestData) -> assert test_workspace == config_fixture.trickle.expected +def test_trickle_no_shell_command_before_skips_propagation() -> None: + """trickle(no_shell_command_before=True) skips session/window/pane prep.""" + import copy as _copy + + yaml_text = """ + session_name: t7 + shell_command_before: + - echo SESSION_BEFORE + windows: + - window_name: w1 + shell_command_before: + - echo WINDOW_BEFORE + panes: + - shell_command: + - echo PANE_MAIN + shell_command_before: + - echo PANE_BEFORE + """ + sconfig = ConfigReader._load(fmt="yaml", content=yaml_text) + expanded = loader.expand(sconfig) + + # trickle mutates nested dicts; use deepcopy so the two runs don't share + # window/pane references. + skipped = loader.trickle(_copy.deepcopy(expanded), no_shell_command_before=True) + pane_cmds = skipped["windows"][0]["panes"][0]["shell_command"] + pane_strs = [c.get("cmd", "") if isinstance(c, dict) else c for c in pane_cmds] + assert all("BEFORE" not in s for s in pane_strs) + assert any("PANE_MAIN" in s for s in pane_strs) + + # With flag=False (default): pane.shell_command has all three before's + # prepended. + propagated = loader.trickle(_copy.deepcopy(expanded)) + propagated_cmds = propagated["windows"][0]["panes"][0]["shell_command"] + propagated_strs = [ + c.get("cmd", "") if isinstance(c, dict) else c for c in propagated_cmds + ] + joined = " ".join(propagated_strs) + assert "SESSION_BEFORE" in joined + assert "WINDOW_BEFORE" in joined + assert "PANE_BEFORE" in joined + assert "PANE_MAIN" in joined + + def test_trickle_window_with_no_pane_workspace() -> None: """Verify tmuxp window config automatically infers a single pane.""" test_yaml = """ From e54330f71d571a0337e352d6aef944dd399168cf Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:40:57 -0500 Subject: [PATCH 76/89] feat(builder): Pane title config keys + per-pane title why: tmuxinator's named-pane syntax sets both a pane title and its command (assets/template.erb:54, pane.rb:35-39). tmuxp had no equivalent. With L1 (Pane.set_title) shipped in libtmux v0.55.0, the builder can now wire it through. what: - builder.build session-level options block: read enable_pane_titles (toggle), pane_title_position (default "top"), pane_title_format (default "#{pane_index}: #{pane_title}"). Apply via session.set_option("pane-border-status"/"pane-border-format"). - builder.iter_create_panes: after send_keys, before focus handling, call pane.set_title(pane_config["title"]) when title is present. Title is stored on the pane regardless of border-status (per tmux cmd-select-pane.c:215-221). - import_tmuxinator: new _normalize_tmuxinator_pane helper translates tmuxinator's named-pane shorthand {name: cmd} into {title: name, shell_command: [cmd]} so the builder picks it up. ref: notes/plan.md T2 --- src/tmuxp/workspace/builder.py | 19 ++++++++++++++++++ src/tmuxp/workspace/importers.py | 34 +++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 4e3388e913..4d1ec41c29 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -538,6 +538,19 @@ 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) + # Pane title display options: tmuxinator's enable_pane_titles + # toggles `pane-border-status`; pane_title_position picks where the + # status bar lives; pane_title_format defines what tmux renders. + # Per-pane `title` is set later in iter_create_panes via set_title(). + if self.session_config.get("enable_pane_titles"): + position = self.session_config.get("pane_title_position", "top") + fmt = self.session_config.get( + "pane_title_format", + "#{pane_index}: #{pane_title}", + ) + self.session.set_option("pane-border-status", position) + self.session.set_option("pane-border-format", fmt) + for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) @@ -820,6 +833,12 @@ def get_pane_shell( if sleep_after is not None: time.sleep(sleep_after) + # Per-pane title via libtmux Pane.set_title; works regardless of + # pane-border-status (title is stored on the pane, display is + # independent — see tmux cmd-select-pane.c:215-221). + if "title" in pane_config: + 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/importers.py b/src/tmuxp/workspace/importers.py index b259915c00..bd4e22f6e2 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -212,7 +212,12 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: if "pre" in v: window_dict["shell_command_before"] = v["pre"] if "panes" in v: - window_dict["panes"] = v["panes"] + # Named-pane shorthand: tmuxinator's {title: command} maps to + # tmuxp {title: ..., shell_command: [...]} so the builder + # can apply the title via Pane.set_title(). + window_dict["panes"] = [ + _normalize_tmuxinator_pane(p) for p in v["panes"] + ] if "root" in v: window_dict["start_directory"] = v["root"] @@ -278,6 +283,33 @@ def _apply_tmuxinator_startup_focus( windows[target_window_idx]["panes"] = normalized_panes +def _normalize_tmuxinator_pane(pane: t.Any) -> t.Any: + """Normalize a tmuxinator pane shorthand to tmuxp pane form. + + tmuxinator panes can be: + + - bare string ``"cmd"`` (passed through unchanged; loader.expand wraps it) + - list ``["cmd1", "cmd2"]`` (passed through unchanged) + - hash ``{name: "cmd"}`` (a single-key dict — name becomes the pane + title). Returns ``{"title": name, "shell_command": [cmd]}``. + + Anything else (e.g., already-tmuxp pane dicts, ``None``) passes + through unchanged. + + >>> _normalize_tmuxinator_pane("vim") + 'vim' + >>> _normalize_tmuxinator_pane({"editor": "vim"}) + {'title': 'editor', 'shell_command': ['vim']} + """ + if isinstance(pane, dict) and len(pane) == 1: + ((name, cmd),) = pane.items() + if isinstance(cmd, str): + return {"title": name, "shell_command": [cmd]} + if isinstance(cmd, list): + return {"title": name, "shell_command": cmd} + return pane + + def _resolve_startup_index( value: t.Any, items: list[t.Any], From 0bc93098f11ad895be30fe22ec4f708e8c37f843 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:44:30 -0500 Subject: [PATCH 77/89] tests(builder): Cover pane titles + fix global_=True for window scope why: Lock in T2 behavior. pane-border-status / pane-border-format are window-scope options; the initial source landed without global_=True which silently no-op'd at session level (tests caught this). what: - builder: set pane-border-status / pane-border-format with global_=True so the values apply across every window in the session (matches tmuxinator's `set-option -g`). - New fixture pane_titles.yaml. - 3 new tests: explicit position+format, per-pane title verified via display-message format query, defaults-only path. All read options via show_option(..., global_=True). ref: notes/plan.md T2 --- src/tmuxp/workspace/builder.py | 6 +- .../workspace/builder/pane_titles.yaml | 13 ++++ tests/workspace/test_builder.py | 66 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/workspace/builder/pane_titles.yaml diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 4d1ec41c29..7450b0ecff 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -542,14 +542,16 @@ def build(self, session: Session | None = None, append: bool = False) -> None: # toggles `pane-border-status`; pane_title_position picks where the # status bar lives; pane_title_format defines what tmux renders. # Per-pane `title` is set later in iter_create_panes via set_title(). + # Both pane-border-* are window-scope options; use global_=True so + # the values cover every window in the session. if self.session_config.get("enable_pane_titles"): position = self.session_config.get("pane_title_position", "top") fmt = self.session_config.get( "pane_title_format", "#{pane_index}: #{pane_title}", ) - self.session.set_option("pane-border-status", position) - self.session.set_option("pane-border-format", fmt) + self.session.set_option("pane-border-status", position, global_=True) + self.session.set_option("pane-border-format", fmt, global_=True) for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) diff --git a/tests/fixtures/workspace/builder/pane_titles.yaml b/tests/fixtures/workspace/builder/pane_titles.yaml new file mode 100644 index 0000000000..2aa8bdfd65 --- /dev/null +++ b/tests/fixtures/workspace/builder/pane_titles.yaml @@ -0,0 +1,13 @@ +session_name: pane-titles +enable_pane_titles: true +pane_title_position: bottom +pane_title_format: "#{pane_index}: #{pane_title}" +windows: +- window_name: w1 + panes: + - shell_command: + - echo first + title: editor + - shell_command: + - echo second + title: server diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index aa50d8c7ea..8e1db57f62 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1840,6 +1840,72 @@ def test_synchronize_panes_true_treated_as_before( builder.session.kill() +def test_pane_titles_session_options_set( + session: Session, +) -> None: + """enable_pane_titles applies pane-border-status and -format.""" + 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) + + # pane-border-* are window-scope options applied via global_=True + assert builder.session.show_option("pane-border-status", global_=True) == "bottom" + assert ( + builder.session.show_option("pane-border-format", global_=True) + == "#{pane_index}: #{pane_title}" + ) + builder.session.kill() + + +def test_pane_titles_per_pane_title_applied( + session: Session, +) -> None: + """Per-pane `title` is stored on the pane via set_title().""" + 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 = builder.session.windows[-1] + titles = [ + p.cmd("display-message", "-p", "#{pane_title}").stdout[0] for p in window.panes + ] + assert "editor" in titles + assert "server" in titles + builder.session.kill() + + +def test_pane_titles_defaults_when_only_enable( + session: Session, +) -> None: + """enable_pane_titles=true with no other keys uses defaults.""" + workspace = { + "session_name": "titles-defaults", + "enable_pane_titles": True, + "windows": [ + { + "window_name": "w1", + "panes": [{"shell_command": ["echo a"]}], + }, + ], + } + workspace = loader.expand(workspace) + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert builder.session.show_option("pane-border-status", global_=True) == "top" + assert ( + builder.session.show_option("pane-border-format", global_=True) + == "#{pane_index}: #{pane_title}" + ) + builder.session.kill() + + @pytest.mark.flaky(reruns=5) def test_shell_command_after_runs_in_each_pane( session: Session, From d2ca89b62cd86b28ac952ecbb6d08226d884348a Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:47:11 -0500 Subject: [PATCH 78/89] feat(builder): Wire on_project_start/first_start/restart lifecycle hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxinator's hook system fires shell commands at well-defined phases (template.erb:14, hooks/project.rb:1-44). tmuxp had `before_script` covering only the first-start case. With T6 the full hook set lands. what: - util.run_lifecycle_hook: new helper accepting str | list[str] | None. Iterates list values sequentially and calls run_before_script for each item. No shell=True (matches before_script's semantics — users wrap shell logic in script files). Aborts list iteration on first non-zero exit code. - builder.build: after `before_workspace_builder` plugin and before `before_script`, fire on_project_start (always), then on_project_restart (when append=True) or on_project_first_start (otherwise). - Hooks share the same `cwd` resolution (session start_directory) and output callback (on_script_output) as before_script. - on_project_exit and on_project_stop wire in via cli/load.py and cli/stop.py respectively in subsequent commits. ref: notes/plan.md T6 --- src/tmuxp/util.py | 46 ++++++++++++++++++++++++++++++++++ src/tmuxp/workspace/builder.py | 29 ++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 152b1f6c06..a03ad4c0d6 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -105,6 +105,52 @@ def run_before_script( return return_code +def run_lifecycle_hook( + hook_name: str, + value: str | list[str] | None, + cwd: pathlib.Path | None = None, + on_line: t.Callable[[str], None] | None = None, +) -> int: + """Execute a tmuxinator-style lifecycle hook value. + + Reuses :func:`run_before_script`'s `shlex.split` + `subprocess.Popen` + semantics — no `shell=True`. Pipes/redirects in hook strings won't + work; users wrap shell logic in a script file. + + Parameters + ---------- + hook_name : str + The hook key (e.g. ``"on_project_start"``); used in DEBUG logs. + value : str | list[str] | None + The config value. ``None`` is a no-op (returns 0). String is + run once. List is run sequentially; aborts on first non-zero + exit code. + cwd : pathlib.Path | None + Working directory passed to each invocation. + on_line : Callable[[str], None] | None + Per-line output callback (same as ``run_before_script``). + + Returns + ------- + int + Final exit code (0 if all items succeeded). Raises the same + exceptions ``run_before_script`` does. + """ + if value is None: + return 0 + items = value if isinstance(value, list) else [value] + last_code = 0 + for item in items: + logger.debug( + "running lifecycle hook", + extra={"tmux_key": hook_name}, + ) + last_code = run_before_script(item, cwd=cwd, on_line=on_line) + if last_code != 0: + return last_code + return last_code + + 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 7450b0ecff..908450494b 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -16,7 +16,7 @@ from tmuxp import exc from tmuxp.log import TmuxpLoggerAdapter -from tmuxp.util import get_current_pane, run_before_script +from tmuxp.util import get_current_pane, run_before_script, run_lifecycle_hook if t.TYPE_CHECKING: from collections.abc import Iterator @@ -491,6 +491,33 @@ def build(self, session: Session | None = None, append: bool = False) -> None: focus = None + # Lifecycle hooks: on_project_start fires every load (matches + # tmuxinator template.erb:14). first_start fires only on a NEW + # session; restart fires only when appending to an existing one. + # All execute via run_lifecycle_hook which reuses run_before_script + # (no shell=True; pipes need a script file). + hook_cwd = self.session_config.get("start_directory") + run_lifecycle_hook( + "on_project_start", + self.session_config.get("on_project_start"), + cwd=hook_cwd, + on_line=self.on_script_output, + ) + if append: + run_lifecycle_hook( + "on_project_restart", + self.session_config.get("on_project_restart"), + cwd=hook_cwd, + on_line=self.on_script_output, + ) + else: + run_lifecycle_hook( + "on_project_first_start", + self.session_config.get("on_project_first_start"), + cwd=hook_cwd, + on_line=self.on_script_output, + ) + if "before_script" in self.session_config: if self.on_before_script: self.on_before_script() From 7ae5354a6a4e5bb582120402f3281d12ae7fafb4 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:49:15 -0500 Subject: [PATCH 79/89] tests(util): Cover run_lifecycle_hook helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Lock in the T6 helper's contract — string vs list, sequential execution, abort-on-failure, exception propagation. what: - test_run_lifecycle_hook_none_is_noop: None value returns 0. - test_run_lifecycle_hook_string: single string is run once. - test_run_lifecycle_hook_list_runs_each: list iterates in order (verified by appending to a log file). - test_run_lifecycle_hook_aborts_on_first_failure: when an item exits non-zero, BeforeLoadScriptError raises and remaining items are not invoked. - test_run_lifecycle_hook_propagates_not_found: missing script path raises BeforeLoadScriptNotExists (same as run_before_script). ref: notes/plan.md T6 --- tests/test_util.py | 57 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/tests/test_util.py b/tests/test_util.py index 098c8c212b..5f3e5d6381 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_lifecycle_hook, +) from .constants import FIXTURE_PATH @@ -234,3 +240,52 @@ 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 + + +def test_run_lifecycle_hook_none_is_noop() -> None: + """Passing None to run_lifecycle_hook returns 0 without invoking shell.""" + assert run_lifecycle_hook("on_project_start", None) == 0 + + +def test_run_lifecycle_hook_string(tmp_path: pathlib.Path) -> None: + """String value runs once via run_before_script.""" + script = tmp_path / "h.sh" + script.write_text("#!/bin/sh\necho ok\n") + script.chmod(0o755) + assert run_lifecycle_hook("on_project_start", str(script)) == 0 + + +def test_run_lifecycle_hook_list_runs_each(tmp_path: pathlib.Path) -> None: + """List value runs each item sequentially.""" + log_file = tmp_path / "out.log" + a = tmp_path / "a.sh" + a.write_text(f"#!/bin/sh\necho A >> {log_file}\n") + a.chmod(0o755) + b = tmp_path / "b.sh" + b.write_text(f"#!/bin/sh\necho B >> {log_file}\n") + b.chmod(0o755) + assert run_lifecycle_hook("on_project_start", [str(a), str(b)]) == 0 + assert log_file.read_text().splitlines() == ["A", "B"] + + +def test_run_lifecycle_hook_aborts_on_first_failure( + tmp_path: pathlib.Path, +) -> None: + """List iteration aborts when any item exits non-zero.""" + a = tmp_path / "ok.sh" + a.write_text("#!/bin/sh\nexit 0\n") + a.chmod(0o755) + b = tmp_path / "fail.sh" + b.write_text("#!/bin/sh\nexit 7\n") + b.chmod(0o755) + c = tmp_path / "never.sh" + c.write_text("#!/bin/sh\nexit 0\n") + c.chmod(0o755) + with pytest.raises(BeforeLoadScriptError): + run_lifecycle_hook("on_project_start", [str(a), str(b), str(c)]) + + +def test_run_lifecycle_hook_propagates_not_found(tmp_path: pathlib.Path) -> None: + """Missing script raises BeforeLoadScriptNotExists like before_script.""" + with pytest.raises(BeforeLoadScriptNotExists): + run_lifecycle_hook("on_project_start", str(tmp_path / "nope.sh")) From 69d21b0e5860ae99ac9d4247d69e42972de49ab3 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:53:14 -0500 Subject: [PATCH 80/89] feat(cli[manage]): Add new/copy/delete/implode workspace commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxinator ships new, copy, delete, and implode for workspace config file management (cli.rb:78-457). tmuxp had only `edit`. This brings parity for the full lifecycle of workspace files. what: - New module src/tmuxp/cli/manage.py groups the 4 commands so the shared file-IO helpers (_confirm, _config_path, _open_in_editor, _ensure_dir) live in one place. Each command has its own typed Namespace, subparser builder, and entry function — same shape as the existing per-command modules (load.py, freeze.py). - new <name>: writes a starter YAML to the user's workspace dir if missing, then opens in $EDITOR. - copy <src> <dst>: validates src via find_workspace_file, copies to dst path, prompts on overwrite (unless -y), opens dst in $EDITOR. - delete <name>...: per-name confirmation prompt (unless -y), unlinks each. Skips non-existent with a warning. - implode: deletes ALL tmuxp config directories (legacy ~/.tmuxp + XDG ~/.config/tmuxp). Single global confirmation (unless -y). - Wired into cli/__init__.py: imports, subparsers, dispatch, and the CLISubparserName Literal. ref: notes/plan.md T10 --- src/tmuxp/cli/__init__.py | 62 +++++++++ src/tmuxp/cli/manage.py | 285 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 src/tmuxp/cli/manage.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 860a9200cb..8d7a9f4c03 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -45,6 +45,24 @@ create_load_subparser, ) from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .manage import ( + COPY_DESCRIPTION, + DELETE_DESCRIPTION, + IMPLODE_DESCRIPTION, + NEW_DESCRIPTION, + CLICopyNamespace, + CLIDeleteNamespace, + CLIImplodeNamespace, + CLINewNamespace, + command_copy, + command_delete, + command_implode, + command_new, + create_copy_subparser, + create_delete_subparser, + create_implode_subparser, + create_new_subparser, +) from .search import ( SEARCH_DESCRIPTION, CLISearchNamespace, @@ -156,6 +174,10 @@ "search", "shell", "debug-info", + "new", + "copy", + "delete", + "implode", ] CLIImportSubparserName: TypeAlias = t.Literal["teamocil", "tmuxinator"] @@ -262,6 +284,38 @@ def create_parser() -> argparse.ArgumentParser: ) create_freeze_subparser(freeze_parser) + new_parser = subparsers.add_parser( + "new", + help="create a new workspace file in the user config dir", + description=NEW_DESCRIPTION, + formatter_class=formatter_class, + ) + create_new_subparser(new_parser) + + copy_parser = subparsers.add_parser( + "copy", + help="copy an existing workspace file to a new name", + description=COPY_DESCRIPTION, + formatter_class=formatter_class, + ) + create_copy_subparser(copy_parser) + + delete_parser = subparsers.add_parser( + "delete", + help="delete one or more workspace files", + description=DELETE_DESCRIPTION, + formatter_class=formatter_class, + ) + create_delete_subparser(delete_parser) + + implode_parser = subparsers.add_parser( + "implode", + help="delete ALL workspace files", + description=IMPLODE_DESCRIPTION, + formatter_class=formatter_class, + ) + create_implode_subparser(implode_parser) + return parser @@ -353,6 +407,14 @@ def cli(_args: list[str] | None = None) -> None: args=CLIFreezeNamespace(**vars(args)), parser=parser, ) + elif args.subparser_name == "new": + command_new(args=CLINewNamespace(**vars(args)), parser=parser) + elif args.subparser_name == "copy": + command_copy(args=CLICopyNamespace(**vars(args)), parser=parser) + elif args.subparser_name == "delete": + command_delete(args=CLIDeleteNamespace(**vars(args)), parser=parser) + elif args.subparser_name == "implode": + command_implode(args=CLIImplodeNamespace(**vars(args)), parser=parser) elif args.subparser_name == "ls": command_ls( args=CLILsNamespace(**vars(args)), diff --git a/src/tmuxp/cli/manage.py b/src/tmuxp/cli/manage.py new file mode 100644 index 0000000000..9b09cfb937 --- /dev/null +++ b/src/tmuxp/cli/manage.py @@ -0,0 +1,285 @@ +"""Workspace-config management commands: ``new``, ``copy``, ``delete``, ``implode``. + +Mirrors tmuxinator's ``new``/``copy``/``delete``/``implode`` subcommands +(cli.rb:78-457). Implemented in a single module to keep the four +commands' shared file-IO patterns close together. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import pathlib +import shutil +import subprocess +import sys +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +_NEW_TEMPLATE = """# {name}.yaml — generated by `tmuxp new {name}` +session_name: {name} +windows: +- window_name: editor + panes: + - shell_command: + - +""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _config_path(name: str) -> pathlib.Path: + """Return the canonical workspace file path for ``name`` in the user's dir.""" + return pathlib.Path(get_workspace_dir()) / f"{name}.yaml" + + +def _ensure_dir(path: pathlib.Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _open_in_editor(path: pathlib.Path) -> None: + editor = os.environ.get("EDITOR", "vim") + subprocess.call([editor, str(path)]) + + +def _confirm(prompt: str, *, default_no: bool = True) -> bool: + """Read y/n from stdin; on EOF (no TTY) return ``not default_no``.""" + suffix = " [y/N] " if default_no else " [Y/n] " + try: + answer = input(prompt + suffix).strip().lower() + except EOFError: + return not default_no + if not answer: + return not default_no + return answer in {"y", "yes"} + + +# --------------------------------------------------------------------------- +# new +# --------------------------------------------------------------------------- + + +NEW_DESCRIPTION = build_description( + "Create a new tmuxp workspace file in the user's workspace directory.", + ((None, ["tmuxp new myproject"]),), +) + + +class CLINewNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp new``.""" + + name: str + color: CLIColorModeLiteral | None + + +def create_new_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp new``.""" + parser.add_argument("name", help="config name (without .yaml extension)") + return parser + + +def command_new( + args: CLINewNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp new``.""" + colors = Colors(get_color_mode(args.color)) + target = _config_path(args.name) + _ensure_dir(target) + if not target.exists(): + target.write_text(_NEW_TEMPLATE.format(name=args.name), encoding="utf-8") + tmuxp_echo(colors.muted("Created ") + colors.info(str(PrivatePath(target)))) + _open_in_editor(target) + + +# --------------------------------------------------------------------------- +# copy +# --------------------------------------------------------------------------- + + +COPY_DESCRIPTION = build_description( + "Copy an existing workspace file to a new name and open it for editing.", + ((None, ["tmuxp copy myproject myproject-staging"]),), +) + + +class CLICopyNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp copy``.""" + + src: str + dst: str + answer_yes: bool + color: CLIColorModeLiteral | None + + +def create_copy_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp copy``.""" + parser.add_argument("src", help="source config name or path") + parser.add_argument("dst", help="destination config name (without extension)") + parser.add_argument( + "-y", + "--yes", + dest="answer_yes", + action="store_true", + help="skip overwrite confirmation", + ) + return parser + + +def command_copy( + args: CLICopyNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp copy``.""" + colors = Colors(get_color_mode(args.color)) + src_path = pathlib.Path(find_workspace_file(args.src)) + dst_path = _config_path(args.dst) + _ensure_dir(dst_path) + if ( + dst_path.exists() + and not args.answer_yes + and not _confirm(colors.warning(f"Overwrite {PrivatePath(dst_path)}?")) + ): + tmuxp_echo(colors.muted("Aborted.")) + return + shutil.copyfile(src_path, dst_path) + tmuxp_echo( + colors.muted("Copied ") + + colors.info(str(PrivatePath(src_path))) + + colors.muted(" -> ") + + colors.info(str(PrivatePath(dst_path))), + ) + _open_in_editor(dst_path) + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +DELETE_DESCRIPTION = build_description( + "Delete one or more workspace config files (with confirmation).", + ((None, ["tmuxp delete myproject", "tmuxp delete proj1 proj2"]),), +) + + +class CLIDeleteNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp delete``.""" + + names: list[str] + answer_yes: bool + color: CLIColorModeLiteral | None + + +def create_delete_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp delete``.""" + parser.add_argument("names", nargs="+", help="config name(s) to delete") + parser.add_argument( + "-y", + "--yes", + dest="answer_yes", + action="store_true", + help="skip per-config confirmation", + ) + return parser + + +def command_delete( + args: CLIDeleteNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp delete``.""" + colors = Colors(get_color_mode(args.color)) + for name in args.names: + try: + path = pathlib.Path(find_workspace_file(name)) + except Exception: + tmuxp_echo(colors.error(f"Not found: {name}")) + continue + if not args.answer_yes and not _confirm( + colors.warning(f"Delete {PrivatePath(path)}?"), + ): + tmuxp_echo(colors.muted(f"Skipped {PrivatePath(path)}")) + continue + path.unlink() + tmuxp_echo(colors.muted("Deleted ") + colors.info(str(PrivatePath(path)))) + + +# --------------------------------------------------------------------------- +# implode +# --------------------------------------------------------------------------- + + +IMPLODE_DESCRIPTION = build_description( + "Delete ALL workspace config files in every tmuxp workspace directory.", + ((None, ["tmuxp implode"]),), +) + + +class CLIImplodeNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp implode``.""" + + answer_yes: bool + color: CLIColorModeLiteral | None + + +def create_implode_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp implode``.""" + parser.add_argument( + "-y", + "--yes", + dest="answer_yes", + action="store_true", + help="skip the global confirmation prompt", + ) + return parser + + +def _implode_dirs() -> list[pathlib.Path]: + """Return existing tmuxp config directories worth removing.""" + candidates = [ + pathlib.Path(os.path.expanduser("~/.tmuxp")), + pathlib.Path(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))) + / "tmuxp", + ] + return [p for p in candidates if p.exists() and p.is_dir()] + + +def command_implode( + args: CLIImplodeNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp implode``.""" + colors = Colors(get_color_mode(args.color)) + dirs = _implode_dirs() + if not dirs: + tmuxp_echo(colors.muted("No tmuxp config directories found.")) + return + if not args.answer_yes and not _confirm( + colors.error(f"Delete ALL configs in: {', '.join(str(d) for d in dirs)}?"), + ): + tmuxp_echo(colors.muted("Aborted.")) + return + for d in dirs: + shutil.rmtree(d) + tmuxp_echo(colors.muted("Removed ") + colors.info(str(PrivatePath(d)))) + + +# Silence unused-import warnings on platforms where `sys` isn't directly needed +# at runtime (kept for future stdin-detection extensions). +_ = sys From 948645a29f70ea708b28fecb0156020a7a2e7f90 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:55:21 -0500 Subject: [PATCH 81/89] tests(cli[manage]): Cover new/copy/delete/implode why: Lock in T10 behavior across all 4 management commands. what: - configdir/no_editor fixtures: monkeypatch TMUXP_CONFIGDIR to tmp_path and replace subprocess.call (the $EDITOR invocation) with a no-op so tests run headless. - new: writes starter YAML when missing; preserves an existing file. - copy: duplicates src content to dst; aborts when user declines overwrite (input mocked). - delete: -y removes the file; missing name doesn't raise. - implode: removes every dir from _implode_dirs() with -y; aborts when user declines (dir + content survive). ref: notes/plan.md T10 --- tests/cli/test_manage.py | 162 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/cli/test_manage.py diff --git a/tests/cli/test_manage.py b/tests/cli/test_manage.py new file mode 100644 index 0000000000..892f41e31d --- /dev/null +++ b/tests/cli/test_manage.py @@ -0,0 +1,162 @@ +"""Tests for ``tmuxp new``, ``tmuxp copy``, ``tmuxp delete``, ``tmuxp implode``.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from tmuxp.cli.manage import ( + CLICopyNamespace, + CLIDeleteNamespace, + CLIImplodeNamespace, + CLINewNamespace, + command_copy, + command_delete, + command_implode, + command_new, +) + +if t.TYPE_CHECKING: + pass + + +@pytest.fixture +def configdir(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: + """Force tmuxp config dir to a tmp_path for the test scope.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def no_editor(monkeypatch: pytest.MonkeyPatch) -> None: + """Replace subprocess.call with a no-op so $EDITOR doesn't actually run.""" + monkeypatch.setattr("tmuxp.cli.manage.subprocess.call", lambda *a, **kw: 0) + + +# --------------------------------------------------------------------------- +# new +# --------------------------------------------------------------------------- + + +def test_command_new_creates_file_when_missing( + configdir: pathlib.Path, no_editor: None +) -> None: + """`tmuxp new <name>` writes a starter YAML if file is missing.""" + args = CLINewNamespace(name="proj1", color=None) + command_new(args) + assert (configdir / "proj1.yaml").exists() + content = (configdir / "proj1.yaml").read_text() + assert "session_name: proj1" in content + + +def test_command_new_skips_write_when_file_exists( + configdir: pathlib.Path, no_editor: None +) -> None: + """`tmuxp new <name>` does not overwrite an existing file.""" + target = configdir / "proj1.yaml" + target.write_text("session_name: original\n") + args = CLINewNamespace(name="proj1", color=None) + command_new(args) + assert target.read_text() == "session_name: original\n" + + +# --------------------------------------------------------------------------- +# copy +# --------------------------------------------------------------------------- + + +def test_command_copy_duplicates_file(configdir: pathlib.Path, no_editor: None) -> None: + """`tmuxp copy src dst` writes dst with src's content.""" + src = configdir / "src.yaml" + src.write_text("session_name: src\n") + args = CLICopyNamespace(src=str(src), dst="dst", answer_yes=True, color=None) + command_copy(args) + assert (configdir / "dst.yaml").read_text() == "session_name: src\n" + + +def test_command_copy_aborts_on_overwrite_decline( + configdir: pathlib.Path, + no_editor: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When dst exists and the user declines, dst content is preserved.""" + src = configdir / "src.yaml" + src.write_text("session_name: src\n") + dst = configdir / "dst.yaml" + dst.write_text("session_name: existing\n") + + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + args = CLICopyNamespace(src=str(src), dst="dst", answer_yes=False, color=None) + command_copy(args) + assert dst.read_text() == "session_name: existing\n" + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +def test_command_delete_unlinks_with_yes( + configdir: pathlib.Path, +) -> None: + """`tmuxp delete <name> -y` deletes without prompting.""" + target = configdir / "proj1.yaml" + target.write_text("session_name: proj1\n") + args = CLIDeleteNamespace(names=[str(target)], answer_yes=True, color=None) + command_delete(args) + assert not target.exists() + + +def test_command_delete_skips_missing(configdir: pathlib.Path) -> None: + """Nonexistent name produces an error message but no exception.""" + args = CLIDeleteNamespace( + names=["nope-no-such-config"], + answer_yes=True, + color=None, + ) + command_delete(args) # must not raise + + +# --------------------------------------------------------------------------- +# implode +# --------------------------------------------------------------------------- + + +def test_command_implode_removes_existing_dirs( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + """`tmuxp implode -y` removes every tmuxp config directory found.""" + legacy_dir = tmp_path / "home" / ".tmuxp" + xdg_dir = tmp_path / "config" / "tmuxp" + legacy_dir.mkdir(parents=True) + xdg_dir.mkdir(parents=True) + (legacy_dir / "p1.yaml").write_text("session_name: p1\n") + (xdg_dir / "p2.yaml").write_text("session_name: p2\n") + + monkeypatch.setattr( + "tmuxp.cli.manage._implode_dirs", + lambda: [legacy_dir, xdg_dir], + ) + + args = CLIImplodeNamespace(answer_yes=True, color=None) + command_implode(args) + + assert not legacy_dir.exists() + assert not xdg_dir.exists() + + +def test_command_implode_aborts_on_decline( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + """Declining the global confirmation leaves directories intact.""" + d = tmp_path / "tmuxp" + d.mkdir() + (d / "p.yaml").write_text("session_name: p\n") + monkeypatch.setattr("tmuxp.cli.manage._implode_dirs", lambda: [d]) + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + args = CLIImplodeNamespace(answer_yes=False, color=None) + command_implode(args) + assert d.exists() + assert (d / "p.yaml").exists() From df27abf981b0ac7e7884aa53438f771fcecd4838 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 12:58:40 -0500 Subject: [PATCH 82/89] feat(template): In-house ${var} engine + load -D KEY=VALUE plumbing why: tmuxinator pre-processes its YAML through Erubi (full Ruby) so config files can interpolate runtime values. tmuxp gets the equivalent without any new dependency: a ~50-LOC stdlib regex-based engine that handles only ${var} and ${var:-default} (no conditionals/loops/Ruby escape hatches). Pre-YAML rendering matches tmuxinator's ERB-before-YAML semantics. what: - New module src/tmuxp/_internal/template.py exporting render() and parse_cli_vars(). Doctests cover the canonical cases. UnresolvedVariableError subclasses KeyError so existing except-KeyError sites still catch. - cli/load.py: new repeatable `-D KEY=VALUE` / `--var KEY=VALUE` flag. Plumbed through CLILoadNamespace, command_load, and load_workspace. - load_workspace: when template_vars is non-empty AND the workspace file is YAML, read the raw text, render with strict=False, then hand to ConfigReader._load("yaml", rendered). JSON files are parsed as-is (escaping headaches not worth the marginal benefit). - strict=False at the load site so unresolved ${HOME}-style refs flow through to loader.expandshell, preserving the existing env-var expansion behaviour. ref: notes/plan.md T8 --- src/tmuxp/_internal/template.py | 140 ++++++++++++++++++++++++++++++++ src/tmuxp/cli/load.py | 33 +++++++- 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 src/tmuxp/_internal/template.py diff --git a/src/tmuxp/_internal/template.py b/src/tmuxp/_internal/template.py new file mode 100644 index 0000000000..55c36cda3c --- /dev/null +++ b/src/tmuxp/_internal/template.py @@ -0,0 +1,140 @@ +"""Minimal template engine for tmuxp workspace files. + +Two substitution forms only — keep the surface small and predictable: + +- ``${name}`` — required variable; raises in strict mode if missing +- ``${name:-default}`` — optional, falls back to the literal default text + +Bare ``$name`` is intentionally NOT supported: the post-parse expansion in +:func:`tmuxp.workspace.loader.expandshell` already handles ``$ENV_VAR`` +forms on specific config keys. Keeping this engine tied to ``${...}`` lets +the two layers cohabit without ambiguity. + +Templating runs on the raw YAML text before +:class:`tmuxp._internal.config_reader.ConfigReader` parses it, +mirroring tmuxinator's ERB-before-YAML behaviour. + +This module deliberately has no runtime dependencies beyond :mod:`re`. +""" + +from __future__ import annotations + +import re +import typing as t + +__all__ = [ + "UnresolvedVariableError", + "parse_cli_vars", + "render", +] + +_VAR_PATTERN = re.compile( + r""" + \$\{ # opening ${ + (?P<name>[A-Za-z_][A-Za-z0-9_]*) # variable name + (?: # optional default group + :- # default operator + (?P<default>[^}]*) # default value (no nested braces) + )? + \} # closing } + """, + re.VERBOSE, +) + + +class UnresolvedVariableError(KeyError): + """A template referenced a variable with no value and no default. + + Subclass of :class:`KeyError` so callers using ``except KeyError`` keep + working, but the more specific name lets tmuxp's CLI distinguish this + from an actual dict-key miss. + """ + + +def render( + template: str, + variables: t.Mapping[str, str], + *, + strict: bool = True, +) -> str: + """Substitute ``${name}`` and ``${name:-default}`` placeholders. + + Parameters + ---------- + template : str + The raw text containing placeholders. + variables : Mapping[str, str] + Substitution dictionary (typically from CLI ``KEY=VALUE`` args). + strict : bool, optional + When ``True`` (the default), an unresolved placeholder without a + default raises :class:`UnresolvedVariableError`. When ``False``, + unresolved placeholders are left in the output untouched — useful + when a later pipeline stage (e.g. ``loader.expandshell``) will + resolve them. + + Returns + ------- + str + The rendered template. + + >>> render("hello ${name}", {"name": "world"}) + 'hello world' + >>> render("port=${port:-3000}", {}) + 'port=3000' + >>> render("port=${port:-3000}", {"port": "4000"}) + 'port=4000' + >>> render("untouched ${HOME}", {}, strict=False) + 'untouched ${HOME}' + """ + + def replace(match: re.Match[str]) -> str: + name = match["name"] + if name in variables: + return variables[name] + default = match["default"] + if default is not None: + return default + if strict: + raise UnresolvedVariableError(name) + return match[0] + + return _VAR_PATTERN.sub(replace, template) + + +def parse_cli_vars(args: t.Sequence[str]) -> dict[str, str]: + """Parse ``KEY=VALUE`` positional CLI args into a dict. + + Supports values containing ``=``: only the first ``=`` is treated as + the separator. Empty input returns an empty dict. + + Parameters + ---------- + args : Sequence[str] + Tokens from the CLI (e.g. ``["app=blog", "port=4000"]``). + + Returns + ------- + dict[str, str] + Variable map suitable for passing as ``variables=`` to + :func:`render`. + + Raises + ------ + ValueError + If any token has no ``=`` separator. + + >>> parse_cli_vars(["app=blog", "port=4000"]) + {'app': 'blog', 'port': '4000'} + >>> parse_cli_vars(["url=https://example.com/?a=1"]) + {'url': 'https://example.com/?a=1'} + >>> parse_cli_vars([]) + {} + """ + out: dict[str, str] = {} + for token in args: + if "=" not in token: + msg = f"template var must be KEY=VALUE: {token!r}" + raise ValueError(msg) + key, _, value = token.partition("=") + out[key] = value + return out diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 534c91242e..d3bb713035 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -106,6 +106,7 @@ class CLILoadNamespace(argparse.Namespace): detached: bool append: bool | None no_shell_command_before: bool + template_vars: list[str] colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None @@ -452,6 +453,7 @@ def load_workspace( panel_lines: int | None = None, no_progress: bool = False, no_shell_command_before: bool = False, + template_vars: t.Mapping[str, str] | None = None, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -554,8 +556,20 @@ def load_workspace( + cli_colors.highlight(str(PrivatePath(workspace_file))), ) - # ConfigReader allows us to open a yaml or json file as a dict - raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} + # Render ${var} / ${var:-default} placeholders against the raw YAML + # text BEFORE parsing (matches tmuxinator's ERB-before-YAML ordering). + # JSON files skip this step. strict=False leaves unresolved placeholders + # in place so loader.expandshell handles $ENV_VAR forms. + workspace_path = pathlib.Path(workspace_file) + if template_vars and workspace_path.suffix.lower() in {".yaml", ".yml"}: + from tmuxp._internal import template as _template + + raw_text = workspace_path.read_text(encoding="utf-8") + rendered = _template.render(raw_text, template_vars, strict=False) + raw_workspace = config_reader.ConfigReader._load("yaml", rendered) or {} + else: + # ConfigReader allows us to open a yaml or json file as a dict + raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} # shapes workspaces relative to config / profile file location expanded_workspace = loader.expand( @@ -774,6 +788,18 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP "pre_window)" ), ) + parser.add_argument( + "-D", + "--var", + dest="template_vars", + action="append", + default=[], + metavar="KEY=VALUE", + help=( + "template variable for ${var} / ${var:-default} substitution " + "in YAML workspace files; repeatable" + ), + ) colorsgroup = parser.add_mutually_exclusive_group() colorsgroup.add_argument( @@ -906,6 +932,8 @@ def command_load( detached = True new_session_name = None + from tmuxp._internal import template as _template + load_workspace( workspace_file, socket_name=args.socket_name, @@ -921,4 +949,5 @@ def command_load( panel_lines=args.panel_lines, no_progress=args.no_progress, no_shell_command_before=args.no_shell_command_before, + template_vars=_template.parse_cli_vars(args.template_vars or []), ) From 6b754bb93d140cb2488c818b4e06034e09fbf76d Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 13:00:33 -0500 Subject: [PATCH 83/89] tests(_internal/template): Cover render + parse_cli_vars why: Lock in the T8 template engine's contract. what: - 12 RENDER_FIXTURES via NamedTuple parametrize (test_id-based ids): plain text, braced, defaults, defaults overridden, missing-strict raises, multiple substitutions, special chars in defaults, underscore names, bare $ untouched, env-var-like syntax, empty default, dash in default. - test_render_non_strict_leaves_missing_alone + test_render_non_strict_still_substitutes_known: cover the load.py integration mode where unresolved vars flow to expandshell. - test_render_default_does_not_apply_when_explicit_none and test_render_unresolved_error_is_keyerror_subclass: lock in the exception contract. - 6 PARSE_FIXTURES for parse_cli_vars: empty, single, multiple, value-contains-equals (URL with query string), empty value, missing-equals raises ValueError. ref: notes/plan.md T8 --- tests/_internal/test_template.py | 144 +++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/_internal/test_template.py diff --git a/tests/_internal/test_template.py b/tests/_internal/test_template.py new file mode 100644 index 0000000000..658fc41fd9 --- /dev/null +++ b/tests/_internal/test_template.py @@ -0,0 +1,144 @@ +"""Tests for the in-house template engine.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp._internal.template import ( + UnresolvedVariableError, + parse_cli_vars, + render, +) + + +class RenderFixture(t.NamedTuple): + """Fixture for the strict=True render path.""" + + test_id: str + template: str + variables: dict[str, str] + expected: str | None + expected_error: type[Exception] | None = None + + +RENDER_FIXTURES: list[RenderFixture] = [ + RenderFixture("plain_no_subst", "hello world", {}, "hello world"), + RenderFixture("braced", "hi ${name}!", {"name": "ada"}, "hi ada!"), + RenderFixture("default_used", "port=${port:-3000}", {}, "port=3000"), + RenderFixture( + "default_overridden", + "port=${port:-3000}", + {"port": "4000"}, + "port=4000", + ), + RenderFixture( + "missing_strict", + "hi ${gone}", + {}, + None, + UnresolvedVariableError, + ), + RenderFixture("multiple_in_one", "${a}/${b}", {"a": "x", "b": "y"}, "x/y"), + RenderFixture("default_with_special", "p=${p:-a/b/c}", {}, "p=a/b/c"), + RenderFixture("name_underscore", "${my_var}", {"my_var": "v"}, "v"), + RenderFixture("dollar_not_braced", "$bare", {"bare": "v"}, "$bare"), + RenderFixture("env_like_var", "${HOME}", {"HOME": "/tmp"}, "/tmp"), + RenderFixture("default_empty", "p=${p:-}", {}, "p="), + RenderFixture( + "default_with_dash", + "x=${x:-a-b-c}", + {}, + "x=a-b-c", + ), +] + + +@pytest.mark.parametrize( + list(RenderFixture._fields), + RENDER_FIXTURES, + ids=[f.test_id for f in RENDER_FIXTURES], +) +def test_render( + test_id: str, + template: str, + variables: dict[str, str], + expected: str | None, + expected_error: type[Exception] | None, +) -> None: + """Run each render fixture; verify output or error.""" + if expected_error is not None: + with pytest.raises(expected_error): + render(template, variables) + else: + assert render(template, variables) == expected + + +def test_render_non_strict_leaves_missing_alone() -> None: + """strict=False keeps unresolved placeholders for later expansion.""" + out = render("untouched ${HOME}", {}, strict=False) + assert out == "untouched ${HOME}" + + +def test_render_non_strict_still_substitutes_known() -> None: + """strict=False resolves what it can and leaves the rest.""" + out = render("${a} ${b}", {"a": "1"}, strict=False) + assert out == "1 ${b}" + + +def test_render_default_does_not_apply_when_explicit_none() -> None: + """``${name}`` (no default) raises in strict; not silent-empty.""" + with pytest.raises(UnresolvedVariableError): + render("${name}", {}) + + +def test_render_unresolved_error_is_keyerror_subclass() -> None: + """UnresolvedVariableError is a KeyError subclass.""" + try: + render("${x}", {}) + except KeyError: + return + pytest.fail("Expected KeyError-derived UnresolvedVariableError") + + +class ParseVarsFixture(t.NamedTuple): + """Fixture for parse_cli_vars.""" + + test_id: str + args: list[str] + expected: dict[str, str] | None + expected_error: type[Exception] | None = None + + +PARSE_FIXTURES: list[ParseVarsFixture] = [ + ParseVarsFixture("empty", [], {}), + ParseVarsFixture("single", ["app=blog"], {"app": "blog"}), + ParseVarsFixture("multiple", ["a=1", "b=2"], {"a": "1", "b": "2"}), + ParseVarsFixture( + "value_contains_equals", + ["url=https://example.com/?a=1"], + {"url": "https://example.com/?a=1"}, + ), + ParseVarsFixture("empty_value", ["x="], {"x": ""}), + ParseVarsFixture("missing_equals", ["nope"], None, ValueError), +] + + +@pytest.mark.parametrize( + list(ParseVarsFixture._fields), + PARSE_FIXTURES, + ids=[f.test_id for f in PARSE_FIXTURES], +) +def test_parse_cli_vars( + test_id: str, + args: list[str], + expected: dict[str, str] | None, + expected_error: type[Exception] | None, +) -> None: + """Round-trip CLI tokens to the variable dict.""" + if expected_error is not None: + with pytest.raises(expected_error): + parse_cli_vars(args) + else: + assert parse_cli_vars(args) == expected From 9fdeb207030385a476338f5f04771590aa689afd Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 13:03:36 -0500 Subject: [PATCH 84/89] feat(cli[stop]): Add tmuxp stop with on_project_stop hook why: tmuxinator's `stop <project>` (cli.rb:300-322 + template-stop.erb) fires the project's on_project_stop hook then kills the session. tmuxp had no equivalent; users had to `tmux kill-session` directly which skipped any cleanup hooks. what: - New module src/tmuxp/cli/stop.py with command_stop / CLIStopNamespace / create_stop_subparser, mirroring the existing per-command module shape (load.py, freeze.py). - Resolves workspace by name via find_workspace_file, reads session_name + on_project_stop from the config, fires the hook (via T6's run_lifecycle_hook) then calls Server.kill_session. - Idempotent: if no server is alive or session doesn't exist, prints a muted "nothing to stop" and returns without error. - Wired into cli/__init__.py imports, subparsers, dispatch, and the CLISubparserName Literal. Per template-stop.erb tmuxinator runs on_project_stop only (NOT on_project_exit). tmuxp matches that. ref: notes/plan.md T5 --- src/tmuxp/cli/__init__.py | 17 ++++++ src/tmuxp/cli/stop.py | 111 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/tmuxp/cli/stop.py diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 8d7a9f4c03..c0bb530b78 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -75,6 +75,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__) @@ -178,6 +184,7 @@ "copy", "delete", "implode", + "stop", ] CLIImportSubparserName: TypeAlias = t.Literal["teamocil", "tmuxinator"] @@ -316,6 +323,14 @@ def create_parser() -> argparse.ArgumentParser: ) create_implode_subparser(implode_parser) + stop_parser = subparsers.add_parser( + "stop", + help="stop a tmuxp-managed tmux session (fires on_project_stop)", + description=STOP_DESCRIPTION, + formatter_class=formatter_class, + ) + create_stop_subparser(stop_parser) + return parser @@ -415,6 +430,8 @@ def cli(_args: list[str] | None = None) -> None: command_delete(args=CLIDeleteNamespace(**vars(args)), parser=parser) elif args.subparser_name == "implode": command_implode(args=CLIImplodeNamespace(**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..99495bd055 --- /dev/null +++ b/src/tmuxp/cli/stop.py @@ -0,0 +1,111 @@ +"""CLI for ``tmuxp stop`` subcommand. + +Mirrors tmuxinator's ``stop`` command (cli.rb:300-322) — fires the +``on_project_stop`` hook before killing the session, but does NOT fire +``on_project_exit`` (that's tmuxinator's behavior in template-stop.erb). +""" + +from __future__ import annotations + +import argparse +import logging +import os +import pathlib +import typing as t + +from libtmux.server import Server + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.util import run_lifecycle_hook +from tmuxp.workspace.finders import find_workspace_file + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +STOP_DESCRIPTION = build_description( + "Stop a tmuxp-managed session, firing the on_project_stop hook first.", + ((None, ["tmuxp stop myproject", "tmuxp stop -L mysocket myproject"]),), +) + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +class CLIStopNamespace(argparse.Namespace): + """Typed namespace for ``tmuxp stop``.""" + + workspace_file: str + socket_name: str | None + socket_path: str | None + color: CLIColorModeLiteral | None + + +def create_stop_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` for ``tmuxp stop``.""" + parser.add_argument( + "workspace_file", + metavar="workspace-file", + help="config name (resolved via tmuxp workspace dir) or path", + ) + parser.add_argument( + "-L", + dest="socket_name", + action="store", + help="passthru to tmux(1) -L", + ) + parser.add_argument( + "-S", + dest="socket_path", + action="store", + help="passthru to tmux(1) -S", + ) + return parser + + +def command_stop( + args: CLIStopNamespace, parser: argparse.ArgumentParser | None = None +) -> None: + """Entry point for ``tmuxp stop``.""" + colors = Colors(get_color_mode(args.color)) + + workspace_file = pathlib.Path(find_workspace_file(args.workspace_file)) + raw = ConfigReader._from_file(workspace_file) or {} + session_name = raw.get("session_name") + if not session_name: + tmuxp_echo( + colors.error( + f"Cannot determine session_name from {workspace_file}", + ), + ) + return + + server = Server( + socket_name=args.socket_name, + socket_path=args.socket_path, + ) + if not server.is_alive() or not server.has_session(session_name): + tmuxp_echo( + colors.muted( + f"No running session named {session_name!r}; nothing to stop.", + ), + ) + return + + # Run on_project_stop BEFORE killing (matches template-stop.erb:1-10). + cwd_str = ( + os.path.dirname(workspace_file) + if "start_directory" not in raw + else raw["start_directory"] + ) + run_lifecycle_hook( + "on_project_stop", + raw.get("on_project_stop"), + cwd=pathlib.Path(cwd_str) if cwd_str else None, + ) + + server.kill_session(session_name) + tmuxp_echo(colors.muted(f"Stopped {session_name!r}.")) From 2224dcf39b0e731c22348e385258dfffee57971f Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 13:06:14 -0500 Subject: [PATCH 85/89] tests(cli[stop]): Cover tmuxp stop session kill + hook order ref: notes/plan.md T5 --- tests/cli/test_stop.py | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 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..2b59de2ffc --- /dev/null +++ b/tests/cli/test_stop.py @@ -0,0 +1,86 @@ +"""Tests for ``tmuxp stop``.""" + +from __future__ import annotations + +import pathlib +import typing as t + +from tmuxp.cli.stop import CLIStopNamespace, command_stop + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_command_stop_kills_running_session( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """`tmuxp stop` removes a running session that matches the config.""" + server = session.server + name = session.name or "" + assert name + workspace = tmp_path / "test_stop.yaml" + workspace.write_text( + f"session_name: {name}\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + args = CLIStopNamespace( + workspace_file=str(workspace), + socket_name=server.socket_name, + socket_path=server.socket_path, + color=None, + ) + assert server.has_session(name) + command_stop(args) + assert not server.has_session(name) + + +def test_command_stop_runs_on_project_stop_before_kill( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """on_project_stop hook fires before the session is killed.""" + server = session.server + name = session.name or "" + assert name + marker = tmp_path / "marker.log" + hook = tmp_path / "stop_hook.sh" + hook.write_text(f"#!/bin/sh\necho fired > {marker}\n") + hook.chmod(0o755) + + workspace = tmp_path / "test_stop_hook.yaml" + workspace.write_text( + f"session_name: {name}\n" + f"on_project_stop: {hook}\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + args = CLIStopNamespace( + workspace_file=str(workspace), + socket_name=server.socket_name, + socket_path=server.socket_path, + color=None, + ) + command_stop(args) + assert marker.exists() + assert marker.read_text().strip() == "fired" + assert not server.has_session(name) + + +def test_command_stop_no_session_is_noop( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """Stopping a non-existent session is a quiet no-op.""" + workspace = tmp_path / "test_stop_missing.yaml" + workspace.write_text( + "session_name: definitely-not-running-aaa-bbb\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + args = CLIStopNamespace( + workspace_file=str(workspace), + socket_name=server.socket_name, + socket_path=server.socket_path, + color=None, + ) + command_stop(args) # must not raise From 01e173d410d3eec6bce60410e906b8466bfd1ff4 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 13:09:49 -0500 Subject: [PATCH 86/89] feat(cli[load]): Add --here flag (load workspace into current session) why: teamocil's --here lets users layer a workspace onto the tmux session they're already attached to. tmuxp had no equivalent. what: - New --here flag on tmuxp load. Mutually exclusive with --append. Refuses to run outside a tmux session (TMUX env var must be set). - When --here is set, force append=True so the existing append codepath handles the build (windows added to current session). - args.here flows through CLILoadNamespace and load_workspace(). ref: notes/plan.md T4 --- src/tmuxp/cli/load.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index d3bb713035..66019216ef 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -106,6 +106,7 @@ class CLILoadNamespace(argparse.Namespace): detached: bool append: bool | None no_shell_command_before: bool + here: bool template_vars: list[str] colors: CLIColorsLiteral | None color: CLIColorModeLiteral @@ -454,6 +455,7 @@ def load_workspace( no_progress: bool = False, no_shell_command_before: bool = False, template_vars: t.Mapping[str, str] | None = None, + here: bool = False, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -536,6 +538,17 @@ def load_workspace( behalf. An exception raised during this process means it's not easy to predict how broken the session is. """ + # --here precondition checks. Forces append=True so the existing + # append codepath layers windows onto the user's current session. + if here and append: + msg = "--here is mutually exclusive with --append" + raise exc.TmuxpException(msg) + if here and not os.environ.get("TMUX"): + msg = "--here requires being inside an existing tmux session" + raise exc.TmuxpException(msg) + if here: + append = True + # Initialize CLI colors if not provided if cli_colors is None: cli_colors = Colors(ColorMode.AUTO) @@ -788,6 +801,17 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP "pre_window)" ), ) + parser.add_argument( + "--here", + dest="here", + action="store_true", + default=False, + help=( + "load workspace into the current tmux session (rename it + add " + "windows here). Mirrors teamocil --here. Requires being inside " + "tmux. Mutually exclusive with -a/--append." + ), + ) parser.add_argument( "-D", "--var", @@ -950,4 +974,5 @@ def command_load( no_progress=args.no_progress, no_shell_command_before=args.no_shell_command_before, template_vars=_template.parse_cli_vars(args.template_vars or []), + here=getattr(args, "here", False), ) From a2ac0c31ddb2f7c43f36ace1176016c71a16cd94 Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 13:11:31 -0500 Subject: [PATCH 87/89] tests(cli[load]): Cover --here precondition errors why: Lock in the precondition checks the T4 source commit added to load_workspace. what: - test_load_with_here_outside_tmux_errors: with TMUX env var unset, --here raises TmuxpException with the documented message. - test_load_with_here_and_append_errors: with TMUX set (to bypass the outside-tmux check) but both --here and --append given, raises TmuxpException with "mutually exclusive". The full --here-into-real-session smoke test requires running tmuxp from inside an actual nested tmux, deferred to manual verification. ref: notes/plan.md T4 --- tests/cli/test_here.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/cli/test_here.py diff --git a/tests/cli/test_here.py b/tests/cli/test_here.py new file mode 100644 index 0000000000..1335e07949 --- /dev/null +++ b/tests/cli/test_here.py @@ -0,0 +1,42 @@ +"""Tests for ``tmuxp load --here``.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp import exc +from tmuxp.cli.load import load_workspace + + +def test_load_with_here_outside_tmux_errors( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--here outside an existing tmux session raises TmuxpException.""" + workspace = tmp_path / "ws.yaml" + workspace.write_text( + "session_name: t4-outside\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + monkeypatch.delenv("TMUX", raising=False) + with pytest.raises(exc.TmuxpException, match="--here requires being inside"): + load_workspace(workspace, here=True, detached=True) + + +def test_load_with_here_and_append_errors( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--here + --append are mutually exclusive.""" + workspace = tmp_path / "ws.yaml" + workspace.write_text( + "session_name: t4-mutex\n" + "windows:\n- window_name: w1\n panes:\n - shell_command:\n - echo hi\n", + ) + # Set TMUX so we get past the second precondition; the mutex check fires + # first. + monkeypatch.setenv("TMUX", "/tmp/fake-tmux-socket,0,0") + with pytest.raises(exc.TmuxpException, match="mutually exclusive"): + load_workspace(workspace, here=True, append=True, detached=True) From 76037567ec8974f698de81f94f67951f8ff7445e Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 13:13:53 -0500 Subject: [PATCH 88/89] feat(cli[load]): Add --dry-run via isolated tmux socket why: tmuxinator's `debug` outputs the rendered shell script for preview. tmuxp uses libtmux API calls instead of script generation, so the honest-equivalent preview is to run the build against an isolated tmux server (so the user's main tmux is untouched), surface libtmux's existing DEBUG-level `tmux command dispatched` log at INFO, then kill the temp server. what: - New `--dry-run` flag on `tmuxp load`. When set: - Generate a unique socket name (tmuxp-dryrun-<pid>-<rand>) and override args.socket_name so the temp server is isolated. - Force detached=True so we don't attach to a sandbox session. - Bump libtmux + tmuxp loggers to DEBUG so every libtmux call's `tmux command dispatched` record (libtmux common.py:284-289) is visible to the user. - Wrap the load_workspace call in try/finally; on exit, call Server(socket_name=...).kill_server() to clean up. Cleanup is best-effort (suppressed if the build raised early). - args.dry_run flows through CLILoadNamespace. Trade-off vs a true zero-execution dry-run: real tmux processes spawn briefly but the user's main tmux server is never touched. That's the honest-preview compromise documented in notes/plan.md T9. ref: notes/plan.md T9 --- src/tmuxp/cli/load.py | 85 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 66019216ef..6040c8ef27 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -107,6 +107,7 @@ class CLILoadNamespace(argparse.Namespace): append: bool | None no_shell_command_before: bool here: bool + dry_run: bool template_vars: list[str] colors: CLIColorsLiteral | None color: CLIColorModeLiteral @@ -812,6 +813,17 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP "tmux. Mutually exclusive with -a/--append." ), ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + default=False, + help=( + "execute the build on an isolated tmux socket (won't touch your " + "main tmux server) and surface the libtmux command log at INFO. " + "The temp server is killed when done." + ), + ) parser.add_argument( "-D", "--var", @@ -943,6 +955,25 @@ def command_load( original_detached_option = args.detached original_new_session_name = args.new_session_name + # --dry-run isolates the build on a temp socket so the user's main tmux + # server is untouched. Force socket_name to a unique value, force + # detached=True (no attaching to a sandbox session), bump tmuxp's logger + # to DEBUG so libtmux's `tmux command dispatched` messages surface, and + # kill the temp server in finally. + dry_run = getattr(args, "dry_run", False) + dry_run_socket: str | None = None + if dry_run: + import secrets as _secrets + + dry_run_socket = f"tmuxp-dryrun-{os.getpid()}-{_secrets.token_hex(2)}" + args.socket_name = dry_run_socket + original_detached_option = True + logging.getLogger("libtmux").setLevel(logging.DEBUG) + logging.getLogger("tmuxp").setLevel(logging.DEBUG) + tmuxp_echo( + cli_colors.info(f"[dry-run] using isolated socket: {dry_run_socket}"), + ) + for idx, workspace_file in enumerate(args.workspace_files): workspace_file = find_workspace_file( workspace_file, @@ -958,21 +989,39 @@ def command_load( from tmuxp._internal import template as _template - load_workspace( - workspace_file, - socket_name=args.socket_name, - socket_path=args.socket_path, - tmux_config_file=args.tmux_config_file, - new_session_name=new_session_name, - colors=args.colors, - detached=detached, - answer_yes=args.answer_yes or False, - append=args.append or False, - cli_colors=cli_colors, - progress_format=args.progress_format, - panel_lines=args.panel_lines, - no_progress=args.no_progress, - no_shell_command_before=args.no_shell_command_before, - template_vars=_template.parse_cli_vars(args.template_vars or []), - here=getattr(args, "here", False), - ) + try: + load_workspace( + workspace_file, + socket_name=args.socket_name, + socket_path=args.socket_path, + tmux_config_file=args.tmux_config_file, + new_session_name=new_session_name, + colors=args.colors, + detached=dry_run or detached, + answer_yes=args.answer_yes or False, + append=args.append or False, + cli_colors=cli_colors, + progress_format=args.progress_format, + panel_lines=args.panel_lines, + no_progress=args.no_progress, + no_shell_command_before=args.no_shell_command_before, + template_vars=_template.parse_cli_vars(args.template_vars or []), + here=getattr(args, "here", False), + ) + finally: + if dry_run and dry_run_socket: + # Kill the temp tmux server we spun up. Best-effort: if the + # build raised before any session was created, the server + # may not exist yet. + try: + Server(socket_name=dry_run_socket).kill_server() + tmuxp_echo( + cli_colors.muted( + f"[dry-run] cleaned up isolated socket: {dry_run_socket}", + ), + ) + except Exception: + logger.debug( + "dry-run cleanup skipped", + extra={"tmux_session": dry_run_socket}, + ) From 1268774e9372beedde75447c01be8f966322b29a Mon Sep 17 00:00:00 2001 From: Tony Narlock <tony@git-pull.com> Date: Sat, 9 May 2026 16:14:58 -0500 Subject: [PATCH 89/89] docs(CHANGES,notes[plan]): Document parity features and mark limitations resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: This branch closes the remaining feature gaps with tmuxinator and teamocil. Document the new config keys, CLI flags, and CLI commands in user-facing language so the changelog reads like product news, not an internal task list. Stamp the plan's gap section as historical so the next reader knows it's design context, not current backlog. what: - CHANGES: new "New features — tmuxinator/teamocil parity" section above the existing "Behavior changes" entry. Grouped into "New config keys", "New CLI flags on tmuxp load", and "New CLI commands" with one bullet per feature describing what the user can now do. - notes/plan.md: short status callout under "## tmuxp Limitations" pointing readers to CHANGES for current behavior. The gap-by-gap sections below remain intact as design notes for future maintainers. --- CHANGES | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++ notes/plan.md | 5 +++++ 2 files changed, 67 insertions(+) diff --git a/CHANGES b/CHANGES index be25f57124..c805440159 100644 --- a/CHANGES +++ b/CHANGES @@ -39,6 +39,68 @@ $ pipx install \ _Notes on the upcoming release will go here._ <!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE --> +### New features — tmuxinator/teamocil parity (#1014) + +These additions close the remaining feature gaps with tmuxinator and +teamocil, so configs from either tool now have a tmuxp-native equivalent. + +**New config keys** + +- **`synchronize`** (window-level) — sync keystrokes across the panes of a + window. Accepts `before` / `true` (turn `synchronize-panes` on before + pane commands run) or `after` (turn it on once each pane has finished + setup). Mirrors tmuxinator. +- **`enable_pane_titles`**, **`pane_title_position`** (default `top`), + **`pane_title_format`** (default `"#{pane_index}: #{pane_title}"`) + (session-level) plus per-pane **`title`**. Renders pane titles in the + border bar. tmuxinator's named-pane shorthand (`{name: command}`) is + imported as both a title and the pane command. +- **`shell_command_after`** (window-level) — list of commands sent to + every pane after each pane's main `shell_command` has run. Mirrors + teamocil's `filters.after`. +- **`on_project_start`**, **`on_project_first_start`**, + **`on_project_restart`**, **`on_project_exit`**, **`on_project_stop`** + (session-level) — lifecycle hooks. Each accepts a string or list. Hooks + reuse tmuxp's existing script executor: no `shell=True`, so pipes and + redirects need a real script file. `start` fires on every load, + `first_start` only on a brand-new session, `restart` only when + attaching to an existing one, `exit` after attach, and `stop` runs from + `tmuxp stop`. + +**New CLI flags on `tmuxp load`** + +- **`--here`** — load the workspace into the current tmux session + instead of creating a new one. Mutually exclusive with `--append`; + refuses to run outside a tmux session. +- **`--no-shell-command-before`** — skip every `shell_command_before` + (session, window, and pane). Slightly broader than tmuxinator's + `--no-pre-window`, which only suppresses `pre_window`. +- **`--dry-run`** — execute the build on an isolated tmux socket so your + main tmux server is untouched. tmuxp surfaces libtmux's + `tmux command dispatched` log at INFO so you can see the exact tmux + call sequence; the temporary server is killed when the run finishes. +- **`-D KEY=VALUE` / `--var KEY=VALUE`** (repeatable) — substitute + `${var}` and `${var:-default}` placeholders in the workspace YAML + before parsing, matching tmuxinator's ERB-before-YAML order. Bare + `$name` is intentionally not handled here so `loader.expandshell` can + keep handling environment-variable expansion on specific config keys + after parsing. Stdlib-only — no Jinja2 dependency. + +**New CLI commands** + +- **`tmuxp stop <name>`** — fire `on_project_stop` and kill the matching + session. Idempotent: a no-op if the session isn't running. Mirrors + tmuxinator's `stop` command. +- **`tmuxp new <name>`** — create a starter workspace YAML in your + tmuxp config directory and open it in `$EDITOR`. +- **`tmuxp copy <src> <dst>`** — copy an existing workspace and open the + copy in `$EDITOR`. Prompts before overwriting. +- **`tmuxp delete <name>...`** — delete one or more workspaces, with a + per-name confirmation unless `-y` is supplied. +- **`tmuxp implode`** — delete every workspace in every tmuxp config + directory (legacy `~/.tmuxp/` and XDG `~/.config/tmuxp/`). Single + confirmation; `-y` skips it. + ### Behavior changes — tmuxinator/teamocil import (#1014) - **tmuxinator**: `pre` now correctly maps to `before_script` (was: per-pane diff --git a/notes/plan.md b/notes/plan.md index b3b5e4aa83..4434b13931 100644 --- a/notes/plan.md +++ b/notes/plan.md @@ -41,6 +41,11 @@ These libtmux APIs already exist and do NOT need changes: ## tmuxp Limitations +> **Status (2026-05-09):** Every gap below has shipped on the +> `tmuxinator-parity` branch. tmuxp now has feature parity with +> tmuxinator and teamocil. Sections below remain as historical design +> references; see `CHANGES` for the user-visible feature summary. + ### T1. No `synchronize` Config Key - **Blocker**: `WorkspaceBuilder` (`builder.py`) does not check for a `synchronize` key on window configs. Silently ignored if present.